diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" 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/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..2095acad7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev libasound2-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index f8ac8a300..8967f9431 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -2,12 +2,10 @@ # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. name: FreeBSD -# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push -# or dependency change: a drifted dependency still shows up within a day, and always before a -# release ships. PRs that change the FreeBSD packaging itself still run it. +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: - schedule: - - cron: "47 2 * * *" push: branches: [release] pull_request: @@ -15,6 +13,8 @@ on: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "47 2 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 808e1628d..3cfbdc8b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,15 +10,14 @@ on: type: string default: "" -permissions: - contents: write - concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: release: + permissions: + contents: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..8f1aa5ad5 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,24 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + sonarcloud: + # Track shared CI improvements on main. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + with: + timeout-minutes: 120 + fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index aad29c94a..24abee6a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,8 @@ reproduce an image (for example, point lists traced from someone else's icon). See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). + Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index be99cdce1..77a9cc432 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -1,9 +1,28 @@ # Attribution -Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has +Every non-code asset in EffectCraft, with its author, source and licence. Each repository file listed here also has a `.attribution` sidecar with full details. The rules are in [AGENTS.md](AGENTS.md) §1: no Adobe iconography, images or other Adobe assets; open licences only; every asset attributed. `cargo xtask assets` checks that this index and the sidecars cover every asset in the repository. +The complete licence texts and copyright notices for the bundled fonts, including the fonts embedded +by the pinned egui dependency, are reproduced in [`NOTICE`](NOTICE), which every package ships; the +`assets/fonts/OFL-*.txt` files are the upstream copies kept beside the fonts. + +## Bundled dependency fonts + +These fonts are embedded by the pinned egui dependency (epaint_default_fonts 0.37 at +`6d518fc799681571963842f95b55a6b68431b15a`); no font file is added to this repository. +egui-icons is always embedded, `default_fonts` adds Ubuntu Light and Hack, and +`monochrome_emoji_fonts` adds Noto Emoji and emoji-icon-font. The complete copyright notices +and licence texts are in [`NOTICE`](NOTICE). + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) | +| `epaint_default_fonts/fonts/NotoEmoji-Regular.ttf` | Google Inc. (The Noto Project Authors) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/OFL.txt | OFL-1.1 (complete text in `NOTICE`) | +| `epaint_default_fonts/fonts/Ubuntu-Light.ttf` | Canonical Ltd. and Dalton Maag | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/UFL.txt | Ubuntu Font Licence 1.0 (complete text in `NOTICE`) | +| `epaint_default_fonts/fonts/Hack-Regular.ttf` | Source Foundry Authors; Bitstream Inc. | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/Hack-Regular.txt; original https://github.com/source-foundry/Hack | MIT AND Bitstream-Vera (complete texts in `NOTICE`) | ## Asset files @@ -15,6 +34,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) | +| `assets/fonts/NotoSansSymbols2-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/symbols | OFL-1.1 (`assets/fonts/OFL-NotoSansSymbols2.txt`) | | `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | | `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | | `packaging/macos/dmg/background.svg` | @XusBadia | Original work: macOS DMG Finder window background, derived from the EffectCraft app icon (`assets/app-icon/effectcraft.svg`); rendered to a TIFF by `packaging/macos/package.sh` at package time | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | diff --git a/CLAUDE.md b/CLAUDE.md index a7c47fe3f..e52386942 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e - **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`). - **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32. - **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`). - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id. - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde. diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..4a4043140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,66 +4,60 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows 0.62.2", "windows-core 0.62.2", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -214,14 +209,15 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", + "wl-clipboard-rs", "x11rb", ] @@ -436,13 +432,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "bit-set" -version = "0.8.0" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" @@ -454,10 +447,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -465,6 +461,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -483,6 +489,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -498,7 +513,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -567,7 +582,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -607,7 +622,7 @@ dependencies = [ "hashbrown 0.17.1", "indexmap", "once_cell", - "phf 0.14.0", + "phf", "rustc-hash 2.1.3", "static_assertions", ] @@ -658,6 +673,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -799,8 +838,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core 0.10.1", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", ] [[package]] @@ -812,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" @@ -867,6 +924,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -880,7 +947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -893,7 +960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -959,6 +1026,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -971,12 +1047,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-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=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-syntax", "thiserror 2.0.21", @@ -985,10 +1089,47 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", ] [[package]] @@ -1040,6 +1181,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1072,6 +1223,16 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1087,7 +1248,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1131,6 +1292,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dynify" version = "0.1.2" @@ -1153,9 +1320,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1163,9 +1329,10 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.12.0" +version = "0.17.0" dependencies = [ "cpal", + "craft-update", "effectcraft-engine", "effectcraft-host", "effectcraft-ui-egui", @@ -1174,10 +1341,10 @@ dependencies = [ "fmv-macos-events", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", - "pollster 0.4.0", + "pollster 1.0.1", "rfd", "serde", "serde_json", @@ -1188,7 +1355,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1367,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.12.0" +version = "0.17.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.12.0" +version = "0.17.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1390,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.12.0" +version = "0.17.0" dependencies = [ "proptest", "serde", @@ -1231,8 +1398,9 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.12.0" +version = "0.17.0" dependencies = [ + "chrono", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", @@ -1248,8 +1416,9 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.12.0" +version = "0.17.0" dependencies = [ + "chrono", "craft-docpath", "effectcraft-color", "effectcraft-effects", @@ -1282,7 +1451,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1481,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.12.0" +version = "0.17.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1498,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.12.0" +version = "0.17.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1506,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.12.0" +version = "0.17.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1351,7 +1520,7 @@ dependencies = [ "effectcraft-time", "js-sys", "log", - "pollster 0.4.0", + "pollster 1.0.1", "wasm-bindgen", "web-time", "wgpu", @@ -1359,14 +1528,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.12.0" +version = "0.17.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1560,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1579,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1591,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.12.0" +version = "0.17.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1609,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.12.0" +version = "0.17.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,11 +1638,11 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-geom", "image", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "serde", "serde_json", "thiserror 2.0.21", @@ -1481,14 +1650,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.12.0" +version = "0.17.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1669,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.12.0" +version = "0.17.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1508,7 +1677,7 @@ dependencies = [ "effectcraft-text", "image", "kurbo", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "skrifa", "wasm-bindgen-test", "zune-jpeg", @@ -1516,7 +1685,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1697,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1710,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.12.0" +version = "0.17.0" dependencies = [ - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1726,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1748,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.12.0" +version = "0.17.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1591,21 +1760,22 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "serde_json", + "socket2", "web-time", ] [[package]] name = "effectcraft-segment" -version = "0.12.0" +version = "0.17.0" dependencies = [ - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "ndarray", "rayon", ] [[package]] name = "effectcraft-svg" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1787,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1799,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.12.0" +version = "0.17.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1807,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,9 +1819,11 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.12.0" +version = "0.17.0" dependencies = [ + "chrono", "craft-ui", + "craft-update", "effectcraft-color", "effectcraft-engine", "effectcraft-geom", @@ -1670,7 +1842,7 @@ dependencies = [ "image", "kurbo", "log", - "pollster 0.4.0", + "pollster 1.0.1", "rayon", "serde", "serde_json", @@ -1681,11 +1853,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.12.0" +version = "0.17.0" [[package]] name = "effectcraft-web" -version = "0.12.0" +version = "0.17.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1708,9 +1880,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1724,7 +1895,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1732,6 +1903,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1743,9 +1915,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1763,9 +1934,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1783,31 +1953,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1819,9 +1992,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1843,9 +2015,8 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1893,9 +2064,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1911,16 +2081,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -2049,6 +2220,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "filmcraft-aac" version = "0.1.1" @@ -2310,6 +2491,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" @@ -2333,7 +2525,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2469,6 +2661,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2479,6 +2681,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2501,7 +2714,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", + "rand_core", "wasm-bindgen", ] @@ -2528,9 +2741,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -2568,7 +2781,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2713,8 +2926,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-ccitt", ] @@ -2728,7 +2940,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2756,6 +2968,22 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.3.0" @@ -2770,6 +2998,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" @@ -2778,11 +3023,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" @@ -2795,6 +3061,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2805,6 +3072,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" @@ -2834,6 +3127,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2841,6 +3137,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" @@ -3047,12 +3367,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3128,9 +3448,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "linesweeper" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769" +checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" dependencies = [ "arrayvec", "kurbo", @@ -3156,6 +3476,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" @@ -3180,9 +3503,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lz4_flex" -version = "0.11.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" [[package]] name = "mach2" @@ -3269,18 +3592,17 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png", "thiserror 2.0.21", "windows-sys 0.61.2", @@ -3375,12 +3697,33 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3496,9 +3839,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3527,7 +3870,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3541,7 +3884,7 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.13.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -3579,7 +3922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -3591,7 +3934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3614,7 +3957,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3625,7 +3968,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3681,7 +4024,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3692,7 +4035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3728,7 +4071,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3752,7 +4095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3797,7 +4140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3844,6 +4187,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3873,6 +4222,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -3934,14 +4293,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", ] [[package]] @@ -3950,18 +4309,9 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros 0.14.0", - "phf_shared 0.14.0", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", + "serde", ] [[package]] @@ -3971,20 +4321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.14.0", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.119", + "phf_shared", ] [[package]] @@ -3993,22 +4330,13 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator 0.14.0", - "phf_shared 0.14.0", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - [[package]] name = "phf_shared" version = "0.14.0" @@ -4067,6 +4395,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -4130,12 +4471,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", ] @@ -4145,15 +4498,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -4186,16 +4530,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4253,6 +4598,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -4274,16 +4628,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -4292,26 +4636,7 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -4322,11 +4647,11 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -4356,7 +4681,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -4464,7 +4789,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4480,6 +4805,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -4536,6 +4875,80 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4569,6 +4982,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4581,6 +5003,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4592,6 +5037,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -4625,9 +5080,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4656,6 +5111,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -4815,6 +5281,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.9" @@ -4890,6 +5366,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "symphonia" version = "0.5.5" @@ -5087,7 +5569,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]] @@ -5102,6 +5587,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5249,6 +5744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5350,6 +5846,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -5359,6 +5866,18 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5427,6 +5946,41 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5445,6 +5999,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5464,9 +6024,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -5479,9 +6039,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -5489,6 +6049,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5514,6 +6084,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5830,7 +6406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5875,13 +6451,22 @@ dependencies = [ "jni 0.22.4", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6016,7 +6601,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -6205,6 +6790,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" @@ -6259,15 +6855,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6301,30 +6888,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -6346,12 +6916,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6364,12 +6928,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6382,24 +6940,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6412,12 +6958,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6430,12 +6970,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6448,12 +6982,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6466,17 +6994,10 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -6487,7 +7008,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -6533,21 +7054,55 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write16" version = "1.0.0" @@ -6559,6 +7114,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" @@ -6625,9 +7183,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.12.0" +version = "0.17.0" dependencies = [ + "chrono", "serde_json", + "tempfile", ] [[package]] @@ -6800,6 +7360,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" @@ -6807,8 +7373,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6817,6 +7386,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -6833,6 +7403,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6845,6 +7432,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -6909,3 +7508,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 7af58f009..79ec24655 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,15 +4,16 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.12.0" +version = "0.17.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 = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-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" } @@ -52,6 +53,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" # std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there). web-time = "1" @@ -100,14 +103,17 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } bytemuck = "1" -pollster = "0.4" +pollster = "1" # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +# 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 = "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"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" cpal = "0.16" # The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop). @@ -137,9 +143,12 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +# Panics and backtraces keep file and line; full debug info only made the many test binaries large. +debug = "line-tables-only" # Pixel hot loops: keep debug builds interactive. [profile.dev.package.effectcraft-raster] @@ -211,4 +220,20 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" } +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" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 43bd2e2a9..5cf18a553 100644 --- a/NOTICE +++ b/NOTICE @@ -12,11 +12,280 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. -Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. -Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt. -JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. +Bundled fonts + +EffectCraft embeds Inter, Noto Serif, JetBrains Mono and Noto Sans Symbols 2 from +assets/fonts/. The pinned egui dependency (epaint_default_fonts, revision +6d518fc799681571963842f95b55a6b68431b15a) additionally embeds Ubuntu Light, Hack, +Noto Emoji, egui-icons and emoji-icon-font through EffectCraft's enabled `default_fonts` +and `monochrome_emoji_fonts` features; no font file from that dependency is added to +this repository. The complete copyright notices and licence texts required for all of +these fonts follow. The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0. The shared patched syntax source is centralized in storytold/craft-libs, transferred from PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters. + +SIL Open Font License 1.1 + +Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf), +JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf), Noto Sans Symbols 2 +(assets/fonts/NotoSansSymbols2-Regular.ttf) and Noto Emoji (NotoEmoji-Regular.ttf). +Copyright notices: + +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols) +Copyright 2013 Google Inc. All Rights Reserved. + +The fonts in assets/fonts/ also keep their upstream licence files at +assets/fonts/OFL-.txt. The complete licence text follows. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Ubuntu Font Licence 1.0 + +Applies to Ubuntu Light (Ubuntu-Light.ttf). Copyright notice: + +Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0 + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +Hack: MIT License and Bitstream Vera License + +Applies to Hack (Hack-Regular.ttf). Upstream notice and both licence texts: + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + +MIT License (egui-icons and emoji-icon-font) + +Applies to egui-icons.ttf, a five-glyph subset of emoji-icon-font, and the parent +emoji-icon-font.ttf, both by John Slegers, https://github.com/jslegers/emoji-icon-font. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 914a40907..5fa28f0bb 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,18 @@

+

+ EffectCraft icon: an engraved unicorn on a pink tile +

+

EffectCraft

- Motion graphics and visual effects; an open-source, clean-room reimplementation of Adobe After Effects, rebuilt in pure Rust. + Motion graphics and visual effects; an open-source, professional motion graphics and visual effects studio, built in pure Rust.

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

@@ -24,6 +28,9 @@ Status: young and moving fast Written in Rust License: MIT OR Apache-2.0 +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -64,13 +71,14 @@ ## What EffectCraft is EffectCraft is for animated titles, motion graphics and compositing work: the kind of thing -people reach for After Effects to do. You build a composition out of layers (solids, shapes, +motion designers and VFX artists need. You build a composition out of layers (solids, shapes, text, footage, other compositions), animate their properties with keyframes, stack effects on them and render the result. -The aim is to feel familiar to anyone who has used After Effects, with the same panels (Project, -Composition, Timeline, Effect Controls, Effects & Presets) and the same keyframe behaviour, and -then to go further in a few places where it matters to us: +The interface feels natural and responsive, with dedicated panels (Project, Composition, Timeline, +Layer, Footage, Effect Controls, Tools, Preview, Effects & Presets, Audio, Info, Paragraph, +Character, Align, Progress, Render Queue) and standard motion graphics shortcuts, going further in +a few places where it matters to us: - **Lottie import and export built in**, so animations can go straight to the web and apps without a plugin. @@ -84,7 +92,7 @@ then to go further in a few places where it matters to us: ## Animate -The panels, menus and shortcuts follow After Effects, so your muscle memory carries over: +The panels, menus and shortcuts follow standard motion design conventions: Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character, Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag footage, comps and effects between them: onto the comp viewer, where they land under the pointer, @@ -102,7 +110,7 @@ or into the Timeline, between layers and at the time you point to. feather, expansion and vertex editing in the viewer. - **Text:** point and paragraph text with real shaping in any font installed on your machine, the Character and Paragraph panels, and text animators with range selectors. -- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`, +- **Expressions:** JavaScript with the standard motion expressions model (`wiggle`, `loopOut`, `thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip. - **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S, @@ -118,9 +126,9 @@ or into the Timeline, between layers and at the time you point to. ## Effects -306 effects (every one of After Effects' 298, and more) across its categories, each with its parameter names, order and -defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri -Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, +306 built-in effects across its categories, each with its parameter names, order and +defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full +color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective, **simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…), stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…), @@ -134,7 +142,7 @@ overlays, Stroke) with Global Light. Preview plays audio in sync, with meters an ## 3D -Classic 3D the way After Effects does it: 3D layers with orientation and material options, +Classic 3D compositing: 3D layers with orientation and material options, one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient) with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera tools, and Front, Top, Left and Custom views. @@ -145,7 +153,7 @@ tools, and Front, Top, Left and Custom views. ## Export -A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and +A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and **ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1** MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and @@ -219,25 +227,26 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING ## Where it stands -EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature -exists, including all of its effects, but existing is not the same as behaving exactly like After -Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us -what breaks. It isn't yet a replacement for After Effects on client work. In particular: +EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set +exists, including 306 effects, but breadth is not the same as full maturity across every edge +case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended +for production client work. In particular: -- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned - JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run. -- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with - After Effects automatically, so please report differences. +- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary + project formats and After Effects SDK plug-ins are not supported. +- **Fidelity is still being refined.** Automated rendering tests currently focus on small + compositions, so please report visual discrepancies or unexpected behaviour. - **macOS is the most tested platform.** Linux and Windows users have hit basic interaction bugs, which we're fixing first. - **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional - 3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet. + 3.8 MB download in Settings ▸ Face Tracking). Both are actively undergoing evaluation and tuning. -EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is -about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md) -has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and -[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. +EffectCraft is in **alpha**: about 91% of the features tracked in +[docs/target-app-parity.md](docs/target-app-parity.md) exist, and we estimate it is about 45% of the +way to being ready for full professional production work. The [ROADMAP](ROADMAP.md) has the plan, +[docs/target-app-parity.md](docs/target-app-parity.md) the measurement and [docs/gaps.md](docs/gaps.md) +every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. [Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can send us right now. @@ -245,8 +254,8 @@ send us right now. - **Engine first.** A stack of small crates with strict layering. The interface (egui) sits on top and nothing underneath depends on it. -- **Clean room.** We work from After Effects' public documentation and how it behaves, never - from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules +- **Clean room.** We work from public documentation and observable behavior, never + from proprietary files, icons, presets or code. No code is copied from GPL projects. The full rules are in [AGENTS.md](AGENTS.md). - **Private by default.** No telemetry, and no network access unless you ask for it. - **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and @@ -368,8 +377,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index 1887d7b82..f4c99d61f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had +Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had native-speaker review. ## Upcoming @@ -135,6 +135,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. +- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index c7a012c76..6e5b8d6cc 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -6,6 +6,7 @@ //! effectcraft-cli exec [--params JSON] run one command //! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order +//! effectcraft-cli run --args-file F the same, one argument per line of F //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property //! effectcraft-cli set [--time S] [--expression E] @@ -96,6 +97,7 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order + run --args-file F the same, one argument per line of F (no command-line length limit) props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property set [--time S] [--expression E] @@ -148,6 +150,7 @@ const VALUED: &[&str] = &[ "--expression", "--depth", "--eval", + "--args-file", // render "--template", "--values", @@ -515,10 +518,15 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { emit(&with_saved(r, saved), json_out); } "run" => { - if args.pos.is_empty() { + let mut pairs = args.pos.clone(); + // Windows caps a command line at 32 767 characters: long batches come from a file. + if let Some(f) = args.opt("--args-file") { + let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?; + pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from)); + } + if pairs.is_empty() { return usage_err("run needs [] pairs"); } - let pairs = args.pos.clone(); let mut b = backend(args, true)?; let mut results = vec![]; let mut i = 0; diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index 844c978b2..8cf5c124f 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -150,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map, import } let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?; let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?; - // Keep a partial final frame: layer.addItem snaps its default end to the frame grid. + // Keep a partial final frame: layer.addItem snaps its default end to the frame grid. Give the + // wrapper the source's exact name, not Duplicate's numbered one, for `[compName]` outputs. s.edit("Template Duration", None, |project, _| { + let name = project.item(ItemId(source)).ok_or_else(|| EngineError::Other("template composition is missing".into()))?.name.clone(); + project.item_mut(ItemId(parent)).ok_or_else(|| EngineError::Other("template render composition is missing".into()))?.name = name; let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?; let duration = comp.duration; let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?; diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs similarity index 96% rename from apps/effectcraft-cli/tests/cli.rs rename to apps/effectcraft-cli/tests/it/cli.rs index 1111db403..c7a905e58 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -7,7 +7,7 @@ use std::process::{Command, Stdio}; use serde_json::{Value, json}; fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + Command::new(crate::binary()) } /// Run with `--json`; returns (exit code, parsed stdout). @@ -110,6 +110,29 @@ fn exec_set_get_with_saved_project() { assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); } +/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer +/// than a Windows command line (32 767 characters, os error 206) still runs. +#[test] +fn run_reads_arguments_from_a_file() { + let proj = tmp("args-file.ecproj"); + let p = proj.to_str().unwrap(); + let mut lines = vec!["comp.new".to_string(), json!({"name": "A", "width": 64, "height": 64}).to_string()]; + for i in 0..400 { + lines.push("layer.newSolid".into()); + lines.push(json!({"width": 8, "height": 8, "name": format!("Solid with a long name to pass the limit {i:04}")}).to_string()); + lines.push(String::new()); + } + let text = lines.join("\n"); + assert!(text.len() > 32_767); + let file = tmp("args-file.txt"); + std::fs::write(&file, text).unwrap(); + let v = ok_json(&["run", "--args-file", file.to_str().unwrap(), "--empty", "--save-as", p]); + assert_eq!(v["result"].as_array().map(Vec::len), Some(401), "{v}"); + let out = bin().args(["run", "--args-file", "no/such/file.txt", "--empty"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt")); +} + #[test] fn render_frame_writes_png() { let out = tmp("f.png"); diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..bca36ec78 --- /dev/null +++ b/apps/effectcraft-cli/tests/it/main.rs @@ -0,0 +1,9 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod cli; +mod qa_template; + +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_effectcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft-cli").into()) +} diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs similarity index 93% rename from apps/effectcraft-cli/tests/qa_template.rs rename to apps/effectcraft-cli/tests/it/qa_template.rs index 8b877755b..8cb533fda 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/it/qa_template.rs @@ -9,7 +9,7 @@ use std::process::Command; use serde_json::Value; fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) { - let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap(); + let out = Command::new(crate::binary()).current_dir(dir).args(args).arg("--json").output().unwrap(); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr))); (out.status.code().unwrap_or(-1), v) @@ -191,7 +191,7 @@ fn essential_graphics_template_via_script_and_commands() { vec!["render", "--template", "lt.ectemplate", "--queue"], vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"], ] { - let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap(); + let output = Command::new(crate::binary()).current_dir(&dir).args(&args).output().unwrap(); assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr)); } @@ -251,7 +251,7 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value { let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); let values_before = std::fs::read(dir.join("values.json")).unwrap(); // Read the inputs before interpreting the subprocess status or error text. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + let result = Command::new(crate::binary()) .current_dir(dir) .args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"]) .output() @@ -527,7 +527,7 @@ fn template_unknown_control_does_not_replace_extracted_media() { let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap(); // Capture every file before interpreting status/JSON, so a failed command cannot hide a write. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + let result = Command::new(crate::binary()) .current_dir(&dir) .args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"]) .output() @@ -623,11 +623,8 @@ fn template_missing_output_does_not_replace_extracted_media() { assert_ne!(sentinel_before, source_before); // Observe writes before interpreting the actual CLI usage exit. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) - .current_dir(&dir) - .args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]) - .output() - .unwrap(); + let result = + Command::new(crate::binary()).current_dir(&dir).args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]).output().unwrap(); let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); let values_after = std::fs::read(dir.join("values.json")).unwrap(); let source_after = std::fs::read(dir.join("source.png")).unwrap(); @@ -767,3 +764,45 @@ fn template_media_control_reports_a_path_that_cannot_be_imported() { assert!(!dir.join("missing-out.png").exists(), "{reply}"); let _ = std::fs::remove_dir_all(&dir); } + +/// Renders with `--template` name their outputs after the template's composition (#1937), not +/// the numbered name a duplicate would get. The name ends in a number and differs from the +/// archive's file name; an expression that looks the composition up by that name still finds it. +#[test] +fn template_render_outputs_keep_the_template_composition_name() { + let dir = std::env::temp_dir().join(format!("ec-qa-template-comp-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let build = BUILD.replace(r#"addComp("Lower Third""#, r#"addComp("Title Card 2""#).replace( + "var fill =", + "bar.property(\"ADBE Transform Group\").property(\"ADBE Position\").expression = '[comp(\"Title Card 2\").numLayers * 80, 65]';\nvar fill =", + ); + std::fs::write(dir.join("build.jsx"), build).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Title Card 2","path":"card.ectemplate"}"#, "t.ecproj"]); + let template_before = std::fs::read(dir.join("card.ectemplate")).unwrap(); + std::fs::write(dir.join("values.json"), r#"{"Title":"John Smith"}"#).unwrap(); + + for (case, values) in [("defaults", None), ("values", Some("values.json"))] { + let out = dir.join(case).join("[compName]_[#####].png"); + let mut args = + vec!["render", "--template", "card.ectemplate", "--out", out.to_str().unwrap(), "--format", "png", "--start", "1", "--end", "1.0833333333333333"]; + if let Some(values) = values { + args.extend(["--values", values]); + } + let rendered = ok(&dir, &args); + let rendered = rendered["rendered"].as_array().unwrap(); + assert_eq!(rendered.len(), 1, "{case}: {rendered:?}"); + assert_eq!(rendered[0]["comp"], "Title Card 2", "{case}: {rendered:?}"); + let frames: Vec<_> = std::fs::read_dir(dir.join(case)).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(frames.len(), 1, "{case}: {frames:?}"); + assert!(frames[0].starts_with("Title Card 2_") && frames[0].ends_with(".png"), "{case}: {frames:?}"); + // The template's own two layers place the bar; the one-layer wrapper would move it left. + let frame = image::open(dir.join(case).join(&frames[0])).unwrap().to_rgba8(); + let bar = frame.get_pixel(300, 75).0; + assert!(bar[0] > 240 && bar[1] < 15, "{case}: comp(\"Title Card 2\") is the template's composition: {bar:?}"); + } + assert_eq!(std::fs::read(dir.join("card.ectemplate")).unwrap(), template_before); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 6c30f9a56..28ea2f369 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [ "HtmlElement", "HtmlInputElement", "Location", + "Navigator", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 5c438550e..9346acc29 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -19,7 +19,7 @@ const enc = (k) => encodeURIComponent(k); const dec = (n) => decodeURIComponent(n); async function opfs() { - if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS"); + if (!navigator.storage?.getDirectory) throw new Error("no OPFS"); const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle(DIR, { create: true }); // Probe a write: some browsers expose OPFS but only write it from workers. @@ -116,7 +116,7 @@ export async function storeOpen(prefer) { try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); } } // Ask the browser not to evict the store under storage pressure (granted for installed apps). - try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {} + try { if (navigator.storage?.persist) await navigator.storage.persist(); } catch {} return backend.name; } @@ -128,7 +128,7 @@ export async function storeDelete(key) { return backend.del(key); } /// Ask the browser to keep the origin's storage under pressure; resolves with whether it is. export async function storePersist() { try { - if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist(); + if (navigator.storage?.persist) return await navigator.storage.persist(); } catch {} return false; } @@ -227,7 +227,7 @@ export async function audioStart(workletUrl) { } if (gen !== generation) return; node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] }); - node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; }; + node.port.onmessage = (e) => { if (e.data?.underrun) underruns += e.data.underrun; }; kind = "worklet"; } catch (e) { console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e); @@ -318,11 +318,11 @@ function spawn(base) { const w = { worker, files: new Map(), ready: null, gpu: null }; w.ready = new Promise((res, rej) => { worker.onmessage = (e) => { - if (e.data && e.data.type === "gpu") { + if (e.data?.type === "gpu") { w.gpu = e.data.gpu ? { adapter: e.data.gpu } : { cpu: e.data.error || "no GPU" }; jobGpu = w.gpu; } - if (e.data && e.data.type === "ready") res(); + if (e.data?.type === "ready") res(); }; worker.onerror = (e) => rej(new Error(e.message || "worker failed to start")); }); @@ -341,7 +341,7 @@ export function workerRun(id, json, files, base, onMessage) { } catch (e) { // `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job // fails like one whose worker didn't start. - setTimeout(() => onMessage("error", String((e && e.message) || e), null, null)); + setTimeout(() => onMessage("error", String(e?.message || e), null, null)); return; } running.set(id, w); @@ -416,12 +416,12 @@ export function frameWorkerStart(base, onMessage, gpu) { // slot stays, and the failure is reported like a worker that died, after the caller has // recorded the index: frames then render on the page's thread (as with `?noworkers`). frameWorkers.push(null); - setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed"))); + setTimeout(() => onMessage(index, undefined, undefined, String(e?.message || e || "frame worker failed"))); return index; } worker.onmessage = (e) => { const m = e.data; - if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined); + if (m?.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined); }; worker.onerror = (e) => { onMessage(index, undefined, undefined, String(e.message || "frame worker failed")); @@ -447,7 +447,7 @@ const cacheDirHandles = {}; async function cacheDir(kind = "frames") { if (cacheDirHandles[kind]) return cacheDirHandles[kind]; - if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System"); + if (!navigator.storage?.getDirectory) throw new Error("no Origin Private File System"); let d = await navigator.storage.getDirectory(); for (const n of [CACHE_ROOT, "v1", kind]) d = await d.getDirectoryHandle(n, { create: true }); cacheDirHandles[kind] = d; @@ -494,8 +494,12 @@ export async function cacheRead(name) { /// resolves with its size. export async function cacheWrite(fullName, bytes) { const [dir, name] = await entryDir(fullName); - const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype; - const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name; + const canMove = + typeof FileSystemHandle !== "undefined" && + "move" in FileSystemHandle.prototype && + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function"; + const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name; const fh = await dir.getFileHandle(tmp, { create: true }); if (fh.createSyncAccessHandle) { const h = await fh.createSyncAccessHandle(); @@ -540,7 +544,7 @@ export async function cacheClear() { /// Whether this thread has WebGPU (`navigator.gpu`). export function hasWebGpu() { - return !!(self.navigator && self.navigator.gpu); + return !!self.navigator?.gpu; } /// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`. @@ -648,7 +652,7 @@ export async function browseForget(name) { export async function browseRead(path) { const rel = path.replace(/^\/Folders\//, ""); const folder = folders.get(rel.split("/")[0]); - const h = folder && folder.files.get(rel); + const h = folder?.files.get(rel); if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`); return new Uint8Array(await (await h.getFile()).arrayBuffer()); } @@ -702,7 +706,7 @@ export function showDeviceLost(message, kept) { self.effectcraft .execute("file.save", {}) .catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" })) - .catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`)); + .catch((e) => (text.textContent = `Saving failed: ${e?.message || e}`)); const reload = document.createElement("button"); reload.textContent = "Reload"; reload.onclick = async () => { @@ -710,7 +714,7 @@ export function showDeviceLost(message, kept) { try { await self.effectcraft.flush(); } catch (e) { - text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`; + text.textContent = `Writing to browser storage failed (${e?.message || e}). Save the project, or reload anyway and lose the changes.`; reload.textContent = "Reload Anyway"; reload.disabled = false; reload.onclick = () => location.reload(); diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..169529e5d 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -86,27 +86,39 @@ pub struct WebApp { } impl eframe::App for WebApp { + /// Take dropped files out of `raw_input` before eframe can replay them on a hidden tab, read them asynchronously + /// through `open_or_import`, then run the shared app's input hook. fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // Files dropped on the page are input: read them here, where the browser's drop event is + // still the current pass's, not from a later (possibly logic-only) tick. + // + // The list is *taken* out of the raw input: a hidden tab runs `logic` and eframe appends + // the same prepared raw input back for the next pass, so leaving the files there would + // import them again on every hidden logic tick. + let dropped_files = std::mem::take(&mut raw_input.dropped_files); + let dropped: Vec = dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect(); + if !dropped.is_empty() { + files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths))); + } self.app.raw_input_hook(ctx, raw_input); } + /// The browser host's logic tick: run queued UI actions, tick the shared app, poll API replies, flush Render + /// Queue downloads, persist the session snapshot when enabled, publish GPU/frame-worker/disk-cache info and close + /// the IME pass. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); for f in actions { f(&mut self.app); } - // Files dropped on the page: read them, then open/import. - let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect()); - if !dropped.is_empty() { - files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths))); - } - self.app.logic(ctx, frame); + self.app.tick(ctx, frame); api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); // What a reload comes back to. if self.persist { - persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0); + // The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick. + persist::snapshot(&self.app.session, self.app.now(), 1.0); } api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()})); api::set_info("frameWorkers", frames::stats()); @@ -114,6 +126,7 @@ impl eframe::App for WebApp { let d = &self.app.session.prefs.disk; diskcache::configure(d.disk_cache_enabled, d.disk_cache_max_gb, storage::quota()); api::set_info("diskCache", diskcache::stats()); + self.app.finish_ime_logic(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -184,13 +197,20 @@ fn install_hooks(app: &mut EffectcraftApp) { #[wasm_bindgen] pub async fn start(canvas_id: String) -> Result<(), JsValue> { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + // Match System follows `?lang=` in the address, then the browser's languages; a language + // chosen in Settings still wins. + let navigator = web_sys::window().map(|w| w.navigator()); + let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::>()); + effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect()); let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; let empty = query_flag("empty"); let home = query_flag("home"); // Browser storage: settings, recent projects, imported media, saved projects, auto-saves and // the last session come back. - let t0 = js_sys::Date::now(); + // Monotonic (performance.now) rather than the wall clock: a system-time jump must not turn + // the storage-load report negative or huge. + let t0 = web_time::Instant::now(); let stored = match persist::load(&query_value("storage").unwrap_or_default()).await { Ok(n) => n, Err(e) => { @@ -198,7 +218,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { 0 } }; - api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0})); + api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); browse::restore(); diskcache::init(); @@ -209,6 +229,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { canvas, eframe::WebOptions::default(), Box::new(move |cc| { + EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx); CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone())); // The page owns this shared presentation device; worker devices retain their // separate owned-device handlers. No browser error-scope future is blocked on. @@ -257,6 +278,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { if n > 0 { app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers"))))); } + app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path))); let (tx, rx) = std::sync::mpsc::channel(); api::set_sender(tx); app = app.with_control(rx); diff --git a/apps/effectcraft-web/src/browse.rs b/apps/effectcraft-web/src/browse.rs index ad7500fea..2fd680dcb 100644 --- a/apps/effectcraft-web/src/browse.rs +++ b/apps/effectcraft-web/src/browse.rs @@ -52,7 +52,7 @@ struct State { static STATE: LazyLock> = LazyLock::new(Mutex::default); fn state() -> std::sync::MutexGuard<'static, State> { - STATE.lock().unwrap_or_else(|e| e.into_inner()) + STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } /// The web app's Media Browser. diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index 3f3f3a99f..206314e4b 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -48,14 +48,14 @@ pub fn list() -> Vec<(String, usize)> { /// The next write of `path` goes to browser storage only (no download). pub fn save_quietly(path: &str) { - QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string()); + QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string()); } /// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and /// downloaded at the next [`flush_downloads`]. pub fn add_output(path: &str, data: Arc<[u8]>) { STORE.put_file(path, data, false); - OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string()); + OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string()); } /// The table path for a picked/dropped file name. @@ -120,7 +120,7 @@ impl Services for WebServices { fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { put(path, data.into()); let quiet = { - let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner()); + let mut q = QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let n = q.len(); q.retain(|p| p != path); q.len() != n @@ -184,7 +184,7 @@ pub fn export_sink() -> Arc { /// Download the render outputs written since the last call. pub fn flush_downloads() { - let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner())); + let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect(); out.sort_by(|a, b| a.0.cmp(&b.0)); out.dedup_by(|a, b| a.0 == b.0); diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index 523fd03c8..c7f350526 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -416,6 +416,7 @@ fn post_reply(r: &FrameReply, px: Option<&[u8]>) { fn new_server() -> FrameServer { let mut s = FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions))); s.set_layer_store(Some(Arc::new(effectcraft_engine::render::PrefetchStore::default()))); + s.set_virtual_id(Some(Arc::new(|path| crate::files::STORE.file_identity(path)))); s } diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 0d59906f2..97d2e887f 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -6,17 +6,17 @@ //! //! | Desktop | Web | //! |---|---| -//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) | +//! | file system (`FsServices`, media reads, export writes) | a virtual file table (`files`) persisted to the Origin Private File System / IndexedDB (`persist`, [`store`]) | //! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) | //! | rfd file dialogs | `` pickers; drop files on the page | -//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) | +//! | Media Browser on the file system | browser storage and folders opened with the File System Access API (`browse`) | //! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) | -//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers | -//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) | -//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) | -//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) | -//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) | -//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) | +//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device (`frames`); `Frames::pump` on the UI thread without workers | +//! | disk cache folder | frames in the Origin Private File System, written by the frame workers (`diskcache`) | +//! | (none) | the storage manager: usage, quota, persistence, clearing (`storage`) | +//! | Render Queue / analysis threads | Web Workers running their own engine instance (`worker`) | +//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | +//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! //! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index b3a858def..8aafdac1c 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_engine::config::{ConfigStore, FileOps}; @@ -45,6 +46,8 @@ pub struct Entry { /// Which write this is: new with every write of the key, so a file replaced by another of /// the same size still reads as changed (workers are sent it again, #218). pub version: u64, + /// FNV-1a of `data`, filled the first time a disk-cache key asks for it. + hash: Option, } /// A queued write for the browser storage: `data` = `None` deletes the key. @@ -69,13 +72,13 @@ impl Mirror { /// An entry read from the browser's storage at startup (nothing to flush). pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) { let version = self.next_version(); - self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version }); + self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version, hash: None }); } pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) { let was_persisted = self.entries.get(key).is_some_and(|e| e.persist); let version = self.next_version(); - self.entries.insert(key.to_string(), Entry { data, modified, persist, version }); + self.entries.insert(key.to_string(), Entry { data, modified, persist, version, hash: None }); if persist || was_persisted { self.touch(key); } @@ -149,19 +152,36 @@ pub fn key_path(key: &str) -> Option<&str> { key.strip_prefix(FILES).filter(|p| p.starts_with('/')) } +/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write. +fn content_hash(data: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in data { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + /// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes. -#[derive(Default)] pub struct Store { pub mirror: Mutex, /// Called after every change that needs flushing (the web host schedules a flush). pub on_change: OnceLock, /// Clock in milliseconds since the Unix epoch (tests pin it). pub clock: OnceLock f64>, + /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash. + media_epoch: Arc, +} + +impl Default for Store { + fn default() -> Self { + Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) } + } } impl Store { pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> { - self.mirror.lock().unwrap_or_else(|e| e.into_inner()) + self.mirror.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } fn now(&self) -> f64 { @@ -179,12 +199,40 @@ impl Store { /// Store a file under `path` (persisted unless `persist` is false: render outputs). pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) { let now = self.now(); - self.lock().put(&file_key(path), data, now, persist); + let key = file_key(path); + let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data)); + self.lock().put(&key, data, now, persist); + if replaced { + self.media_epoch.fetch_add(1, Ordering::Relaxed); + } if persist { self.changed(); } } + /// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes + /// a different blob, and it is not a content hash. + pub fn media_epoch(&self) -> Arc { + self.media_epoch.clone() + } + + /// Size and content hash of the file under `path`. The hash is stored on the entry so a + /// later key does not walk the bytes again. + pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> { + let key = file_key(path); + let mut mirror = self.lock(); + let entry = mirror.entries.get(&key)?; + if let Some(hash) = entry.hash { + return Some((entry.data.len() as u64, hash)); + } + let hash = content_hash(&entry.data); + let len = entry.data.len() as u64; + if let Some(entry) = mirror.entries.get_mut(&key) { + entry.hash = Some(hash); + } + Some((len, hash)) + } + pub fn file(&self, path: &str) -> Option> { self.lock().get(&file_key(path)).map(|e| e.data.clone()) } @@ -446,6 +494,11 @@ mod tests { assert_ne!(a, b); assert_eq!(&data[..], b"blue"); assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it"); + let epoch = s.media_epoch().load(Ordering::Relaxed); + let id = s.file_identity("/collision.png").expect("identity"); + assert_eq!(id, (4, content_hash(b"blue"))); + assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry"); + assert!(epoch >= 2, "each new blob bumps the generation the preview keys on"); } /// A write that failed is flushed again with the key's latest state (#215). diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index 3a6c1337c..83091a72a 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -162,7 +162,7 @@ try { const info = await js("effectcraft.info()"); report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu }; if (info.webgpu && info.backend === "BrowserWebGpu") { - check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU"); + check(info.gpu?.compositor, "GPU compositor on WebGPU"); check(info.gpu.viewerOnGpu, "viewer frames on the GPU"); } @@ -250,7 +250,7 @@ try { const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`); report.steps.storage = st; check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`); - check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`); + check(st.diskCache?.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`); await js(`effectcraft.execute("prefs.open", {page: "disk"})`); await sleep(800); report.steps.storage.screenshot = await shot("01c-storage-manager"); @@ -378,14 +378,14 @@ try { const jobs = gj.workers.jobs || []; const rj = jobs.filter((j) => j.kind === "Render").pop(); const wj = jobs.filter((j) => j.kind === "Warp").pop(); - check(gj.workers.gpu && gj.workers.gpu.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`); + check(gj.workers.gpu?.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`); check(gj.status.every((st) => st === "Done"), `GPU job render: ${JSON.stringify(gj.status)}`); - check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`); + check(rj?.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`); check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`); - check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`); + check(wj?.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`); const fj = jobs.filter((j) => j.kind === "ContentFill").pop(); check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`); - check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`); + check(fj?.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`); check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`); check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`); } else { @@ -418,7 +418,7 @@ try { check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`); check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…"); const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`); - check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`); + check(!mbImport.pending && mbImport.items?.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`); await js(`effectcraft.request("ui.playback", {action: "stop"})`); await js(`effectcraft.execute("time.set", {time: 0})`); await js(`effectcraft.request("ui.playback", {action: "play"})`); @@ -579,7 +579,7 @@ try { report.steps.final2 = { screenshot: await shot("03-offline") }; } catch (e) { failed = true; - report.error = String(e && e.stack || e); + report.error = String(e?.stack || e); try { report.errorShot = await shot("error"); } catch {} } report.console = logs.slice(-40); diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 8c7efc2e5..4acbeeeeb 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -32,13 +32,15 @@ self.addEventListener("fetch", (event) => { if (req.method !== "GET" || url.origin !== self.location.origin) return; event.respondWith( (async () => { - const cache = await caches.open(CACHE); - // The page itself regardless of its query (?empty, ?demo…). - const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req; - const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }); - if (hit) return hit; + const cache = await caches.open(CACHE).catch(() => null); + if (cache) { + // The page itself regardless of its query (?empty, ?demo…). + const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req; + const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }).catch(() => null); + if (hit) return hit; + } const res = await fetch(req); - if (res.ok && res.type === "basic") cache.put(req, res.clone()); + if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {}); return res; })(), ); diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5ad6c80d1..e09e7d8b2 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -15,7 +15,7 @@ effectcraft-host = { workspace = true } effectcraft-ui-egui = { workspace = true } egui = { workspace = true } # `links`: links (Help menu, Discord, Settings) open in the system browser. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] } serde = { workspace = true } serde_json = { workspace = true } # The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app @@ -48,3 +48,6 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", # PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index a1fb6e4a5..6df015290 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -50,7 +50,7 @@ fn app_icon() -> egui::IconData { fn is_project_file(path: &str) -> bool { std::path::Path::new(path) .extension() - .and_then(|ext| ext.to_str()) + .and_then(std::ffi::OsStr::to_str) .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx")) } @@ -128,17 +128,36 @@ fn control_port(name: &str, value: Option<&str>) -> Result { /// answer without pumping events, so the window stops answering the compositor and the desktop /// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps /// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends. -fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) { +/// The dialogs asked for in a frame show at its end ([`sheet_dialogs::show_asked`]), where the +/// main window's handle is at hand. +fn install_file_dialogs(app: &mut EffectcraftApp) -> Option> { #[cfg(any(target_os = "macos", target_os = "linux"))] { - let ctx = ctx.clone(); - app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone()))); + let (tx, rx) = std::sync::mpsc::channel::(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| { + if tx.send((dialog, done)).is_err() { + log::error!("file dialog: the window is gone"); + } + })); + Some(rx) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] - let _ = (app, ctx); + { + let _ = app; + None + } } +/// Run a normal desktop launch: install logging and the panic hook, parse arguments (`--version` exits before the +/// session log is attached), scan fonts, honor the launch guard's backend choice and open the eframe window. +/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then +/// reports startup failure through `startup_failed`. Returns the event loop's result. fn main() -> eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded. Records go to stderr // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings // feed the compatibility report. @@ -203,6 +222,7 @@ fn main() -> eframe::Result { "EffectCraft", options, Box::new(move |cc| { + EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx); match &cc.wgpu_render_state { Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()), None => log::info!("graphics: no wgpu device"), @@ -263,6 +283,7 @@ fn main() -> eframe::Result { // reported in a toast and forgotten). let _ = session.execute("window.restoreScriptPanels", json!({})); let mut app = EffectcraftApp::new(session); + app.set_startup_update_check(true); app.set_gpu_failure_bridge(gpu_failures); app.ui.start_screen = show_home; if let Some(n) = notice { @@ -319,7 +340,7 @@ fn main() -> eframe::Result { })); // The commands' file dialogs don't block the frame where the platform's backend // would (#511, #1275); see [`install_file_dialogs`]. - install_file_dialogs(&mut app, &cc.egui_ctx); + let file_dialogs = install_file_dialogs(&mut app); #[cfg(target_os = "macos")] { app.hooks.app_action = Some(Box::new(native_menu::app_action)); @@ -337,6 +358,7 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] apple_events: apple_events.connect(&cc.egui_ctx), app, + file_dialogs, window: window::Tracker::new(opened, saved_window, control_port.is_none()), launch, drawn: false, @@ -371,15 +393,22 @@ fn wgpu_options(backends: Option) -> eframe::WgpuConfigu // Iris Pro: the device is lost and the window never opens. // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on. setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); - setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor { - label: Some("EffectCraft device"), - required_limits: device_limits(adapter.limits()), - ..Default::default() - }); + setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits())); } config } +/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`, +/// so memory the compositor frees on the shared device goes back to the driver. +fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> { + eframe::wgpu::DeviceDescriptor { + label: Some("EffectCraft device"), + required_limits: device_limits(adapter), + memory_hints: eframe::wgpu::MemoryHints::MemoryUsage, + ..Default::default() + } +} + /// The limits to request from an adapter that offers `adapter`: all of them, with textures /// capped at 16384 px like the headless renderer's device. fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits { @@ -411,6 +440,8 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) { /// The desktop app: the egui app plus, on macOS, the system menu bar. struct Desktop { app: EffectcraftApp, + /// The commands' file dialogs, shown at the end of the frame that asked for them. + file_dialogs: Option>, #[cfg(target_os = "macos")] menu: native_menu::NativeBar, /// Documents opened from Finder, the Dock or `open` (macOS). @@ -424,14 +455,22 @@ 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. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] self.menu.update(&mut self.app, ctx); - self.app.logic(ctx, frame); + self.app.tick(ctx, frame); + self.app.finish_ime_logic(ctx); + if let Some(asked) = &self.file_dialogs { + sheet_dialogs::show_asked(asked, frame, ctx); + } } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.app.ui(ui, frame); + if let Some(asked) = &self.file_dialogs { + sheet_dialogs::show_asked(asked, frame, ui.ctx()); + } self.window.frame(ui.ctx(), self.app.session.config.as_deref()); if !self.drawn { self.drawn = true; @@ -539,6 +578,17 @@ mod tests { assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384); } + /// The window device asks for `MemoryUsage`: the adapter's limits, unchanged. + #[test] + fn the_window_device_favours_memory_usage() { + use eframe::wgpu::Limits; + let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() }; + let d = super::device_descriptor(big); + assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage)); + assert_eq!(d.required_limits.max_texture_dimension_2d, 16384); + assert_eq!(d.label, Some("EffectCraft device")); + } + /// A positional .ecprojx is a project file, not an unsupported media import (#528). #[test] fn launch_classifies_xml_and_json_projects_as_projects() { @@ -620,10 +670,10 @@ mod tests { #[test] fn commands_file_dialogs_do_not_block_the_frame() { let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default()); - super::install_file_dialogs(&mut app, &egui::Context::default()); + let asked = super::install_file_dialogs(&mut app); #[cfg(any(target_os = "macos", target_os = "linux"))] - assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame"); + assert!(app.hooks.file_dialog_async.is_some() && asked.is_some(), "the commands' file dialogs must not block the frame"); #[cfg(not(any(target_os = "macos", target_os = "linux")))] - assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here"); + assert!(app.hooks.file_dialog_async.is_none() && asked.is_none(), "the blocking dialog pumps its own messages here"); } } diff --git a/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs index 9db2ffe76..c3b142c5f 100644 --- a/apps/effectcraft/src/native_menu.rs +++ b/apps/effectcraft/src/native_menu.rs @@ -2,10 +2,10 @@ //! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree. //! //! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window -//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels, -//! enabled and check state into the existing items; a structural change (Open Recent, a new -//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS -//! swaps back to the in-window bar. +//! bar), and when [`native_menu::state_key`] or whether a text field has the keyboard changed it +//! rebuilds the spec and pushes labels, enabled and check state into the existing items; a +//! structural change (Open Recent, a new shortcut preset) rebuilds the menu. Settings ▸ +//! Appearance ▸ Use In-Window Menu Bar on macOS swaps back to the in-window bar. use std::collections::HashMap; use std::sync::mpsc::{Receiver, channel}; @@ -24,7 +24,7 @@ pub struct NativeBar { items: HashMap, spec: NativeMenu, structure: u64, - state: Option, + state: Option<(u64, bool)>, events: Receiver, /// The in-window-bar setting last applied (None: not yet). in_window: Option, @@ -67,12 +67,13 @@ impl NativeBar { if in_window { return; } - let key = native_menu::state_key(app); + // While a text field has the keyboard, the Edit items it takes stay enabled. + let key = (native_menu::state_key(app), native_menu::text_field_window(ctx).is_some()); if self.state == Some(key) && self.menu.is_some() { return; } self.state = Some(key); - let spec = native_menu::build(app); + let spec = native_menu::build_now(app, ctx); let structure = spec.structure(); if self.menu.is_none() || structure != self.structure { self.install(&spec); diff --git a/apps/effectcraft/src/sheet_dialogs.rs b/apps/effectcraft/src/sheet_dialogs.rs index 7a75ef2a7..911870190 100644 --- a/apps/effectcraft/src/sheet_dialogs.rs +++ b/apps/effectcraft/src/sheet_dialogs.rs @@ -17,10 +17,24 @@ use std::pin::Pin; type Answer = Pin> + Send>>; -/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it -/// closes, then wake the UI. -pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) { - let answer = open(dialog); +/// A command's dialog waiting for [`show_asked`]. +pub type Asked = (FileDialog, DialogDone); + +/// Show the dialogs the commands asked for in this frame (on the main thread: a sheet opens +/// here) and hand each one's paths to its `done` once it closes, then wake the UI. +/// +/// On macOS the sheet goes on the main window (`frame`'s). Without a parent rfd puts it on +/// AppKit's main window, which is a floating panel's OS window after a click in one; that +/// window hides while the app has no focused window, and a sheet whose window goes away ends +/// without answering, with later sheets queued behind it on the same window. +pub fn show_asked(asked: &std::sync::mpsc::Receiver, frame: &eframe::Frame, ctx: &egui::Context) { + while let Ok((dialog, done)) = asked.try_recv() { + show(dialog, done, frame, ctx.clone()); + } +} + +fn show(dialog: FileDialog, done: DialogDone, frame: &eframe::Frame, ctx: egui::Context) { + let answer = open(dialog, frame); let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || { done(pollster::block_on(answer)); ctx.request_repaint(); @@ -30,6 +44,13 @@ pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) { } } +/// A dialog on the main window. Linux keeps the portal's dialog unparented: rfd exports the +/// window for the portal from the dialog's worker thread there. +fn parented(frame: &eframe::Frame) -> AsyncFileDialog { + let d = AsyncFileDialog::new(); + if cfg!(target_os = "macos") { d.set_parent(frame) } else { d } +} + fn paths(files: Option>) -> Vec { files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect() } @@ -39,10 +60,10 @@ fn path(file: Option) -> Vec { } /// The dialog, as the blocking hooks in `main.rs` show it. -fn open(dialog: FileDialog) -> Answer { +fn open(dialog: FileDialog, frame: &eframe::Frame) -> Answer { match dialog { FileDialog::Open { exts, many } => { - let d = AsyncFileDialog::new().add_filter("Media", &exts); + let d = parented(frame).add_filter("Media", &exts); if many { let f = d.pick_files(); Box::pin(async move { paths(f.await) }) @@ -52,22 +73,22 @@ fn open(dialog: FileDialog) -> Answer { } } FileDialog::OpenProject => { - let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file(); + let f = parented(frame).add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file(); Box::pin(async move { path(f.await) }) } FileDialog::Folder => { - let f = AsyncFileDialog::new().pick_folder(); + let f = parented(frame).pick_folder(); Box::pin(async move { path(f.await) }) } FileDialog::Save { default, ext, project } => { let at = std::path::Path::new(&default); let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); let mut d = if project { - AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"]) + parented(frame).add_filter("EffectCraft Project", &["ecproj"]) } else if ext.is_empty() { - AsyncFileDialog::new() + parented(frame) } else { - AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()]) + parented(frame).add_filter(&ext, &[ext.as_str()]) }; // A default with a folder that exists opens the dialog there. if let Some(dir) = at.parent().filter(|d| d.is_dir()) { diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs index 8367d2a99..41281eb23 100644 --- a/apps/effectcraft/tests/args.rs +++ b/apps/effectcraft/tests/args.rs @@ -6,6 +6,11 @@ use std::path::PathBuf; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_effectcraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft").into()) +} + /// A settings folder of its own, so a launch that got past the arguments would show up as /// preferences (Recent Footage) written there instead of in the user's settings. fn config_dir(name: &str) -> PathBuf { @@ -19,7 +24,7 @@ fn config_dir(name: &str) -> PathBuf { /// regression fails instead of leaving a window open: the process is killed after 5 s. fn rejects(name: &str, args: &[&str], env: &[(&str, &str)], message: &str) { let config = config_dir(name); - let mut cmd = Command::new(env!("CARGO_BIN_EXE_effectcraft")); + let mut cmd = Command::new(binary()); cmd.args(args).env_remove("EFFECTCRAFT_CONTROL_PORT").env_remove("RUST_LOG").env("EFFECTCRAFT_CONFIG_DIR", &config); for (k, v) in env { cmd.env(k, v); @@ -67,7 +72,7 @@ fn a_bad_control_port_stops_the_launch() { #[test] fn version_still_prints_with_a_bad_port_variable() { - let out = Command::new(env!("CARGO_BIN_EXE_effectcraft")).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap(); + let out = Command::new(binary()).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("effectcraft {}\n", env!("CARGO_PKG_VERSION"))); } diff --git a/assets/fonts/NotoSansSymbols2-Regular.ttf b/assets/fonts/NotoSansSymbols2-Regular.ttf new file mode 100644 index 000000000..caf89dd0e Binary files /dev/null and b/assets/fonts/NotoSansSymbols2-Regular.ttf differ diff --git a/assets/fonts/NotoSansSymbols2-Regular.ttf.attribution b/assets/fonts/NotoSansSymbols2-Regular.ttf.attribution new file mode 100644 index 000000000..4d6d416be --- /dev/null +++ b/assets/fonts/NotoSansSymbols2-Regular.ttf.attribution @@ -0,0 +1,8 @@ +asset: NotoSansSymbols2-Regular.ttf +title: Noto Sans Symbols 2 Regular (chrome symbols such as ✔ and ▣) +author: The Noto Project Authors +source: https://github.com/notofonts/symbols (Google Fonts commit 47d54ca1ebb14fd864695f1d39dc734f39017c33, file ofl/notosanssymbols2/NotoSansSymbols2-Regular.ttf) +license: OFL-1.1 +license-file: assets/fonts/OFL-NotoSansSymbols2.txt +added: 2026-10-24 by @skylartaylor +notes: Unmodified font file, sha256 7d5fb73b7ca67a6798101741f5d280a3d016a56a197afcd4199dbb57b4b82a21. Embedded with include_bytes! in crates/ui-egui/src/theme.rs as the last UI fallback so every family can draw the chrome symbols (U+2714 ✔, U+25A3 ▣) that Inter, Inter Medium, Inter SemiBold and JetBrains Mono lack. diff --git a/assets/fonts/OFL-NotoSansSymbols2.txt b/assets/fonts/OFL-NotoSansSymbols2.txt new file mode 100644 index 000000000..7c95767f5 --- /dev/null +++ b/assets/fonts/OFL-NotoSansSymbols2.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/clippy.toml b/clippy.toml index 4b6c79f55..287e14edc 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,6 @@ 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)" }, +] diff --git a/contributors/people.toml b/contributors/people.toml index 625fcdb73..1012a37bd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -95,3 +95,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.ki2pixel] +display_name = "ki2pixel" diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md index 6ced20ba1..c922c95eb 100644 --- a/crates/av1enc/README.md +++ b/crates/av1enc/README.md @@ -4,8 +4,8 @@ Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0, no `unsafe`, builds for `wasm32-unknown-unknown`). Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in -`tests/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived -`filmcraft-av1` decoder (`tests/roundtrip.rs`): every decoded frame equals the encoder's own +`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived +`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own reconstruction, so prediction never drifts. ## What is implemented diff --git a/crates/av1enc/tests/bench.rs b/crates/av1enc/tests/it/bench.rs similarity index 97% rename from crates/av1enc/tests/bench.rs rename to crates/av1enc/tests/it/bench.rs index 6577f8f9f..b29817e1a 100644 --- a/crates/av1enc/tests/bench.rs +++ b/crates/av1enc/tests/it/bench.rs @@ -1,7 +1,7 @@ //! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default): -//! `cargo test -p effectcraft-av1enc --test bench -- --ignored --nocapture`. +//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`. -mod common; +use crate::common; use std::process::Command; use std::time::Instant; diff --git a/crates/av1enc/tests/common/mod.rs b/crates/av1enc/tests/it/common/mod.rs similarity index 100% rename from crates/av1enc/tests/common/mod.rs rename to crates/av1enc/tests/it/common/mod.rs diff --git a/crates/av1enc/tests/ffmpeg_oracle.rs b/crates/av1enc/tests/it/ffmpeg_oracle.rs similarity index 99% rename from crates/av1enc/tests/ffmpeg_oracle.rs rename to crates/av1enc/tests/it/ffmpeg_oracle.rs index 66f380ac2..b35cbf496 100644 --- a/crates/av1enc/tests/ffmpeg_oracle.rs +++ b/crates/av1enc/tests/it/ffmpeg_oracle.rs @@ -2,7 +2,7 @@ //! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1` //! decoder only drives hardware decoders, so it is not used. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/av1enc/tests/it/main.rs b/crates/av1enc/tests/it/main.rs new file mode 100644 index 000000000..c66bacde5 --- /dev/null +++ b/crates/av1enc/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod bench; +mod common; +mod ffmpeg_oracle; +mod roundtrip; diff --git a/crates/av1enc/tests/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs similarity index 99% rename from crates/av1enc/tests/roundtrip.rs rename to crates/av1enc/tests/it/roundtrip.rs index 1e777e509..282880e08 100644 --- a/crates/av1enc/tests/roundtrip.rs +++ b/crates/av1enc/tests/it/roundtrip.rs @@ -1,7 +1,7 @@ //! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the //! encoder's own reconstruction bit for bit. -mod common; +use crate::common; use common::*; use effectcraft_av1enc::{EncoderConfig, RateControl}; diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 1fae0ea90..2a0dfc50e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -39,7 +39,13 @@ impl Rgba { /// `#rrggbb` or `#rrggbbaa`. pub fn from_hex(s: &str) -> Option { let s = s.trim().trim_start_matches('#'); - let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok(); + let p = |i: usize| { + let pair = s.get(i..i + 2)?; + if !pair.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u8::from_str_radix(pair, 16).ok() + }; match s.len() { 6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)), 8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }), @@ -93,7 +99,7 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { let d = max - min; let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; let h = if max == r { - ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0 + (((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0) } else if max == g { ((b - r) / d + 2.0) / 6.0 } else { @@ -130,7 +136,7 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { let h = if d < 1e-7 { 0.0 } else if max == r { - ((g - b) / d).rem_euclid(6.0) / 6.0 + (((g - b) / d).rem_euclid(6.0) / 6.0).rem_euclid(1.0) } else if max == g { ((b - r) / d + 2.0) / 6.0 } else { @@ -141,7 +147,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { } pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { - let h6 = h.rem_euclid(1.0) * 6.0; + let mut h6 = h.rem_euclid(1.0) * 6.0; + // In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0. + if h6 >= 6.0 { + h6 = 0.0; + } let i = h6.floor(); let f = h6 - i; let p = v * (1.0 - s); @@ -252,6 +262,28 @@ impl Label { mod tests { use super::*; + #[test] + fn hsv_to_rgb_tiny_negative_hue_is_red() { + let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0); + assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}"); + } + + #[test] + fn hue_stays_below_one_for_tiny_blue() { + let (h, _, _) = rgb_to_hsv(1.0, 0.0, 1e-9); + assert!((0.0..1.0).contains(&h), "rgb_to_hsv hue {h} not in 0..1"); + let (h, _, _) = rgb_to_hsl(1.0, 0.0, 1e-9); + assert!((0.0..1.0).contains(&h), "rgb_to_hsl hue {h} not in 0..1"); + } + + #[test] + fn rejects_signed_hex_pairs() { + assert!(Rgba::from_hex("#+1+2+3").is_none()); + assert!(Rgba::from_hex("#1+2+3+").is_none()); + assert!(Rgba::from_hex("#ff00+0ff").is_none()); + assert!(Rgba::from_hex("#a1B2c3").is_some()); + } + #[test] fn srgb_roundtrip() { for i in 0..=100 { diff --git a/crates/color/src/space.rs b/crates/color/src/space.rs index ee7590eca..b570975aa 100644 --- a/crates/color/src/space.rs +++ b/crates/color/src/space.rs @@ -177,7 +177,7 @@ impl ColorSpace { /// Parse an id or label (case-insensitive, loose). pub fn parse(s: &str) -> Option { - let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); match k.as_str() { "srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb), "rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709), diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index c37d09949..bb26d3127 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -21,4 +21,5 @@ rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index e4f2b0348..929f41553 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -509,11 +509,24 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { // --------------------------------------------------------------------------------------------- // CC Radial Fast Blur +/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center +/// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`, +/// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other +/// growing effects). Shared with the GPU path so both grow the same way. +pub fn radial_fast_pad(c: (f64, f64), w: u32, h: u32, s: f64) -> u32 { + let (w, h) = (f64::from(w), f64::from(h)); + let far = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - c.0).hypot(y - c.1)).fold(0.0, f64::max); + ((far * s / (1.0 - s)).ceil() + 1.0).min(4096.0) as u32 +} + fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5; if s <= 0.0 { return b; } + if !ctx.adjustment { + b.pad(radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s)); + } let c = b.to_px(ctx.params.v2("center")); let mode = ctx.params.e("zoom"); let src = b.img.clone(); @@ -906,6 +919,23 @@ mod tests { assert_eq!(circle.len(), 21); } + #[test] + fn radial_fast_blur_streaks_past_the_layer_edge() { + // Center left of an opaque square: the streaks run right, beyond the layer rectangle (#2095). + let img = Image::filled(20, 20, [1.0, 1.0, 1.0, 1.0]); + let out = run("ec.blur.ccradialfast", &img, &[("center", Value::Vec2([-20.0, 10.0])), ("amount", num(80.0))]); + assert!(out.img.width > 20 && out.img.height > 20, "{}x{}", out.img.width, out.img.height); + let (o, d) = (out.offset[0] as i64, out.offset[1] as i64); + assert!(out.img.get(30 + o, 10 + d)[3] > 0.1, "{:?}", out.img.get(30 + o, 10 + d)); + // Its streak samples only the square: still opaque, so the content keeps its place. + assert!((out.img.get(19 + o, 10 + d)[3] - 1.0).abs() < 1e-5, "{:?}", out.img.get(19 + o, 10 + d)); + // An adjustment layer is comp-sized already; its buffer is not grown. + let s = find("ec.blur.ccradialfast").unwrap(); + let params = Params { values: [("center".into(), Value::Vec2([-20.0, 10.0])), ("amount".into(), num(80.0))].into_iter().collect() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [20.0, 20.0], seed: 1, adjustment: true, env: Default::default() }; + assert_eq!((s.render)(&ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img.width, 20); + } + #[test] fn reduce_flicker_blurs_vertically_only() { let mut img = Image::filled(8, 8, [0.0, 0.0, 0.0, 1.0]); diff --git a/crates/effects/src/camera_tracker.rs b/crates/effects/src/camera_tracker.rs index ff4e51fe4..ba7878abf 100644 --- a/crates/effects/src/camera_tracker.rs +++ b/crates/effects/src/camera_tracker.rs @@ -136,11 +136,11 @@ type Cache = Mutex>>; fn cached(c: &'static OnceLock>, json: &str, parse: impl Fn(&str) -> Option) -> Option> { let key = fnv(json.as_bytes()); let c = c.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { return Some(a.clone()); } let a = Arc::new(parse(json)?); - let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if m.len() > 8 { m.clear(); } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 0be2b04f5..80a937b22 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -92,7 +92,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see -/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked +/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked /// pinning). pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs index b763ac21f..44e9dd89a 100644 --- a/crates/effects/src/channel.rs +++ b/crates/effects/src/channel.rs @@ -49,12 +49,15 @@ fn set_channels(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Shift Channels' Take … From menu. After Effects' ten options come first, in its order, so a +/// project or script that sets 10 ("Full Off" there) turns the channel off here too; "Half On" is +/// an EffectCraft extra and sits last. const SHIFT_ORDER: [Src; 11] = - [Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off]; -const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"]; + [Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half]; +const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Full Off", "Half On"]; fn shift_channels(ctx: &EffectCtx, mut b: Buf) -> Buf { - let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(10)]); + let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(SHIFT_ORDER.len() - 1)]); route(&mut b, s); b } @@ -548,6 +551,24 @@ mod tests { assert!(close(out.get(7, 0), [0.25, 0.25, 0.45, 0.5])); } + /// #1797: the Take … From menu keeps After Effects' order, where the tenth option (`Enum(9)` + /// here, 10 to a script) is Full Off, not Half On. + #[test] + fn shift_channels_menu_follows_after_effects_order() { + assert_eq!(SHIFT_NAMES[8..], ["Full On", "Full Off", "Half On"]); + // The sample's right-hand pixels are half transparent, so a straight 0.0 and 0.5 are + // stored as 0.0 and 0.25. + let off = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(9))], sample()); + assert!(close(off.get(0, 0), [0.2, 0.0, 0.9, 1.0]), "{:?}", off.get(0, 0)); + assert!(close(off.get(7, 0), [0.1, 0.0, 0.45, 0.5]), "{:?}", off.get(7, 0)); + let half = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(10))], sample()); + assert!(close(half.get(0, 0), [0.2, 0.5, 0.9, 1.0]), "{:?}", half.get(0, 0)); + assert!(close(half.get(7, 0), [0.1, 0.25, 0.45, 0.5]), "{:?}", half.get(7, 0)); + // A value past the last option stays on the last one instead of panicking. + let past = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(99))], sample()); + assert_eq!(past.get(0, 0), half.get(0, 0)); + } + #[test] fn channel_combiner_hls_round_trip() { let img = sample(); diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 7884e8084..08241107c 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -37,16 +37,24 @@ fn index_map(b: &Buf, a: &AuxChannels) -> Vec> { /// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1). fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec> { let (w, h) = (b.img.width as usize, b.img.height as usize); - let inv = 1.0 / b.scale.max(1e-9); (0..w * h) .into_par_iter() .map(|i| { - let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]); - a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv) + let [x, y] = aux_point(b.scale, b.offset, [(i % w) as f64 + at[0], (i / w) as f64 + at[1]]); + a.index_at(x, y) }) .collect() } +/// The layer point where 3D Channel effects look up the aux for buffer position `p` (pixel +/// `i` + sub-pixel offset) of a buffer with `scale` and `offset`. The Cryptomatte pick calls +/// it too, so the two can't pick different aux pixels. +#[inline] +pub fn aux_point(scale: f64, offset: [f64; 2], p: [f64; 2]) -> [f64; 2] { + let inv = 1.0 / scale.max(1e-9); + [(p[0] - offset[0]) * inv, (p[1] - offset[1]) * inv] +} + /// A channel sampled into the buffer grid (`fill` outside). fn plane_of(b: &Buf, idx: &[Option], ch: &[f32], fill: f32) -> Plane { Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() } @@ -326,7 +334,7 @@ fn wild(pat: &str, name: &str) -> bool { /// Split a selection at its commas. A name in double or single quotes stays whole, commas /// included (Blender allows `Green, Glossy`), and a backslash takes the next character /// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped. -fn selection_names(sel: &str) -> Vec { +pub(crate) fn selection_names(sel: &str) -> Vec { let mut out = vec![]; let mut cur = String::new(); let mut quote: Option = None; @@ -351,7 +359,7 @@ fn selection_names(sel: &str) -> Vec { out } -/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or +/// Parse a selection: comma-separated names (optionally quoted, see `selection_names`) or /// `` hashes / wildcards. pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec { let mut out = vec![]; @@ -395,13 +403,42 @@ fn crypto_matte(planes: &RankPlanes, sel: &[u32], i: usize) -> f32 { .clamp(0.0, 1.0) } +/// [`id_color`] of the last ID seen on each rank plane, per worker. Neighbouring pixels mostly +/// carry the same IDs, so this skips the hash and the HSL conversion for most of them. Keyed by the +/// ID's bits, so it returns exactly what [`id_color`] would. +/// +/// Keep the per-pixel loop below as it is: with NaN coverage, which NaN payload ends up in a +/// channel depends on the order the compiler adds the ranks in, and a row-wise loop let it reorder +/// them (`crypto_aux_image_is_bit_exact_on_edge_values` catches that). +#[derive(Default)] +struct IdColorMemo(Vec<(u32, [f32; 3])>); + +impl IdColorMemo { + #[inline] + fn get(&mut self, rank: usize, id: f32) -> [f32; 3] { + let bits = id.to_bits(); + match self.0.get_mut(rank) { + Some(e) if e.0 == bits => e.1, + Some(e) => { + *e = (bits, id_color(id)); + e.1 + } + None => { + let c = id_color(id); + self.0.resize(rank + 1, (bits, c)); + c + } + } + } +} + /// The coverage-weighted ID colours at aux pixel `i` (the spec's preview). #[inline] -fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] { +fn crypto_colors(planes: &RankPlanes, i: usize, memo: &mut IdColorMemo) -> [f32; 3] { let mut c = [0.0f32; 3]; - for (id, cov) in planes { + for (rank, (id, cov)) in planes.iter().enumerate() { let (Some(&id), Some(&cov)) = (id.get(i), cov.get(i)) else { continue }; - let k = id_color(id); + let k = memo.get(rank, id); for j in 0..3 { c[j] += k[j] * cov; } @@ -413,10 +450,37 @@ fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] { /// selection's coverage (A), computed in parallel with the rank planes resolved once. The GPU /// effect uploads this image and samples it per layer pixel. pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image { + crypto_aux_image_with(a, layer, sel, CryptoAux::ColorsAndMatte) +} + +/// What [`crypto_aux_image_with`] computes per aux pixel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CryptoAux { + /// The coverage-weighted ID colours (RGB) and the selection's coverage (A). + ColorsAndMatte, + /// Only the selection's coverage (A); the RGB stays 0. + MatteOnly, +} + +impl CryptoAux { + /// What the Cryptomatte effect reads for `display` and Matte Only: the ID colours are only + /// read by the Colors display (0) without Matte Only; Matte, Preview and Matte Only need the + /// matte alone. + pub fn for_display(display: u32, matte_only: bool) -> Self { + if display == 0 && !matte_only { Self::ColorsAndMatte } else { Self::MatteOnly } + } +} + +/// [`crypto_aux_image`]; with [`CryptoAux::MatteOnly`] the RGB stays 0 and only the matte (A) is +/// computed, for the displays that never read the ID colours. +pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], what: CryptoAux) -> Image { let planes = a.crypto_rank_planes(layer); let mut img = Image::new(a.width, a.height); - img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let c = crypto_colors(&planes, i); + img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| { + let c = match what { + CryptoAux::ColorsAndMatte => crypto_colors(&planes, i, memo), + CryptoAux::MatteOnly => [0.0; 3], + }; *px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)]; }); img @@ -436,9 +500,8 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let display = ctx.params.e("display"); let matte_only = ctx.params.b("matteOnly"); let planes = a.crypto_rank_planes(&layer); - let src = b.img.clone(); - b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k)); + b.img.data.par_iter_mut().zip(idx.par_iter()).for_each_init(IdColorMemo::default, |memo, (px, &k)| { + let m = k.map_or(0.0, |k| crypto_matte(&planes, &sel, k)); if matte_only { *px = [m, m, m, m]; return; @@ -446,15 +509,15 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { *px = match display { // Colors: coverage-weighted ID colours (the spec's preview), selection brighter. 0 => { - let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k)); + let c = k.map_or([0.0; 3], |k| crypto_colors(&planes, k, memo)); let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); [c[0], c[1], c[2], 1.0] } - // Matte: the layer keyed by the selection. - 1 => src.data[i].map(|v| v * m), + // Matte: the layer keyed by the selection (each pixel only reads itself, so no copy). + 1 => px.map(|v| v * m), // Preview: the layer with the selection tinted. _ => { - let s = src.data[i]; + let s = *px; [s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]] } }; @@ -623,345 +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 - } - - 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"); - } - } - - /// 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 71dee87b4..25203cd7a 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -55,6 +55,17 @@ impl Curve { /// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or /// fewer than two valid points. pub fn parse(s: &str) -> Option { + Self::parse_with(s, false) + } + + /// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline + /// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and + /// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does). + pub fn parse_natural(s: &str) -> Option { + Self::parse_with(s, true) + } + + fn parse_with(s: &str, natural: bool) -> Option { let mut pts: Vec<(f32, f32)> = s .split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) @@ -74,6 +85,12 @@ impl Curve { let ys: Vec = pts.iter().map(|p| p.1).collect(); let n = xs.len(); let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); + if natural { + let ms = natural_slopes(&xs, &d); + let mut c = Curve { xs, ys, ms, lut: Vec::new() }; + c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect(); + return Some(c); + } let mut ms = vec![0.0f32; n]; ms[0] = d[0]; ms[n - 1] = d[n - 2]; @@ -136,6 +153,40 @@ impl Curve { } } +/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with +/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly. +fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec { + let n = xs.len(); + if n < 3 { + return vec![d.first().copied().unwrap_or(0.0); n]; + } + let h: Vec = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect(); + let dd: Vec = d.iter().map(|&v| f64::from(v)).collect(); + // Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm. + let m = n - 2; + let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]); + for i in 0..m { + let k = i + 1; + a[i] = h[k - 1]; + b[i] = 2.0 * (h[k - 1] + h[k]); + c[i] = h[k]; + r[i] = 6.0 * (dd[k] - dd[k - 1]); + } + for i in 1..m { + let w = a[i] / b[i - 1]; + b[i] -= w * c[i - 1]; + r[i] -= w * r[i - 1]; + } + let mut sec = vec![0.0f64; n]; + for i in (0..m).rev() { + let next = if i + 1 < m { sec[i + 2] } else { 0.0 }; + sec[i + 1] = (r[i] - c[i] * next) / b[i]; + } + let mut ms: Vec = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect(); + ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32); + ms +} + fn ev(c: &Option, v: f32) -> f32 { c.as_ref().map(|c| c.eval(v)).unwrap_or(v) } @@ -145,7 +196,7 @@ fn ev(c: &Option, v: f32) -> f32 { /// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the /// master RGB curve first, then their own curve. fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf { - let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id))); + let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id))); if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() { return b; } @@ -201,7 +252,7 @@ impl Lv { if clip_w { t = t.min(1.0); } - self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01)) + self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01)) } } @@ -217,7 +268,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { return b; } let clip = crate::color_fx::levels_clip(ctx); - map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); + // Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB + // (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green + // 52/255 (RGB first would give 45). + map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); b } @@ -1075,267 +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); - } - - #[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 cf6698fee..3a52e7a54 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,12 +14,27 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } +/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red +/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and +/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from +/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree +/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on +/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green +/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. +pub(crate) fn luma_601(c: [f32; 3]) -> f32 { + LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2] +} + +/// The Rec. 601 luma weights of `luma_601`. The WGSL kernels repeat them as literals; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114]; + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); let amt = ctx.params.f("amount") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l]; mix(c, t, amt) }); @@ -32,7 +47,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { let sh = ctx.params.color("shadows"); let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = if l < 0.5 { mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0) } else { @@ -49,18 +64,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { /// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of /// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1 /// pass through. The GPU kernel mirrors it. +/// +/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects +/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise), +/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2. pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 { if v >= 1.0 { return v; } let mut v = v.max(0.0); - if b > 0.0 { - v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b)); - } else if b < 0.0 { - v = v.powf(2f32.powf(-1.32 * b)); + if b != 0.0 { + let e = 2f32.powf(1.24 * b.abs()); + v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e }); } if c != 0.0 { - let g = (1.0 + 0.53 * c).max(0.05); + let g = (1.0 + 0.53 * c.abs()).max(0.05); + let g = if c > 0.0 { g } else { 1.0 / g }; v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }; } v @@ -197,16 +216,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"]; /// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips -/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch -/// of projects saved before the two popups existed turns both off. +/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual +/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden +/// `noClip` switch of projects saved before the two popups existed turns both off. pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) { if ctx.params.b("noClip") { return (false, false); } - let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0); + let on = |id: &str| levels_clip_on(ctx, id); (on("clipToOutputBlack"), on("clipToOutputWhite")) } +/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip). +pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool { + match ctx.params.get(id).map(|v| v.as_enum()) { + Some(0) => false, + Some(2) => !ctx.env.float_depth, + _ => true, + } +} + +/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate +/// (the curve mirrored through 0) instead of stopping at Output Black. +pub fn levels_gamma(t: f32, g: f32) -> f32 { + if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) } +} + /// Levels' Channel popup. pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"]; @@ -232,8 +267,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) if clip_w { t = t.min(1.0); } - let t = t.max(0.0).powf(1.0 / g); - ob + (ow - ob) * t + ob + (ow - ob) * levels_gamma(t, g) } const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; @@ -276,7 +310,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] let ids = levels_channel_ids(i + 1); let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k])); let pre = LEVELS_CHANNEL_PREFIX[i]; - let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0); + let on = |id: String| levels_clip_on(ctx, &id); let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) }; ([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip) }) @@ -304,6 +338,18 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { } } +/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion +/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 +/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it. +pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; + +/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless +/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve +/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it +/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a +/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past +/// black and the default settings leave every value unchanged. fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = exposure_settings(ctx); let bypass = ctx.params.b("bypassLinearLight"); @@ -313,9 +359,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { if bypass { return mirror_pow(c[i] * e + off, 1.0 / g); } - let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0)); + let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA); let o = mirror_pow(lin * e + off, 1.0 / g); - effectcraft_color::linear_to_srgb(o.abs()).copysign(o) + mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA) }) }); b @@ -333,6 +379,37 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted /// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright /// and black blocks (#1463), and 32-bpc overbrights divided by zero. +/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a +/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled +/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid +/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), +/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. +/// The luminosity weights of `bw_tint` (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; + +pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] { + let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2]; + if !gray.is_finite() || gray <= 0.0 { + return [0.0; 3]; + } + if gray >= 1.0 { + return [gray; 3]; + } + let d = gray - lum(tint); + let c = tint.map(|v| v + d); + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut o = c; + if n < 0.0 && l - n > 1e-9 { + o = o.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 && x - l > 1e-9 { + o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + o +} + fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")] .map(|v| v as f32 / 100.0); @@ -347,13 +424,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f; let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); let gray = (min + (max - min) * wt).max(0.0); - if tint_on { - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); - let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0)); - [r, g, bl] - } else { - [gray, gray, gray] - } + if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] } }); b } @@ -418,15 +489,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { }); return b; } + // Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After + // Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with + // nothing of the original showing through (it is not a mix with the original). b.img.data.iter_mut().for_each(|px| { - let a = if invert { 1.0 - px[3] } else { px[3] }; - let f = [c[0] * a, c[1] * a, c[2] * a]; - for i in 0..3 { - px[i] += (f[i] - px[i]) * opacity; - } - if invert { - px[3] += (a - px[3]) * opacity; - } + let a = if invert { 1.0 - px[3] } else { px[3] } * opacity; + *px = [c[0] * a, c[1] * a, c[2] * a, a]; }); b } @@ -687,7 +755,7 @@ impl ColoramaPalette { } } - /// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. + /// Parse "pos:r,g,b\[,a\] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. pub fn parse(s: &str) -> Option { let mut stops: Vec<(f32, [f32; 4])> = s .split_whitespace() @@ -969,11 +1037,13 @@ pub fn specs() -> Vec { "Exposure", { let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))]; + // After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from + // older projects or Photoshop imports (0.01..9.99) are kept and still render. let group = |ids: [&'static str; 3]| { [ - p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)), p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)), - p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)), + p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)), ] }; v.extend(group(["master/exposure", "master/offset", "master/gamma"])); @@ -1145,360 +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}"); - } - } - - #[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); - } - - /// #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:?}"); - } - - #[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]); - } - - #[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/crypto_select.rs b/crates/effects/src/crypto_select.rs new file mode 100644 index 000000000..19d93cdba --- /dev/null +++ b/crates/effects/src/crypto_select.rs @@ -0,0 +1,122 @@ +//! Editing a Cryptomatte effect's Selection text from a pick in the viewer (#473): replace it +//! with the picked ID, add it, or remove it, in the grammar `selection_hashes` reads. + +use crate::channel3d::{selection_hashes, selection_names}; + +/// How a pick changes the selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PickMode { + Replace, + Add, + Remove, +} + +/// A name as a selection token: quoted (with `\` escapes) when it has a comma, a quote or a +/// backslash, which the selection parser would otherwise split on or read as syntax. +fn quote(name: &str) -> String { + if !name.contains([',', '"', '\'', '\\']) { + return name.to_string(); + } + let mut out = String::from("\""); + for c in name.chars() { + if c == '"' || c == '\\' { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +/// The selection token for the ID `hash` named `name` in `manifest`: the (quoted) name when it +/// reads back as exactly that ID, else ``. That covers unnamed IDs, names with `*` (which +/// would select by wildcard), names that look like ``, names with surrounding spaces (the +/// parser trims them) and names shared by several IDs. +pub fn token_for(name: Option<&str>, hash: u32, manifest: Option<&[(String, u32)]>) -> String { + match name { + Some(n) if !n.is_empty() && !n.contains('*') && selection_hashes("e(n), manifest) == [hash] => quote(n), + _ => format!("<{hash:08x}>"), + } +} + +/// `sel` edited by a pick of the ID `hash` whose token is `token` (see [`token_for`]). The text +/// stays as typed when the edit wouldn't change what it selects: Replace when it selects just +/// that ID, Add when it already selects it, Remove when no name or `` token selects it (a +/// wildcard that matches it stays). Other tokens (wildcards included) keep their text, +/// re-quoted where needed, separated by `, `. +pub fn edit_selection(sel: &str, token: &str, hash: u32, mode: PickMode, manifest: Option<&[(String, u32)]>) -> String { + let tokens: Vec = selection_names(sel).into_iter().filter(|t| !t.is_empty()).collect(); + let selects = |t: &String| !t.contains('*') && selection_hashes("e(t), manifest).contains(&hash); + let kept: Vec = match mode { + PickMode::Replace if selection_hashes(sel, manifest) == [hash] => return sel.to_string(), + PickMode::Replace => return token.to_string(), + PickMode::Add if selection_hashes(sel, manifest).contains(&hash) => return sel.to_string(), + PickMode::Add => tokens.iter().map(|t| quote(t)).chain([token.to_string()]).collect(), + PickMode::Remove if !tokens.iter().any(selects) => return sel.to_string(), + PickMode::Remove => tokens.iter().filter(|t| !selects(t)).map(|t| quote(t)).collect(), + }; + kept.join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_raster::channels3d::crypto_hash; + + fn manifest() -> Vec<(String, u32)> { + ["bunny", "Green, Glossy", r#"say "hi""#, r"back\slash", " pad ", "star*", "<0000abcd>", "twin"] + .iter() + .map(|n| (n.to_string(), crypto_hash(n))) + .chain([("twin".to_string(), 7)]) + .collect() + } + + /// Every name's token reads back as exactly its ID, through the effect's own parser. + #[test] + fn tokens_round_trip_through_selection_hashes() { + let m = manifest(); + for (n, h) in &m { + let t = token_for(Some(n), *h, Some(&m)); + assert_eq!(selection_hashes(&t, Some(&m)), [*h], "{n:?} → {t:?}"); + } + assert_eq!(token_for(Some("bunny"), crypto_hash("bunny"), Some(&m)), "bunny"); + assert_eq!(token_for(Some("Green, Glossy"), crypto_hash("Green, Glossy"), Some(&m)), r#""Green, Glossy""#); + assert_eq!(token_for(Some(r#"say "hi""#), crypto_hash(r#"say "hi""#), Some(&m)), r#""say \"hi\"""#); + // Wildcard-looking, hash-looking, empty, unnamed and shared names: the hash. + assert_eq!(token_for(Some("star*"), crypto_hash("star*"), Some(&m)), format!("<{:08x}>", crypto_hash("star*"))); + assert_eq!(token_for(Some("<0000abcd>"), crypto_hash("<0000abcd>"), Some(&m)), format!("<{:08x}>", crypto_hash("<0000abcd>"))); + assert_eq!(token_for(Some(""), 5, Some(&m)), "<00000005>"); + assert_eq!(token_for(Some(" pad "), crypto_hash(" pad "), Some(&m)), format!("<{:08x}>", crypto_hash(" pad "))); + assert_eq!(token_for(None, 0xdead_beef, None), ""); + assert_eq!(token_for(Some("twin"), 7, Some(&m)), "<00000007>", "the name selects the other twin"); + } + + #[test] + fn add_and_remove_keep_other_tokens_and_skip_duplicates() { + let m = manifest(); + let (b, g) = (crypto_hash("bunny"), crypto_hash("Green, Glossy")); + let tok = |n: &str| token_for(Some(n), crypto_hash(n), Some(&m)); + let edit = |sel: &str, n: &str, mode| edit_selection(sel, &tok(n), crypto_hash(n), mode, Some(&m)); + assert_eq!(edit("anything, else", "bunny", PickMode::Replace), "bunny"); + assert_eq!(edit(" bunny ", "bunny", PickMode::Replace), " bunny ", "same meaning: unchanged"); + assert_eq!(edit("", "bunny", PickMode::Add), "bunny"); + assert_eq!(edit("bunny", "Green, Glossy", PickMode::Add), r#"bunny, "Green, Glossy""#); + // Already selected: by name, by hash, by a wildcard. The text stays as typed. + assert_eq!(edit("bunny,floor", "bunny", PickMode::Add), "bunny,floor"); + assert_eq!(edit(&format!("<{b:08x}>"), "bunny", PickMode::Add), format!("<{b:08x}>")); + assert_eq!(edit("bun*", "bunny", PickMode::Add), "bun*"); + // Other tokens keep their meaning: quoted where needed, wildcards stay wildcards. + assert_eq!(edit(r#"'a, b', fl*, "x, *""#, "bunny", PickMode::Add), r#""a, b", fl*, "x, *", bunny"#); + assert_eq!(edit(r#""Green, Glossy", bunny"#, "bunny", PickMode::Remove), r#""Green, Glossy""#); + assert_eq!(edit(&format!("bunny, <{b:08x}>, x, bunny"), "bunny", PickMode::Remove), "x"); + assert_eq!(edit(&format!("<{g:08x}>"), "Green, Glossy", PickMode::Remove), ""); + // Not selected, or only by a wildcard: unchanged. + assert_eq!(edit("a ,b", "bunny", PickMode::Remove), "a ,b"); + assert_eq!(edit("bun*, x", "bunny", PickMode::Remove), "bun*, x"); + for sel in [r#""a, b", fl*, bunny"#, r#"x, "Green, Glossy", "say \"hi\"", "back\\slash""#] { + let before = selection_hashes(sel, Some(&m)); + let after = selection_hashes(&edit(sel, " pad ", PickMode::Add), Some(&m)); + assert_eq!(after, [before, vec![crypto_hash(" pad ")]].concat(), "{sel}"); + } + } +} diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 99139483f..a02ab5743 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -107,7 +107,14 @@ fn norm3(v: [f64; 3]) -> [f64; 3] { [v[0] / l, v[1] / l, v[2] / l] } -fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { +/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32; +/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance. +fn norm3f(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [v[0] / l, v[1] / l, v[2] / l] +} + +fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } @@ -115,14 +122,23 @@ fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { let soft = ctx.params.f("softness").max(0.0) * b.scale; - let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32; + let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 1.0) as f32; + if cut >= 1.0 { + // Endpoint guard: at Cut Away 100 the gradient antialias must not leave a half-covered + // pixel where an opaque source reaches exactly 1.0. + b.img.data.fill([0.0; 4]); + return b; + } let blob = layer_or_self(ctx, &b, "blobLayer", true, false); let prop = src_at(ctx.params.e("property")); let raw = Plane::from_image(&blob, |px| { let (c, a) = unpremul(px); pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a } }); - let h = gauss_plane(&raw, soft, soft); + // Oracle (AE 26.2, 16 bpc): a step thresholded at Cut 25/75 with Softness 20 moves the + // 50% contour by 5.55 px. For a Gaussian that is 0.67449 sigma, so sigma = 0.42 Softness. + // The same probe shows the matte stays a ~1 px antialiased threshold, not a broad ramp. + let h = gauss_plane(&raw, soft * 0.42, soft * 0.42); let intensity = ctx.params.f("lightIntensity") / 100.0; let lc = ctx.params.color("lightColor"); let point = ctx.params.e("lightType") == 1; @@ -135,37 +151,69 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { let rough = ctx.params.f("roughness").clamp(0.001, 1.0); let metal = (ctx.params.f("metal") / 100.0) as f32; let shininess = (1.0 / rough).min(500.0); - let depth = (soft * 0.5).max(1.0); let src = b.img.clone(); let (w, hh) = (b.img.width as usize, b.img.height as usize); let span = (w.max(hh) as f64).max(1.0); + + // The source matte and the RGB refraction are separate in the oracle: + // - the matte edge of a self step moves 5/9/14/18 px at Softness 5/10/20/40. A normal + // with z = 1, a ~30 px depth at 1920 and a slope scale of 1.5 Softness reproduces that; + // - RGB follows a height-field refraction with z = height: on a linear ramp the measured + // horizontal offset is dx = -0.34 / h, independent of Softness. + let matte_slope = soft * 1.5; + let matte_depth = if soft > 0.05 { span / 64.0 } else { 0.0 }; + let rgb_depth = span * 0.34 * b.scale; + let light_slope = (soft * 0.65).max(1.0); + let limit = span * 0.5; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let (xi, yi) = (x as i64, y as i64); let hv = h.get(x, y); let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5; let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5; - // Refraction-like displacement down the slope, then the blobby matte. - let sx = x as f64 + 0.5 - gx * depth * 2.0; - let sy = y as f64 + 0.5 - gy * depth * 2.0; - let s = src.sample_bilinear(sx, sy); - let (c, a) = unpremul(s); - let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0); - let alpha = a.max(s[3]) * m; + let grad = (gx * gx + gy * gy).sqrt(); + // One-pixel antialiased threshold. A flat field is either fully in or fully out. + let aa = (1.25 * grad).max(5e-4); + let m = if grad > 1e-9 { + (0.5 + (hv - cut) as f64 / aa).clamp(0.0, 1.0) as f32 + } else if hv > cut { + 1.0 + } else { + 0.0 + }; + if m <= 0.0 { + *px = [0.0; 4]; + continue; + } + // Warp the source matte with the softness-scaled bevel normal (z = 1). + let na = norm3([-gx * matte_slope, -gy * matte_slope, 1.0]); + let sa = src.sample_bilinear(x as f64 + 0.5 + na[0] * matte_depth, y as f64 + 0.5 + na[1] * matte_depth); + let alpha = sa[3].max(0.0) * m; if alpha <= 0.0 { *px = [0.0; 4]; continue; } - let n = norm3([-gx * depth, -gy * depth, 1.0]); - let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir }; - let ndl = dot3(n, l).max(0.0); - let hv3 = norm3([l[0], l[1], l[2] + 1.0]); - let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular; + // Refract the colour through the height field (z = height for this pass). + let hz = (hv as f64).max(1e-3); + let rx = (-rgb_depth * gx / hz).clamp(-limit, limit); + let ry = (-rgb_depth * gy / hz).clamp(-limit, limit); + let srgb = src.sample_bilinear(x as f64 + 0.5 + rx, y as f64 + 0.5 + ry); + let (c, _) = unpremul(srgb); + let (gx32, gy32) = (gx as f32, gy as f32); + let n = norm3f([-gx32 * light_slope as f32, -gy32 * light_slope as f32, 1.0]); + let l = if point { + norm3f([lpos.0 as f32 - (x as f32 + 0.5), lpos.1 as f32 - (y as f32 + 0.5), (lheight / 100.0 * span) as f32]) + } else { + [ldir[0] as f32, ldir[1] as f32, ldir[2] as f32] + }; + let ndl = dot3f(n, l).max(0.0); + let hv3 = norm3f([l[0], l[1], l[2] + 1.0]); + let sp = dot3f(n, hv3).max(0.0).powf(shininess as f32) * specular as f32; let mut out = [0.0f32; 3]; for i in 0..3 { - let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i]; + let lit = (ambient + diffuse * ndl as f64 * intensity) as f32 * lc[i]; let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i]; - out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col; + out[i] = c[i] * lit + (sp as f64 * intensity) as f32 * spec_col; } *px = premul(out, alpha); } @@ -880,19 +928,19 @@ pub fn specs() -> Vec { "CC Blobbylize", vec![ p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer), - p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])), - p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)), - p("cutAway", "Cut Away", num(0.0), pct()), - p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])), + p("softness", "Softness", num(20.0), slider(0.0, 500.0, 0.0, 50.0, 1)), + p("cutAway", "Cut Away", num(25.0), pct()), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])), - p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), - p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point), + p("lightHeight", "Light Height", num(65.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point), p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), - p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)), + p("ambient", "Ambient", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(25.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.025), slider(0.001, 1.0, 0.001, 1.0, 3)), p("metal", "Metal", num(100.0), pct()), ], cc_blobbylize, @@ -1036,260 +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"); - } - } - - /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized - /// (200 × 100) layer bounds, after `id` with `set`. - fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { - let s = crate::find(id).unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for (k, v) in set { - assert!(params.values.contains_key(*k), "{id}: {k}"); - params.values.insert(k.to_string(), v.clone()); - } - let mut im = Image::new(20, 10); - im.data.iter_mut().for_each(|p| *p = [1.0; 4]); - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) - } - - /// Alpha at layer point `p` (0 outside the buffer). - fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { - let (x, y) = b.to_px(p); - b.img.get(x.floor() as i64, y.floor() as i64)[3] - } - - /// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds - /// (comp-sized, as in After Effects), not cut off where the glyphs end. - #[test] - fn effects_on_text_reach_past_its_box() { - let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); - assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); - assert!(smear.img.width < 200, "it grows only as far as pixels move"); - let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); - assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); - assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); - assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); - // A footage layer's buffer is its bounds already: no change. - let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); - assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); - } - - #[test] - fn every_warp_style_runs_and_bends() { - let im = img(40, 30); - for s in 0..WARP_STYLES.len() as u32 { - for axis in 0..2 { - let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]); - let pad = o.offset[0] as u32; - // The layer centre's neighbourhood is still made of source pixels. - assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}"); - assert!(o.img.width > im.width); - } - } - // Arc with positive bend raises the middle of the layer: the top edge in the middle - // moves up (opaque pixels above the original top). - let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]); - let pad = o.offset[1] as i64; - assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5); - assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5); - } - - #[test] - fn power_pin_moves_corners() { - let im = img(20, 20); - // Pin to the right half: output x in 10..20 shows the whole layer squeezed. - let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))]; - let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); - assert_eq!(o.img.get(3, 10)[3], 0.0); - let px = o.img.get(15, 10); - assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); - } - - #[test] - fn power_pin_unstretch_expands_pinned_region() { - let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]); - let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))]; - let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count(); - let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]); - assert_eq!(opaque(&off), 96); - let mut set = inset.to_vec(); - set.push(("unstretch", Value::Bool(true))); - let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); - assert_eq!(opaque(&on), 24 * 16); - assert!(on.img.get(0, 0)[3] > 0.99); - // Default corners are the identity either way. - let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]); - assert!(maxd(&id.img, &im) < 1e-4); - } - - #[test] - fn split_opens_a_gap() { - let im = img(40, 30); - let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]); - assert_eq!(o.img.get(20, 15)[3], 0.0); - assert_eq!(o.img.get(2, 2), im.get(2, 2)); - } - - #[test] - fn slant_shears_from_floor() { - let im = img(20, 20); - let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]); - let pad = o.offset[0] as i64; - // Bottom row stays put; the top row shifts right by ~10 px. - assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05); - assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08); - } - - #[test] - fn reshape_moves_source_to_destination() { - // A bright square at the source mask; reshape to a shifted destination mask. - let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); - let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; - let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks); - assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15)); - assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); - } - - #[test] - fn reshape_correspondence_points_steer_the_match() { - // Matching outlines with a zero pair change nothing; a quarter-turn offset twists. - let im = img(40, 40); - let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; - let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]; - let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks); - assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change"); - let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks); - assert!(maxd(&twisted.img, &im) > 0.1); - // Fraction mapping. - assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9); - assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9); - assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9); - } - - #[test] - fn upscale_grows_about_centre() { - let im = img(20, 10); - let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]); - assert_eq!((o.img.width, o.img.height), (40, 20)); - // Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the - // centre (10,5) lands in the middle of the 40×20 buffer. - assert_eq!(o.offset, [10.0, 5.0]); - let c = o.img.sample_bilinear(20.0, 10.0); - assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03); - } - - #[test] - fn twirl_legacy_rotates_inside_radius() { - let im = img(40, 40); - let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]); - assert!(maxd(&o.img, &im) > 0.1); - // Corners lie outside the radius. - assert_eq!(o.img.get(0, 0), im.get(0, 0)); - } - - #[test] - fn half_resolution_matches_scaled_geometry() { - // Bender at scale 0.5: the buffer is half size, parameters in layer pixels. - let s = crate::find("ec.distort.ccbender").unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("amount".into(), num(30.0)); - params.values.insert("top".into(), pt(20.0, 0.0)); - params.values.insert("base".into(), pt(20.0, 40.0)); - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() }; - let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 }); - let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 }); - let a = full.img.sample_bilinear(20.0, 4.0)[3]; - let b = half.img.sample_bilinear(10.0, 2.0)[3]; - assert!((a - b).abs() < 0.3); - } -} +mod tests; diff --git a/crates/effects/src/distort3/tests.rs b/crates/effects/src/distort3/tests.rs new file mode 100644 index 000000000..b50d6b450 --- /dev/null +++ b/crates/effects/src/distort3/tests.rs @@ -0,0 +1,332 @@ +use super::*; +use crate::{EffectEnv, MaskShape, Params}; + +fn img(w: u32, h: u32) -> Image { + crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0]) +} + +/// Run with Point defaults scaled to the layer (like `instantiate`). +fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [im.width as f64, im.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for ps in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) { + params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let env = EffectEnv { masks, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }) +} + +fn maxd(a: &Image, b: &Image) -> f32 { + assert_eq!((a.width, a.height), (b.width, b.height)); + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) +} + +fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape { + MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false } +} + +#[test] +fn identity_at_neutral_settings() { + let im = img(40, 30); + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![]), + ("ec.distort.ccpowerpin", vec![]), + ("ec.distort.ccripplepulse", vec![]), + ("ec.distort.ccslant", vec![]), + ("ec.distort.ccsmear", vec![("reach", num(0.0))]), + ("ec.distort.ccsplit", vec![("split", num(0.0))]), + ("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]), + ("ec.distort.smear", vec![]), + ("ec.distort.reshape", vec![]), + ("ec.distort.twirllegacy", vec![]), + ("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]), + ("ec.distort.upscale", vec![]), + ]; + for (id, set) in cases { + let o = run(id, set, im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity"); + assert_eq!(o.offset, [0.0, 0.0], "{id}"); + } + // Power pin with perspective 0 at default corners is also identity. + let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-4); +} + +#[test] +fn all_deterministic() { + let im = img(36, 28); + let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)]; + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]), + ("ec.distort.ccblobbylize", vec![]), + ("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]), + ("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]), + ("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]), + ("ec.distort.ccsmear", vec![]), + ("ec.distort.ccsplit", vec![]), + ("ec.distort.ccsplit2", vec![("split2", num(10.0))]), + ("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]), + ("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]), + ("ec.distort.twirllegacy", vec![("angle", num(90.0))]), + ("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]), + ("ec.distort.upscale", vec![("scale", num(200.0))]), + ]; + for (id, set) in cases { + let a = run(id, set, im.clone(), &masks); + let b = run(id, set, im.clone(), &masks); + assert_eq!(a.img, b.img, "{id}"); + assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}"); + assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing"); + } +} + +#[test] +fn blobbylize_defaults_match_ae() { + let s = crate::find("ec.distort.ccblobbylize").unwrap(); + let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone(); + assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red"); + assert_eq!(default("softness"), num(20.0)); + assert_eq!(default("cutAway"), num(25.0)); + assert_eq!(default("lightHeight"), num(65.0)); + assert_eq!(default("lightPosition"), pt(0.5, 0.5)); + assert_eq!(default("ambient"), num(75.0)); + assert_eq!(default("diffuse"), num(25.0)); + assert_eq!(default("specular"), num(100.0)); + assert_eq!(default("roughness"), num(0.025)); + assert_eq!(default("metal"), num(100.0)); +} + +#[test] +fn blobbylize_cut_away_thresholds_a_height_ramp() { + // AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge. + let im = crate::util::gen_image(1920, 8, |x, _| { + let h = x as f32 / 1920.0; + [h, h, h, 1.0] + }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]); + assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent"); + assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque"); + let edge = o.img.get(480, 4)[3]; + assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold"); + let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count(); + assert!(partial <= 2, "{partial} antialiased columns instead of one"); +} + +#[test] +fn blobbylize_cut_away_100_is_empty() { + // Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100. + let im = crate::util::gen_image(1920, 8, |x, _| { + let h = x as f32 / 1920.0; + [h, h, h, 1.0] + }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); + assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels"); +} + +#[test] +fn blobbylize_cut_away_100_clears_an_opaque_edge() { + // An opaque source reaches h = 1 on the high side of an edge; the gradient antialias + // used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels. + let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); + assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge"); +} + +#[test] +fn blobbylize_softness_warps_a_step_matte_inward() { + // AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte + // edge at about x = 946, not at the source edge. The PR's matte-preserving version + // left it at 960; the effect must warp the source matte. + let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] }); + let o = run( + "ec.distort.ccblobbylize", + &[ + ("property", Value::Enum(3)), + ("softness", num(20.0)), + ("cutAway", num(0.0)), + ("ambient", num(100.0)), + ("diffuse", num(0.0)), + ("specular", num(0.0)), + ("metal", num(0.0)), + ], + im, + &[], + ); + assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]); + assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge"); + assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped"); +} + +/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized +/// (200 × 100) layer bounds, after `id` with `set`. +fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let mut im = Image::new(20, 10); + im.data.iter_mut().for_each(|p| *p = [1.0; 4]); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) +} + +/// Alpha at layer point `p` (0 outside the buffer). +fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { + let (x, y) = b.to_px(p); + b.img.get(x.floor() as i64, y.floor() as i64)[3] +} + +/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds +/// (comp-sized, as in After Effects), not cut off where the glyphs end. +#[test] +fn effects_on_text_reach_past_its_box() { + let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); + assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); + assert!(smear.img.width < 200, "it grows only as far as pixels move"); + let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); + assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); + assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); + assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); + // A footage layer's buffer is its bounds already: no change. + let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); + assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); +} + +#[test] +fn every_warp_style_runs_and_bends() { + let im = img(40, 30); + for s in 0..WARP_STYLES.len() as u32 { + for axis in 0..2 { + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as u32; + // The layer centre's neighbourhood is still made of source pixels. + assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}"); + assert!(o.img.width > im.width); + } + } + // Arc with positive bend raises the middle of the layer: the top edge in the middle + // moves up (opaque pixels above the original top). + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[1] as i64; + assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5); + assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5); +} + +#[test] +fn power_pin_moves_corners() { + let im = img(20, 20); + // Pin to the right half: output x in 10..20 shows the whole layer squeezed. + let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))]; + let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(o.img.get(3, 10)[3], 0.0); + let px = o.img.get(15, 10); + assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); +} + +#[test] +fn power_pin_unstretch_expands_pinned_region() { + let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]); + let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))]; + let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count(); + let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]); + assert_eq!(opaque(&off), 96); + let mut set = inset.to_vec(); + set.push(("unstretch", Value::Bool(true))); + let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(opaque(&on), 24 * 16); + assert!(on.img.get(0, 0)[3] > 0.99); + // Default corners are the identity either way. + let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]); + assert!(maxd(&id.img, &im) < 1e-4); +} + +#[test] +fn split_opens_a_gap() { + let im = img(40, 30); + let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]); + assert_eq!(o.img.get(20, 15)[3], 0.0); + assert_eq!(o.img.get(2, 2), im.get(2, 2)); +} + +#[test] +fn slant_shears_from_floor() { + let im = img(20, 20); + let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as i64; + // Bottom row stays put; the top row shifts right by ~10 px. + assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05); + assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08); +} + +#[test] +fn reshape_moves_source_to_destination() { + // A bright square at the source mask; reshape to a shifted destination mask. + let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks); + assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15)); + assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); +} + +#[test] +fn reshape_correspondence_points_steer_the_match() { + // Matching outlines with a zero pair change nothing; a quarter-turn offset twists. + let im = img(40, 40); + let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]; + let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change"); + let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&twisted.img, &im) > 0.1); + // Fraction mapping. + assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9); + assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9); + assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9); +} + +#[test] +fn upscale_grows_about_centre() { + let im = img(20, 10); + let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]); + assert_eq!((o.img.width, o.img.height), (40, 20)); + // Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the + // centre (10,5) lands in the middle of the 40×20 buffer. + assert_eq!(o.offset, [10.0, 5.0]); + let c = o.img.sample_bilinear(20.0, 10.0); + assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03); +} + +#[test] +fn twirl_legacy_rotates_inside_radius() { + let im = img(40, 40); + let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) > 0.1); + // Corners lie outside the radius. + assert_eq!(o.img.get(0, 0), im.get(0, 0)); +} + +#[test] +fn half_resolution_matches_scaled_geometry() { + // Bender at scale 0.5: the buffer is half size, parameters in layer pixels. + let s = crate::find("ec.distort.ccbender").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("amount".into(), num(30.0)); + params.values.insert("top".into(), pt(20.0, 0.0)); + params.values.insert("base".into(), pt(20.0, 40.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() }; + let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 }); + let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 }); + let a = full.img.sample_bilinear(20.0, 4.0)[3]; + let b = half.img.sample_bilinear(10.0, 2.0)[3]; + assert!((a - b).abs() < 0.3); +} diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index fd5a54123..637cc428e 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px { } } +/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point +/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100 +/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error +/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0. +pub fn four_color_softening(blend: f64, scale: f64) -> f64 { + let b = blend.max(0.0) * scale; + (b * b).max(1e-6) +} + fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect(); - let blend = ctx.params.f("blend").max(1.0); + let k = four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); @@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let mut wsum = 0.0; for ((qx, qy), c) in &pts { let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2); - let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5); + let w = 1.0 / (d2 + k); for i in 0..3 { acc[i] += c[i] as f64 * w; } @@ -423,6 +432,59 @@ mod generator_tests { } } + /// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 % + /// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10 + /// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5). + #[test] + fn four_color_blend_matches_after_effects() { + let pts = [ + ("positionsColors/point1", [19.2, 10.8]), + ("positionsColors/point2", [172.8, 10.8]), + ("positionsColors/point3", [19.2, 97.2]), + ("positionsColors/point4", [172.8, 97.2]), + ]; + let cols = [ + ("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]), + ("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]), + ("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]), + ("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]), + ]; + let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [ + ( + 10.0, + [ + (30, 20, [249.0, 240.0, 15.0]), + (150, 30, [20.0, 208.0, 47.0]), + (60, 90, [213.0, 56.0, 199.0]), + (100, 10, [115.0, 177.0, 78.0]), + (10, 50, [238.0, 145.0, 110.0]), + ], + ), + ( + 30.0, + [ + (30, 20, [236.0, 214.0, 41.0]), + (150, 30, [30.0, 189.0, 66.0]), + (60, 90, [203.0, 67.0, 188.0]), + (100, 10, [117.0, 173.0, 82.0]), + (10, 50, [233.0, 140.0, 115.0]), + ], + ), + ]; + for (blend, samples) in ae { + let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect(); + vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v)))); + vals.push(("blend", num(blend))); + let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()); + for (x, y, want) in samples { + let p = o.img.get(x, y); + for c in 0..3 { + assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]); + } + } + } + } + #[test] fn four_color_none_stays_inside_the_layer_alpha() { // #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box. diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 80d32ce0e..62bce1706 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [ "Tubular", ]; -/// Shaping used for Cell Pattern option `i` (see [`cell_value`]). +/// Shaping used for Cell Pattern option `i` (see `cell_value`). pub fn pattern_kind(i: u32) -> u32 { match i { 0..=5 => i, @@ -1223,315 +1223,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::Params; - use effectcraft_raster::Image; - - fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { - let s = crate::find(id).unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - let ls = [img.width as f64, img.height as f64]; - for p in &s.params { - if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { - params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); - } - } - for (k, v) in set { - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) - } - - #[test] - fn cell_pattern_is_deterministic_and_seeded() { - let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); - let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); - assert_eq!(a.img, b2.img); - let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30)); - assert_ne!(a.img, c.img); - assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0]))); - } - - #[test] - fn ellipse_ring_coverage() { - let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64)); - // Ring passes through (32 + 20, 32); centre is empty. - assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32)); - assert!(o.img.get(32, 32)[3] < 1e-3); - assert!(o.img.get(2, 2)[3] < 1e-3); - } - - #[test] - fn lightning_is_deterministic_per_conductivity() { - let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - assert_eq!(a.img, b2.img); - assert!(a.img.data.iter().any(|p| p[3] > 0.5)); - let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48)); - assert_ne!(a.img, c.img); - } - - #[test] - fn beam_draws_between_points() { - let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32)); - assert!(o.img.get(32, 16)[3] > 0.99); - assert!(o.img.get(32, 2)[3] < 1e-3); - assert!(o.img.get(2, 16)[3] < 1e-3); - } - - #[test] - fn cell_pattern_tiles() { - // 4×4-cell tiles of 8 px: the pattern repeats every 32 px. - let set = [ - ("size", num(8.0)), - ("tilingOptions/enableTiling", Value::Bool(true)), - ("tilingOptions/cellsHorizontal", num(4.0)), - ("tilingOptions/cellsVertical", num(4.0)), - ]; - let o = run("ec.generate.cellpattern", &set, Image::new(64, 64)); - for (x, y) in [(3, 5), (10, 20), (17, 2)] { - let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32)); - assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}"); - } - } - - #[test] - fn radio_waves_expansion_velocity_and_star() { - let r = |set: &[(&str, Value)], t: f64| { - let s = crate::find("ec.generate.radiowaves").unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); - for (k, v) in set { - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 }) - }; - // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px. - let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))]; - let o = r(&base, 0.2); - assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32)); - assert!(o.img.get(32, 32)[3] < 0.01); - // Velocity moves the wave toward Direction (90° = right). - let mut moving = base.to_vec(); - moving.push(("waveMotion/velocity", num(50.0))); - let o = r(&moving, 0.2); - assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32)); - // A 4-point star: the outline dips toward the centre between the points. - let mut star = base.to_vec(); - star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]); - let o = r(&star, 0.2); - assert!(o.img.get(32, 12)[3] > 0.9, "point straight up"); - // Between points (45°) the outline sits near half the radius. - assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24)); - assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); - } - - #[test] - fn lightning_alpha_obstacle_and_expert_settings() { - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); - // A wall of opaque pixels across the middle of the bolt's path. - let mut wall = Image::new(64, 48); - for y in 0..48 { - for x in 0..64 { - if (18..30).contains(&y) && x < 50 { - wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); - } - } - } - let opts = |o: f64| { - vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))] - }; - let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); - let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); - assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); - // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. - let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - for (id, v) in [ - ("expertSettings/fractalType", Value::Enum(2)), - ("expertSettings/coreDrain", num(100.0)), - ("expertSettings/forkStrength", num(100.0)), - ("expertSettings/forkVariation", num(100.0)), - ] { - assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); - } - let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); - assert!(sum(&drained.img) < sum(&base.img)); - let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); - let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); - assert_eq!(no_forks.img, term.img, "no forks either way"); - assert!(sum(&no_forks.img) < sum(&base.img)); - } - - #[test] - fn lightning_types_and_decay_main_core() { - let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48)); - assert_eq!(a.img, strike.img, "Strike is the default"); - for t in 0..8 { - let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48)); - assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws"); - } - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48)); - let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48)); - assert!(sum(&d.img) < sum(&n.img)); - } - - #[test] - fn beam_3d_perspective_controls_thickness() { - // Short beam near the start: with 3D Perspective its thickness comes from its position - // (thin, near the start); without, its head takes the full ending thickness. - let set = |p: bool| { - vec![ - ("length", num(20.0)), - ("time", num(0.0)), - ("softness", num(0.0)), - ("startThickness", num(2.0)), - ("endThickness", num(20.0)), - ("perspective3d", Value::Bool(p)), - ] - }; - let on = run("ec.generate.beam", &set(true), Image::new(64, 32)); - let off = run("ec.generate.beam", &set(false), Image::new(64, 32)); - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img)); - } - - #[test] - fn lens_flare_brightest_at_center() { - let o = run("ec.generate.lensflare", &[], Image::new(64, 64)); - let c = o.img.get(16, 16)[0]; - assert!(c > o.img.get(60, 4)[0]); - assert!(c > 0.5); - } - - fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { - let mut v: Vec<(&str, Value)> = - vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; - v.extend_from_slice(set); - crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img - } - - #[test] - fn signed_distance_of_a_square() { - let (w, h) = (20, 20); - let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); - let d = crate::util::signed_distance(&inside, w, h); - assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); - assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); - assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); - } - - #[test] - fn radio_waves_mask_contours_profile_reflection_and_birth() { - // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. - let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; - let masks = [sq]; - let env = crate::EffectEnv { masks: &masks, ..Default::default() }; - let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); - assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); - assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); - // Rounded corner: diagonal distance 10 from (37, 37). - assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); - // No mask chosen: nothing drawn. - assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); - // Image Contours: the bright square of a source layer. - struct Src; - impl crate::EffectHost for Src { - fn layer(&self, _: u64, _: bool) -> Option { - let mut img = Image::new(64, 64); - for y in 27..37 { - for x in 27..37 { - img.set(x, y, [1.0, 1.0, 1.0, 1.0]); - } - } - Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn params_at(&self, t: f64) -> Option { - // Colour animates from red (born at 0) to green (born later). - let s = crate::find("ec.generate.radiowaves").unwrap(); - let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); - p.values.insert("waveMotion/frequency".into(), num(1.0)); - p.values.insert("waveMotion/expansion".into(), num(20.0)); - p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); - Some(p) - } - } - let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; - let ic = rw( - &[ - ("waveType", Value::Enum(1)), - ("imageContour/sourceLayer", Value::Layer(Some(1))), - ("imageContour/preBlur", num(0.0)), - ("imageContour/tolerance", num(0.0)), - ], - 0.1, - env, - ); - assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); - // Source Center off the square shifts the contour onto the producer point accordingly. - let sh = rw( - &[ - ("waveType", Value::Enum(1)), - ("imageContour/sourceLayer", Value::Layer(Some(1))), - ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), - ("imageContour/preBlur", num(0.0)), - ("imageContour/tolerance", num(0.0)), - ], - 0.1, - env, - ); - assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); - // Profile: Taper Both thins the stroke edges; Square is solid across. - let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; - let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); - let mut tap = wide.to_vec(); - tap.push(("waveStroke/profile", Value::Enum(3))); - let tap = rw(&tap, 0.2, crate::EffectEnv::default()); - assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); - assert!(tap.get(52, 32)[3] > 0.8); - // Reflection: a fast wave bounces back off the right edge. - let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; - let gone = rw(&fast, 0.3, crate::EffectEnv::default()); - let mut rf = fast.to_vec(); - rf.push(("waveMotion/reflection", Value::Bool(true))); - let back = rw(&rf, 0.3, crate::EffectEnv::default()); - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); - // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. - let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); - let (old, new) = (both.get(55, 32), both.get(35, 32)); - assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); - assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); - } - - #[test] - fn cell_pattern_hq_variants_antialias() { - let s = crate::find("ec.generate.cellpattern").unwrap(); - let r = |pat: u32| { - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("cellPattern".into(), Value::Enum(pat)); - params.values.insert("size".into(), num(12.0)); - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img - }; - let distinct = |img: &Image| { - let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); - v.sort_unstable(); - v.dedup(); - v.len() - }; - let (std, hq) = (r(4), r(9)); - assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); - assert_ne!(std, hq); - assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); - } -} +mod tests; diff --git a/crates/effects/src/generate2/tests.rs b/crates/effects/src/generate2/tests.rs new file mode 100644 index 000000000..c2acff446 --- /dev/null +++ b/crates/effects/src/generate2/tests.rs @@ -0,0 +1,309 @@ +use super::*; +use crate::Params; +use effectcraft_raster::Image; + +fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + let ls = [img.width as f64, img.height as f64]; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) +} + +#[test] +fn cell_pattern_is_deterministic_and_seeded() { + let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + assert_eq!(a.img, b2.img); + let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30)); + assert_ne!(a.img, c.img); + assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0]))); +} + +#[test] +fn ellipse_ring_coverage() { + let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64)); + // Ring passes through (32 + 20, 32); centre is empty. + assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32)); + assert!(o.img.get(32, 32)[3] < 1e-3); + assert!(o.img.get(2, 2)[3] < 1e-3); +} + +#[test] +fn lightning_is_deterministic_per_conductivity() { + let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + assert_eq!(a.img, b2.img); + assert!(a.img.data.iter().any(|p| p[3] > 0.5)); + let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48)); + assert_ne!(a.img, c.img); +} + +#[test] +fn beam_draws_between_points() { + let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32)); + assert!(o.img.get(32, 16)[3] > 0.99); + assert!(o.img.get(32, 2)[3] < 1e-3); + assert!(o.img.get(2, 16)[3] < 1e-3); +} + +#[test] +fn cell_pattern_tiles() { + // 4×4-cell tiles of 8 px: the pattern repeats every 32 px. + let set = [ + ("size", num(8.0)), + ("tilingOptions/enableTiling", Value::Bool(true)), + ("tilingOptions/cellsHorizontal", num(4.0)), + ("tilingOptions/cellsVertical", num(4.0)), + ]; + let o = run("ec.generate.cellpattern", &set, Image::new(64, 64)); + for (x, y) in [(3, 5), (10, 20), (17, 2)] { + let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32)); + assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}"); + } +} + +#[test] +fn radio_waves_expansion_velocity_and_star() { + let r = |set: &[(&str, Value)], t: f64| { + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 }) + }; + // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px. + let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))]; + let o = r(&base, 0.2); + assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32)); + assert!(o.img.get(32, 32)[3] < 0.01); + // Velocity moves the wave toward Direction (90° = right). + let mut moving = base.to_vec(); + moving.push(("waveMotion/velocity", num(50.0))); + let o = r(&moving, 0.2); + assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32)); + // A 4-point star: the outline dips toward the centre between the points. + let mut star = base.to_vec(); + star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]); + let o = r(&star, 0.2); + assert!(o.img.get(32, 12)[3] > 0.9, "point straight up"); + // Between points (45°) the outline sits near half the radius. + assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24)); + assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); +} + +#[test] +fn lightning_alpha_obstacle_and_expert_settings() { + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); + // A wall of opaque pixels across the middle of the bolt's path. + let mut wall = Image::new(64, 48); + for y in 0..48 { + for x in 0..64 { + if (18..30).contains(&y) && x < 50 { + wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + } + let opts = + |o: f64| vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))]; + let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); + let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); + assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); + // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. + let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + for (id, v) in [ + ("expertSettings/fractalType", Value::Enum(2)), + ("expertSettings/coreDrain", num(100.0)), + ("expertSettings/forkStrength", num(100.0)), + ("expertSettings/forkVariation", num(100.0)), + ] { + assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); + } + let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); + assert!(sum(&drained.img) < sum(&base.img)); + let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); + let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); + assert_eq!(no_forks.img, term.img, "no forks either way"); + assert!(sum(&no_forks.img) < sum(&base.img)); +} + +#[test] +fn lightning_types_and_decay_main_core() { + let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48)); + assert_eq!(a.img, strike.img, "Strike is the default"); + for t in 0..8 { + let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48)); + assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws"); + } + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48)); + let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48)); + assert!(sum(&d.img) < sum(&n.img)); +} + +#[test] +fn beam_3d_perspective_controls_thickness() { + // Short beam near the start: with 3D Perspective its thickness comes from its position + // (thin, near the start); without, its head takes the full ending thickness. + let set = |p: bool| { + vec![ + ("length", num(20.0)), + ("time", num(0.0)), + ("softness", num(0.0)), + ("startThickness", num(2.0)), + ("endThickness", num(20.0)), + ("perspective3d", Value::Bool(p)), + ] + }; + let on = run("ec.generate.beam", &set(true), Image::new(64, 32)); + let off = run("ec.generate.beam", &set(false), Image::new(64, 32)); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img)); +} + +#[test] +fn lens_flare_brightest_at_center() { + let o = run("ec.generate.lensflare", &[], Image::new(64, 64)); + let c = o.img.get(16, 16)[0]; + assert!(c > o.img.get(60, 4)[0]); + assert!(c > 0.5); +} + +fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { + let mut v: Vec<(&str, Value)> = + vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; + v.extend_from_slice(set); + crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img +} + +#[test] +fn signed_distance_of_a_square() { + let (w, h) = (20, 20); + let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); + let d = crate::util::signed_distance(&inside, w, h); + assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); + assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); + assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); +} + +#[test] +fn radio_waves_mask_contours_profile_reflection_and_birth() { + // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. + let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; + let masks = [sq]; + let env = crate::EffectEnv { masks: &masks, ..Default::default() }; + let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); + assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); + assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); + // Rounded corner: diagonal distance 10 from (37, 37). + assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); + // No mask chosen: nothing drawn. + assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); + // Image Contours: the bright square of a source layer. + struct Src; + impl crate::EffectHost for Src { + fn layer(&self, _: u64, _: bool) -> Option { + let mut img = Image::new(64, 64); + for y in 27..37 { + for x in 27..37 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + // Colour animates from red (born at 0) to green (born later). + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + p.values.insert("waveMotion/frequency".into(), num(1.0)); + p.values.insert("waveMotion/expansion".into(), num(20.0)); + p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); + Some(p) + } + } + let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; + let ic = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); + // Source Center off the square shifts the contour onto the producer point accordingly. + let sh = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); + // Profile: Taper Both thins the stroke edges; Square is solid across. + let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; + let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); + let mut tap = wide.to_vec(); + tap.push(("waveStroke/profile", Value::Enum(3))); + let tap = rw(&tap, 0.2, crate::EffectEnv::default()); + assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); + assert!(tap.get(52, 32)[3] > 0.8); + // Reflection: a fast wave bounces back off the right edge. + let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; + let gone = rw(&fast, 0.3, crate::EffectEnv::default()); + let mut rf = fast.to_vec(); + rf.push(("waveMotion/reflection", Value::Bool(true))); + let back = rw(&rf, 0.3, crate::EffectEnv::default()); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); + // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. + let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); + let (old, new) = (both.get(55, 32), both.get(35, 32)); + assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); + assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); +} + +#[test] +fn cell_pattern_hq_variants_antialias() { + let s = crate::find("ec.generate.cellpattern").unwrap(); + let r = |pat: u32| { + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("cellPattern".into(), Value::Enum(pat)); + params.values.insert("size".into(), num(12.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img + }; + let distinct = |img: &Image| { + let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); + v.sort_unstable(); + v.dedup(); + v.len() + }; + let (std, hq) = (r(4), r(9)); + assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); + assert_ne!(std, hq); + assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); +} diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 6beec4cb7..a61c3f769 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -17,2430 +17,26 @@ use rayon::prelude::*; use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider}; -fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { - EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } -} - -fn pt(x: f64, y: f64) -> Value { - Value::Vec2([x, y]) -} - -fn pct() -> ParamUi { - slider(0.0, 100.0, 0.0, 100.0, 1) -} - -// ---------------------------------------------------------------- shared rasterisation - -/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`. -#[derive(Clone, Copy, Debug)] -pub struct Seg { - pub a: [f64; 2], - pub b: [f64; 2], - pub r: f64, - pub v: f32, -} - -/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction -/// of the radius that is fully opaque; the rest falls off smoothly. -pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane { - let mut out = Plane::new(w, h); - if w == 0 || h == 0 || segs.is_empty() { - return out; - } - let mut buckets: Vec> = vec![Vec::new(); h]; - for (i, s) in segs.iter().enumerate() { - let pad = s.r + 1.0; - let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize; - let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64); - if y1 <= 0.0 || y0 >= h { - continue; - } - for row in buckets.iter_mut().take(y1 as usize).skip(y0) { - row.push(i as u32); - } - } - let hard = hardness.clamp(0.0, 1.0); - out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let py = y as f64 + 0.5; - for &i in &buckets[y] { - let s = segs[i as usize]; - let pad = s.r + 1.0; - let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize; - let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w); - let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]); - let l2 = dx * dx + dy * dy; - let inner = s.r * hard - 0.5; - let outer = s.r + 0.5; - for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) { - let px = x as f64 + 0.5; - let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt(); - if d >= outer { - continue; - } - let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v; - if c > *o { - *o = c; - } - } - } - }); - out -} - -/// Segments along a polyline (buffer px). -pub fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec) { - let n = pts.len(); - if n == 1 { - out.push(Seg { a: pts[0], b: pts[0], r, v }); - } - if n < 2 { - return; - } - let segs = if closed { n } else { n - 1 }; - for i in 0..segs { - out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v }); - } -} - -/// Portion `[s0, s1]` (arc length) of a polyline. -pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> { - let n = pts.len(); - let mut out = Vec::new(); - if n < 2 || s1 <= s0 { - return out; - } - let segs = if closed { n } else { n - 1 }; - let mut acc = 0.0; - for i in 0..segs { - let (a, b) = (pts[i], pts[(i + 1) % n]); - let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); - let (e0, e1) = (acc, acc + l); - acc = e1; - if e1 < s0 || e0 > s1 || l <= 0.0 { - continue; - } - let at = |s: f64| { - let t = ((s - e0) / l).clamp(0.0, 1.0); - [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] - }; - let p0 = at(s0.max(e0)); - if out.last() != Some(&p0) { - out.push(p0); - } - out.push(at(s1.min(e1))); - } - out -} - -/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal -/// Original Image. -pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) { - img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { - let a = (c * opacity * color[3]).clamp(0.0, 1.0); - let s = [color[0] * a, color[1] * a, color[2] * a, a]; - *px = match style { - 1 => s, - 2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a], - _ => over(*px, s), - }; - }); -} - -/// Coverage of a plan-drawn generator: round-capped segments rasterised by [`raster_segs`] -/// with `hardness`, or the union of convex polygons ([`raster_convex`]). -#[derive(Clone, Debug)] -pub enum Coverage { - Segs { segs: Vec, hardness: f64 }, - Convex(Vec>), -} - -impl Coverage { - fn raster(&self, w: usize, h: usize) -> Plane { - match self { - Coverage::Segs { segs, hardness } => raster_segs(w, h, segs, *hardness), - Coverage::Convex(polys) => raster_convex(w, h, polys), - } - } -} - -/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu -/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as [`paint`] (On Original -/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under). -#[derive(Clone, Debug)] -pub struct PaintPlan { - pub cov: Coverage, - pub color: [f32; 4], - pub opacity: f32, - pub style: u32, -} - -/// Draw a [`PaintPlan`]. -fn draw_paint(b: &mut Buf, plan: &PaintPlan) { - let cov = plan.cov.raster(b.img.width as usize, b.img.height as usize); - if plan.style == 3 { - let color = plan.color; - b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { - let a = c * color[3]; - *px = over([color[0] * a, color[1] * a, color[2] * a, a], *px); - }); - } else { - paint(&mut b.img, &cov, plan.color, plan.opacity, plan.style); - } -} - -/// Generators drawn from a plan that need the layer's own pixels (`b.img`) to build it. -pub fn plan_reads_pixels(id: &str, ctx: &EffectCtx) -> bool { - match id { - // Image Contours read the layer itself when no (resolvable) Input Layer is chosen. - "ec.generate.vegas" => ctx.params.e("stroke") == 0, - "ec.generate.paintbucket" | "ec.generate.eyedropperfill" => true, - "ec.generate.advancedlightning" => ctx.params.f("alphaObstacle").clamp(-10.0, 10.0) != 0.0, - _ => false, - } -} - -/// [`PaintPlan`] of Stroke, Scribble, Vegas or Write-on on `b`'s geometry (`b.img` is read -/// only when [`plan_reads_pixels`]). `None`: Scribble without a usable mask (the layer is -/// cleared when it composites On Transparent, else kept). -pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.generate.stroke" => Some(stroke_plan(ctx, b)), - "ec.generate.scribble" => scribble_plan(ctx, b), - "ec.generate.vegas" => Some(vegas_plan(ctx, b)), - "ec.generate.writeon" => Some(write_on_plan(ctx, b)), - _ => None, - } -} - -/// Scribble's Composite as a [`paint`] style. -pub fn scribble_style(ctx: &EffectCtx) -> u32 { - match ctx.params.e("composite") { - 0 => 0, - 2 => 2, - _ => 1, - } -} - -#[inline] -fn over(dst: Px, src: Px) -> Px { - let k = 1.0 - src[3]; - [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] -} - -/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels. -pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> { - let m = ctx.env.masks.get(idx.checked_sub(1)?)?; - if m.points.is_empty() { - return None; - } - Some((m.points.iter().map(|&q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed, m.inverted)) -} - -/// All masks mapped to buffer pixels. -fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> { - (1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect() -} - -/// Chaikin corner cutting (open polyline, endpoints kept). -fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { - let mut cur = pts.to_vec(); - for _ in 0..iters { - if cur.len() < 3 { - return cur; - } - let mut nx = Vec::with_capacity(cur.len() * 2); - nx.push(cur[0]); - for w in cur.windows(2) { - let (a, b) = (w[0], w[1]); - nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); - nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); - } - nx.push(*cur.last().unwrap_or(&[0.0; 2])); - cur = nx; - } - cur -} - -// ---------------------------------------------------------------- Fractal - -#[derive(Clone, Copy)] -struct FracSet { - /// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices). - over_julia: bool, - julia: bool, - inverse: bool, -} - -/// Smooth escape count, or `None` inside the set. -fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option { - let (mut x, mut y) = z0; - let lim2 = limit * limit; - for i in 0..max_iter { - let (nx, ny) = match power { - 3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y), - 4 => { - let (a, b) = (x * x - y * y, 2.0 * x * y); - (a * a - b * b, 2.0 * a * b) - } - _ => (x * x - y * y, 2.0 * x * y), - }; - x = nx + c.0; - y = ny + c.1; - let m2 = x * x + y * y; - if m2 > lim2 { - let lz = m2.ln() * 0.5; - let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln(); - return Some((i as f64 + 1.0 - nu).max(0.0)); - } - } - None -} - -/// Fractal Set Choice options. -pub(crate) const FRACTAL_SETS: [&str; 6] = - ["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"]; -/// Fractal Palette options. -pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"]; -/// Fractal Oversample Method options. -pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"]; - -fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let set_choice = pr.e("setChoice"); - let power = 2 + pr.e("equation").min(2); - let mc = (pr.f("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY")); - let mmag = pr.f("mandelbrot/mandelbrotMagnification"); - let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01); - let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY")); - let jmag = pr.f("julia/juliaMagnification"); - let jlim = pr.f("julia/juliaEscapeLimit").max(1.01); - let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY")); - let overlay = pr.b("fractalColor/overlay"); - let transparency = pr.b("fractalColor/transparency"); - let transparent_inside = transparency || overlay; - let palette = pr.e("fractalColor/palette"); - let hue = (pr.f("fractalColor/hue") / 360.0) as f32; - let steps = pr.f("fractalColor/cycleSteps").max(1.0); - let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0; - let edge = pr.b("fractalColor/edgeHighlight"); - let every = pr.e("highQualitySettings/samplingMethod") == 1; - let factor = pr.f("highQualitySettings/samplingFactor").round().clamp(1.0, 9.0) as usize; - let (w, h) = (b.img.width as usize, b.img.height as usize); - let lw = ctx.layer_size[0].max(1.0); - let lh = ctx.layer_size[1].max(1.0); - let inv = 1.0 / b.scale.max(1e-9); - let (off_x, off_y) = (b.offset[0], b.offset[1]); - // FRACTAL_SETS order. - let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse }; - let sets: (FracSet, Option) = match set_choice { - 1 => (set(false, true, false), None), - 2 => (set(false, false, true), None), - 3 => (set(false, true, true), None), - 4 => (set(true, false, false), None), - 5 => (set(true, true, false), None), - _ => (set(false, false, false), None), - }; - let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32; - let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option { - let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) }; - let span = 3.5 / 2f64.powf(mag); - let mut u = (lx - lw * 0.5) / lw * span + center.0; - let mut v = (ly - lh * 0.5) / lw * span + center.1; - if s.inverse { - let d = (u * u + v * v).max(1e-12); - u = u / d + post.0; - v = -v / d + post.1; - } - if s.julia { - escape((u, v), mc, power, lim, iter_for(mag)) - } else { - let z0 = if s.over_julia { jc } else { (0.0, 0.0) }; - escape(z0, (u, v), power, lim, iter_for(mag)) - } - }; - let eval = |lx: f64, ly: f64| -> Option { - let a = eval_set(sets.0, lx, ly); - match (a, sets.1) { - (None, Some(s2)) => eval_set(s2, lx, ly), - _ => a, - } - }; - let shade = |n: Option| -> Px { - // Solid Color: only the inside of the set is drawn (Transparency flips the sides). - if palette == 3 { - let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5); - return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] }; - } - let Some(n) = n else { - return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; - }; - let u = n / steps + cyc_off; - let band = (u.rem_euclid(1.0)) as f32; - let (r, g, bl) = match palette { - // Hue Wheel: the full hue circle at full saturation and brightness. - 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), - // Black And White: alternating bands. - 2 => { - let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 }; - (v, v, v) - } - // Lightness Gradient: black → hue → white, the hue moving 45° each cycle. - _ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 0.85, band.clamp(0.0, 1.0)), - }; - let mut c = [r, g, bl]; - if edge && n.fract() < 0.12 { - c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6]; - } - [c[0], c[1], c[2], 1.0] - }; - // First pass: one sample per pixel. - let mut first: Vec> = vec![None; w * h]; - first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { - for (x, v) in row.iter_mut().enumerate() { - *v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv); - } - }); - let src = if overlay { Some(b.img.clone()) } else { None }; - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let here = first[y * w + x]; - let needs_ss = factor > 1 - && (every || { - let mut diff = false; - for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] { - let (nx, ny) = (x as i64 + dx, y as i64 + dy); - if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { - continue; - } - let o = first[ny as usize * w + nx as usize]; - diff |= match (here, o) { - (Some(a), Some(c)) => (a - c).abs() > steps * 0.25, - (None, None) => false, - _ => true, - }; - } - diff - }); - let c = if needs_ss { - let mut acc = [0.0f32; 4]; - for sy in 0..factor { - for sx in 0..factor { - let fx = x as f64 + (sx as f64 + 0.5) / factor as f64; - let fy = y as f64 + (sy as f64 + 0.5) / factor as f64; - let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv)); - let a = s[3]; - for k in 0..3 { - acc[k] += s[k] * a; - } - acc[3] += a; - } - } - let n = (factor * factor) as f32; - [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n] - } else { - let s = shade(here); - [s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]] - }; - *px = match &src { - Some(o) => over(o.data[y * w + x], c), - None => c, - }; - } - }); - b -} - -// ---------------------------------------------------------------- Scribble - -/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. -#[allow(clippy::too_many_arguments)] -fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) { - out.clear(); - for (pts, _closed, inverted) in polys { - let m = pts.len(); - if m < 2 { - continue; - } - // Crossings: (t along d, |sin| of crossing angle). - let mut xs: Vec<(f64, f64)> = Vec::new(); - for i in 0..m { - let (a, b) = (pts[i], pts[(i + 1) % m]); - let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s); - if (na > 0.0) == (nb > 0.0) { - continue; - } - let t = na / (na - nb); - let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; - let (ex, ey) = (b[0] - a[0], b[1] - a[1]); - let el = (ex * ex + ey * ey).sqrt().max(1e-9); - let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2); - xs.push((q[0] * d[0] + q[1] * d[1], sin)); - } - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - // Signed area decides which side is "left". - let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum(); - let left_is_inside = area < 0.0; - let mode = match fill { - 4 => { - if left_is_inside { - 2 - } else { - 3 - } - } - 5 => { - if left_is_inside { - 3 - } else { - 2 - } - } - f => f, - }; - let mode = if *inverted && mode == 0 { 6 } else { mode }; - for (k, &(t, sin)) in xs.iter().enumerate() { - let entering = k % 2 == 0; - let l = ew / sin; - match mode { - 0 if entering && k + 1 < xs.len() => { - out.push((t, xs[k + 1].0)); - } - 1 => out.push((t - l * 0.5, t + l * 0.5)), - 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), - 3 => out.push(if entering { (t - l, t) } else { (t, t + l) }), - _ => {} - } - } - if mode == 6 { - // Inverted Inside: outside of the polygon within the bounds. - let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew; - let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew; - let mut cur = lo; - for pair in xs.chunks(2) { - out.push((cur, pair[0].0)); - cur = pair.get(1).map(|v| v.0).unwrap_or(hi); - } - out.push((cur, hi)); - } - } - out.sort_by(|a, b| a.0.total_cmp(&b.0)); - // Merge overlaps. - let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len()); - for &(a, b) in out.iter() { - if let Some(last) = merged.last_mut() - && a <= last.1 - { - last.1 = last.1.max(b); - continue; - } - merged.push((a, b)); - } - *out = merged; -} - -/// End Cap and Join options (Scribble's Edge Options). -const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"]; -const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"]; - -/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`: -/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the -/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends). -pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec> { - let disc = |c: [f64; 2]| -> Vec<[f64; 2]> { - (0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect() - }; - let mut out = Vec::new(); - for l in lines { - let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| { - if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) { - v.push(p); - } - v - }); - if pts.len() < 2 { - if let (Some(p), 0) = (pts.first(), cap) { - out.push(disc(*p)); - } - continue; - } - let dir = |a: [f64; 2], b: [f64; 2]| { - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let l = dx.hypot(dy).max(1e-12); - [dx / l, dy / l] - }; - let n = pts.len(); - for k in 0..n - 1 { - let (mut a, mut b) = (pts[k], pts[k + 1]); - let d = dir(a, b); - // Projecting caps extend the end segments by the half width. - if cap == 2 && k == 0 { - a = [a[0] - d[0] * r, a[1] - d[1] * r]; - } - if cap == 2 && k == n - 2 { - b = [b[0] + d[0] * r, b[1] + d[1] * r]; - } - let nn = [-d[1] * r, d[0] * r]; - out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]); - } - // Joins. - for k in 1..n - 1 { - let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1])); - let cross = d0[0] * d1[1] - d0[1] * d1[0]; - if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 { - continue; - } - if join == 0 { - out.push(disc(p)); - continue; - } - // The outer side of the turn. - let s = if cross > 0.0 { -1.0 } else { 1.0 }; - let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s]; - let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s]; - let bevel = vec![p, o0, o1]; - if join == 1 { - // Miter point: where the two offset edges meet. - let bis = [d0[0] - d1[0], d0[1] - d1[1]]; - let bl = bis[0].hypot(bis[1]); - let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt(); - let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY }; - if bl > 1e-9 && len <= miter_limit * r { - let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5]; - let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12); - let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len]; - out.push(vec![p, o0, tip, o1]); - continue; - } - } - out.push(bevel); - } - if cap == 0 { - out.push(disc(pts[0])); - out.push(disc(pts[n - 1])); - } - } - out -} - -/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid. -pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane { - let mut out = Plane::new(w, h); - let inside = |poly: &[[f64; 2]], x: f64, y: f64| { - let n = poly.len(); - let mut sign = 0.0; - for i in 0..n { - let (a, b) = (poly[i], poly[(i + 1) % n]); - let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); - if c.abs() < 1e-12 { - continue; - } - if sign == 0.0 { - sign = c.signum(); - } else if c.signum() != sign { - return false; - } - } - true - }; - for poly in polys { - if poly.len() < 3 { - continue; - } - let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - for p in poly { - x0 = x0.min(p[0]); - y0 = y0.min(p[1]); - x1 = x1.max(p[0]); - y1 = y1.max(p[1]); - } - let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w)); - let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h)); - for y in ya..yb { - for x in xa..xb { - let mut hit = 0; - for sy in 0..4 { - for sx in 0..4 { - if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) { - hit += 1; - } - } - } - let v = &mut out.data[y * w + x]; - *v = v.max(hit as f32 / 16.0); - } - } - } - out -} - -fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { - match scribble_plan(ctx, &b) { - Some(plan) => draw_paint(&mut b, &plan), - None if scribble_style(ctx) == 1 => b.img = Image::new(b.img.width, b.img.height), - None => {} - } - b -} - -fn scribble_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let which = pr.e("scribble"); - let polys: Vec<_> = if which == 0 { mask_px(ctx, b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, b) }; - let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect(); - let style = scribble_style(ctx); - if polys.is_empty() { - return None; - } - let sc = b.scale; - let fill = pr.e("fillType"); - let ew = pr.f("edgeOptions/edgeWidth").max(0.1) * sc; - let ang = pr.f("angle").to_radians(); - let d = [ang.cos(), ang.sin()]; - let n = [-d[1], d[0]]; - let width = (pr.f("strokeWidth") * sc).max(0.1); - let curv = pr.f("strokeOptions/curviness") / 100.0; - let curv_var = pr.f("strokeOptions/curvinessVariation") / 100.0; - let spacing = (pr.f("strokeOptions/spacing") * sc).max(0.75).max(width * 0.25); - let spacing_var = pr.f("strokeOptions/spacingVariation") / 100.0; - let overlap = pr.f("strokeOptions/pathOverlap") / 100.0; - let overlap_var = pr.f("strokeOptions/pathOverlapVariation") / 100.0; - let start = pr.f("start").clamp(0.0, 100.0) / 100.0; - let end = pr.f("end").clamp(0.0, 100.0) / 100.0; - let sequential = pr.b("fillPathsSequentially"); - let wiggle = pr.e("wiggleType"); - let wps = pr.f("wigglesPerSecond").max(0.0); - let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; - let (step, frac) = match wiggle { - 0 => (0u32, 0.0f32), - 1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0), - _ => { - let u = (ctx.time * wps).max(0.0); - (u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32)) - } - }; - let rnd = |i: u32, k: u32| { - let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0; - if frac > 0.0 { - let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0; - a + (c - a) * frac - } else { - a - } - }; - let groups: Vec, bool, bool)>> = - if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() }; - // Per group a zig-zag polyline (in buffer px). - let mut lines: Vec>> = Vec::new(); - let mut runs = Vec::new(); - for (gi, g) in groups.iter().enumerate() { - let mut nmin = f64::INFINITY; - let mut nmax = f64::NEG_INFINITY; - for (pts, _, _) in g { - for q in pts { - let v = q[0] * n[0] + q[1] * n[1]; - nmin = nmin.min(v); - nmax = nmax.max(v); - } - } - nmin -= ew; - nmax += ew; - // Chains of zig-zags: each run joins the nearest chain that ended on the previous - // scan line (so separate regions get separate strokes). - let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new(); - let mut s = nmin + spacing * 0.5; - let mut li = 0u32; - let reach = spacing * 3.0 + ew; - while s < nmax && li < 20_000 { - scan_runs(g, fill, ew, d, n, s, &mut runs); - for (ri, &(a, bb)) in runs.iter().enumerate() { - let k = (gi as u32) << 20 | ri as u32; - let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45); - let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45); - let (a, bb) = (a - ov, bb + ov2); - if bb <= a { - continue; - } - let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s]; - let (pa, pb) = (at(a), at(bb)); - let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt(); - let best = chains - .iter() - .enumerate() - .filter(|(_, c)| li > 0 && c.1 == li - 1) - .map(|(i, c)| { - let last = *c.0.last().unwrap_or(&pa); - (i, dist(last, pa).min(dist(last, pb))) - }) - .filter(|&(_, dd)| dd < reach) - .min_by(|x, y| x.1.total_cmp(&y.1)); - match best { - Some((ci, _)) => { - let last = *chains[ci].0.last().unwrap_or(&pa); - let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) }; - // Curved turn: an extra point pushed past the edge. - let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0); - let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing]; - let ch = &mut chains[ci]; - ch.0.extend([mid, p0, p1]); - ch.1 = li; - } - None => chains.push((vec![pa, pb], li)), - } - } - s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25); - li += 1; - } - lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect()); - } - let r = width * 0.5; - // Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own. - let each = pr.e("startEndApplyTo") == 1; - let mut parts: Vec> = Vec::new(); - for group in &lines { - let lens: Vec = group.iter().map(|l| poly_length(l, false)).collect(); - let total: f64 = lens.iter().sum(); - let (g0, g1) = (total * start, total * end); - let mut acc = 0.0; - for (l, len) in group.iter().zip(lens) { - let part = - if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) }; - acc += len; - parts.push(part); - } - } - let cap = pr.e("edgeOptions/endCap"); - let join = pr.e("edgeOptions/join"); - let cov = if cap == 0 && join == 0 { - let mut segs = Vec::new(); - for part in &parts { - poly_segs(part, false, r, 1.0, &mut segs); - } - Coverage::Segs { segs, hardness: 0.9 } - } else { - Coverage::Convex(stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0))) - }; - Some(PaintPlan { cov, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style }) -} - -// ---------------------------------------------------------------- Stroke - -fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = stroke_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -fn stroke_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - let pr = ctx.params; - let all = pr.b("allMasks"); - let polys: Vec<_> = if all { all_masks_px(ctx, b) } else { mask_px(ctx, b, pr.f("path").round() as usize).into_iter().collect() }; - let style = pr.e("paintStyle"); - let size = (pr.f("brushSize") * b.scale).max(0.1); - let r = size * 0.5; - let hard = pr.f("brushHardness") / 100.0; - let start = pr.f("start").clamp(0.0, 100.0) / 100.0; - let end = pr.f("end").clamp(0.0, 100.0) / 100.0; - let spacing = pr.f("spacing") / 100.0; - let sequential = pr.b("strokeSequentially"); - let lens: Vec = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect(); - let total: f64 = lens.iter().sum(); - let mut segs = Vec::new(); - let mut acc = 0.0; - for ((pts, closed, _), &len) in polys.iter().zip(&lens) { - let (s0, s1) = if all && sequential { - let (g0, g1) = (total * start, total * end); - ((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) - } else { - (len * start, len * end) - }; - acc += len; - if s1 <= s0 { - continue; - } - if spacing <= 0.5 { - let part = trim_poly(pts, *closed, s0, s1); - poly_segs(&part, false, r, 1.0, &mut segs); - } else { - let step = (size * spacing).max(0.5); - let mut s = s0; - while s <= s1 + 1e-9 && segs.len() < 200_000 { - let (q, _) = crate::util::poly_point_at(pts, *closed, s); - segs.push(Seg { a: q, b: q, r, v: 1.0 }); - s += step; - } - } - } - PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style } -} - -// ---------------------------------------------------------------- Vegas - -/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag). -fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> { - use std::collections::HashMap; - let (w, h) = (pl.w, pl.h); - if w < 2 || h < 2 { - return Vec::new(); - } - // Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1. - let hk = |x: usize, y: usize| 2 * (y * w + x) as u64; - let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1; - let mut pos: HashMap = HashMap::new(); - let mut adj: HashMap> = HashMap::new(); - let v = |x: usize, y: usize| pl.data[y * w + x]; - let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 }; - for y in 0..h - 1 { - for x in 0..w - 1 { - let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1)); - let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3; - if idx == 0 || idx == 15 { - continue; - } - // Edge points (pixel centres at +0.5). - let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]); - let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]); - let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]); - let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]); - let pairs: &[(usize, usize)] = match idx { - 1 | 14 => &[(3, 0)], - 2 | 13 => &[(0, 1)], - 3 | 12 => &[(3, 1)], - 4 | 11 => &[(1, 2)], - 5 => &[(3, 0), (1, 2)], - 6 | 9 => &[(0, 2)], - 7 | 8 => &[(3, 2)], - 10 => &[(0, 1), (2, 3)], - _ => &[], - }; - let e = [top, right, bottom, left]; - for &(i, j) in pairs { - pos.insert(e[i].0, e[i].1); - pos.insert(e[j].0, e[j].1); - adj.entry(e[i].0).or_default().push(e[j].0); - adj.entry(e[j].0).or_default().push(e[i].0); - } - } - } - let mut keys: Vec = adj.keys().copied().collect(); - keys.sort_unstable(); - // Start open chains at endpoints (degree 1) first, then closed loops. - keys.sort_by_key(|k| adj[k].len() != 1); - let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new(); - let mut out = Vec::new(); - for &k0 in &keys { - for &first in &adj[&k0] { - if used.contains(&(k0.min(first), k0.max(first))) { - continue; - } - let mut chain = vec![pos[&k0]]; - let (mut prev, mut cur) = (k0, first); - used.insert((prev.min(cur), prev.max(cur))); - let mut closed = false; - loop { - chain.push(pos[&cur]); - if cur == k0 { - closed = true; - chain.pop(); - break; - } - let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n)))); - match next { - Some(n) => { - used.insert((cur.min(n), cur.max(n))); - prev = cur; - cur = n; - } - None => break, - } - } - if chain.len() >= 2 { - out.push((chain, closed)); - } - } - } - out -} - -fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = vegas_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - let pr = ctx.params; - let (w, h) = (b.img.width as usize, b.img.height as usize); - let sc = b.scale; - let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 { - mask_px(ctx, b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default() - } else { - let src = layer_or_self(ctx, b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1); - let ch = pr.e("channel"); - let inv = pr.b("invertInput"); - let mut pl = Plane::from_image(&src, |px| { - let (c, a) = unpremul(px); - let v = match ch { - 1 => c[0], - 2 => c[1], - 3 => c[2], - 4 => a, - _ => luminance(c[0], c[1], c[2]) * a, - }; - if inv { 1.0 - v } else { v } - }); - let blur = pr.f("preBlur") * sc; - if blur > 0.05 { - pl = gauss_plane(&pl, blur, blur); - } - let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32); - // Simplify by tolerance (drop points closer than tol × size). - let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1; - if tol > 0.0 { - for (c, _) in cs.iter_mut() { - let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len()); - for &q in c.iter() { - if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) { - keep.push(q); - } - } - *c = keep; - } - } - cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1))); - if pr.e("renderMode") == 1 { - let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1; - cs.into_iter().skip(sel).take(1).collect() - } else { - cs - } - }; - let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize; - // Shorter Contours Have Fewer Segments: segment count scales with contour length. - let fewer = pr.e("stroke") == 0 && pr.e("shorterContoursHave") == 1; - let longest = paths.iter().map(|(p, c)| poly_length(p, *c)).fold(0.0, f64::max); - let seg_len = pr.f("length").clamp(0.0, 1.0); - let bunched = pr.e("segmentDistribution") == 0; - let rot = pr.f("rotation") / 360.0; - let random_phase = pr.b("randomPhase"); - let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; - let width = (pr.f("width") * sc).max(0.1); - let hard = pr.f("hardness").clamp(0.0, 1.0); - let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99)); - let mut segs = Vec::new(); - for (ci, (pts, closed)) in paths.iter().enumerate() { - let len = poly_length(pts, *closed); - if len <= 0.0 { - continue; - } - let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 }; - let nseg = if fewer && longest > 0.0 { ((nseg as f64 * len / longest).round() as usize).max(1) } else { nseg }; - let each = len / nseg as f64 * seg_len; - for i in 0..nseg { - let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 }; - let s0 = (base + phase).rem_euclid(1.0) * len; - // Sub-sample the segment so opacity can ramp along it. - let sub = 12usize; - let mut prev: Option<[f64; 2]> = None; - for k in 0..=sub { - let f = k as f64 / sub as f64; - let mut s = s0 + each * f; - if *closed { - s = s.rem_euclid(len); - } else if s > len { - break; - } - let (q, _) = crate::util::poly_point_at(pts, *closed, s); - let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 }; - if let Some(pv) = prev - && ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0 - { - segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) }); - } - prev = Some(q); - } - } - } - let style = match pr.e("blendMode") { - 1 => 0, - 2 => 3, - 3 => 2, - _ => 1, - }; - PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: 1.0, style } -} - -// ---------------------------------------------------------------- Write-on / Glue Gun - -/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every -/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length -/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; -/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own -/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per -/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. -fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = write_on_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. -const WRITE_ON_MAX_DABS: i64 = 4096; - -fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - let pr = ctx.params; - let own_opacity = pr.e("paintTimeProperties") == 1; - let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); - let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; - let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); - let dab = |p: &Params| { - let q = b.to_px(p.v2("brushPosition")); - ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) - }; - let mut segs = vec![]; - let t = ctx.time; - let spacing = pr.f("brushSpacing").max(0.001); - let life = pr.f("strokeLength").max(0.0); - if let Some(host) = ctx.env.host - && t.is_finite() - && t >= 0.0 - { - let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; - let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; - let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; - let n = k1.saturating_sub(k0).saturating_add(1); - let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); - let mut prev: Option<[f64; 2]> = None; - let mut k = k0; - while k <= k1 { - let tk = k as f64 * spacing; - let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; - if let Some((q, r, v)) = at { - // Dabs laid further apart than the brush spacing are joined into a line. - let a = if stride > 1 { prev.unwrap_or(q) } else { q }; - segs.push(Seg { a, b: q, r, v }); - prev = Some(q); - } - k = k.saturating_add(stride); - } - } - if segs.is_empty() { - let (q, r, v) = dab(pr); - segs.push(Seg { a: q, b: q, r, v }); - } - PaintPlan { - cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, - color: pr.color("color"), - opacity: if own_opacity { 1.0 } else { opacity(pr) }, - style: pr.e("paintStyle"), - } -} - -fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let q = b.to_px(pr.v2("brushPosition")); - let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5); - let density = pr.f("density").max(0.0); - let refl = pr.f("reflection").to_radians(); - let strength = (pr.f("strength") / 100.0) as f32; - let glue_only = pr.e("style") == 1; - let src = b.img.clone(); - let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53]; - let beads = (density.round() as usize).clamp(1, 32); - let seed = ctx.seed; - let centers: Vec<([f64; 2], f64)> = (0..beads) - .map(|i| { - let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI; - let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 }; - ([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64)) - }) - .collect(); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); - // Height field: union of hemispheres. - let mut best = (0.0f64, [0.0f64; 2], 1.0f64); - for &(c, r) in ¢ers { - let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r); - let d2 = dx * dx + dy * dy; - if d2 < 1.0 { - let hgt = (1.0 - d2).sqrt(); - if hgt > best.0 { - best = (hgt, [dx, dy], r); - } - } - } - if best.0 <= 0.0 { - if glue_only { - *px = [0.0; 4]; - } - continue; - } - let nrm = [best.1[0], best.1[1], best.0]; - let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0); - let spec = ndl.powi(24) as f32; - // Refraction: look through the bead. - let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64); - let edge = smoothstep(0.0, 0.15, best.0 as f32); - let shade = 0.65 + 0.35 * ndl as f32; - let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)]; - let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)]; - let base = if glue_only { [0.0; 4] } else { *px }; - *px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3)); - px[3] = px[3].clamp(0.0, 1.0); - } - }); - b -} - -// ---------------------------------------------------------------- Paint Bucket - -/// Paint Bucket's filled region (1 inside) on `b`: the flood fill from the Fill Point over the -/// layer's pixels (shared with the GPU compositor, which reads the layer back for it). -pub fn paint_bucket_region(ctx: &EffectCtx, b: &Buf) -> Plane { - let pr = ctx.params; - let (w, h) = (b.img.width as usize, b.img.height as usize); - let fp = b.to_px(pr.v2("fillPoint")); - let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64); - let sel = pr.e("fillSelector"); - let tol = (pr.f("tolerance") / 255.0) as f32; - let key = |p: Px| -> [f32; 4] { - let (c, a) = unpremul(p); - match sel { - 1 => [c[0], c[1], c[2], 0.0], - 2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }], - 3 | 4 => [0.0, 0.0, 0.0, a], - _ => p, - } - }; - let mut region = Plane::new(w, h); - if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h { - let seed = key(b.img.data[sy as usize * w + sx as usize]); - let within = |i: usize| { - let k = key(b.img.data[i]); - if sel == 2 { - return k[3] == seed[3]; - } - (0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6) - }; - let mut seen = vec![false; w * h]; - let mut stack = vec![(sx as usize, sy as usize)]; - while let Some((x, y)) = stack.pop() { - let row = y * w; - if seen[row + x] || !within(row + x) { - continue; - } - let mut l = x; - while l > 0 && !seen[row + l - 1] && within(row + l - 1) { - l -= 1; - } - let mut r = x; - while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) { - r += 1; - } - for i in l..=r { - seen[row + i] = true; - region.data[row + i] = 1.0; - if y > 0 && !seen[row - w + i] { - stack.push((i, y - 1)); - } - if y + 1 < h && !seen[row + w + i] { - stack.push((i, y + 1)); - } - } - } - } - region -} - -fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let (w, h) = (b.img.width as usize, b.img.height as usize); - if w == 0 || h == 0 { - return b; - } - let region = paint_bucket_region(ctx, &b); - if pr.b("viewThreshold") { - b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]); - return b; - } - let sc = b.scale; - let mut cov = match pr.e("stroke") { - 1 => { - let s = pr.f("featherSoftness") * sc * 0.5; - if s > 0.05 { gauss_plane(®ion, s, s) } else { region.clone() } - } - 2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true), - 3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false), - 4 => { - let r = pr.f("strokeWidth") * sc; - let out = morph_frac(®ion, r * 0.5, true); - let inn = morph_frac(®ion, r * 0.5, false); - out.zip_map(&inn, |a, b| (a - b).max(0.0)) - } - _ => gauss_plane(®ion, 0.5, 0.5), - }; - if pr.e("stroke") == 0 { - // Antialias: keep the hard interior, soften only the boundary. - cov = cov.zip_map(®ion, |a, r| if r > 0.0 { a.max(0.5) } else { a }); - } - let color = pr.color("color"); - let op = (pr.f("opacity") / 100.0) as f32; - let mode = pr.e("blendingMode"); - b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { - let k = (c * op * color[3]).clamp(0.0, 1.0); - if k <= 0.0 { - if mode == 9 { - *px = [0.0; 4]; - } - return; - } - let (dc, da) = unpremul(*px); - let fc = [color[0], color[1], color[2]]; - let mixed = match mode { - 1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]], - 2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]], - 3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])], - 4..=7 => { - let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]); - let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]); - let (r, g, bl) = match mode { - 4 => hsl_to_rgb(fh, ds, dl), - 5 => hsl_to_rgb(dh, fs, dl), - 6 => hsl_to_rgb(fh, fs, dl), - _ => hsl_to_rgb(dh, ds, fl), - }; - [r, g, bl] - } - _ => fc, - }; - *px = match mode { - 8 => over(premul(fc, k), *px), - 9 => premul(fc, k), - 0 => over(*px, premul(fc, k)), - _ => { - if da <= 1e-6 { - return; - } - let c3 = crate::util::lerp3(dc, mixed, k); - premul(c3, da) - } - }; - }); - b -} - -// ---------------------------------------------------------------- Eyedropper Fill - -fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let fill = eyedropper_color(ctx, &b); - let keep_alpha = pr.b("maintainOriginalAlpha"); - let blend = (pr.f("blendWithOriginal") / 100.0) as f32; - b.img.data.par_iter_mut().for_each(|px| { - let a = if keep_alpha { px[3] } else { fill[3] }; - let f = premul([fill[0], fill[1], fill[2]], a); - *px = crate::util::lerp4(f, *px, blend); - }); - b -} - -/// Eyedropper Fill's sampled colour (straight RGB, alpha) from the layer's pixels (shared with -/// the GPU compositor, which reads the layer back for it). -pub fn eyedropper_color(ctx: &EffectCtx, b: &Buf) -> Px { - let pr = ctx.params; - let (w, h) = (b.img.width as i64, b.img.height as i64); - let c = b.to_px(pr.v2("samplePoint")); - let r = (pr.f("sampleRadius") * b.scale).max(0.0); - let mode = pr.e("averagePixelColors"); - let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64); - let ri = r.ceil() as i64; - let mut acc = [0.0f64; 4]; - let mut n = 0.0f64; - for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) { - for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) { - if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 { - continue; - } - let px = b.img.data[(y * w + x) as usize]; - match mode { - 0 => { - if px[3] > 1e-4 { - let (s, _) = unpremul(px); - for k in 0..3 { - acc[k] += s[k] as f64; - } - acc[3] += 1.0; - n += 1.0; - } - } - 1 => { - let (s, _) = unpremul(px); - for k in 0..3 { - acc[k] += s[k] as f64; - } - acc[3] += 1.0; - n += 1.0; - } - 2 => { - for k in 0..3 { - acc[k] += px[k] as f64; - } - acc[3] += 1.0; - n += 1.0; - } - _ => { - for k in 0..4 { - acc[k] += px[k] as f64; - } - n += 1.0; - } - } - } - } - if n > 0.0 { - if mode == 3 { - let a = (acc[3] / n) as f32; - let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] }; - [s[0], s[1], s[2], a] - } else { - [(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0] - } - } else { - [0.0, 0.0, 0.0, 0.0] - } -} - -// ---------------------------------------------------------------- CC Threads - -fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let sc = b.scale; - let tw = (pr.f("width") * sc).max(1.0); - let th = (pr.f("height") * sc).max(1.0); - let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64; - let ang = pr.f("direction").to_radians(); - let (sn, cs) = ang.sin_cos(); - let c = b.to_px(pr.v2("center")); - let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0); - let shadow = (pr.f("shadowing") / 100.0) as f32; - let texture = (pr.f("texture") / 100.0) as f32; - let src = b.img.clone(); - let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); - let u = dx * cs + dy * sn; - let v = -dx * sn + dy * cs; - let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64); - let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64); - // Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the - // band |fu-0.5| < coverage/2. - let in_h = (fv - 0.5).abs() < coverage * 0.5; - let in_v = (fu - 0.5).abs() < coverage * 0.5; - // Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps. - let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps; - let which = match (in_h, in_v) { - (true, true) => Some(h_top), - (true, false) => Some(true), - (false, true) => Some(false), - _ => None, - }; - let Some(horizontal) = which else { - *px = [0.0; 4]; - continue; - }; - // Thread colour: the source sampled along the thread centre line. - let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) }; - let s = src.sample_bilinear_clamped(sx, sy); - // Round profile across the thread + darkening where it dives under its neighbour. - let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) }; - let along = if horizontal { fu } else { fv }; - let profile = (1.0 - across * across).max(0.0).sqrt() as f32; - let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0; - let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 }; - let fib = - if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 }; - let k = (shade * fib).max(0.0); - *px = [s[0] * k, s[1] * k, s[2] * k, s[3]]; - } - }); - b -} - -// ---------------------------------------------------------------- Audio Spectrum / Waveform - -/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two). -pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) { - let n = re.len(); - if n < 2 { - return; - } - let mut j = 0; - for i in 1..n { - let mut bit = n >> 1; - while j & bit != 0 { - j ^= bit; - bit >>= 1; - } - j |= bit; - if i < j { - re.swap(i, j); - im.swap(i, j); - } - } - let mut len = 2; - while len <= n { - let ang = -2.0 * PI / len as f64; - let (ws, wc) = ang.sin_cos(); - for start in (0..n).step_by(len) { - let (mut cr, mut ci) = (1.0, 0.0); - for k in 0..len / 2 { - let (a, bb) = (start + k, start + k + len / 2); - let tr = re[bb] * cr - im[bb] * ci; - let ti = re[bb] * ci + im[bb] * cr; - re[bb] = re[a] - tr; - im[bb] = im[a] - ti; - re[a] += tr; - im[a] += ti; - let ncr = cr * wc - ci * ws; - ci = cr * ws + ci * wc; - cr = ncr; - } - } - len <<= 1; - } -} - -const AUDIO_RATE: u32 = 48_000; - -/// Fetch mono (or one channel) samples of the audio layer around the current time. -fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option> { - let id = ctx.params.get("audioLayer")?.as_layer()?; - let host = ctx.env.host?; - let dur = (dur_ms / 1000.0).max(0.001); - let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16); - let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5; - let s = host.audio(id, start, frames, AUDIO_RATE)?; - Some( - (0..frames) - .map(|i| { - let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64); - match channel { - 1 => l, - 2 => r, - _ => (l + r) * 0.5, - } - }) - .collect(), - ) -} - -/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at -/// parameter u∈[0,1] → (point, unit normal). -struct Baseline { - pts: Vec<[f64; 2]>, - closed: bool, - len: f64, - polar: bool, - center: [f64; 2], - radius: f64, -} - -impl Baseline { - fn new(ctx: &EffectCtx, b: &Buf) -> Baseline { - let pr = ctx.params; - let polar = pr.b("usePolarPath"); - let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) { - Some((p, c, _)) if !polar => (p, c), - _ => { - let s = b.to_px(pr.v2("startPoint")); - let e = b.to_px(pr.v2("endPoint")); - (vec![[s.0, s.1], [e.0, e.1]], false) - } - }; - let len = poly_length(&pts, closed); - let center = pts[0]; - let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 }; - Baseline { pts, closed, len, polar, center, radius } - } - fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) { - if self.polar { - let a = u * 2.0 * PI - PI * 0.5; - let (s, c) = a.sin_cos(); - return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]); - } - let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len); - (q, [d[1], -d[0]]) - } -} - -/// Draw line/dot marks with core (inside colour) and soft halo (outside colour). -fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) { - let (w, h) = (b.img.width as usize, b.img.height as usize); - let core = raster_segs(w, h, segs_core, 1.0); - let halo_segs: Vec = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect(); - let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() }; - b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let (c, hl) = (core.data[i], halo.data[i]); - let a = c.max(hl); - let base = if composite { *px } else { [0.0; 4] }; - if a <= 0.0 { - *px = base; - return; - } - let t = if a > 0.0 { c / a } else { 0.0 }; - let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t); - *px = over(base, premul(col, a)); - }); -} - -/// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`): -/// the marks (the audio analysis runs on the CPU) and how [`draw_marks`] colours them. -#[derive(Clone, Debug)] -pub struct MarksPlan { - pub segs: Vec, - pub thickness: f64, - pub softness: f64, - pub inside: [f32; 4], - pub outside: [f32; 4], - pub composite: bool, -} - -/// [`MarksPlan`] of Audio Spectrum or Audio Waveform on `b`'s geometry (`None` = no audio: -/// the layer passes through). -pub fn marks_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.generate.audiospectrum" => spectrum_plan(ctx, b), - "ec.generate.audiowaveform" => waveform_plan(ctx, b), - _ => None, - } -} - -fn marks(ctx: &EffectCtx, segs: Vec, thick: f64, soft: f64) -> MarksPlan { - let pr = ctx.params; - MarksPlan { - segs, - thickness: thick, - softness: soft * 2.0, - inside: pr.color("insideColor"), - outside: pr.color("outsideColor"), - composite: pr.b("compositeOnOriginal"), - } -} - -fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { - if let Some(m) = spectrum_plan(ctx, &b) { - draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); - } - b -} - -fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { - if let Some(m) = waveform_plan(ctx, &b) { - draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); - } - b -} - -fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec) { - let m = samples.len(); - let n = m.next_power_of_two().max(64); - let mut re = vec![0.0; n]; - let mut im = vec![0.0; n]; - let denom = (m.max(2) - 1) as f64; - for (i, s) in samples.iter().enumerate() { - let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos(); - if let Some(r) = re.get_mut(i) { - *r = s * w; - } - } - fft(&mut re, &mut im); - let hz_per_bin = AUDIO_RATE as f64 / n as f64; - let scale = 4.0 / m.max(1) as f64; - let num_bins = n / 2 + 1; - let mut mags = Vec::with_capacity(num_bins); - for k in 0..num_bins { - let r = re.get(k).copied().unwrap_or(0.0); - let im_val = im.get(k).copied().unwrap_or(0.0); - let mag = (r * r + im_val * im_val).sqrt() * scale; - mags.push(mag); - } - (n, hz_per_bin, mags) -} - -fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?; - let (_n, hz_per_bin, mags) = compute_fft_mags(&samples); - - let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; - let f0 = pr.f("startFrequency").max(0.0); - let f1 = pr.f("endFrequency").max(f0 + 1.0); - let max_bin = mags.len().saturating_sub(1); - - let get_band_mag = |fc: f64| -> f64 { - if mags.is_empty() || hz_per_bin <= 0.0 { - return 0.0; - } - let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64); - let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1)); - let k1 = (k0 + 1).min(max_bin); - let frac = (k_float - k0 as f64).clamp(0.0, 1.0); - let m0 = mags.get(k0).copied().unwrap_or(0.0); - let m1 = mags.get(k1).copied().unwrap_or(0.0); - m0 * (1.0 - frac) + m1 * frac - }; - - let max_h = pr.f("maximumHeight") * b.scale; - let thick = (pr.f("thickness") * b.scale).max(0.1); - let soft = pr.f("softness") / 100.0; - let disp = pr.e("displayOptions"); - let side = pr.e("sideOptions"); - let base = Baseline::new(ctx, b); - let mut tops = Vec::with_capacity(bands); - let mut segs = Vec::new(); - for i in 0..bands { - let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 }; - let fc = f0 + (f1 - f0) * u; - let v = get_band_mag(fc); - let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 }; - let (q, nrm) = base.at(u); - let (ua, ub) = match side { - 0 => (0.0, hgt), - 1 => (-hgt, 0.0), - _ => (-hgt, hgt), - }; - let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua]; - let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub]; - match disp { - 0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }), - _ => tops.push((pa, pb)), - } - } - if disp == 1 { - for w2 in tops.windows(2) { - segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 }); - if side == 2 { - segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 }); - } - } - } else if disp == 2 { - for &(pa, pb) in &tops { - segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 }); - if side == 2 { - segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 }); - } - } - } - Some(marks(ctx, segs, thick, soft)) -} - -fn waveform_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions"))?; - let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize; - let max_h = pr.f("maximumHeight") * b.scale; - let thick = (pr.f("thickness") * b.scale).max(0.1); - let soft = pr.f("softness") / 100.0; - let disp = pr.e("displayOptions"); - let base = Baseline::new(ctx, b); - let m = samples.len(); - let mut pts = Vec::with_capacity(count); - let mut segs = Vec::new(); - for i in 0..count { - let u = i as f64 / (count - 1) as f64; - let a = ((u * (m - 1) as f64) as usize).min(m - 1); - let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1); - // Peak with sign of the window for a stable digital display. - let (mut lo, mut hi) = (0.0f64, 0.0f64); - for s in &samples[a..=bnd] { - lo = lo.min(*s); - hi = hi.max(*s); - } - let (q, nrm) = base.at(u); - match disp { - 0 => segs.push(Seg { - a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h], - b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h], - r: thick * 0.5, - v: 1.0, - }), - _ => { - let v = samples[a] * max_h; - pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]); - } - } - } - if disp == 1 { - poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs); - } else if disp == 2 { - segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 })); - } - Some(marks(ctx, segs, thick, soft)) -} - -// ---------------------------------------------------------------- registry - -pub fn specs() -> Vec { - let ang = || ParamUi::Angle; - let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); - let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]); - let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), ParamUi::Mask); - let display = || popup(&["Digital", "Analog lines", "Analog dots"]); - vec![ - spec( - "ec.generate.fractal", - "Fractal", - vec![ - p("setChoice", "Set Choice", Value::Enum(0), popup(&FRACTAL_SETS)), - p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), - p("mandelbrot/mandelbrotX", "X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("mandelbrot/mandelbrotY", "Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("mandelbrot/mandelbrotMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), - p("mandelbrot/mandelbrotEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), - p("julia/juliaX", "X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("julia/juliaY", "Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("julia/juliaMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), - p("julia/juliaEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), - p("postInversionOffset/postInversionX", "X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("postInversionOffset/postInversionY", "Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - check("fractalColor/overlay", "Overlay", false), - check("fractalColor/transparency", "Transparency", false), - p("fractalColor/palette", "Palette", Value::Enum(0), popup(&FRACTAL_PALETTES)), - p("fractalColor/hue", "Hue", num(0.0), ang()), - p("fractalColor/cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), - p("fractalColor/cycleOffset", "Cycle Offset", num(0.0), ang()), - check("fractalColor/edgeHighlight", "Edge Highlight", false), - p("highQualitySettings/samplingMethod", "Oversample Method", Value::Enum(0), popup(&FRACTAL_OVERSAMPLE)), - p("highQualitySettings/samplingFactor", "Oversample Factor", num(2.0), slider(1.0, 9.0, 1.0, 9.0, 0)), - ], - fractal, - ), - spec( - "ec.generate.scribble", - "Scribble", - vec![ - p("scribble", "Scribble", Value::Enum(0), popup(&["Single Mask", "All Masks", "All Masks Using Modes"])), - mask_idx("mask", "Mask", 1.0), - p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])), - p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)), - p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)), - p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.0, 1)), - p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), - p("opacity", "Opacity", num(100.0), pct()), - p("angle", "Angle", num(45.0), ang()), - p("strokeWidth", "Stroke Width", num(2.0), slider(0.1, 50.0, 0.1, 50.0, 1)), - p("strokeOptions/curviness", "Curviness", num(50.0), pct()), - p("strokeOptions/curvinessVariation", "Curviness Variation", num(50.0), pct()), - p("strokeOptions/spacing", "Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), - p("strokeOptions/spacingVariation", "Spacing Variation", num(50.0), pct()), - p("strokeOptions/pathOverlap", "Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), - p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()), - p("start", "Start", num(0.0), pct()), - p("end", "End", num(100.0), pct()), - p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])), - check("fillPathsSequentially", "Fill Paths Sequentially", true), - p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])), - p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), - p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), - p("composite", "Composite", Value::Enum(1), popup(&["On Original Image", "On Transparent", "Reveal Original Image"])), - ], - scribble, - ), - spec( - "ec.generate.stroke", - "Stroke", - vec![ - mask_idx("path", "Path", 1.0), - check("allMasks", "All Masks", false), - check("strokeSequentially", "Stroke Sequentially", true), - p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("brushSize", "Brush Size", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), - p("brushHardness", "Brush Hardness", num(75.0), pct()), - p("opacity", "Opacity", num(100.0), pct()), - p("start", "Start", num(0.0), pct()), - p("end", "End", num(100.0), pct()), - p("spacing", "Spacing", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)), - p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), - ], - stroke, - ), - spec( - "ec.generate.vegas", - "Vegas", - vec![ - p("stroke", "Stroke", Value::Enum(0), popup(&["Image Contours", "Mask/Path"])), - p("inputLayer", "Image Contours: Input Layer", Value::Layer(None), ParamUi::Layer), - check("invertInput", "Image Contours: Invert Input", false), - p("ifLayerSizesDiffer", "Image Contours: If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), - p("channel", "Image Contours: Channel", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Alpha"])), - p("threshold", "Image Contours: Threshold", num(50.0), pct()), - p("preBlur", "Image Contours: Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("tolerance", "Image Contours: Tolerance", num(0.01), slider(0.0, 1.0, 0.0, 0.1, 3)), - p("renderMode", "Image Contours: Render", Value::Enum(0), popup(&["All Contours", "Selected Contour"])), - p("selectedContour", "Image Contours: Selected Contour", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), - p("shorterContoursHave", "Image Contours: Shorter Contours Have", Value::Enum(0), popup(&["Same Number of Segments", "Fewer Segments"])), - mask_idx("path", "Mask/Path: Path", 1.0), - p("segments", "Segments: Segments", num(32.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), - p("length", "Segments: Length", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("segmentDistribution", "Segments: Segment Distribution", Value::Enum(0), popup(&["Bunched", "Even"])), - p("rotation", "Segments: Rotation", num(0.0), ang()), - check("randomPhase", "Segments: Random Phase", false), - p("randomSeed", "Segments: Random Seed", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), - p("blendMode", "Rendering: Blend Mode", Value::Enum(1), popup(&["Transparent", "Over", "Under", "Stencil"])), - p("color", "Rendering: Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("width", "Rendering: Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), - p("hardness", "Rendering: Hardness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("startOpacity", "Rendering: Start Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("midpointOpacity", "Rendering: Mid-point Opacity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("midpointPosition", "Rendering: Mid-point Position", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("endOpacity", "Rendering: End Opacity", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - ], - vegas, - ), - spec( - "ec.generate.writeon", - "Write-on", - vec![ - p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), - p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("brushSize", "Brush Size", num(6.0), slider(0.0, 50.0, 0.0, 50.0, 1)), - p("brushHardness", "Brush Hardness", num(75.0), pct()), - p("brushOpacity", "Brush Opacity", num(100.0), pct()), - p("strokeLength", "Stroke Length (secs)", num(0.0), slider(0.0, 30.0, 0.0, 30.0, 1)), - p("brushSpacing", "Brush Spacing (secs)", num(0.001), slider(0.001, 5.0, 0.001, 5.0, 3)), - p("paintTimeProperties", "Paint Time Properties", Value::Enum(0), popup(&["None", "Opacity", "Color"])), - p("brushTimeProperties", "Brush Time Properties", Value::Enum(0), popup(&["None", "Size", "Hardness", "Size & Hardness"])), - p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), - ], - write_on, - ), - spec( - "ec.generate.paintbucket", - "Paint Bucket", - vec![ - p("fillPoint", "Fill Point", pt(0.5, 0.5), ParamUi::Point), - p("fillSelector", "Fill Selector", Value::Enum(0), popup(&["Color & Alpha", "Straight Color", "Transparency", "Opacity", "Alpha Channel"])), - p("tolerance", "Tolerance", num(10.0), slider(0.0, 255.0, 0.0, 100.0, 1)), - check("viewThreshold", "View Threshold", false), - p("stroke", "Stroke", Value::Enum(0), popup(&["Antialias", "Feather", "Spread", "Choke", "Stroke"])), - p("featherSoftness", "Feather Softness", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), - p("spreadRadius", "Spread Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("strokeWidth", "Stroke Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), - p("opacity", "Opacity", num(100.0), pct()), - p( - "blendingMode", - "Blending Mode", - Value::Enum(0), - popup(&["Normal", "Add", "Multiply", "Screen", "Hue", "Saturation", "Color", "Luminosity", "Behind", "Fill Only"]), - ), - ], - paint_bucket, - ), - spec( - "ec.generate.eyedropperfill", - "Eyedropper Fill", - vec![ - p("samplePoint", "Sample Point", pt(0.5, 0.5), ParamUi::Point), - p("sampleRadius", "Sample Radius", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("averagePixelColors", "Average Pixel Colors", Value::Enum(0), popup(&["Skip Empty", "All", "All Premultiplied", "Including Alpha"])), - check("maintainOriginalAlpha", "Maintain Original Alpha", false), - p("blendWithOriginal", "Blend With Original", num(0.0), pct()), - ], - eyedropper_fill, - ), - spec( - "ec.generate.ccgluegun", - "CC Glue Gun", - vec![ - p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), - p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)), - p("density", "Density", num(5.0), slider(0.0, 32.0, 0.0, 32.0, 1)), - p("timeSpan", "Time Span (sec)", num(1.0), slider(0.0, 60.0, 0.0, 10.0, 2)), - p("reflection", "Reflection", num(45.0), ang()), - p("strength", "Strength", num(100.0), pct()), - p("style", "Style", Value::Enum(0), popup(&["Over Layer", "Glue Only"])), - ], - glue_gun, - ), - spec( - "ec.generate.ccthreads", - "CC Threads", - vec![ - p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), - p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), - p("overlaps", "Overlaps", num(1.0), slider(1.0, 20.0, 1.0, 10.0, 0)), - p("direction", "Direction", num(0.0), ang()), - p("center", "Center", pt(0.5, 0.5), ParamUi::Point), - p("coverage", "Coverage", num(80.0), pct()), - p("shadowing", "Shadowing", num(50.0), pct()), - p("texture", "Texture", num(10.0), pct()), - ], - threads, - ), - spec( - "ec.generate.audiospectrum", - "Audio Spectrum", - vec![ - p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), - p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), - p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), - mask_idx("path", "Path", 0.0), - check("usePolarPath", "Use Polar Path", false), - p("startFrequency", "Start Frequency", num(20.0), slider(1.0, 20000.0, 1.0, 2000.0, 0)), - p("endFrequency", "End Frequency", num(2000.0), slider(1.0, 20000.0, 1.0, 10000.0, 0)), - p("frequencyBands", "Frequency bands", num(64.0), slider(1.0, 4096.0, 1.0, 256.0, 0)), - p("maximumHeight", "Maximum Height", num(50.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), - p("audioDuration", "Audio Duration (milliseconds)", num(90.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), - p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), - p("thickness", "Thickness", num(3.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), - p("softness", "Softness", num(50.0), pct()), - p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), - check("blendOverlappingColors", "Blend Overlapping Colors", true), - p("hueInterpolation", "Hue Interpolation", num(0.0), ang()), - check("dynamicHuePhase", "Dynamic Hue Phase", false), - check("colorSymmetry", "Color Symmetry", false), - p("displayOptions", "Display Options", Value::Enum(0), display()), - p("sideOptions", "Side Options", Value::Enum(0), popup(&["Side A", "Side B", "Side A & B"])), - check("durationAveraging", "Duration Averaging", false), - check("compositeOnOriginal", "Composite On Original", false), - ], - audio_spectrum, - ), - spec( - "ec.generate.audiowaveform", - "Audio Waveform", - vec![ - p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), - p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), - p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), - mask_idx("path", "Path", 0.0), - check("usePolarPath", "Use Polar Path", false), - p("displayedSamples", "Displayed Samples", num(200.0), slider(2.0, 4096.0, 2.0, 1000.0, 0)), - p("maximumHeight", "Maximum Height", num(100.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), - p("audioDuration", "Audio Duration (milliseconds)", num(200.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), - p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), - p("thickness", "Thickness", num(2.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), - p("softness", "Softness", num(10.0), pct()), - p("randomSeed", "Random Seed (Analog)", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), - p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), - p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Mono", "Left", "Right"])), - p("displayOptions", "Display Options", Value::Enum(1), display()), - check("compositeOnOriginal", "Composite On Original", false), - ], - audio_waveform, - ), - ] -} +mod audio; +mod common; +mod fill; +mod fractal; +mod paths; +mod raster; +mod registry; #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, EffectHost, LayerPixels, MaskShape, Params}; +pub(crate) use audio::fft; +pub use audio::{MarksPlan, marks_plan}; +pub use fill::{eyedropper_color, paint_bucket_region}; +#[cfg(test)] +use paths::contours; +pub use paths::{Coverage, PaintPlan, paint_plan, plan_reads_pixels, scribble_style}; +pub use raster::{Seg, poly_segs}; +pub(crate) use raster::{mask_px, raster_segs}; +#[cfg(test)] +pub(crate) use raster::{raster_convex, stroke_outline}; +pub use registry::specs; - /// Run with point defaults scaled to the layer size. - pub(crate) fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { - let s = crate::find(id).unwrap(); - let ls = [img.width as f64, img.height as f64]; - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for p in &s.params { - if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { - params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); - } - } - for (k, v) in set { - assert!(params.values.contains_key(*k), "{id}: unknown {k}"); - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; - crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) - } - - fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { - run_env(id, set, img, 0.5, EffectEnv::default()) - } - - fn square_mask() -> Vec { - vec![MaskShape { name: "Mask 1".into(), points: vec![[16.0, 16.0], [48.0, 16.0], [48.0, 48.0], [16.0, 48.0]], closed: true, inverted: false }] - } - - fn alpha_sum(img: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 { - let mut s = 0.0; - for y in y0..y1 { - for x in x0..x1 { - s += img.data[y * img.width as usize + x][3]; - } - } - s - } - - #[test] - fn audio_spectrum_linear_frequency_and_linear_amplitude() { - let host = Tone; // Tone produces 440 Hz - let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; - let img = Image::new(200, 100); - let out = run_env( - "ec.generate.audiospectrum", - &[ - ("audioLayer", Value::Layer(Some(1))), - ("startPoint", Value::Vec2([0.0, 50.0])), - ("endPoint", Value::Vec2([200.0, 50.0])), - ("startFrequency", num(20.0)), - ("endFrequency", num(2000.0)), - ("frequencyBands", num(100.0)), - ("maximumHeight", num(40.0)), - ("thickness", num(2.0)), - ("softness", num(0.0)), - ], - img, - 1.0, - env, - ); - // Verify 440 Hz tone produces a peak above baseline, while high frequencies have zero bar height - let peak_zone = alpha_sum(&out.img, 36, 10, 48, 48); - let high_zone = alpha_sum(&out.img, 120, 10, 150, 48); - let silent_zone = alpha_sum(&out.img, 170, 10, 195, 48); - assert!(peak_zone > 5.0, "peak zone has energy: {peak_zone}"); - assert!(peak_zone > high_zone * 2.0, "linear peak is distinctly taller than far frequencies: {peak_zone} vs {high_zone}"); - assert_eq!(silent_zone, 0.0, "silent end has zero height, not boosted by dB scale"); - } - - #[test] - fn fractal_deterministic_and_has_inside_set() { - let img = Image::new(64, 48); - let a = run("ec.generate.fractal", &[], img.clone()); - let b = run("ec.generate.fractal", &[], img.clone()); - assert_eq!(a.img.data, b.img.data); - // The default view contains the main cardioid (black, opaque) and escaped (coloured) points. - assert!(a.img.data.iter().any(|p| p[3] > 0.99 && p[0] + p[1] + p[2] < 0.01)); - assert!(a.img.data.iter().any(|p| p[0] + p[1] + p[2] > 0.3)); - let t = run("ec.generate.fractal", &[("fractalColor/transparency", Value::Bool(true))], img); - assert!(t.img.data.iter().any(|p| p[3] == 0.0)); - } - - #[test] - fn fractal_palettes_and_set_choices() { - let img = Image::new(64, 48); - // Solid Color: only the inside of the set is drawn; Transparency flips it. - let solid = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3))], img.clone()); - assert!(solid.img.data.iter().any(|p| p[3] == 0.0) && solid.img.data.iter().any(|p| p[3] > 0.99 && p[0] > 0.9)); - let flipped = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3)), ("fractalColor/transparency", Value::Bool(true))], img.clone()); - for (a, b) in solid.img.data.iter().zip(&flipped.img.data) { - assert!((a[3] > 0.5) != (b[3] > 0.5) || (a[3] - 0.5).abs() < 0.49); - } - // Black And White: escaped pixels are pure black or white (away from supersampled edges). - let bw = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(2)), ("highQualitySettings/samplingFactor", num(1.0))], img.clone()); - assert!(bw.img.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && (p[0] == 0.0 || p[0] == 1.0))); - // Mandelbrot Over Julia depends on the Julia centre. - let o1 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(0.3))], img.clone()); - let o2 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(-0.2))], img.clone()); - assert_ne!(o1.img.data, o2.img.data); - let plain = run("ec.generate.fractal", &[("julia/juliaX", num(0.3))], img); - assert_ne!(o1.img.data, plain.img.data); - } - - #[test] - fn scribble_fills_mask_and_animates() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]); - let a = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); - let b = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); - assert_eq!(a.img.data, b.img.data); - let inside = alpha_sum(&a.img, 18, 18, 46, 46); - let outside = alpha_sum(&a.img, 0, 0, 10, 64); - assert!(inside > 100.0 && outside < 1.0, "{inside} {outside}"); - let c = run_env("ec.generate.scribble", &[], img.clone(), 1.5, env); - assert_ne!(a.img.data, c.img.data, "jumpy wiggle changes over time"); - let s = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 0.5, env); - let s2 = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 3.5, env); - assert_eq!(s.img.data, s2.img.data, "static wiggle is constant"); - let half = run_env("ec.generate.scribble", &[("end", num(50.0))], img, 0.5, env); - assert!(alpha_sum(&half.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64)); - } - - #[test] - fn scribble_edge_types() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::new(64, 64); - let o = run_env("ec.generate.scribble", &[("fillType", Value::Enum(3)), ("edgeOptions/edgeWidth", num(6.0))], img, 0.5, env); - // Outside edge: paint near the border outside, nothing in the centre. - assert!(alpha_sum(&o.img, 26, 26, 38, 38) < 0.5); - assert!(alpha_sum(&o.img, 8, 8, 16, 56) > 5.0); - } - - #[test] - fn stroke_draws_along_mask() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::new(64, 64); - let a = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("paintStyle", Value::Enum(1))], img.clone(), 0.0, env); - assert!(a.img.get(32, 16)[3] > 0.9, "on the top edge"); - assert!(a.img.get(32, 32)[3] < 0.01, "centre empty"); - let b = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("end", num(25.0))], img.clone(), 0.0, env); - assert!(b.img.get(32, 16)[3] > 0.9 && b.img.get(48, 32)[3] < 0.01, "first quarter only"); - let d = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("spacing", num(300.0))], img, 0.0, env); - assert!(alpha_sum(&d.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64), "spaced dabs"); - // No masks: unchanged. - let src = Image::filled(8, 8, [0.2, 0.3, 0.4, 1.0]); - assert_eq!(run("ec.generate.stroke", &[], src.clone()).img.data, src.data); - } - - #[test] - fn vegas_traces_contours() { - let mut img = Image::new(64, 64); - for y in 16..48 { - for x in 16..48 { - img.set(x, y, [1.0, 1.0, 1.0, 1.0]); - } - } - let a = run( - "ec.generate.vegas", - &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], - img.clone(), - ); - let b = run( - "ec.generate.vegas", - &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], - img.clone(), - ); - assert_eq!(a.img.data, b.img.data); - assert!(alpha_sum(&a.img, 14, 14, 50, 50) > 50.0, "edges drawn"); - assert!(alpha_sum(&a.img, 26, 26, 38, 38) < 0.5, "interior untouched"); - let r = run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("rotation", num(90.0))], img); - assert_ne!(r.img.data, a.img.data); - } - - #[test] - fn contours_of_square_are_closed() { - let mut pl = Plane::new(20, 20); - for y in 5..15 { - for x in 5..15 { - pl.data[y * 20 + x] = 1.0; - } - } - let c = contours(&pl, 0.5); - assert_eq!(c.len(), 1); - assert!(c[0].1); - let l = poly_length(&c[0].0, true); - assert!((l - 40.0).abs() < 4.0, "{l}"); - } - - #[test] - fn write_on_and_glue_gun_draw_at_brush() { - let img = Image::new(32, 32); - let a = run("ec.generate.writeon", &[], img.clone()); - assert!(a.img.get(16, 16)[3] > 0.9 && a.img.get(2, 2)[3] == 0.0); - assert_eq!(a.img.data, run("ec.generate.writeon", &[], img.clone()).img.data); - let src = Image::filled(32, 32, [0.5, 0.2, 0.1, 1.0]); - let g = run("ec.generate.ccgluegun", &[], src.clone()); - assert_eq!(g.img.data, run("ec.generate.ccgluegun", &[], src.clone()).img.data); - assert_ne!(g.img.get(16, 16), src.get(16, 16)); - assert_eq!(g.img.get(0, 0), src.get(0, 0)); - } - - /// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires - /// after Stroke Length, and lays its dabs Brush Spacing seconds apart. - #[test] - fn write_on_accumulates_a_stroke_along_the_animated_brush() { - // Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px. - struct Brush(Vec<(&'static str, Value)>); - impl Brush { - fn at(&self, t: f64) -> Vec<(&'static str, Value)> { - let mut v = self.0.clone(); - v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0]))); - v - } - } - impl EffectHost for Brush { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn params_at(&self, t: f64) -> Option { - let s = crate::find("ec.generate.writeon").unwrap(); - let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for (k, v) in self.at(t) { - p.values.insert(k.to_string(), v); - } - Some(p) - } - } - let render = |extra: &[(&'static str, Value)], t: f64| { - let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))]; - set.extend(extra.iter().cloned()); - let host = Brush(set); - let env = EffectEnv { host: Some(&host), ..Default::default() }; - let at = host.at(t); - run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img - }; - let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9; - let half = render(&[], 0.5); - assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56)); - let end = render(&[], 1.0); - assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x))); - // Stroke Length 0.25 s: only the last quarter second's trail. - let trail = render(&[("strokeLength", num(0.25))], 1.0); - assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56)); - // Brush Spacing 0.5 s: three separate marks. - let spaced = render(&[("brushSpacing", num(0.5))], 1.0); - assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44)); - // Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with. - struct Fade; - impl EffectHost for Fade { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn params_at(&self, t: f64) -> Option { - let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?; - p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 })); - Some(p) - } - } - let p1 = Fade.params_at(1.0).unwrap(); - let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); - let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img; - assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16)); - } - - #[test] - fn paint_bucket_fills_enclosed_region_only() { - let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); - for i in 8..24 { - for &(x, y) in &[(i, 8), (i, 23), (8, i), (23, i)] { - img.set(x, y, [0.0, 0.0, 0.0, 1.0]); - } - } - let out = run("ec.generate.paintbucket", &[], img.clone()); - let inside = out.img.get(16, 16); - assert!(inside[0] > 0.99 && inside[1] < 0.01, "{inside:?}"); - assert_eq!(out.img.get(2, 2), [1.0, 1.0, 1.0, 1.0], "outside untouched"); - assert_eq!(out.img.get(8, 16), [0.0, 0.0, 0.0, 1.0], "border untouched"); - assert_eq!(out.img.data, run("ec.generate.paintbucket", &[], img).img.data); - } - - #[test] - fn eyedropper_fill_uses_sample() { - let mut img = Image::filled(16, 16, [0.0, 0.0, 1.0, 1.0]); - img.set(8, 8, [1.0, 0.0, 0.0, 1.0]); - let out = run("ec.generate.eyedropperfill", &[], img.clone()); - assert!(out.img.data.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0])); - let blended = run("ec.generate.eyedropperfill", &[("blendWithOriginal", num(100.0))], img.clone()); - assert_eq!(blended.img.data, img.data); - } - - #[test] - fn threads_weave_is_deterministic_and_gappy() { - let src = Image::filled(64, 64, [0.4, 0.6, 0.2, 1.0]); - let a = run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src.clone()); - assert_eq!(a.img.data, run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src).img.data); - assert!(a.img.data.iter().any(|p| p[3] == 0.0), "gaps between threads"); - assert!(a.img.data.iter().any(|p| p[3] > 0.99), "threads"); - } - - struct Tone; - impl EffectHost for Tone { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option> { - Some( - (0..frames) - .flat_map(|i| { - let t = start + i as f64 / rate as f64; - let v = (2.0 * PI * 440.0 * t).sin() as f32 * 0.8; - [v, v] - }) - .collect(), - ) - } - } - - #[test] - fn audio_effects_without_audio_pass_through() { - let src = Image::filled(16, 16, [0.3, 0.3, 0.3, 1.0]); - for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { - assert_eq!(run(id, &[], src.clone()).img.data, src.data, "{id}"); - } - } - - #[test] - fn audio_effects_draw_with_host() { - let host = Tone; - let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; - let img = Image::new(96, 64); - for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { - let a = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); - let b = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); - assert_eq!(a.img.data, b.img.data, "{id}"); - assert!(alpha_sum(&a.img, 0, 0, 96, 64) > 10.0, "{id} drew nothing"); - } - } - - #[test] - fn fft_finds_tone() { - let n = 256; - let mut re: Vec = (0..n).map(|i| (2.0 * PI * 8.0 * i as f64 / n as f64).cos()).collect(); - let mut im = vec![0.0; n]; - fft(&mut re, &mut im); - let mags: Vec = (0..n / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect(); - let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; - assert_eq!(peak, 8); - } - - #[test] - fn stroke_outline_caps_and_joins() { - let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]]; - let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0)); - // Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square. - let (round, butt, proj) = (cov(0), cov(1), cov(2)); - assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9); - assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner"); - // A right-angle turn: Miter fills the outer corner, Bevel cuts it. - let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]]; - let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0)); - let (miter, bevel) = (j(1), j(2)); - assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32)); - // A tight miter limit falls back to a bevel. - let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0)); - assert!(limited.get(32, 32) < 0.1); - } - - #[test] - fn scribble_caps_and_start_end_apply_to() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::new(64, 64); - let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))]; - let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env); - let butt = run_env( - "ec.generate.scribble", - &[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(), - img.clone(), - 0.0, - env, - ); - assert_ne!(round.img.data, butt.img.data); - // Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over. - let two = vec![ - crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false }, - crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false }, - ]; - let env2 = EffectEnv { masks: &two, ..Default::default() }; - let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))]; - let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2); - let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2); - assert_ne!(all.img.data, each.img.data); - } -} +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/generate3/audio.rs b/crates/effects/src/generate3/audio.rs new file mode 100644 index 000000000..f1ac54db8 --- /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::*; + +// ---------------------------------------------------------------- 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..b996cdc99 --- /dev/null +++ b/crates/effects/src/generate3/common.rs @@ -0,0 +1,13 @@ +use super::*; + +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..a86f123ee --- /dev/null +++ b/crates/effects/src/generate3/fill.rs @@ -0,0 +1,334 @@ +use super::raster::over; +use super::*; + +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..519bec640 --- /dev/null +++ b/crates/effects/src/generate3/fractal.rs @@ -0,0 +1,193 @@ +use super::raster::over; +use super::*; + +// ---------------------------------------------------------------- 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..779892cc7 --- /dev/null +++ b/crates/effects/src/generate3/paths.rs @@ -0,0 +1,553 @@ +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::*; + +/// 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..f9acd88a3 --- /dev/null +++ b/crates/effects/src/generate3/raster.rs @@ -0,0 +1,369 @@ +use super::*; + +// ---------------------------------------------------------------- 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`. +#[allow(clippy::too_many_arguments)] +pub(super) fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) { + out.clear(); + for (pts, _closed, inverted) in polys { + 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..f7f6b040f --- /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::*; + +// ---------------------------------------------------------------- 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/keying.rs b/crates/effects/src/keying.rs index 9d61ff8c8..4953d482c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -2,12 +2,13 @@ //! matte clean-up. Written from public behaviour descriptions and the standard colour-difference / //! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting"). -use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear}; +use effectcraft_color::{rgb_to_hsl, srgb_to_linear}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::{Image, gaussian_blur}; use rayon::prelude::*; +use crate::color_fx::luma_601; use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -135,46 +136,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Luma Key ---- +/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and +/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar +/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather +/// then soften the cut. fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let kind = ctx.params.e("keyType"); let thr = ctx.params.f("threshold") as f32 / 255.0; let tol = ctx.params.f("tolerance") as f32 / 255.0; let m = matte_of(&b.img, |c, _| { - let l = luminance(c[0], c[1], c[2]); - match kind { - 0 => { - if tol > 0.0 { - ((thr + tol - l) / tol).clamp(0.0, 1.0) - } else if l > thr { - 0.0 - } else { - 1.0 - } - } - 2 => { - if (l - thr).abs() <= tol { - 0.0 - } else { - 1.0 - } - } - 3 => { - if (l - thr).abs() <= tol { - 1.0 - } else { - 0.0 - } - } - _ => { - if tol > 0.0 { - ((l - (thr - tol)) / tol).clamp(0.0, 1.0) - } else if l < thr { - 0.0 - } else { - 1.0 - } - } - } + let l = luma_601([c[0], c[1], c[2]]); + let keep = match kind { + 0 => l <= thr, + 2 => (l - thr).abs() > tol, + 3 => (l - thr).abs() <= tol, + _ => l >= thr, + }; + if keep { 1.0 } else { 0.0 } }); let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale); apply_matte(&mut b.img, &m, 0); @@ -334,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf { 2 => c[1], 3 => c[2], 4 => a, - _ => luminance(c[0], c[1], c[2]), + _ => luma_601([c[0], c[1], c[2]]), }; let mut k = 1.0f32; if v < bp { @@ -802,6 +780,68 @@ mod tests { assert_eq!(out.get(1, 0)[3], 1.0); } + /// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y + /// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the + /// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it + /// out). + #[test] + fn luma_key_matches_after_effects() { + let ramp = || { + let mut img = Image::new(1, 1080); + for y in 0..1080 { + let v = (y as f32 + 0.5) / 1080.0; + img.set(0, y, [v, v, v, 1.0]); + } + img + }; + let key = |kind: u32, thin: f64, feather: f64| { + let set = + [("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))]; + let o = run("ec.key.luma", &set, ramp()); + (0..1080).map(|y| o.get(0, y)[3]).collect::>() + }; + // Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593. + let d = key(1, 0.0, 0.0); + assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]); + // Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the + // rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way. + let s = key(2, 0.0, 0.0); + assert!( + (0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)), + "{:?} {:?}", + &s[505..512], + &s[674..681] + ); + // Key Out Brighter and Dissimilar are the complements. + let b = key(0, 0.0, 0.0); + assert!(b[595] == 0.0 && b[590] == 1.0); + let ds = key(3, 0.0, 0.0); + assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0); + // A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719). + let o = + run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0])); + assert_eq!(o.get(0, 0)[3], 0.0); + // Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596. + let f = key(1, 1.0, 2.5); + for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] { + assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]); + } + } + + /// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure + /// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709 + /// would key out red and blue and keep green). + #[test] + fn luma_key_and_extract_use_rec601_as_after_effects() { + for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] { + let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]); + let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone()); + let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img); + let want = if kept { 1.0 } else { 0.0 }; + assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}"); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..cc0aad515 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -21,6 +21,7 @@ mod color2; mod color3; mod color_fx; pub mod controls; +pub mod crypto_select; mod distort; mod distort2; mod distort3; @@ -70,11 +71,11 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; -pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color_fx::{ - HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, - levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, + BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, + fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; +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; @@ -90,8 +91,8 @@ pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). -pub use blur2::{camera_lens_plain, camera_lens_spans}; -pub use generate::gen_mode; +pub use blur2::{camera_lens_plain, camera_lens_spans, radial_fast_pad}; +pub use generate::{four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ @@ -110,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ @@ -297,6 +298,43 @@ impl Buf { } } +/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: +/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its +/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp +/// origin in the buffer below (After Effects; a Transform after it brings the tiles into view, +/// #1727). An adjustment at 100 % keeps the buffer it was given; one whose frame would pass +/// 16 384 pixels a side does too. +pub fn motion_tile_frame(ctx: &EffectCtx, offset: [f64; 2], size: (u32, u32), scale: f64) -> (u32, u32, [f64; 2]) { + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let (pw, ph) = (ctx.params.f("outputWidth"), ctx.params.f("outputHeight")); + let ow = (lw * pw / 100.0).max(1.0 / scale); + let oh = (lh * ph / 100.0).max(1.0 / scale); + let (nw, nh) = ((ow * scale).round().max(1.0), (oh * scale).round().max(1.0)); + let grow = [(ow - lw) * 0.5 * scale, (oh - lh) * 0.5 * scale]; + if !ctx.adjustment { + return (nw as u32, nh as u32, grow); + } + // The grown rectangle's top-left in the buffer below, on whole pixels so the result lines + // up with the comp again when the stack ends. + let (x0, y0) = ((offset[0] - grow[0]).round(), (offset[1] - grow[1]).round()); + let fits = [x0, y0, nw, nh].iter().all(|v| v.is_finite()) && nw <= 16384.0 && nh <= 16384.0; + if (pw == 100.0 && ph == 100.0) || !fits { + return (size.0, size.1, offset); + } + (nw as u32, nh as u32, [offset[0] - x0, offset[1] - y0]) +} + +/// Where an adjustment stack's result (comp origin at `offset`, `size` pixels) lies on the canvas +/// it came from (comp origin at `canvas_offset`): the result pixel at canvas pixel (0, 0). +/// `None` when it already is the canvas, frame for frame. +pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u32), canvas: (u32, u32)) -> Option<(i64, i64)> { + let (dx, dy) = ((offset[0] - canvas_offset[0]).round(), (offset[1] - canvas_offset[1]).round()); + if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0 && size == canvas) { + return None; + } + Some((dx as i64, dy as i64)) +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -406,6 +444,8 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it). + pub float_depth: bool, /// The composition's shutter (angle and phase in degrees, samples per frame) when both the /// comp's and the layer's motion blur switches are on (Transform's Use Composition's /// Shutter Angle). diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 0e037022e..b5976b761 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -499,6 +499,22 @@ mod tests { assert_eq!(pr.ui, popup.ui); } + /// #1797: Shift Channels' menu put Half On before Full Off; a project saved then keeps the + /// option it chose, by label. + #[test] + fn shift_channels_half_on_and_full_off_are_remapped_by_label() { + const OLD: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"]; + for (saved, want) in [(9u32, 10u32), (10, 9), (1, 1)] { + let mut next = 1; + let (spec, mut g) = instance("ec.channel.shiftchannels", &mut next); + let pr = g.get_mut("takeGreenFrom").unwrap(); + pr.ui = ParamUi::Popup { options: OLD.iter().map(|s| s.to_string()).collect() }; + pr.value = Value::Enum(saved); + upgrade_instance(spec, &mut g, &mut Ids(&mut next), SIZE); + assert_eq!(g.get("takeGreenFrom").unwrap().value, Value::Enum(want), "saved {saved}"); + } + } + /// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were /// plain text; old projects get the Choose… file parameter, keeping the path. #[test] diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..7468c6b84 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -35,22 +35,44 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); } - b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment); + let (sx, sy) = (s * kx, s * ky); + // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). + b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) { + effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, repeat || ctx.adjustment) + } else { + gaussian_blur(&b.img, sx, sy, repeat || ctx.adjustment) + }; b } fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; - if r == 0 { + let radius = (ctx.params.f("radius") * b.scale).max(0.0); + let r = radius.floor() as usize; + let frac = (radius - r as f64) as f32; + // Fractional radius: blend the two neighbouring integer radii so the result changes continuously (#1939). + let frac = if frac < 1e-4 { 0.0 } else { frac }; + if r == 0 && frac == 0.0 { return b; } let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize; let (kx, ky) = dims_xy(ctx.params.e("dimensions")); let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { - b.pad((r * it) as u32 + 1); + let reach = if frac > 0.0 { r + 1 } else { r }; + b.pad((reach * it) as u32 + 1); + } + let edge = repeat || ctx.adjustment; + let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); + if frac == 0.0 { + b.img = lo; + return b; + } + let hi = effectcraft_raster::box_blur(&b.img, (r + 1) * kx as usize, (r + 1) * ky as usize, it, edge); + for ((l, h), o) in lo.data.iter().zip(hi.data.iter()).zip(b.img.data.iter_mut()) { + for c in 0..4 { + o[c] = l[c] + (h[c] - l[c]) * frac; + } } - b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment); b } @@ -738,6 +760,42 @@ mod tests { use super::*; use crate::{EffectEnv, run_fx}; + /// White left half, black right half (one vertical edge at x = 20). + fn edge() -> effectcraft_raster::Image { + let mut img = effectcraft_raster::Image::filled(40, 8, [0.0, 0.0, 0.0, 1.0]); + for y in 0..8 { + for x in 0..20 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + img + } + + fn blurred_row(id: &str, param: &str, v: f64) -> Vec { + let out = run_fx(id, &[(param, num(v)), ("repeatEdge", Value::Bool(true))], edge(), 0.0, EffectEnv::default()); + (16..24).map(|x| out.img.get(x, 4)[0]).collect() + } + + #[test] + fn small_gaussian_blurriness_is_continuous() { + // #1939: 0.9 used to be identical to 0, and 1.2 / 1.6 / 2.0 identical to each other. + let row = |v| blurred_row("ec.blur.gaussian", "blurriness", v); + assert_ne!(row(0.0), row(0.9)); + assert_ne!(row(0.9), row(1.2)); + assert_ne!(row(1.2), row(1.6)); + assert_ne!(row(1.6), row(2.0)); + assert_ne!(row(2.0), row(2.4)); + } + + #[test] + fn fractional_box_blur_radius_is_continuous() { + let row = |v| blurred_row("ec.blur.fastbox", "radius", v); + assert_ne!(row(0.0), row(0.5)); + assert_ne!(row(0.5), row(0.9)); + assert_ne!(row(1.2), row(1.6)); + assert_ne!(row(1.6), row(2.4)); + } + /// A bright square on mid grey. fn scene() -> effectcraft_raster::Image { let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]); diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 88a5c01d5..bf1f05ed6 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -17,7 +17,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use rayon::prelude::*; -use crate::generate::{fade, lattice}; +use crate::generate::lattice; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider}; /// Fractal Type options (shared by both effects). @@ -139,7 +139,7 @@ fn bspline(t: f32) -> [f32; 3] { } /// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the -/// evolution axis z always fades smoothly). +/// evolution axis z always interpolates linearly, so evolution moves at a constant rate). /// /// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic /// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values @@ -149,7 +149,7 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 { // Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's. let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 }; let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor()); - let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0)); + let (tx, ty, fz) = (x + h - x0, y + h - y0, z - z0); let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32); let l = |a: f32, b: f32, t: f32| a + (b - a) * t; let slice = |dz: i32| -> f32 { @@ -333,7 +333,7 @@ impl Fractal { } /// Fractal / Turbulent Noise resolved for the GPU kernel (effectcraft-gpu `fx_noise`): the -/// per-pixel constants and, per octave, the terms [`Fractal::raw`] computes before touching the +/// per-pixel constants and, per octave, the terms `Fractal::raw` computes before touching the /// pixel, in the same f32 order. pub struct FractalGpu { pub kind: u32, @@ -406,15 +406,20 @@ pub fn fractal_gpu(ctx: &EffectCtx, offset: [f64; 2], scale: f64, turbulent: boo sub_rotation: fr.sub_rotation != 0.0, norm, octaves, - mode: match ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE) { - BLEND_NONE => None, - m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)), - }, + mode: blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE)), opacity: (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0), blend: ctx.params.f("blend") as f32 / 100.0, } } +/// Resolve a Blending Mode index: None → `None`, unknown → Normal. +fn blend_mode(m: u32) -> Option { + match m { + BLEND_NONE => None, + m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)), + } +} + /// Map a value by an [`OVERFLOWS`] option. pub fn overflow(v: f32, mode: u32) -> f32 { match mode { @@ -431,14 +436,11 @@ pub fn overflow(v: f32, mode: u32) -> f32 { /// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend /// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within /// its masks (#939). -pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] { +pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: Option, blend_orig: f32) -> [f32; 4] { let src = [val * opacity, val * opacity, val * opacity, opacity]; let out = match mode { - BLEND_NONE => src, - m => { - let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal); - blend_pixel(bm, orig, src, 0.5) - } + None => src, + Some(bm) => blend_pixel(bm, orig, src, 0.5), }; let k = 1.0 - blend_orig; [0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k) @@ -447,7 +449,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf { let fr = Fractal::from_params(ctx, &b, turbulent); let opacity = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0); - let mode = ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE); + let mode = blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE)); let blend = ctx.params.f("blend") as f32 / 100.0; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { @@ -476,6 +478,20 @@ mod tests { run_fx(id, vals, Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]), 0.0, EffectEnv::default()).img } + /// Evolution moves the noise at a constant rate: no ease in or out within a revolution. + #[test] + fn evolution_is_linear() { + for noise_type in [0, 1, 2, 3] { + let a = typed_noise(0.3, 0.7, 0.0, 5, noise_type); + let b = typed_noise(0.3, 0.7, 1.0, 5, noise_type); + for z in [0.25, 0.5, 0.75] { + let want = a + (b - a) * z; + let got = typed_noise(0.3, 0.7, z, 5, noise_type); + assert!((got - want).abs() < 1e-4, "type {noise_type} z {z}: {got} vs {want}"); + } + } + } + /// A Fractal Noise saved before the twirl-downs existed: flat ids move into their groups and /// the new controls take values that keep the old look. #[test] @@ -665,4 +681,34 @@ mod tests { } assert!(axis / diag < 1.15, "gradient along the axes / along the diagonals: {}", axis / diag); } + + /// The base composite, resolving the Blending Mode by name on every call, kept only to prove + /// the hoisted lookup changes nothing. + fn old_composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] { + let src = [val * opacity, val * opacity, val * opacity, opacity]; + let out = match mode { + BLEND_NONE => src, + m => { + let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal); + blend_pixel(bm, orig, src, 0.5) + } + }; + let k = 1.0 - blend_orig; + [0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k) + } + + #[test] + fn resolved_blend_matches_per_pixel_lookup() { + for m in (0..BLEND_MODES.len() as u32).chain([24, 999]) { + for orig in [[0.2, 0.4, 0.6, 1.0], [0.9, 0.1, 0.5, 0.5], [0.0, 0.0, 0.0, 0.0]] { + for val in [0.0, 0.3, 0.75, 1.0] { + for (op, bl) in [(1.0, 0.0), (0.6, 0.25)] { + assert_eq!(composite(orig, val, op, blend_mode(m), bl), old_composite(orig, val, op, m, bl), "mode {m}"); + } + } + } + } + assert_eq!(blend_mode(BLEND_NONE), None); + assert_eq!(blend_mode(999), Some(BlendMode::Normal)); + } } diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 32a196af2..d98ec73ad 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -284,9 +284,13 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: return None; } let fr = fc.frames.get(&f)?; - let a = rle::decode(&fr.a)?; let [fw, fh] = fc.size; - if fw == 0 || fh == 0 || a.len() != fw.checked_mul(fh)? { + let expected = fw.checked_mul(fh)?; + if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { + return None; + } + let a = rle::decode_bounded(&fr.a, expected)?; + if a.len() != expected { return None; } let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect(); diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs index c58f5ca6b..a2b161b39 100644 --- a/crates/effects/src/obsolete.rs +++ b/crates/effects/src/obsolete.rs @@ -99,7 +99,13 @@ fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { if !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); } - b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment); + let (sx, sy) = (s * kx, s * ky); + // Small sigmas use the sampled kernel, like Gaussian Blur (#1939). + b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) { + effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, ctx.adjustment) + } else { + gaussian_blur(&b.img, sx, sy, ctx.adjustment) + }; b } @@ -175,6 +181,16 @@ mod tests { assert!(out.img.get(c as i64, c as i64)[3] < 0.5); } + #[test] + fn gaussian_legacy_small_blurriness_blurs() { + // #1939: Blurriness under ~1.2 used to be a no-op. + let mut img = Image::new(15, 15); + img.set(7, 7, [1.0, 1.0, 1.0, 1.0]); + let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(0.9))], img, 0.0, EffectEnv::default()); + let c = out.img.width / 2; + assert!(out.img.get(c as i64, c as i64)[3] < 0.9); + } + #[test] fn moved_effects_are_obsolete() { for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] { diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index 74816b110..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(|e| e.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..1feab04a2 --- /dev/null +++ b/crates/effects/src/ocio/cdl.rs @@ -0,0 +1,115 @@ +use super::*; + +// ---------------------------------------------------------------- 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..7d07011a4 --- /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::*; + +// ---------------------------------------------------------------- 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..ff4cbd935 --- /dev/null +++ b/crates/effects/src/ocio/lut.rs @@ -0,0 +1,331 @@ +use super::cdl::{Cdl, parse_cdl_xml}; +use super::*; + +// ---------------------------------------------------------------- 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..ab9c4ea78 --- /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::*; + +// ---- 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..b020b053b --- /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::*; + +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..44fcf84d0 --- /dev/null +++ b/crates/effects/src/ocio/space.rs @@ -0,0 +1,214 @@ +use super::*; + +pub(super) type M3 = [[f64; 3]; 3]; + +pub(crate) const D65: [f64; 2] = [0.3127, 0.3290]; + +const ACES_WHITE: [f64; 2] = [0.32168, 0.33767]; + +const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]]; + +const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]]; + +pub(crate) const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; + +pub(crate) const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; + +pub(crate) const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; + +pub(super) const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +/// Transfer functions (encoded ↔ linear). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Tf { + Linear, + Srgb, + Gamma(f64), + AcesCct, + /// ACEScc (Academy S-2014-003), the pure-log grading encoding. + AcesCc, + /// SMPTE ST 2084 with 1.0 = 100 cd/m². + Pq, +} + +/// ACEScc: the lowest code value (linear 0 and below) and the end of its toe (linear 2⁻¹⁵). +const ACESCC_MIN: f64 = (-16.0 + 9.72) / 17.52; + +const ACESCC_TOE: f64 = (-15.0 + 9.72) / 17.52; + +impl Tf { + pub fn decode(self, v: f64) -> f64 { + match self { + Tf::Linear => v, + Tf::Srgb => { + let a = v.abs(); + let l = if a <= 0.04045 { a / 12.92 } else { ((a + 0.055) / 1.055).powf(2.4) }; + l.copysign(v) + } + Tf::Gamma(g) => v.abs().powf(g).copysign(v), + Tf::AcesCct => { + if v <= 0.155_251_141_552_511 { + (v - 0.072_905_534_195_835_5) / 10.540_237_741_654_5 + } else if v < (65504f64.log2() + 9.72) / 17.52 { + 2f64.powf(v * 17.52 - 9.72) + } else { + 65504.0 + } + } + Tf::AcesCc => { + if v < ACESCC_TOE { + (2f64.powf(v * 17.52 - 9.72) - 2f64.powi(-16)) * 2.0 + } else if v < (65504f64.log2() + 9.72) / 17.52 { + 2f64.powf(v * 17.52 - 9.72) + } else { + 65504.0 + } + } + Tf::Pq => { + let (m1, m2, c1, c2, c3) = pq_consts(); + let e = v.max(0.0).powf(1.0 / m2); + let l = ((e - c1).max(0.0) / (c2 - c3 * e)).powf(1.0 / m1); + l * 100.0 + } + } + } + pub fn encode(self, v: f64) -> f64 { + match self { + Tf::Linear => v, + Tf::Srgb => { + let a = v.abs(); + let e = if a <= 0.003_130_8 { a * 12.92 } else { 1.055 * a.powf(1.0 / 2.4) - 0.055 }; + e.copysign(v) + } + Tf::Gamma(g) => v.abs().powf(1.0 / g).copysign(v), + Tf::AcesCct => { + if v <= 0.007_812_5 { + 10.540_237_741_654_5 * v + 0.072_905_534_195_835_5 + } else { + (v.log2() + 9.72) / 17.52 + } + } + Tf::AcesCc => { + if v <= 0.0 { + ACESCC_MIN + } else if v < 2f64.powi(-15) { + ((2f64.powi(-16) + v * 0.5).log2() + 9.72) / 17.52 + } else { + (v.log2() + 9.72) / 17.52 + } + } + Tf::Pq => { + let (m1, m2, c1, c2, c3) = pq_consts(); + let y = (v / 100.0).max(0.0).powf(m1); + ((c1 + c2 * y) / (1.0 + c3 * y)).powf(m2) + } + } + } +} + +fn pq_consts() -> (f64, f64, f64, f64, f64) { + (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0, 3424.0 / 4096.0, 2413.0 / 4096.0 * 32.0, 2392.0 / 4096.0 * 32.0) +} + +/// A colour space of the built-in config. +#[derive(Clone, Copy, Debug)] +pub struct Space { + pub name: &'static str, + /// Primaries and white (None = CIE XYZ itself). + pub prims: Option<([[f64; 2]; 3], [f64; 2])>, + pub tf: Tf, + /// Passes data through untouched. + pub raw: bool, +} + +pub(crate) const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space { + Space { name, prims: Some((prims, white)), tf, raw: false } +} + +/// The built-in configuration's colour spaces, in popup order. +pub(crate) const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear); + +pub(crate) const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear); + +pub(super) const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct); + +pub const SPACES: &[Space] = &[ + ACES2065_1, + ACES_CG, + ACES_CCT, + sp("Linear Rec.709 (sRGB)", REC709, D65, Tf::Linear), + sp("sRGB", REC709, D65, Tf::Srgb), + sp("Gamma 2.4 Rec.709", REC709, D65, Tf::Gamma(2.4)), + sp("Linear Rec.2020", REC2020, D65, Tf::Linear), + sp("Gamma 2.4 Rec.2020", REC2020, D65, Tf::Gamma(2.4)), + sp("Linear P3-D65", P3, D65, Tf::Linear), + sp("Display P3", P3, D65, Tf::Srgb), + Space { name: "CIE-XYZ-D65", prims: None, tf: Tf::Linear, raw: false }, + Space { name: "Raw", prims: None, tf: Tf::Linear, raw: true }, +]; + +pub fn space_names() -> Vec<&'static str> { + SPACES.iter().map(|s| s.name).collect() +} + +#[cfg(test)] +pub fn space_by_name(n: &str) -> Option<&'static Space> { + SPACES.iter().find(|s| s.name.eq_ignore_ascii_case(n)) +} + +/// Bradford chromatic adaptation from white `src` to white `dst` (xy). +pub fn bradford(src: [f64; 2], dst: [f64; 2]) -> M3 { + if src == dst { + return IDENTITY; + } + const MB: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; + let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; + let s = mul_vec(&MB, xyz(src)); + let d = mul_vec(&MB, xyz(dst)); + let diag = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]]; + mul(&invert(&MB), &mul(&diag, &MB)) +} + +impl Space { + /// Linear RGB → XYZ D65 (Bradford-adapted when `adapt`, else the space's own white). + pub fn to_ref(self, adapt: bool) -> M3 { + match self.prims { + None => IDENTITY, + Some((p, w)) => { + let m = rgb_to_xyz(p, w); + if adapt { mul(&bradford(w, D65), &m) } else { m } + } + } + } +} + +/// Linear-RGB matrix from space `a` to space `b`. +pub fn matrix(a: &Space, b: &Space) -> M3 { + mul(&invert(&b.to_ref(true)), &a.to_ref(true)) +} + +/// A compiled colour-space conversion. +#[derive(Clone, Copy, Debug)] +pub struct Xform { + pub(super) dec: Tf, + pub(super) m: [[f32; 3]; 3], + pub(super) ident: bool, + pub(super) enc: Tf, +} + +impl Xform { + pub fn new(a: &Space, b: &Space) -> Xform { + if a.raw || b.raw { + return Xform { dec: Tf::Linear, m: IDENTITY.map(|r| r.map(|v| v as f32)), ident: true, enc: Tf::Linear }; + } + let m = matrix(a, b); + let ident = (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-12)); + Xform { dec: a.tf, m: m.map(|r| r.map(|v| v as f32)), ident, enc: b.tf } + } + #[inline] + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let l = c.map(|v| self.dec.decode(v as f64) as f32); + let l = if self.ident { l } else { [0, 1, 2].map(|i| self.m[i][0] * l[0] + self.m[i][1] * l[1] + self.m[i][2] * l[2]) }; + l.map(|v| self.enc.encode(v as f64) as f32) + } +} diff --git a/crates/effects/src/ocio/tests.rs b/crates/effects/src/ocio/tests.rs new file mode 100644 index 000000000..edbc6eff2 --- /dev/null +++ b/crates/effects/src/ocio/tests.rs @@ -0,0 +1,313 @@ +use super::*; +use crate::{EffectEnv, EffectHost, Image, LayerPixels, run_fx}; + +fn close(a: M3, b: M3, tol: f64) { + for i in 0..3 { + for j in 0..3 { + assert!((a[i][j] - b[i][j]).abs() < tol, "{a:?} vs {b:?}"); + } + } +} + +#[test] +fn aces_matrices_match_published_values() { + let ap0 = space_by_name("ACES2065-1").unwrap(); + let ap1 = space_by_name("ACEScg").unwrap(); + // Academy S-2014-004 AP0 → AP1. + close( + matrix(ap0, ap1), + [ + [1.451_439_316_1, -0.236_510_746_9, -0.214_928_569_3], + [-0.076_553_773_4, 1.176_229_699_8, -0.099_675_926_4], + [0.008_316_148_4, -0.006_032_449_8, 0.997_716_301_4], + ], + 2e-4, + ); + // ACEScg → linear Rec.709 with Bradford adaptation (published ACES config value). + let lin = space_by_name("Linear Rec.709 (sRGB)").unwrap(); + close(matrix(ap1, lin), [[1.705_05, -0.621_79, -0.083_26], [-0.130_26, 1.140_80, -0.010_55], [-0.024_00, -0.128_97, 1.152_97]], 2e-3); + // White maps to white. + let w = mul_vec(&matrix(ap1, lin), [1.0, 1.0, 1.0]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-3), "{w:?}"); +} + +#[test] +fn transfer_functions_round_trip() { + for tf in [Tf::Srgb, Tf::Gamma(2.4), Tf::AcesCct, Tf::Pq] { + for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { + let r = tf.decode(tf.encode(v)); + assert!((r - v).abs() < 1e-6 * v.max(1.0), "{tf:?} {v} {r}"); + } + } + // ACEScct of 18 % grey (S-2016-001). + assert!((Tf::AcesCct.encode(0.18) - 0.413_588).abs() < 1e-5); + // PQ: 100 nits (1.0) encodes to ≈ 0.508. + assert!((Tf::Pq.encode(1.0) - 0.508).abs() < 1e-3); +} + +#[test] +fn color_space_transform_effect_round_trips() { + let img = Image::filled(4, 4, [0.2, 0.5, 0.7, 1.0]); + let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap() as u32; + let cg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap() as u32; + let a = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg))], img.clone(), 0.0, EffectEnv::default()); + assert_ne!(a.img.data, img.data); + let b = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(cg)), ("destination", Value::Enum(srgb))], a.img.clone(), 0.0, EffectEnv::default()); + let c = run_fx( + "ec.color.ociocolorspace", + &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg)), ("direction", Value::Enum(1))], + a.img, + 0.0, + EffectEnv::default(), + ); + for (x, y) in b.img.data.iter().zip(&img.data).chain(c.img.data.iter().zip(&img.data)) { + assert!((0..4).all(|k| (x[k] - y[k]).abs() < 1e-4), "{x:?} {y:?}"); + } +} + +#[test] +fn cdl_math() { + let cdl = Cdl { slope: [1.2, 1.0, 0.8], offset: [0.05, 0.0, -0.02], power: [1.1, 1.0, 0.9], sat: 0.8 }; + let c = [0.3, 0.5, 0.7]; + // Hand computation of the v1.2 formula. + let sop = [(0.3f64 * 1.2 + 0.05).powf(1.1), 0.5, (0.7f64 * 0.8 - 0.02).powf(0.9)]; + let l = sop[0] * 0.2126 + sop[1] * 0.7152 + sop[2] * 0.0722; + let want = sop.map(|v| l + 0.8 * (v - l)); + let got = cdl.apply(c, true); + for k in 0..3 { + assert!((got[k] - want[k]).abs() < 1e-12); + } + let back = cdl.invert(got, true); + for k in 0..3 { + assert!((back[k] - c[k]).abs() < 1e-9); + } + // v1.2 clamps, No Clamp keeps negatives. + let neg = Cdl { offset: [-0.5; 3], ..Cdl::default() }; + assert_eq!(neg.apply([0.2; 3], true), [0.0; 3]); + assert!((neg.apply([0.2; 3], false)[0] + 0.3).abs() < 1e-12); + let img = Image::filled(2, 2, [0.3, 0.5, 0.7, 1.0]); + let out = run_fx("ec.color.ociocdl", &[("slopeRed", num(1.2)), ("offsetRed", num(0.05)), ("powerRed", num(1.1))], img, 0.0, EffectEnv::default()); + let r = (0.3f64 * 1.2 + 0.05).powf(1.1); + assert!((out.img.get(0, 0)[0] as f64 - r).abs() < 1e-5); +} + +fn identity_lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Vec<[f32; 3]> { + let mut v = vec![]; + for b in 0..n { + for g in 0..n { + for r in 0..n { + let s = (n - 1) as f32; + v.push(f([r as f32 / s, g as f32 / s, b as f32 / s])); + } + } + } + v +} + +/// Sony Imageworks `.spi1d`, `.spi3d` and `.spimtx` (Blender's configs use them, #410), and +/// malformed ones rejected without panicking. +#[test] +fn spi_files_parse() { + let spi1d = "Version 1\nFrom 0.0 2.0\nLength 3\nComponents 1\n{\n0.0\n0.25\n1.0\n}\n"; + let x = parse_file(spi1d, "spi1d").unwrap(); + let o = x.apply([1.0, 0.5, 2.0], 1, false, ""); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.125).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); + let back = x.apply(o, 1, true, ""); + assert!((back[0] - 1.0).abs() < 1e-5 && (back[2] - 2.0).abs() < 1e-5, "{back:?}"); + let three = parse_spi1d("Version 1\nFrom 0 1\nLength 2\nComponents 3\n{\n0 0 0\n1 0.5 2\n}\n").unwrap(); + assert_eq!(three.d1, vec![[0.0; 3], [1.0, 0.5, 2.0]]); + let mut spi3d = String::from("SPILUT 1.0\n3 3\n2 2 2\n"); + for (r, g, b) in (0..8).map(|i| (i & 1, (i >> 1) & 1, i >> 2)) { + spi3d += &format!("{r} {g} {b} {} {} {}\n", r as f32 * 0.5, g, b); + } + let x = parse_file(&spi3d, "spi3d").unwrap(); + let o = x.apply([1.0, 0.5, 0.25], 1, false, ""); + assert!((o[0] - 0.5).abs() < 1e-6 && (o[1] - 0.5).abs() < 1e-6 && (o[2] - 0.25).abs() < 1e-6, "{o:?}"); + let m = parse_file("2 0 0 6553.5\n0 1 0 0\n0 0 1 0\n", "spimtx").unwrap(); + let o = m.apply([0.25, 0.5, 1.0], 1, false, ""); + assert!((o[0] - 0.6).abs() < 1e-6 && o[1] == 0.5 && o[2] == 1.0, "{o:?}"); + assert!((m.apply(o, 1, true, "")[0] - 0.25).abs() < 1e-6); + for bad in [ + "Version 1\nFrom 0 1\nLength 99999999999\nComponents 1\n{\n0\n}\n", + "Version 1\nFrom 1 0\nLength 2\nComponents 1\n{\n0\n1\n}\n", + "Version 1\nFrom 0 1\nLength 3\nComponents 1\n{\n0\n1\n}\n", + "Version 1\nFrom 0 1\nLength 2\nComponents 2\n{\n0 0\n1 1\n}\n", + ] { + assert!(parse_spi1d(bad).is_none(), "{bad}"); + } + assert!(parse_spi3d("SPILUT 1.0\n3 3\n4000 4000 4000\n").is_none()); + assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n9 0 0 1 1 1\n").is_none()); + assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n0 0 0 1 1 1\n").is_none(), "incomplete lattice"); + assert!(parse_spimtx("1 0 0\n0 1 0\n").is_none()); + assert!(parse_spimtx("NaN 0 0 0 0 1 0 0 0 0 1 0").is_none()); +} + +#[test] +fn lut_files_parse_and_interpolate() { + // .cube with a non-linear lattice: trilinear between lattice points. + let f = |c: [f32; 3]| [c[0] * c[0], c[1], 1.0 - c[2]]; + let mut cube = String::from("LUT_3D_SIZE 3\n"); + for r in identity_lattice(3, f) { + cube += &format!("{} {} {}\n", r[0], r[1], r[2]); + } + let Some(FileXform::Lut { lut, .. }) = parse_file(&cube, "cube") else { panic!() }; + // Trilinear between 0.5 (0.25) and 1.0 (1.0) at 0.75 → 0.625. + let o = lut.apply_interp([0.75, 0.3, 0.2], 1); + assert!((o[0] - 0.625).abs() < 1e-6 && (o[1] - 0.3).abs() < 1e-6 && (o[2] - 0.8).abs() < 1e-6, "{o:?}"); + // Nearest snaps. + assert_eq!(lut.apply_interp([0.74, 0.0, 0.0], 0)[0], 0.25); + // .3dl (blue fastest, 10-bit integers, mesh line). + let mut tdl = String::from("0 511 1023\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + let v = [r, g, b].map(|i| i as f64 * 511.5); + tdl += &format!("{} {} {}\n", v[0].round(), v[1].round(), (1023.0 - v[2]).round()); + } + } + } + let Some(FileXform::Lut { lut, .. }) = parse_file(&tdl, "3dl") else { panic!() }; + let o = lut.apply_interp([0.25, 0.5, 0.25], 1); + assert!((o[0] - 0.25).abs() < 2e-3 && (o[1] - 0.5).abs() < 2e-3 && (o[2] - 0.75).abs() < 2e-3, "{o:?}"); + // .csp with a squaring pre-LUT on red and an identity 2³ lattice. + let mut csp = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.25 1\n2\n0 1\n0 1\n2\n0 1\n0 1\n\n2 2 2\n"); + for r in identity_lattice(2, |c| c) { + csp += &format!("{} {} {}\n", r[0], r[1], r[2]); + } + let x = parse_file(&csp, "csp").unwrap(); + let o = x.apply([0.5, 0.4, 0.6], 2, false, ""); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.4).abs() < 1e-6, "{o:?}"); + let inv = x.apply(o, 2, true, ""); + assert!((inv[0] - 0.5).abs() < 1e-4, "{inv:?}"); + // CDL collection: pick by id. + let ccc = r#"2 2 20 0 01 1 1 + 0.5 0.5 0.51"#; + let x = parse_file(ccc, "ccc").unwrap(); + assert!((x.apply([0.4; 3], 2, false, "b")[0] - 0.2).abs() < 1e-6); + assert!((x.apply([0.4; 3], 2, false, "a")[0] - 0.8).abs() < 1e-6); + assert!((x.apply([0.4; 3], 2, false, "")[0] - 0.8).abs() < 1e-6); + // Effect with inline content; inverse of a 3D lattice. + let img = Image::filled(2, 2, [0.6, 0.3, 0.2, 1.0]); + let out = run_fx("ec.color.ociofile", &[("file", Value::Str(cube.clone()))], img.clone(), 0.0, EffectEnv::default()); + let back = run_fx("ec.color.ociofile", &[("file", Value::Str(cube)), ("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); + assert!((back.img.get(0, 0)[0] - 0.6).abs() < 1e-3, "{:?}", back.img.get(0, 0)); + assert!(parse_file("garbage", "3dl").is_none()); +} + +#[test] +fn display_and_look_transforms() { + // Linear 0.18 through the sRGB display / Standard view = sRGB encoding of 0.18. + let img = Image::filled(2, 2, [0.18, 0.18, 0.18, 1.0]); + let out = run_fx("ec.color.ociodisplay", &[], img.clone(), 0.0, EffectEnv::default()); + assert!((out.img.get(0, 0)[0] as f64 - Tf::Srgb.encode(0.18)).abs() < 1e-5); + let back = run_fx("ec.color.ociodisplay", &[("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); + assert!((back.img.get(0, 0)[0] - 0.18).abs() < 1e-5); + // Tone mapped view keeps highlights below 1. + let hot = Image::filled(1, 1, [8.0, 8.0, 8.0, 1.0]); + let tm = run_fx("ec.color.ociodisplay", &[("view", Value::Enum(1))], hot, 0.0, EffectEnv::default()); + assert!(tm.img.get(0, 0)[0] < 1.0); + assert!((tonemap_inv(tonemap(3.0)) - 3.0).abs() < 1e-9); + // Looks: Warm raises red over blue; its inverse undoes it. + let grey = Image::filled(1, 1, [0.18, 0.18, 0.18, 1.0]); + let w = run_fx("ec.color.ociolook", &[("looks", Value::Str("Warm".into()))], grey.clone(), 0.0, EffectEnv::default()); + let px = w.img.get(0, 0); + assert!(px[0] > px[2]); + let u = run_fx("ec.color.ociolook", &[("looks", Value::Str("-Warm".into()))], w.img, 0.0, EffectEnv::default()); + assert!((u.img.get(0, 0)[0] - 0.18).abs() < 1e-4 && (u.img.get(0, 0)[2] - 0.18).abs() < 1e-4); + assert_eq!(parse_looks("Warm, -Cool, nope").len(), 2); +} + +#[test] +fn profile_converter_converts_and_round_trips() { + let img = Image::filled(2, 2, [0.8, 0.4, 0.1, 1.0]); + // sRGB → Rec. 2020 (relative), then back. + let a = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(1)), ("outputProfile", Value::Enum(3))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_ne!(a.img.data, img.data); + let b = + run_fx("ec.utility.colorprofileconverter", &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], a.img, 0.0, EffectEnv::default()); + assert!((0..3).all(|k| (b.img.get(0, 0)[k] - img.get(0, 0)[k]).abs() < 1e-4)); + // Same profile both sides: identity. Working space defaults to sRGB. + let c = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(0)), ("outputProfile", Value::Enum(1))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_eq!(c.img.data, img.data); + // Perceptual pulls out-of-gamut colours inside: saturated Rec.2020 green into sRGB. + let g = Image::filled(1, 1, [0.0, 1.0, 0.0, 1.0]); + let rel = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], + g.clone(), + 0.0, + EffectEnv::default(), + ); + assert!(rel.img.get(0, 0)[0] < 0.0); + let per = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1)), ("intent", Value::Enum(0))], + g, + 0.0, + EffectEnv::default(), + ); + assert!(per.img.get(0, 0).iter().all(|v| *v >= -1e-6)); +} + +struct RefHost(Image); +impl EffectHost for RefHost { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, _: f64, _: usize) -> Option { + Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }) + } +} + +#[test] +fn color_stabilizer_matches_the_reference_frame() { + let mk = |k: f32, o: f32| { + let mut img = Image::new(40, 40); + for y in 0..40 { + for x in 0..40 { + let v = (x as f32 / 40.0) * k + o; + img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]); + } + } + img + }; + let reference = mk(1.0, 0.0); + let flicker = mk(0.8, 0.1); + let host = RefHost(reference.clone()); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let pts = [("blackPoint", Value::Vec2([5.0, 20.0])), ("midPoint", Value::Vec2([20.0, 20.0])), ("whitePoint", Value::Vec2([35.0, 20.0]))]; + let mut vals: Vec<(&str, Value)> = pts.to_vec(); + vals.push(("stabilize", Value::Enum(1))); + vals.push(("sampleSize", num(1.0))); + let out = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); + for x in [5i64, 20, 35] { + let (o, r) = (out.img.get(x, 20), reference.get(x, 20)); + assert!((o[0] - r[0]).abs() < 0.01, "x {x}: {o:?} vs {r:?}"); + } + // Deterministic, and a no-op without a host. + let again = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); + assert_eq!(out.img.data, again.img.data); + let none = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, flicker.data); + // Brightness mode corrects an offset at the black point. + let lifted = mk(1.0, 0.1); + let mut vals2: Vec<(&str, Value)> = pts.to_vec(); + vals2.push(("sampleSize", num(1.0))); + let b = run_fx("ec.color.colorstabilizer", &vals2, lifted, 0.0, env); + assert!((b.img.get(5, 20)[0] - reference.get(5, 20)[0]).abs() < 0.01); +} diff --git a/crates/effects/src/ocio_config.rs b/crates/effects/src/ocio_config.rs index 5ea844414..af860c2fc 100644 --- a/crates/effects/src/ocio_config.rs +++ b/crates/effects/src/ocio_config.rs @@ -20,7 +20,7 @@ //! * `looks` (`name`, `process_space`, `transform`, `inverse_transform`) and look strings //! (`+A, -B`, `A | B` = the first one the config has); //! * transforms: `MatrixTransform` (`matrix` 4×4 row-major, `offset`), `FileTransform` (the LUT, -//! CDL and matrix files [`super::ocio::parse_file`] reads; `interpolation`, `ccc_id`), +//! CDL and matrix files `super::ocio::parse_file` reads; `interpolation`, `ccc_id`), //! `ExponentTransform` (`value`), `ExponentWithLinearTransform` (`gamma`, `offset`, `style: //! mirror`; the power curve with a linear toe that the Academy CLF specification calls //! "moncurve"), `LogTransform` (`base`), `LogAffineTransform` (`base`, `logSideSlope`, @@ -1331,401 +1331,4 @@ pub fn load_config(src: &str) -> Result, String> { } #[cfg(test)] -mod tests { - use super::*; - - const CONFIG: &str = r#" -ocio_profile_version: 2 - -name: Test Config # a comment -search_path: "luts:other" -roles: - scene_linear: lin - color_timing: "log2" - -displays: - sRGB: - - ! {name: Standard, colorspace: gamma22} - - ! {name: Raw, colorspace: raw} - -colorspaces: - - ! - name: lin - family: "" - description: | - The reference. - Linear. - isdata: false - - - ! - name: gamma22 - aliases: [g22, "gamma 2.2"] - to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} - - - ! - name: half - from_reference: ! {matrix: [0.5, 0, 0, 0, - 0, 0.5, 0, 0, - 0, 0, 0.5, 0, - 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]} - - - ! - name: log2 - from_reference: ! - children: - - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} - - ! {base: 2} - - - ! - name: affine - to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse} - - - ! - name: lut - to_reference: ! {src: double.cube, interpolation: linear} - - - ! - name: raw - isdata: true - - - ! - name: weird - to_reference: ! {exposure: 1} -"#; - - fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { - (0..3).all(|i| (a[i] - b[i]).abs() < tol) - } - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("ec-ocio-{name}-{}", std::process::id())); - std::fs::create_dir_all(dir.join("luts")).unwrap(); - dir - } - - #[test] - fn yaml_subset() { - let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: ! {v: 3}\nd:\n- 4\n- 5\n"); - assert_eq!(y.get("a").and_then(Yaml::str), Some("1")); - assert_eq!(y.get("b").unwrap().seq().len(), 2); - assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]); - assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r")); - assert_eq!(y.get("c").unwrap().tag(), Some("T")); - assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3")); - assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent"); - } - - /// Hostile nesting (flow brackets and block indentation) is cut off instead of exhausting - /// the stack. - #[test] - fn yaml_nesting_is_bounded() { - let flow = format!("a: {}\nb: 1\n", "[".repeat(200_000)); - assert!(matches!(parse_yaml(&flow), Yaml::Map(_))); - let block: String = (0..2_000).map(|i| format!("{}k{i}:\n", " ".repeat(i))).collect(); - assert!(matches!(parse_yaml(&block), Yaml::Map(_))); - let dashes: String = (0..2_000).map(|i| format!("{}- x{i}:\n", " ".repeat(2 * i))).collect(); - let _ = parse_yaml(&dashes); - assert!(Config::parse(&flow, Path::new(".")).is_err()); - } - - #[test] - fn parses_spaces_roles_displays_and_converts() { - let dir = temp_dir("basic"); - // A 1D LUT that doubles (0..1 → 0..2). - std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap(); - let c = Config::parse(CONFIG, &dir).unwrap(); - assert_eq!(c.name, "Test Config"); - assert_eq!(c.search_path, vec!["luts", "other"]); - assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]); - // Roles and aliases resolve. - assert_eq!(c.space("scene_linear").unwrap().name, "lin"); - assert_eq!(c.space("G22").unwrap().name, "gamma22"); - assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22"); - assert_eq!(c.view("sRGB", "Standard").unwrap().1.colorspace, "gamma22"); - let sp = |n: &str| c.space(n).unwrap(); - // gamma22 → lin: decode with the exponent. - let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]); - assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}"); - // lin → gamma22: the inverse (only to_reference given). - let back = c.convert(sp("lin"), sp("gamma22"), g); - assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}"); - // The display's view is that conversion. - assert!(close(c.display_path(sp("lin"), "sRGB", "Standard").apply(g, false), [0.5, 0.25, 1.0], 1e-9)); - // lin → half: matrix and offset; and back through the inverse matrix. - let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]); - assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}"); - assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9)); - // lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2. - let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]); - assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}"); - assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9)); - // LogAffine (inverse direction as to_reference) round-trips. - let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]); - let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2); - assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}"); - assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9)); - // FileTransform found on the search path. - let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]); - assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}"); - // Data spaces pass through; unsupported transforms are reported. - assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]); - assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]); - assert_eq!(c.path(sp("lin"), sp("weird")).unsupported_list(), vec!["ExposureContrastTransform".to_string()]); - assert!(c.path(sp("lin"), sp("half")).unsupported_list().is_empty()); - // Loading from a file path (cached), and errors. - std::fs::write(dir.join("config.ocio"), CONFIG).unwrap(); - let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap(); - assert_eq!(loaded.spaces.len(), 8); - assert!(load_config("/no/such/config.ocio").is_err()); - assert!(Config::parse("just: text\n", &dir).is_err()); - } - - /// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom. - #[test] - fn effects_use_a_custom_config() { - use effectcraft_keyframe::Value; - let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - ! {name: Video, colorspace: g2}\ncolorspaces:\n - !\n name: lin\n - !\n name: g2\n to_reference: ! {value: [2, 2, 2, 1]}\n"; - let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]); - let fx = |id: &str, extra: &[(&str, Value)]| { - let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))]; - v.extend(extra.iter().cloned()); - crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0] - }; - let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]); - assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); - // Inverse direction. - let o = fx( - "ec.color.ociocolorspace", - &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))], - ); - assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); - // Display ▸ view → its colour space. - let o = fx( - "ec.color.ociodisplay", - &[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))], - ); - assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); - // An unreadable config leaves pixels alone. - let o = crate::run_fx( - "ec.color.ociocolorspace", - &[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))], - img.clone(), - 0.0, - crate::EffectEnv::default(), - ); - assert_eq!(o.img.data, img.data); - } - - /// A config laid out like Blender 4.x / 5.x's (written for this test): the reference space is - /// CIE XYZ with the E white, the D65 spaces adapt through a `.spimtx` matrix, sRGB is a - /// ColorSpaceTransform plus an ExponentWithLinearTransform, the display-referred spaces meet - /// the scene through the default view transform, and a log space uses an - /// AllocationTransform. - fn blender_like(dir: &Path) -> Config { - use effectcraft_color::space::{invert, rgb_to_xyz}; - let e_to_d65 = crate::ocio::bradford([1.0 / 3.0, 1.0 / 3.0], D65); - let rows = |m: [[f64; 3]; 3], sep: &str, end: &str| m.iter().map(|r| format!("{}{sep}{}{sep}{}{end}", r[0], r[1], r[2])).collect::>(); - std::fs::write(dir.join("luts/e_to_d65.spimtx"), rows(e_to_d65, " ", " 0\n").concat()).unwrap(); - let xyz_to_709 = rows(invert(&rgb_to_xyz(REC709, D65)), ", ", ", 0").join(", "); - let text = format!( - r#"ocio_profile_version: 2.4 -search_path: luts -roles: - reference: Linear CIE-XYZ E - scene_linear: Linear Rec.709 - cie_xyz_d65_interchange: XYZ D65 Display -default_view_transform: Standard -view_transforms: - - ! - name: Standard - from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} -displays: - sRGB: - - ! {{name: Standard, colorspace: sRGB}} - - ! {{name: Display, view_transform: Standard, display_colorspace: sRGB Display}} - - ! {{name: Log, colorspace: Log Encoding}} - - ! {{name: Log Brighter, colorspace: Log Encoding, looks: +Brighter}} - - ! {{name: Log Darker, colorspace: Log Encoding, looks: "-Brighter, Missing"}} - - ! {{name: Raw, colorspace: Non-Color}} -display_colorspaces: - - ! - name: XYZ D65 Display - isdata: false - - ! - name: sRGB Display - from_display_reference: ! - children: - - ! {{src: cie_xyz_d65_interchange, dst: Linear Rec.709}} - - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} -colorspaces: - - ! - name: Linear CIE-XYZ E - aliases: [Linear CIE-XYZ I-E] - - ! - name: Linear CIE-XYZ D65 - from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} - - ! - name: Linear Rec.709 - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ I-E, dst: Linear CIE-XYZ D65}} - - ! {{matrix: [{xyz_to_709}, 0, 0, 0, 1]}} - - ! - name: sRGB - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} - - ! - name: Log Encoding - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{allocation: lg2, vars: [-12, 12]}} - - ! - name: ACEScg - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65}} - - ! {{style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}} - - ! - name: Clipped - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{min_in_value: 0, min_out_value: 0}} - - ! - name: Graded - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! - shadows: {{rgb: [0.2, 0.2, 0.2], master: 0.35, start: 0.4, pivot: 0.1}} - - ! - name: Loop - from_scene_reference: ! {{src: Linear CIE-XYZ E, dst: Loop}} - - ! - name: Fan - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! - name: Non-Color - isdata: true -looks: - - ! - name: Brighter - process_space: Log Encoding - transform: ! {{offset: [0.05, 0.05, 0.05]}} -"# - ); - Config::parse(&text, dir).unwrap() - } - - /// The sRGB encoding (IEC 61966-2-1). - fn srgb(v: f64) -> f64 { - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } - } - - /// #410: with Blender's config, Linear Rec.709 → sRGB got no sRGB curve and a blue cast: - /// ColorSpaceTransform and ExponentWithLinearTransform passed colours through, so the - /// colours stayed in the E-white XYZ reference. Every route to sRGB now encodes greys to - /// neutral sRGB values (0.18 → 0.4614). - #[test] - fn blender_like_config_converts_linear_rec709_to_srgb() { - let dir = temp_dir("blender"); - let c = blender_like(&dir); - assert!(c.unsupported().iter().all(|t| t == "GradingToneTransform"), "{:?}", c.unsupported()); - let sp = |n: &str| c.space(n).unwrap(); - let lin = sp("scene_linear"); - let routes = [ - ("Color Space Transform", c.path(lin, sp("sRGB"))), - ("display-referred sRGB", c.path(lin, sp("sRGB Display"))), - ("v1 view", c.display_path(lin, "sRGB", "Standard")), - ("v2 view", c.display_path(lin, "sRGB", "Display")), - ]; - for (route, x) in &routes { - assert!(x.unsupported_list().is_empty(), "{route}: {:?}", x.unsupported_list()); - for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { - let o = x.apply([v; 3], false); - assert!(close(o, [srgb(v); 3], 2e-5), "{route}: {v} → {o:?}, want {}", srgb(v)); - } - let red = x.apply([1.0, 0.0, 0.0], false); - assert!(close(red, [1.0, 0.0, 0.0], 1e-4), "{route}: red {red:?}"); - // And back. - assert!(close(x.apply([srgb(0.18); 3], true), [0.18; 3], 1e-6), "{route}"); - } - // AllocationTransform lg2 and a look in its process space; data passes through. - let log = |v: f64| (v.log2() + 12.0) / 24.0; - let o = c.display_path(lin, "sRGB", "Log").apply([0.18; 3], false); - assert!(close(o, [log(0.18); 3], 1e-6), "{o:?}"); - let o = c.display_path(lin, "sRGB", "Log Brighter").apply([0.18; 3], false); - assert!(close(o, [log(0.18) + 0.05; 3], 1e-6), "{o:?}"); - let darker = c.display_path(lin, "sRGB", "Log Darker"); - assert!(close(darker.apply([0.18; 3], false), [log(0.18) - 0.05; 3], 1e-6)); - assert_eq!(darker.unsupported_list(), vec!["Look `Missing` (not in the config)".to_string()]); - assert_eq!(c.display_path(lin, "sRGB", "Raw").apply([0.3, 2.0, -1.0], false), [0.3, 2.0, -1.0]); - // BuiltinTransform: the built-in config's Linear Rec.709 → ACEScg. - let want = crate::ocio::Xform::new(&sp_builtin("Linear Rec.709 (sRGB)"), &sp_builtin("ACEScg")).apply([1.0, 0.25, 0.0]); - let got = c.convert(lin, sp("ACEScg"), [1.0, 0.25, 0.0]); - assert!(close(got, want.map(f64::from), 1e-4), "{got:?} vs {want:?}"); - // RangeTransform with only a minimum clamps below and shifts nothing. - let o = c.convert(lin, sp("Clipped"), [-0.5, 0.5, 2.0]); - assert!(close(o, [0.0, 0.5, 2.0], 1e-6), "{o:?}"); - // Unsupported steps are listed (and pass through); loops stop. - assert_eq!(c.path(lin, sp("Graded")).unsupported_list(), vec!["GradingToneTransform".to_string()]); - assert!(c.path(lin, sp("Loop")).unsupported_list()[0].contains("nested")); - let fan = c.path(lin, sp("Fan")); - assert!(!fan.unsupported_list().is_empty()); - assert!(matches!(&fan, Xf::Group(v) if v.len() <= MAX_OPS + 1)); - } - - fn sp_builtin(name: &str) -> Space { - *crate::ocio::SPACES.iter().find(|s| s.name == name).unwrap() - } - - /// ExponentWithLinearTransform is the sRGB curve at gamma 2.4 / offset 0.055, continuous at - /// its break point and inverted exactly; mirror mirrors negatives. - #[test] - fn moncurve_matches_srgb_and_inverts() { - for v in [-0.2, 0.0, 0.002, 0.039, 0.0393, 0.2, 0.5, 1.0, 3.0] { - let lin = moncurve(v, 2.4, 0.055, false, false); - assert!((moncurve(lin, 2.4, 0.055, true, false) - v).abs() < 1e-12, "{v}"); - if v >= 0.0 { - assert!((moncurve(srgb(v), 2.4, 0.055, false, false) - v).abs() < 2e-5, "{v}"); - } - } - let xb = 0.055 / 1.4; - assert!((moncurve(xb - 1e-12, 2.4, 0.055, false, false) - moncurve(xb + 1e-12, 2.4, 0.055, false, false)).abs() < 1e-9); - assert_eq!(moncurve(-0.5, 2.4, 0.055, false, true), -moncurve(0.5, 2.4, 0.055, false, false)); - assert_eq!(moncurve(-0.5, 1.0, 0.0, false, false), 0.0); - } - - #[test] - fn builtin_styles() { - for style in ["IDENTITY", "DISPLAY - CIE-XYZ-D65_to_sRGB", "display - cie-xyz-d65_to_rec.2100-pq", "ACEScc_to_ACES2065-1", "ACEScct_to_ACES2065-1"] { - assert!(builtin(style).is_some(), "{style}"); - } - assert!(builtin("ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0").is_none()); - // D65 white in XYZ is display white. - let xyz = rgb_white_xyz(); - for style in ["DISPLAY - CIE-XYZ-D65_to_sRGB", "DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709", "DISPLAY - CIE-XYZ-D65_to_DisplayP3"] { - let o = builtin(style).unwrap().apply(xyz, false); - assert!(close(o, [1.0; 3], 1e-6), "{style}: {o:?}"); - } - // PQ: 1.0 = 100 nits → code value ≈ 0.508. - let pq = builtin("DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ").unwrap().apply(xyz, false); - assert!((pq[0] - 0.508).abs() < 1e-3, "{pq:?}"); - // ACEScc and ACEScct of 0.18 (ACES2065-1 grey stays grey). - for (style, code) in [("ACEScc_to_ACES2065-1", 0.413_588_402_492_442_1), ("ACEScct_to_ACES2065-1", 0.413_588_402_492_442_1)] { - let o = builtin(style).unwrap().apply([code; 3], false); - assert!(close(o, [0.18; 3], 1e-6), "{style}: {o:?}"); - } - } - - fn rgb_white_xyz() -> [f64; 3] { - [D65[0] / D65[1], 1.0, (1.0 - D65[0] - D65[1]) / D65[1]] - } -} +mod tests; diff --git a/crates/effects/src/ocio_config/tests.rs b/crates/effects/src/ocio_config/tests.rs new file mode 100644 index 000000000..f534fe0e2 --- /dev/null +++ b/crates/effects/src/ocio_config/tests.rs @@ -0,0 +1,394 @@ +use super::*; + +const CONFIG: &str = r#" +ocio_profile_version: 2 + +name: Test Config # a comment +search_path: "luts:other" +roles: + scene_linear: lin + color_timing: "log2" + +displays: + sRGB: + - ! {name: Standard, colorspace: gamma22} + - ! {name: Raw, colorspace: raw} + +colorspaces: + - ! + name: lin + family: "" + description: | + The reference. + Linear. + isdata: false + + - ! + name: gamma22 + aliases: [g22, "gamma 2.2"] + to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: half + from_reference: ! {matrix: [0.5, 0, 0, 0, + 0, 0.5, 0, 0, + 0, 0, 0.5, 0, + 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]} + + - ! + name: log2 + from_reference: ! + children: + - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} + - ! {base: 2} + + - ! + name: affine + to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse} + + - ! + name: lut + to_reference: ! {src: double.cube, interpolation: linear} + + - ! + name: raw + isdata: true + + - ! + name: weird + to_reference: ! {exposure: 1} +"#; + +fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() < tol) +} + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-ocio-{name}-{}", std::process::id())); + std::fs::create_dir_all(dir.join("luts")).unwrap(); + dir +} + +#[test] +fn yaml_subset() { + let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: ! {v: 3}\nd:\n- 4\n- 5\n"); + assert_eq!(y.get("a").and_then(Yaml::str), Some("1")); + assert_eq!(y.get("b").unwrap().seq().len(), 2); + assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]); + assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r")); + assert_eq!(y.get("c").unwrap().tag(), Some("T")); + assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3")); + assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent"); +} + +/// Hostile nesting (flow brackets and block indentation) is cut off instead of exhausting +/// the stack. +#[test] +fn yaml_nesting_is_bounded() { + let flow = format!("a: {}\nb: 1\n", "[".repeat(200_000)); + assert!(matches!(parse_yaml(&flow), Yaml::Map(_))); + let block: String = (0..2_000).map(|i| format!("{}k{i}:\n", " ".repeat(i))).collect(); + assert!(matches!(parse_yaml(&block), Yaml::Map(_))); + let dashes: String = (0..2_000).map(|i| format!("{}- x{i}:\n", " ".repeat(2 * i))).collect(); + let _ = parse_yaml(&dashes); + assert!(Config::parse(&flow, Path::new(".")).is_err()); +} + +#[test] +fn parses_spaces_roles_displays_and_converts() { + let dir = temp_dir("basic"); + // A 1D LUT that doubles (0..1 → 0..2). + std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap(); + let c = Config::parse(CONFIG, &dir).unwrap(); + assert_eq!(c.name, "Test Config"); + assert_eq!(c.search_path, vec!["luts", "other"]); + assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]); + // Roles and aliases resolve. + assert_eq!(c.space("scene_linear").unwrap().name, "lin"); + assert_eq!(c.space("G22").unwrap().name, "gamma22"); + assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22"); + assert_eq!(c.view("sRGB", "Standard").unwrap().1.colorspace, "gamma22"); + let sp = |n: &str| c.space(n).unwrap(); + // gamma22 → lin: decode with the exponent. + let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]); + assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}"); + // lin → gamma22: the inverse (only to_reference given). + let back = c.convert(sp("lin"), sp("gamma22"), g); + assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}"); + // The display's view is that conversion. + assert!(close(c.display_path(sp("lin"), "sRGB", "Standard").apply(g, false), [0.5, 0.25, 1.0], 1e-9)); + // lin → half: matrix and offset; and back through the inverse matrix. + let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]); + assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}"); + assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9)); + // lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2. + let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]); + assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}"); + assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9)); + // LogAffine (inverse direction as to_reference) round-trips. + let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]); + let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2); + assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}"); + assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9)); + // FileTransform found on the search path. + let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]); + assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}"); + // Data spaces pass through; unsupported transforms are reported. + assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]); + assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]); + assert_eq!(c.path(sp("lin"), sp("weird")).unsupported_list(), vec!["ExposureContrastTransform".to_string()]); + assert!(c.path(sp("lin"), sp("half")).unsupported_list().is_empty()); + // Loading from a file path (cached), and errors. + std::fs::write(dir.join("config.ocio"), CONFIG).unwrap(); + let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap(); + assert_eq!(loaded.spaces.len(), 8); + assert!(load_config("/no/such/config.ocio").is_err()); + assert!(Config::parse("just: text\n", &dir).is_err()); +} + +/// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom. +#[test] +fn effects_use_a_custom_config() { + use effectcraft_keyframe::Value; + let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - ! {name: Video, colorspace: g2}\ncolorspaces:\n - !\n name: lin\n - !\n name: g2\n to_reference: ! {value: [2, 2, 2, 1]}\n"; + let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]); + let fx = |id: &str, extra: &[(&str, Value)]| { + let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))]; + v.extend(extra.iter().cloned()); + crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0] + }; + let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); + // Inverse direction. + let o = + fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))]); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // Display ▸ view → its colour space. + let o = fx( + "ec.color.ociodisplay", + &[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))], + ); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // An unreadable config leaves pixels alone. + let o = crate::run_fx( + "ec.color.ociocolorspace", + &[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))], + img.clone(), + 0.0, + crate::EffectEnv::default(), + ); + assert_eq!(o.img.data, img.data); +} + +/// A config laid out like Blender 4.x / 5.x's (written for this test): the reference space is +/// CIE XYZ with the E white, the D65 spaces adapt through a `.spimtx` matrix, sRGB is a +/// ColorSpaceTransform plus an ExponentWithLinearTransform, the display-referred spaces meet +/// the scene through the default view transform, and a log space uses an +/// AllocationTransform. +fn blender_like(dir: &Path) -> Config { + use effectcraft_color::space::{invert, rgb_to_xyz}; + let e_to_d65 = crate::ocio::bradford([1.0 / 3.0, 1.0 / 3.0], D65); + let rows = |m: [[f64; 3]; 3], sep: &str, end: &str| m.iter().map(|r| format!("{}{sep}{}{sep}{}{end}", r[0], r[1], r[2])).collect::>(); + std::fs::write(dir.join("luts/e_to_d65.spimtx"), rows(e_to_d65, " ", " 0\n").concat()).unwrap(); + let xyz_to_709 = rows(invert(&rgb_to_xyz(REC709, D65)), ", ", ", 0").join(", "); + let text = format!( + r#"ocio_profile_version: 2.4 +search_path: luts +roles: + reference: Linear CIE-XYZ E + scene_linear: Linear Rec.709 + cie_xyz_d65_interchange: XYZ D65 Display +default_view_transform: Standard +view_transforms: + - ! + name: Standard + from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} +displays: + sRGB: + - ! {{name: Standard, colorspace: sRGB}} + - ! {{name: Display, view_transform: Standard, display_colorspace: sRGB Display}} + - ! {{name: Log, colorspace: Log Encoding}} + - ! {{name: Log Brighter, colorspace: Log Encoding, looks: +Brighter}} + - ! {{name: Log Darker, colorspace: Log Encoding, looks: "-Brighter, Missing"}} + - ! {{name: Raw, colorspace: Non-Color}} +display_colorspaces: + - ! + name: XYZ D65 Display + isdata: false + - ! + name: sRGB Display + from_display_reference: ! + children: + - ! {{src: cie_xyz_d65_interchange, dst: Linear Rec.709}} + - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} +colorspaces: + - ! + name: Linear CIE-XYZ E + aliases: [Linear CIE-XYZ I-E] + - ! + name: Linear CIE-XYZ D65 + from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} + - ! + name: Linear Rec.709 + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ I-E, dst: Linear CIE-XYZ D65}} + - ! {{matrix: [{xyz_to_709}, 0, 0, 0, 1]}} + - ! + name: sRGB + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} + - ! + name: Log Encoding + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{allocation: lg2, vars: [-12, 12]}} + - ! + name: ACEScg + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65}} + - ! {{style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}} + - ! + name: Clipped + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{min_in_value: 0, min_out_value: 0}} + - ! + name: Graded + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! + shadows: {{rgb: [0.2, 0.2, 0.2], master: 0.35, start: 0.4, pivot: 0.1}} + - ! + name: Loop + from_scene_reference: ! {{src: Linear CIE-XYZ E, dst: Loop}} + - ! + name: Fan + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! + name: Non-Color + isdata: true +looks: + - ! + name: Brighter + process_space: Log Encoding + transform: ! {{offset: [0.05, 0.05, 0.05]}} +"# + ); + Config::parse(&text, dir).unwrap() +} + +/// The sRGB encoding (IEC 61966-2-1). +fn srgb(v: f64) -> f64 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// #410: with Blender's config, Linear Rec.709 → sRGB got no sRGB curve and a blue cast: +/// ColorSpaceTransform and ExponentWithLinearTransform passed colours through, so the +/// colours stayed in the E-white XYZ reference. Every route to sRGB now encodes greys to +/// neutral sRGB values (0.18 → 0.4614). +#[test] +fn blender_like_config_converts_linear_rec709_to_srgb() { + let dir = temp_dir("blender"); + let c = blender_like(&dir); + assert!(c.unsupported().iter().all(|t| t == "GradingToneTransform"), "{:?}", c.unsupported()); + let sp = |n: &str| c.space(n).unwrap(); + let lin = sp("scene_linear"); + let routes = [ + ("Color Space Transform", c.path(lin, sp("sRGB"))), + ("display-referred sRGB", c.path(lin, sp("sRGB Display"))), + ("v1 view", c.display_path(lin, "sRGB", "Standard")), + ("v2 view", c.display_path(lin, "sRGB", "Display")), + ]; + for (route, x) in &routes { + assert!(x.unsupported_list().is_empty(), "{route}: {:?}", x.unsupported_list()); + for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { + let o = x.apply([v; 3], false); + assert!(close(o, [srgb(v); 3], 2e-5), "{route}: {v} → {o:?}, want {}", srgb(v)); + } + let red = x.apply([1.0, 0.0, 0.0], false); + assert!(close(red, [1.0, 0.0, 0.0], 1e-4), "{route}: red {red:?}"); + // And back. + assert!(close(x.apply([srgb(0.18); 3], true), [0.18; 3], 1e-6), "{route}"); + } + // AllocationTransform lg2 and a look in its process space; data passes through. + let log = |v: f64| (v.log2() + 12.0) / 24.0; + let o = c.display_path(lin, "sRGB", "Log").apply([0.18; 3], false); + assert!(close(o, [log(0.18); 3], 1e-6), "{o:?}"); + let o = c.display_path(lin, "sRGB", "Log Brighter").apply([0.18; 3], false); + assert!(close(o, [log(0.18) + 0.05; 3], 1e-6), "{o:?}"); + let darker = c.display_path(lin, "sRGB", "Log Darker"); + assert!(close(darker.apply([0.18; 3], false), [log(0.18) - 0.05; 3], 1e-6)); + assert_eq!(darker.unsupported_list(), vec!["Look `Missing` (not in the config)".to_string()]); + assert_eq!(c.display_path(lin, "sRGB", "Raw").apply([0.3, 2.0, -1.0], false), [0.3, 2.0, -1.0]); + // BuiltinTransform: the built-in config's Linear Rec.709 → ACEScg. + let want = crate::ocio::Xform::new(&sp_builtin("Linear Rec.709 (sRGB)"), &sp_builtin("ACEScg")).apply([1.0, 0.25, 0.0]); + let got = c.convert(lin, sp("ACEScg"), [1.0, 0.25, 0.0]); + assert!(close(got, want.map(f64::from), 1e-4), "{got:?} vs {want:?}"); + // RangeTransform with only a minimum clamps below and shifts nothing. + let o = c.convert(lin, sp("Clipped"), [-0.5, 0.5, 2.0]); + assert!(close(o, [0.0, 0.5, 2.0], 1e-6), "{o:?}"); + // Unsupported steps are listed (and pass through); loops stop. + assert_eq!(c.path(lin, sp("Graded")).unsupported_list(), vec!["GradingToneTransform".to_string()]); + assert!(c.path(lin, sp("Loop")).unsupported_list()[0].contains("nested")); + let fan = c.path(lin, sp("Fan")); + assert!(!fan.unsupported_list().is_empty()); + assert!(matches!(&fan, Xf::Group(v) if v.len() <= MAX_OPS + 1)); +} + +fn sp_builtin(name: &str) -> Space { + *crate::ocio::SPACES.iter().find(|s| s.name == name).unwrap() +} + +/// ExponentWithLinearTransform is the sRGB curve at gamma 2.4 / offset 0.055, continuous at +/// its break point and inverted exactly; mirror mirrors negatives. +#[test] +fn moncurve_matches_srgb_and_inverts() { + for v in [-0.2, 0.0, 0.002, 0.039, 0.0393, 0.2, 0.5, 1.0, 3.0] { + let lin = moncurve(v, 2.4, 0.055, false, false); + assert!((moncurve(lin, 2.4, 0.055, true, false) - v).abs() < 1e-12, "{v}"); + if v >= 0.0 { + assert!((moncurve(srgb(v), 2.4, 0.055, false, false) - v).abs() < 2e-5, "{v}"); + } + } + let xb = 0.055 / 1.4; + assert!((moncurve(xb - 1e-12, 2.4, 0.055, false, false) - moncurve(xb + 1e-12, 2.4, 0.055, false, false)).abs() < 1e-9); + assert_eq!(moncurve(-0.5, 2.4, 0.055, false, true), -moncurve(0.5, 2.4, 0.055, false, false)); + assert_eq!(moncurve(-0.5, 1.0, 0.0, false, false), 0.0); +} + +#[test] +fn builtin_styles() { + for style in ["IDENTITY", "DISPLAY - CIE-XYZ-D65_to_sRGB", "display - cie-xyz-d65_to_rec.2100-pq", "ACEScc_to_ACES2065-1", "ACEScct_to_ACES2065-1"] { + assert!(builtin(style).is_some(), "{style}"); + } + assert!(builtin("ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0").is_none()); + // D65 white in XYZ is display white. + let xyz = rgb_white_xyz(); + for style in ["DISPLAY - CIE-XYZ-D65_to_sRGB", "DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709", "DISPLAY - CIE-XYZ-D65_to_DisplayP3"] { + let o = builtin(style).unwrap().apply(xyz, false); + assert!(close(o, [1.0; 3], 1e-6), "{style}: {o:?}"); + } + // PQ: 1.0 = 100 nits → code value ≈ 0.508. + let pq = builtin("DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ").unwrap().apply(xyz, false); + assert!((pq[0] - 0.508).abs() < 1e-3, "{pq:?}"); + // ACEScc and ACEScct of 0.18 (ACES2065-1 grey stays grey). + for (style, code) in [("ACEScc_to_ACES2065-1", 0.413_588_402_492_442_1), ("ACEScct_to_ACES2065-1", 0.413_588_402_492_442_1)] { + let o = builtin(style).unwrap().apply([code; 3], false); + assert!(close(o, [0.18; 3], 1e-6), "{style}: {o:?}"); + } +} + +fn rgb_white_xyz() -> [f64; 3] { + [D65[0] / D65[1], 1.0, (1.0 - D65[0] - D65[1]) / D65[1]] +} diff --git a/crates/effects/src/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 a6c80e742..4917cc231 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -560,10 +560,14 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(V && fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps()) && let Some(fr) = fc.frames.get(&f) { - let a = rle::decode(&fr.a)?; - let bnd = rle::decode(&fr.b)?; let [fw, fh] = fc.size; - if a.len() == fw * fh && bnd.len() == fw * fh { + let expected = fw.checked_mul(fh)?; + if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { + return None; + } + let a = rle::decode_bounded(&fr.a, expected)?; + let bnd = rle::decode_bounded(&fr.b, expected)?; + if a.len() == expected && bnd.len() == expected { let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect(); let (alpha, band) = if fw == w && fh == h { (af, bnd) diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 14146a9f4..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..c11c3f886 --- /dev/null +++ b/crates/effects/src/sim/common.rs @@ -0,0 +1,25 @@ +use super::*; + +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..973b7b5e8 --- /dev/null +++ b/crates/effects/src/sim/engine.rs @@ -0,0 +1,287 @@ +use super::common::{h, hs}; +use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys}; +use super::sprites::{Shape, Sprite}; +use super::*; + +// ------------------------------------------------------------------ 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], 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'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..f8af6cba0 --- /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::*; + +// ------------------------------------------------------------------ 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..a688f1046 --- /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::*; + +/// 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..982579a5c --- /dev/null +++ b/crates/effects/src/sim/sprites.rs @@ -0,0 +1,361 @@ +use super::*; + +// ------------------------------------------------------------------ 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..cac0b023c --- /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::*; + +// ------------------------------------------------------------------ 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 cf7322bda..497b60f16 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -16,2683 +16,28 @@ use crate::sim::{Acc, Post, Shape, Sprite, SpritePlan, h, hs, pct, pt, raster, s use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; -// ------------------------------------------------------------------ 3D pieces - -pub(crate) type M3 = [[f64; 3]; 3]; - -fn mat_mul(a: &M3, b: &M3) -> M3 { - std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) -} - -/// Rotation from Euler angles (radians), applied X then Y then Z. -fn rot_xyz(x: f64, y: f64, z: f64) -> M3 { - let (sx, cx) = x.sin_cos(); - let (sy, cy) = y.sin_cos(); - let (sz, cz) = z.sin_cos(); - let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; - let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; - let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; - mat_mul(&rz, &mat_mul(&ry, &rx)) -} - -/// Rotation by `angle` about unit `axis` (Rodrigues). -pub(crate) fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { - let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt(); - if l < 1e-12 || angle == 0.0 { - return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - } - let (x, y, z) = (axis[0] / l, axis[1] / l, axis[2] / l); - let (s, c) = angle.sin_cos(); - let t = 1.0 - c; - [ - [t * x * x + c, t * x * y - s * z, t * x * z + s * y], - [t * x * y + s * z, t * y * y + c, t * y * z - s * x], - [t * x * z - s * y, t * y * z + s * x, t * z * z + c], - ] -} - -fn inv3(m: &M3) -> Option { - let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) - + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); - if det.abs() < 1e-12 { - return None; - } - let d = 1.0 / det; - Some([ - [(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d], - [(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d], - [(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d], - ]) -} - -/// Perspective camera looking down +z at the layer plane z = 0, `d` pixels away, centred on -/// `c` (buffer pixels): screen = c + (P - c) · d / (d + z). -#[derive(Clone, Copy)] -struct Cam { - c: [f64; 2], - d: f64, -} - -impl Cam { - fn proj(self) -> Proj { - Proj::simple(self.c, self.d) - } -} - -/// A planar textured piece ready to draw. -#[derive(Clone, Copy, Debug)] -pub struct Piece { - /// Screen (buffer px, homogeneous) → piece-local (a, b, w). - pub inv: [[f64; 3]; 3], - /// Rest centre of the piece in texture pixels. - pub c0: [f32; 2], - /// Convex polygon (texture pixels), counter-clockwise in y-down space. - pub poly: [[f32; 2]; 6], - pub n: u8, - pub bbox: [f32; 4], - pub shade: f32, - /// Lighting: colour multiplier and additive specular highlight. - pub tint: [f32; 3], - pub spec: [f32; 3], - pub alpha: f32, - /// Flat straight colour instead of the texture. - pub flat: Option<[f32; 3]>, - /// Facing away from the camera (draw the back texture). - pub back: bool, - /// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically - /// (flipped about the X axis), 2 horizontally (about the Y axis). - pub back_mirror: u8, - depth: f64, -} - -/// A piece texture: the layer itself or another image (buffer-aligned). -#[derive(Clone, Debug)] -pub enum PieceTex { - Layer, - Image(Image), -} - -/// What a piece-drawing effect (Card Dance, Shatter, Card Wipe) draws this frame: pieces sorted -/// far to near over a transparent frame, textured by `front` (and `back` for pieces facing -/// away; none = they take `front`). -#[derive(Clone, Debug)] -pub struct PiecePlan { - pub pieces: Vec, - pub front: PieceTex, - pub back: Option, -} - -impl PiecePlan { - /// Rasterise over a transparent frame of the layer `b`'s size. - pub fn finish(&self, mut b: Buf) -> Buf { - let mut out = Image::new(b.img.width, b.img.height); - let front = match &self.front { - PieceTex::Layer => &b.img, - PieceTex::Image(i) => i, - }; - let back = self.back.as_ref().map(|t| match t { - PieceTex::Layer => &b.img, - PieceTex::Image(i) => i, - }); - draw_pieces(&mut out, &self.pieces, front, back); - b.img = out; - b - } -} - -/// The piece plan of Card Dance, Shatter (Rendered view) or Card Wipe for layer `b` (its pixels -/// may be read: gradient maps and textures default to the layer), `None` for other effects or -/// views drawn otherwise. -pub fn piece_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.sim.carddance" => Some(card_dance_plan(ctx, b)), - "ec.sim.shatter" if ctx.params.e("view") == 0 => match shatter_impl(ctx, b) { - ShatterPlan::Pieces(plan) => Some(plan), - ShatterPlan::Wire(_) => None, - }, - "ec.transition.cardwipe" => crate::transition::card_wipe_plan(ctx, b), - _ => None, - } -} - -impl Piece { - /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its - /// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`. - pub(crate) fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: impl Into) -> Option { - let cam: Proj = cam.into(); - let n = poly.len().min(6); - if n < 3 { - return None; - } - let m = &cam.m; - let row = |i: usize| -> [f64; 3] { - [ - m[i][0] * r[0][0] + m[i][1] * r[1][0] + m[i][2] * r[2][0], - m[i][0] * r[0][1] + m[i][1] * r[1][1] + m[i][2] * r[2][1], - m[i][0] * pos[0] + m[i][1] * pos[1] + m[i][2] * pos[2] + m[i][3], - ] - }; - let hm: M3 = [row(0), row(1), row(2)]; - let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; - for v in &poly[..n] { - let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64); - let w = hm[2][0] * a + hm[2][1] * b + hm[2][2]; - if w < cam.near { - return None; - } - let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32; - let sy = ((hm[1][0] * a + hm[1][1] * b + hm[1][2]) / w) as f32; - bb = [bb[0].min(sx), bb[1].min(sy), bb[2].max(sx), bb[3].max(sy)]; - } - let inv = inv3(&hm)?; - let mut pp = [[0.0f32; 2]; 6]; - pp[..n].copy_from_slice(&poly[..n]); - // Normalise winding so "inside" is cross >= 0 for every edge. - let area: f32 = (0..n).map(|i| pp[i][0] * pp[(i + 1) % n][1] - pp[(i + 1) % n][0] * pp[i][1]).sum(); - if area < 0.0 { - pp[..n].reverse(); - } - // The front faces -z at rest; it faces the camera when its normal points at the eye. - let eye = cam.eye(); - let nrm = [-r[0][2], -r[1][2], -r[2][2]]; - let to_eye = [eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]]; - let facing = nrm[0] * to_eye[0] + nrm[1] * to_eye[1] + nrm[2] * to_eye[2]; - let depth = hm[2][2]; - Some(Piece { - inv, - c0, - poly: pp, - n: n as u8, - bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0], - shade: 1.0, - tint: [1.0; 3], - spec: [0.0; 3], - alpha: 1.0, - flat: None, - back: facing < 0.0, - back_mirror: 0, - depth, - }) - } - - /// Lambert factor of the piece normal against a light shining along +z. - fn facing(r: &M3) -> f32 { - r[2][2].abs() as f32 - } - - /// Light the piece (rotation `r`, centre `pos`) with Lighting / Material. - pub(crate) fn light(&mut self, l: &Lighting, r: &M3, pos: [f64; 3], eye: [f64; 3]) { - let (t, s) = l.shade([-r[0][2], -r[1][2], -r[2][2]], pos, eye); - self.tint = t; - self.spec = s; - } -} - -impl From for Proj { - fn from(c: Cam) -> Proj { - c.proj() - } -} - -/// Draw pieces (already sorted far → near) over `out`. -pub(crate) fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { - raster( - out, - pieces, - |q| Some(q.bbox), - |q, x, y| { - let (xf, yf) = (x as f64, y as f64); - let m = &q.inv; - let w = m[2][0] * xf + m[2][1] * yf + m[2][2]; - if w.abs() < 1e-12 { - return None; - } - let u = ((m[0][0] * xf + m[0][1] * yf + m[0][2]) / w) as f32 + q.c0[0]; - let v = ((m[1][0] * xf + m[1][1] * yf + m[1][2]) / w) as f32 + q.c0[1]; - let n = q.n as usize; - let mut md = f32::INFINITY; - for i in 0..n { - let a = q.poly[i]; - let b = q.poly[(i + 1) % n]; - let (ex, ey) = (b[0] - a[0], b[1] - a[1]); - let l = (ex * ex + ey * ey).sqrt().max(1e-6); - md = md.min((ex * (v - a[1]) - ey * (u - a[0])) / l); - } - // Coverage extends half a pixel past the edge so abutting pieces leave no seams. - let cov = (md + 1.0).clamp(0.0, 1.0); - if cov <= 0.0 { - return None; - } - let (c, a) = match q.flat { - Some(c) => (c, 1.0), - None => { - let tex = if q.back { back.unwrap_or(front) } else { front }; - let (u, v) = match (q.back, q.back_mirror) { - (true, 1) => (u, 2.0 * q.c0[1] - v), - (true, 2) => (2.0 * q.c0[0] - u, v), - _ => (u, v), - }; - unpremul(tex.sample_bilinear(u as f64, v as f64)) - } - }; - let a = a * cov * q.alpha; - if a <= 1e-6 { - return None; - } - let k = q.shade; - let o: [f32; 3] = std::array::from_fn(|i| c[i] * k * q.tint[i] + q.spec[i]); - Some([o[0] * a, o[1] * a, o[2] * a, a]) - }, - Acc::Over, - ); -} - -pub(crate) fn sort_far_first(pieces: &mut [Piece]) { - pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth)); -} - -// ------------------------------------------------------------------ CC Ball Action - -fn ball_action(ctx: &EffectCtx, b: Buf) -> Buf { - ball_action_plan(ctx, &b).finish(b) -} - -/// CC Ball Action's balls (far to near), coloured by the layer's pixels. -fn ball_action_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { - let pr = ctx.params; - let scatter = pr.f("scatter") as f32; - let axis = pr.e("rotationAxis"); - let rot = pr.f("rotation").to_radians(); - let twist_prop = pr.e("twistProperty"); - let twist = pr.f("twistAngle").to_radians(); - let spacing = pr.f("gridSpacing").max(1.0) as f32; - let size = pr.f("ballSize") as f32 / 100.0; - let instab = pr.f("instabilityState").to_radians() as f32; - let seed = ctx.seed.wrapping_mul(0x2545f491); - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let s = b.scale as f32; - let nx = (lw / spacing).ceil().max(1.0) as u32; - let ny = (lh / spacing).ceil().max(1.0) as u32; - let d = 2.0 * lw.max(lh) as f64; - let (cx, cy) = (lw as f64 * 0.5, lh as f64 * 0.5); - let src = &b.img; - let ax: [f64; 3] = match axis { - 0 => [1.0, 0.0, 0.0], - 1 => [0.0, 1.0, 0.0], - 2 => [0.0, 0.0, 1.0], - 3 => [1.0, 1.0, 0.0], - 4 => [1.0, 0.0, 1.0], - 5 => [0.0, 1.0, 1.0], - _ => [1.0, 1.0, 1.0], - }; - let maxr = ((lw * lw + lh * lh).sqrt() * 0.5).max(1.0); - let mut balls: Vec<(f64, Sprite)> = (0..nx * ny) - .into_par_iter() - .filter_map(|i| { - let gx = (i % nx) as f32 * spacing + spacing * 0.5; - let gy = (i / nx) as f32 * spacing + spacing * 0.5; - let (sx, sy) = b.to_px([gx as f64, gy as f64]); - let (c, a) = unpremul(src.sample_bilinear(sx, sy)); - if a <= 0.0 || size <= 0.0 { - return None; - } - let tp = match twist_prop { - 0 => gx / lw.max(1.0), - 1 => gy / lh.max(1.0), - 2 => ((gx - cx as f32).hypot(gy - cy as f32)) / maxr, - _ => crate::util::rgb3([c[0], c[1], c[2], 0.0]).iter().sum::() / 3.0, - } as f64; - let ang = rot + twist * tp; - // Scatter: random 3D offsets whose direction drifts with the instability state. - let ph = h(i, 1, seed) * std::f32::consts::TAU + instab; - let sc = scatter * h(i, 2, seed); - let off = [(sc * ph.cos()) as f64, (sc * ph.sin()) as f64, (scatter * hs(i, 3, seed)) as f64]; - let p0 = [gx as f64 - cx + off[0], gy as f64 - cy + off[1], off[2]]; - let m = rot_axis(ax, ang); - let q: [f64; 3] = std::array::from_fn(|k| m[k][0] * p0[0] + m[k][1] * p0[1] + m[k][2] * p0[2]); - if d + q[2] <= d * 0.05 { - return None; - } - let persp = d / (d + q[2]); - let (bx, by) = b.to_px([cx + q[0] * persp, cy + q[1] * persp]); - let r = spacing * 0.5 * size * persp as f32 * s; - Some((q[2], Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a], Shape::Sphere))) - }) - .collect(); - balls.sort_by(|a, b| b.0.total_cmp(&a.0)); - let sprites: Vec = balls.into_iter().map(|(_, s)| s).collect(); - SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: Post::Replace } -} - -// ------------------------------------------------------------------ CC Pixel Polly - -fn pixel_polly(ctx: &EffectCtx, b: Buf) -> Buf { - match pixel_polly_plan(ctx, &b) { - Some(plan) => plan.finish(b), - None => b, - } -} - -/// CC Pixel Polly's pieces, `None` before Start Time (the layer as is). -fn pixel_polly_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let t = ctx.time - pr.f("startTime"); - if t <= 0.0 { - return None; - } - let force = pr.f("force"); - let gravity = pr.f("gravity"); - let spin = pr.f("spinning").to_radians(); - let fc = pr.v2("forceCenter"); - let dir_rand = pr.f("directionRandomness") / 100.0; - let spd_rand = pr.f("speedRandomness") / 100.0; - let g = pr.f("gridSpacing").max(1.0); - let object = pr.e("object"); - let depth_sort = pr.b("enableDepthSort"); - let seed = ctx.seed.wrapping_mul(0x632be5ab); - let [lw, lh] = ctx.layer_size; - let s = b.scale; - let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; - let nx = (lw / g).ceil().max(1.0) as u32; - let ny = (lh / g).ceil().max(1.0) as u32; - let tri = object <= 1; - let textured = object == 1 || object == 3; - let maxd = lw.max(lh); - let src = &b.img; - let per = if tri { 2 } else { 1 }; - let mut pieces: Vec = (0..nx * ny * per) - .into_par_iter() - .filter_map(|i| { - let cell = i / per; - let (x0, y0) = ((cell % nx) as f64 * g, (cell / nx) as f64 * g); - let poly: Vec<[f64; 2]> = if !tri { - vec![[x0, y0], [x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] - } else if i % 2 == 0 { - vec![[x0, y0], [x0 + g, y0], [x0, y0 + g]] - } else { - vec![[x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] - }; - let c = [poly.iter().map(|v| v[0]).sum::() / poly.len() as f64, poly.iter().map(|v| v[1]).sum::() / poly.len() as f64]; - let (dx, dy) = (c[0] - fc[0], c[1] - fc[1]); - let dist = (dx * dx + dy * dy).sqrt().max(1e-6); - let ang = dy.atan2(dx) + dir_rand * std::f64::consts::PI * hs(i, 1, seed) as f64; - let speed = force / 100.0 * 0.6 * maxd / (1.0 + dist / (0.5 * maxd)) * (1.0 + spd_rand * hs(i, 2, seed) as f64); - let vz = -speed * 0.5 * h(i, 3, seed) as f64; - let pos = [c[0] + ang.cos() * speed * t, c[1] + ang.sin() * speed * t + 0.5 * gravity * 0.5 * lh * t * t, vz * t]; - let axis = [hs(i, 4, seed) as f64, hs(i, 5, seed) as f64, hs(i, 6, seed) as f64]; - let r = rot_axis(axis, t * (std::f64::consts::PI * h(i, 7, seed) as f64 + spin)); - let tex: Vec<[f32; 2]> = poly.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); - let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; - let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; - let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; - pc.shade = 0.35 + 0.65 * Piece::facing(&r); - if !textured { - let (cc, a) = unpremul(src.sample_bilinear(c0[0] as f64, c0[1] as f64)); - if a <= 0.0 { - return None; - } - pc.flat = Some(cc); - pc.alpha = a; - } - Some(pc) - }) - .collect(); - if depth_sort { - sort_far_first(&mut pieces); - } - Some(PiecePlan { pieces, front: PieceTex::Layer, back: None }) -} - -// ------------------------------------------------------------------ CC Scatterize - -fn scatterize(ctx: &EffectCtx, b: Buf) -> Buf { - match scatterize_plan(ctx, &b) { - Some(plan) => plan.finish(b), - None => b, - } -} - -/// CC Scatterize's pixel dots, `None` when nothing moves (the layer as is). -fn scatterize_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let scatter = pr.f("scatter") as f32; - let right = pr.f("rightTwist").to_radians(); - let left = pr.f("leftTwist").to_radians(); - if scatter == 0.0 && right == 0.0 && left == 0.0 { - return None; - } - let add = pr.e("transferMode") == 1; - let seed = ctx.seed.wrapping_mul(0x7feb352d); - let s = b.scale; - let (w, hh) = (b.img.width, b.img.height); - let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); - let half = (ctx.layer_size[0] * 0.5 * s).max(1.0); - let d = 2.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * s; - let src = &b.img; - let sprites: Vec = (0..w * hh) - .into_par_iter() - .filter_map(|i| { - let px = src.data[i as usize]; - if px[3] <= 0.0 { - return None; - } - let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); - // Twist about the horizontal axis through the centre, growing towards each side. - let k = ((x - cx) / half).abs().min(1.0); - let ang = if x < cx { left } else { right } * k; - let (sa, ca) = ang.sin_cos(); - let (yy, zz) = ((y - cy) * ca, (y - cy) * sa); - let persp = d / (d + zz).max(d * 0.05); - let jx = hs(i, 1, seed) * scatter * s as f32; - let jy = hs(i, 2, seed) * scatter * s as f32; - let sx = cx + (x - cx) * persp + jx as f64; - let sy = cy + yy * persp + jy as f64; - let (c, a) = unpremul(px); - Some(Sprite::new(sx as f32, sy as f32, 0.6 * persp as f32, [c[0], c[1], c[2], a], Shape::Disc)) - }) - .collect(); - Some(SpritePlan { sprites, tints: vec![], acc: if add { Acc::Add } else { Acc::Over }, post: Post::Replace }) -} - -/// What a particle effect that reads the layer's pixels draws this frame (CC Ball Action, CC -/// Pixel Polly, CC Scatterize, Shatter's wireframe views), for layer `b`; `None` for other -/// effects or a frame showing the layer as is. -pub enum PixelPlan { - Sprites(SpritePlan), - Pieces(PiecePlan), -} - -/// The [`PixelPlan`] of effect `id` for layer `b` (its pixels are read). -pub fn pixel_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.sim.ccballaction" => Some(PixelPlan::Sprites(ball_action_plan(ctx, b))), - "ec.sim.ccpixelpolly" => pixel_polly_plan(ctx, b).map(PixelPlan::Pieces), - "ec.sim.ccscatterize" => scatterize_plan(ctx, b).map(PixelPlan::Sprites), - "ec.sim.shatter" => Some(match shatter_impl(ctx, b) { - ShatterPlan::Pieces(plan) => PixelPlan::Pieces(plan), - ShatterPlan::Wire(plan) => PixelPlan::Sprites(plan), - }), - _ => None, - } -} - -// ------------------------------------------------------------------ Card Dance - -const CD_SOURCES: &[&str] = &["None", "Intensity 1", "Red 1", "Green 1", "Blue 1", "Alpha 1", "Intensity 2", "Red 2", "Green 2", "Blue 2", "Alpha 2"]; -/// (param id prefix, twirl-down group match id) per transformed card property. -const CD_PROPS: [(&str, &str); 8] = [ - ("xPos", "xPosition"), - ("yPos", "yPosition"), - ("zPos", "zPosition"), - ("xRot", "xRotation"), - ("yRot", "yRotation"), - ("zRot", "zRotation"), - ("xScale", "xScale"), - ("yScale", "yScale"), -]; - -fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 { - let (c1, a1) = unpremul(g1); - let (c2, a2) = unpremul(g2); - let lum = |c: [f32; 3]| effectcraft_color::luminance(c[0], c[1], c[2]); - match src { - 1 => lum(c1), - 2 => c1[0], - 3 => c1[1], - 4 => c1[2], - 5 => a1, - 6 => lum(c2), - 7 => c2[0], - 8 => c2[1], - 9 => c2[2], - 10 => a2, - _ => 0.0, - } -} - -fn card_dance(ctx: &EffectCtx, b: Buf) -> Buf { - card_dance_plan(ctx, &b).finish(b) -} - -fn card_dance_plan(ctx: &EffectCtx, b: &Buf) -> PiecePlan { - let pr = ctx.params; - let rows = pr.f("rows").clamp(1.0, 1000.0) as u32; - let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 }; - let g1 = layer_or_self(ctx, b, "gradientLayer1", true, true); - let g2 = layer_or_self(ctx, b, "gradientLayer2", true, true); - let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); - let [lw, lh] = ctx.layer_size; - let s = b.scale; - let (cw, ch) = (lw / cols as f64, lh / rows as f64); - let cam = crate::card3d::projection(ctx, b); - let eye = cam.eye(); - let light = Lighting::from(ctx, b); - let props: Vec<(u32, f64, f64)> = - CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); - let mut pieces: Vec = (0..rows * cols) - .into_par_iter() - .filter_map(|i| { - let (cx0, cy0) = ((i % cols) as f64 * cw, (i / cols) as f64 * ch); - let c = [cx0 + cw * 0.5, cy0 + ch * 0.5]; - let (bx, by) = (c[0] * s + b.offset[0], c[1] * s + b.offset[1]); - let (gp1, gp2) = (g1.sample_bilinear(bx, by), g2.sample_bilinear(bx, by)); - let val = |k: usize| { - let (sr, m, o) = props[k]; - o + m * card_source(sr, gp1, gp2) as f64 - }; - let (sx, sy) = (val(6), val(7)); - let r = rot_xyz(val(3).to_radians(), val(4).to_radians(), val(5).to_radians()); - let rs: M3 = std::array::from_fn(|k| [r[k][0] * sx, r[k][1] * sy, r[k][2]]); - let pos = [bx + val(0) * cw * s, by + val(1) * ch * s, val(2) * cw.max(ch) * s]; - let (tx0, ty0) = ((cx0 * s + b.offset[0]) as f32, (cy0 * s + b.offset[1]) as f32); - let (tw, th) = ((cw * s) as f32, (ch * s) as f32); - let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]]; - let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?; - pc.light(&light, &r, pos, eye); - Some(pc) - }) - .collect(); - sort_far_first(&mut pieces); - PiecePlan { pieces, front: PieceTex::Layer, back: back.map(PieceTex::Image) } -} - -// ------------------------------------------------------------------ Shatter - -/// Shatter's Pattern options; the last, Custom, takes its pieces from Custom Shatter Map. -const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles", "Custom"]; - -/// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`. -fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec> { - let mut out = Vec::new(); - let i0 = (x0 / cell).floor() as i64 - 1; - let i1 = (x1 / cell).ceil() as i64 + 1; - let j0 = (y0 / cell).floor() as i64 - 1; - let j1 = (y1 / cell).ceil() as i64 + 1; - match kind { - 0 => { - let bh = cell * 0.5; - let r0 = (y0 / bh).floor() as i64 - 1; - let r1 = (y1 / bh).ceil() as i64 + 1; - for r in r0..=r1 { - let sh = if r.rem_euclid(2) == 1 { cell * 0.5 } else { 0.0 }; - for i in i0..=i1 { - let (x, y) = (i as f64 * cell + sh, r as f64 * bh); - out.push(vec![[x, y], [x + cell, y], [x + cell, y + bh], [x, y + bh]]); - } - } - } - 1 => { - // Jittered lattice split into triangles along a random diagonal. - let jit = |i: i64, j: i64| -> [f64; 2] { - let (a, b) = (i as u32, j as u32); - [i as f64 * cell + hs(a, b.wrapping_add(17), seed) as f64 * cell * 0.35, j as f64 * cell + hs(a, b.wrapping_add(91), seed) as f64 * cell * 0.35] - }; - for j in j0..=j1 { - for i in i0..=i1 { - let (p00, p10, p11, p01) = (jit(i, j), jit(i + 1, j), jit(i + 1, j + 1), jit(i, j + 1)); - if h(i as u32, j as u32, seed ^ 0x55) < 0.5 { - out.push(vec![p00, p10, p11]); - out.push(vec![p00, p11, p01]); - } else { - out.push(vec![p00, p10, p01]); - out.push(vec![p10, p11, p01]); - } - } - } - } - 2 => { - let r = cell / 3f64.sqrt(); - let dx = 1.5 * r; - let dy = 3f64.sqrt() * r; - let c0 = (x0 / dx).floor() as i64 - 1; - let c1 = (x1 / dx).ceil() as i64 + 1; - let rr0 = (y0 / dy).floor() as i64 - 1; - let rr1 = (y1 / dy).ceil() as i64 + 1; - for ci in c0..=c1 { - for rj in rr0..=rr1 { - let cx = ci as f64 * dx; - let cy = (rj as f64 + if ci.rem_euclid(2) == 1 { 0.5 } else { 0.0 }) * dy; - out.push( - (0..6) - .map(|k| { - let a = k as f64 * std::f64::consts::PI / 3.0; - [cx + r * a.cos(), cy + r * a.sin()] - }) - .collect(), - ); - } - } - } - 3 => { - for j in j0..=j1 { - for i in i0..=i1 { - let (x, y) = (i as f64 * cell, j as f64 * cell); - out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell], [x, y + cell]]); - } - } - } - _ => { - for j in j0..=j1 { - for i in i0..=i1 { - let (x, y) = (i as f64 * cell, j as f64 * cell); - if (i + j).rem_euclid(2) == 0 { - out.push(vec![[x, y], [x + cell, y], [x, y + cell]]); - out.push(vec![[x + cell, y], [x + cell, y + cell], [x, y + cell]]); - } else { - out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell]]); - out.push(vec![[x, y], [x + cell, y + cell], [x, y + cell]]); - } - } - } - } - } - out -} - -/// `∫0^t e^{-ks} ds` and `∫0^t ∫0^u e^{-ks} ds du` (drag-damped ballistic factors). -fn drag_factors(k: f64, t: f64) -> (f64, f64) { - if k < 1e-6 { - (t, 0.5 * t * t) - } else { - let e = (-k * t).exp(); - ((1.0 - e) / k, t / k - (1.0 - e) / (k * k)) - } -} -/// Shatter's Front / Side / Back Mode options. -const SHATTER_TEXTURE_MODES: [&str; 3] = ["Color", "Layer", "Tinted Layer"]; - -/// One face's texture: a flat colour, a layer, or a layer tinted by the Textures colour. -struct Face { - mode: u32, - img: Option, -} - -impl Face { - fn from(ctx: &EffectCtx, b: &Buf, mode: &str, layer: &str, default_mode: u32) -> Face { - let mode = ctx.params.get(mode).map(|v| v.as_enum()).unwrap_or(default_mode); - // A face layer of None is the effect's own layer. - let img = (mode != 0).then(|| layer_or_self(ctx, b, layer, true, true)); - Face { mode, img } - } -} - -/// Group key of a Custom Shatter Map colour (pieces of one colour break as one). -fn map_key(c: [f32; 4]) -> u32 { - let (c, a) = unpremul(c); - if a < 0.5 { - return u32::MAX; - } - let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round() as u32) & 15; - q(c[0]) << 8 | q(c[1]) << 4 | q(c[2]) -} - -fn shatter(ctx: &EffectCtx, b: Buf) -> Buf { - match shatter_impl(ctx, &b) { - ShatterPlan::Pieces(plan) => plan.finish(b), - ShatterPlan::Wire(plan) => plan.finish(b), - } -} - -/// What Shatter draws: the pieces (Rendered view) or the wireframe lines. -enum ShatterPlan { - Pieces(PiecePlan), - Wire(SpritePlan), -} - -fn shatter_impl(ctx: &EffectCtx, b: &Buf) -> ShatterPlan { - let pr = ctx.params; - let view = pr.e("view"); - let render = pr.e("render"); - let kind = pr.e("shape/pattern"); - let custom = kind as usize == SHATTER_PATTERNS.len() - 1; - let reps = pr.f("shape/repetitions").clamp(1.0, 500.0); - let dir = pr.f("shape/direction").to_radians(); - let origin = pr.v2("shape/origin"); - let forces = [ - (pr.v2("force1/force1Position"), pr.f("force1/force1Depth"), pr.f("force1/force1Radius"), pr.f("force1/force1Strength")), - (pr.v2("force2/force2Position"), pr.f("force2/force2Depth"), pr.f("force2/force2Radius"), pr.f("force2/force2Strength")), - ]; - let rot_speed = pr.f("physics/rotationSpeed"); - let tumble = pr.e("physics/tumbleAxis"); - let randomness = pr.f("physics/randomness"); - let k = pr.f("physics/viscosity").max(0.0) * 5.0; - let mass_var = pr.f("physics/massVariance") / 100.0; - let gravity = pr.f("physics/gravity"); - let gdir = pr.f("physics/gravityDirection").to_radians(); - let ginc = pr.f("physics/gravityInclination").to_radians(); - let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed; - let [lw, lh] = ctx.layer_size; - let s = b.scale; - let t = ctx.time.max(0.0); - let unit = lw * 0.1; - let cell = lw / reps; - let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s; - let cam = crate::card3d::projection(ctx, b); - let eye = cam.eye(); - let light = Lighting::from(ctx, b); - // Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold - // (Invert Gradient: as dark) break. - let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); - let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0; - let invert_gradient = pr.b("gradient/invertGradient"); - // Textures. - let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]); - let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32; - let front = Face::from(ctx, b, "textures/frontMode", "textures/frontLayer", 1); - let side = Face::from(ctx, b, "textures/sideMode", "textures/sideLayer", 0); - let backf = Face::from(ctx, b, "textures/backMode", "textures/backLayer", 1); - let custom_map = custom.then(|| layer_or_self(ctx, b, "shape/customShatterMap", true, true)); - let white_fixed = pr.b("shape/whiteTilesFixed"); - let (sd, cd) = dir.sin_cos(); - let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; - // Pattern-space bounds of the layer rectangle. - let corners = [[0.0, 0.0], [lw, 0.0], [lw, lh], [0.0, lh]].map(|c: [f64; 2]| { - let (dx, dy) = (c[0] - origin[0], c[1] - origin[1]); - [dx * cd + dy * sd, -dx * sd + dy * cd] - }); - let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max)); - let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max)); - // Custom Shatter Map: square cells grouped by the map's colour. - let cells = pattern_cells(if custom { 3 } else { kind }, cell, px0, py0, px1, py1, seed); - let polys: Vec> = cells.iter().map(|poly| poly.iter().map(|&q| to_layer(q)).collect()).collect(); - let centre = |lp: &[[f64; 2]]| [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; - let keys: Vec = match &custom_map { - Some(m) => polys - .iter() - .map(|lp| { - let c = centre(lp); - map_key(m.sample_bilinear(c[0] * s + b.offset[0], c[1] * s + b.offset[1])) - }) - .collect(), - None => (0..polys.len() as u32).collect(), - }; - // Group centres (custom map) — each group moves as one rigid piece. - let mut groups: std::collections::HashMap = std::collections::HashMap::new(); - for (lp, &key) in polys.iter().zip(&keys) { - let c = centre(lp); - let e = groups.entry(key).or_insert(([0.0; 2], 0.0)); - e.0[0] += c[0]; - e.0[1] += c[1]; - e.1 += 1.0; - } - let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit]; - let mut pieces: Vec = polys - .par_iter() - .zip(keys.par_iter()) - .flat_map_iter(|(lp, &key)| { - let mut out: Vec = vec![]; - let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max)); - let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max)); - if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh || key == u32::MAX { - return out.into_iter(); - } - let pc_c = centre(lp); - let (gsum, gn) = groups[&key]; - let gc = [gsum[0] / gn, gsum[1] / gn]; - let i = key; - // Broken when inside a force sphere (centred `depth` in front of the layer). - let mut v = [0.0f64; 3]; - let mut broken = false; - for (fp, fd, fr, fs) in forces { - let rad = fr * lw; - if rad <= 0.0 { - continue; - } - let dv = [gc[0] - fp[0], gc[1] - fp[1], fd * lw]; - let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt(); - if dist < rad { - broken = true; - let f = fs * (1.0 - dist / rad) * unit / dist.max(1e-6); - for q in 0..3 { - v[q] += dv[q] * f; - } - } - } - if broken && let Some(g) = &gradient { - let (c, _) = unpremul(g.sample_bilinear(gc[0] * s + b.offset[0], gc[1] * s + b.offset[1])); - let mut l = effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64; - if invert_gradient { - l = 1.0 - l; - } - broken = threshold > 0.0 && l >= 1.0 - threshold; - } - if broken && custom && white_fixed && key == 0xfff { - broken = false; - } - if (broken && render == 1) || (!broken && render == 2) { - return out.into_iter(); - } - let mut gpos = [gc[0], gc[1], 0.0]; - let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); - let moving = broken && t > 0.0; - if moving { - let mass = 1.0 + mass_var * hs(i, 1, seed) as f64; - for q in 0..3 { - v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0; - } - let (f1, f2) = drag_factors(k, t); - for q in 0..3 { - gpos[q] += v[q] * f1 + g3[q] * f2; - } - let axis = match tumble { - 1 => [0.0, 0.0, 0.0], - 2 => [1.0, 0.0, 0.0], - 3 => [0.0, 1.0, 0.0], - 4 => [0.0, 0.0, 1.0], - 5 => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, 0.0], - 6 => [hs(i, 6, seed) as f64, 0.0, hs(i, 8, seed) as f64], - 7 => [0.0, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], - _ => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], - }; - let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() / unit; - let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0); - r = rot_axis(axis, omega * f1); - } - // The piece's centre: its offset from the group centre, rotated with the group. - let off = [pc_c[0] - gc[0], pc_c[1] - gc[1], 0.0]; - let pos: [f64; 3] = std::array::from_fn(|q| gpos[q] + (0..3).map(|k| r[q][k] * off[k]).sum::()); - let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); - let c0 = [(pc_c[0] * s + b.offset[0]) as f32, (pc_c[1] * s + b.offset[1]) as f32]; - let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; - let Some(mut pc) = Piece::new(&tex, c0, &r, posb, cam) else { return out.into_iter() }; - pc.light(&light, &r, posb, eye); - pc.alpha = tex_opacity; - let face = if pc.back { &backf } else { &front }; - if face.mode == 0 { - pc.flat = Some([tex_color[0], tex_color[1], tex_color[2]]); - } else if face.mode == 2 { - pc.tint = std::array::from_fn(|q| pc.tint[q] * tex_color[q]); - } - // Extrusion: the sides of a moving piece, from its front outline back by the depth. - if moving && extrude > 0.0 { - let nb = [r[0][2], r[1][2], r[2][2]]; - let n = tex.len(); - for e in 0..n { - let (a, bv) = (tex[e], tex[(e + 1) % n]); - let ed = [(bv[0] - a[0]) as f64, (bv[1] - a[1]) as f64]; - let len = (ed[0] * ed[0] + ed[1] * ed[1]).sqrt(); - if len < 1e-6 { - continue; - } - let ew: [f64; 3] = std::array::from_fn(|q| (r[q][0] * ed[0] + r[q][1] * ed[1]) / len); - let third = [ew[1] * nb[2] - ew[2] * nb[1], ew[2] * nb[0] - ew[0] * nb[2], ew[0] * nb[1] - ew[1] * nb[0]]; - let rs: M3 = std::array::from_fn(|q| [ew[q], nb[q], third[q]]); - // Side centre in world: edge midpoint (world) plus half the depth backwards. - let mid = [((a[0] + bv[0]) * 0.5 - c0[0]) as f64, ((a[1] + bv[1]) * 0.5 - c0[1]) as f64]; - let mw: [f64; 3] = std::array::from_fn(|q| posb[q] + r[q][0] * mid[0] + r[q][1] * mid[1] + nb[q] * extrude * 0.5); - let (hl, hd) = ((len * 0.5) as f32, (extrude * 0.5) as f32); - let cs = [(a[0] + bv[0]) * 0.5, (a[1] + bv[1]) * 0.5]; - let quad = [[cs[0] - hl, cs[1] - hd], [cs[0] + hl, cs[1] - hd], [cs[0] + hl, cs[1] + hd], [cs[0] - hl, cs[1] + hd]]; - if let Some(mut sp) = Piece::new(&quad, cs, &rs, mw, cam) { - sp.light(&light, &rs, mw, eye); - sp.alpha = tex_opacity; - let colour = match (&side.img, side.mode) { - (Some(img), m) if m != 0 => { - let (c, _) = unpremul(img.sample_bilinear(cs[0] as f64, cs[1] as f64)); - if m == 2 { std::array::from_fn(|q| c[q] * tex_color[q]) } else { c } - } - _ => [tex_color[0], tex_color[1], tex_color[2]], - }; - sp.flat = Some(colour); - out.push(sp); - } - } - } - out.push(pc); - out.into_iter() - }) - .collect(); - sort_far_first(&mut pieces); - if view == 0 { - let tex = |i: &Option| i.clone().map(PieceTex::Image); - let front_t = tex(&front.img).unwrap_or(PieceTex::Layer); - let back_t = tex(&backf.img).unwrap_or_else(|| front_t.clone()); - return ShatterPlan::Pieces(PiecePlan { pieces, front: front_t, back: Some(back_t) }); - } - { - // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. - let animated = view == 2 || view == 4; - let mut lines = Vec::new(); - for pc in &pieces { - let n = pc.n as usize; - let pts: Vec<[f32; 2]> = if animated { - let fw = inv3(&pc.inv); - match fw { - Some(m) => pc.poly[..n] - .iter() - .map(|v| { - let (a, bb) = ((v[0] - pc.c0[0]) as f64, (v[1] - pc.c0[1]) as f64); - let w = m[2][0] * a + m[2][1] * bb + m[2][2]; - [((m[0][0] * a + m[0][1] * bb + m[0][2]) / w) as f32, ((m[1][0] * a + m[1][1] * bb + m[1][2]) / w) as f32] - }) - .collect(), - None => continue, - } - } else { - pc.poly[..n].to_vec() - }; - for e in 0..n { - let (a, c) = (pts[e], pts[(e + 1) % n]); - lines.push(Sprite::new(a[0], a[1], 0.5, [0.75, 0.85, 1.0, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); - } - } - if view >= 3 { - for (fp, _, fr, _) in forces { - let (fx, fy) = b.to_px(fp); - let rad = fr * lw * s; - for k in 0..64 { - let (a0, a1) = (k as f64 / 64.0 * std::f64::consts::TAU, (k + 1) as f64 / 64.0 * std::f64::consts::TAU); - let (x0, y0) = (fx + rad * a0.cos(), fy + rad * a0.sin()); - let (x1, y1) = (fx + rad * a1.cos(), fy + rad * a1.sin()); - lines.push(Sprite::new(x0 as f32, y0 as f32, 0.5, [1.0, 0.3, 0.3, 1.0], Shape::Line { dx: (x1 - x0) as f32, dy: (y1 - y0) as f32 })); - } - } - } - ShatterPlan::Wire(SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: Post::Replace }) - } -} - -// ------------------------------------------------------------------ Caustics - -/// Sample with repeat mode: 0 Once (transparent outside), 1 Tiled, 2 Reflected. -fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px { - let (w, hh) = (img.width as f64, img.height as f64); - match mode { - 1 => img.sample_bilinear_clamped(x.rem_euclid(w), y.rem_euclid(hh)), - 2 => { - let m = |v: f64, n: f64| { - let r = v.rem_euclid(2.0 * n); - if r > n { 2.0 * n - r } else { r } - }; - img.sample_bilinear_clamped(m(x, w), m(y, hh)) - } - _ => img.sample_bilinear(x, y), - } -} - -/// Everything Caustics' per-pixel pass needs, prepared on the CPU (the other layers it reads -/// and the parameter conversions); the layer's own pixels are not read. -pub struct CausticsSetup { - /// The Bottom layer fitted to the buffer, `None` = the layer itself. - pub bottom: Option, - /// Gaussian σ (buffer px) blurring the bottom (transparent edges), 0 = none. - pub bottom_sigma: f64, - /// The water surface's height field (smoothed luminance), `None` = flat. - pub height: Option, - /// The Sky layer fitted to the buffer. - pub sky: Option, - pub scaling: f64, - pub repeat: u32, - pub wave_h: f64, - /// Displacement per unit of height gradient. - pub k: f64, - /// Layer centre (buffer px). - pub cx: f64, - pub cy: f64, - /// Layer width (buffer px). - pub lw: f64, - pub surf: [f32; 4], - pub surf_op: f32, - pub cstr: f32, - pub lc: [f32; 4], - pub li: f32, - /// Light position (buffer px) and height (layer widths); `point` = from the position to - /// each pixel, else one direction from above the layer centre. - pub lx: f64, - pub ly: f64, - pub lheight: f64, - pub point: bool, - pub ambient: f32, - pub diffuse: f32, - pub specular: f32, - pub sharp: f32, - pub sky_scaling: f64, - pub sky_repeat: u32, - pub sky_int: f32, - pub convergence: f64, -} - -/// Caustics' setup for layer geometry `b` (see [`CausticsSetup`]). -pub fn caustics_setup(ctx: &EffectCtx, b: &Buf) -> CausticsSetup { - let pr = ctx.params; - let s = b.scale; - let bottom = ctx.layer_param("bottom/bottom", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("bottom/bottomSizeDiffers") == 1)); - let blur = pr.f("bottom/blur") * s; - let wave_h = pr.f("water/waveHeight"); - let smoothing = pr.f("water/smoothing") * s; - let depth = pr.f("water/waterDepth"); - let ior = pr.f("water/refractiveIndex").max(1.0); - let lpos = pr.v2("lighting/lightPosition"); - // Height field of the water surface (luminance of the chosen layer; none = flat). - let height = ctx.layer_param("water/waterSurface", true).map(|o| { - let img = crate::util::fit_layer(ctx, b, &o, true); - let pl = Plane::luma(&img); - if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl } - }); - let lw = ctx.layer_size[0] * s; - let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; - let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); - let (lx, ly) = b.to_px(lpos); - let (lc, li, lheight) = (pr.color("lighting/lightColor"), pr.f("lighting/lightIntensity") as f32, pr.f("lighting/lightHeight").max(0.01)); - // Light Type: Distant Source (one direction, from the light position above the layer - // centre), Point Source (from the light position to each pixel) or First Comp Light. - let light_type = pr.e("lighting/lightType"); - let comp_light = if light_type == 2 { ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) } else { None }; - let (lc, li, lx, ly, lheight, point) = match (light_type, comp_light) { - (2, Some(l)) => { - let c = [l.color[0], l.color[1], l.color[2], 1.0]; - // Comp space: z away from the viewer; the light above the water is at −z. - let (px, py) = (l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1]); - (c, li, px, py, (-l.pos[2] / ctx.layer_size[0].max(1.0)).max(0.01), l.kind == 1) - } - (2, None) => (lc, 0.0, lx, ly, lheight, false), - (1, _) => (lc, li, lx, ly, lheight, true), - _ => (lc, li, lx, ly, lheight, false), - }; - // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). - let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("sky/skySizeDiffers") == 1)); - CausticsSetup { - bottom, - bottom_sigma: if blur > 0.05 { blur * 0.5 } else { 0.0 }, - height, - sky, - scaling: pr.f("bottom/scaling").max(0.01), - repeat: pr.e("bottom/repeatMode"), - wave_h, - k, - cx, - cy, - lw, - surf: pr.color("water/surfaceColor"), - surf_op: pr.f("water/surfaceOpacity") as f32, - cstr: pr.f("water/causticsStrength") as f32, - lc, - li, - lx, - ly, - lheight, - point, - ambient: pr.f("lighting/ambientLight") as f32, - diffuse: pr.f("material/diffuse") as f32, - specular: pr.f("material/specular") as f32, - sharp: pr.f("material/highlightSharpness").max(1.0) as f32, - sky_scaling: pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01), - sky_repeat: pr.e("sky/repeatMode"), - sky_int: pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32, - convergence: pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0), - } -} - -fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { - let st = caustics_setup(ctx, &b); - let mut bottom = st.bottom.clone().unwrap_or_else(|| b.img.clone()); - if st.bottom_sigma > 0.0 { - bottom = effectcraft_raster::gaussian_blur(&bottom, st.bottom_sigma, st.bottom_sigma, false); - } - let CausticsSetup { - scaling, - repeat, - wave_h, - k, - cx, - cy, - lw, - surf, - surf_op, - cstr, - lc, - li, - lx, - ly, - lheight, - point, - ambient, - diffuse, - specular, - sharp, - sky_scaling, - sky_repeat, - sky_int, - convergence, - .. - } = st; - let (height, sky) = (&st.height, &st.sky); - let (w, hh) = (b.img.width as usize, b.img.height as usize); - let norm3 = |v: [f64; 3]| { - let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); - [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] - }; - let lv_at = |x: f64, y: f64| { - if point { - norm3([(lx - x) / lw.max(1.0), (ly - y) / lw.max(1.0), lheight]) - } else { - norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]) - } - }; - let grad = |x: usize, y: usize| -> (f64, f64) { - match height { - Some(pl) => { - let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5; - let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5; - (gx, gy) - } - None => (0.0, 0.0), - } - }; - // Displacement and its Jacobian (caustic focusing) per pixel. - let disp: Vec<(f64, f64)> = (0..w * hh) - .into_par_iter() - .map(|i| { - let (gx, gy) = grad(i % w, i / w); - (-gx * k, -gy * k) - }) - .collect(); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let i = y * w + x; - let (dx, dy) = disp[i]; - let d = |xx: usize, yy: usize| disp[yy.min(hh - 1) * w + xx.min(w - 1)]; - let (dxr, _) = d(x + 1, y); - let (_, dyd) = d(x, y + 1); - let (dxl, _) = d(x.saturating_sub(1), y); - let (_, dyu) = d(x, y.saturating_sub(1)); - let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5); - let focus = ((1.0 / jac.abs().max(0.2)) as f32 - 1.0).clamp(-1.0, 4.0); - let sx = cx + (x as f64 + 0.5 + dx - cx) / scaling; - let sy = cy + (y as f64 + 0.5 + dy - cy) / scaling; - let p = sample_repeat(&bottom, sx, sy, repeat); - let (c, a) = unpremul(p); - let (gx, gy) = grad(x, y); - let n = { - let v = [-gx * wave_h * 20.0, -gy * wave_h * 20.0, 1.0]; - let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); - [(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32] - }; - let lv = lv_at(x as f64 + 0.5, y as f64 + 0.5); - let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0); - let hv = { - let v = [lv[0], lv[1], lv[2] + 1.0]; - let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - [v[0] / m, v[1] / m, v[2] / m] - }; - let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li; - let light = ambient + diffuse * li * nl + cstr * focus * li; - // The surface: its colour lit like the water, or the sky it reflects (sampled along - // the surface normal; Convergence pulls the reflection towards the pixel itself). - let surface: [f32; 3] = match &sky { - Some(sk) => { - let reach = (1.0 - convergence) * lw * 0.5; - let (rx, ry) = (x as f64 + 0.5 + n[0] as f64 * reach, y as f64 + 0.5 + n[1] as f64 * reach); - let (rx, ry) = (cx + (rx - cx) / sky_scaling, cy + (ry - cy) / sky_scaling); - let (sc, _) = unpremul(sample_repeat(sk, rx, ry, sky_repeat)); - std::array::from_fn(|q| sc[q] * sky_int * 3.0 * surf[q]) - } - None => std::array::from_fn(|q| surf[q] * (ambient + diffuse * li * nl)), - }; - let cc: [f32; 3] = std::array::from_fn(|q| { - let base = c[q] * light * lc[q]; - base * (1.0 - surf_op) + surface[q] * surf_op + spec * lc[q] - }); - let a = (a + surf_op * (1.0 - a)).min(1.0); - *px = [cc[0] * a, cc[1] * a, cc[2] * a, a]; - } - }); - b -} - -// ------------------------------------------------------------------ Wave World - -/// Wave World's simulated surface: an `nx` × `ny` grid of heights `u` and velocities `v`. -#[derive(Clone, Debug)] -pub struct Waves { - pub nx: usize, - pub ny: usize, - pub u: Vec, - pub v: Vec, -} - -struct Producer { - ring: bool, - pos: [f32; 2], - len: f32, - width: f32, - angle: f32, - amp: f32, - freq: f32, - phase: f32, -} - -impl Producer { - /// Weight of a grid point (layer-fraction coordinates, x in widths, y in widths). - fn weight(&self, x: f32, y: f32) -> f32 { - let (dx, dy) = (x - self.pos[0], y - self.pos[1]); - let (sa, ca) = self.angle.sin_cos(); - let (u, v) = (dx * ca + dy * sa, -dx * sa + dy * ca); - let d = if self.ring { - let (a, bb) = ((self.len * 0.5).max(1e-3), (self.width * 0.5).max(1e-3)); - ((u / a).powi(2) + (v / bb).powi(2)).sqrt() - } else { - let l = (self.len * 0.5).max(1e-3); - let wd = (self.width * 0.5).max(1e-3); - (u.abs() / l).max(v.abs() / wd) - }; - (1.0 - d).clamp(0.0, 1.0).min(1.0) - } -} - -static WAVE_CACHE: SimCache = SimCache::new(4); - -/// What Wave World draws: the Height Map view of the simulated surface (grid heights and, -/// with a ground layer, water depths per grid point), or the wireframe preview's lines. -pub enum WavePlan { - Height { st: std::sync::Arc, depth: Option> }, - Wire(crate::sim::SpritePlan), -} - -fn wave_world(ctx: &EffectCtx, b: Buf) -> Buf { - match wave_world_impl(ctx, &b, false) { - Ok(img) => Buf { img, ..b }, - Err(WavePlan::Wire(plan)) => plan.finish(b), - Err(WavePlan::Height { .. }) => b, - } -} - -/// Wave World's simulation state for the frame, as a [`WavePlan`] (its pixels are not read). -pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { - wave_world_impl(ctx, b, true).err() -} - -fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { - let pr = ctx.params; - let [lw, lh] = ctx.layer_size; - let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize; - let res = if pr.b("simulation/gridResDownsamples") { ((res as f64) * b.scale).round().max(1.0) as usize } else { res }; - let nx = res.max(2) + 1; - let ny = ((res as f64 * lh / lw.max(1.0)).round() as usize).max(2) + 1; - let speed = pr.f("simulation/waveSpeed").max(0.0) as f32; - let damping = pr.f("simulation/damping").max(0.0) as f32; - let reflect = pr.e("simulation/reflectEdges"); - let preroll = pr.f("simulation/preRoll").max(0.0); - let prods: Vec = [1, 2] - .iter() - .map(|k| { - let pos = pr.v2(&format!("producer{k}/producer{k}Position")); - Producer { - ring: pr.e(&format!("producer{k}/producer{k}Type")) == 0, - pos: [(pos[0] / lw.max(1.0)) as f32, (pos[1] / lw.max(1.0)) as f32], - len: pr.f(&format!("producer{k}/producer{k}Length")) as f32, - width: pr.f(&format!("producer{k}/producer{k}Width")) as f32, - angle: (pr.f(&format!("producer{k}/producer{k}Angle")) as f32).to_radians(), - amp: pr.f(&format!("producer{k}/producer{k}Amplitude")) as f32, - freq: pr.f(&format!("producer{k}/producer{k}Frequency")) as f32, - phase: (pr.f(&format!("producer{k}/producer{k}Phase")) as f32).to_radians(), - } - }) - .collect(); - let sps = crate::sim::SPS as f32; - // Wave speed in layer widths per second → cells per second; sub-steps keep it stable. - let cps = speed * (nx - 1) as f32; - let sub = ((cps * 2.0 / sps).ceil() as usize).clamp(1, 64); - let dt = 1.0 / (sps * sub as f32); - let cell = 1.0 / (nx - 1) as f32; - let weights: Vec> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect(); - // Ground: the ground layer's first frame, its luminance an elevation (× Steepness). Where - // it rises above Height the grid is dry (a wall the waves reflect from); shallow water - // slows the waves by Wave Strength. - let ground = pr.get("ground/ground").and_then(Value::as_layer).and_then(|id| { - let host = ctx.env.host?; - host.layer_at(id, ctx.env.comp_time - ctx.time, true) - }); - let steep = pr.get("ground/steepness").map(Value::as_f64).unwrap_or(0.25) as f32; - let level = pr.get("ground/height").map(Value::as_f64).unwrap_or(0.25) as f32; - let wave_strength = pr.f("ground/waveStrength").clamp(0.0, 1.0) as f32; - // Water depth per grid point (None without ground: deep everywhere). - let depth: Option> = ground.as_ref().map(|lp| { - let (gw, gh) = (lp.size[0].max(1.0), lp.size[1].max(1.0)); - (0..nx * ny) - .map(|i| { - let (fx, fy) = ((i % nx) as f64 / (nx - 1) as f64, (i / nx) as f64 / (ny - 1) as f64); - let (c, a) = unpremul(lp.buf.img.sample_bilinear_clamped(fx * gw * lp.buf.scale + lp.buf.offset[0], fy * gh * lp.buf.scale + lp.buf.offset[1])); - level - steep * effectcraft_color::luminance(c[0], c[1], c[2]) * a - }) - .collect() - }); - let speed_k: Vec = match &depth { - Some(d) => d.iter().map(|&v| if v <= 0.0 { 0.0 } else { 1.0 - wave_strength * (1.0 - (v / level.max(1e-3)).clamp(0.0, 1.0).sqrt()) }).collect(), - None => vec![1.0; nx * ny], - }; - let mut key = - params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4); - if let Some(d) = &depth { - key ^= d.iter().fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); - } - let steps = crate::sim::steps_at(ctx.time + preroll); - let st = WAVE_CACHE.run( - key, - steps, - || Waves { nx, ny, u: vec![0.0; nx * ny], v: vec![0.0; nx * ny] }, - |w, step| { - for k in 0..sub { - let t = (step as f32 + k as f32 / sub as f32) / sps - preroll as f32; - let c2 = cps * cps; - let mut acc = vec![0.0f32; nx * ny]; - for y in 0..ny { - for x in 0..nx { - let i = y * nx + x; - let g = |xx: usize, yy: usize| w.u[yy * nx + xx]; - let l = g(x.saturating_sub(1), y) + g((x + 1).min(nx - 1), y) + g(x, y.saturating_sub(1)) + g(x, (y + 1).min(ny - 1)) - 4.0 * w.u[i]; - acc[i] = c2 * speed_k[i] * speed_k[i] * l - damping * 4.0 * w.v[i]; - } - } - for i in 0..nx * ny { - w.v[i] += acc[i] * dt; - w.u[i] += w.v[i] * dt; - } - // Producers drive the surface. - for (p, wt) in prods.iter().zip(&weights) { - if p.amp == 0.0 { - continue; - } - let target = p.amp * (std::f32::consts::TAU * p.freq * t + p.phase).sin(); - for i in 0..nx * ny { - if wt[i] > 0.0 { - w.u[i] += (target - w.u[i]) * wt[i]; - w.v[i] *= 1.0 - wt[i]; - } - } - } - // Dry ground holds the surface flat. - for i in 0..nx * ny { - if speed_k[i] <= 0.0 { - w.u[i] = 0.0; - w.v[i] = 0.0; - } - } - // Edges: reflective ones mirror; the others absorb in a thin sponge layer. - let refl = |side: u32| reflect == 5 || reflect == side; - let sponge = 4usize.min(nx / 4).max(1); - for y in 0..ny { - for x in 0..nx { - let i = y * nx + x; - let mut f = 1.0f32; - if !refl(1) && x < sponge { - f = f.min(x as f32 / sponge as f32); - } - if !refl(3) && nx - 1 - x < sponge { - f = f.min((nx - 1 - x) as f32 / sponge as f32); - } - if !refl(2) && y < sponge { - f = f.min(y as f32 / sponge as f32); - } - if !refl(4) && ny - 1 - y < sponge { - f = f.min((ny - 1 - y) as f32 / sponge as f32); - } - if f < 1.0 { - let k = 0.85 + 0.15 * f; - w.u[i] *= k; - w.v[i] *= k; - } - } - } - } - }, - ); - let s = b.scale; - let (bw, bh) = (b.img.width, b.img.height); - let sample = |x: f64, y: f64| -> f32 { - let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.nx - 1) as f64); - let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.ny - 1) as f64); - let (x0, y0) = (gx.floor() as usize, gy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(st.nx - 1), (y0 + 1).min(st.ny - 1)); - let (tx, ty) = ((gx - x0 as f64) as f32, (gy - y0 as f64) as f32); - let g = |xx: usize, yy: usize| st.u[yy * st.nx + xx]; - let a = g(x0, y0) + (g(x1, y0) - g(x0, y0)) * tx; - let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx; - a + (c - a) * ty - }; - if plan_only && pr.e("view") == 1 { - return Err(WavePlan::Height { st, depth }); - } - if pr.e("view") == 1 { - let mut img = Image::new(bw, bh); - let bright = pr.f("heightMapControls/brightness") as f32; - let contrast = pr.f("heightMapControls/contrast") as f32; - let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32; - let alpha = 1.0 - pr.f("heightMapControls/transparency") as f32; - let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1; - let dry_at = |x: f64, y: f64| -> bool { - let Some(d) = &depth else { return false }; - let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.nx - 1) as f64) as usize; - let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize; - d[gy * st.nx + gx] <= 0.0 - }; - img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let lx = (x as f64 + 0.5 - b.offset[0]) / s; - let ly = (y as f64 + 0.5 - b.offset[1]) / s; - let v = (bright + contrast * sample(lx, ly)).clamp(0.0, 1.0).powf(1.0 / gamma); - let al = if dry_transparent && dry_at(lx, ly) { 0.0 } else { alpha }; - *px = [v * al, v * al, v * al, al]; - } - }); - Ok(img) - } else { - // Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis, - // tilted by Vertical Rotation, heights scaled by Vertical Scale. - let hr = pr.f("wireframeControls/horizontalRotation").to_radians(); - let vr = pr.get("wireframeControls/verticalRotation").map(Value::as_f64).unwrap_or(53.0).to_radians(); - let vs = pr.get("wireframeControls/verticalScale").map(Value::as_f64).unwrap_or(0.5); - let (sh, ch) = hr.sin_cos(); - let (sv, cv) = vr.sin_cos(); - let to = |x: usize, y: usize| -> [f32; 2] { - // Grid point relative to the layer centre (layer px), height in layer px. - let gx = x as f64 / (st.nx - 1) as f64 * lw - lw * 0.5; - let gy = y as f64 / (st.nx - 1) as f64 * lw - (st.ny - 1) as f64 / (st.nx - 1) as f64 * lw * 0.5; - let hgt = st.u[y * st.nx + x] as f64 * vs * lh * 0.5; - let (rx, ry) = (gx * ch - gy * sh, gx * sh + gy * ch); - let (px, py) = b.to_px([lw * 0.5 + rx, lh * 0.5 + ry * cv - hgt * sv]); - [px as f32, py as f32] - }; - let mut lines = Vec::new(); - for y in 0..st.ny { - for x in 0..st.nx { - let a = to(x, y); - if x + 1 < st.nx { - let c = to(x + 1, y); - lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); - } - if y + 1 < st.ny { - let c = to(x, y + 1); - lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); - } - } - } - let plan = crate::sim::SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: crate::sim::Post::OverBlack }; - if plan_only { - return Err(WavePlan::Wire(plan)); - } - Ok(plan.finish(Buf { img: Image::new(bw, bh), offset: b.offset, scale: b.scale }).img) - } -} - -// ------------------------------------------------------------------ Foam - -#[derive(Clone, Debug)] -struct FoamBubble { - p: [f32; 2], - v: [f32; 2], - age: f32, - size: f32, - /// Spin (radians) for Physical Orientation. - spin: f32, - id: u32, -} - -#[derive(Clone, Debug, Default)] -struct FoamState { - b: Vec, - carry: f32, - next: u32, -} - -static FOAM_CACHE: SimCache = SimCache::new(4); - -/// Foam steps once per frame at 30 frames per second. -const FOAM_FPS: f64 = 30.0; - -/// Foam's Bubble Texture options (the last uses Bubble Texture Layer). -const FOAM_TEXTURES: [&str; 6] = ["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky", "User Defined"]; - -/// Age (in simulation steps) at which bubble `id` pops: its lifespan, shortened at random for -/// frail bubbles (`frailty` 0 = every bubble reaches `lifespan`, 1 = uniformly early). -fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 { - lifespan * (1.0 - frailty * (hs(id, 11, seed) * 0.5 + 0.5)) -} - -/// Foam: bubbles born in the producer rectangle grow to their size, drift with the wind, -/// turbulence and the Flow Map (bubbles run downhill on its luminance), repel and stick to each -/// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their -/// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble -/// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map. -fn foam(ctx: &EffectCtx, b: Buf) -> Buf { - foam_full_plan(ctx, &b).finish(b) -} - -/// Foam's bubbles as sprites, `None` when it draws more than sprites (a User Defined bubble -/// texture, an Environment Map reflection or the Draft + Flow Map view's flow map). -pub(crate) fn foam_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let plan = foam_full_plan(ctx, b); - (plan.user.is_none() && plan.env.is_none() && plan.flow.is_none()).then_some(plan.sprites) -} - -/// A bubble disc filled by Foam's User Defined texture (buffer px; `rot` radians). -#[derive(Clone, Copy, Debug)] -pub struct FoamDisc { - pub x: f64, - pub y: f64, - pub r: f32, - pub rot: f32, - pub fade: f32, -} - -/// Foam's Environment Map reflection: the map fitted to the buffer, Reflection Convergence and -/// Strength, and the bubble discs reflecting it (`rot` and `fade` unused). -pub struct FoamEnv { - pub img: Image, - pub conv: f64, - pub strength: f32, - pub discs: Vec, -} - -/// What Foam draws this frame over a transparent frame of the layer's size: the bubble sprites, -/// then the User Defined texture's discs, the Environment Map's reflections and (Draft + Flow -/// Map) the flow map under it all, each [`foam_disc`]-composited in bubble order. -pub struct FoamPlan { - pub sprites: crate::sim::SpritePlan, - pub user: Option<(crate::LayerPixels, Vec)>, - pub env: Option, - /// The flow map fitted to the buffer. - pub flow: Option, -} - -impl FoamPlan { - pub fn finish(&self, b: Buf) -> Buf { - let mut out = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc); - // User Defined: the texture layer fills each bubble's disc. - if let Some((tex, discs)) = &self.user { - for d in discs { - let (rot, fade) = (d.rot, d.fade); - foam_disc(&mut out, d.x, d.y, d.r as f64, |u, v| { - // u, v in −1..1 across the bubble, rotated into the texture. - let (sr, cr) = rot.sin_cos(); - let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr); - let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]); - let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]); - px.map(|c| c * fade) - }); - } - } - // Environment Map: each bubble reflects the map along its sphere normal (Reflection - // Convergence pulls the reflection towards the bubble's own spot of the map). - if let Some(env) = &self.env { - let (conv, strength) = (env.conv, env.strength); - let (w, h) = (b.img.width as f64, b.img.height as f64); - for d in &env.discs { - let (bx, by) = (d.x, d.y); - foam_disc(&mut out, bx, by, d.r as f64, |u, v| { - // Sphere normal; the reflection looks across the whole map, or (convergence 1) - // just behind the bubble. - let (ex, ey) = - if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) }; - let e = env.img.sample_bilinear(ex, ey); - let rim = ((u * u + v * v) as f32).min(1.0); - let k = strength * (0.3 + 0.7 * rim); - [e[0] * k, e[1] * k, e[2] * k, 0.0] - }); - } - } - // Draft + Flow Map shows the flow map under the bubbles. - if let Some(fm) = &self.flow { - out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { - let k = 1.0 - o[3]; - for c in 0..4 { - o[c] += f[c] * 0.5 * k; - } - }); - } - Buf { img: out, ..b } - } -} - -/// Foam's [`FoamPlan`] for layer geometry `b` (its size; its pixels are not read). -pub fn foam_full_plan(ctx: &EffectCtx, b: &Buf) -> FoamPlan { - let pr = ctx.params; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let unit = lw * 0.01; - let prod = pr.v2("producer/producerPoint"); - let (psx, psy) = (pr.f("producer/producerXSize") as f32 * lw, pr.f("producer/producerYSize") as f32 * lh); - let porient = (pr.f("producer/producerOrientation") as f32).to_radians(); - let rate = pr.f("producer/productionRate").max(0.0) as f32; - let size = pr.f("bubbles/size").max(0.0) as f32; - let size_var = pr.f("bubbles/sizeVariance") as f32; - let lifespan = pr.f("bubbles/lifespan").max(1.0) as f32; - let growth = pr.f("bubbles/growthSpeed").max(0.0001) as f32; - // Strength: weaker bubbles tend to pop before their lifespan (at 20 or more every bubble - // lives the full lifespan); each bubble gets its own pop age. - let frailty = 1.0 - (pr.f("bubbles/strength") as f32 / 20.0).clamp(0.0, 1.0); - let init_speed = pr.f("physics/initialSpeed") as f32; - let init_dir = (pr.f("physics/initialDirection") as f32).to_radians(); - let wind = pr.f("physics/windSpeed") as f32; - let wind_dir = (pr.f("physics/windDirection") as f32).to_radians(); - let turb = pr.f("physics/turbulence") as f32; - let wobble = pr.f("physics/wobbleAmount").max(0.0) as f32; - let repulsion = pr.f("physics/repulsion") as f32; - let pop_velocity = pr.f("physics/popVelocity") as f32; - let viscosity = pr.f("physics/viscosity").clamp(0.0, 1.0) as f32; - let sticky = pr.f("physics/stickiness").clamp(0.0, 1.0) as f32; - let universe = pr.f("universeSize").max(0.01) as f32; - let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x68e31da4) ^ ctx.seed; - let wvec = [wind_dir.sin() * wind * unit, -wind_dir.cos() * wind * unit]; - // Flow Map: the map layer's first frame, its luminance a height field (Fits the layer or - // the whole universe). - let flow = pr.get("flowMap/flowMap").and_then(Value::as_layer).and_then(|id| { - let host = ctx.env.host?; - let me = pr.get(&crate::layer_source_id("flowMap/flowMap")).is_none_or(|v| v.as_enum() != 0); - host.layer_at(id, ctx.env.comp_time - ctx.time, me) - }); - let steep = pr.f("flowMap/flowMapSteepness") as f32; - let fits_universe = pr.e("flowMap/flowMapFits") == 1; - let substeps = match pr.e("flowMap/simulationQuality") { - 1 => 2, - 2 => 4, - _ => 1, - }; - let height = flow.as_ref().map(|lp| { - let img = &lp.buf.img; - let pl = Plane::from_image(img, |px| { - let (c, a) = unpremul(px); - effectcraft_color::luminance(c[0], c[1], c[2]) * a - }); - (pl, lp.buf.scale, lp.buf.offset, lp.size) - }); - let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); - let (ux1, uy1) = (lw - ux0, lh - uy0); - // Height at a layer point (0..1). - let h_at = |x: f32, y: f32| -> f32 { - let Some((pl, s, off, size)) = &height else { return 0.0 }; - let (fx, fy) = if fits_universe { ((x - ux0) / (ux1 - ux0), (y - uy0) / (uy1 - uy0)) } else { (x / lw, y / lh) }; - pl.sample(fx as f64 * size[0] * s + off[0], fy as f64 * size[1] * s + off[1]) - }; - let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); - if let Some((pl, ..)) = &height { - key ^= pl.data.iter().step_by(5).fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); - } - let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; - let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); - let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| { - let t = step as f32 / FOAM_FPS as f32; - // Births. - st.carry += rate; - let n = st.carry.floor() as u32; - st.carry -= n as f32; - for _ in 0..n { - if st.b.len() >= 3000 { - break; - } - let id = st.next; - st.next = st.next.wrapping_add(1); - let (ux, uy) = (hs(id, 1, seed) * 0.5 * psx, hs(id, 2, seed) * 0.5 * psy); - let (so, co) = porient.sin_cos(); - let p = [prod[0] as f32 + ux * co - uy * so, prod[1] as f32 + ux * so + uy * co]; - let v = [init_dir.sin() * init_speed * unit, -init_dir.cos() * init_speed * unit]; - let sz = (size * (1.0 + size_var * hs(id, 3, seed))).max(0.01); - st.b.push(FoamBubble { p, v, age: 0.0, size: sz, spin: hs(id, 12, seed) * std::f32::consts::PI, id }); - } - for _ in 0..substeps { - let k = 1.0 / substeps as f32; - // Forces: drift towards the wind, turbulence, viscosity, the flow map's slope. - for q in st.b.iter_mut() { - let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; - let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; - for c in 0..2 { - q.v[c] += (wvec[c] - q.v[c]) * 0.1 * k; - q.v[c] *= 1.0 - viscosity * 0.5 * k; - } - q.v[0] += n1 * turb * unit * k; - q.v[1] += n2 * turb * unit * k; - if height.is_some() && steep != 0.0 { - let e = unit.max(1.0); - let gx = (h_at(q.p[0] + e, q.p[1]) - h_at(q.p[0] - e, q.p[1])) / (2.0 * e); - let gy = (h_at(q.p[0], q.p[1] + e) - h_at(q.p[0], q.p[1] - e)) / (2.0 * e); - // Downhill: from light (high) to dark (low). - q.v[0] -= gx * steep * lw * 0.5 * k; - q.v[1] -= gy * steep * lw * 0.5 * k; - } - // Physical Orientation: bubbles roll with their sideways motion. - q.spin += q.v[0] / (rad(q).max(0.5)) * k; - } - // Repulsion between overlapping bubbles (grid hashed, deterministic order). - if repulsion > 0.0 && st.b.len() > 1 { - let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0); - let cs = maxr * 2.0; - let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); - for (i, q) in st.b.iter().enumerate() { - grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i); - } - let mut push = vec![[0.0f32; 2]; st.b.len()]; - for (i, q) in st.b.iter().enumerate() { - let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32); - for dy in -1..=1 { - for dx in -1..=1 { - let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue }; - for &j in list { - if j <= i { - continue; - } - let o = &st.b[j]; - let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]); - let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4); - let overlap = rad(q) + rad(o) - d; - if overlap > 0.0 { - let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d * k; - push[i][0] -= ddx * f; - push[i][1] -= ddy * f; - push[j][0] += ddx * f; - push[j][1] += ddy * f; - } - } - } - } - } - for (q, d) in st.b.iter_mut().zip(&push) { - q.p[0] += d[0].clamp(-maxr, maxr); - q.p[1] += d[1].clamp(-maxr, maxr); - } - } - for q in st.b.iter_mut() { - q.p[0] += q.v[0] * k; - q.p[1] += q.v[1] * k; - } - } - for q in st.b.iter_mut() { - q.age += 1.0; - } - // Pops: bubbles past their pop age burst, pushing their neighbours away. - let popped: Vec<([f32; 2], f32)> = st.b.iter().filter(|q| q.age >= foam_pop_age(q.id, lifespan, frailty, seed)).map(|q| (q.p, rad(q))).collect(); - if pop_velocity != 0.0 { - for (pp, pr_) in &popped { - let reach = (pr_ * 3.0).max(unit); - for q in st.b.iter_mut() { - let (dx, dy) = (q.p[0] - pp[0], q.p[1] - pp[1]); - let d = (dx * dx + dy * dy).sqrt(); - if d > 1e-4 && d < reach { - let f = pop_velocity * unit * (1.0 - d / reach) / d; - q.v[0] += dx * f; - q.v[1] += dy * f; - } - } - } - } - st.b.retain(|q| q.age < foam_pop_age(q.id, lifespan, frailty, seed) && q.p[0] > ux0 && q.p[0] < ux1 && q.p[1] > uy0 && q.p[1] < uy1); - }); - let view = pr.e("view"); - let zoom = pr.f("zoom").max(0.01) as f32; - let zoom_producer = pr.b("producer/zoomProducerPoint"); - let texture = pr.e("rendering/bubbleTexture"); - let orient = pr.e("rendering/bubbleOrientation"); - let blend = pr.e("rendering/blendMode"); - let s = b.scale as f32; - // Zoom centres on the layer, or on the producer with Zoom Producer Point. - let (cx, cy) = if zoom_producer { (prod[0] as f32, prod[1] as f32) } else { (lw * 0.5, lh * 0.5) }; - let mut order: Vec<&FoamBubble> = st.b.iter().collect(); - if blend == 2 { - // Solid New on Top: oldest first. - order.sort_by(|a, c| c.age.total_cmp(&a.age).then(a.id.cmp(&c.id))); - } else if blend == 1 { - order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id))); - } - // Where a bubble is drawn: buffer centre, radius, rotation (radians). - let place = |q: &FoamBubble| { - let wob = if wobble > 0.0 { 1.0 + wobble * 0.15 * ((q.age * 0.35) + hs(q.id, 13, seed) * 6.0).sin() } else { 1.0 }; - let r = rad(q) * wob * zoom * s; - let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]); - let rot = match orient { - 1 => q.spin, - 2 => q.v[1].atan2(q.v[0]), - _ => 0.0, - }; - (bx, by, r, rot) - }; - let user_tex = (view == 2 && texture == 5).then(|| ctx.layer_param("rendering/bubbleTextureLayer", true)).flatten(); - let sprites: Vec = order - .iter() - .filter(|_| user_tex.is_none()) - .map(|q| { - let (bx, by, r, rot) = place(q); - let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); - // Draft and Draft + Flow Map show the wireframe-style bubbles. - let mut sp = if view != 2 { - Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble) - } else { - let (c, shape) = match texture { - 1 => ([0.95, 0.95, 0.92], Shape::Faded), - 2 => ([1.0, 0.6, 0.8], Shape::Bubble), - 3 => ([0.8, 0.85, 0.75], Shape::Bubble), - 4 => ([0.97, 0.97, 0.95], Shape::Sphere), - _ => ([1.0, 1.0, 1.0], Shape::Bubble), - }; - Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], fade], shape) - }; - sp.rot = rot; - sp - }) - .collect(); - let acc = if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }; - let disc = |q: &&FoamBubble| { - let (x, y, r, rot) = place(q); - let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); - FoamDisc { x, y, r, rot, fade } - }; - let user = user_tex.map(|tex| (tex, order.iter().map(disc).collect())); - let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32; - let env = if view == 2 - && strength > 0.0 - && let Some(env) = ctx.layer_param("rendering/environmentMap", true) - { - Some(FoamEnv { - img: crate::util::fit_layer(ctx, b, &env, true), - conv: pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0), - strength, - discs: order.iter().map(disc).collect(), - }) - } else { - None - }; - let flow = if view == 1 - && let Some(lp) = &flow - { - Some(crate::util::fit_layer(ctx, b, lp, true)) - } else { - None - }; - FoamPlan { sprites: crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace }, user, env, flow } -} - -/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at -/// (`x`, `y`) with anti-aliased edges ("over" for opaque texels, additive for alpha 0). -fn foam_disc(out: &mut Image, x: f64, y: f64, r: f64, f: impl Fn(f64, f64) -> Px) { - if r <= 0.0 { - return; - } - let (x0, x1) = ((x - r - 1.0).floor().max(0.0) as i64, (x + r + 1.0).ceil().min(out.width as f64) as i64); - let (y0, y1) = ((y - r - 1.0).floor().max(0.0) as i64, (y + r + 1.0).ceil().min(out.height as f64) as i64); - for py in y0..y1 { - for px in x0..x1 { - let (u, v) = ((px as f64 + 0.5 - x) / r, (py as f64 + 0.5 - y) / r); - let d = (u * u + v * v).sqrt(); - let cov = ((1.0 - d) * r + 0.5).clamp(0.0, 1.0) as f32; - if cov <= 0.0 { - continue; - } - let s = f(u.clamp(-1.0, 1.0), v.clamp(-1.0, 1.0)).map(|c| c * cov); - let o = out.get(px, py); - let k = 1.0 - s[3]; - out.set(px as u32, py as u32, [s[0] + o[0] * k, s[1] + o[1] * k, s[2] + o[2] * k, (s[3] + o[3] * k).min(1.0)]); - } - } -} - -// ------------------------------------------------------------------ specs - -fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec { - let ids: [&'static str; 8] = match k { - 1 => [ - "producer1/producer1Type", - "producer1/producer1Position", - "producer1/producer1Length", - "producer1/producer1Width", - "producer1/producer1Angle", - "producer1/producer1Amplitude", - "producer1/producer1Frequency", - "producer1/producer1Phase", - ], - _ => [ - "producer2/producer2Type", - "producer2/producer2Position", - "producer2/producer2Length", - "producer2/producer2Width", - "producer2/producer2Angle", - "producer2/producer2Amplitude", - "producer2/producer2Frequency", - "producer2/producer2Phase", - ], - }; - vec![ - p(ids[0], "Type", Value::Enum(0), popup(&["Ring", "Line"])), - p(ids[1], "Position", pt(pos.0, pos.1), ParamUi::Point), - p(ids[2], "Height/Length", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), - p(ids[3], "Width", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), - p(ids[4], "Angle", num(0.0), ParamUi::Angle), - p(ids[5], "Amplitude", num(amp), slider(-5.0, 5.0, -1.0, 1.0, 3)), - p(ids[6], "Frequency", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 3)), - p(ids[7], "Phase", num(0.0), ParamUi::Angle), - ] -} - -fn card_dance_params() -> Vec { - let mut v = vec![ - p("rowsColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])), - p("rows", "Rows", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), - p("columns", "Columns", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), - p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), - p("gradientLayer1", "Gradient Layer 1", Value::Layer(None), ParamUi::Layer), - p("gradientLayer2", "Gradient Layer 2", Value::Layer(None), ParamUi::Layer), - ]; - let names: [[&'static str; 4]; 8] = [ - ["xPosition/xPosSource", "xPosition/xPosMultiplier", "xPosition/xPosOffset", "X Position"], - ["yPosition/yPosSource", "yPosition/yPosMultiplier", "yPosition/yPosOffset", "Y Position"], - ["zPosition/zPosSource", "zPosition/zPosMultiplier", "zPosition/zPosOffset", "Z Position"], - ["xRotation/xRotSource", "xRotation/xRotMultiplier", "xRotation/xRotOffset", "X Rotation"], - ["yRotation/yRotSource", "yRotation/yRotMultiplier", "yRotation/yRotOffset", "Y Rotation"], - ["zRotation/zRotSource", "zRotation/zRotMultiplier", "zRotation/zRotOffset", "Z Rotation"], - ["xScale/xScaleSource", "xScale/xScaleMultiplier", "xScale/xScaleOffset", "X Scale"], - ["yScale/yScaleSource", "yScale/yScaleMultiplier", "yScale/yScaleOffset", "Y Scale"], - ]; - debug_assert_eq!(names.len(), CD_PROPS.len()); - for (i, n) in names.iter().enumerate() { - let rot = (3..6).contains(&i); - let scale = i >= 6; - let (lo, hi) = if rot { (-3600.0, 3600.0) } else { (-100.0, 100.0) }; - v.push(p(n[0], "Source", Value::Enum(0), popup(CD_SOURCES))); - v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); - v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); - } - v.extend(crate::card3d::camera_params(2.0)); - v.extend(crate::card3d::lighting_params(1.0, 0.25)); - v.extend(crate::card3d::material_params(0.75)); - v -} - -pub fn specs() -> Vec { - vec![ - spec( - "ec.sim.ccballaction", - "CC Ball Action", - vec![ - p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), - p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])), - p("rotation", "Rotation", num(0.0), ParamUi::Angle), - p("twistProperty", "Twist Property", Value::Enum(0), popup(&["X Axis", "Y Axis", "Radius", "Brightness"])), - p("twistAngle", "Twist Angle", num(0.0), ParamUi::Angle), - p("gridSpacing", "Grid Spacing", num(4.0), slider(1.0, 200.0, 1.0, 50.0, 0)), - p("ballSize", "Ball Size", num(40.0), slider(0.0, 400.0, 0.0, 200.0, 1)), - p("instabilityState", "Instability State", num(0.0), ParamUi::Angle), - ], - ball_action, - ), - spec( - "ec.sim.ccpixelpolly", - "CC Pixel Polly", - vec![ - p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)), - p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), - p("spinning", "Spinning", num(0.0), ParamUi::Angle), - p("forceCenter", "Force Center", pt(0.5, 0.5), ParamUi::Point), - p("directionRandomness", "Direction Randomness", num(10.0), pct()), - p("speedRandomness", "Speed Randomness", num(10.0), pct()), - p("gridSpacing", "Grid Spacing", num(10.0), slider(1.0, 500.0, 2.0, 100.0, 0)), - p("object", "Object", Value::Enum(0), popup(&["Polygon", "Textured Polygon", "Square", "Textured Square"])), - p("enableDepthSort", "Enable Depth Sort", Value::Bool(true), ParamUi::Checkbox), - p("startTime", "Start Time (sec)", num(0.0), slider(-1000.0, 1000.0, 0.0, 10.0, 2)), - ], - pixel_polly, - ), - spec( - "ec.sim.ccscatterize", - "CC Scatterize", - vec![ - p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), - p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle), - p("leftTwist", "Left Twist", num(0.0), ParamUi::Angle), - p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Add"])), - ], - scatterize, - ), - spec("ec.sim.carddance", "Card Dance", card_dance_params(), card_dance), - spec( - "ec.sim.caustics", - "Caustics", - vec![ - p("bottom/bottom", "Bottom", Value::Layer(None), ParamUi::Layer), - p("bottom/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), - p("bottom/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), - p("bottom/bottomSizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), - p("bottom/blur", "Blur", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("water/waterSurface", "Water Surface", Value::Layer(None), ParamUi::Layer), - p("water/waveHeight", "Wave Height", num(0.2), slider(-1.0, 1.0, -1.0, 1.0, 3)), - p("water/smoothing", "Smoothing", num(5.0), slider(0.0, 100.0, 0.0, 50.0, 1)), - p("water/waterDepth", "Water Depth", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("water/refractiveIndex", "Refractive Index", num(1.2), slider(1.0, 3.0, 1.0, 2.0, 3)), - p("water/surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("water/surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("water/causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("sky/sky", "Sky", Value::Layer(None), ParamUi::Layer), - p("sky/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), - p("sky/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), - p("sky/skySizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), - p("sky/intensity", "Intensity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("sky/convergence", "Convergence", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("lighting/lightType", "Light Type", Value::Enum(0), popup(&crate::card3d::LIGHT_TYPES)), - p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), - p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("lighting/lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point), - p("lighting/lightHeight", "Light Height", num(1.0), slider(0.0, 10.0, 0.0, 4.0, 2)), - p("lighting/ambientLight", "Ambient Light", num(0.35), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/specular", "Specular Reflection", num(0.2), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)), - ], - caustics, - ), - spec( - "ec.sim.waveworld", - "Wave World", - { - let mut v = vec![ - p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])), - p("wireframeControls/horizontalRotation", "Horizontal Rotation", num(0.0), ParamUi::Angle), - p("wireframeControls/verticalRotation", "Vertical Rotation", num(53.0), slider(0.0, 90.0, 0.0, 90.0, 1)), - p("wireframeControls/verticalScale", "Vertical Scale", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("heightMapControls/brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)), - p("heightMapControls/contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)), - p("heightMapControls/gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)), - p("heightMapControls/renderDryAreasAs", "Render Dry Areas As", Value::Enum(0), popup(&["Solid", "Transparent"])), - p("heightMapControls/transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("simulation/gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)), - p("simulation/gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox), - p("simulation/waveSpeed", "Wave Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), - p("simulation/damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)), - p("simulation/reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])), - p("simulation/preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)), - p("ground/ground", "Ground", Value::Layer(None), ParamUi::Layer), - p("ground/steepness", "Steepness", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("ground/height", "Height", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("ground/waveStrength", "Wave Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - ]; - v.extend(producer_params(1, 0.5, (0.5, 0.5))); - v.extend(producer_params(2, 0.0, (0.25, 0.25))); - v - }, - wave_world, - ), - spec( - "ec.sim.shatter", - "Shatter", - vec![ - p( - "view", - "View", - Value::Enum(1), - popup(&["Rendered", "Wireframe Front View", "Wireframe", "Wireframe Front View + Forces", "Wireframe + Forces"]), - ), - p("render", "Render", Value::Enum(0), popup(&["All", "Layer", "Pieces"])), - p("shape/pattern", "Pattern", Value::Enum(0), popup(SHATTER_PATTERNS)), - p("shape/repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)), - p("shape/direction", "Direction", num(0.0), ParamUi::Angle), - p("shape/origin", "Origin", pt(0.5, 0.5), ParamUi::Point), - p("shape/customShatterMap", "Custom Shatter Map", Value::Layer(None), ParamUi::Layer), - p("shape/whiteTilesFixed", "White Tiles Fixed", Value::Bool(false), ParamUi::Checkbox), - p("shape/extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("force1/force1Position", "Position", pt(0.5, 0.5), ParamUi::Point), - p("force1/force1Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), - p("force1/force1Radius", "Radius", num(0.4), slider(0.0, 4.0, 0.0, 2.0, 3)), - p("force1/force1Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), - p("force2/force2Position", "Position", pt(0.25, 0.25), ParamUi::Point), - p("force2/force2Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), - p("force2/force2Radius", "Radius", num(0.0), slider(0.0, 4.0, 0.0, 2.0, 3)), - p("force2/force2Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), - p("physics/rotationSpeed", "Rotation Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), - p("physics/tumbleAxis", "Tumble Axis", Value::Enum(0), popup(&["Free", "None", "X", "Y", "Z", "XY", "XZ", "YZ"])), - p("physics/randomness", "Randomness", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("physics/massVariance", "Mass Variance", num(30.0), pct()), - p("physics/gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), - p("physics/gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle), - p("physics/gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), - p("gradient/shatterThreshold", "Shatter Threshold", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), - p("gradient/gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), - p("gradient/invertGradient", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox), - p("textures/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("textures/opacity", "Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("textures/frontMode", "Front Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), - p("textures/sideMode", "Side Mode", Value::Enum(0), popup(&SHATTER_TEXTURE_MODES)), - p("textures/backMode", "Back Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), - p("textures/frontLayer", "Front Layer", Value::Layer(None), ParamUi::Layer), - p("textures/sideLayer", "Side Layer", Value::Layer(None), ParamUi::Layer), - p("textures/backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), - ] - .into_iter() - .chain(crate::card3d::camera_params(4.0)) - .chain(crate::card3d::lighting_params(1.0, 0.25)) - .chain(crate::card3d::material_params(0.75)) - .chain([p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0))]) - .collect(), - shatter, - ), - spec( - "ec.sim.foam", - "Foam", - vec![ - p("view", "View", Value::Enum(0), popup(&["Draft", "Draft + Flow Map", "Rendered"])), - p("producer/producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), - p("producer/producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("producer/producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("producer/producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle), - p("producer/zoomProducerPoint", "Zoom Producer Point", Value::Bool(false), ParamUi::Checkbox), - p("producer/productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)), - p("bubbles/size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("bubbles/sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("bubbles/lifespan", "Lifespan", num(300.0), slider(1.0, 30_000.0, 1.0, 1000.0, 1)), - p("bubbles/growthSpeed", "Bubble Growth Speed", num(0.1), slider(0.0001, 10.0, 0.0001, 1.0, 3)), - p("bubbles/strength", "Strength", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)), - p("physics/initialSpeed", "Initial Speed", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), - p("physics/initialDirection", "Initial Direction", num(0.0), ParamUi::Angle), - p("physics/windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)), - p("physics/windDirection", "Wind Direction", num(90.0), ParamUi::Angle), - p("physics/turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("physics/wobbleAmount", "Wobble Amount", num(0.05), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("physics/repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("physics/popVelocity", "Pop Velocity", num(1.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), - p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("physics/stickiness", "Stickiness", num(0.75), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("zoom", "Zoom", num(1.0), slider(0.01, 50.0, 0.1, 5.0, 3)), - p("universeSize", "Universe Size", num(1.0), slider(0.01, 10.0, 0.5, 3.0, 3)), - p("rendering/blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])), - p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&FOAM_TEXTURES)), - p("rendering/bubbleTextureLayer", "Bubble Texture Layer", Value::Layer(None), ParamUi::Layer), - p("rendering/bubbleOrientation", "Bubble Orientation", Value::Enum(0), popup(&["Fixed", "Physical Orientation", "Bubble Velocity"])), - p("rendering/environmentMap", "Environment Map", Value::Layer(None), ParamUi::Layer), - p("rendering/reflectionStrength", "Reflection Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("rendering/reflectionConvergence", "Reflection Convergence", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("flowMap/flowMap", "Flow Map", Value::Layer(None), ParamUi::Layer), - p("flowMap/flowMapSteepness", "Flow Map Steepness", num(0.05), slider(-100.0, 100.0, -1.0, 1.0, 3)), - p("flowMap/flowMapFits", "Flow Map Fits", Value::Enum(0), popup(&["Background", "Universe"])), - p("flowMap/simulationQuality", "Simulation Quality", Value::Enum(0), popup(&["Normal", "High", "Intense"])), - p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - ], - foam, - ), - ] -} +mod foam; +mod pieces; +mod pixels; +mod registry; +mod shatter; +mod surfaces; + +pub(crate) use foam::foam_plan; +#[cfg(test)] +use foam::foam_pop_age; +pub use foam::{FoamDisc, FoamEnv, FoamPlan, foam_full_plan}; +#[cfg(test)] +use pieces::Cam; +#[cfg(test)] +pub(crate) use pieces::draw_pieces; +pub use pieces::{Piece, PiecePlan, PieceTex, piece_plan}; +pub(crate) use pieces::{rot_axis, sort_far_first}; +pub use pixels::{PixelPlan, pixel_plan}; +pub use registry::specs; +#[cfg(test)] +use shatter::SHATTER_PATTERNS; +pub use surfaces::{CausticsSetup, WavePlan, Waves, caustics_setup, wave_world_plan}; #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, run_fx}; - - fn ramp(w: u32, hh: u32) -> Image { - let mut img = Image::new(w, hh); - for y in 0..hh { - for x in 0..w { - img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); - } - } - img - } - - fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { - run_fx(id, vals, img, t, EffectEnv::default()).img - } - - fn max_diff(a: &Image, b: &Image) -> f32 { - a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) - } - - fn sum_diff(a: &Image, b: &Image) -> f32 { - a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() - } - - #[test] - fn piece_at_rest_is_identity() { - let src = ramp(16, 16); - let cam = Cam { c: [8.0, 8.0], d: 64.0 }; - let id = rot_axis([1.0, 0.0, 0.0], 0.0); - let pc = Piece::new(&[[0.0, 0.0], [16.0, 0.0], [16.0, 16.0], [0.0, 16.0]], [8.0, 8.0], &id, [8.0, 8.0, 0.0], cam).unwrap(); - let mut out = Image::new(16, 16); - draw_pieces(&mut out, &[pc], &src, None); - assert!(max_diff(&out, &src) < 1e-4); - } - - #[test] - fn ball_action_draws_balls() { - let v = [("ballSize", num(80.0))]; - let a = run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0); - assert_eq!(a, run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0)); - assert!(a.data.iter().any(|p| p[3] > 0.9) && a.data.iter().any(|p| p[3] == 0.0), "balls with gaps"); - let none = run("ec.sim.ccballaction", &[("ballSize", num(0.0))], ramp(32, 32), 0.0); - assert!(none.data.iter().all(|p| p[3] == 0.0)); - let sc = run("ec.sim.ccballaction", &[("ballSize", num(80.0)), ("scatter", num(20.0))], ramp(32, 32), 0.0); - assert!(sum_diff(&a, &sc) > 1.0); - } - - #[test] - fn pixel_polly_breaks_after_start() { - let v = [("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0))]; - let before = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.0); - assert_eq!(before, ramp(32, 32), "intact before the start time"); - let a = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5); - assert_eq!(a, run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5)); - assert!(sum_diff(&a, &ramp(32, 32)) > 10.0); - let tex = run("ec.sim.ccpixelpolly", &[("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0)), ("object", Value::Enum(3))], ramp(32, 32), 0.5); - assert!(sum_diff(&a, &tex) > 0.1); - } - - #[test] - fn scatterize_identity_and_scatter() { - assert_eq!(run("ec.sim.ccscatterize", &[], ramp(24, 24), 0.0), ramp(24, 24)); - let a = run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0); - assert_eq!(a, run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0)); - assert!(sum_diff(&a, &ramp(24, 24)) > 1.0); - let tw = run("ec.sim.ccscatterize", &[("leftTwist", num(80.0)), ("rightTwist", num(80.0))], ramp(24, 24), 0.0); - assert!(sum_diff(&tw, &ramp(24, 24)) > 1.0); - } - - #[test] - fn card_dance_identity_and_rotation() { - let src = ramp(30, 20); - let a = run("ec.sim.carddance", &[], src.clone(), 0.0); - assert!(max_diff(&a, &src) < 0.02, "flat cards reproduce the layer: {}", max_diff(&a, &src)); - let r = run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0); - assert_eq!(r, run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0)); - assert!(sum_diff(&r, &src) > 5.0); - let z = run("ec.sim.carddance", &[("zPosition/zPosOffset", num(-1.0))], src.clone(), 0.0); - assert!(sum_diff(&z, &src) > 5.0, "cards pulled towards the camera grow"); - } - - #[test] - fn shatter_at_rest_then_breaks() { - let v = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))]; - let src = ramp(32, 32); - let t0 = run("ec.sim.shatter", &v, src.clone(), 0.0); - assert!(max_diff(&t0, &src) < 0.03, "{}", max_diff(&t0, &src)); - let t1 = run("ec.sim.shatter", &v, src.clone(), 0.5); - assert_eq!(t1, run("ec.sim.shatter", &v, src.clone(), 0.5)); - assert!(sum_diff(&t1, &src) > 10.0); - for pat in 0..SHATTER_PATTERNS.len() as u32 { - let vv = - [("view", Value::Enum(0)), ("shape/pattern", Value::Enum(pat)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))]; - let r = run("ec.sim.shatter", &vv, src.clone(), 0.0); - assert!(max_diff(&r, &src) < 0.05, "pattern {pat} tiles the layer: {}", max_diff(&r, &src)); - } - let wire = run("ec.sim.shatter", &[("shape/repetitions", num(3.0))], src.clone(), 0.0); - assert!(wire.data.iter().any(|p| p[3] > 0.5) && wire.data.iter().any(|p| p[3] == 0.0)); - let layer_only = - run("ec.sim.shatter", &[("view", Value::Enum(0)), ("render", Value::Enum(1)), ("force1/force1Position", pt(16.0, 16.0))], src.clone(), 0.5); - assert!(layer_only.get(16, 16)[3] == 0.0, "broken centre removed"); - } - - #[test] - fn caustics_flat_water() { - let src = ramp(24, 24); - let a = run( - "ec.sim.caustics", - &[ - ("water/surfaceOpacity", num(0.0)), - ("lighting/ambientLight", num(0.0)), - ("material/diffuse", num(1.0)), - ("lighting/lightHeight", num(1000.0)), - ("material/specular", num(0.0)), - ], - src.clone(), - 0.0, - ); - assert!(max_diff(&a, &src) < 0.01, "flat water, light overhead = bottom: {}", max_diff(&a, &src)); - let b = run("ec.sim.caustics", &[], src.clone(), 0.0); - assert_eq!(b, run("ec.sim.caustics", &[], src.clone(), 0.0)); - assert!(sum_diff(&b, &src) > 0.1); - } - - #[test] - fn caustics_sky_and_light_types() { - let src = Image::filled(32, 32, [0.5, 0.5, 0.5, 1.0]); - let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; - let run_h = |vals: &[(&str, Value)]| run_fx("ec.sim.caustics", vals, src.clone(), 0.0, env).img; - // A mirror-like surface shows the (green) sky. - let mirror = [("water/surfaceOpacity", num(1.0)), ("sky/sky", Value::Layer(Some(2))), ("sky/intensity", num(1.0))]; - let p = run_h(&mirror).get(16, 16); - assert!(p[1] > p[0] + 0.3 && p[1] > p[2] + 0.3, "{p:?}"); - // Point Source lights pixels by their own direction to the light: the image is no - // longer uniform; Distant Source lights the flat water evenly. - let lit = |t: u32| { - run_h(&[ - ("lighting/lightType", Value::Enum(t)), - ("lighting/lightPosition", pt(0.0, 0.0)), - ("lighting/lightHeight", num(0.2)), - ("water/surfaceOpacity", num(0.0)), - ]) - }; - let (distant, point) = (lit(0), lit(1)); - assert!((distant.get(2, 2)[0] - distant.get(30, 30)[0]).abs() < 1e-5); - assert!(point.get(2, 2)[0] > point.get(30, 30)[0] + 0.02, "{:?} {:?}", point.get(2, 2), point.get(30, 30)); - // First Comp Light without a comp light: ambient only. - let none = run_h(&[("lighting/lightType", Value::Enum(2)), ("water/surfaceOpacity", num(0.0))]); - assert!(none.get(16, 16)[0] < distant.get(16, 16)[0]); - } - - #[test] - fn wave_world_wireframe_controls_ground_and_dry_areas() { - let base = [("producer1/producer1Position", pt(8.0, 16.0)), ("simulation/gridResolution", num(24.0))]; - let wire = |extra: &[(&str, Value)]| run("ec.sim.waveworld", &[base.as_slice(), extra].concat(), Image::new(32, 32), 0.5); - assert_ne!(wire(&[]), wire(&[("wireframeControls/horizontalRotation", num(40.0))])); - assert_ne!(wire(&[]), wire(&[("wireframeControls/verticalScale", num(3.0))])); - // Ground (left dark, right light): the light side rises above the water and is dry. - let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; - let hm = |extra: &[(&str, Value)]| { - let mut v: Vec<(&str, Value)> = base.to_vec(); - v.extend([("view", Value::Enum(1)), ("ground/ground", Value::Layer(Some(1))), ("ground/steepness", num(0.5)), ("ground/height", num(0.25))]); - v.extend_from_slice(extra); - run_fx("ec.sim.waveworld", &v, Image::new(32, 32), 1.0, env).img - }; - let solid = hm(&[]); - let clear = hm(&[("heightMapControls/renderDryAreasAs", Value::Enum(1))]); - assert!(solid.get(30, 16)[3] > 0.99 && clear.get(30, 16)[3] == 0.0); - assert!(clear.get(4, 16)[3] > 0.99, "wet side stays"); - // Dry ground stays flat: mid grey (brightness 0.5) there. - assert!((solid.get(30, 16)[0] - 0.5).abs() < 1e-4); - // Wave Strength slows waves over shallow water: the surface differs. - assert_ne!(hm(&[]), hm(&[("ground/waveStrength", num(1.0))])); - } - - #[test] - fn wave_world_seek_and_waves() { - let v = vec![("view", Value::Enum(1)), ("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(24.0))]; - let _ = run("ec.sim.waveworld", &v, Image::new(32, 32), 0.5); - let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.0); - let mut v2 = v.clone(); - v2.push(("heightMapControls/transparency", num(0.0))); - assert_eq!(a, run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.0)); - // Waves spread: some pixels differ from the flat-water grey. - assert!(a.data.iter().any(|p| (p[0] - 0.5).abs() > 0.05), "waves"); - let flat = run("ec.sim.waveworld", &[("view", Value::Enum(1)), ("producer1/producer1Amplitude", num(0.0))], Image::new(32, 32), 1.0); - assert!(flat.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5)); - let wire = - run("ec.sim.waveworld", &[("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(10.0))], Image::new(32, 32), 0.3); - assert!(wire.data.iter().any(|p| p[1] > 0.3)); - } - - #[test] - fn wave_world_steps_match_direct() { - // Fresh caches: resumed simulation equals simulation from scratch. - let v = vec![ - ("view", Value::Enum(1)), - ("producer1/producer1Position", pt(16.0, 16.0)), - ("simulation/gridResolution", num(20.0)), - ("simulation/damping", num(0.07)), - ]; - let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.3); - let mut v2 = v.clone(); - v2.push(("simulation/damping", num(0.07000001))); - // Different key: computed from scratch at 1.3 directly vs via 0.4 (both fresh keys). - let _ = run("ec.sim.waveworld", &v2, Image::new(32, 32), 0.4); - let b = run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.3); - assert!(max_diff(&a, &b) < 1e-3, "{}", max_diff(&a, &b)); - } - - #[test] - fn foam_bubbles_drift_and_seek() { - let v = vec![("producer/producerPoint", pt(16.0, 16.0)), ("view", Value::Enum(2)), ("producer/productionRate", num(2.0))]; - let none = run("ec.sim.foam", &v, Image::new(32, 32), 0.0); - assert!(none.data.iter().all(|p| p[3] == 0.0)); - let _ = run("ec.sim.foam", &v, Image::new(32, 32), 0.6); - let a = run("ec.sim.foam", &v, Image::new(32, 32), 1.2); - let mut v2 = v.clone(); - v2.push(("randomSeed", num(1.0))); - assert_eq!(a, run("ec.sim.foam", &v2, Image::new(32, 32), 1.2)); - assert!(a.data.iter().any(|p| p[3] > 0.05), "bubbles"); - let draft = run("ec.sim.foam", &[("producer/producerPoint", pt(16.0, 16.0)), ("producer/productionRate", num(2.0))], Image::new(32, 32), 1.0); - assert!(draft.data.iter().any(|p| p[3] > 0.05)); - } - - #[test] - fn foam_strength_controls_early_popping() { - // Strong bubbles all live their full lifespan; weak ones pop at random earlier. - assert_eq!(foam_pop_age(7, 300.0, 0.0, 1), 300.0); - let early = (0..200).filter(|&id| foam_pop_age(id, 300.0, 1.0, 1) < 150.0).count(); - assert!(early > 60 && early < 140, "{early}"); - let count = |strength: f64| { - let v = vec![ - ("producer/producerPoint", pt(16.0, 16.0)), - ("producer/productionRate", num(3.0)), - ("bubbles/lifespan", num(40.0)), - ("bubbles/strength", num(strength)), - ("physics/windSpeed", num(0.0)), - ]; - run("ec.sim.foam", &v, Image::new(32, 32), 1.5).data.iter().map(|p| p[3]).sum::() - }; - assert!(count(0.0) < count(20.0) * 0.9, "{} vs {}", count(0.0), count(20.0)); - } - - /// A host with a comp camera (identity, or tilted) and a first comp light. - struct Scene { - camera: Option<[[f64; 4]; 3]>, - light: Option, - layer: Option, - } - impl crate::EffectHost for Scene { - fn layer(&self, _: u64, _: bool) -> Option { - let img = self.layer.clone()?; - let size = [img.width as f64, img.height as f64]; - Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size }) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn comp_scene(&self) -> Option { - Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light }) - } - } - - fn run_env(id: &str, vals: &[(&str, Value)], img: Image, t: f64, host: &Scene) -> Image { - run_fx(id, vals, img, t, EffectEnv { host: Some(host), ..Default::default() }).img - } - - #[test] - fn card_dance_camera_systems() { - let src = ramp(40, 30); - // Camera Position: rotating the camera changes the view; zero rotation is the layer. - let r = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0))], src.clone(), 0.0); - assert!(sum_diff(&r, &src) > 10.0); - let order = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0))], src.clone(), 0.0); - let order2 = run( - "ec.sim.carddance", - &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0)), ("cameraPosition/transformOrder", Value::Enum(5))], - src.clone(), - 0.0, - ); - assert!(sum_diff(&order, &order2) > 1.0, "Transform Order matters"); - // X, Y Position moves the view: the layer shifts by the offset. - let moved = run("ec.sim.carddance", &[("cameraPosition/xyPosition", pt(30.0, 15.0))], src.clone(), 0.0); - assert!((moved.get(25, 10)[0] - src.get(15, 10)[0]).abs() < 0.03, "{:?} {:?}", moved.get(25, 10), src.get(15, 10)); - // Corner Pins: the layer's corners land on the pins. - let pins = [ - ("cameraSystem", Value::Enum(1)), - ("cornerPins/upperLeftCorner", pt(10.0, 5.0)), - ("cornerPins/upperRightCorner", pt(30.0, 5.0)), - ("cornerPins/lowerLeftCorner", pt(10.0, 25.0)), - ("cornerPins/lowerRightCorner", pt(30.0, 25.0)), - ]; - let cp = run("ec.sim.carddance", &pins, src.clone(), 0.0); - assert_eq!(cp.get(3, 3)[3], 0.0, "outside the pinned quad"); - assert!(cp.get(20, 15)[3] > 0.99); - assert!((cp.get(20, 15)[0] - src.get(20, 15)[0]).abs() < 0.05, "centre maps to centre"); - // Comp Camera: the identity camera reproduces the layer; a camera that doubles x moves it. - let ident = Scene { camera: Some([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), light: None, layer: None }; - let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &ident); - assert!(max_diff(&cc, &src) < 0.02, "{}", max_diff(&cc, &src)); - let half = Scene { camera: Some([[0.5, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), ..ident }; - let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &half); - assert_eq!(cc.get(35, 25)[3], 0.0, "half-size view leaves the far corner empty"); - } - - #[test] - fn card_dance_lighting_and_material() { - let src = Image::filled(40, 30, [0.5, 0.5, 0.5, 1.0]); - let tilt = [("rows", num(1.0)), ("columns", num(1.0)), ("yRotation/yRotSource", Value::Enum(0)), ("yRotation/yRotOffset", num(60.0))]; - let lit = |extra: &[(&str, Value)]| { - let mut v: Vec<(&str, Value)> = tilt.to_vec(); - v.extend_from_slice(extra); - run("ec.sim.carddance", &v, src.clone(), 0.0).get(20, 15) - }; - let base = lit(&[]); - // Tilted cards facing away from a frontal light get darker than flat ones. - assert!(base[0] < 0.45, "{base:?}"); - // Light colour tints; intensity brightens; ambient alone lights evenly. - let red = lit(&[("lighting/lightColor", col(1.0, 0.0, 0.0))]); - assert!(red[0] > red[1] + 0.05); - assert!(lit(&[("lighting/lightIntensity", num(2.0))])[0] > base[0]); - let amb = lit(&[("lighting/ambientLight", num(1.0)), ("material/diffuse", num(0.0))]); - assert!((amb[0] - 0.5).abs() < 1e-3); - // A light placed to the side (Point Source) changes the shading of the tilted card. - let side = lit(&[("lighting/lightType", Value::Enum(1)), ("lighting/lightPosition", pt(200.0, 15.0)), ("lighting/lightDepth", num(0.2))]); - assert!((side[0] - base[0]).abs() > 0.02, "{side:?} vs {base:?}"); - // Specular adds a highlight on flat, frontally lit cards. - let flat = run("ec.sim.carddance", &[("material/specular", num(0.5))], src.clone(), 0.0).get(20, 15); - assert!(flat[0] > 0.9, "{flat:?}"); - // First Comp Light: no light in the comp → ambient only; a light from the host is used. - let none = Scene { camera: None, light: None, layer: None }; - let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &none).get(20, 15); - assert!((a[0] - 0.5 * (0.25 + 0.75)).abs() < 1e-3, "{a:?}"); - let blue = Scene { light: Some(crate::CompLight { pos: [20.0, 15.0, -100.0], dir: [0.0, 0.0, 1.0], color: [0.0, 0.0, 1.0], kind: 0 }), ..none }; - let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &blue).get(20, 15); - assert!(a[2] > a[0] + 0.2, "{a:?}"); - } - - #[test] - fn shatter_custom_map_gradient_textures_and_extrusion() { - let src = ramp(32, 32); - let base = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("force1/force1Radius", num(2.0)), ("shape/origin", pt(16.0, 16.0))]; - let with = |extra: &[(&str, Value)], t: f64, host: Option<&Scene>| { - let mut v: Vec<(&str, Value)> = base.to_vec(); - v.extend_from_slice(extra); - match host { - Some(h) => run_env("ec.sim.shatter", &v, src.clone(), t, h), - None => run("ec.sim.shatter", &v, src.clone(), t), - } - }; - // Custom Shatter Map: a map white on the left and black on the right; with White Tiles - // Fixed the left half stays put while the right half breaks away. - let mut map = Image::new(32, 32); - for y in 0..32 { - for x in 0..32 { - map.set(x, y, if x < 16 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); - } - } - let host = Scene { camera: None, light: None, layer: Some(map) }; - let custom = [("shape/pattern", Value::Enum(5)), ("shape/customShatterMap", Value::Layer(Some(1))), ("shape/whiteTilesFixed", Value::Bool(true))]; - let at_rest = with(&custom, 0.0, Some(&host)); - assert!(max_diff(&at_rest, &src) < 0.05, "custom pieces tile the layer: {}", max_diff(&at_rest, &src)); - let broke = with(&custom, 1.0, Some(&host)); - assert!(max_diff_region(&broke, &src, 0..12) < 0.05, "white tiles fixed"); - assert!(sum_diff_region(&broke, &src, 20..32) > 5.0, "black group moved"); - // Gradient: a black gradient layer with threshold 50 % keeps everything together. - let black = Scene { camera: None, light: None, layer: Some(Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0])) }; - let g = with(&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0))], 1.0, Some(&black)); - assert!(max_diff(&g, &src) < 0.05); - let gi = with( - &[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0)), ("gradient/invertGradient", Value::Bool(true))], - 1.0, - Some(&black), - ); - assert!(sum_diff(&gi, &src) > 10.0); - // Textures: Front Mode Color paints the pieces flat; Opacity fades them. - let flat = with(&[("textures/frontMode", Value::Enum(0)), ("textures/color", col(0.0, 1.0, 0.0))], 0.0, None); - let p = flat.get(16, 16); - assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}"); - let half = with(&[("textures/opacity", num(0.5)), ("shape/repetitions", num(2.0))], 0.0, None); - assert!((half.get(8, 4)[3] - 0.5).abs() < 0.02, "{:?}", half.get(8, 4)); - // Extrusion: thicker pieces cover more once they tumble. - let cover = |d: f64| { - with(&[("shape/extrusionDepth", num(d)), ("textures/sideMode", Value::Enum(0)), ("textures/color", col(1.0, 1.0, 1.0))], 0.6, None) - .data - .iter() - .map(|p| p[3]) - .sum::() - }; - assert!(cover(1.0) > cover(0.0) + 1.0, "{} vs {}", cover(1.0), cover(0.0)); - // Deterministic and seek-consistent. - assert_eq!(with(&custom, 0.7, Some(&host)), with(&custom, 0.7, Some(&host))); - } - - fn max_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { - let mut m = 0.0f32; - for y in 0..a.height { - for x in xs.clone() { - let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); - m = (0..4).map(|k| (p[k] - q[k]).abs()).fold(m, f32::max); - } - } - m - } - - fn sum_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { - let mut s = 0.0; - for y in 0..a.height { - for x in xs.clone() { - let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); - s += (0..4).map(|k| (p[k] - q[k]).abs()).sum::(); - } - } - s - } - - /// Layer 1 = a left-dark, right-light ramp; layer 2 = solid green. - struct FoamHost; - impl crate::EffectHost for FoamHost { - fn layer(&self, id: u64, _: bool) -> Option { - let img = if id == 1 { ramp(32, 32) } else { Image::filled(32, 32, [0.0, 1.0, 0.0, 1.0]) }; - Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [32.0, 32.0] }) - } - fn layer_at(&self, id: u64, _: f64, me: bool) -> Option { - self.layer(id, me) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - } - - #[test] - fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() { - let base = vec![ - ("producer/producerPoint", pt(16.0, 16.0)), - ("producer/productionRate", num(2.0)), - ("physics/windSpeed", num(0.0)), - ("physics/turbulence", num(0.0)), - ("view", Value::Enum(2)), - ]; - let with = |extra: &[(&str, Value)], t: f64| { - let mut v = base.clone(); - v.extend_from_slice(extra); - run_fx("ec.sim.foam", &v, Image::new(32, 32), t, EffectEnv { host: Some(&FoamHost), ..Default::default() }).img - }; - let cx = |img: &Image| { - let (mut s, mut n) = (0.0, 0.0); - for y in 0..32 { - for x in 0..32 { - let a = img.get(x, y)[3]; - s += a * x as f32; - n += a; - } - } - s / n.max(1e-6) - }; - // Flow map: bubbles run downhill (towards the dark left side). - let still = with(&[], 1.0); - let flowing = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0))], 1.0); - assert!(cx(&flowing) < cx(&still) - 1.0, "{} vs {}", cx(&flowing), cx(&still)); - let hq = - with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0); - assert_eq!( - hq, - with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0) - ); - // Pop velocity scatters the survivors when short-lived bubbles pop. - let short = [("bubbles/lifespan", num(8.0)), ("bubbles/strength", num(0.0))]; - let a = with(&[short.as_slice(), &[("physics/popVelocity", num(0.0))]].concat(), 1.0); - let b2 = with(&[short.as_slice(), &[("physics/popVelocity", num(20.0))]].concat(), 1.0); - assert_ne!(a, b2); - // Wobble changes the drawn bubbles. - assert_ne!(with(&[("physics/wobbleAmount", num(0.0))], 1.0), with(&[("physics/wobbleAmount", num(3.0))], 1.0)); - // User Defined texture: bubbles show the texture layer (green). - let tex = with(&[("rendering/bubbleTexture", Value::Enum(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(2)))], 1.0); - assert!(tex.data.iter().any(|p| p[1] > 0.5 && p[0] < 0.1)); - // Environment map: reflections tint the bubbles. - let refl = with(&[("rendering/environmentMap", Value::Layer(Some(2))), ("rendering/reflectionStrength", num(1.0))], 1.0); - let g = |i: &Image| i.data.iter().map(|p| p[1] - p[0]).sum::(); - assert!(g(&refl) > g(&still) + 1.0); - // Zoom Producer Point zooms about the producer instead of the layer centre. - let off = [("producer/producerPoint", pt(8.0, 8.0)), ("zoom", num(2.0))]; - let z1 = with(&off, 1.0); - let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0); - assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1)); - } -} +mod tests; diff --git a/crates/effects/src/sim2/foam.rs b/crates/effects/src/sim2/foam.rs new file mode 100644 index 000000000..cf9009492 --- /dev/null +++ b/crates/effects/src/sim2/foam.rs @@ -0,0 +1,408 @@ +use super::*; + +// ------------------------------------------------------------------ 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..3aa3a68f6 --- /dev/null +++ b/crates/effects/src/sim2/pieces.rs @@ -0,0 +1,277 @@ +use super::shatter::{ShatterPlan, card_dance_plan, shatter_impl}; +use super::*; + +// ------------------------------------------------------------------ 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..2079f03c3 --- /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::*; + +// ------------------------------------------------------------------ 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..93508e7b5 --- /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::*; + +// ------------------------------------------------------------------ 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..90b5163f1 --- /dev/null +++ b/crates/effects/src/sim2/shatter.rs @@ -0,0 +1,481 @@ +use super::pieces::{M3, Piece, PiecePlan, PieceTex, inv3, rot_axis, rot_xyz, sort_far_first}; +use super::*; + +// ------------------------------------------------------------------ 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..0bcc639c4 --- /dev/null +++ b/crates/effects/src/sim2/surfaces.rs @@ -0,0 +1,524 @@ +use super::*; + +// ------------------------------------------------------------------ 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..688b45950 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -1176,379 +1176,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/stylize2.rs b/crates/effects/src/stylize2.rs index b957b4e41..e0354ea75 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -433,13 +433,7 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf { let mirror = ctx.params.b("mirrorEdges"); let phase = ctx.params.f("phase") / 360.0; let hshift = ctx.params.b("horizontalPhaseShift"); - let (nw, nh, noff) = if ctx.adjustment { - (b.img.width, b.img.height, b.offset) - } else { - let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); - let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); - ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) - }; + let (nw, nh, noff) = crate::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); let mut out = Image::new(nw, nh); let src = &b.img; out.rows_mut().for_each(|(y, row)| { diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index 34282c272..cb172dfc5 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -288,20 +288,39 @@ fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook { const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; -/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday). +/// `num_days_from_ce` of 2000-01-01: the day count since 2000-01-01 (Numbers' date epoch, a +/// Saturday) rebased so that chrono's `num_days_from_ce` (0001-01-01 is day 1) can convert it. +const CE_FROM_2000: i64 = 730_120; + +/// Days in one 400-year Gregorian cycle; the proleptic calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + +/// Proleptic Gregorian date (year, month, day, Saturday-indexed weekday) from a day count since +/// 2000-01-01 (a Saturday), for any `i64` count. +/// +/// chrono's `NaiveDate` stops near ±262143, but Numbers' `value` slider is a plain scalar with +/// no range guard, so a keyframed count can run past that. The calendar repeats every 400 years, +/// so chrono converts the day within one cycle and the whole cycles are added back as years (via +/// i128 so `i64::MIN`/`MAX` can't overflow); the result matches the pre-chrono count for every +/// year. The weekday comes from the same resolved date, so it can never disagree with it. fn date_from_days(days: i64) -> (i64, u32, u32, usize) { - // Days since 0000-03-01 based civil calendar conversion. - let z = days + 730_425; // 2000-01-01 relative to 0000-03-01 - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d, days.rem_euclid(7) as usize) + use chrono::Datelike; + let from_ce = i128::from(days) + i128::from(CE_FROM_2000); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so it is always a valid `num_days_from_ce` day inside chrono's range. + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + // Unreachable fallback: every `rest` in 0..146_097 converts. 2000-01-01 keeps the epoch's date. + let (y, m, d, wd) = match date { + Some(date) => { + let year = i128::from(date.year()) + cycles * 400; + let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX }); + // DAYS is Saturday-indexed; chrono counts from Sunday. + let wd = (date.weekday().num_days_from_sunday() as usize + 1) % 7; + (year, date.month(), date.day(), wd) + } + None => (2000, 1, 1, 0), + }; + (y, m, d, wd) } fn timecode(frames: i64, fps: f64) -> String { @@ -1123,6 +1142,10 @@ mod tests { assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF"); assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00"); assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000"); + // A keyframed date count past chrono's ±262143 range still renders its real date and a + // matching weekday (275790-09-13 is a Monday), not an epoch-derived stand-in. + assert_eq!(txt(&[("value", num(100_000_000.0)), ("type", Value::Enum(8))], 0.0), "Monday, September 13, 275790"); + assert_eq!(txt(&[("value", num(-100_000_000.0)), ("type", Value::Enum(7))], 0.0), "Apr 20, -271791"); assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0"); assert_ne!( txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0), @@ -1130,6 +1153,28 @@ mod tests { ); } + #[test] + fn date_from_days_past_chrono_range() { + // chrono's NaiveDate stops near ±262143, but Numbers' `value` has no range guard, so a + // keyframed day count can run past it. The 400-year cycle keeps the real date (and a + // matching weekday) for every count; values from the pre-chrono formula, both directions, + // both sides of the 2000-01-01 epoch. + for (days, date, day) in [ + (0_i64, (2000, 1, 1), "Saturday"), + (60, (2000, 3, 1), "Wednesday"), + (95_015_644, (262143, 12, 31), "Tuesday"), // chrono's last representable date + (100_000_000, (275790, 9, 13), "Monday"), // past it: used to read as the epoch + (-96_476_616, (-262145, 12, 31), "Monday"), + (-100_000_000, (-271791, 4, 20), "Thursday"), + ] { + let (y, m, d, wd) = super::date_from_days(days); + assert_eq!((y, m, d), date, "days={days}"); + assert_eq!(super::DAYS[wd], day, "days={days}"); + } + // Extremes don't overflow (i128 intermediates). + let _ = (super::date_from_days(i64::MAX), super::date_from_days(i64::MIN)); + } + #[test] fn numbers_draws_glyph_at_position() { let img = Image::new(80, 60); diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 1527e221f..ae55d095d 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -537,7 +537,7 @@ impl SimCache { pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc { use std::sync::Arc; let start = { - let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; g.iter_mut().find(|e| e.key == key).and_then(|e| { e.used = tick; @@ -563,7 +563,7 @@ impl SimCache { } let out = Arc::new(st); fresh.push((target, out.clone())); - let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; let idx = match g.iter().position(|e| e.key == key) { Some(ix) => ix, diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index 595cb5200..f850bdcf3 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -191,11 +191,11 @@ fn analysis_cached(json: &str) -> Option<(u64, Arc)> { static C: OnceLock = OnceLock::new(); let key = fnv(json.as_bytes()); let c = C.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { return Some((key, a.clone())); } let a = Arc::new(WarpAnalysis::from_json(json)?); - let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if m.len() > 8 { m.clear(); } @@ -294,7 +294,7 @@ pub fn store_summary(key: [u64; 2], summary: PlanSummary) { } fn keep_summary(key: (u64, u64), summary: Arc) { - let mut m = summaries().lock().unwrap_or_else(|e| e.into_inner()); + let mut m = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner); if m.len() > 32 { m.clear(); } @@ -305,7 +305,7 @@ fn keep_summary(key: (u64, u64), summary: Arc) { /// cached (seconds for a long Subspace Warp clip). `None` when not analysed. pub fn summary(params: &Params) -> Option<([u64; 2], Arc)> { let (key, a, st) = plan_key(params)?; - let cached = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + let cached = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).cloned(); let sum = match cached { Some(s) => s, None => { @@ -333,7 +333,7 @@ pub fn summary_at(params: &Params, time: f64) -> Option<(usize, Arc pub fn cached_summary_at(params: &Params, time: f64) -> Option<(usize, Arc)> { let (key, a, _) = plan_key(params)?; let k = a.frame_at(time)?; - let sum = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + let sum = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).cloned(); let sum = match sum { Some(s) => s, None => { @@ -445,13 +445,13 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { } } if !local.is_empty() { - let mut n = need_m.lock().unwrap_or_else(|e| e.into_inner()); + let mut n = need_m.lock().unwrap_or_else(std::sync::PoisonError::into_inner); for (x, w) in local { n[y * ow as usize + x] = w; } } }); - let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner()); + 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); } diff --git a/crates/effects/tests/gen_golden.rs b/crates/effects/tests/it/gen_golden.rs similarity index 100% rename from crates/effects/tests/gen_golden.rs rename to crates/effects/tests/it/gen_golden.rs diff --git a/crates/effects/tests/it/main.rs b/crates/effects/tests/it/main.rs new file mode 100644 index 000000000..7b12e25c6 --- /dev/null +++ b/crates/effects/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod gen_golden; +mod particle_golden; +mod sim_golden; +mod timewarp_golden; diff --git a/crates/effects/tests/particle_golden.rs b/crates/effects/tests/it/particle_golden.rs similarity index 100% rename from crates/effects/tests/particle_golden.rs rename to crates/effects/tests/it/particle_golden.rs diff --git a/crates/effects/tests/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs similarity index 100% rename from crates/effects/tests/sim_golden.rs rename to crates/effects/tests/it/sim_golden.rs diff --git a/crates/effects/tests/timewarp_golden.rs b/crates/effects/tests/it/timewarp_golden.rs similarity index 100% rename from crates/effects/tests/timewarp_golden.rs rename to crates/effects/tests/it/timewarp_golden.rs diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0e26cca58..7104bc1cc 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } diff --git a/crates/engine/src/analysis.rs b/crates/engine/src/analysis.rs new file mode 100644 index 000000000..fa2b8b4ec --- /dev/null +++ b/crates/engine/src/analysis.rs @@ -0,0 +1,6 @@ +pub mod camera_track; +pub mod mask_track; +pub mod models; +pub mod roto; +pub mod tracking; +pub mod warp; diff --git a/crates/engine/src/camera_track.rs b/crates/engine/src/analysis/camera_track.rs similarity index 99% rename from crates/engine/src/camera_track.rs rename to crates/engine/src/analysis/camera_track.rs index adc5aee69..29961f533 100644 --- a/crates/engine/src/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -7,7 +7,7 @@ //! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method, //! deleted points) the stored tracks are re-solved without re-tracking. Results are written into //! the effect's hidden parameters as one undo step; edits that change the layer's frames clear -//! them and queue the effect again ([`invalidate`], run after every edit), like After Effects. +//! them and queue the effect again (`invalidate`, run after every edit), like After Effects. //! //! Composition space: the solve's canonical frame maps to comp world by //! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's diff --git a/crates/engine/src/mask_track.rs b/crates/engine/src/analysis/mask_track.rs similarity index 98% rename from crates/engine/src/mask_track.rs rename to crates/engine/src/analysis/mask_track.rs index f85ba6b70..79c2b4643 100644 --- a/crates/engine/src/mask_track.rs +++ b/crates/engine/src/analysis/mask_track.rs @@ -80,10 +80,10 @@ impl MaskMethod { } } pub fn from_name(s: &str) -> Option { - let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + let n: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); MaskMethod::ALL .into_iter() - .find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() == n) + .find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() == n) } pub fn model(self) -> Model { match self { 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 99% rename from crates/engine/src/roto.rs rename to crates/engine/src/analysis/roto.rs index 49f53d39b..d9ce6e5af 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -6,7 +6,7 @@ //! `wait`; in the browser the background is a Web Worker whose segmentations stream back, //! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input //! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same -//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every +//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made //! of (the layer's source and timing, its masks and the effects above it) and drops frozen //! mattes made from other frames. diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/analysis/tracking.rs similarity index 99% rename from crates/engine/src/tracking.rs rename to crates/engine/src/analysis/tracking.rs index cfdd12441..434cdd8af 100644 --- a/crates/engine/src/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -157,7 +157,7 @@ pub struct TrackShared { } pub(crate) fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { - m.lock().unwrap_or_else(|e| e.into_inner()) + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } /// A running (or finished, not yet polled) analysis. diff --git a/crates/engine/src/warp.rs b/crates/engine/src/analysis/warp.rs similarity index 99% rename from crates/engine/src/warp.rs rename to crates/engine/src/analysis/warp.rs index 7928a5cab..5472d4e8c 100644 --- a/crates/engine/src/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -7,7 +7,7 @@ //! stabilization plan once to validate the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that -//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect +//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect //! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses //! automatically too). 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 ad2c0cde0..23cff4da6 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/background/offload.rs @@ -289,10 +289,10 @@ pub struct Inbox(Mutex>); impl Inbox { pub fn push(&self, r: WorkerReply) { - self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r); + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(r); } pub fn take(&self) -> Vec { - std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner())) + std::mem::take(&mut *self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)) } } @@ -992,317 +992,4 @@ pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup } #[cfg(test)] -mod tests { - use super::*; - - fn round Deserialize<'de>>(v: &T) -> String { - let a = serde_json::to_string(v).unwrap(); - let back: T = serde_json::from_str(&a).unwrap(); - let b = serde_json::to_string(&back).unwrap(); - assert_eq!(a, b); - a - } - - /// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c` - /// (uid 5). - fn group() -> PropGroup { - use effectcraft_keyframe::Value as KV; - use effectcraft_project::{Node, Property}; - let mut sub = PropGroup::new(4, "sub", "Sub"); - sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0)))); - let mut g = PropGroup::new(1, "fx", "Effect"); - g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0)))); - g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0)))); - g.children.push(Node::Group(sub)); - g - } - - fn set(g: &mut PropGroup, uid: Uid, v: f64) { - g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v); - } - - fn get(g: &PropGroup, uid: Uid) -> f64 { - match g.find(uid).unwrap().value { - effectcraft_keyframe::Value::Scalar(v) => v, - ref v => panic!("{v:?}"), - } - } - - #[test] - fn analysis_results_merge_with_edits_made_meanwhile() { - use effectcraft_project::Node; - let base = group(); - // The user changes `a` and switches the effect off; the analysis writes `b` and `c`. - let mut live = base.clone(); - set(&mut live, 2, 1.0); - live.enabled = false; - let mut result = base.clone(); - set(&mut result, 3, 2.0); - set(&mut result, 5, 3.0); - let m = merge_group(&base, &live, &result).unwrap(); - assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false)); - // Unchanged on either side: the other side wins whole. - assert_eq!(merge_group(&base, &base, &result).unwrap(), result); - assert_eq!(merge_group(&base, &live, &base).unwrap(), live); - // Both changed `b`, differently: a conflict (nothing is applied). The same way: fine. - let mut both = live.clone(); - set(&mut both, 3, 9.0); - assert!(merge_group(&base, &both, &result).is_none()); - set(&mut both, 3, 2.0); - assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0); - // The analysis adds a child (a result group) while the user edits `a`: both kept. - let mut added = result.clone(); - added.children.push(Node::Group(PropGroup::new(6, "out", "Output"))); - let m = merge_group(&base, &live, &added).unwrap(); - assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0); - // The analysis drops the sub-group the user didn't touch: dropped. - let mut dropped = result.clone(); - dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4)); - let m = merge_group(&base, &live, &dropped).unwrap(); - assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0); - // …but if the user changed something in it, that's a conflict. - let mut touched = live.clone(); - set(&mut touched, 5, 7.0); - assert!(merge_group(&base, &touched, &dropped).is_none()); - } - - #[test] - fn protocol_round_trips() { - let mut s = Session::default(); - s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); - let comp = s.active_comp_id().unwrap(); - s.execute("renderQueue.add", serde_json::json!({})).unwrap(); - let item = s.project.render_queue[0].clone(); - let reqs = [ - WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] }, - WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 }, - WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) }, - WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 }, - WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 }, - WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, - WorkerJob::Track { - comp, - layer: LayerId(1), - tracker: 4, - direction: crate::tracking::Direction::Backward, - settings: TrackerSettings::default(), - points: vec![PointValues { - uid: 5, - spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] }, - attach_offset: [0.25, -1.0], - }], - times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)], - }, - WorkerJob::MaskTrack { - comp, - layer: LayerId(2), - mask: 7, - direction: crate::tracking::Direction::Forward, - path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0), - method: MaskMethod::Perspective, - times: vec![Tick::from_seconds_f64(1.0)], - }, - WorkerJob::ContentFill { - plan: Box::new(crate::commands::content_fill::FillPlan { - comp, - layer: LayerId(2), - name: "Clip".into(), - width: 4, - height: 3, - times: vec![Tick::ZERO], - method: "object".into(), - lighting: 0.5, - expand: 1, - reference: Some((LayerId(1), Tick::ZERO)), - dir: "/Fill/Clip_Fill_1".into(), - }), - }, - ]; - for job in reqs { - let kind = job.kind(); - let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job }; - let t = round(&req); - assert!(t.contains("\"kind\":"), "{kind:?}"); - let back: WorkerRequest = serde_json::from_str(&t).unwrap(); - assert_eq!(back.job.kind(), kind); - assert_eq!(Project::from_json(&back.project).unwrap(), *s.project); - } - let g = s.active_comp().unwrap().layers[0].props.clone(); - for r in [ - WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } }, - WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } }, - WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } }, - WorkerReply::Progress { done: 1, total: 2 }, - WorkerReply::WarpPlan { - key: [1, u64::MAX], - summary: Box::new(effectcraft_effects::warp_stab::PlanSummary { - warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]], - auto_scale: 1.08, - crop: Some([1.0, 2.0, 300.0, 200.0]), - valid_fraction: 0.9, - }), - }, - WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() }, - WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, - WorkerReply::Fill { - plan: Box::new(crate::commands::content_fill::FillPlan { - comp, - layer: LayerId(2), - name: "Clip".into(), - width: 4, - height: 3, - times: vec![Tick::ZERO], - method: "object".into(), - lighting: 0.5, - expand: 1, - reference: Some((LayerId(1), Tick::ZERO)), - dir: "/Fill/Clip_Fill_1".into(), - }), - files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()], - }, - WorkerReply::Failed { error: "boom".into() }, - WorkerReply::Done, - ] { - let t = round(&r); - assert!(t.starts_with("{\"type\":"), "{t}"); - } - } - - /// An [`Offload`] that runs the job right away in a second session, through JSON (what a - /// Web Worker does, minus the threads). - struct Inline; - impl Offload for Inline { - fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { - let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); - let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() }; - let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); - run_request(&mut w, req, &post); - Ok(()) - } - fn cancel(&self, _: u64) {} - } - - #[test] - fn render_runs_through_the_offload() { - let mut s = Session { - exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), - offload: Some(Arc::new(Inline)), - ..Default::default() - }; - s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); - s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap(); - let ids = s.start_render(false).unwrap(); - // (the inline offload has replied already; replies are applied when polled) - s.poll_render(); - assert!(s.render_job.is_none()); - let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); - assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status); - assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif"))); - // `wait` renders inline. - s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap(); - s.start_render(true).unwrap(); - assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none()); - } - - /// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests). - struct InlineWith(Arc); - impl Offload for InlineWith { - fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { - let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); - let mut w = Session { footage: self.0.clone(), ..Default::default() }; - let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); - run_request(&mut w, req, &post); - Ok(()) - } - fn cancel(&self, _: u64) {} - } - - /// Never replies (a job still running); records cancellations. - #[derive(Default)] - struct Hanging(Mutex>); - impl Offload for Hanging { - fn start(&self, _: WorkerRequest, _: Arc) -> Result<(), String> { - Ok(()) - } - fn cancel(&self, id: u64) { - self.0.lock().unwrap().push(id); - } - } - - fn pose(f: u32) -> ([f64; 2], f64, f64) { - ([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0) - } - - #[test] - fn track_analysis_runs_through_the_offload_as_one_undo_step() { - let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); - s.offload = Some(Arc::new(InlineWith(s.footage.clone()))); - s.execute("track.motion", serde_json::json!({})).unwrap(); - s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); - let undo = s.history.undo.len(); - s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap(); - assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread"); - assert!(s.track_progress().is_some()); - assert!(s.poll_offload()); - assert!(!s.is_tracking() && s.offloaded.is_empty()); - let l = s.active_comp().unwrap().layer(clip).unwrap(); - let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone(); - let fc = tp.get("featureCenter").unwrap(); - assert_eq!(fc.keys.len(), 25); - let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2(); - assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}"); - assert_eq!(s.history.undo.len(), undo + 1); - assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track"); - assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked")))); - assert!(s.undo()); - let l = s.active_comp().unwrap().layer(clip).unwrap(); - assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); - // `wait` runs inline. - s.redo(); - s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok(); - assert!(s.offloaded.is_empty()); - } - - #[test] - fn cancelling_offloaded_jobs() { - let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); - let off = Arc::new(Hanging::default()); - s.offload = Some(off.clone()); - s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })); - // Analysis: stopping drops the job, nothing is written. - s.execute("track.motion", serde_json::json!({})).unwrap(); - let rev = s.revision; - s.execute("track.analyze", serde_json::json!({})).unwrap(); - assert!(s.is_tracking()); - assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time"); - assert!(s.stop_track()); - s.poll_offload(); - assert!(!s.is_tracking()); - assert_eq!(off.0.lock().unwrap().len(), 1); - assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped")))); - assert!(s.revision > rev); - // Render: the queued items become User Stopped. - s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap(); - let ids = s.start_render(false).unwrap(); - assert!(s.is_rendering()); - assert!(s.stop_render()); - s.poll_render(); - assert!(!s.is_rendering()); - assert_eq!(off.0.lock().unwrap().len(), 2); - let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); - assert_eq!(it.status, RenderStatus::UserStopped); - } - - #[test] - fn progress_hook_is_scoped() { - report(1, 2); // no hook: nothing happens - let seen = Rc::new(RefCell::new(vec![])); - let s2 = seen.clone(); - effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async { - report(1, 3); - report(2, 3); - })); - report(3, 3); - assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]); - } -} +mod tests; diff --git a/crates/engine/src/background/offload/tests.rs b/crates/engine/src/background/offload/tests.rs new file mode 100644 index 000000000..c08623189 --- /dev/null +++ b/crates/engine/src/background/offload/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +fn round Deserialize<'de>>(v: &T) -> String { + let a = serde_json::to_string(v).unwrap(); + let back: T = serde_json::from_str(&a).unwrap(); + let b = serde_json::to_string(&back).unwrap(); + assert_eq!(a, b); + a +} + +/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c` +/// (uid 5). +fn group() -> PropGroup { + use effectcraft_keyframe::Value as KV; + use effectcraft_project::{Node, Property}; + let mut sub = PropGroup::new(4, "sub", "Sub"); + sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0)))); + let mut g = PropGroup::new(1, "fx", "Effect"); + g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0)))); + g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0)))); + g.children.push(Node::Group(sub)); + g +} + +fn set(g: &mut PropGroup, uid: Uid, v: f64) { + g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v); +} + +fn get(g: &PropGroup, uid: Uid) -> f64 { + match g.find(uid).unwrap().value { + effectcraft_keyframe::Value::Scalar(v) => v, + ref v => panic!("{v:?}"), + } +} + +#[test] +fn analysis_results_merge_with_edits_made_meanwhile() { + use effectcraft_project::Node; + let base = group(); + // The user changes `a` and switches the effect off; the analysis writes `b` and `c`. + let mut live = base.clone(); + set(&mut live, 2, 1.0); + live.enabled = false; + let mut result = base.clone(); + set(&mut result, 3, 2.0); + set(&mut result, 5, 3.0); + let m = merge_group(&base, &live, &result).unwrap(); + assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false)); + // Unchanged on either side: the other side wins whole. + assert_eq!(merge_group(&base, &base, &result).unwrap(), result); + assert_eq!(merge_group(&base, &live, &base).unwrap(), live); + // Both changed `b`, differently: a conflict (nothing is applied). The same way: fine. + let mut both = live.clone(); + set(&mut both, 3, 9.0); + assert!(merge_group(&base, &both, &result).is_none()); + set(&mut both, 3, 2.0); + assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0); + // The analysis adds a child (a result group) while the user edits `a`: both kept. + let mut added = result.clone(); + added.children.push(Node::Group(PropGroup::new(6, "out", "Output"))); + let m = merge_group(&base, &live, &added).unwrap(); + assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0); + // The analysis drops the sub-group the user didn't touch: dropped. + let mut dropped = result.clone(); + dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4)); + let m = merge_group(&base, &live, &dropped).unwrap(); + assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0); + // …but if the user changed something in it, that's a conflict. + let mut touched = live.clone(); + set(&mut touched, 5, 7.0); + assert!(merge_group(&base, &touched, &dropped).is_none()); +} + +#[test] +fn protocol_round_trips() { + let mut s = Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + s.execute("renderQueue.add", serde_json::json!({})).unwrap(); + let item = s.project.render_queue[0].clone(); + let reqs = [ + WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] }, + WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) }, + WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, + WorkerJob::Track { + comp, + layer: LayerId(1), + tracker: 4, + direction: crate::tracking::Direction::Backward, + settings: TrackerSettings::default(), + points: vec![PointValues { + uid: 5, + spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] }, + attach_offset: [0.25, -1.0], + }], + times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)], + }, + WorkerJob::MaskTrack { + comp, + layer: LayerId(2), + mask: 7, + direction: crate::tracking::Direction::Forward, + path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0), + method: MaskMethod::Perspective, + times: vec![Tick::from_seconds_f64(1.0)], + }, + WorkerJob::ContentFill { + plan: Box::new(crate::commands::content_fill::FillPlan { + comp, + layer: LayerId(2), + name: "Clip".into(), + width: 4, + height: 3, + times: vec![Tick::ZERO], + method: "object".into(), + lighting: 0.5, + expand: 1, + reference: Some((LayerId(1), Tick::ZERO)), + dir: "/Fill/Clip_Fill_1".into(), + }), + }, + ]; + for job in reqs { + let kind = job.kind(); + let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job }; + let t = round(&req); + assert!(t.contains("\"kind\":"), "{kind:?}"); + let back: WorkerRequest = serde_json::from_str(&t).unwrap(); + assert_eq!(back.job.kind(), kind); + assert_eq!(Project::from_json(&back.project).unwrap(), *s.project); + } + let g = s.active_comp().unwrap().layers[0].props.clone(); + for r in [ + WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } }, + WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } }, + WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } }, + WorkerReply::Progress { done: 1, total: 2 }, + WorkerReply::WarpPlan { + key: [1, u64::MAX], + summary: Box::new(effectcraft_effects::warp_stab::PlanSummary { + warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]], + auto_scale: 1.08, + crop: Some([1.0, 2.0, 300.0, 200.0]), + valid_fraction: 0.9, + }), + }, + WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() }, + WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, + WorkerReply::Fill { + plan: Box::new(crate::commands::content_fill::FillPlan { + comp, + layer: LayerId(2), + name: "Clip".into(), + width: 4, + height: 3, + times: vec![Tick::ZERO], + method: "object".into(), + lighting: 0.5, + expand: 1, + reference: Some((LayerId(1), Tick::ZERO)), + dir: "/Fill/Clip_Fill_1".into(), + }), + files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()], + }, + WorkerReply::Failed { error: "boom".into() }, + WorkerReply::Done, + ] { + let t = round(&r); + assert!(t.starts_with("{\"type\":"), "{t}"); + } +} + +/// An [`Offload`] that runs the job right away in a second session, through JSON (what a +/// Web Worker does, minus the threads). +struct Inline; +impl Offload for Inline { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() }; + let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + run_request(&mut w, req, &post); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +#[test] +fn render_runs_through_the_offload() { + let mut s = + Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), offload: Some(Arc::new(Inline)), ..Default::default() }; + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap(); + let ids = s.start_render(false).unwrap(); + // (the inline offload has replied already; replies are applied when polled) + s.poll_render(); + assert!(s.render_job.is_none()); + let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); + assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status); + assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif"))); + // `wait` renders inline. + s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap(); + s.start_render(true).unwrap(); + assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none()); +} + +/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests). +struct InlineWith(Arc); +impl Offload for InlineWith { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { footage: self.0.clone(), ..Default::default() }; + let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + run_request(&mut w, req, &post); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +/// Never replies (a job still running); records cancellations. +#[derive(Default)] +struct Hanging(Mutex>); +impl Offload for Hanging { + fn start(&self, _: WorkerRequest, _: Arc) -> Result<(), String> { + Ok(()) + } + fn cancel(&self, id: u64) { + self.0.lock().unwrap().push(id); + } +} + +fn pose(f: u32) -> ([f64; 2], f64, f64) { + ([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0) +} + +#[test] +fn track_analysis_runs_through_the_offload_as_one_undo_step() { + let (mut s, clip, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose)); + s.offload = Some(Arc::new(InlineWith(s.footage.clone()))); + s.execute("track.motion", serde_json::json!({})).unwrap(); + s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap(); + assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread"); + assert!(s.track_progress().is_some()); + assert!(s.poll_offload()); + assert!(!s.is_tracking() && s.offloaded.is_empty()); + let l = s.active_comp().unwrap().layer(clip).unwrap(); + let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone(); + let fc = tp.get("featureCenter").unwrap(); + assert_eq!(fc.keys.len(), 25); + let v = fc.value_at(crate::tests::track::frame_time(12)).as_vec2(); + assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}"); + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track"); + assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked")))); + assert!(s.undo()); + let l = s.active_comp().unwrap().layer(clip).unwrap(); + assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); + // `wait` runs inline. + s.redo(); + s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok(); + assert!(s.offloaded.is_empty()); +} + +#[test] +fn cancelling_offloaded_jobs() { + let (mut s, _, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose)); + let off = Arc::new(Hanging::default()); + s.offload = Some(off.clone()); + s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })); + // Analysis: stopping drops the job, nothing is written. + s.execute("track.motion", serde_json::json!({})).unwrap(); + let rev = s.revision; + s.execute("track.analyze", serde_json::json!({})).unwrap(); + assert!(s.is_tracking()); + assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time"); + assert!(s.stop_track()); + s.poll_offload(); + assert!(!s.is_tracking()); + assert_eq!(off.0.lock().unwrap().len(), 1); + assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped")))); + assert!(s.revision > rev); + // Render: the queued items become User Stopped. + s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap(); + let ids = s.start_render(false).unwrap(); + assert!(s.is_rendering()); + assert!(s.stop_render()); + s.poll_render(); + assert!(!s.is_rendering()); + assert_eq!(off.0.lock().unwrap().len(), 2); + let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); + assert_eq!(it.status, RenderStatus::UserStopped); +} + +#[test] +fn progress_hook_is_scoped() { + report(1, 2); // no hook: nothing happens + let seen = Rc::new(RefCell::new(vec![])); + let s2 = seen.clone(); + effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async { + report(1, 3); + report(2, 3); + })); + report(3, 3); + assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]); +} diff --git a/crates/engine/src/remote.rs b/crates/engine/src/background/remote.rs similarity index 54% rename from crates/engine/src/remote.rs rename to crates/engine/src/background/remote.rs index 207367b07..6539ac767 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -9,7 +9,7 @@ //! added, changed or removed; arrays element-wise when their length is unchanged, else //! replaced). Lossless (unlike JSON Merge Patch, `null` values survive). //! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a -//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards). +//! [`FrameMsg::Project`] the first time and [`FrameMsg::Patch`] for subsequent diffs. //! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render //! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]). //! @@ -263,6 +263,8 @@ pub struct FrameServer { /// The disk cache's layer buffers (see the module docs) and the project's salt. layer_store: Option>, salt: u64, + /// Size and content hash for footage paths with no native metadata (the browser file table). + virtual_id: Option Option<(u64, u64)> + Send + Sync>>, } /// A frame whose readbacks are in flight. @@ -294,9 +296,16 @@ impl FrameServer { pending: None, layer_store: None, salt: 0, + virtual_id: None, } } + /// Identity of virtual footage files, used when native metadata cannot describe the path. + /// Called from `FrameServer::load`, not from the UI thread's cache lookup. + pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { + self.virtual_id = id; + } + /// The store behind the layer cache when a request asks for `layers` (the browser: the /// disk cache's layer buffers, fetched before the request). pub fn set_layer_store(&mut self, store: Option>) { @@ -338,7 +347,7 @@ impl FrameServer { fn load(&mut self, revision: u64) -> Result<(), String> { let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?; - self.salt = effectcraft_render::disk_cache::footage_salt(&p); + self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref()); self.cache.set_store_salt(self.salt); self.project = Some(Arc::new(p)); self.revision = Some(revision); @@ -463,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..47b3cbde9 --- /dev/null +++ b/crates/engine/src/cmd.rs @@ -0,0 +1,294 @@ +//! 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 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::*; +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 93% rename from crates/engine/src/commands/align_data.rs rename to crates/engine/src/cmd/align_data.rs index 097bddc22..38d9e7841 100644 --- a/crates/engine/src/commands/align_data.rs +++ b/crates/engine/src/cmd/align_data.rs @@ -33,14 +33,16 @@ pub struct Stamp { pub dated: bool, } -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let mp = (m + 9) % 12; - let doy = (153 * mp + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 +/// Days since 1970-01-01 of the proleptic-Gregorian date `(y, m, d)`. `d` is added linearly (as +/// the previous hand-rolled `days_from_civil` did), so a day past the month's end rolls over; +/// `None` when the year/month is out of chrono's range. `m` must be 1..=12. +fn days_from_civil(y: i64, m: i64, d: i64) -> Option { + let year = i32::try_from(y).ok()?; + let month = u32::try_from(m).ok()?; + let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?; + // Days since the Unix epoch: `num_days_from_ce` counts from 0001-01-01; 719_162 days precede 1970-01-01. + use chrono::Datelike; + i64::from(first.num_days_from_ce()).checked_sub(719_163)?.checked_add(d.checked_sub(1)?) } /// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds. @@ -71,7 +73,7 @@ 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 mut secs = days_from_civil(y, m, d) as f64 * 86_400.0; + let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0; let rest = s[10..].trim_start_matches(['T', 't', ' ']); if !rest.is_empty() { // Zone: Z or ±hh:mm / ±hhmm. 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 90% rename from crates/engine/src/commands/app_more.rs rename to crates/engine/src/cmd/app_more.rs index 1bb94952f..f607c8288 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -13,14 +13,23 @@ use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_pr use crate::{EngineError, Event, Result, Session, cmd, query}; /// Run `f` (which may run several undoable commands) as one undo step named `label`; on error -/// the project is restored. +/// the project, editor state and undo history are restored. +/// +/// Front-end events emitted by a failed `f` are discarded with the state. Pending-analysis +/// queues and the journal are not part of this rollback and keep their existing behaviour. pub(crate) fn grouped(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { // The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot - // taken before them), then become one step on the real one. + // taken before them), then become one step on the real one. Keep the complete pre-`f` + // history and editor state for a failure: rollback must not leave a partially retargeted + // editor or a `f`-recorded redo/branch behind. + let saved_history = s.history.clone(); let saved = std::mem::take(&mut s.history.undo); let before = s.project.clone(); + let before_state = s.state.clone(); + let event_mark = s.events.len(); let r = f(s); let changed = !s.history.undo.is_empty(); + let interim_revision = s.ime_revision; s.history.undo = saved; s.history.merge_key = None; match r { @@ -36,9 +45,22 @@ pub(crate) fn grouped(s: &mut Session, label: &str, f: impl FnOnce(&mut Sessi Ok(v) } Err(e) => { + // Snapshot the failing editor before restoring the pre-`f` state: an intermediate + // text.edit / text change / comp switch must invalidate the pre-`f` editor token + // instead of reviving it. + let edited = super::text_edit::active_editor_snapshot(s); + s.project = before; + s.state = before_state; + s.history = saved_history; + s.history.merge_key = None; + // A failed intermediate comp switch must not reach the front end through an + // `OpenComp` event while its state has been rolled back. + s.events.truncate(event_mark); + s.sanitize_after_replacement(edited.as_ref()); + // As in the batch rollback, the IME revision is a high-water mark: leave the highest + // intermediate stamp rather than rewinding to before `f`. + s.ime_revision = s.ime_revision.max(interim_revision); if changed { - s.project = before; - s.sanitize_state(); s.bump(); } Err(e) @@ -332,7 +354,7 @@ fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result { reassign(s, view_command(cur), &slot, "view.assign3dShortcut") } -/// Window ▸ Assign Shortcut to "" Workspace: Shift+F10, Shift+F11 or Shift+F12. +/// Window ▸ Assign Shortcut to `""` Workspace: Shift+F10, Shift+F11 or Shift+F12. /// The frontend fills in the current workspace. fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result { let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?; @@ -646,6 +668,9 @@ fn extract_cubemap(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// The specs for the commands in this module: history steps, recent footage and presets, Save +/// a Copy As XML, footage replacement and Execute File, 3D-view and workspace shortcuts, +/// logging and the system report, VR environment/cubemap creation and System Information. pub fn specs() -> Vec { vec![ cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history), @@ -704,6 +729,36 @@ pub fn specs() -> Vec { mod tests { use serde_json::json; + #[test] + fn grouped_rollback_restores_the_editor_state_and_history_after_a_transient_text_change() { + let mut s = crate::Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 1})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap(); + let original_comp = s.active_comp_id().unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + let other = + s.execute("comp.new", json!({"name": "Other", "width": 160, "height": 90, "duration": 1, "open": false})).unwrap()["comp"].as_u64().unwrap(); + let undo = s.history.undo.len(); + s.drain_events(); + + let r = super::grouped(&mut s, "Rollback", |s| -> crate::Result<()> { + // An intermediate document edit and state switch, both undone by the error path. + s.execute("layer.setText", json!({"layer": lid, "text": "ab"}))?; + s.execute("comp.open", json!({"comp": other}))?; + Err(crate::EngineError::Other("stop".into())) + }); + assert!(r.is_err()); + assert_eq!(s.active_comp_id(), Some(original_comp), "the pre-group comp is restored"); + assert_eq!(super::super::text_edit::layer_doc(&s, effectcraft_project::LayerId(lid)).unwrap().text, "abcdef", "the document is restored"); + assert_eq!(s.history.undo.len(), undo, "the partial grouped steps are not left in history"); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a transient replacement does not revive the old IME token"); + assert!( + !s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(_))), + "the failed intermediate comp switch is not published to the front end" + ); + } + /// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`). #[test] fn execute_file_opens_an_encoded_file_url() { 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 69% rename from crates/engine/src/commands/batch.rs rename to crates/engine/src/cmd/batch.rs index 4bfd2b2b1..f6e6901cc 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/cmd/batch.rs @@ -51,11 +51,25 @@ fn substitute(v: &Value, results: &[Value]) -> Result { }) } +/// `engine.batch`: runs `steps` (`[{command|id, params?}]`) as engine commands, substituting +/// `$N` / `$N.key.0` strings in each step's params with earlier results, and returns +/// `{steps, results}`. +/// +/// `engine.batch`, `file.runScript` and `script.run` are refused inside a batch. Successful +/// steps are folded into one undo step named `label` (default "Batch"). With the default +/// `atomic: true`, a failing step restores the project, history and editor state, drops the +/// events the batch published, sanitizes the editor against the failing intermediate state (so +/// no pre-batch IME token is revived) and keeps the higher process IME revision; with +/// `atomic: false` the steps before the failure stay applied. Either way the error names the +/// failing step and command. fn batch(s: &mut Session, p: &Value) -> Result { let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone(); let label = str_p(p, "label").unwrap_or("Batch").to_string(); let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true); let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone()); + // Rolled-back steps may have queued events (an `OpenComp`, a Toast) for the state that is + // about to be dropped: the UI must not act on them. + let event_mark = s.events.len(); let mut results: Vec = Vec::with_capacity(steps.len()); for (i, step) in steps.iter().enumerate() { let run = |s: &mut Session, results: &[Value]| -> Result { @@ -75,10 +89,21 @@ fn batch(s: &mut Session, p: &Value) -> Result { Err(e) => { let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?"); let note = if atomic { + // The snapshot is the intermediate state the failing step left: comparing it + // with the restored editor detects a restart or cross-comp switch inside the + // batch, so the pre-batch token is never revived. Do not restore + // `ime_revision`: a transient text edit inside the batch already advanced + // this process-monotonic high-water mark, and rewinding it would reuse a + // stamp (letting a wide-selection composition look current). An unrelated + // failed batch did not advance it, so its value stays exactly as it was. + let edited = crate::commands::text_edit::active_editor_snapshot(s); + let interim_revision = s.ime_revision; s.project = project; s.history = history; s.state = state; - s.sanitize_state(); + s.events.truncate(event_mark); + s.sanitize_after_replacement(edited.as_ref()); + s.ime_revision = s.ime_revision.max(interim_revision); s.bump(); "; the batch was rolled back (nothing changed)".to_string() } else { @@ -124,6 +149,32 @@ mod tests { use crate::Session; + /// Failed atomic steps discard their UI events while preserving events queued beforehand. + #[test] + fn atomic_batch_event_rollback_preserves_prior_events() { + for atomic in [true, false] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "First", "width": 100, "height": 100})).unwrap(); + let first = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Second", "width": 100, "height": 100})).unwrap(); + let second = s.active_comp_id().unwrap(); + s.drain_events(); + s.execute("comp.open", json!({"comp": first.0})).unwrap(); + let result = s.execute_checked( + "engine.batch", + json!({"atomic": atomic, "steps": [ + {"command": "comp.open", "params": {"comp": second.0}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(result.is_err()); + assert_eq!(s.active_comp_id(), Some(if atomic { first } else { second })); + let opened: Vec<_> = + s.drain_events().into_iter().filter_map(|event| if let crate::Event::OpenComp(id) = event { Some(id) } else { None }).collect(); + assert_eq!(opened, if atomic { vec![first] } else { vec![first, second] }); + } + } + #[test] fn batch_refs_one_undo_step_and_rollback() { let mut s = Session::default(); diff --git a/crates/engine/src/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 92% rename from crates/engine/src/commands/edit.rs rename to crates/engine/src/cmd/edit.rs index 019d56d1d..d0dbabb5b 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,7 +1,8 @@ //! Edit menu. use effectcraft_color::Label; -use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte}; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, TrackMatte}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -73,6 +74,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { + // A deselected layer never keeps an open text edit: commit the edit first, exactly as + // `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut, + // script, MCP — leaves one final state. + super::text_edit::end_edit(s, &Value::Null)?; s.state.selected_layers.clear(); s.state.selected_props.clear(); s.state.selected_keys.clear(); @@ -144,46 +149,73 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!(new.iter().map(|l| l.0).collect::>())) } +/// `edit.clear`: delegates to `text.delete` while editing text (deleting the selection or +/// preceding character/word), then handles selected Time Remap (turning remapping off), keyframes, loose mask +/// vertices, puppet pins, effects, masks/shape items/text animators/trackers, and finally the +/// selected (or given) unlocked layers. Layer and selected-property-group paths return the number +/// removed; text, Time Remap, keyframe, loose-vertex, puppet-pin and effect paths return their +/// delegated command's result. +/// +/// Layer removal also unparents surviving children in place, clears track mattes that pointed +/// at a removed layer and rewrites layer-reference values and keys to `None` in the same undo +/// step; the text-edit session is then sanitized so a removed layer's editor is not reused. fn delete(s: &mut Session, p: &Value) -> Result { - if s.state.text_edit.is_some() && p.get("layers").is_none() { - return s.execute("text.delete", json!({})); + if let Some(done) = delete_selection(s, p)? { + return Ok(done); + } + let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "edit.clear")?; + clear_layers(s, cid, &ids)?; + // A removed layer may have been the edited one: end that session before another command + // can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session. + s.sanitize_state(); + Ok(json!(ids.len())) +} + +/// Deletes the selected things that are not layers. `None` when the layers are deleted +/// instead (nothing else is selected, or the command names its layers). +fn delete_selection(s: &mut Session, p: &Value) -> Result> { + let no_layers = p.get("layers").is_none(); + if s.state.text_edit.is_some() && no_layers { + return Ok(Some(s.execute("text.delete", json!({}))?)); } // Time Remap selected (its name, which selects all its keys) → time remapping off, not the // layer (After Effects). - if p.get("layers").is_none() { + if no_layers { let remapped = super::layer_time::selected_time_remap(s); if !remapped.is_empty() { - return super::layer_time::disable_time_remap(s, &remapped); + return Ok(Some(super::layer_time::disable_time_remap(s, &remapped)?)); } } // Keyframes selected → delete keys; mask vertices → delete them; else layers. - if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { - return s.execute("keys.delete", json!({})); + if !s.state.selected_keys.is_empty() && no_layers { + return Ok(Some(s.execute("keys.delete", json!({}))?)); } // (Points selected with their mask, which selecting a mask does: the mask goes, below.) let comp = s.active_comp(); let loose = s.state.selected_vertices.iter().any(|v| { !s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some()) }); - if loose && p.get("layers").is_none() { - return s.execute("mask.deleteVertices", json!({})); + if loose && no_layers { + return Ok(Some(s.execute("mask.deleteVertices", json!({}))?)); } // Puppet pins selected → delete the pins (not their layer). - if p.get("layers").is_none() && !super::puppet::selected_pins(s).is_empty() { - return s.execute("puppet.removePin", json!({})); + if no_layers && !super::puppet::selected_pins(s).is_empty() { + return Ok(Some(s.execute("puppet.removePin", json!({}))?)); } // Effects selected → remove them. - if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { - return s.execute("effect.remove", json!({})); + if no_layers && !super::effect::selected_effects(s).is_empty() { + return Ok(Some(s.execute("effect.remove", json!({}))?)); } // Masks, shape items, text animators or trackers selected → remove them. - if p.get("layers").is_none() - && let Some(n) = super::prop_groups::remove_selected(s)? - { - return Ok(n); + if no_layers && let Some(n) = super::prop_groups::remove_selected(s)? { + return Ok(Some(n)); } - let (cid, ids) = layers_p(s, p)?; - let ids = super::unlocked(s, cid, ids, "edit.clear")?; + Ok(None) +} + +/// Removes layers `ids` from comp `cid` in one undoable step, and clears what referred to them. +fn clear_layers(s: &mut Session, cid: ItemId, ids: &[LayerId]) -> Result<()> { // Children of deleted layers are unparented where they are (like After Effects). let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; let orphans: Vec = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect(); @@ -201,13 +233,27 @@ fn delete(s: &mut Session, p: &Value) -> Result { if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) { l.track_matte = None; } + // A layer-reference property (an Environment light's Source, an effect's Clone Source, + // Back Layer…) must read as None once its layer is gone, in this same history step: a + // dead id renders as nothing while the UI shows "None", which means "fall back to the + // comp's Environment Layer" (#2153). Keys carry the same value kind as the property. + l.props.walk_mut(&mut |p| { + let dead = |v: &KV| matches!(v, KV::Layer(Some(id)) if ids.contains(&LayerId(*id))); + if dead(&p.value) { + p.value = KV::Layer(None); + } + for k in &mut p.keys { + if dead(&k.value) { + k.value = KV::Layer(None); + } + } + }); } st.selected_layers.clear(); st.selected_props.clear(); st.selected_keys.clear(); Ok(()) - })?; - Ok(json!(ids.len())) + }) } fn clear_clipboards(s: &mut Session) { @@ -827,7 +873,7 @@ fn original_path(s: &Session) -> Option { /// importer takes. Anything else (a folder or app bundle, a script, a shortcut) is refused. pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> { let media = |p: &std::path::Path| { - let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default(); + let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); crate::media_browser::kind_of(&p.to_string_lossy()).is_some_and(|k| k != "project") || matches!(ext.as_str(), "pdf" | "ai" | "eps") }; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/cmd/effect.rs similarity index 83% rename from crates/engine/src/commands/effect.rs rename to crates/engine/src/cmd/effect.rs index ce773e545..4f263b6fb 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -186,6 +186,101 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "color": color})) } +/// What a Cryptomatte effect has at a layer-space point: the ID with the most coverage in its +/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection. +struct CryptoHit { + at: (effectcraft_project::ItemId, LayerId, Tick), + aux: std::sync::Arc, + layer: String, + hash: u32, + selection: String, + prop: Uid, + /// The Selection has an expression: setting its value wouldn't change what it reads. + driven: bool, +} + +impl CryptoHit { + fn manifest(&self) -> Option<&[(String, u32)]> { + self.aux.manifests.iter().find(|(l, _)| *l == self.layer).map(|(_, m)| m.as_slice()) + } + fn reply(&self, sel: &str) -> Value { + let selected = effectcraft_effects::selection_hashes(sel, self.manifest()).contains(&self.hash); + let name = self.aux.manifest_name(&self.layer, self.hash); + json!({"name": name, "hash": format!("{:08x}", self.hash), "layer": self.layer, "selected": selected}) + } +} + +/// 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> { + 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")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?; + 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)")); + }; + // 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 (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 })) +} + +/// `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). +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))) +} + +/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it +/// or remove it, in one undo step at the time it was read (none when its meaning stays). +fn pick_crypto(s: &mut Session, p: &Value) -> Result { + use effectcraft_effects::crypto_select::{PickMode, edit_selection, token_for}; + let cmd = "effect.pickCryptomatte"; + let mode = match str_p(p, "mode").unwrap_or("replace") { + "replace" => PickMode::Replace, + "add" => PickMode::Add, + "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)"))?; + if h.driven { + return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick")); + } + let m = h.manifest(); + let sel = edit_selection(&h.selection, &token_for(h.aux.manifest_name(&h.layer, h.hash), h.hash, m), h.hash, mode, m); + let changed = sel != h.selection; + 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"}))?; + } + let mut out = h.reply(&sel); + out["selection"] = json!(sel); + out["changed"] = json!(changed); + Ok(out) +} + fn remove(s: &mut Session, p: &Value) -> Result { let cid = super::comp_id(s, p)?; let fx = effects_p(s, p, "effect.remove")?; @@ -632,6 +727,21 @@ pub fn specs() -> Vec { has_layers, pick_color ), + cmd!( + "effect.pickCryptomatte", + "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", + 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", + crypto_at + ), cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last), cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all), cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove), diff --git a/crates/engine/src/cmd/enabled.rs b/crates/engine/src/cmd/enabled.rs new file mode 100644 index 000000000..ba2631b20 --- /dev/null +++ b/crates/engine/src/cmd/enabled.rs @@ -0,0 +1,38 @@ +use super::*; + +// ---------- 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 98% rename from crates/engine/src/commands/file.rs rename to crates/engine/src/cmd/file.rs index 8c4c384ea..b3922c7d6 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -560,7 +560,7 @@ pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"]; /// A data footage item for `path` (JSON / CSV / TSV), `None` for other files. pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option> { - let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); if !DATA_EXTENSIONS.contains(&ext.as_str()) { return None; } @@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t) .ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?; } + if let Some(f) = p.get("frameStart") { + // Frame Count: After Effects offers Start at 0 and Start at 1 only. Scripts and dialog + // number fields may send `1.0`. + proj.settings.frame_start = match f.as_f64() { + Some(0.0) => 0, + Some(1.0) => 1, + _ => return Err(bad(cmd, format!("frameStart: 0|1, not `{f}`"))), + }; + } Ok(()) })?; Ok(serde_json::to_value(&s.project.settings).unwrap_or_default()) @@ -745,7 +754,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16, frameStart?: 0|1 (Frame Count: Start at 0 / Start at 1)}", always, project_settings ), diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/cmd/file_more.rs similarity index 100% rename from crates/engine/src/commands/file_more.rs rename to crates/engine/src/cmd/file_more.rs diff --git a/crates/engine/src/commands/focus.rs b/crates/engine/src/cmd/focus.rs similarity index 100% rename from crates/engine/src/commands/focus.rs rename to crates/engine/src/cmd/focus.rs diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/cmd/footage_panel.rs similarity index 100% rename from crates/engine/src/commands/footage_panel.rs rename to crates/engine/src/cmd/footage_panel.rs diff --git a/crates/engine/src/commands/frame_export.rs b/crates/engine/src/cmd/frame_export.rs similarity index 100% rename from crates/engine/src/commands/frame_export.rs rename to crates/engine/src/cmd/frame_export.rs diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/cmd/frontend.rs similarity index 100% rename from crates/engine/src/commands/frontend.rs rename to crates/engine/src/cmd/frontend.rs diff --git a/crates/engine/src/commands/help.rs b/crates/engine/src/cmd/help.rs similarity index 100% rename from crates/engine/src/commands/help.rs rename to crates/engine/src/cmd/help.rs diff --git a/crates/engine/src/commands/import_project.rs b/crates/engine/src/cmd/import_project.rs similarity index 100% rename from crates/engine/src/commands/import_project.rs rename to crates/engine/src/cmd/import_project.rs diff --git a/crates/engine/src/commands/key_labels.rs b/crates/engine/src/cmd/key_labels.rs similarity index 100% rename from crates/engine/src/commands/key_labels.rs rename to crates/engine/src/cmd/key_labels.rs diff --git a/crates/engine/src/commands/key_transform.rs b/crates/engine/src/cmd/key_transform.rs similarity index 100% rename from crates/engine/src/commands/key_transform.rs rename to crates/engine/src/cmd/key_transform.rs diff --git a/crates/engine/src/commands/keys_more.rs b/crates/engine/src/cmd/keys_more.rs similarity index 100% rename from crates/engine/src/commands/keys_more.rs rename to crates/engine/src/cmd/keys_more.rs diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs new file mode 100644 index 000000000..f6d2feb04 --- /dev/null +++ b/crates/engine/src/cmd/layer.rs @@ -0,0 +1,212 @@ +//! Layer menu. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::{Justify, TextDoc, Value as KV}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{ + Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, + TrackMatte, limits, +}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p, unlocked}; +use crate::{EngineError, Result, Session, cmd}; + +mod contents; +mod create; +mod parent; +mod replace; +mod select; +mod switches; +mod timing; +mod transform; + +use contents::{add_mask, add_shape_item, mask_props, set_text}; +use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text}; +pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, position_p}; +pub(crate) use parent::parent_fixes; +use parent::set_parent; +use replace::{has_replace_source, replace_source}; +use select::{select, select_next, select_prev, select_step}; +use switches::{blend, rename, set_comment, set_switch, track_matte}; +use timing::{arrange, move_frames, precompose, slip, timing}; +use transform::{has_layer_settings, layer_settings, transform_op}; +pub(crate) use transform::{offset_position, transform_default, update_position}; + +/// `p` with `key` set. +fn with(p: &Value, key: &str, v: Value) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p[key] = v; + p +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.newText", + "Text", + ["Layer", "New"], + Some("Cmd+Alt+Shift+T"), + "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)", + has_comp, + new_text + ), + cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?, pixelAspect?}", has_comp, new_solid), + cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null), + cmd!( + "layer.newShape", + "Shape Layer", + ["Layer", "New"], + None, + "{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}", + has_comp, + new_shape + ), + cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment), + cmd!( + "layer.settings", + "Layer Settings...", + ["Layer"], + Some("Cmd+Shift+Y"), + "{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", + has_layer_settings, + layer_settings + ), + cmd!( + "layer.addItem", + "Add Footage to Comp", + ["File"], + Some("Cmd+/"), + "{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}", + has_comp, + add_item + ), + cmd!( + "layer.replaceSource", + "Replace Layer Source", + [], + Some("Cmd+Alt+/"), + "{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}", + has_replace_source, + replace_source + ), + cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select), + cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next), + cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev), + cmd!("layer.extendSelectionNext", "Extend Selection to Next Layer", [], Some("Cmd+Shift+ArrowDown"), "{}", has_comp, |s, _| select_step( + s, + &json!({"add": true}), + 1 + )), + cmd!("layer.extendSelectionPrevious", "Extend Selection to Previous Layer", [], Some("Cmd+Shift+ArrowUp"), "{}", has_comp, |s, _| select_step( + s, + &json!({"add": true}), + -1 + )), + cmd!( + "layer.setSwitch", + "Layer Switch", + [], + None, + "{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}", + has_layers, + set_switch + ), + cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename), + cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment), + cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend), + cmd!( + "layer.setTrackMatte", + "Track Matte", + [], + None, + "{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}", + has_layers, + track_matte + ), + cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null, compensate?: bool}", has_layers, set_parent), + cmd!( + "layer.timing", + "Layer Timing", + [], + None, + "{layers?, op?: moveInToTime|moveOutToTime|moveInToStart|moveOutToEnd|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}", + has_layers, + timing + ), + cmd!("layer.moveInToStart", "Move Layer In Point to Beginning of Comp", [], Some("Alt+Home"), "{layers?}", has_layers, |s, p| timing( + s, + &with(p, "op", json!("moveInToStart")) + )), + cmd!("layer.moveOutToEnd", "Move Layer Out Point to End of Comp", [], Some("Alt+End"), "{layers?}", has_layers, |s, p| timing( + s, + &with(p, "op", json!("moveOutToEnd")) + )), + cmd!("layer.moveEarlier", "Move Layers 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -1)), + cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)), + cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)), + cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)), + cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip), + cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(-1); + slip(s, &p) + }), + cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], None, "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(1); + slip(s, &p) + }), + cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?, above?: layer}", has_layers, arrange), + cmd!( + "layer.precompose", + "Pre-compose...", + ["Layer"], + Some("Cmd+Shift+C"), + "{layers?, name?, mode?: move|leave, adjustDuration?, open?}", + has_layers, + precompose + ), + cmd!( + "layer.addMask", + "New Mask", + ["Layer", "Mask"], + Some("Cmd+Shift+N"), + "{layer?, shape?: rect|ellipse|rounded|polygon|star, rect? [x,y,w,h] (layer space; a polygon or star fits its width), mode?}", + has_layers, + add_mask + ), + cmd!("layer.setMask", "Mask Mode", [], None, "{layer?, mask: index|uid|name, mode?, inverted?}", has_layers, mask_props), + cmd!( + "layer.addShapeItem", + "Add (Shape)", + [], + None, + "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", + has_layers, + add_shape_item + ), + cmd!( + "layer.setText", + "Edit Text", + [], + None, + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + has_layers, + set_text + ), + cmd!( + "layer.transform", + "Transform", + ["Layer", "Transform"], + None, + "{layers?, op: reset|center|fit|fitWidth|fitHeight|flipH|flipV}", + has_layers, + transform_op + ), + ] +} + +#[allow(dead_code)] +fn unused(_: &Comp) {} diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs new file mode 100644 index 000000000..884d0df5c --- /dev/null +++ b/crates/engine/src/cmd/layer/contents.rs @@ -0,0 +1,205 @@ +use super::create::{apply_text_params, text_range_p}; +use super::*; + +pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.addMask")?; + let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?.clone(); + let (w, h) = effectcraft_render::source_size(&s.project, &layer); + let (w, h) = if w == 0 { (400.0, 300.0) } else { (w as f64, h as f64) }; + let shape = str_p(p, "shape").unwrap_or("rect"); + let r = p.get("rect").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0))); + let (cx, cy, rw, rh) = match r { + Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh), + None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6), + }; + // The shape tools' masks: rectangle, ellipse, rounded rectangle, polygon or star. + let path = crate::cmd::shape_tool::mask_path(shape, [cx, cy], [rw, rh]) + .ok_or_else(|| bad("layer.addMask", format!("unknown shape `{shape}`; one of rect, ellipse, rounded, polygon, star")))?; + let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); + let cycle = s.prefs.appearance.cycle_mask_colors; + let uid = s.edit("New Mask", None, |proj, _| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?; + let n = masks.children.len(); + let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, crate::prefs::mask_color(cycle, n)); + let uid = g.uid; + masks.children.push(g.into()); + proj.next_id = next; + Ok(uid) + })?; + Ok(json!({"mask": uid})) +} + +pub(super) fn mask_props(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.setMask")?; + let target = p.get("mask").cloned().unwrap_or(json!(1)); + let mode = str_p(p, "mode").and_then(MaskMode::from_name); + let inverted = b_p(p, "inverted"); + s.edit("Mask Settings", None, |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "the layer has no masks (add one with layer.addMask)"))?; + let g = match &target { + Value::Number(n) => { + let n = n.as_u64().unwrap_or(1); + if let Some(i) = masks.children.iter().position(|c| c.uid() == n) { + masks.children[i].as_group_mut() + } else { + masks.children.get_mut(n.saturating_sub(1) as usize).and_then(|c| c.as_group_mut()) + } + } + Value::String(name) => masks.children.iter_mut().find(|c| c.name() == name).and_then(|c| c.as_group_mut()), + _ => None, + } + .ok_or_else(|| bad("layer.setMask", "no such mask"))?; + if let GroupKind::Mask { mode: m, inverted: inv, .. } = &mut g.kind { + if let Some(mm) = mode { + *m = mm; + } + if let Some(i) = inverted { + *inv = i; + } + } + Ok(()) + })?; + Ok(Value::Null) +} + +pub(super) fn add_shape_item(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.addShapeItem")?; + let kind = str_p(p, "kind").ok_or_else(|| bad("layer.addShapeItem", "missing `kind`"))?.to_string(); + // `group`: a uid or a property path (`contents/group`). + let group_uid = match p.get("group") { + Some(Value::String(path)) => Some( + s.project + .comp(cid) + .and_then(|c| c.layer(lid)) + .and_then(|l| l.props.group(path)) + .map(|g| g.uid) + .ok_or_else(|| bad("layer.addShapeItem", format!("no group `{path}`")))?, + ), + v => v.and_then(Value::as_u64), + }; + let uid = s.edit("Add Shape Item", None, |proj, _| { + let mut next = proj.next_id; + let mut ids = Ids(&mut next); + // Front / Bevel / Side / Back ▸ property: into the group's surface item (one per group). + if let Some((surface, prop)) = build::shape_surface_kind(&kind) { + let target = shape_item_target(proj, cid, lid, group_uid)?; + let item = match target.children.iter().position(|c| matches!(c, Node::Group(g) if g.match_id == surface)) { + Some(i) => i, + None => { + let g = build::shape_surface(&mut ids, surface).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?; + target.children.push(g.into()); + target.children.len() - 1 + } + }; + let Some(Node::Group(g)) = target.children.get_mut(item) else { return Err(bad("layer.addShapeItem", "no such group")) }; + if g.get(prop).is_none() { + let p = build::shape_surface_prop(&mut ids, prop).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?; + g.children.push(p.into()); + } + let uid = g.uid; + proj.next_id = next; + return Ok(uid); + } + let g = match kind.as_str() { + "group" => build::shape_group(&mut ids, "Group 1", vec![]), + "rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0), + "ellipse" => build::shape_ellipse(&mut ids, [200.0, 200.0], [0.0, 0.0]), + "star" => build::shape_star(&mut ids, true, 5.0, [0.0, 0.0], 100.0, 50.0), + "polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0), + "fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]), + "stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0), + "path" => build::shape_path(&mut ids, Default::default()), + "gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()), + "gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0), + "trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0), + "repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]), + k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?, + }; + let uid = g.uid; + proj.next_id = next; + shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into()); + Ok(uid) + })?; + // The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`). + let path = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.match_path_of(uid)); + Ok(json!({"uid": uid, "path": path})) +} + +/// The contents a new shape item goes into: the shape group `group_uid` (its Contents), else the +/// layer's Contents. +fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: Option) -> Result<&mut PropGroup> { + let l = layer_mut(proj, cid, lid)?; + let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?; + Ok(match group_uid { + Some(u) => contents + .find_group_mut(u) + .and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") }) + .ok_or_else(|| bad("layer.addShapeItem", "no such group"))?, + None => contents, + }) +} + +/// `layer.setText`: sets a text layer's Source Text and/or character attributes at the CTI. +/// +/// A `font` name is also added to the recent fonts and the preferences saved. With an empty +/// `range` and no `text`, while that layer is being edited and its document is non-empty, +/// recognised character attributes are also staged in the pending insertion style without +/// changing existing characters. Paragraph/document attributes still apply through the edit; +/// the session is bumped whenever this branch runs. Document changes use [`Session::edit`], +/// merged by `merge_p`. With `text` given, a layer whose name is empty or starts with `Text` +/// takes the new first line (up to 40 characters) when that line is non-empty. Replies `null`. +pub(super) fn set_text(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.setText")?; + // The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display). + if let Some(f) = str_p(p, "font") { + s.prefs.push_recent_font(f); + s.save_prefs(); + } + let t = s.time(); + let range = text_range_p(p); + // Character attributes with only a caret (no selected text) set the insertion style. + if let Some(r) = range.clone().filter(|r| r.is_empty() && str_p(p, "text").is_none()) + && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) + && let Some(doc) = crate::cmd::text_edit::layer_doc(s, lid) + && doc.char_len() > 0 + && let Some(obj) = p.as_object() + && let Some(e) = s.state.text_edit.as_mut() + { + let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start)); + let mut any = false; + for (k, v) in obj { + any |= effectcraft_keyframe::text_doc::apply_char_attr(&mut st, k, v).map_err(|m| bad("layer.setText", m))?; + } + if any { + e.pending = Some(st); + } + s.bump(); + } + // A changed Source Text is handled by `Session::edit`: its boundary clamps the selection and + // starts a new IME epoch only when the CTI document actually changed, so an identical text + // write stays a no-op and keeps the editor's owner. + s.edit("Edit Text", merge_p(p), |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let lt = l.layer_time(t); + let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?; + let mut doc = match pr.value_at(lt) { + KV::Text(d) => *d, + KV::Str(t) => TextDoc::plain(&t), + _ => TextDoc::default(), + }; + apply_text_params("layer.setText", p, &mut doc, range.clone())?; + pr.set_value_at(lt, KV::Text(Box::new(doc.clone()))); + if let Some(first) = doc.text.lines().next() + && str_p(p, "text").is_some() + && !first.is_empty() + && (l.name.starts_with("Text") || l.name.is_empty()) + { + l.name = first.chars().take(40).collect(); + } + Ok(()) + })?; + Ok(Value::Null) +} diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs new file mode 100644 index 000000000..7a01aa538 --- /dev/null +++ b/crates/engine/src/cmd/layer/create.rs @@ -0,0 +1,312 @@ +use super::*; + +pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { + match p.get(k)? { + Value::String(s) => effectcraft_color::Rgba::from_hex(s).map(|c| [c.r, c.g, c.b]), + Value::Array(a) => { + let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32; + Some([g(0), g(1), g(2)]) + } + _ => None, + } +} + +/// Insert a new layer above the selection (or at the top) and select it. +pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { + insert_layer_at(proj, st, cid, layer, None) +} + +/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the +/// bottom = the bottom), as 0-based. +pub(crate) fn index_p(p: &Value, cmd: &str) -> Result> { + match p.get("index") { + None | Some(Value::Null) => Ok(None), + Some(v) => { + v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position")) + } + } +} + +/// A `position` parameter: `[x, y]` in comp pixels (finite). +pub(crate) fn position_p(p: &Value, cmd: &str) -> Result> { + match p.get("position") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_array() + .and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) + .filter(|c| c.iter().all(|x| x.is_finite())) + .map(Some) + .ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")), + } +} + +/// Put a new layer's Position at `pos` (comp pixels). +pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) { + if let Some([x, y]) = pos + && let Some(pr) = l.props.prop_mut("transform/position") + { + let z = match pr.value { + KV::Vec3(v) => v[2], + _ => 0.0, + }; + pr.value = KV::Vec3([x, y, z]); + } +} + +/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer. +pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option) -> Result { + // Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels). + if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) { + crate::cmd::model3d::add_geometry_options(&mut proj.next_id, &mut layer); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let at = match at { + Some(i) => i.min(comp.layers.len()), + None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0), + }; + let id = layer.id; + comp.layers.insert(at, layer); + st.selected_layers = vec![id]; + st.selected_props.clear(); + st.selected_keys.clear(); + Ok(id) +} + +fn solids_folder(proj: &mut Project) -> ItemId { + proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)) +} + +fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] }); + // 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid. + let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width); + let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height); + let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); + let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; + let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); + let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { + let folder = solids_folder(proj); + let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect })); + let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); + if adjustment { + l.switches.adjustment = true; + l.label = Label::Purple; + } + insert_layer(proj, st, cid, l) + })?; + Ok(json!({"layer": id.0})) +} + +/// `pixelAspect` (a positive number), when given. +pub(super) fn pixel_aspect_p(p: &Value) -> Result> { + match p.get("pixelAspect").and_then(Value::as_f64) { + Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")), + v => Ok(v.map(limits::pixel_aspect)), + } +} + +pub(super) fn new_solid(s: &mut Session, p: &Value) -> Result { + new_solid_like(s, p, false) +} + +pub(super) fn new_adjustment(s: &mut Session, p: &Value) -> Result { + new_solid_like(s, p, true) +} + +/// Parameters of `layer.setText` / `layer.newText` that aren't text attributes. +const NOT_ATTRS: &[&str] = &["layer", "layers", "comp", "merge", "text", "range", "position", "edit"]; + +/// Apply `layer.setText` keys to `d`: `text` replaces characters `r` (or everything), then each +/// character / paragraph / document attribute applies to `r` (or everything). +pub(super) fn apply_text_params(cmd: &str, p: &Value, d: &mut TextDoc, r: Option>) -> Result<()> { + let mut r = r; + if let Some(t) = str_p(p, "text") { + match r.clone() { + Some(rr) => { + d.replace_range(rr.clone(), t, None); + r = Some(rr.start..rr.start + t.chars().count()); + } + None => d.set_text(t), + } + } + let Some(obj) = p.as_object() else { return Ok(()) }; + for (k, v) in obj { + if NOT_ATTRS.contains(&k.as_str()) { + continue; + } + if !d.set_attr(k, v, r.clone()).map_err(|e| bad(cmd, e))? { + return Err(bad(cmd, format!("unknown text attribute {k}"))); + } + } + Ok(()) +} + +pub(super) fn text_range_p(p: &Value) -> Option> { + let a = p.get("range")?.as_array()?; + let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize); + let (x, y) = (g(0)?, g(1).or(g(0))?); + Some(x.min(y)..x.max(y)) +} + +/// `layer.newText`: adds a Text layer to the comp at the CTI and returns `{layer}`. +/// +/// The document starts as centre-justified "Text", with the optional text parameters applied. +/// With `box` `[x, y, w, h]`, `(x, y)` is the paragraph box's top-left in comp space. Its width +/// and height are `w.abs().max(1.0)` and `h.abs().max(1.0)`; the layer position is the box centre. +/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 +/// characters of the first text line, else "Text". `edit: true` also opens a new editing +/// session with anchor `0` and the caret after the text, selecting all initial text so the next +/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer. +pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut doc = TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() }; + // A paragraph box given in comp space: the layer sits at its centre. + let bx = p.get("box").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0))); + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.remove("box"); + o.remove("name"); + } + let layer_name = str_p(p, "name").map(str::to_string); + apply_text_params("layer.newText", &q, &mut doc, None)?; + let mut pos = position_p(p, "layer.newText")?; + if let Some([x, y, w, h]) = bx { + let (w, h) = (w.abs().max(1.0), h.abs().max(1.0)); + doc.box_size = Some([w, h]); + doc.box_pos = [-w / 2.0, -h / 2.0]; + pos = Some([x + w / 2.0, y + h / 2.0]); + if p.get("justify").is_none() { + doc.set_attr("justify", &json!("left"), None).map_err(|e| bad("layer.newText", e))?; + } + } + let edit = b_p(p, "edit").unwrap_or(false); + let id = s.edit("New Text Layer", None, |proj, st| { + let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect()); + let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + if let Some(pr) = l.props.prop_mut("text/sourceText") { + pr.value = KV::Text(Box::new(doc.clone())); + } + if let Some(pos) = pos + && let Some(pr) = l.props.prop_mut("transform/position") + { + pr.value = KV::Vec3([pos[0], pos[1], 0.0]); + } + let n = doc.char_len(); + let id = insert_layer(proj, st, cid, l)?; + if edit { + st.text_edit = Some(crate::cmd::text_edit::TextEdit { + layer: id, + anchor: 0, + caret: n, + created: true, + session: crate::cmd::text_edit::next_edit_session(), + ..Default::default() + }); + } + Ok(id) + })?; + Ok(json!({"layer": id.0})) +} + +pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let kind = str_p(p, "kind").unwrap_or("none").to_string(); + let size = p + .get("size") + .and_then(Value::as_array) + .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)]) + .unwrap_or([300.0, 300.0]); + // A fill, and a stroke only with a width (not the Tools bar's paint). + let paint = crate::cmd::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?; + let pos = p + .get("position") + .and_then(|v| v.as_array()) + .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); + let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); + let id = s.edit("New Shape Layer", None, |proj, st| { + let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; + let mut next = proj.next_id; + let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); + proj.next_id = next; + if let Some(g) = g { + crate::cmd::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?; + } + Ok(lid) + })?; + Ok(json!({"layer": id.0})) +} + +fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &str) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let name = str_p(p, "name").unwrap_or(name).to_string(); + let id = s.edit(label, None, |proj, st| { + let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + insert_layer(proj, st, cid, l) + })?; + Ok(json!({"layer": id.0})) +} + +pub(super) fn new_null(s: &mut Session, p: &Value) -> Result { + new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object") +} + +pub(super) fn add_item(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let item = match p.get("item") { + Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)), + Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.addItem", format!("no item `{name}`")))?, + _ => *s.state.project_selection.first().ok_or_else(|| bad("layer.addItem", "missing `item`"))?, + }; + if s.project.comp_contains(item, cid) { + return Err(bad("layer.addItem", "a composition can't contain itself")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone(); + let (src, size, dur) = match &it.kind { + ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)), + ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => { + // `duration`, else Settings ▸ Import ▸ Still Footage: length of the composition or a + // duration. + let d = f_p(p, "duration") + .filter(|d| d.is_finite() && *d > 0.0) + .or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds)) + .map(Tick::from_seconds_f64); + (LayerSource::Footage { item }, (f.width, f.height), d) + } + ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => { + return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData")); + } + ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => { + return crate::cmd::model3d::new_model( + s, + &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}), + ); + } + ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.looped_duration())), + ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), + ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), + }; + let index = index_p(p, "layer.addItem")?; + let position = position_p(p, "layer.addItem")?; + let fr = comp.frame_rate; + // Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time). + let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() }; + let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start)); + let id = s.edit("Add Footage to Comp", None, |proj, st| { + let mut l = build::layer(proj, &comp, &it.name, src, size, dur); + l.start_time = start; + l.in_point = start; + // Footage and precomps keep their full length past the comp's end (After Effects); items + // without a duration (solids, comp-length stills) end with the comp. + let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d); + l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration()); + place(&mut l, position); + insert_layer_at(proj, st, cid, l, index) + })?; + Ok(json!({"layer": id.0})) +} diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs new file mode 100644 index 000000000..8565cbbb1 --- /dev/null +++ b/crates/engine/src/cmd/layer/parent.rs @@ -0,0 +1,452 @@ +use super::*; + +/// Re-express a layer in its new parent space without moving its anchor. +pub(crate) struct ParentFix { + pub layer: LayerId, + /// Maps Position from the old parent's space into the new one's. + m: effectcraft_geom::Mat3, + /// Added to Rotation. + rotation: f64, + /// Multiply Scale. + scale: [f64; 2], + /// 3D AV layers retain their world-space anchor, including Z. + position_3d: Option, + /// A uniform relative parent transform can preserve static Orientation and Scale. + pose_3d: Option<([f64; 3], f64)>, +} + +impl ParentFix { + /// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space. + pub fn apply(&self, l: &mut Layer) { + let Some(tr) = l.props.sub_mut("transform") else { return }; + if let Some(m) = self.position_3d { + // Spatial Position speed measures distance along its path. Its parent-space + // unit changes even when the child's Orientation cannot be re-expressed. + let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let map_position = |v: &KV| { + let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); + KV::Vec3([q.x, q.y, q.z]) + }; + // Read all separated axes before writing any of them. + let names = ["positionX", "positionY", "positionZ"]; + if tr.get("positionX").is_some() { + let axes = names.map(|name| tr.get(name).cloned()); + let current = axes.each_ref().map(|pr| pr.as_ref().map(|pr| pr.value.as_f64()).unwrap_or(0.0)); + let q = m.apply(effectcraft_geom::Vec3::from(current)); + for (i, name) in names.into_iter().enumerate() { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0)); + for key in &mut pr.keys { + let p = axes.each_ref().map(|axis| axis.as_ref().map(|axis| axis.value_at(key.time).as_f64()).unwrap_or(0.0)); + let q = m.apply(effectcraft_geom::Vec3::from(p)); + key.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0)); + } + } + } + } + if let Some(pr) = tr.get_mut("position") { + pr.value = map_position(&pr.value); + for key in &mut pr.keys { + key.value = map_position(&key.value); + // Spatial tangents are vectors, so the parent's translation is excluded. + for tangent in [&mut key.spatial_in, &mut key.spatial_out] { + let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); + *tangent = [v.x, v.y, v.z]; + } + } + if let Some(factor) = position_speed + .filter(|_| pr.spatial) + .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) + { + for key in &mut pr.keys { + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed *= factor; + } + } + } + } + // Cameras and lights aim with Point of Interest: move it by the same parent-space + // change, so a two-node rig keeps its world frame (#1010, #1020). + if let Some(pr) = tr.get_mut("poi") { + pr.value = map_position(&pr.value); + for key in &mut pr.keys { + key.value = map_position(&key.value); + for tangent in [&mut key.spatial_in, &mut key.spatial_out] { + let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); + *tangent = [v.x, v.y, v.z]; + } + } + if let Some(factor) = position_speed + .filter(|_| pr.spatial) + .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) + { + for key in &mut pr.keys { + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed *= factor; + } + } + } + } + if let Some((orientation, factor)) = self.pose_3d { + if let Some(pr) = tr.get_mut("orientation") { + pr.value = KV::Vec3(orientation); + } + if let Some(pr) = tr.get_mut("scale") { + let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); + pr.value = scaled(&pr.value); + for key in &mut pr.keys { + key.value = scaled(&key.value); + // Scale's temporal speeds use the same units as its values. + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed *= factor; + } + } + } + } + return; + } + let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { + pr.value = f(&pr.value); + for key in &mut pr.keys { + key.value = f(&key.value); + } + }; + let m = self.m; + if tr.get("positionX").is_some() { + // Separated dimensions: X and Y move together, key by key at the same times. + // Everything is read before anything is written. + let [x, y] = [tr.get("positionX"), tr.get("positionY")].map(|p| p.map(|p| p.value.as_f64()).unwrap_or(0.0)); + let q = m.apply(effectcraft_geom::vec2(x, y)); + let keys_x: Vec<(Tick, f64)> = tr.get("positionX").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); + let keys_y: Vec<(Tick, f64)> = tr.get("positionY").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); + let at = |keys: &[(Tick, f64)], name: &str, t: Tick, tr: &PropGroup| { + keys.iter().find(|k| k.0 == t).map(|k| k.1).unwrap_or_else(|| tr.get(name).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0)) + }; + let (xs, ys): (Vec, Vec) = ( + keys_x.iter().map(|&(t, x)| m.apply(effectcraft_geom::vec2(x, at(&keys_y, "positionY", t, tr))).x).collect(), + keys_y.iter().map(|&(t, y)| m.apply(effectcraft_geom::vec2(at(&keys_x, "positionX", t, tr), y)).y).collect(), + ); + for (name, value, vals) in [("positionX", q.x, xs), ("positionY", q.y, ys)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(value); + for (k, v) in pr.keys.iter_mut().zip(vals) { + k.value = KV::Scalar(v); + } + } + } + } else if let Some(pr) = tr.get_mut("position") { + map(pr, &|v| { + let c = v.as_vec3(); + let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); + KV::Vec3([q.x, q.y, c[2]]) + }); + } + let (dr, k) = (self.rotation, self.scale); + if let Some(pr) = tr.get_mut("rotation") { + map(pr, &|v| KV::Scalar(v.as_f64() + dr)); + } + if let Some(pr) = tr.get_mut("scale") { + map(pr, &|v| { + let c = v.as_vec3(); + KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) + }); + } + } +} + +/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal +/// rotation to static Orientation using the existing Rz * Ry * Rx order. +fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { + use effectcraft_geom::{Mat4, Vec3, vec3}; + if !orientation.iter().all(|v| v.is_finite()) { + return None; + } + let a = m.0; + let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; + let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); + if !magnitude.is_finite() || magnitude <= 0.0 { + return None; + } + let c = columns.map(|c| c / magnitude); + if c.iter().any(|c| (c.dot(*c) - 1.0).abs() > 1e-9) || c[0].dot(c[1]).abs() > 1e-9 || c[0].dot(c[2]).abs() > 1e-9 || c[1].dot(c[2]).abs() > 1e-9 { + return None; + } + let cross = vec3(c[1].y * c[2].z - c[1].z * c[2].y, c[1].z * c[2].x - c[1].x * c[2].z, c[1].x * c[2].y - c[1].y * c[2].x); + let factor = if c[0].dot(cross) < 0.0 { -magnitude } else { magnitude }; + let mut rotation = Mat4::IDENTITY; + for row in 0..3 { + for col in 0..3 { + rotation.0[row][col] = a[row][col] / factor; + } + } + if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { + return Some((orientation, factor)); + } + let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. + let cy = r[0][0].hypot(r[1][0]); + let y = (-r[2][0]).atan2(cy); + let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; + Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) +} + +/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, +/// and Point of Interest for two-node cameras and lights). Position is +/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current +/// time. +pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { + let Some(comp) = s.project.comp(cid) else { return vec![] }; + let ectx = + effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let space = |id: Option| -> effectcraft_geom::Mat3 { + id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) + }; + let decompose = |m: &effectcraft_geom::Mat3| { + let a = m.0; + let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); + let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; + (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) + }; + let new_m = space(parent); + let Some(inv) = new_m.inverse() else { return vec![] }; + let (rn, sxn, syn) = decompose(&new_m); + let mut fixes: Vec<_> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) + .map(|l| { + let old_m = space(l.parent); + let (ro, sxo, syo) = decompose(&old_m); + let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; + ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale, position_3d: None, pose_3d: None } + }) + .collect(); + let space_3d = |id: Option| { + id.and_then(|id| comp.layer(id)).map_or(effectcraft_geom::Mat4::IDENTITY, |layer| { + // Parent transforms do not inherit the parent's source pixel-aspect stretch. + let world = ectx.world_matrix(layer); + let par = ectx.par_ratio(layer); + let a = layer.transform().map(|tr| ectx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + let anchor = effectcraft_geom::vec3(a[0], a[1], if layer.is_3d() { a[2] } else { 0.0 }); + world + * effectcraft_geom::Mat4::translate(anchor) + * effectcraft_geom::Mat4::scale(effectcraft_geom::vec3(1.0 / par, 1.0, 1.0)) + * effectcraft_geom::Mat4::translate(-anchor) + }) + }; + if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { + // Cameras and lights compensate like 3D layers (Position, and Point of Interest for + // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no + // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. + for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { + let m = inv * space_3d(layer.parent); + if m.0.iter().flatten().all(|v| v.is_finite()) { + let pose_3d = layer.transform().and_then(|tr| { + let orientation = tr.get("orientation")?; + let scale = tr.get("scale")?; + // Animated Euler Orientation and auto-orient cannot be re-expressed by + // prepending a constant Euler value. Expression-driven values stay authored. + if !orientation.keys.is_empty() + || orientation.expr.is_some() + || scale.expr.is_some() + || layer.auto_orient != effectcraft_project::AutoOrient::Off + { + return None; + } + let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; + let factor = pose.1; + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); + (values_finite && speeds_finite).then_some(pose) + }); + fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); + } + } + } + fixes +} + +/// Validate the proposed ancestor chain before any authored edit, including when compensation +/// is disabled. Bound both unique ancestors and linear lookup/selection work on loaded graphs. +fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option, max_visits: usize, max_work: usize) -> Result<()> { + let mut seen = std::collections::HashSet::new(); + let mut work = 0usize; + let mut cur = parent; + while let Some(id) = cur { + if seen.len() >= max_visits { + return Err(bad("layer.setParent", "parent chain exceeds the safety visit limit")); + } + if !seen.insert(id) { + return Err(bad("layer.setParent", "parent chain contains an existing cycle")); + } + for selected in ids { + if work >= max_work { + return Err(bad("layer.setParent", "parent chain exceeds the safety work limit")); + } + work += 1; + if *selected == id { + return Err(bad("layer.setParent", "parenting would create a cycle")); + } + } + let mut ancestor = None; + for layer in &comp.layers { + if work >= max_work { + return Err(bad("layer.setParent", "parent chain exceeds the safety work limit")); + } + work += 1; + if layer.id == id { + ancestor = Some(layer); + break; + } + } + let layer = ancestor.ok_or_else(|| bad("layer.setParent", "parent chain refers to a missing layer"))?; + cur = layer.parent; + } + Ok(()) +} + +pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let parent = match p.get("parent") { + None | Some(Value::Null) => None, + Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?), + }; + // A loaded cycle need not include a selected child. Validate before compensation or edit; + // do not impose the renderer's 64-ancestor truncation as a new ordinary hierarchy limit. + check_parent_chain(comp, &ids, parent, 4096, 1_048_576)?; + // Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed + // in the new parent's space (Position, Rotation, Scale), unless `compensate: false`. + let compensate = b_p(p, "compensate").unwrap_or(true); + let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; + s.edit("Parent", None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + l.parent = parent; + if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { + f.apply(l); + } + } + Ok(()) + })?; + Ok(Value::Null) +} + +#[cfg(test)] +mod parent_chain_tests { + use super::*; + use std::sync::Arc; + + fn fixture() -> (Session, u64, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Parent safety","width":16,"height":16,"duration":1})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name":"A"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"name":"C"})).unwrap()["layer"].as_u64().unwrap(); + for id in [a, b, c] { + s.execute("prop.set", json!({"layer":id,"path":"transform/anchor","value":[0,0,0]})).unwrap(); + } + s.execute("prop.set", json!({"layer":a,"path":"transform/position","value":[100,200,0]})).unwrap(); + s.execute("prop.set", json!({"layer":c,"path":"transform/position","value":[10,20,0]})).unwrap(); + s.history.undo.clear(); + (s, a, b, c) + } + fn malformed_parent(s: &mut Session, child: u64, parent: u64) { + let cid = s.active_comp_id().unwrap(); + Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(child)).unwrap().parent = Some(LayerId(parent)); + } + fn assert_atomic_error(s: &mut Session, child: u64, parent: u64, compensate: bool, message: &str) { + let before = s.project.clone(); + let revision = s.revision; + let undo = s.history.undo.len(); + let err = s.execute("layer.setParent", json!({"layers":[child],"parent":parent,"compensate":compensate})).unwrap_err(); + assert!(err.to_string().contains(message), "{err}"); + assert!(Arc::ptr_eq(&before, &s.project)); + assert_eq!(s.revision, revision); + assert_eq!(s.history.undo.len(), undo); + } + #[test] + fn loaded_cycle_excluding_selected_child_is_rejected_atomically() { + for compensate in [true, false] { + let (mut s, a, b, c) = fixture(); + // Simulate a malformed loaded project. Never execute this on the unguarded baseline. + malformed_parent(&mut s, a, b); + malformed_parent(&mut s, b, a); + assert_atomic_error(&mut s, c, a, compensate, "existing cycle"); + } + } + #[test] + fn dangling_loaded_parent_is_rejected_atomically() { + for compensate in [true, false] { + let (mut s, a, _, c) = fixture(); + malformed_parent(&mut s, a, u64::MAX); + assert_atomic_error(&mut s, c, a, compensate, "missing layer"); + } + } + #[test] + fn selected_descendant_and_direct_self_are_rejected() { + let (mut s, a, _, c) = fixture(); + assert_atomic_error(&mut s, c, c, false, "create a cycle"); + s.execute("layer.setParent", json!({"layers":[a],"parent":c,"compensate":false})).unwrap(); + assert_atomic_error(&mut s, c, a, true, "create a cycle"); + } + #[test] + fn ordinary_parent_and_unparent_keep_explicit_false_and_undo_semantics() { + for compensate in [true, false] { + let (mut s, a, _, c) = fixture(); + let old = s.project.clone(); + let world = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let comp = s.active_comp().unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time()); + ctx.world_matrix(comp.layer(LayerId(c)).unwrap()).apply(effectcraft_geom::Vec3::ZERO) + }; + let before = world(&s); + s.execute("layer.setParent", json!({"layers":[c],"parent":a,"compensate":compensate})).unwrap(); + let expected = if compensate { before } else { before + effectcraft_geom::vec3(100.0, 200.0, 0.0) }; + assert!((world(&s) - expected).length() < 1e-9); + assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, Some(LayerId(a))); + assert_eq!(s.history.undo.len(), 1); + let parented = s.project.clone(); + assert!(s.undo()); + assert!(Arc::ptr_eq(&s.project, &old)); + assert!(s.redo()); + assert!(Arc::ptr_eq(&s.project, &parented)); + s.execute("layer.setParent", json!({"layers":[c],"parent":null,"compensate":compensate})).unwrap(); + assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, None); + assert!(s.undo()); + assert!(Arc::ptr_eq(&s.project, &parented)); + } + } + #[test] + fn ancestor_and_lookup_work_budgets_fail_closed() { + let (mut s, a, b, c) = fixture(); + malformed_parent(&mut s, a, b); + let comp = s.active_comp().unwrap(); + // New solids are inserted above the selection: C,B,A. Walking A->B scans + // three then two layers, and compares the one selected child twice: seven work units. + assert_eq!(comp.layers.iter().map(|l| l.id).collect::>(), vec![LayerId(c), LayerId(b), LayerId(a)]); + assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 7).is_ok()); + assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 6).unwrap_err().to_string().contains("work limit")); + assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 256).is_ok()); + assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 1, 256).unwrap_err().to_string().contains("visit limit")); + assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 1).unwrap_err().to_string().contains("work limit")); + assert!(check_parent_chain(comp, &[LayerId(c)], None, 0, 0).is_ok(), "unparenting does not traverse ancestors"); + } + + #[test] + fn well_formed_chain_beyond_renderer_depth_is_not_unnecessarily_rejected() { + let (s, _, _, c) = fixture(); + let mut comp = s.active_comp().unwrap().clone(); + let template = comp.layers.first().unwrap().clone(); + for n in 0..80 { + let mut layer = template.clone(); + layer.id = LayerId(10_000 + n); + layer.parent = (n < 79).then_some(LayerId(10_001 + n)); + comp.layers.push(layer); + } + assert!(check_parent_chain(&comp, &[LayerId(c)], Some(LayerId(10_000)), 4096, 1_048_576).is_ok()); + } +} diff --git a/crates/engine/src/cmd/layer/replace.rs b/crates/engine/src/cmd/layer/replace.rs new file mode 100644 index 000000000..3fa208d15 --- /dev/null +++ b/crates/engine/src/cmd/layer/replace.rs @@ -0,0 +1,98 @@ +use super::*; + +/// A layer whose source is an item (footage, precomposition or solid), as in After Effects' +/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to +/// replace. +fn replaceable_layer(l: &Layer) -> bool { + matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) +} + +/// The source a Project item gives a layer, or why it can't replace one. +fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result { + const CMD: &str = "layer.replaceSource"; + match &it.kind { + ItemKind::Comp(_) => Ok(LayerSource::Comp { item }), + ItemKind::Footage(f) if f.kind == FootageKind::Data => { + Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData")) + } + ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")), + ItemKind::Footage(_) => Ok(LayerSource::Footage { item }), + ItemKind::Solid(_) => Ok(LayerSource::Solid { item }), + ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")), + } +} + +/// Whether a source brings audio properties (precompositions always can; footage when it has an +/// audio stream). +fn source_has_audio(project: &Project, source: &LayerSource) -> bool { + match source { + LayerSource::Comp { .. } => true, + LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)), + _ => false, + } +} + +pub(super) fn has_replace_source(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + let c = s.active_comp().ok_or("no composition")?; + if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) { + return Err("select a footage, composition or solid layer".into()); + } + if s.state.project_selection.is_empty() { + return Err("select a footage item, a composition or a solid in the Project panel".into()); + } + Ok(()) +} + +/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After +/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms, +/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source +/// changes; precompositions bring their Essential Properties group through `sync_project`, and a +/// source with audio adds an Audio group when the layer has none. +pub(super) fn replace_source(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.replaceSource"; + let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, CMD)?; + if ids.is_empty() { + return Err(bad(CMD, "select a layer first")); + } + let item = match p.get("item") { + Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?), + Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?, + _ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?, + }; + let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?; + let source = replacement_source(item, it)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for lid in &ids { + let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?; + if !replaceable_layer(l) { + return Err(bad(CMD, format!("“{}” has no source item to replace", l.name))); + } + } + if let LayerSource::Comp { item: new } = &source + && s.project.comp_contains(*new, cid) + { + return Err(bad(CMD, "a composition can't contain itself")); + } + let has_audio = source_has_audio(&s.project, &source); + let source_for_edit = source.clone(); + s.edit("Replace Layer Source", None, |proj, _| { + for lid in &ids { + let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none()); + let audio = if needs_audio { + let mut next = Ids(&mut proj.next_id); + Some(build::audio(&mut next)) + } else { + None + }; + let l = layer_mut(proj, cid, *lid)?; + l.source = source_for_edit.clone(); + if let Some(audio) = audio { + l.props.children.push(audio.into()); + } + } + Ok(()) + })?; + Ok(json!({"layers": ids.iter().map(|l| l.0).collect::>(), "item": item.0})) +} diff --git a/crates/engine/src/cmd/layer/select.rs b/crates/engine/src/cmd/layer/select.rs new file mode 100644 index 000000000..b7fded70d --- /dev/null +++ b/crates/engine/src/cmd/layer/select.rs @@ -0,0 +1,73 @@ +use super::*; + +pub(super) fn select(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ids: Vec = match p.get("layers").or(p.get("layer")) { + Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(), + Some(v) => resolve_layer(comp, v).into_iter().collect(), + None => vec![], + }; + let add = b_p(p, "add").unwrap_or(false); + let toggle = b_p(p, "toggle").unwrap_or(false); + if toggle { + for id in ids { + if let Some(i) = s.state.selected_layers.iter().position(|l| *l == id) { + s.state.selected_layers.remove(i); + } else { + s.state.selected_layers.push(id); + } + } + } else if add { + for id in ids { + if !s.state.selected_layers.contains(&id) { + s.state.selected_layers.push(id); + } + } + } else { + s.state.selected_layers = ids; + s.state.selected_props.clear(); + s.state.selected_keys.clear(); + let keep = s.state.selected_layers.clone(); + s.state.selected_vertices.retain(|v| keep.contains(&v.layer)); + } + Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::>())) +} + +pub(super) fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + if comp.layers.is_empty() { + return Ok(Value::Null); + } + let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)); + let n = comp.layers.len() as i64; + // The next layer that can be selected (locked and hidden shy layers are stepped over); at + // the end of the stack the selection stays. + let start = match cur { + Some(i) => i as i64 + dir, + None if dir > 0 => 0, + None => n - 1, + }; + let next = std::iter::successors(Some(start), |i| Some(i + dir)) + .take_while(|i| (0..n).contains(i)) + .find(|i| crate::cmd::selectable(comp, &comp.layers[*i as usize])); + let Some(i) = next.or(cur.map(|i| i as i64)) else { return Ok(Value::Null) }; + let id = comp.layers[i as usize].id; + let add = b_p(p, "add").unwrap_or(false); + if add { + if !s.state.selected_layers.contains(&id) { + s.state.selected_layers.insert(0, id); + } + } else { + s.state.selected_layers = vec![id]; + } + Ok(json!(id.0)) +} + +pub(super) fn select_next(s: &mut Session, p: &Value) -> Result { + select_step(s, p, 1) +} + +pub(super) fn select_prev(s: &mut Session, p: &Value) -> Result { + select_step(s, p, -1) +} diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs new file mode 100644 index 000000000..046cc37a4 --- /dev/null +++ b/crates/engine/src/cmd/layer/switches.rs @@ -0,0 +1,210 @@ +use super::*; + +pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string(); + let v = b_p(p, "value"); + let label = format!("Layer Switch ({name})"); + if b_p(p, "others") == Some(true) { + return set_switch_others(s, cid, &ids, &name, v, &label); + } + // `merge`: a switch dragged over several layers in the Timeline is one undo step. + let r = s.edit(&label, merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut last = false; + // Toggle relative to the first layer so a multi-selection ends up consistent. + let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false)); + let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; + let target = v.unwrap_or(!first); + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + set_switch_value(l, &name, target); + last = target; + } + Ok(last) + })?; + Ok(json!(r)) +} + +/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named +/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch +/// applies to is the opposite; when they already do, the other layers come back. `value` sets +/// the other layers directly. +fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option, label: &str) -> Result { + let r = s.edit(label, None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + // The state that sets a layer apart: unlocked for Lock, on for the rest. + let active = !matches!(name, "lock" | "locked"); + // Track mattes keep their video off by design. + let mattes: Vec = + if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() }; + let applies = |l: &Layer| match name { + "audio" => l.props.sub("audio").is_some(), + "lock" | "locked" => true, + // Every layer has a Video switch (it hides a null's outline in the viewer and turns a + // camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off + // by design. + "video" | "eye" => !mattes.contains(&l.id), + _ => l.source.is_av() && !mattes.contains(&l.id), + }; + let mut alone = true; + for l in comp.layers.iter().filter(|l| applies(l)) { + let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; + alone &= on == (ids.contains(&l.id) == active); + } + let others = value.unwrap_or(alone == active); + for l in comp.layers.iter_mut().filter(|l| applies(l)) { + let v = if ids.contains(&l.id) { active } else { others }; + set_switch_value(l, name, v); + } + Ok(others) + })?; + Ok(json!(r)) +} + +fn switch_value(l: &Layer, name: &str) -> Option { + let sw = &l.switches; + Some(match name { + "video" | "eye" => sw.video, + "audio" => sw.audio, + "solo" => sw.solo, + "lock" | "locked" => sw.locked, + "shy" => sw.shy, + "collapse" => sw.collapse, + "quality" => sw.quality == Quality::Best, + "fx" | "effects" => sw.effects, + "frameBlend" => sw.frame_blend != FrameBlend::Off, + "motionBlur" => sw.motion_blur, + "adjustment" => sw.adjustment, + "threeD" | "3d" | "3D" => sw.three_d, + "guide" => sw.guide, + "preserveTransparency" => l.preserve_transparency, + _ => return None, + }) +} + +fn set_switch_value(l: &mut Layer, name: &str, v: bool) { + let sw = &mut l.switches; + match name { + "video" | "eye" => sw.video = v, + "audio" => sw.audio = v, + "solo" => sw.solo = v, + "lock" | "locked" => sw.locked = v, + "shy" => sw.shy = v, + "collapse" => sw.collapse = v, + "quality" => sw.quality = if v { Quality::Best } else { Quality::Draft }, + "fx" | "effects" => sw.effects = v, + "frameBlend" => sw.frame_blend = if v { FrameBlend::FrameMix } else { FrameBlend::Off }, + "motionBlur" => sw.motion_blur = v, + "adjustment" => sw.adjustment = v, + "threeD" | "3d" | "3D" => { + if sw.three_d && !v { + drop_3d_values(l); + } + l.switches.three_d = v; + } + "guide" => sw.guide = v, + "preserveTransparency" => l.preserve_transparency = v, + _ => {} + } +} + +/// Turning the 3D switch off discards Z values, Orientation and X/Y Rotation (as in AE). +fn drop_3d_values(l: &mut Layer) { + let Some(tr) = l.transform_mut() else { return }; + for (m, keep) in [("position", 0.0), ("anchor", 0.0), ("scale", 100.0)] { + if let Some(pr) = tr.get_mut(m) { + let flat = |v: &mut KV| { + if let KV::Vec3(a) = v { + a[2] = keep; + } + }; + flat(&mut pr.value); + for k in &mut pr.keys { + flat(&mut k.value); + } + } + } + for (m, zero) in [("orientation", KV::Vec3([0.0; 3])), ("rotationX", KV::Scalar(0.0)), ("rotationY", KV::Scalar(0.0))] { + if let Some(pr) = tr.get_mut(m) { + pr.keys.clear(); + pr.expr = None; + pr.value = zero; + } + } +} + +pub(super) fn rename(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.rename")?; + let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); + s.edit("Rename Layer", None, |proj, _| { + layer_mut(proj, cid, lid)?.name = name.clone(); + Ok(()) + })?; + Ok(Value::Null) +} + +/// The Timeline's Comment column. +pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string(); + s.edit("Layer Comment", None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + l.comment = text.clone(); + } + Ok(()) + })?; + Ok(Value::Null) +} + +pub(super) fn blend(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let mode = str_p(p, "mode").and_then(BlendMode::from_name); + let step = p.get("step").and_then(Value::as_i64).unwrap_or(0); + if mode.is_none() && step == 0 { + return Err(bad("layer.setBlendMode", "need `mode` (e.g. Multiply) or `step` ±1")); + } + s.edit("Blending Mode", None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + l.blend_mode = match mode { + Some(m) => m, + None if step > 0 => l.blend_mode.next(), + None => l.blend_mode.previous(), + }; + } + Ok(()) + })?; + Ok(Value::Null) +} + +pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let matte = match p.get("matte") { + None | Some(Value::Null) => None, + Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?), + }; + let kind = str_p(p, "kind") + .map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "alphainverted" | "alphainv" => MatteKind::AlphaInverted, + "luma" => MatteKind::Luma, + "lumainverted" | "lumainv" => MatteKind::LumaInverted, + _ => MatteKind::Alpha, + }) + .unwrap_or(MatteKind::Alpha); + s.edit("Track Matte", None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + if let Some(m) = matte { + if m == lid { + return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); + } + if let Some(ml) = comp.layer_mut(m) { + ml.switches.video = false; + } + } + comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind }); + Ok(()) + })?; + Ok(Value::Null) +} diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs new file mode 100644 index 000000000..a1f83dc8f --- /dev/null +++ b/crates/engine/src/cmd/layer/timing.rs @@ -0,0 +1,283 @@ +use super::*; + +pub(super) fn timing(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let t = s.time(); + let op = str_p(p, "op").unwrap_or("set").to_string(); + // Layer times stay on frame boundaries of the comp (as in AE). + let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; + let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); + let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); + // Relative trims: each layer moves its own in/out point by the same amount, so dragging an + // edge of several selected layers trims them all together (as in AE). + let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta")); + s.edit("Layer Timing", merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let fd = comp.frame_duration(); + let comp_dur = comp.duration; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + let shift = |l: &mut Layer, d: Tick| { + l.start_time += d; + l.in_point += d; + l.out_point += d; + }; + match op.as_str() { + "moveInToTime" => { + let d = t - l.in_point; + shift(l, d); + } + "moveOutToTime" => { + let d = t - l.out_point; + shift(l, d); + } + "moveInToStart" => { + let d = Tick::ZERO - l.in_point; + shift(l, d); + } + "moveOutToEnd" => { + let d = comp_dur - l.out_point; + shift(l, d); + } + "trimInToTime" => l.in_point = t.min(l.out_point - fd), + "trimOutToTime" => l.out_point = t.max(l.in_point + fd), + _ => { + if let Some(d) = delta { + shift(l, d); + } + if let Some(st) = start { + let d = st - l.start_time; + shift(l, d); + } + if let Some(i) = inp { + l.in_point = i.min(l.out_point - fd); + } + if let Some(o) = outp { + l.out_point = o.max(l.in_point + fd); + } + // Stops at the comp start/end (a point already past it doesn't jump onto + // it), keeping at least one frame of layer. + if let Some(d) = in_delta { + l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd); + } + if let Some(d) = out_delta { + l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd); + } + } + } + } + Ok(()) + })?; + Ok(Value::Null) +} + +/// Move the layers `frames` frames in time (Alt+Page Up / Page Down, with Shift ten frames). +pub(super) fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result { + let (cid, _) = layers_p(s, p)?; + let delta = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate.tick_of(frames).seconds(); + timing(s, &with(p, "delta", json!(delta))) +} + +/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like +/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive +/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and +/// layer markers are in layer time, so they travel with the source. +pub(super) fn slip(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) { + (Some(f), _) => fr.tick_of(f), + (None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)), + _ => return Err(bad("layer.slip", "delta (seconds) or frames required")), + }; + // Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out. + let mut shifts: Vec<(LayerId, Tick)> = Vec::new(); + for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { + if l.props.get("timeRemap").is_some() { + continue; + } + let dur = match &l.source { + LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(f.looped_duration()), + _ => None, + }, + LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration), + _ => None, + }; + let Some(dur) = dur else { continue }; + let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur)); + let (lo, hi) = (a.min(b), a.max(b)); + let (min_d, max_d) = (l.out_point - hi, l.in_point - lo); + if min_d > max_d { + continue; + } + shifts.push((l.id, delta.max(min_d).min(max_d))); + } + if shifts.is_empty() { + return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)")); + } + s.edit("Slip Edit", merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, d) in &shifts { + if let Some(l) = comp.layer_mut(*id) { + l.start_time += *d; + } + } + Ok(()) + })?; + Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::>()})) +} + +pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, "layer.arrange")?; + let how = str_p(p, "to").unwrap_or("front").to_string(); + let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); + // `above`: put the layers right above that one (the timeline's drag to reorder). + let above = match p.get("above") { + Some(v) => { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let a = resolve_layer(comp, v).ok_or_else(|| bad("layer.arrange", "no such layer for `above`"))?; + if ids.contains(&a) { + return Err(bad("layer.arrange", "`above` must be a layer that isn't being moved")); + } + Some(a) + } + None => None, + }; + s.edit("Arrange Layers", None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + // Bring Forward / Send Backward move each layer one step past the next layer that isn't + // moving (a run of selected layers moves as one; one already at the end stays). + if above.is_none() && index.is_none() && matches!(how.as_str(), "forward" | "backward") { + let picked = |l: &Layer| ids.contains(&l.id); + let n = comp.layers.len(); + if how == "forward" { + for i in 1..n { + if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { + let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); + comp.layers[i - 1..run_end].rotate_left(1); + } + } + } else { + for i in (0..n.saturating_sub(1)).rev() { + if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { + let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); + comp.layers[run_start..=i + 1].rotate_right(1); + } + } + } + return Ok(()); + } + let mut picked: Vec = Vec::new(); + comp.layers.retain(|l| { + if ids.contains(&l.id) { + picked.push(l.clone()); + false + } else { + true + } + }); + let at = match (how.as_str(), index) { + _ if above.is_some() => comp.layers.iter().position(|l| Some(l.id) == above).unwrap_or(comp.layers.len()), + (_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()), + ("back", _) => comp.layers.len(), + _ => 0, + }; + for (k, l) in picked.into_iter().enumerate() { + comp.layers.insert((at + k).min(comp.layers.len()), l); + } + Ok(()) + })?; + Ok(Value::Null) +} + +/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition") +/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage, +/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's +/// transform, masks, effects and timing in the current comp. `adjustDuration` (move) trims the +/// new comp to the selected layers' span; `open` opens the new comp. +pub(super) fn precompose(s: &mut Session, p: &Value) -> Result { + let c = "layer.precompose"; + let (cid, ids) = layers_p(s, p)?; + if ids.is_empty() { + return Err(bad(c, "no layers")); + } + let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string(); + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let leave = match str_p(p, "mode").unwrap_or("move") { + "move" => false, + "leave" => true, + x => return Err(bad(c, format!("mode: move|leave, got `{x}`"))), + }; + let adjust = b_p(p, "adjustDuration").unwrap_or(false); + if leave { + let [lid] = ids[..] else { return Err(bad(c, "\"Leave all attributes\" needs exactly one layer")) }; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); + let Some(src_item) = layer.source.item() else { + return Err(bad(c, "\"Leave all attributes\" is available for footage, solid and precomp layers only")); + }; + let (w, h) = effectcraft_render::source_size(&s.project, &layer); + let (w, h) = if w == 0 || h == 0 { (comp.width, comp.height) } else { (w, h) }; + let dur = match s.project.item(src_item).map(|i| &i.kind) { + Some(ItemKind::Comp(nc)) => nc.duration, + Some(ItemKind::Footage(f)) if f.duration > Tick(0) => f.duration, + _ => comp.duration, + }; + let new = s.edit("Pre-compose", None, |proj, st| { + let mut inner = comp.nested_like(w, h, dur); + let mut il = build::layer(proj, &inner, &layer.name, layer.source.clone(), (w, h), Some(dur)); + il.name = layer.name.clone(); + inner.layers.push(il); + let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into())); + let l = layer_mut(proj, cid, lid)?; + l.source = LayerSource::Comp { item: iid }; + l.name = name.clone(); + st.selected_layers = vec![lid]; + Ok((iid, lid)) + })?; + if b_p(p, "open").unwrap_or(false) { + s.open_comp(new.0); + } + return Ok(json!({"comp": new.0.0, "layer": new.1.0})); + } + let span = { + let sel: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| ids.contains(&l.id)).collect(); + let a = sel.iter().map(|l| l.in_point).min().unwrap_or(Tick(0)).max(Tick(0)); + let b = sel.iter().map(|l| l.out_point).max().unwrap_or(comp.duration).min(comp.duration); + (a, b) + }; + let (offset, dur) = if adjust && span.1 > span.0 { (span.0, span.1 - span.0) } else { (Tick(0), comp.duration) }; + let new = s.edit("Pre-compose", None, |proj, st| { + let mut inner = comp.nested_like(comp.width, comp.height, dur); + inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); + for l in &mut inner.layers { + if l.parent.is_some_and(|p| !ids.contains(&p)) { + l.parent = None; + } + if l.track_matte.is_some_and(|m| !ids.contains(&m.layer)) { + l.track_matte = None; + } + l.start_time -= offset; + l.in_point -= offset; + l.out_point -= offset; + } + let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(dur)); + l.name = name.clone(); + l.start_time = offset; + l.in_point = offset; + l.out_point = offset + dur; + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); + c.layers.retain(|l| !ids.contains(&l.id)); + let lid = l.id; + c.layers.insert(at.min(c.layers.len()), l); + st.selected_layers = vec![lid]; + Ok((iid, lid)) + })?; + if b_p(p, "open").unwrap_or(false) { + s.open_comp(new.0); + } + Ok(json!({"comp": new.0.0, "layer": new.1.0})) +} diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs new file mode 100644 index 000000000..6612a2d07 --- /dev/null +++ b/crates/engine/src/cmd/layer/transform.rs @@ -0,0 +1,191 @@ +use super::create::{color_p, pixel_aspect_p}; +use super::*; + +/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well +/// as the combined property the channels were split from. +pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) { + if dx == 0.0 && dy == 0.0 { + return; + } + if tr.get("positionX").is_some() { + for (name, d) in [("positionX", dx), ("positionY", dy)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(pr.value.as_f64() + d); + for k in &mut pr.keys { + k.value = KV::Scalar(k.value.as_f64() + d); + } + } + } + } + if let Some(pr) = tr.get_mut("position") { + let shift = |v: &KV| match v { + KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]), + other => { + let a = other.as_vec3(); + KV::Vec3([a[0] + dx, a[1] + dy, a[2]]) + } + }; + pr.value = shift(&pr.value); + for k in &mut pr.keys { + k.value = shift(&k.value); + } + } +} + +/// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions +/// are separated. +pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { + const SEPARATE: [&str; 3] = ["positionX", "positionY", "positionZ"]; + if tr.get("positionX").is_some() { + let v = f(SEPARATE.map(|m| tr.get(m).map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0))); + for (m, x) in SEPARATE.iter().zip(v) { + if let Some(pr) = tr.get_mut(m) { + pr.set_value_at(lt, KV::Scalar(x)); + } + } + } else if let Some(pr) = tr.get_mut("position") { + let v = f(pr.value_at(lt).as_vec3()); + pr.set_value_at(lt, KV::Vec3(v)); + } +} + +/// A Transform property's default (Reset) by match id: the anchor at the centre of the source +/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation. +pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option { + Some(match m { + "anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]), + "position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]), + "positionX" => KV::Scalar(cw / 2.0), + "positionY" => KV::Scalar(ch / 2.0), + "positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0), + "scale" => KV::Vec3([100.0; 3]), + "orientation" => KV::Vec3([0.0; 3]), + "opacity" => KV::Scalar(100.0), + _ => return None, + }) +} + +pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, "layer.transform")?; + let op = str_p(p, "op").unwrap_or("reset").to_string(); + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let t = s.time(); + let sizes: Vec<(LayerId, (u32, u32))> = + comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); + s.edit("Transform", None, |proj, _| { + for (lid, (w, h)) in &sizes { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + let (cw, ch) = (comp.width as f64, comp.height as f64); + let (w, h) = (*w as f64, *h as f64); + let Some(tr) = l.transform_mut() else { continue }; + let set = |tr: &mut PropGroup, m: &str, v: KV| { + if let Some(pr) = tr.get_mut(m) { + pr.set_value_at(lt, v); + } + }; + match op.as_str() { + "reset" => { + for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] { + if let Some(v) = transform_default(m, (w, h), (cw, ch)) { + set(tr, m, v); + } + } + update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]); + } + // Centred in the frame; a 3D layer keeps its depth. + "center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]), + "fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => { + let (sx, sy) = match op.as_str() { + "fitWidth" => (cw / w, cw / w), + "fitHeight" => (ch / h, ch / h), + _ => (cw / w, ch / h), + }; + let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); + let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); + set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); + update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); + set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); + } + "flipH" | "flipV" => { + let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); + let v = if op == "flipH" { [-cur[0], cur[1], cur[2]] } else { [cur[0], -cur[1], cur[2]] }; + set(tr, "scale", KV::Vec3(v)); + } + _ => {} + } + } + Ok(()) + })?; + Ok(Value::Null) +} + +/// Layer ▸ Layer Settings: cameras and lights open their own settings; solids (and adjustment +/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other +/// layers also use is copied into a new solid for this layer instead of being changed for all of +/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and +/// its solid. +pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.settings")?; + // Cameras and lights open their own settings (Camera Settings / Light Settings). + match s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.source.clone()) { + Some(LayerSource::Camera) => return s.execute("layer.cameraSettings", with_layer(p, lid)), + Some(LayerSource::Light { .. }) => return s.execute("layer.lightSettings", with_layer(p, lid)), + _ => {} + } + let color = color_p(p, "color"); + let w = p.get("width").and_then(Value::as_u64).map(limits::size); + let h = p.get("height").and_then(Value::as_u64).map(limits::size); + let pixel_aspect = pixel_aspect_p(p)?; + let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); + let solid = s.edit("Layer Settings", None, |proj, _| { + let src = layer_mut(proj, cid, lid)?.source.clone(); + if let Some(n) = &name { + layer_mut(proj, cid, lid)?.name = n.clone(); + } + let LayerSource::Solid { item } = src else { return Ok(None) }; + let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; + let target = if shared && !affect_all { + // This layer gets its own copy of the solid. + let it = proj.item(item).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?.clone(); + let copy = proj.add_item(name.as_deref().unwrap_or(&it.name), it.label, it.parent, it.kind); + layer_mut(proj, cid, lid)?.source = LayerSource::Solid { item: copy }; + copy + } else { + item + }; + let it = proj.item_mut(target).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?; + if let Some(n) = &name { + it.name = n.clone(); + } + if let ItemKind::Solid(so) = &mut it.kind { + so.color = color.unwrap_or(so.color); + so.width = w.unwrap_or(so.width); + so.height = h.unwrap_or(so.height); + so.pixel_aspect = pixel_aspect.unwrap_or(so.pixel_aspect); + } + Ok(Some(target)) + })?; + Ok(match solid { + Some(item) => json!({"layer": lid.0, "solid": item.0}), + None => json!({"layer": lid.0}), + }) +} + +/// Layer Settings applies to solids (and adjustment layers), nulls, cameras and lights. +pub(super) fn has_layer_settings(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + let l = s.active_comp().zip(s.state.selected_layers.first()).and_then(|(c, l)| c.layer(*l)).ok_or("select a layer first")?; + match l.source { + LayerSource::Solid { .. } | LayerSource::Null | LayerSource::Camera | LayerSource::Light { .. } => Ok(()), + _ => Err("this kind of layer has no settings".into()), + } +} + +fn with_layer(p: &Value, lid: LayerId) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["layer"] = json!(lid.0); + p +} diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/cmd/layer_menu.rs similarity index 96% rename from crates/engine/src/commands/layer_menu.rs rename to crates/engine/src/cmd/layer_menu.rs index 9cac80412..33d17bb4f 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/cmd/layer_menu.rs @@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { fn hide_other_video(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Hide Other Video", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() { - if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) { - l.switches.video = false; - } + // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's + // eye cell shows): the selection stays shown. + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + l.switches.video = false; } Ok(()) })?; @@ -708,7 +708,7 @@ const BUNDLE_EXTENSIONS: &[&str] = &[ /// Whether the system file manager would open folder `dir` as something other than a folder: a /// bundle (`Tool.app`), or a Windows folder with a `{CLSID}` extension. fn opens_as_bundle(dir: &std::path::Path) -> bool { - let ext = dir.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); + let ext = dir.extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str()) } @@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { } fn reveal_source(s: &mut Session, p: &Value) -> Result { - let item = source_item(s, p, "layer.revealSource")?; - s.state.project_selection = vec![item]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": item.0})) + let (cid, ids) = layers_p(s, p)?; + let mut items: Vec = Vec::new(); + { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for layer in ids.iter().filter_map(|id| comp.layer(*id)) { + if let Some(item) = layer.source.item() + && s.project.item(item).is_some() + && !items.contains(&item) + { + items.push(item); + } + } + } + if items.is_empty() { + return Err(bad("layer.revealSource", "the selected layers have no source item")); + } + s.state.project_selection = items.clone(); + let raw: Vec = items.iter().map(|item| item.0).collect(); + frontend(s, "project.reveal", &json!({"items": raw}))?; + Ok(json!({"item": items[0].0, "items": raw})) } fn reveal_comp(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.state.project_selection = vec![cid]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": cid.0})) + frontend(s, "project.reveal", &json!({"items": [cid.0]}))?; + Ok(json!({"item": cid.0, "items": [cid.0]})) } impl Session { @@ -909,7 +925,7 @@ pub fn specs() -> Vec { cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op), cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source), cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder), - cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source), + cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source), cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp), cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors), cmd!( diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/cmd/layer_time.rs similarity index 100% rename from crates/engine/src/commands/layer_time.rs rename to crates/engine/src/cmd/layer_time.rs diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/cmd/link.rs similarity index 100% rename from crates/engine/src/commands/link.rs rename to crates/engine/src/cmd/link.rs diff --git a/crates/engine/src/commands/liquify.rs b/crates/engine/src/cmd/liquify.rs similarity index 100% rename from crates/engine/src/commands/liquify.rs rename to crates/engine/src/cmd/liquify.rs diff --git a/crates/engine/src/commands/lottie.rs b/crates/engine/src/cmd/lottie.rs similarity index 100% rename from crates/engine/src/commands/lottie.rs rename to crates/engine/src/cmd/lottie.rs diff --git a/crates/engine/src/commands/markers.rs b/crates/engine/src/cmd/markers.rs similarity index 100% rename from crates/engine/src/commands/markers.rs rename to crates/engine/src/cmd/markers.rs diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/cmd/mask.rs similarity index 100% rename from crates/engine/src/commands/mask.rs rename to crates/engine/src/cmd/mask.rs diff --git a/crates/engine/src/commands/mask_interp.rs b/crates/engine/src/cmd/mask_interp.rs similarity index 100% rename from crates/engine/src/commands/mask_interp.rs rename to crates/engine/src/cmd/mask_interp.rs diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/cmd/model3d.rs similarity index 100% rename from crates/engine/src/commands/model3d.rs rename to crates/engine/src/cmd/model3d.rs diff --git a/crates/engine/src/commands/nav_more.rs b/crates/engine/src/cmd/nav_more.rs similarity index 100% rename from crates/engine/src/commands/nav_more.rs rename to crates/engine/src/cmd/nav_more.rs diff --git a/crates/engine/src/commands/paint.rs b/crates/engine/src/cmd/paint.rs similarity index 99% rename from crates/engine/src/commands/paint.rs rename to crates/engine/src/cmd/paint.rs index 73f600b56..c02a1849d 100644 --- a/crates/engine/src/commands/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -107,7 +107,7 @@ impl Default for PaintOptions { } fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() + s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() } /// Index of `v` in `names` (by number or case/space-insensitive name). diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/cmd/panels_cmds.rs similarity index 100% rename from crates/engine/src/commands/panels_cmds.rs rename to crates/engine/src/cmd/panels_cmds.rs diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs new file mode 100644 index 000000000..296ab6f6f --- /dev/null +++ b/crates/engine/src/cmd/params.rs @@ -0,0 +1,199 @@ +use super::*; + +/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]). +pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result { + s.events.push(crate::Event::Frontend { command: id.to_string(), params: p.clone() }); + Ok(serde_json::json!({"frontend": id})) +} + +/// Match path of a node (`transform/position`, `effects/#2/blurriness` style with `match#n` +/// occurrences) so it can be found again on another layer. +pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_project::Uid) -> Option { + for c in &g.children { + let nth = g.children.iter().take_while(|x| !std::ptr::eq(*x, c)).filter(|x| x.match_id() == c.match_id()).count() + 1; + let seg = if nth == 1 { c.match_id().to_string() } else { format!("{}#{nth}", c.match_id()) }; + if c.uid() == uid { + return Some(seg); + } + if let effectcraft_project::Node::Group(sub) = c + && let Some(rest) = match_path_of(sub, uid) + { + return Some(format!("{seg}/{rest}")); + } + } + None +} + +/// Turn `effects/[#n]/` (as [`match_path_of`] writes it) into a uid path on +/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart. +/// Any other path, or an effect that is not on the layer, comes back unchanged. +pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String { + let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; + let (match_id, nth) = match seg.rsplit_once('#') { + Some((m, n)) if !m.is_empty() => match n.parse::() { + Ok(n) if n >= 1 => (m, n), + _ => (seg, 1), + }, + _ => (seg, 1), + }; + match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) { + Some(g) => format!("effects/@{}/{tail}", g.uid), + None => path.to_string(), + } +} + +/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to +/// their properties. +pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> { + let Some(comp) = s.active_comp() else { return vec![] }; + let mut out = vec![]; + for (lid, uid) in &s.state.selected_props { + let Some(l) = comp.layer(*lid) else { continue }; + if l.props.find(*uid).is_some() { + out.push((*lid, *uid)); + } else if let Some(g) = l.props.find_group(*uid) { + g.walk("", &mut |_, p| out.push((*lid, p.uid))); + } + } + out +} + +// ---------- params ---------- + +pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +pub(crate) fn f_p(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_f64) +} + +pub(crate) fn b_p(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_bool) +} + +pub(crate) fn merge_p(p: &Value) -> Option<&str> { + str_p(p, "merge") +} + +/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP +/// clients often send numbers as strings); default the active comp. +pub(crate) fn comp_id(s: &Session, p: &Value) -> Result { + let missing = |v: &Value| EngineError::NoSuchComp(v.to_string()); + match p.get("comp") { + Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)), + Some(v @ Value::String(name)) => s + .project + .items + .values() + .find(|i| &i.name == name && i.as_comp().is_some()) + .map(|i| i.id) + .or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some())) + .ok_or_else(|| missing(v)), + _ => s.active_comp_id().ok_or(EngineError::NoComp), + } +} + +/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`. +fn digits_id(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string +/// that names no layer is resolved as the number it spells. +pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option { + match v { + Value::Number(n) => { + let id = n.as_u64()?; + if comp.layer(LayerId(id)).is_some() { Some(LayerId(id)) } else { comp.layers.get((id as usize).checked_sub(1)?).map(|l| l.id) } + } + Value::String(s) => { + if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::().ok()) { + return comp.layers.get(i.checked_sub(1)?).map(|l| l.id); + } + comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id)))) + } + _ => None, + } +} + +/// `layer` param or the first selected layer. +pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId)> { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + if let Some(v) = p.get("layer") { + return resolve_layer(comp, v).map(|l| (cid, l)).ok_or_else(|| bad(cmd, format!("no layer {v}"))); + } + s.state + .selected_layers + .first() + .copied() + .filter(|l| comp.layer(*l).is_some()) + .map(|l| (cid, l)) + .ok_or_else(|| bad(cmd, "no `layer` given and no layer selected")) +} + +/// `layers` param (array) or the selection. +pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + // Explicit references that match no layer are an error (agents otherwise get a silent no-op). + let resolve = + |v: &Value| resolve_layer(comp, v).ok_or_else(|| EngineError::Other(format!("no layer {v} (use a layer id, \"#n\" or a name from comp.info)"))); + if let Some(Value::Array(a)) = p.get("layers") { + return Ok((cid, a.iter().map(resolve).collect::>()?)); + } + if let Some(v) = p.get("layer").filter(|v| !v.is_null()) { + return Ok((cid, vec![resolve(v)?])); + } + Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) +} + +/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not +/// locked, and not hidden by the composition's Hide Shy Layers. +pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool { + !(l.switches.locked || (comp.hide_shy && l.switches.shy)) +} + +/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects. +/// An error when every one of them is locked. +pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec, cmd: &str) -> Result> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked); + match ids.iter().find(|id| locked(id)) { + Some(first) if ids.iter().all(locked) => { + let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default(); + Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)"))) + } + _ => Ok(ids.into_iter().filter(|id| !locked(id)).collect()), + } +} + +/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All +/// Keyframes use only those, as in After Effects). `None` when not given. +pub(crate) fn visible_p(p: &Value) -> Option> { + let a = p.get("visible")?.as_array()?; + Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect()) +} + +/// `time` (seconds) or `frame` param, else the CTI. +pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { + if let Some(t) = f_p(p, "time") { + return Tick::from_seconds_f64(t); + } + if let (Some(f), Some(c)) = (p.get("frame").and_then(Value::as_i64), comp) { + return c.frame_rate.tick_of(f); + } + s.time() +} + +pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { + p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) +} diff --git a/crates/engine/src/commands/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs similarity index 100% rename from crates/engine/src/commands/path_nulls.rs rename to crates/engine/src/cmd/path_nulls.rs diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/cmd/paths.rs similarity index 100% rename from crates/engine/src/commands/paths.rs rename to crates/engine/src/cmd/paths.rs diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/cmd/project_items.rs similarity index 100% rename from crates/engine/src/commands/project_items.rs rename to crates/engine/src/cmd/project_items.rs diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/cmd/prop.rs similarity index 98% rename from crates/engine/src/commands/prop.rs rename to crates/engine/src/cmd/prop.rs index 3af877d34..00232967e 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -22,8 +22,11 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay }); } let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?; - let pr = layer.props.prop(path).ok_or_else(|| { - let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); + // `effects/[#n]/` is how `essential.list`, presets and Copy write an + // effect property; turn it into the uid path `PropGroup::prop` parses (#2064). + let resolved = super::resolve_effect_path(layer, path); + let pr = layer.props.prop(&resolved).ok_or_else(|| { + let group = layer.props.group(&resolved).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); if group.is_some() { bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property")) } else { @@ -179,7 +182,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: & pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value { let ct = layer.comp_time(t); let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()}); - if !layer.is_active_at(ct) { + // A key exactly on the out point ends a fade normally: it is outside the half-open visible + // range, but it is not a caller mistake, so it does not warn (#2084). + if ct < layer.in_point || ct > layer.out_point { out["warning"] = json!(format!( "Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).", ct.seconds(), diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/cmd/prop_groups.rs similarity index 100% rename from crates/engine/src/commands/prop_groups.rs rename to crates/engine/src/cmd/prop_groups.rs diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/cmd/proxy.rs similarity index 100% rename from crates/engine/src/commands/proxy.rs rename to crates/engine/src/cmd/proxy.rs diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/cmd/puppet.rs similarity index 100% rename from crates/engine/src/commands/puppet.rs rename to crates/engine/src/cmd/puppet.rs diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/cmd/query.rs similarity index 100% rename from crates/engine/src/commands/query.rs rename to crates/engine/src/cmd/query.rs diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/cmd/quick_offset.rs similarity index 100% rename from crates/engine/src/commands/quick_offset.rs rename to crates/engine/src/cmd/quick_offset.rs diff --git a/crates/engine/src/commands/relink.rs b/crates/engine/src/cmd/relink.rs similarity index 99% rename from crates/engine/src/commands/relink.rs rename to crates/engine/src/cmd/relink.rs index 2d7c2ea13..d899decbe 100644 --- a/crates/engine/src/commands/relink.rs +++ b/crates/engine/src/cmd/relink.rs @@ -103,7 +103,7 @@ fn run(s: &mut Session, p: &Value) -> Result { } let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio) && old.layer.is_none() && old.page == 0; - let name = Path::new(&old.path).file_name().and_then(|n| n.to_str()).unwrap_or(""); + let name = Path::new(&old.path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); let paths = index.get(name).cloned().unwrap_or_default(); let mut candidates = vec![]; let mut accepted = vec![]; diff --git a/crates/engine/src/commands/relink_moved.rs b/crates/engine/src/cmd/relink_moved.rs similarity index 100% rename from crates/engine/src/commands/relink_moved.rs rename to crates/engine/src/cmd/relink_moved.rs diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/cmd/render_queue.rs similarity index 100% rename from crates/engine/src/commands/render_queue.rs rename to crates/engine/src/cmd/render_queue.rs diff --git a/crates/engine/src/commands/rig3d.rs b/crates/engine/src/cmd/rig3d.rs similarity index 100% rename from crates/engine/src/commands/rig3d.rs rename to crates/engine/src/cmd/rig3d.rs diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/cmd/roto_cmds.rs similarity index 100% rename from crates/engine/src/commands/roto_cmds.rs rename to crates/engine/src/cmd/roto_cmds.rs diff --git a/crates/engine/src/commands/scene_detect.rs b/crates/engine/src/cmd/scene_detect.rs similarity index 100% rename from crates/engine/src/commands/scene_detect.rs rename to crates/engine/src/cmd/scene_detect.rs diff --git a/crates/engine/src/cmd/schema.rs b/crates/engine/src/cmd/schema.rs new file mode 100644 index 000000000..11e2ef9f2 --- /dev/null +++ b/crates/engine/src/cmd/schema.rs @@ -0,0 +1,119 @@ +use super::params::bad; +use super::*; + +// ---------- parameter validation (agents) ---------- + +/// Top-level keys accepted by a params doc such as `{layers: [id|name|#n], add?, toggle?}` or +/// `{time? (s) | frame? | timecode?}`; `None` when the doc isn't a `{…}` key list. +pub fn accepted_params(doc: &str) -> Option> { + let body = doc.trim().strip_prefix('{')?; + // Top-level pieces up to the matching close brace, split at `,` and `|`; `:` starts a value. + let (mut keys, mut cur, mut depth, mut in_value) = (Vec::new(), String::new(), 0i32, false); + let push = |cur: &mut String, keys: &mut Vec| { + let k: String = cur.trim().chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + if !k.is_empty() && !keys.contains(&k) { + keys.push(k); + } + cur.clear(); + }; + for c in body.chars() { + match c { + '{' | '[' | '(' => depth += 1, + ']' | ')' => depth -= 1, + '}' if depth == 0 => { + push(&mut cur, &mut keys); + return Some(keys); + } + '}' => depth -= 1, + ',' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = false; + } + '|' if depth == 0 && !in_value => push(&mut cur, &mut keys), + ':' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = true; + } + _ if depth == 0 && !in_value => cur.push(c), + _ => {} + } + } + None +} + +/// Top-level pieces of a params doc (split at depth-0 commas), e.g. `{layer?, path|prop, value}` +/// → `["layer?", "path|prop", "value"]`. +fn doc_pieces(doc: &str) -> Option> { + let body = doc.trim().strip_prefix('{')?; + let (mut out, mut cur, mut depth) = (Vec::new(), String::new(), 0i32); + for c in body.chars() { + match c { + '{' | '[' | '(' => depth += 1, + ']' | ')' => depth -= 1, + '}' if depth == 0 => { + if !cur.trim().is_empty() { + out.push(cur.trim().to_string()); + } + return Some(out); + } + '}' => depth -= 1, + ',' if depth == 0 => { + if !cur.trim().is_empty() { + out.push(cur.trim().to_string()); + } + cur.clear(); + continue; + } + _ => {} + } + cur.push(c); + } + None +} + +/// The JSON Schema of a command's parameters, derived from its params doc: one property per +/// accepted key (described by its piece of the doc), no others. MCP `describe_command` and +/// `list_commands {schemas: true}` serve it. +pub fn params_schema(spec: &CommandSpec) -> Value { + let mut props = serde_json::Map::new(); + let mut required = vec![]; + for piece in doc_pieces(spec.params).unwrap_or_default() { + let keys = accepted_params(&format!("{{{piece}}}")).unwrap_or_default(); + let alternatives = keys.len() > 1; + for k in keys { + // `key?` is optional; so is any key with alternatives (`path|prop`). + let optional = alternatives || piece.contains(&format!("{k}?")); + if !optional && !required.contains(&k) { + required.push(k.clone()); + } + props.insert(k, serde_json::json!({"description": piece})); + } + } + serde_json::json!({ + "type": "object", + "description": spec.params, + "properties": props, + "required": required, + "additionalProperties": accepted_params(spec.params).is_none(), + }) +} + +/// Keys every command understands (comp targeting, gesture merging) and accepted aliases. +const ALWAYS_OK: &[&str] = &["comp", "merge"]; + +const ALIASES: &[(&str, &str)] = &[("layers", "layer"), ("layer", "layers"), ("time", "frame"), ("frameRate", "fps"), ("properties", "property")]; + +/// Reject unknown top-level parameters (typos, guessed names) with the list of accepted keys. +pub fn check_params(spec: &CommandSpec, params: &Value) -> Result<()> { + let (Some(obj), Some(accepted)) = (params.as_object(), accepted_params(spec.params)) else { return Ok(()) }; + let ok = |k: &str| ALWAYS_OK.contains(&k) || accepted.iter().any(|a| a == k || ALIASES.iter().any(|(doc, alias)| doc == a && *alias == k)); + let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect(); + if unknown.is_empty() { + return Ok(()); + } + let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") }; + Err(bad( + spec.id, + format!("unknown parameter(s) {}; accepted: {list} (doc: {})", unknown.iter().map(|k| format!("`{k}`")).collect::>().join(", "), spec.params), + )) +} diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/cmd/scripts.rs similarity index 99% rename from crates/engine/src/commands/scripts.rs rename to crates/engine/src/cmd/scripts.rs index 24049459c..d352e636c 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -234,7 +234,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } -/// Run an installed or bundled script by name (File ▸ Scripts ▸ ). A ScriptUI panel run +/// Run an installed or bundled script by name (File ▸ Scripts ▸ ``). A ScriptUI panel run /// this way gets `this` = a floating palette-like panel window. pub(crate) fn run_named(s: &mut Session, name: &str) -> Result { let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?; @@ -308,7 +308,7 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } -/// Window ▸ : run the panel script (or bring its panel forward). +/// Window ▸ ``: run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/cmd/settings.rs similarity index 100% rename from crates/engine/src/commands/settings.rs rename to crates/engine/src/cmd/settings.rs diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/cmd/shape_stroke.rs similarity index 100% rename from crates/engine/src/commands/shape_stroke.rs rename to crates/engine/src/cmd/shape_stroke.rs diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs similarity index 100% rename from crates/engine/src/commands/shape_tool.rs rename to crates/engine/src/cmd/shape_tool.rs diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/cmd/stubs.rs similarity index 100% rename from crates/engine/src/commands/stubs.rs rename to crates/engine/src/cmd/stubs.rs diff --git a/crates/engine/src/commands/styles.rs b/crates/engine/src/cmd/styles.rs similarity index 100% rename from crates/engine/src/commands/styles.rs rename to crates/engine/src/cmd/styles.rs diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/cmd/text_anim.rs similarity index 100% rename from crates/engine/src/commands/text_anim.rs rename to crates/engine/src/cmd/text_anim.rs diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/cmd/text_edit.rs similarity index 55% rename from crates/engine/src/commands/text_edit.rs rename to crates/engine/src/cmd/text_edit.rs index 5dcb6d09a..0d9b25aec 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -6,15 +6,64 @@ //! Positions are character indices. The selection is `anchor..caret` (either order). use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV}; -use effectcraft_project::{LayerId, LayerSource}; +use effectcraft_project::{ItemId, LayerId, LayerSource}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +/// Session tokens for the on-canvas editor, unique across the process (so a recreated session +/// never reuses a live IME context). Exhaustion wraps; it cannot panic. +static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// Takes the next session token: returns the counter's current value and increments it, so +/// successive tokens differ until the process-wide `u64` counter wraps. +pub(super) fn next_edit_session() -> u64 { + NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +/// Process-global checked high-water allocator for [`crate::Session::ime_revision`] stamps. +/// +/// egui's unavailable-IME receipt identity must be process-monotonic. Allocating the stamp from +/// one process-global counter means neither a second session nor a rollback can reuse a value. +/// The counter fails closed at `u64::MAX`: it returns `None` without wrapping, and callers keep +/// their existing stamp. An unavailable before/after pair then compares equal, so it earns no +/// receipt rather than claiming a mutation with a reused revision. +static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Allocates a fresh process-wide IME revision, or `None` once the counter has nowhere left +/// to advance; callers keep their existing stamp rather than reuse one. +pub(crate) fn next_ime_revision() -> Option { + allocate_ime_revision(&NEXT_IME_REVISION) +} + +/// Takes the next value of `counter` with a compare-exchange loop: `Some(current + 1)` after +/// storing it, or `None` without storing when the counter is already `u64::MAX`. +fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option { + use std::sync::atomic::Ordering::Relaxed; + let mut current = counter.load(Relaxed); + loop { + let next = current.checked_add(1)?; + match counter.compare_exchange_weak(current, next, Relaxed, Relaxed) { + Ok(_) => return Some(next), + Err(observed) => current = observed, + } + } +} + +#[test] +fn ime_revision_allocation_stops_at_exhaustion_without_reusing_a_stamp() { + use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + let counter = AtomicU64::new(u64::MAX - 1); + assert_eq!(allocate_ime_revision(&counter), Some(u64::MAX)); + assert_eq!(allocate_ime_revision(&counter), None); + assert_eq!(allocate_ime_revision(&counter), None); + assert_eq!(counter.load(Relaxed), u64::MAX); +} + use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p}; use crate::{EditorState, EngineError, Result, Session, cmd}; /// An on-canvas text editing session. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextEdit { pub layer: LayerId, pub anchor: usize, @@ -28,6 +77,25 @@ pub struct TextEdit { /// Line position kept by repeated up / down moves (layout space). #[serde(default)] pub goal_x: Option, + /// This editing session's identity. + /// + /// It changes when the editor is started and when its layer or document is replaced, by + /// undo/redo, a history jump, a rollback, or a time change showing another document. A + /// restore that keeps the same composition, layer and byte/style-equal document (an opacity + /// undo while editing) keeps it, so an active IME composition survives an unrelated change. + /// It is deliberately not serialized: a saved token belongs to a previous process and + /// cannot identify a new target. The IME owner uses it as its replacement epoch, so an old + /// composition is never inherited by a recreated layer or a replaced document. + #[serde(skip, default = "next_edit_session")] + pub session: u64, +} + +impl Default for TextEdit { + /// An empty, uncreated editing session on layer `0` with no selection, pending style or goal + /// column, and a fresh process-wide session token. + fn default() -> Self { + Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() } + } } impl TextEdit { @@ -49,6 +117,139 @@ pub fn layer_doc(s: &Session, lid: LayerId) -> Option { } } +/// The largest committed-text window an IME observation copies. +/// +/// This mirrors `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`: a selection wider than this is +/// reported as unavailable rather than silently truncated. +pub const MAX_IME_TEXT_BYTES: usize = 4000; + +/// The editor's committed-text revision for an IME receipt whose surrounding text is +/// unavailable (a selection wider than [`MAX_IME_TEXT_BYTES`]). +/// +/// Unlike [`Session::revision`], unrelated project edits (an opacity undo) do not advance it, so +/// they cannot make an active composition look stale. +pub fn ime_stamp_revision(s: &Session) -> u64 { + s.ime_revision +} + +/// Bounded committed text around an IME selection, with UTF-8 byte offsets. +/// +/// The Type tool keeps the input method's pre-edit in egui's temporary data, never in the +/// layer's [`TextDoc`], so [`Self::text`] is committed text only. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeSurrounding { + /// At most [`MAX_IME_TEXT_BYTES`] bytes of the edited layer's text. + pub text: String, + /// The caret (primary selection end) as a UTF-8 byte offset in [`Self::text`]. + pub cursor: usize, + /// The other selection end; larger than `cursor` for a reversed selection. + pub anchor: usize, +} + +impl ImeSurrounding { + /// A bounded window of `text` around the selection `cursor`/`anchor`. + /// + /// Returns `None` when an offset is past the text or inside a character, or when the + /// selection itself is wider than [`MAX_IME_TEXT_BYTES`]: the caller then reports + /// surrounding text as unavailable instead of sending a truncated selection. + pub fn from_text(text: &str, cursor: usize, anchor: usize) -> Option { + if cursor > text.len() || anchor > text.len() || !text.is_char_boundary(cursor) || !text.is_char_boundary(anchor) { + return None; + } + let (start, end) = (cursor.min(anchor), cursor.max(anchor)); + let selection = end - start; + if selection > MAX_IME_TEXT_BYTES { + return None; + } + // Spend the remaining budget on both sides of the selection, giving unused room on one + // side to the other. Only this bounded window is copied. + let budget = MAX_IME_TEXT_BYTES - selection; + let mut before = (budget / 2).min(start); + let mut after = (budget - before).min(text.len() - end); + before = (budget - after).min(start); + after = (budget - before).min(text.len() - end); + let mut window_start = start - before; + let mut window_end = end + after; + // Byte budgets can land inside a multi-byte character: shrink the window so it never + // splits one and the selection stays inside it. + while window_start < start && !text.is_char_boundary(window_start) { + window_start += 1; + } + while end < window_end && !text.is_char_boundary(window_end) { + window_end -= 1; + } + Some(Self { + text: text.get(window_start..window_end)?.to_owned(), + cursor: cursor.checked_sub(window_start)?, + anchor: anchor.checked_sub(window_start)?, + }) + } +} + +/// The edited layer's committed text around its selection, or `None` when there is no edit +/// session, the layer is gone, or the selection is wider than [`MAX_IME_TEXT_BYTES`]. +/// +/// The caret and the anchor are converted to UTF-8 byte offsets separately, so a reversed +/// selection stays reversed. A valid empty document is `Some` with empty text, never `None`. +pub fn ime_surrounding(s: &Session) -> Option { + let e = s.state.text_edit.as_ref()?; + let comp = s.active_comp()?; + let layer = comp.layer(e.layer)?; + let prop = layer.props.prop("text/sourceText")?; + // A static Source Text (the normal case) is borrowed in place; only an animated one has to + // be evaluated into a temporary. Either way only the bounded window is copied. + if !prop.is_animated() { + return match &prop.value { + KV::Text(doc) => { + let (caret, anchor) = doc_bytes(doc, e.caret, e.anchor); + ImeSurrounding::from_text(&doc.text, caret, anchor) + } + KV::Str(text) => { + let (caret, anchor) = char_bytes(text, e.caret, e.anchor); + ImeSurrounding::from_text(text, caret, anchor) + } + _ => None, + }; + } + match prop.value_at(layer.layer_time(s.time())) { + KV::Text(doc) => { + let (caret, anchor) = doc_bytes(&doc, e.caret, e.anchor); + ImeSurrounding::from_text(&doc.text, caret, anchor) + } + KV::Str(text) => { + let (caret, anchor) = char_bytes(&text, e.caret, e.anchor); + ImeSurrounding::from_text(&text, caret, anchor) + } + _ => None, + } +} + +/// Byte offsets of two character positions in `text` (clamped to its end), scanning once. +/// +/// The second position may be before the first; the returned pair keeps the caller's order. +fn char_bytes(text: &str, first: usize, second: usize) -> (usize, usize) { + let (lo, hi) = if first <= second { (first, second) } else { (second, first) }; + let mut lo_byte = if lo == 0 { 0 } else { text.len() }; + let mut hi_byte = if hi == 0 { 0 } else { text.len() }; + if hi != 0 { + for (i, (b, _)) in text.char_indices().enumerate() { + if i == lo { + lo_byte = b; + } + if i == hi { + hi_byte = b; + break; + } + } + } + if first <= second { (lo_byte, hi_byte) } else { (hi_byte, lo_byte) } +} + +/// Byte offsets of the editor's caret and anchor in a document, in that order. +fn doc_bytes(doc: &TextDoc, caret: usize, anchor: usize) -> (usize, usize) { + char_bytes(&doc.text, caret, anchor) +} + /// The text layer a text command targets: `layer`, the edited layer, or the selected text layer. fn target(s: &Session, p: &Value, cmd: &str) -> Result { let cid = comp_id(s, p)?; @@ -78,7 +279,7 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit { Some(e) if e.layer == lid => e.clone(), _ => { let n = layer_doc(s, lid).map_or(0, |d| d.char_len()); - let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() }; + let e = TextEdit { layer: lid, anchor: n, caret: n, session: next_edit_session(), ..Default::default() }; s.state.selected_layers = vec![lid]; s.state.text_edit = Some(e.clone()); e @@ -101,7 +302,8 @@ pub(crate) fn edit_doc( ) -> Result<()> { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let t = s.time(); - s.edit(label, merge, |proj, st| { + let revision = s.revision; + s.edit_text_doc(label, merge, |proj, st| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?; @@ -110,16 +312,28 @@ pub(crate) fn edit_doc( KV::Str(t) => TextDoc::plain(&t), _ => TextDoc::default(), }; - let before = first_line_name(&doc.text); + let original = doc.clone(); f(&mut doc, st)?; - let after = first_line_name(&doc.text); - pr.set_value_at(lt, KV::Text(Box::new(doc))); - // Text layers are named after their text until renamed. - if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { - l.name = after; + // A no-op (an IME deletion with nothing around the selection, a set to the value it + // already has) must not turn a static Str into Text or add a keyframe at the CTI: that + // would change the project and record an empty undo step. + if doc != original { + let before = first_line_name(&original.text); + let after = first_line_name(&doc.text); + pr.set_value_at(lt, KV::Text(Box::new(doc))); + // Text layers are named after their text until renamed. + if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { + l.name = after; + } } Ok(()) - }) + })?; + // The editor's unavailable-text receipt identity follows only its own document: an edit to + // another text layer while this editor is open must not cancel the composition. + if s.revision != revision && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) { + s.note_text_edit_change(); + } + Ok(()) } fn range_p(p: &Value) -> Option> { @@ -153,13 +367,13 @@ fn edit(s: &mut Session, p: &Value) -> Result { }; let created = b_p(p, "created").unwrap_or(false); s.state.selected_layers = vec![lid]; - s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() }); + s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, session: next_edit_session(), ..Default::default() }); s.bump(); Ok(state_json(s)) } /// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted). -fn end_edit(s: &mut Session, _: &Value) -> Result { +pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result { let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) }; if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; @@ -314,6 +528,78 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(state_json(s)) } +/// How many whole characters `before_bytes` / `after_bytes` cover around `r`. +/// +/// This is the input method's own byte count, as winit reports it: a count that ends inside a +/// UTF-8 character leaves that whole character (stock egui's `surrounding_bytes_to_chars`), so a +/// malformed or platform-specific offset never splits or corrupts the document. +fn surrounding_bytes_as_chars(text: &str, n: usize, r: std::ops::Range, before_bytes: usize, after_bytes: usize) -> (usize, usize) { + let r = r.start.min(n)..r.end.min(n); + let (start_byte, end_byte) = char_bytes(text, r.start, r.end); + let from = start_byte.saturating_sub(before_bytes); + let before = text.get(..start_byte).map_or(0, |head| head.char_indices().rev().take_while(|(i, _)| *i >= from).count()); + let after = text.get(end_byte..).map_or(0, |tail| tail.char_indices().take_while(|(i, c)| i.saturating_add(c.len_utf8()) <= after_bytes).count()); + (before.min(r.start), after.min(n.saturating_sub(r.end))) +} + +/// Delete the text an input method marks for replacement around the selection +/// (`DeleteSurrounding`, `DeleteSurroundingBytes`). +/// +/// Unlike [`delete`] with an explicit range, the **selection stays**: the characters before its +/// start and after its end are removed in two separate ranges, and the following `Commit` +/// replaces the selected text. `bytes` counts UTF-8 bytes (`before`/`after` are characters +/// otherwise); a count that ends inside a character leaves that character. +/// +/// The counts are computed from the one document `edit_doc` already evaluated, so an animated +/// Source Text and a large static document are not copied or evaluated again just to decide what +/// the input method asked to replace. +fn delete_surrounding(s: &mut Session, p: &Value) -> Result { + let lid = target(s, p, "text.deleteSurrounding")?; + // Only an open edit session has a selection to keep: without one there is nothing an input + // method could be replacing. + let e = match &s.state.text_edit { + Some(e) if e.layer == lid => e.clone(), + _ => return Err(bad("text.deleteSurrounding", "not editing this text layer")), + }; + let count = |k: &str| p.get(k).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)); + let (before_count, after_count) = (count("before"), count("after")); + let bytes = b_p(p, "bytes").unwrap_or(false); + let merge = str_p(p, "merge").map(str::to_string); + edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| { + let n = doc.char_len(); + let r = e.range().start.min(n)..e.range().end.min(n); + let (before, after) = if bytes { + surrounding_bytes_as_chars(&doc.text, n, r.clone(), before_count, after_count) + } else { + (before_count.min(r.start), after_count.min(n.saturating_sub(r.end))) + }; + let start = r.start.saturating_sub(before); + let end = r.end.saturating_add(after).min(n); + if start == r.start && end == r.end { + // Deletion-only batches with an empty count, or a document with nothing around the + // selection: no empty undo step. + return Ok(()); + } + // The selection keeps its text: remove after it first, then before it, so the two ranges + // stay valid and the caret/selection shift by the characters before it. + if end > r.end { + doc.replace_range(r.end..end, "", None); + } + if r.start > start { + doc.replace_range(start..r.start, "", None); + } + if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) { + let shift = r.start.saturating_sub(start); + ed.anchor = ed.anchor.saturating_sub(shift); + ed.caret = ed.caret.saturating_sub(shift); + ed.pending = None; + ed.goal_x = None; + } + Ok(()) + })?; + Ok(state_json(s)) +} + /// Selected text (plain) of the session, and its formatted fragment. fn selection_doc(s: &Session) -> Option { let e = s.state.text_edit.as_ref()?; @@ -448,16 +734,111 @@ fn paste_formatting(s: &mut Session, p: &Value) -> Result { Ok(state_json(s)) } -/// Keep the session valid after undo / external edits: clamp the selection, drop it when the +/// Give the current editor a fresh replacement epoch after its document/layer was replaced. +pub(crate) fn invalidate_session(s: &mut Session) { + let replaced = s.state.text_edit.is_some(); + if let Some(e) = s.state.text_edit.as_mut() { + e.session = next_edit_session(); + } + if replaced { + s.note_text_edit_change(); + } +} + +/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced. +/// +/// This is the one replacement path for document changes outside `edit_doc`: +/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`] +/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and +/// [`crate::Session::sanitize_after_replacement`] calls it with the editor document snapshotted +/// before a history restore or rollback. +/// Clamping first means a shortened document leaves the caret in range, so the next Backspace +/// deletes instead of being swallowed by an out-of-range selection. +pub(crate) fn document_replaced(s: &mut Session) { + sanitize(s); + invalidate_session(s); +} + +/// The identity and document of the active on-canvas editor at one moment. +/// +/// The composition and the editor session are part of the identity: a restoration must not keep +/// a composition alive when the snapshot came from another comp, or from an intermediate editor +/// that was restarted during the operation being rolled back. +#[derive(Clone, Debug, PartialEq)] +pub struct EditorSnapshot { + /// The composition the editor was attached to. + pub comp: ItemId, + /// The edited layer. + pub layer: LayerId, + /// The editor session token, unique across the process. + pub session: u64, + /// The Source Text the editor was showing. + pub doc: TextDoc, +} + +/// The identity and document the active on-canvas editor is showing. +/// +/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it +/// afterwards: only a change that actually replaces this document starts a new epoch, while a +/// write to another property, layer or composition, a move to a time showing the same text, or a +/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a +/// single `Option` check, with no document copy. +pub fn active_editor_snapshot(s: &Session) -> Option { + let e = s.state.text_edit.as_ref()?; + Some(EditorSnapshot { comp: s.active_comp_id()?, layer: e.layer, session: e.session, doc: layer_doc(s, e.layer)? }) +} + +/// Finish an edit outside the editor: if it replaced the document the editor owned, clamp the +/// selection and start a new IME epoch. +/// +/// An edit that itself moves or closes the editor owns the resulting session, so an identity +/// mismatch is not a replacement here. +pub(crate) fn after_external_change(s: &mut Session, before: Option<&EditorSnapshot>) { + let Some(before) = before else { return }; + let Some(e) = s.state.text_edit.as_ref() else { return }; + if s.state.active_comp != Some(before.comp) || e.layer != before.layer || e.session != before.session { + return; + } + if layer_doc(s, before.layer).as_ref() != Some(&before.doc) { + document_replaced(s); + } +} + +/// Finish a project-state replacement (undo, redo, a history jump, a rollback): keep the editor +/// only when the restored state still has the same composition, layer and editor session and +/// shows byte/style-equal text. +/// +/// The restored session token is the one saved in the replaced state, so an intermediate +/// restart or cross-comp switch must be detected against the snapshot instead of being assumed +/// to be the editor's own change. +pub(crate) fn after_replacement(s: &mut Session, before: Option<&EditorSnapshot>) { + let Some(before) = before else { + // No editor was open when the snapshot was taken: a restored editor must not inherit a + // composition from the state the replacement replaced. + if s.state.text_edit.is_some() { + document_replaced(s); + } + return; + }; + let same = s.state.text_edit.as_ref().is_some_and(|e| s.state.active_comp == Some(before.comp) && e.layer == before.layer && e.session == before.session); + if !same || layer_doc(s, before.layer).as_ref() != Some(&before.doc) { + document_replaced(s); + } +} + +/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the /// layer is gone or not in the active comp. +/// +/// This is also called after project-item cleanup that leaves the edited document alone, so it +/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as +/// a document replacement and interrupt the active IME composition. Replacement paths call +/// [`document_replaced`] instead. pub(crate) fn sanitize(s: &mut Session) { let Some(e) = s.state.text_edit.clone() else { return }; match layer_doc(s, e.layer) { Some(d) => { let n = d.char_len(); - if (e.anchor > n || e.caret > n) - && let Some(ed) = s.state.text_edit.as_mut() - { + if let Some(ed) = s.state.text_edit.as_mut() { ed.anchor = ed.anchor.min(n); ed.caret = ed.caret.min(n); } @@ -536,6 +917,8 @@ fn editing(s: &Session) -> std::result::Result<(), String> { if s.state.text_edit.is_some() { Ok(()) } else { Err("not editing text".into()) } } +/// The command specs of this module: the `text.*` editing, selection and caret commands and +/// the two `edit.pasteText*` commands, each with its parameter shape and enablement predicate. pub fn specs() -> Vec { vec![ cmd!( @@ -594,6 +977,15 @@ pub fn specs() -> Vec { editing_or_text_layer, delete ), + cmd!( + "text.deleteSurrounding", + "Delete Text Around Selection", + [], + None, + "{layer?, before?, after?, bytes?, merge?} (input-method replacement: the characters before and after the selection are deleted, the selection stays; bytes counts UTF-8 bytes)", + editing_or_text_layer, + delete_surrounding + ), cmd!( "edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/cmd/three_d.rs similarity index 100% rename from crates/engine/src/commands/three_d.rs rename to crates/engine/src/cmd/three_d.rs diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/cmd/time.rs similarity index 94% rename from crates/engine/src/commands/time.rs rename to crates/engine/src/cmd/time.rs index 8cb3071bf..556c02bcb 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/cmd/time.rs @@ -28,6 +28,20 @@ pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String { } } +/// A length (a layer's Duration) in the project's Time Display Style: a count of frames, so +/// neither Start Numbering Frames At nor the comp's Start Timecode moves it. +pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String { + let fr = comp.frame_rate; + let n = fr.frame_at(len); + match s.project.settings.time_display { + TimeDisplayStyle::Frames => format!("{n:05}"), + TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => { + effectcraft_time::format_feet_frames(n, s.project.settings.time_display.frames_per_foot().unwrap_or(16)) + } + TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(n, fr, false), + } +} + /// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame /// `current`) into a comp frame. pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { diff --git a/crates/engine/src/commands/timeline.rs b/crates/engine/src/cmd/timeline.rs similarity index 100% rename from crates/engine/src/commands/timeline.rs rename to crates/engine/src/cmd/timeline.rs diff --git a/crates/engine/src/commands/track.rs b/crates/engine/src/cmd/track.rs similarity index 100% rename from crates/engine/src/commands/track.rs rename to crates/engine/src/cmd/track.rs diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/cmd/view.rs similarity index 100% rename from crates/engine/src/commands/view.rs rename to crates/engine/src/cmd/view.rs diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs similarity index 100% rename from crates/engine/src/commands/viewer_cmds.rs rename to crates/engine/src/cmd/viewer_cmds.rs diff --git a/crates/engine/src/commands/vpe.rs b/crates/engine/src/cmd/vpe.rs similarity index 100% rename from crates/engine/src/commands/vpe.rs rename to crates/engine/src/cmd/vpe.rs diff --git a/crates/engine/src/commands/vr_editor.rs b/crates/engine/src/cmd/vr_editor.rs similarity index 100% rename from crates/engine/src/commands/vr_editor.rs rename to crates/engine/src/cmd/vr_editor.rs diff --git a/crates/engine/src/commands/warp_cmds.rs b/crates/engine/src/cmd/warp_cmds.rs similarity index 100% rename from crates/engine/src/commands/warp_cmds.rs rename to crates/engine/src/cmd/warp_cmds.rs diff --git a/crates/engine/src/commands/watch_folder.rs b/crates/engine/src/cmd/watch_folder.rs similarity index 100% rename from crates/engine/src/commands/watch_folder.rs rename to crates/engine/src/cmd/watch_folder.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs deleted file mode 100644 index b798493b1..000000000 --- a/crates/engine/src/commands/layer.rs +++ /dev/null @@ -1,1866 +0,0 @@ -//! Layer menu. - -use effectcraft_color::{BlendMode, Label}; -use effectcraft_keyframe::{Justify, TextDoc, Value as KV}; -use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{ - Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte, - limits, -}; -use effectcraft_time::Tick; -use serde_json::{Value, json}; - -use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p, unlocked}; -use crate::{EngineError, Result, Session, cmd}; - -pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { - match p.get(k)? { - Value::String(s) => effectcraft_color::Rgba::from_hex(s).map(|c| [c.r, c.g, c.b]), - Value::Array(a) => { - let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32; - Some([g(0), g(1), g(2)]) - } - _ => None, - } -} - -/// Insert a new layer above the selection (or at the top) and select it. -pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { - insert_layer_at(proj, st, cid, layer, None) -} - -/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the -/// bottom = the bottom), as 0-based. -pub(crate) fn index_p(p: &Value, cmd: &str) -> Result> { - match p.get("index") { - None | Some(Value::Null) => Ok(None), - Some(v) => { - v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position")) - } - } -} - -/// A `position` parameter: `[x, y]` in comp pixels (finite). -pub(crate) fn position_p(p: &Value, cmd: &str) -> Result> { - match p.get("position") { - None | Some(Value::Null) => Ok(None), - Some(v) => v - .as_array() - .and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) - .filter(|c| c.iter().all(|x| x.is_finite())) - .map(Some) - .ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")), - } -} - -/// Put a new layer's Position at `pos` (comp pixels). -pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) { - if let Some([x, y]) = pos - && let Some(pr) = l.props.prop_mut("transform/position") - { - let z = match pr.value { - KV::Vec3(v) => v[2], - _ => 0.0, - }; - pr.value = KV::Vec3([x, y, z]); - } -} - -/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer. -pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option) -> Result { - // Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels). - if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) { - super::model3d::add_geometry_options(&mut proj.next_id, &mut layer); - } - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let at = match at { - Some(i) => i.min(comp.layers.len()), - None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0), - }; - let id = layer.id; - comp.layers.insert(at, layer); - st.selected_layers = vec![id]; - st.selected_props.clear(); - st.selected_keys.clear(); - Ok(id) -} - -fn solids_folder(proj: &mut Project) -> ItemId { - proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)) -} - -fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); - let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] }); - // 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid. - let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width); - let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height); - let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); - let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; - let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); - let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { - let folder = solids_folder(proj); - let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect })); - let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); - if adjustment { - l.switches.adjustment = true; - l.label = Label::Purple; - } - insert_layer(proj, st, cid, l) - })?; - Ok(json!({"layer": id.0})) -} - -/// `pixelAspect` (a positive number), when given. -fn pixel_aspect_p(p: &Value) -> Result> { - match p.get("pixelAspect").and_then(Value::as_f64) { - Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")), - v => Ok(v.map(limits::pixel_aspect)), - } -} - -fn new_solid(s: &mut Session, p: &Value) -> Result { - new_solid_like(s, p, false) -} -fn new_adjustment(s: &mut Session, p: &Value) -> Result { - new_solid_like(s, p, true) -} - -/// Parameters of `layer.setText` / `layer.newText` that aren't text attributes. -const NOT_ATTRS: &[&str] = &["layer", "layers", "comp", "merge", "text", "range", "position", "edit"]; - -/// Apply `layer.setText` keys to `d`: `text` replaces characters `r` (or everything), then each -/// character / paragraph / document attribute applies to `r` (or everything). -fn apply_text_params(cmd: &str, p: &Value, d: &mut TextDoc, r: Option>) -> Result<()> { - let mut r = r; - if let Some(t) = str_p(p, "text") { - match r.clone() { - Some(rr) => { - d.replace_range(rr.clone(), t, None); - r = Some(rr.start..rr.start + t.chars().count()); - } - None => d.set_text(t), - } - } - let Some(obj) = p.as_object() else { return Ok(()) }; - for (k, v) in obj { - if NOT_ATTRS.contains(&k.as_str()) { - continue; - } - if !d.set_attr(k, v, r.clone()).map_err(|e| bad(cmd, e))? { - return Err(bad(cmd, format!("unknown text attribute {k}"))); - } - } - Ok(()) -} - -fn text_range_p(p: &Value) -> Option> { - let a = p.get("range")?.as_array()?; - let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize); - let (x, y) = (g(0)?, g(1).or(g(0))?); - Some(x.min(y)..x.max(y)) -} - -fn new_text(s: &mut Session, p: &Value) -> Result { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); - let mut doc = TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() }; - // A paragraph box given in comp space: the layer sits at its centre. - let bx = p.get("box").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0))); - let mut q = p.clone(); - if let Some(o) = q.as_object_mut() { - o.remove("box"); - o.remove("name"); - } - let layer_name = str_p(p, "name").map(str::to_string); - apply_text_params("layer.newText", &q, &mut doc, None)?; - let mut pos = position_p(p, "layer.newText")?; - if let Some([x, y, w, h]) = bx { - let (w, h) = (w.abs().max(1.0), h.abs().max(1.0)); - doc.box_size = Some([w, h]); - doc.box_pos = [-w / 2.0, -h / 2.0]; - pos = Some([x + w / 2.0, y + h / 2.0]); - if p.get("justify").is_none() { - doc.set_attr("justify", &json!("left"), None).map_err(|e| bad("layer.newText", e))?; - } - } - let edit = b_p(p, "edit").unwrap_or(false); - let id = s.edit("New Text Layer", None, |proj, st| { - let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect()); - let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); - if let Some(pr) = l.props.prop_mut("text/sourceText") { - pr.value = KV::Text(Box::new(doc.clone())); - } - if let Some(pos) = pos - && let Some(pr) = l.props.prop_mut("transform/position") - { - pr.value = KV::Vec3([pos[0], pos[1], 0.0]); - } - let n = doc.char_len(); - let id = insert_layer(proj, st, cid, l)?; - if edit { - st.text_edit = Some(super::text_edit::TextEdit { layer: id, anchor: 0, caret: n, created: true, ..Default::default() }); - } - Ok(id) - })?; - Ok(json!({"layer": id.0})) -} - -fn new_shape(s: &mut Session, p: &Value) -> Result { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); - let kind = str_p(p, "kind").unwrap_or("none").to_string(); - let size = p - .get("size") - .and_then(Value::as_array) - .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)]) - .unwrap_or([300.0, 300.0]); - // A fill, and a stroke only with a width (not the Tools bar's paint). - let paint = super::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?; - let pos = p - .get("position") - .and_then(|v| v.as_array()) - .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); - let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); - let id = s.edit("New Shape Layer", None, |proj, st| { - let lid = super::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; - let mut next = proj.next_id; - let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); - proj.next_id = next; - if let Some(g) = g { - super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?; - } - Ok(lid) - })?; - Ok(json!({"layer": id.0})) -} - -fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &str) -> Result { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); - let name = str_p(p, "name").unwrap_or(name).to_string(); - let id = s.edit(label, None, |proj, st| { - let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); - insert_layer(proj, st, cid, l) - })?; - Ok(json!({"layer": id.0})) -} - -fn new_null(s: &mut Session, p: &Value) -> Result { - new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object") -} - -fn add_item(s: &mut Session, p: &Value) -> Result { - let cid = comp_id(s, p)?; - let item = match p.get("item") { - Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)), - Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.addItem", format!("no item `{name}`")))?, - _ => *s.state.project_selection.first().ok_or_else(|| bad("layer.addItem", "missing `item`"))?, - }; - if s.project.comp_contains(item, cid) { - return Err(bad("layer.addItem", "a composition can't contain itself")); - } - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); - let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone(); - let (src, size, dur) = match &it.kind { - ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)), - ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => { - // `duration`, else Settings ▸ Import ▸ Still Footage: length of the composition or a - // duration. - let d = f_p(p, "duration") - .filter(|d| d.is_finite() && *d > 0.0) - .or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds)) - .map(Tick::from_seconds_f64); - (LayerSource::Footage { item }, (f.width, f.height), d) - } - ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => { - return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData")); - } - ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => { - return super::model3d::new_model( - s, - &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}), - ); - } - ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.looped_duration())), - ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), - ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), - }; - let index = index_p(p, "layer.addItem")?; - let position = position_p(p, "layer.addItem")?; - let fr = comp.frame_rate; - // Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time). - let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() }; - let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start)); - let id = s.edit("Add Footage to Comp", None, |proj, st| { - let mut l = build::layer(proj, &comp, &it.name, src, size, dur); - l.start_time = start; - l.in_point = start; - // Footage and precomps keep their full length past the comp's end (After Effects); items - // without a duration (solids, comp-length stills) end with the comp. - let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d); - l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration()); - place(&mut l, position); - insert_layer_at(proj, st, cid, l, index) - })?; - Ok(json!({"layer": id.0})) -} - -fn select(s: &mut Session, p: &Value) -> Result { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let ids: Vec = match p.get("layers").or(p.get("layer")) { - Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(), - Some(v) => resolve_layer(comp, v).into_iter().collect(), - None => vec![], - }; - let add = b_p(p, "add").unwrap_or(false); - let toggle = b_p(p, "toggle").unwrap_or(false); - if toggle { - for id in ids { - if let Some(i) = s.state.selected_layers.iter().position(|l| *l == id) { - s.state.selected_layers.remove(i); - } else { - s.state.selected_layers.push(id); - } - } - } else if add { - for id in ids { - if !s.state.selected_layers.contains(&id) { - s.state.selected_layers.push(id); - } - } - } else { - s.state.selected_layers = ids; - s.state.selected_props.clear(); - s.state.selected_keys.clear(); - let keep = s.state.selected_layers.clone(); - s.state.selected_vertices.retain(|v| keep.contains(&v.layer)); - } - Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::>())) -} - -fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result { - let comp = s.active_comp().ok_or(EngineError::NoComp)?; - if comp.layers.is_empty() { - return Ok(Value::Null); - } - let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)); - let n = comp.layers.len() as i64; - // The next layer that can be selected (locked and hidden shy layers are stepped over); at - // the end of the stack the selection stays. - let start = match cur { - Some(i) => i as i64 + dir, - None if dir > 0 => 0, - None => n - 1, - }; - let next = - std::iter::successors(Some(start), |i| Some(i + dir)).take_while(|i| (0..n).contains(i)).find(|i| super::selectable(comp, &comp.layers[*i as usize])); - let Some(i) = next.or(cur.map(|i| i as i64)) else { return Ok(Value::Null) }; - let id = comp.layers[i as usize].id; - let add = b_p(p, "add").unwrap_or(false); - if add { - if !s.state.selected_layers.contains(&id) { - s.state.selected_layers.insert(0, id); - } - } else { - s.state.selected_layers = vec![id]; - } - Ok(json!(id.0)) -} - -fn select_next(s: &mut Session, p: &Value) -> Result { - select_step(s, p, 1) -} -fn select_prev(s: &mut Session, p: &Value) -> Result { - select_step(s, p, -1) -} - -fn set_switch(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string(); - let v = b_p(p, "value"); - let label = format!("Layer Switch ({name})"); - if b_p(p, "others") == Some(true) { - return set_switch_others(s, cid, &ids, &name, v, &label); - } - // `merge`: a switch dragged over several layers in the Timeline is one undo step. - let r = s.edit(&label, merge_p(p), |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let mut last = false; - // Toggle relative to the first layer so a multi-selection ends up consistent. - let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false)); - let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; - let target = v.unwrap_or(!first); - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { - set_switch_value(l, &name, target); - last = target; - } - Ok(last) - })?; - Ok(json!(r)) -} - -/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named -/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch -/// applies to is the opposite; when they already do, the other layers come back. `value` sets -/// the other layers directly. -fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option, label: &str) -> Result { - let r = s.edit(label, None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - // The state that sets a layer apart: unlocked for Lock, on for the rest. - let active = !matches!(name, "lock" | "locked"); - // Track mattes keep their video off by design. - let mattes: Vec = - if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() }; - let applies = |l: &Layer| match name { - "audio" => l.props.sub("audio").is_some(), - "lock" | "locked" => true, - // Nulls have a Video switch too (it hides their outline in the viewer). - "video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id), - _ => l.source.is_av() && !mattes.contains(&l.id), - }; - let mut alone = true; - for l in comp.layers.iter().filter(|l| applies(l)) { - let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; - alone &= on == (ids.contains(&l.id) == active); - } - let others = value.unwrap_or(alone == active); - for l in comp.layers.iter_mut().filter(|l| applies(l)) { - let v = if ids.contains(&l.id) { active } else { others }; - set_switch_value(l, name, v); - } - Ok(others) - })?; - Ok(json!(r)) -} - -fn switch_value(l: &Layer, name: &str) -> Option { - let sw = &l.switches; - Some(match name { - "video" | "eye" => sw.video, - "audio" => sw.audio, - "solo" => sw.solo, - "lock" | "locked" => sw.locked, - "shy" => sw.shy, - "collapse" => sw.collapse, - "quality" => sw.quality == Quality::Best, - "fx" | "effects" => sw.effects, - "frameBlend" => sw.frame_blend != FrameBlend::Off, - "motionBlur" => sw.motion_blur, - "adjustment" => sw.adjustment, - "threeD" | "3d" | "3D" => sw.three_d, - "guide" => sw.guide, - "preserveTransparency" => l.preserve_transparency, - _ => return None, - }) -} - -fn set_switch_value(l: &mut Layer, name: &str, v: bool) { - let sw = &mut l.switches; - match name { - "video" | "eye" => sw.video = v, - "audio" => sw.audio = v, - "solo" => sw.solo = v, - "lock" | "locked" => sw.locked = v, - "shy" => sw.shy = v, - "collapse" => sw.collapse = v, - "quality" => sw.quality = if v { Quality::Best } else { Quality::Draft }, - "fx" | "effects" => sw.effects = v, - "frameBlend" => sw.frame_blend = if v { FrameBlend::FrameMix } else { FrameBlend::Off }, - "motionBlur" => sw.motion_blur = v, - "adjustment" => sw.adjustment = v, - "threeD" | "3d" | "3D" => { - if sw.three_d && !v { - drop_3d_values(l); - } - l.switches.three_d = v; - } - "guide" => sw.guide = v, - "preserveTransparency" => l.preserve_transparency = v, - _ => {} - } -} - -/// Turning the 3D switch off discards Z values, Orientation and X/Y Rotation (as in AE). -fn drop_3d_values(l: &mut Layer) { - let Some(tr) = l.transform_mut() else { return }; - for (m, keep) in [("position", 0.0), ("anchor", 0.0), ("scale", 100.0)] { - if let Some(pr) = tr.get_mut(m) { - let flat = |v: &mut KV| { - if let KV::Vec3(a) = v { - a[2] = keep; - } - }; - flat(&mut pr.value); - for k in &mut pr.keys { - flat(&mut k.value); - } - } - } - for (m, zero) in [("orientation", KV::Vec3([0.0; 3])), ("rotationX", KV::Scalar(0.0)), ("rotationY", KV::Scalar(0.0))] { - if let Some(pr) = tr.get_mut(m) { - pr.keys.clear(); - pr.expr = None; - pr.value = zero; - } - } -} - -fn rename(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.rename")?; - let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); - s.edit("Rename Layer", None, |proj, _| { - layer_mut(proj, cid, lid)?.name = name.clone(); - Ok(()) - })?; - Ok(Value::Null) -} - -/// The Timeline's Comment column. -fn set_comment(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string(); - s.edit("Layer Comment", None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { - l.comment = text.clone(); - } - Ok(()) - })?; - Ok(Value::Null) -} - -fn blend(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let mode = str_p(p, "mode").and_then(BlendMode::from_name); - let step = p.get("step").and_then(Value::as_i64).unwrap_or(0); - if mode.is_none() && step == 0 { - return Err(bad("layer.setBlendMode", "need `mode` (e.g. Multiply) or `step` ±1")); - } - s.edit("Blending Mode", None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { - l.blend_mode = match mode { - Some(m) => m, - None if step > 0 => l.blend_mode.next(), - None => l.blend_mode.previous(), - }; - } - Ok(()) - })?; - Ok(Value::Null) -} - -fn track_matte(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let matte = match p.get("matte") { - None | Some(Value::Null) => None, - Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?), - }; - let kind = str_p(p, "kind") - .map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { - "alphainverted" | "alphainv" => MatteKind::AlphaInverted, - "luma" => MatteKind::Luma, - "lumainverted" | "lumainv" => MatteKind::LumaInverted, - _ => MatteKind::Alpha, - }) - .unwrap_or(MatteKind::Alpha); - s.edit("Track Matte", None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - if let Some(m) = matte { - if m == lid { - return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); - } - if let Some(ml) = comp.layer_mut(m) { - ml.switches.video = false; - } - } - comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind }); - Ok(()) - })?; - Ok(Value::Null) -} - -/// Re-express a layer in its new parent space without moving its anchor. -pub(crate) struct ParentFix { - pub layer: LayerId, - /// Maps Position from the old parent's space into the new one's. - m: effectcraft_geom::Mat3, - /// Added to Rotation. - rotation: f64, - /// Multiply Scale. - scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. - position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, -} - -impl ParentFix { - /// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space. - pub fn apply(&self, l: &mut Layer) { - let Some(tr) = l.props.sub_mut("transform") else { return }; - if let Some(m) = self.position_3d { - // Spatial Position speed measures distance along its path. Its parent-space - // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); - let map_position = |v: &KV| { - let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); - KV::Vec3([q.x, q.y, q.z]) - }; - // Read all separated axes before writing any of them. - let names = ["positionX", "positionY", "positionZ"]; - if tr.get("positionX").is_some() { - let axes = names.map(|name| tr.get(name).cloned()); - let current = axes.each_ref().map(|pr| pr.as_ref().map(|pr| pr.value.as_f64()).unwrap_or(0.0)); - let q = m.apply(effectcraft_geom::Vec3::from(current)); - for (i, name) in names.into_iter().enumerate() { - if let Some(pr) = tr.get_mut(name) { - pr.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0)); - for key in &mut pr.keys { - let p = axes.each_ref().map(|axis| axis.as_ref().map(|axis| axis.value_at(key.time).as_f64()).unwrap_or(0.0)); - let q = m.apply(effectcraft_geom::Vec3::from(p)); - key.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0)); - } - } - } - } - if let Some(pr) = tr.get_mut("position") { - pr.value = map_position(&pr.value); - for key in &mut pr.keys { - key.value = map_position(&key.value); - // Spatial tangents are vectors, so the parent's translation is excluded. - for tangent in [&mut key.spatial_in, &mut key.spatial_out] { - let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); - *tangent = [v.x, v.y, v.z]; - } - } - if let Some(factor) = position_speed - .filter(|_| pr.spatial) - .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) - { - for key in &mut pr.keys { - for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { - ease.speed *= factor; - } - } - } - } - // Cameras and lights aim with Point of Interest: move it by the same parent-space - // change, so a two-node rig keeps its world frame (#1010, #1020). - if let Some(pr) = tr.get_mut("poi") { - pr.value = map_position(&pr.value); - for key in &mut pr.keys { - key.value = map_position(&key.value); - for tangent in [&mut key.spatial_in, &mut key.spatial_out] { - let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); - *tangent = [v.x, v.y, v.z]; - } - } - if let Some(factor) = position_speed - .filter(|_| pr.spatial) - .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) - { - for key in &mut pr.keys { - for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { - ease.speed *= factor; - } - } - } - } - if let Some((orientation, factor)) = self.pose_3d { - if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); - } - if let Some(pr) = tr.get_mut("scale") { - let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); - pr.value = scaled(&pr.value); - for key in &mut pr.keys { - key.value = scaled(&key.value); - // Scale's temporal speeds use the same units as its values. - for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { - ease.speed *= factor; - } - } - } - } - return; - } - let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { - pr.value = f(&pr.value); - for key in &mut pr.keys { - key.value = f(&key.value); - } - }; - let m = self.m; - if tr.get("positionX").is_some() { - // Separated dimensions: X and Y move together, key by key at the same times. - // Everything is read before anything is written. - let [x, y] = [tr.get("positionX"), tr.get("positionY")].map(|p| p.map(|p| p.value.as_f64()).unwrap_or(0.0)); - let q = m.apply(effectcraft_geom::vec2(x, y)); - let keys_x: Vec<(Tick, f64)> = tr.get("positionX").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); - let keys_y: Vec<(Tick, f64)> = tr.get("positionY").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); - let at = |keys: &[(Tick, f64)], name: &str, t: Tick, tr: &PropGroup| { - keys.iter().find(|k| k.0 == t).map(|k| k.1).unwrap_or_else(|| tr.get(name).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0)) - }; - let (xs, ys): (Vec, Vec) = ( - keys_x.iter().map(|&(t, x)| m.apply(effectcraft_geom::vec2(x, at(&keys_y, "positionY", t, tr))).x).collect(), - keys_y.iter().map(|&(t, y)| m.apply(effectcraft_geom::vec2(at(&keys_x, "positionX", t, tr), y)).y).collect(), - ); - for (name, value, vals) in [("positionX", q.x, xs), ("positionY", q.y, ys)] { - if let Some(pr) = tr.get_mut(name) { - pr.value = KV::Scalar(value); - for (k, v) in pr.keys.iter_mut().zip(vals) { - k.value = KV::Scalar(v); - } - } - } - } else if let Some(pr) = tr.get_mut("position") { - map(pr, &|v| { - let c = v.as_vec3(); - let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); - KV::Vec3([q.x, q.y, c[2]]) - }); - } - let (dr, k) = (self.rotation, self.scale); - if let Some(pr) = tr.get_mut("rotation") { - map(pr, &|v| KV::Scalar(v.as_f64() + dr)); - } - if let Some(pr) = tr.get_mut("scale") { - map(pr, &|v| { - let c = v.as_vec3(); - KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) - }); - } - } -} - -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } - let a = m.0; - let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; - let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); - if !magnitude.is_finite() || magnitude <= 0.0 { - return None; - } - let c = columns.map(|c| c / magnitude); - if c.iter().any(|c| (c.dot(*c) - 1.0).abs() > 1e-9) || c[0].dot(c[1]).abs() > 1e-9 || c[0].dot(c[2]).abs() > 1e-9 || c[1].dot(c[2]).abs() > 1e-9 { - return None; - } - let cross = vec3(c[1].y * c[2].z - c[1].z * c[2].y, c[1].z * c[2].x - c[1].x * c[2].z, c[1].x * c[2].y - c[1].y * c[2].x); - let factor = if c[0].dot(cross) < 0.0 { -magnitude } else { magnitude }; - let mut rotation = Mat4::IDENTITY; - for row in 0..3 { - for col in 0..3 { - rotation.0[row][col] = a[row][col] / factor; - } - } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; - // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. - let cy = r[0][0].hypot(r[1][0]); - let y = (-r[2][0]).atan2(cy); - let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) -} - -/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, -/// and Point of Interest for two-node cameras and lights). Position is -/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current -/// time. -pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { - let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; - let space = |id: Option| -> effectcraft_geom::Mat3 { - id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) - }; - let decompose = |m: &effectcraft_geom::Mat3| { - let a = m.0; - let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); - let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; - (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) - }; - let new_m = space(parent); - let Some(inv) = new_m.inverse() else { return vec![] }; - let (rn, sxn, syn) = decompose(&new_m); - let mut fixes: Vec<_> = comp - .layers - .iter() - .filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) - .map(|l| { - let old_m = space(l.parent); - let (ro, sxo, syo) = decompose(&old_m); - let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; - ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale, position_3d: None, pose_3d: None } - }) - .collect(); - let space_3d = |id: Option| { - id.and_then(|id| comp.layer(id)).map_or(effectcraft_geom::Mat4::IDENTITY, |layer| { - // Parent transforms do not inherit the parent's source pixel-aspect stretch. - let world = ectx.world_matrix(layer); - let par = ectx.par_ratio(layer); - let a = layer.transform().map(|tr| ectx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); - let anchor = effectcraft_geom::vec3(a[0], a[1], if layer.is_3d() { a[2] } else { 0.0 }); - world - * effectcraft_geom::Mat4::translate(anchor) - * effectcraft_geom::Mat4::scale(effectcraft_geom::vec3(1.0 / par, 1.0, 1.0)) - * effectcraft_geom::Mat4::translate(-anchor) - }) - }; - if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - // Cameras and lights compensate like 3D layers (Position, and Point of Interest for - // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no - // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. - for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { - let m = inv * space_3d(layer.parent); - if m.0.iter().flatten().all(|v| v.is_finite()) { - let pose_3d = layer.transform().and_then(|tr| { - let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - if !orientation.keys.is_empty() - || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off - { - return None; - } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) - }); - fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); - } - } - } - fixes -} - -/// Validate the proposed ancestor chain before any authored edit, including when compensation -/// is disabled. Bound both unique ancestors and linear lookup/selection work on loaded graphs. -fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option, max_visits: usize, max_work: usize) -> Result<()> { - let mut seen = std::collections::HashSet::new(); - let mut work = 0usize; - let mut cur = parent; - while let Some(id) = cur { - if seen.len() >= max_visits { - return Err(bad("layer.setParent", "parent chain exceeds the safety visit limit")); - } - if !seen.insert(id) { - return Err(bad("layer.setParent", "parent chain contains an existing cycle")); - } - for selected in ids { - if work >= max_work { - return Err(bad("layer.setParent", "parent chain exceeds the safety work limit")); - } - work += 1; - if *selected == id { - return Err(bad("layer.setParent", "parenting would create a cycle")); - } - } - let mut ancestor = None; - for layer in &comp.layers { - if work >= max_work { - return Err(bad("layer.setParent", "parent chain exceeds the safety work limit")); - } - work += 1; - if layer.id == id { - ancestor = Some(layer); - break; - } - } - let layer = ancestor.ok_or_else(|| bad("layer.setParent", "parent chain refers to a missing layer"))?; - cur = layer.parent; - } - Ok(()) -} - -fn set_parent(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let parent = match p.get("parent") { - None | Some(Value::Null) => None, - Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?), - }; - // A loaded cycle need not include a selected child. Validate before compensation or edit; - // do not impose the renderer's 64-ancestor truncation as a new ordinary hierarchy limit. - check_parent_chain(comp, &ids, parent, 4096, 1_048_576)?; - // Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed - // in the new parent's space (Position, Rotation, Scale), unless `compensate: false`. - let compensate = b_p(p, "compensate").unwrap_or(true); - let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; - s.edit("Parent", None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { - l.parent = parent; - if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { - f.apply(l); - } - } - Ok(()) - })?; - Ok(Value::Null) -} - -fn timing(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let t = s.time(); - let op = str_p(p, "op").unwrap_or("set").to_string(); - // Layer times stay on frame boundaries of the comp (as in AE). - let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; - let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); - let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); - // Relative trims: each layer moves its own in/out point by the same amount, so dragging an - // edge of several selected layers trims them all together (as in AE). - let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta")); - s.edit("Layer Timing", merge_p(p), |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let fd = comp.frame_duration(); - let comp_dur = comp.duration; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { - let shift = |l: &mut Layer, d: Tick| { - l.start_time += d; - l.in_point += d; - l.out_point += d; - }; - match op.as_str() { - "moveInToTime" => { - let d = t - l.in_point; - shift(l, d); - } - "moveOutToTime" => { - let d = t - l.out_point; - shift(l, d); - } - "moveInToStart" => { - let d = Tick::ZERO - l.in_point; - shift(l, d); - } - "moveOutToEnd" => { - let d = comp_dur - l.out_point; - shift(l, d); - } - "trimInToTime" => l.in_point = t.min(l.out_point - fd), - "trimOutToTime" => l.out_point = t.max(l.in_point + fd), - _ => { - if let Some(d) = delta { - shift(l, d); - } - if let Some(st) = start { - let d = st - l.start_time; - shift(l, d); - } - if let Some(i) = inp { - l.in_point = i.min(l.out_point - fd); - } - if let Some(o) = outp { - l.out_point = o.max(l.in_point + fd); - } - // Stops at the comp start/end (a point already past it doesn't jump onto - // it), keeping at least one frame of layer. - if let Some(d) = in_delta { - l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd); - } - if let Some(d) = out_delta { - l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd); - } - } - } - } - Ok(()) - })?; - Ok(Value::Null) -} - -/// `p` with `key` set. -fn with(p: &Value, key: &str, v: Value) -> Value { - let mut p = if p.is_object() { p.clone() } else { json!({}) }; - p[key] = v; - p -} - -/// Move the layers `frames` frames in time (Alt+Page Up / Page Down, with Shift ten frames). -fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result { - let (cid, _) = layers_p(s, p)?; - let delta = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate.tick_of(frames).seconds(); - timing(s, &with(p, "delta", json!(delta))) -} - -/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like -/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive -/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and -/// layer markers are in layer time, so they travel with the source. -fn slip(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let fr = comp.frame_rate; - let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) { - (Some(f), _) => fr.tick_of(f), - (None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)), - _ => return Err(bad("layer.slip", "delta (seconds) or frames required")), - }; - // Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out. - let mut shifts: Vec<(LayerId, Tick)> = Vec::new(); - for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { - if l.props.get("timeRemap").is_some() { - continue; - } - let dur = match &l.source { - LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) { - Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(f.looped_duration()), - _ => None, - }, - LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration), - _ => None, - }; - let Some(dur) = dur else { continue }; - let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur)); - let (lo, hi) = (a.min(b), a.max(b)); - let (min_d, max_d) = (l.out_point - hi, l.in_point - lo); - if min_d > max_d { - continue; - } - shifts.push((l.id, delta.max(min_d).min(max_d))); - } - if shifts.is_empty() { - return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)")); - } - s.edit("Slip Edit", merge_p(p), |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for (id, d) in &shifts { - if let Some(l) = comp.layer_mut(*id) { - l.start_time += *d; - } - } - Ok(()) - })?; - Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::>()})) -} - -fn arrange(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let ids = unlocked(s, cid, ids, "layer.arrange")?; - let how = str_p(p, "to").unwrap_or("front").to_string(); - let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); - // `above`: put the layers right above that one (the timeline's drag to reorder). - let above = match p.get("above") { - Some(v) => { - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let a = resolve_layer(comp, v).ok_or_else(|| bad("layer.arrange", "no such layer for `above`"))?; - if ids.contains(&a) { - return Err(bad("layer.arrange", "`above` must be a layer that isn't being moved")); - } - Some(a) - } - None => None, - }; - s.edit("Arrange Layers", None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - // Bring Forward / Send Backward move each layer one step past the next layer that isn't - // moving (a run of selected layers moves as one; one already at the end stays). - if above.is_none() && index.is_none() && matches!(how.as_str(), "forward" | "backward") { - let picked = |l: &Layer| ids.contains(&l.id); - let n = comp.layers.len(); - if how == "forward" { - for i in 1..n { - if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { - let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); - comp.layers[i - 1..run_end].rotate_left(1); - } - } - } else { - for i in (0..n.saturating_sub(1)).rev() { - if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { - let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); - comp.layers[run_start..=i + 1].rotate_right(1); - } - } - } - return Ok(()); - } - let mut picked: Vec = Vec::new(); - comp.layers.retain(|l| { - if ids.contains(&l.id) { - picked.push(l.clone()); - false - } else { - true - } - }); - let at = match (how.as_str(), index) { - _ if above.is_some() => comp.layers.iter().position(|l| Some(l.id) == above).unwrap_or(comp.layers.len()), - (_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()), - ("back", _) => comp.layers.len(), - _ => 0, - }; - for (k, l) in picked.into_iter().enumerate() { - comp.layers.insert((at + k).min(comp.layers.len()), l); - } - Ok(()) - })?; - Ok(Value::Null) -} - -/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition") -/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage, -/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's -/// transform, masks, effects and timing in the current comp. `adjustDuration` (move) trims the -/// new comp to the selected layers' span; `open` opens the new comp. -fn precompose(s: &mut Session, p: &Value) -> Result { - let c = "layer.precompose"; - let (cid, ids) = layers_p(s, p)?; - if ids.is_empty() { - return Err(bad(c, "no layers")); - } - let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string(); - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); - let leave = match str_p(p, "mode").unwrap_or("move") { - "move" => false, - "leave" => true, - x => return Err(bad(c, format!("mode: move|leave, got `{x}`"))), - }; - let adjust = b_p(p, "adjustDuration").unwrap_or(false); - if leave { - let [lid] = ids[..] else { return Err(bad(c, "\"Leave all attributes\" needs exactly one layer")) }; - let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); - let Some(src_item) = layer.source.item() else { - return Err(bad(c, "\"Leave all attributes\" is available for footage, solid and precomp layers only")); - }; - let (w, h) = effectcraft_render::source_size(&s.project, &layer); - let (w, h) = if w == 0 || h == 0 { (comp.width, comp.height) } else { (w, h) }; - let dur = match s.project.item(src_item).map(|i| &i.kind) { - Some(ItemKind::Comp(nc)) => nc.duration, - Some(ItemKind::Footage(f)) if f.duration > Tick(0) => f.duration, - _ => comp.duration, - }; - let new = s.edit("Pre-compose", None, |proj, st| { - let mut inner = comp.nested_like(w, h, dur); - let mut il = build::layer(proj, &inner, &layer.name, layer.source.clone(), (w, h), Some(dur)); - il.name = layer.name.clone(); - inner.layers.push(il); - let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into())); - let l = layer_mut(proj, cid, lid)?; - l.source = LayerSource::Comp { item: iid }; - l.name = name.clone(); - st.selected_layers = vec![lid]; - Ok((iid, lid)) - })?; - if b_p(p, "open").unwrap_or(false) { - s.open_comp(new.0); - } - return Ok(json!({"comp": new.0.0, "layer": new.1.0})); - } - let span = { - let sel: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| ids.contains(&l.id)).collect(); - let a = sel.iter().map(|l| l.in_point).min().unwrap_or(Tick(0)).max(Tick(0)); - let b = sel.iter().map(|l| l.out_point).max().unwrap_or(comp.duration).min(comp.duration); - (a, b) - }; - let (offset, dur) = if adjust && span.1 > span.0 { (span.0, span.1 - span.0) } else { (Tick(0), comp.duration) }; - let new = s.edit("Pre-compose", None, |proj, st| { - let mut inner = comp.nested_like(comp.width, comp.height, dur); - inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); - for l in &mut inner.layers { - if l.parent.is_some_and(|p| !ids.contains(&p)) { - l.parent = None; - } - if l.track_matte.is_some_and(|m| !ids.contains(&m.layer)) { - l.track_matte = None; - } - l.start_time -= offset; - l.in_point -= offset; - l.out_point -= offset; - } - let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into())); - let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(dur)); - l.name = name.clone(); - l.start_time = offset; - l.in_point = offset; - l.out_point = offset + dur; - let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); - c.layers.retain(|l| !ids.contains(&l.id)); - let lid = l.id; - c.layers.insert(at.min(c.layers.len()), l); - st.selected_layers = vec![lid]; - Ok((iid, lid)) - })?; - if b_p(p, "open").unwrap_or(false) { - s.open_comp(new.0); - } - Ok(json!({"comp": new.0.0, "layer": new.1.0})) -} - -fn add_mask(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.addMask")?; - let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?.clone(); - let (w, h) = effectcraft_render::source_size(&s.project, &layer); - let (w, h) = if w == 0 { (400.0, 300.0) } else { (w as f64, h as f64) }; - let shape = str_p(p, "shape").unwrap_or("rect"); - let r = p.get("rect").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0))); - let (cx, cy, rw, rh) = match r { - Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh), - None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6), - }; - // The shape tools' masks: rectangle, ellipse, rounded rectangle, polygon or star. - let path = super::shape_tool::mask_path(shape, [cx, cy], [rw, rh]) - .ok_or_else(|| bad("layer.addMask", format!("unknown shape `{shape}`; one of rect, ellipse, rounded, polygon, star")))?; - let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); - let cycle = s.prefs.appearance.cycle_mask_colors; - let uid = s.edit("New Mask", None, |proj, _| { - let mut next = proj.next_id; - let l = layer_mut(proj, cid, lid)?; - let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?; - let n = masks.children.len(); - let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, crate::prefs::mask_color(cycle, n)); - let uid = g.uid; - masks.children.push(g.into()); - proj.next_id = next; - Ok(uid) - })?; - Ok(json!({"mask": uid})) -} - -fn mask_props(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.setMask")?; - let target = p.get("mask").cloned().unwrap_or(json!(1)); - let mode = str_p(p, "mode").and_then(MaskMode::from_name); - let inverted = b_p(p, "inverted"); - s.edit("Mask Settings", None, |proj, _| { - let l = layer_mut(proj, cid, lid)?; - let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "the layer has no masks (add one with layer.addMask)"))?; - let g = match &target { - Value::Number(n) => { - let n = n.as_u64().unwrap_or(1); - if let Some(i) = masks.children.iter().position(|c| c.uid() == n) { - masks.children[i].as_group_mut() - } else { - masks.children.get_mut(n.saturating_sub(1) as usize).and_then(|c| c.as_group_mut()) - } - } - Value::String(name) => masks.children.iter_mut().find(|c| c.name() == name).and_then(|c| c.as_group_mut()), - _ => None, - } - .ok_or_else(|| bad("layer.setMask", "no such mask"))?; - if let GroupKind::Mask { mode: m, inverted: inv, .. } = &mut g.kind { - if let Some(mm) = mode { - *m = mm; - } - if let Some(i) = inverted { - *inv = i; - } - } - Ok(()) - })?; - Ok(Value::Null) -} - -fn add_shape_item(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.addShapeItem")?; - let kind = str_p(p, "kind").ok_or_else(|| bad("layer.addShapeItem", "missing `kind`"))?.to_string(); - // `group`: a uid or a property path (`contents/group`). - let group_uid = match p.get("group") { - Some(Value::String(path)) => Some( - s.project - .comp(cid) - .and_then(|c| c.layer(lid)) - .and_then(|l| l.props.group(path)) - .map(|g| g.uid) - .ok_or_else(|| bad("layer.addShapeItem", format!("no group `{path}`")))?, - ), - v => v.and_then(Value::as_u64), - }; - let uid = s.edit("Add Shape Item", None, |proj, _| { - let mut next = proj.next_id; - let mut ids = Ids(&mut next); - // Front / Bevel / Side / Back ▸ property: into the group's surface item (one per group). - if let Some((surface, prop)) = build::shape_surface_kind(&kind) { - let target = shape_item_target(proj, cid, lid, group_uid)?; - let item = match target.children.iter().position(|c| matches!(c, Node::Group(g) if g.match_id == surface)) { - Some(i) => i, - None => { - let g = build::shape_surface(&mut ids, surface).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?; - target.children.push(g.into()); - target.children.len() - 1 - } - }; - let Some(Node::Group(g)) = target.children.get_mut(item) else { return Err(bad("layer.addShapeItem", "no such group")) }; - if g.get(prop).is_none() { - let p = build::shape_surface_prop(&mut ids, prop).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?; - g.children.push(p.into()); - } - let uid = g.uid; - proj.next_id = next; - return Ok(uid); - } - let g = match kind.as_str() { - "group" => build::shape_group(&mut ids, "Group 1", vec![]), - "rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0), - "ellipse" => build::shape_ellipse(&mut ids, [200.0, 200.0], [0.0, 0.0]), - "star" => build::shape_star(&mut ids, true, 5.0, [0.0, 0.0], 100.0, 50.0), - "polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0), - "fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]), - "stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0), - "path" => build::shape_path(&mut ids, Default::default()), - "gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()), - "gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0), - "trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0), - "repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]), - k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?, - }; - let uid = g.uid; - proj.next_id = next; - shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into()); - Ok(uid) - })?; - // The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`). - let path = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.match_path_of(uid)); - Ok(json!({"uid": uid, "path": path})) -} - -/// The contents a new shape item goes into: the shape group `group_uid` (its Contents), else the -/// layer's Contents. -fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: Option) -> Result<&mut PropGroup> { - let l = layer_mut(proj, cid, lid)?; - let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?; - Ok(match group_uid { - Some(u) => contents - .find_group_mut(u) - .and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") }) - .ok_or_else(|| bad("layer.addShapeItem", "no such group"))?, - None => contents, - }) -} - -fn set_text(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.setText")?; - // The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display). - if let Some(f) = str_p(p, "font") { - s.prefs.push_recent_font(f); - s.save_prefs(); - } - let t = s.time(); - let range = text_range_p(p); - // Character attributes with only a caret (no selected text) set the insertion style. - if let Some(r) = range.clone().filter(|r| r.is_empty() && str_p(p, "text").is_none()) - && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) - && let Some(doc) = super::text_edit::layer_doc(s, lid) - && doc.char_len() > 0 - && let Some(obj) = p.as_object() - && let Some(e) = s.state.text_edit.as_mut() - { - let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start)); - let mut any = false; - for (k, v) in obj { - any |= effectcraft_keyframe::text_doc::apply_char_attr(&mut st, k, v).map_err(|m| bad("layer.setText", m))?; - } - if any { - e.pending = Some(st); - } - s.bump(); - } - s.edit("Edit Text", merge_p(p), |proj, _| { - let l = layer_mut(proj, cid, lid)?; - let lt = l.layer_time(t); - let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?; - let mut doc = match pr.value_at(lt) { - KV::Text(d) => *d, - KV::Str(t) => TextDoc::plain(&t), - _ => TextDoc::default(), - }; - apply_text_params("layer.setText", p, &mut doc, range.clone())?; - pr.set_value_at(lt, KV::Text(Box::new(doc.clone()))); - if let Some(first) = doc.text.lines().next() - && str_p(p, "text").is_some() - && !first.is_empty() - && (l.name.starts_with("Text") || l.name.is_empty()) - { - l.name = first.chars().take(40).collect(); - } - Ok(()) - })?; - Ok(Value::Null) -} - -/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well -/// as the combined property the channels were split from. -pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) { - if dx == 0.0 && dy == 0.0 { - return; - } - if tr.get("positionX").is_some() { - for (name, d) in [("positionX", dx), ("positionY", dy)] { - if let Some(pr) = tr.get_mut(name) { - pr.value = KV::Scalar(pr.value.as_f64() + d); - for k in &mut pr.keys { - k.value = KV::Scalar(k.value.as_f64() + d); - } - } - } - } - if let Some(pr) = tr.get_mut("position") { - let shift = |v: &KV| match v { - KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]), - other => { - let a = other.as_vec3(); - KV::Vec3([a[0] + dx, a[1] + dy, a[2]]) - } - }; - pr.value = shift(&pr.value); - for k in &mut pr.keys { - k.value = shift(&k.value); - } - } -} - -/// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions -/// are separated. -pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { - const SEPARATE: [&str; 3] = ["positionX", "positionY", "positionZ"]; - if tr.get("positionX").is_some() { - let v = f(SEPARATE.map(|m| tr.get(m).map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0))); - for (m, x) in SEPARATE.iter().zip(v) { - if let Some(pr) = tr.get_mut(m) { - pr.set_value_at(lt, KV::Scalar(x)); - } - } - } else if let Some(pr) = tr.get_mut("position") { - let v = f(pr.value_at(lt).as_vec3()); - pr.set_value_at(lt, KV::Vec3(v)); - } -} - -/// A Transform property's default (Reset) by match id: the anchor at the centre of the source -/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation. -pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option { - Some(match m { - "anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]), - "position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]), - "positionX" => KV::Scalar(cw / 2.0), - "positionY" => KV::Scalar(ch / 2.0), - "positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0), - "scale" => KV::Vec3([100.0; 3]), - "orientation" => KV::Vec3([0.0; 3]), - "opacity" => KV::Scalar(100.0), - _ => return None, - }) -} - -fn transform_op(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let ids = unlocked(s, cid, ids, "layer.transform")?; - let op = str_p(p, "op").unwrap_or("reset").to_string(); - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); - let t = s.time(); - let sizes: Vec<(LayerId, (u32, u32))> = - comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); - s.edit("Transform", None, |proj, _| { - for (lid, (w, h)) in &sizes { - let l = layer_mut(proj, cid, *lid)?; - let lt = l.layer_time(t); - let (cw, ch) = (comp.width as f64, comp.height as f64); - let (w, h) = (*w as f64, *h as f64); - let Some(tr) = l.transform_mut() else { continue }; - let set = |tr: &mut PropGroup, m: &str, v: KV| { - if let Some(pr) = tr.get_mut(m) { - pr.set_value_at(lt, v); - } - }; - match op.as_str() { - "reset" => { - for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] { - if let Some(v) = transform_default(m, (w, h), (cw, ch)) { - set(tr, m, v); - } - } - update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]); - } - // Centred in the frame; a 3D layer keeps its depth. - "center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]), - "fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => { - let (sx, sy) = match op.as_str() { - "fitWidth" => (cw / w, cw / w), - "fitHeight" => (ch / h, ch / h), - _ => (cw / w, ch / h), - }; - let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); - let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); - set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); - update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); - } - "flipH" | "flipV" => { - let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); - let v = if op == "flipH" { [-cur[0], cur[1], cur[2]] } else { [cur[0], -cur[1], cur[2]] }; - set(tr, "scale", KV::Vec3(v)); - } - _ => {} - } - } - Ok(()) - })?; - Ok(Value::Null) -} - -/// Layer ▸ Layer Settings: cameras and lights open their own settings; solids (and adjustment -/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other -/// layers also use is copied into a new solid for this layer instead of being changed for all of -/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and -/// its solid. -fn layer_settings(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.settings")?; - // Cameras and lights open their own settings (Camera Settings / Light Settings). - match s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.source.clone()) { - Some(LayerSource::Camera) => return s.execute("layer.cameraSettings", with_layer(p, lid)), - Some(LayerSource::Light { .. }) => return s.execute("layer.lightSettings", with_layer(p, lid)), - _ => {} - } - let color = color_p(p, "color"); - let w = p.get("width").and_then(Value::as_u64).map(limits::size); - let h = p.get("height").and_then(Value::as_u64).map(limits::size); - let pixel_aspect = pixel_aspect_p(p)?; - let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); - let solid = s.edit("Layer Settings", None, |proj, _| { - let src = layer_mut(proj, cid, lid)?.source.clone(); - if let Some(n) = &name { - layer_mut(proj, cid, lid)?.name = n.clone(); - } - let LayerSource::Solid { item } = src else { return Ok(None) }; - let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; - let target = if shared && !affect_all { - // This layer gets its own copy of the solid. - let it = proj.item(item).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?.clone(); - let copy = proj.add_item(name.as_deref().unwrap_or(&it.name), it.label, it.parent, it.kind); - layer_mut(proj, cid, lid)?.source = LayerSource::Solid { item: copy }; - copy - } else { - item - }; - let it = proj.item_mut(target).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?; - if let Some(n) = &name { - it.name = n.clone(); - } - if let ItemKind::Solid(so) = &mut it.kind { - so.color = color.unwrap_or(so.color); - so.width = w.unwrap_or(so.width); - so.height = h.unwrap_or(so.height); - so.pixel_aspect = pixel_aspect.unwrap_or(so.pixel_aspect); - } - Ok(Some(target)) - })?; - Ok(match solid { - Some(item) => json!({"layer": lid.0, "solid": item.0}), - None => json!({"layer": lid.0}), - }) -} - -/// Layer Settings applies to solids (and adjustment layers), nulls, cameras and lights. -fn has_layer_settings(s: &Session) -> std::result::Result<(), String> { - has_layers(s)?; - let l = s.active_comp().zip(s.state.selected_layers.first()).and_then(|(c, l)| c.layer(*l)).ok_or("select a layer first")?; - match l.source { - LayerSource::Solid { .. } | LayerSource::Null | LayerSource::Camera | LayerSource::Light { .. } => Ok(()), - _ => Err("this kind of layer has no settings".into()), - } -} - -fn with_layer(p: &Value, lid: LayerId) -> Value { - let mut p = if p.is_object() { p.clone() } else { json!({}) }; - p["layer"] = json!(lid.0); - p -} - -pub fn specs() -> Vec { - vec![ - cmd!( - "layer.newText", - "Text", - ["Layer", "New"], - Some("Cmd+Alt+Shift+T"), - "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)", - has_comp, - new_text - ), - cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?, pixelAspect?}", has_comp, new_solid), - cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null), - cmd!( - "layer.newShape", - "Shape Layer", - ["Layer", "New"], - None, - "{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}", - has_comp, - new_shape - ), - cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment), - cmd!( - "layer.settings", - "Layer Settings...", - ["Layer"], - Some("Cmd+Shift+Y"), - "{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", - has_layer_settings, - layer_settings - ), - cmd!( - "layer.addItem", - "Add Footage to Comp", - ["File"], - Some("Cmd+/"), - "{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}", - has_comp, - add_item - ), - cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select), - cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next), - cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev), - cmd!("layer.extendSelectionNext", "Extend Selection to Next Layer", [], Some("Cmd+Shift+ArrowDown"), "{}", has_comp, |s, _| select_step( - s, - &json!({"add": true}), - 1 - )), - cmd!("layer.extendSelectionPrevious", "Extend Selection to Previous Layer", [], Some("Cmd+Shift+ArrowUp"), "{}", has_comp, |s, _| select_step( - s, - &json!({"add": true}), - -1 - )), - cmd!( - "layer.setSwitch", - "Layer Switch", - [], - None, - "{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}", - has_layers, - set_switch - ), - cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename), - cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment), - cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend), - cmd!( - "layer.setTrackMatte", - "Track Matte", - [], - None, - "{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}", - has_layers, - track_matte - ), - cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null, compensate?: bool}", has_layers, set_parent), - cmd!( - "layer.timing", - "Layer Timing", - [], - None, - "{layers?, op?: moveInToTime|moveOutToTime|moveInToStart|moveOutToEnd|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}", - has_layers, - timing - ), - cmd!("layer.moveInToStart", "Move Layer In Point to Beginning of Comp", [], Some("Alt+Home"), "{layers?}", has_layers, |s, p| timing( - s, - &with(p, "op", json!("moveInToStart")) - )), - cmd!("layer.moveOutToEnd", "Move Layer Out Point to End of Comp", [], Some("Alt+End"), "{layers?}", has_layers, |s, p| timing( - s, - &with(p, "op", json!("moveOutToEnd")) - )), - cmd!("layer.moveEarlier", "Move Layers 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -1)), - cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)), - cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)), - cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)), - cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip), - cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| { - let mut p = p.clone(); - p["frames"] = json!(-1); - slip(s, &p) - }), - cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], None, "{layers?}", has_layers, |s, p| { - let mut p = p.clone(); - p["frames"] = json!(1); - slip(s, &p) - }), - cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?, above?: layer}", has_layers, arrange), - cmd!( - "layer.precompose", - "Pre-compose...", - ["Layer"], - Some("Cmd+Shift+C"), - "{layers?, name?, mode?: move|leave, adjustDuration?, open?}", - has_layers, - precompose - ), - cmd!( - "layer.addMask", - "New Mask", - ["Layer", "Mask"], - Some("Cmd+Shift+N"), - "{layer?, shape?: rect|ellipse|rounded|polygon|star, rect? [x,y,w,h] (layer space; a polygon or star fits its width), mode?}", - has_layers, - add_mask - ), - cmd!("layer.setMask", "Mask Mode", [], None, "{layer?, mask: index|uid|name, mode?, inverted?}", has_layers, mask_props), - cmd!( - "layer.addShapeItem", - "Add (Shape)", - [], - None, - "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", - has_layers, - add_shape_item - ), - cmd!( - "layer.setText", - "Edit Text", - [], - None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", - has_layers, - set_text - ), - cmd!( - "layer.transform", - "Transform", - ["Layer", "Transform"], - None, - "{layers?, op: reset|center|fit|fitWidth|fitHeight|flipH|flipV}", - has_layers, - transform_op - ), - ] -} - -#[allow(dead_code)] -fn unused(_: &Comp) {} - -#[cfg(test)] -mod parent_chain_tests { - use super::*; - use std::sync::Arc; - - fn fixture() -> (Session, u64, u64, u64) { - let mut s = Session::default(); - s.execute("comp.new", json!({"name":"Parent safety","width":16,"height":16,"duration":1})).unwrap(); - let a = s.execute("layer.newSolid", json!({"name":"A"})).unwrap()["layer"].as_u64().unwrap(); - let b = s.execute("layer.newSolid", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap(); - let c = s.execute("layer.newSolid", json!({"name":"C"})).unwrap()["layer"].as_u64().unwrap(); - for id in [a, b, c] { - s.execute("prop.set", json!({"layer":id,"path":"transform/anchor","value":[0,0,0]})).unwrap(); - } - s.execute("prop.set", json!({"layer":a,"path":"transform/position","value":[100,200,0]})).unwrap(); - s.execute("prop.set", json!({"layer":c,"path":"transform/position","value":[10,20,0]})).unwrap(); - s.history.undo.clear(); - (s, a, b, c) - } - fn malformed_parent(s: &mut Session, child: u64, parent: u64) { - let cid = s.active_comp_id().unwrap(); - Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(child)).unwrap().parent = Some(LayerId(parent)); - } - fn assert_atomic_error(s: &mut Session, child: u64, parent: u64, compensate: bool, message: &str) { - let before = s.project.clone(); - let revision = s.revision; - let undo = s.history.undo.len(); - let err = s.execute("layer.setParent", json!({"layers":[child],"parent":parent,"compensate":compensate})).unwrap_err(); - assert!(err.to_string().contains(message), "{err}"); - assert!(Arc::ptr_eq(&before, &s.project)); - assert_eq!(s.revision, revision); - assert_eq!(s.history.undo.len(), undo); - } - #[test] - fn loaded_cycle_excluding_selected_child_is_rejected_atomically() { - for compensate in [true, false] { - let (mut s, a, b, c) = fixture(); - // Simulate a malformed loaded project. Never execute this on the unguarded baseline. - malformed_parent(&mut s, a, b); - malformed_parent(&mut s, b, a); - assert_atomic_error(&mut s, c, a, compensate, "existing cycle"); - } - } - #[test] - fn dangling_loaded_parent_is_rejected_atomically() { - for compensate in [true, false] { - let (mut s, a, _, c) = fixture(); - malformed_parent(&mut s, a, u64::MAX); - assert_atomic_error(&mut s, c, a, compensate, "missing layer"); - } - } - #[test] - fn selected_descendant_and_direct_self_are_rejected() { - let (mut s, a, _, c) = fixture(); - assert_atomic_error(&mut s, c, c, false, "create a cycle"); - s.execute("layer.setParent", json!({"layers":[a],"parent":c,"compensate":false})).unwrap(); - assert_atomic_error(&mut s, c, a, true, "create a cycle"); - } - #[test] - fn ordinary_parent_and_unparent_keep_explicit_false_and_undo_semantics() { - for compensate in [true, false] { - let (mut s, a, _, c) = fixture(); - let old = s.project.clone(); - let world = |s: &Session| { - let cid = s.active_comp_id().unwrap(); - let comp = s.active_comp().unwrap(); - let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time()); - ctx.world_matrix(comp.layer(LayerId(c)).unwrap()).apply(effectcraft_geom::Vec3::ZERO) - }; - let before = world(&s); - s.execute("layer.setParent", json!({"layers":[c],"parent":a,"compensate":compensate})).unwrap(); - let expected = if compensate { before } else { before + effectcraft_geom::vec3(100.0, 200.0, 0.0) }; - assert!((world(&s) - expected).length() < 1e-9); - assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, Some(LayerId(a))); - assert_eq!(s.history.undo.len(), 1); - let parented = s.project.clone(); - assert!(s.undo()); - assert!(Arc::ptr_eq(&s.project, &old)); - assert!(s.redo()); - assert!(Arc::ptr_eq(&s.project, &parented)); - s.execute("layer.setParent", json!({"layers":[c],"parent":null,"compensate":compensate})).unwrap(); - assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, None); - assert!(s.undo()); - assert!(Arc::ptr_eq(&s.project, &parented)); - } - } - #[test] - fn ancestor_and_lookup_work_budgets_fail_closed() { - let (mut s, a, b, c) = fixture(); - malformed_parent(&mut s, a, b); - let comp = s.active_comp().unwrap(); - // New solids are inserted above the selection: C,B,A. Walking A->B scans - // three then two layers, and compares the one selected child twice: seven work units. - assert_eq!(comp.layers.iter().map(|l| l.id).collect::>(), vec![LayerId(c), LayerId(b), LayerId(a)]); - assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 7).is_ok()); - assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 6).unwrap_err().to_string().contains("work limit")); - assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 256).is_ok()); - assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 1, 256).unwrap_err().to_string().contains("visit limit")); - assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 1).unwrap_err().to_string().contains("work limit")); - assert!(check_parent_chain(comp, &[LayerId(c)], None, 0, 0).is_ok(), "unparenting does not traverse ancestors"); - } - - #[test] - fn well_formed_chain_beyond_renderer_depth_is_not_unnecessarily_rejected() { - let (s, _, _, c) = fixture(); - let mut comp = s.active_comp().unwrap().clone(); - let template = comp.layers.first().unwrap().clone(); - for n in 0..80 { - let mut layer = template.clone(); - layer.id = LayerId(10_000 + n); - layer.parent = (n < 79).then_some(LayerId(10_001 + n)); - comp.layers.push(layer); - } - assert!(check_parent_chain(&comp, &[LayerId(c)], Some(LayerId(10_000)), 4096, 1_048_576).is_ok()); - } -} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs deleted file mode 100644 index 23388594b..000000000 --- a/crates/engine/src/commands/mod.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! 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 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, - } - }; -} - -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]) -} - -// ---------- parameter validation (agents) ---------- - -/// Top-level keys accepted by a params doc such as `{layers: [id|name|#n], add?, toggle?}` or -/// `{time? (s) | frame? | timecode?}`; `None` when the doc isn't a `{…}` key list. -pub fn accepted_params(doc: &str) -> Option> { - let body = doc.trim().strip_prefix('{')?; - // Top-level pieces up to the matching close brace, split at `,` and `|`; `:` starts a value. - let (mut keys, mut cur, mut depth, mut in_value) = (Vec::new(), String::new(), 0i32, false); - let push = |cur: &mut String, keys: &mut Vec| { - let k: String = cur.trim().chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); - if !k.is_empty() && !keys.contains(&k) { - keys.push(k); - } - cur.clear(); - }; - for c in body.chars() { - match c { - '{' | '[' | '(' => depth += 1, - ']' | ')' => depth -= 1, - '}' if depth == 0 => { - push(&mut cur, &mut keys); - return Some(keys); - } - '}' => depth -= 1, - ',' if depth == 0 => { - push(&mut cur, &mut keys); - in_value = false; - } - '|' if depth == 0 && !in_value => push(&mut cur, &mut keys), - ':' if depth == 0 => { - push(&mut cur, &mut keys); - in_value = true; - } - _ if depth == 0 && !in_value => cur.push(c), - _ => {} - } - } - None -} - -/// Top-level pieces of a params doc (split at depth-0 commas), e.g. `{layer?, path|prop, value}` -/// → `["layer?", "path|prop", "value"]`. -fn doc_pieces(doc: &str) -> Option> { - let body = doc.trim().strip_prefix('{')?; - let (mut out, mut cur, mut depth) = (Vec::new(), String::new(), 0i32); - for c in body.chars() { - match c { - '{' | '[' | '(' => depth += 1, - ']' | ')' => depth -= 1, - '}' if depth == 0 => { - if !cur.trim().is_empty() { - out.push(cur.trim().to_string()); - } - return Some(out); - } - '}' => depth -= 1, - ',' if depth == 0 => { - if !cur.trim().is_empty() { - out.push(cur.trim().to_string()); - } - cur.clear(); - continue; - } - _ => {} - } - cur.push(c); - } - None -} - -/// The JSON Schema of a command's parameters, derived from its params doc: one property per -/// accepted key (described by its piece of the doc), no others. MCP `describe_command` and -/// `list_commands {schemas: true}` serve it. -pub fn params_schema(spec: &CommandSpec) -> Value { - let mut props = serde_json::Map::new(); - let mut required = vec![]; - for piece in doc_pieces(spec.params).unwrap_or_default() { - let keys = accepted_params(&format!("{{{piece}}}")).unwrap_or_default(); - let alternatives = keys.len() > 1; - for k in keys { - // `key?` is optional; so is any key with alternatives (`path|prop`). - let optional = alternatives || piece.contains(&format!("{k}?")); - if !optional && !required.contains(&k) { - required.push(k.clone()); - } - props.insert(k, serde_json::json!({"description": piece})); - } - } - serde_json::json!({ - "type": "object", - "description": spec.params, - "properties": props, - "required": required, - "additionalProperties": accepted_params(spec.params).is_none(), - }) -} - -/// Keys every command understands (comp targeting, gesture merging) and accepted aliases. -const ALWAYS_OK: &[&str] = &["comp", "merge"]; -const ALIASES: &[(&str, &str)] = &[("layers", "layer"), ("layer", "layers"), ("time", "frame"), ("frameRate", "fps"), ("properties", "property")]; - -/// Reject unknown top-level parameters (typos, guessed names) with the list of accepted keys. -pub fn check_params(spec: &CommandSpec, params: &Value) -> Result<()> { - let (Some(obj), Some(accepted)) = (params.as_object(), accepted_params(spec.params)) else { return Ok(()) }; - let ok = |k: &str| ALWAYS_OK.contains(&k) || accepted.iter().any(|a| a == k || ALIASES.iter().any(|(doc, alias)| doc == a && *alias == k)); - let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect(); - if unknown.is_empty() { - return Ok(()); - } - let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") }; - Err(bad( - spec.id, - format!("unknown parameter(s) {}; accepted: {list} (doc: {})", unknown.iter().map(|k| format!("`{k}`")).collect::>().join(", "), spec.params), - )) -} - -// ---------- 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())) -} - -/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]). -pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result { - s.events.push(crate::Event::Frontend { command: id.to_string(), params: p.clone() }); - Ok(serde_json::json!({"frontend": id})) -} - -/// Match path of a node (`transform/position`, `effects/#2/blurriness` style with `match#n` -/// occurrences) so it can be found again on another layer. -pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_project::Uid) -> Option { - for c in &g.children { - let nth = g.children.iter().take_while(|x| !std::ptr::eq(*x, c)).filter(|x| x.match_id() == c.match_id()).count() + 1; - let seg = if nth == 1 { c.match_id().to_string() } else { format!("{}#{nth}", c.match_id()) }; - if c.uid() == uid { - return Some(seg); - } - if let effectcraft_project::Node::Group(sub) = c - && let Some(rest) = match_path_of(sub, uid) - { - return Some(format!("{seg}/{rest}")); - } - } - None -} - -/// Turn `effects/[#n]/` (as [`match_path_of`] writes it) into a uid path on -/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart. -/// Any other path, or an effect that is not on the layer, comes back unchanged. -pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String { - let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; - let (match_id, nth) = match seg.rsplit_once('#') { - Some((m, n)) if !m.is_empty() => match n.parse::() { - Ok(n) if n >= 1 => (m, n), - _ => (seg, 1), - }, - _ => (seg, 1), - }; - match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) { - Some(g) => format!("effects/@{}/{tail}", g.uid), - None => path.to_string(), - } -} - -/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to -/// their properties. -pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> { - let Some(comp) = s.active_comp() else { return vec![] }; - let mut out = vec![]; - for (lid, uid) in &s.state.selected_props { - let Some(l) = comp.layer(*lid) else { continue }; - if l.props.find(*uid).is_some() { - out.push((*lid, *uid)); - } else if let Some(g) = l.props.find_group(*uid) { - g.walk("", &mut |_, p| out.push((*lid, p.uid))); - } - } - out -} - -// ---------- params ---------- - -pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { - EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } -} -pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { - p.get(k).and_then(Value::as_str) -} -pub(crate) fn f_p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_f64) -} -pub(crate) fn b_p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_bool) -} -pub(crate) fn merge_p(p: &Value) -> Option<&str> { - str_p(p, "merge") -} - -/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP -/// clients often send numbers as strings); default the active comp. -pub(crate) fn comp_id(s: &Session, p: &Value) -> Result { - let missing = |v: &Value| EngineError::NoSuchComp(v.to_string()); - match p.get("comp") { - Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)), - Some(v @ Value::String(name)) => s - .project - .items - .values() - .find(|i| &i.name == name && i.as_comp().is_some()) - .map(|i| i.id) - .or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some())) - .ok_or_else(|| missing(v)), - _ => s.active_comp_id().ok_or(EngineError::NoComp), - } -} - -/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`. -fn digits_id(s: &str) -> Option { - if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - s.parse().ok() -} - -/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string -/// that names no layer is resolved as the number it spells. -pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option { - match v { - Value::Number(n) => { - let id = n.as_u64()?; - if comp.layer(LayerId(id)).is_some() { Some(LayerId(id)) } else { comp.layers.get((id as usize).checked_sub(1)?).map(|l| l.id) } - } - Value::String(s) => { - if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::().ok()) { - return comp.layers.get(i.checked_sub(1)?).map(|l| l.id); - } - comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id)))) - } - _ => None, - } -} - -/// `layer` param or the first selected layer. -pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId)> { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - if let Some(v) = p.get("layer") { - return resolve_layer(comp, v).map(|l| (cid, l)).ok_or_else(|| bad(cmd, format!("no layer {v}"))); - } - s.state - .selected_layers - .first() - .copied() - .filter(|l| comp.layer(*l).is_some()) - .map(|l| (cid, l)) - .ok_or_else(|| bad(cmd, "no `layer` given and no layer selected")) -} - -/// `layers` param (array) or the selection. -pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - // Explicit references that match no layer are an error (agents otherwise get a silent no-op). - let resolve = - |v: &Value| resolve_layer(comp, v).ok_or_else(|| EngineError::Other(format!("no layer {v} (use a layer id, \"#n\" or a name from comp.info)"))); - if let Some(Value::Array(a)) = p.get("layers") { - return Ok((cid, a.iter().map(resolve).collect::>()?)); - } - if let Some(v) = p.get("layer").filter(|v| !v.is_null()) { - return Ok((cid, vec![resolve(v)?])); - } - Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) -} - -/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not -/// locked, and not hidden by the composition's Hide Shy Layers. -pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool { - !(l.switches.locked || (comp.hide_shy && l.switches.shy)) -} - -/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects. -/// An error when every one of them is locked. -pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec, cmd: &str) -> Result> { - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked); - match ids.iter().find(|id| locked(id)) { - Some(first) if ids.iter().all(locked) => { - let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default(); - Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)"))) - } - _ => Ok(ids.into_iter().filter(|id| !locked(id)).collect()), - } -} - -/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All -/// Keyframes use only those, as in After Effects). `None` when not given. -pub(crate) fn visible_p(p: &Value) -> Option> { - let a = p.get("visible")?.as_array()?; - Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect()) -} - -/// `time` (seconds) or `frame` param, else the CTI. -pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { - if let Some(t) = f_p(p, "time") { - return Tick::from_seconds_f64(t); - } - if let (Some(f), Some(c)) = (p.get("frame").and_then(Value::as_i64), comp) { - return c.frame_rate.tick_of(f); - } - s.time() -} - -pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { - p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) -} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs new file mode 100644 index 000000000..e5fb1791b --- /dev/null +++ b/crates/engine/src/doc_state.rs @@ -0,0 +1,197 @@ +use super::*; + +/// A keyframe reference: layer, property uid, key time (layer time). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct KeyRef { + pub layer: LayerId, + pub prop: Uid, + pub time: Tick, +} + +/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it +/// started from and the total offset so far, so every step is applied to the keys as they were +/// before the drag. A key passed over on the way is never overwritten; only the drop position +/// counts, as in After Effects. +#[derive(Clone, Debug)] +pub struct KeyMove { + pub merge: String, + pub from: Vec, + pub total: Tick, +} + +/// Keyframes of one property on the keyframe clipboard. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KeyClip { + pub layer: LayerId, + /// Match-id path relative to the layer (`transform/position`), portable between layers. + pub path: String, + /// Keys with times relative to the earliest copied key (layer time). + pub keys: Vec, +} + +/// A selected mask vertex: layer, mask group uid, vertex index. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VertexRef { + pub layer: LayerId, + pub mask: Uid, + pub index: usize, +} + +/// One composition's selection. The active comp's copy lives in [`EditorState::selected_layers`] +/// (and the property, key, and vertex lists); every other comp keeps its own here (#1001). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CompSelection { + pub layers: Vec, + pub props: Vec<(LayerId, Uid)>, + pub keys: Vec, + pub vertices: Vec, +} + +impl CompSelection { + pub(crate) fn is_empty(&self) -> bool { + self.layers.is_empty() && self.props.is_empty() && self.keys.is_empty() && self.vertices.is_empty() + } +} + +/// Editing state that commands depend on (headless-relevant, serde for agents). +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct EditorState { + pub active_comp: Option, + /// Open compositions (viewer/timeline tabs), in tab order. + pub open_comps: Vec, + /// Compositions that were active before the current one, most recent first (Shift+Esc). + #[serde(default)] + pub recent_comps: Vec, + /// Current time per comp (the CTI). + pub times: std::collections::BTreeMap, + /// Layer selection of the active comp. Other comps' selections are in [`Self::comp_selections`]. + pub selected_layers: Vec, + /// Selections of comps that are not active, keyed by comp id (#1001). + #[serde(default)] + pub comp_selections: std::collections::BTreeMap, + /// Selected properties / groups (by uid) of selected layers. + pub selected_props: Vec<(LayerId, Uid)>, + pub selected_keys: Vec, + pub project_selection: Vec, + /// Layer clipboard (serialized layers) and keyframe clipboard. + #[serde(skip)] + pub clipboard: Vec, + /// Copied keyframes (times relative to the earliest copied key). + #[serde(skip)] + pub key_clipboard: Vec, + /// The last copy was keyframes (Paste pastes keys at the CTI). + #[serde(skip)] + pub clip_is_keys: bool, + /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; + /// Paste sets the same properties of the selected layers. + #[serde(skip)] + pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, + /// Copied effect instances, with comp-time key offsets from the earliest copied key. + /// Paste adds them to the selected layers at the destination comp's CTI. + #[serde(skip)] + pub effect_clipboard: Vec, + /// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in + /// a shape layer's Contents); Paste adds them to the selected shape layers. + #[serde(skip)] + pub contents_clipboard: Vec, + /// Selected mask vertices (viewer Selection tool / pen). + #[serde(default)] + pub selected_vertices: Vec, + pub snapping: bool, + /// Tools bar ▸ Snapping options (which features snap, Snap Edges Extended). + #[serde(default)] + pub snap_features: viewer::SnapFeatures, + /// Last applied effect id (Effect ▸ last effect). + pub last_effect: Option, + /// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region + /// of Interest). + #[serde(default)] + pub region_of_interest: Option<[f64; 4]>, + /// Property-link clipboard (Edit ▸ Copy with Property Links / Copy Expression Only). + #[serde(skip)] + pub link_clipboard: Option, + /// File ▸ Interpret Footage ▸ Remember Interpretation. + #[serde(skip)] + pub interpretation: Option, + /// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras). + #[serde(default)] + pub views3d: std::collections::BTreeMap, + /// Tracker panel ▸ Current Track: (tracked layer, tracker group uid) in the active comp. + #[serde(default)] + pub current_track: Option<(LayerId, Uid)>, + /// Tracker panel ▸ Method in mask mode (Track Mask). + #[serde(default)] + pub mask_track_method: mask_track::MaskMethod, + /// 3D Camera Tracker: the selected track points (ids) in the viewer. + #[serde(default)] + pub camera_points: Vec, + /// Mask Interpolation panel options. + #[serde(default)] + pub mask_interp: commands::mask_interp::MaskInterpOptions, + /// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools). + #[serde(default)] + pub paint: commands::paint::PaintOptions, + /// Roto Brush and Refine Edge tool options. + #[serde(default)] + pub roto: roto::RotoOptions, + /// Puppet tool options for new meshes. + #[serde(default)] + pub puppet: commands::puppet::PuppetOptions, + /// The shape tools' and the Pen's options (Tool Creates Shape / Mask, Fill and Stroke). + #[serde(default)] + pub shape_tool: commands::shape_tool::ShapeTool, + /// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer. + #[serde(default = "one_view")] + pub view_layout: u8, + /// View ▸ Switch View Layout ▸ Share View Options (grid, guides… in every view). + #[serde(default)] + pub share_view_options: bool, + /// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines). + #[serde(default)] + pub hide_locked_masks: bool, + /// On-canvas text editing (Type tool): the edited layer, selection and insertion style. + #[serde(default)] + pub text_edit: Option, + /// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only. + #[serde(skip)] + pub text_clipboard: Option, + /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). + #[serde(default)] + pub viewer: commands::viewer_cmds::ViewOptions, + /// Essential Graphics panel ▸ Primary composition (`None` = the active comp). + #[serde(default)] + pub essential_primary: Option, + /// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties + /// Essential Graphics can expose). + #[serde(default)] + pub essential_solo: bool, + /// File ▸ Watch Folder: the folder being watched for projects to render (frontends call + /// `file.watchFolder.poll` periodically while it is set). + #[serde(default)] + pub watch_folder: Option, + /// The Footage panel: the footage shown, its time and In/Out marks (source time). + #[serde(default)] + pub footage_panel: Option, + /// Media Browser: the folder shown and the favourites. + #[serde(default)] + pub media_browser: media_browser::BrowserState, + /// Content-Aware Fill panel settings. + #[serde(default)] + pub content_fill: commands::content_fill::FillSettings, + /// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view. + #[serde(default)] + pub locked_viewer: Option, +} + +fn one_view() -> u8 { + 1 +} + +/// What Copy with Property Links / Copy Expression Only put on the clipboard. +#[derive(Clone, Debug, PartialEq)] +pub enum LinkClip { + /// Expressions that link back to the source properties: (match path, expression text). + Links { relative: bool, links: Vec<(String, String)> }, + /// Expressions only: (match path, expression). + Expressions(Vec<(String, effectcraft_project::Expression)>), +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 000000000..75e9e4467 --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,20 @@ +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no active composition")] + NoComp, + /// A `comp` reference (name or id) that names no composition. + #[error("no composition {0}")] + NoSuchComp(String), + #[error("{0}")] + Project(#[from] effectcraft_project::ProjectError), + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs new file mode 100644 index 000000000..0e60575de --- /dev/null +++ b/crates/engine/src/event.rs @@ -0,0 +1,32 @@ +use super::*; + +/// Events for frontends (drained each frame). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum Event { + ProjectChanged { + revision: u64, + }, + Toast { + message: String, + error: bool, + }, + OpenComp(ItemId), + OpenUrl(String), + /// A frontend-only command (viewer zoom, panels, dialogs…) ran: the UI performs it. Headless + /// sessions ignore these. + Frontend { + command: String, + params: Value, + }, + /// Drop cached frames / renders (Edit ▸ Purge). + PurgeCaches, + /// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is + /// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are + /// not serialized. + CopyImage { + width: u32, + height: u32, + #[serde(skip)] + rgba: Vec, + }, +} diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 000000000..039fd2bad --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,92 @@ +use super::*; + +pub fn text_families() -> Vec { + effectcraft_text::families().into_iter().map(|(f, _)| f).collect() +} + +/// The styles a family offers (its own faces, in weight order), for the style menus. A family +/// that isn't installed offers the usual styles, which resolve to Inter's. +pub fn font_styles(family: &str) -> Vec { + effectcraft_text::families() + .into_iter() + .find(|(f, _)| f.eq_ignore_ascii_case(family)) + .map(|(_, st)| st) + .filter(|st| !st.is_empty()) + .unwrap_or_else(|| ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect()) +} + +/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to. +pub fn font_features(family: &str, style: &str) -> Vec { + effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() +} + +/// One row of the Character panel's font menu. +#[derive(Clone, Debug, PartialEq)] +pub struct FontRow { + /// The family to apply (empty for the separator after the recent fonts). + pub family: String, + /// What the menu shows: the English family name, or the font's own-language name when + /// Settings ▸ Type ▸ Show Font Names in English is off. + pub display: String, + pub recent: bool, + /// A variable font (the menu's Variable Fonts filter keeps only these). + pub variable: bool, +} + +/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for +/// the font menu's preview (Settings ▸ Type ▸ Show Font Preview). +pub fn font_preview(family: &str, size: f64) -> Vec> { + use kurbo::PathEl; + let st = effectcraft_keyframe::text_doc::CharStyle { font: family.to_string(), size, ..Default::default() }; + let mut out = vec![]; + let mut x = 0.0; + for ch in "Sample".chars() { + let (path, adv) = effectcraft_text::char_glyph_style(&st, ch); + let mut cur: Vec<[f32; 2]> = vec![]; + let pt = |p: kurbo::Point| [(p.x + x) as f32, p.y as f32]; + kurbo::flatten(&path, 0.2, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur = vec![pt(p)]; + } + PathEl::LineTo(p) => cur.push(pt(p)), + PathEl::ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push(cur); + } + x += adv; + } + out +} + +/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts +/// to Display), a separator, then every family. Each row says whether the family is variable, +/// for the menu's Variable Fonts filter. +pub fn font_menu(prefs: &prefs::Prefs) -> Vec { + let all = text_families(); + let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() }; + let variable = effectcraft_text::fonts::variable_families(); + let row = |f: &String, recent: bool| FontRow { + family: f.clone(), + display: native.get(f).cloned().unwrap_or_else(|| f.clone()), + recent, + variable: variable.contains(f), + }; + let mut out: Vec = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect(); + if !out.is_empty() { + out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false }); + } + out.extend(all.iter().map(|f| row(f, false))); + out +} diff --git a/crates/engine/src/footage.rs b/crates/engine/src/footage.rs new file mode 100644 index 000000000..21e67d8be --- /dev/null +++ b/crates/engine/src/footage.rs @@ -0,0 +1,6 @@ +pub mod doc_path; +pub mod footage_check; +pub mod footage_reload; +pub mod media_browser; +pub mod media_cache; +pub mod sequence; diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/footage/doc_path.rs similarity index 86% rename from crates/engine/src/doc_path.rs rename to crates/engine/src/footage/doc_path.rs index c7416a8ce..7161b6177 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/footage/doc_path.rs @@ -3,7 +3,7 @@ //! document's folder, and only local files, or files on the network share the document itself is //! on, are followed (see `craft_docpath`). //! -//! Loading a document resolves its footage paths once ([`resolve_footage`]): followed paths are +//! Loading a document resolves its footage paths once (`resolve_footage`): followed paths are //! replaced by the resolved path, and an item with a refused path is marked missing, so the media //! layer never reads it. The session then remembers which paths came from which document //! ([`StoredPaths`]); the footage check, the changed-on-disk scan, Reload Footage, Relink, Edit @@ -46,13 +46,22 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result { + NotFollowed::NoProjectFolder + } + why => NotFollowed::Refused(why), + }) +} + +/// Whether `p` is a Windows drive or share path, which a project cannot make absolute outside Windows. +fn is_windows_rooted(p: &Path) -> bool { + let s = p.to_string_lossy(); + let b = s.as_bytes(); + (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || s.starts_with(r"\\") || s.starts_with("//") } /// The paths of `f` (its file and every frame of an image sequence) resolved against the document diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/footage/doc_path/tests.rs similarity index 85% rename from crates/engine/src/doc_path/tests.rs rename to crates/engine/src/footage/doc_path/tests.rs index b8dcbc190..d9d971247 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/footage/doc_path/tests.rs @@ -34,9 +34,11 @@ impl Services for Recording { } } -/// Probes every file as a still. +/// Probes every file as a still. Only the Unix part of the test below imports files. +#[cfg(unix)] struct Stills; +#[cfg(unix)] impl crate::Importer for Stills { fn probe(&self, path: &str) -> Result { if !std::path::Path::new(path).is_file() { @@ -147,7 +149,23 @@ fn stored_paths_resolve_against_the_project() { let doc = doc.to_str().unwrap(); assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov"))); assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder)); + assert_eq!(super::resolve("../logo.png", None), Err(super::NotFollowed::NoProjectFolder)); + // A drive path is not a local file on Unix, with or without a project: it is not a missing project. + #[cfg(unix)] + for stored in [r"C:\x.mov", "C:x.mov"] { + assert_eq!(super::resolve(stored, None), Err(super::NotFollowed::Refused(super::Refused::Invalid)), "{stored}"); + } assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network))); + assert_eq!(super::resolve("jar:clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Url))); + // Windows reads a colon in a name as an alternate data stream; elsewhere it is a plain character. + if cfg!(windows) { + assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid))); + } else { + assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("shots").join("take:1.mov"))); + } + // A Windows drive path names no file on other platforms. + #[cfg(unix)] + assert_eq!(super::resolve(r"C:\art\clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid))); #[cfg(windows)] assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok()); } diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage/footage_check.rs similarity index 100% rename from crates/engine/src/footage_check.rs rename to crates/engine/src/footage/footage_check.rs diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs similarity index 92% rename from crates/engine/src/footage_reload.rs rename to crates/engine/src/footage/footage_reload.rs index fa12b27aa..75fd0f936 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -129,17 +129,22 @@ pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result { /// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read /// (only without `undoable`). fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet)> { - if s.importer.is_none() { + let Some(importer) = s.importer.clone() else { return Err(crate::EngineError::Other("media import is not available in this build".into())); - } + }; let mut updates = vec![]; let mut paths = BTreeSet::new(); let mut failed = BTreeSet::new(); for i in ids { if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { - // Probed the way it imports by default, so a sequence is found as one. A path that - // is not followed is not read. - let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path)); + // A sequence is probed the way it imports by default, so its run is found again; any + // other item is its one file, not the run a numbered still sits in (#2074). A path + // that is not followed is not read. + let probed = s + .stored_paths + .check(f) + .map_err(|(p, why)| crate::doc_path::refusal(&p, why)) + .and_then(|_| if f.kind == FootageKind::Sequence { s.probe_footage(&f.path) } else { importer.probe(&f.path) }); let nf = match probed { Ok(p) => refreshed(s, f, p), Err(_) if undoable => Footage { missing: true, ..f.clone() }, @@ -154,6 +159,14 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi } } let n = updates.len(); + apply_reloads(s, updates, undoable)?; + forget(s, &paths); + Ok((n, failed)) +} + +/// Puts reloaded footage into the project: an undoable edit, or (not undoable) a disk change +/// that leaves the project's dirty state as it was. +fn apply_reloads(s: &mut Session, updates: Vec<(ItemId, Footage)>, undoable: bool) -> crate::Result<()> { if undoable { s.edit("Reload Footage", None, |proj, _| { for (i, f) in updates { @@ -183,8 +196,7 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi } } } - forget(s, &paths); - Ok((n, failed)) + Ok(()) } /// Make the media layer and the layer cache let go of `paths`, so renders read them again. @@ -193,6 +205,8 @@ fn forget(s: &mut Session, paths: &BTreeSet) { s.footage.forget(p); } s.layer_cache.clear(); + // Reloaded files may keep their footage settings but not their size and time. + s.refresh_disk_salt(true); s.events.push(crate::Event::PurgeCaches); } diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/footage/media_browser.rs similarity index 91% rename from crates/engine/src/media_browser.rs rename to crates/engine/src/footage/media_browser.rs index d22875f17..c0e20be71 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/footage/media_browser.rs @@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option { std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } -/// Format seconds since the epoch as an ISO 8601 UTC date-time. +/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own +/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn iso_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ")) +} + +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days` +/// always fits an `i64`. +fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// File dates and size: (created, modified, size). @@ -506,5 +517,10 @@ mod tests { assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z"); assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z"); + assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z"); + assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z"); } } diff --git a/crates/engine/src/media_cache.rs b/crates/engine/src/footage/media_cache.rs similarity index 100% rename from crates/engine/src/media_cache.rs rename to crates/engine/src/footage/media_cache.rs diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/footage/sequence.rs similarity index 98% rename from crates/engine/src/sequence.rs rename to crates/engine/src/footage/sequence.rs index c98aba1d3..46dca154c 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/footage/sequence.rs @@ -20,7 +20,7 @@ use crate::{Importer, Services}; /// How an import treats numbered stills. #[derive(Clone, Copy, Debug)] pub struct SequenceOptions { - /// The " Sequence" checkbox: picked numbered stills of one run import as a sequence. + /// The `" Sequence"` checkbox: picked numbered stills of one run import as a sequence. pub sequence: bool, /// The numbered stills inside a picked or dropped folder import as sequences. pub folders: bool, @@ -44,7 +44,7 @@ impl SequenceOptions { /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` - /// (Alt-drag of a folder). The Import dialog passes its " Sequence" checkbox (off + /// (Alt-drag of a folder). The Import dialog passes its `" Sequence"` checkbox (off /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered /// still brings its run, as it always has. pub fn for_import(p: &serde_json::Value) -> SequenceOptions { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 8b178a05c..6e3d928c5 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -314,6 +314,9 @@ impl Session { } // Every state off the new line goes into branches: a chain per first child, the other // children starting branches of their own. + /// Adds to `out` one [`Branch`] for the off-line subtree at `start`: it follows the first + /// child that is not on the new history line, and records every other such child as a further + /// branch from the state it forked at (`parent`). fn chain(a: &Arena, start: usize, parent: usize, in_new: &[bool], out: &mut Vec) { let mut steps = vec![]; let mut n = start; @@ -341,13 +344,14 @@ impl Session { let k = new_line.iter().position(|&i| i == target).unwrap_or(0); let undo: Vec<(String, Arc)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect(); let redo: Vec<(String, Arc)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect(); + let edited = crate::commands::text_edit::active_editor_snapshot(self); let cur = std::mem::replace(&mut self.project, a.state[target].clone()); self.keep_view_settings(&cur); self.history.undo = undo; self.history.redo = redo; self.history.branches = branches; self.history.merge_key = None; - self.sanitize_state(); + self.sanitize_after_replacement(edited.as_ref()); self.bump(); true } diff --git a/crates/engine/src/home.rs b/crates/engine/src/home.rs new file mode 100644 index 000000000..183461aa4 --- /dev/null +++ b/crates/engine/src/home.rs @@ -0,0 +1,4 @@ +pub mod demo; +pub mod learn; +pub mod links; +pub mod templates; diff --git a/crates/engine/src/demo.rs b/crates/engine/src/home/demo.rs similarity index 100% rename from crates/engine/src/demo.rs rename to crates/engine/src/home/demo.rs diff --git a/crates/engine/src/learn.rs b/crates/engine/src/home/learn.rs similarity index 100% rename from crates/engine/src/learn.rs rename to crates/engine/src/home/learn.rs diff --git a/crates/engine/src/links.rs b/crates/engine/src/home/links.rs similarity index 100% rename from crates/engine/src/links.rs rename to crates/engine/src/home/links.rs diff --git a/crates/engine/src/templates.rs b/crates/engine/src/home/templates.rs similarity index 99% rename from crates/engine/src/templates.rs rename to crates/engine/src/home/templates.rs index a8009f476..500308b72 100644 --- a/crates/engine/src/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -486,10 +486,10 @@ pub fn encode_thumb(w: u32, h: u32, rgba: &[u8]) -> String { /// Decode a thumbnail to RGB bytes (THUMB_W×THUMB_H×3). pub fn decode_thumb(text: &str) -> Option> { let t = text.trim(); - if t.len() != THUMB_W * THUMB_H * 6 { + if t.len() != THUMB_W * THUMB_H * 6 || !t.is_ascii() { return None; } - (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect() + (0..t.len()).step_by(2).map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok()).collect() } /// Render a project's main comp at its poster time into a thumbnail. @@ -943,6 +943,12 @@ mod tests { use super::*; use crate::config::{DirConfig, MemoryConfig}; + #[test] + fn non_ascii_thumbnail_is_rejected_without_panicking() { + let malformed = format!("aé{}", "0".repeat(THUMB_W * THUMB_H * 6 - 3)); + assert!(decode_thumb(&malformed).is_none()); + } + #[test] fn every_builtin_builds_with_controls_and_renders() { let s = Session::default(); diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs new file mode 100644 index 000000000..ec4cff579 --- /dev/null +++ b/crates/engine/src/interchange.rs @@ -0,0 +1,3 @@ +pub mod psd_import; +pub mod vector; +pub mod xml_project; diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/interchange/psd_import.rs similarity index 100% rename from crates/engine/src/psd_import.rs rename to crates/engine/src/interchange/psd_import.rs diff --git a/crates/engine/src/vector.rs b/crates/engine/src/interchange/vector.rs similarity index 99% rename from crates/engine/src/vector.rs rename to crates/engine/src/interchange/vector.rs index 3c349a7df..324d3a82a 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -160,7 +160,7 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[ /// Blend Mode). Top of the paint order first. /// /// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with -/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the +/// the clip paths through Merge Paths ▸ Intersect (see `clipped_items`). Clips enclosing the /// whole document are layer masks instead ([`vector_clip_masks`], used by /// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see /// [`split_at_images`]); soft masks are not converted. diff --git a/crates/engine/src/xml_project.rs b/crates/engine/src/interchange/xml_project.rs similarity index 100% rename from crates/engine/src/xml_project.rs rename to crates/engine/src/interchange/xml_project.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..2174a87ca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,45 +10,20 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -pub mod autosave; -pub mod camera_track; -pub mod commands; -pub mod config; -pub mod demo; -pub mod doc_path; -pub mod footage_check; -pub mod footage_reload; -pub mod guard; +mod analysis; +mod background; +pub mod cmd; +mod footage; pub mod history; -pub mod jobs; -pub mod learn; -pub mod links; -pub mod logging; -pub mod mask_track; -pub mod media_browser; -pub mod media_cache; +mod home; +mod interchange; pub mod menus; -pub mod models; -pub mod offload; -pub mod perf; -pub mod prefs; pub mod preview; -pub mod psd_import; -pub mod remote; pub mod render_queue; -pub mod roto; pub mod scriptui; -pub mod sequence; -mod session_settings; -pub mod shortcuts; -pub mod storage; -pub mod sysinfo; -pub mod templates; -pub mod tracking; -pub mod vector; +mod settings; +mod system; pub mod viewer; -pub mod warp; -pub mod xml_project; use std::sync::Arc; @@ -59,7 +34,17 @@ use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub use commands::{CommandSpec, command_specs, find as find_command}; +pub use analysis::camera_track; +pub use analysis::mask_track; +pub use analysis::models; +pub use analysis::roto; +pub use analysis::tracking; +pub use analysis::warp; +pub use background::jobs; +pub use background::offload; +pub use background::remote; +pub use cmd as commands; +pub use cmd::{CommandSpec, command_specs, find as find_command}; pub use effectcraft_color as color; pub use effectcraft_effects as effects; pub use effectcraft_geom as geom; @@ -71,329 +56,59 @@ pub use effectcraft_segment as segment; pub use effectcraft_text as text; pub use effectcraft_time as time; pub use effectcraft_track as track; +pub use footage::doc_path; +pub use footage::footage_check; +pub use footage::footage_reload; +pub use footage::media_browser; +pub use footage::media_cache; +pub use footage::sequence; pub use history::{Branch, History, HistoryNode}; +pub use home::demo; +pub use home::learn; +pub use home::links; +pub use home::templates; +pub use interchange::psd_import; +pub use interchange::vector; +pub use interchange::xml_project; pub use render_queue::{ExportJob, ExportResult, Exporter, JobState}; +pub use settings::autosave; +pub use settings::config; +pub use settings::prefs; +pub use settings::shortcuts; +pub use system::guard; +pub use system::logging; +pub use system::perf; +pub use system::storage; +pub use system::sysinfo; -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("unknown command `{0}`")] - UnknownCommand(String), - #[error("command `{0}` is not available right now: {1}")] - Disabled(String, String), - #[error("invalid parameters for `{cmd}`: {msg}")] - BadParams { cmd: String, msg: String }, - #[error("no active composition")] - NoComp, - /// A `comp` reference (name or id) that names no composition. - #[error("no composition {0}")] - NoSuchComp(String), - #[error("{0}")] - Project(#[from] effectcraft_project::ProjectError), - #[error("{0}")] - Other(String), -} +mod doc_state; +mod error; +mod event; +mod fonts; +mod services; +mod session; -pub type Result = std::result::Result; - -/// Host services (file access) injected by the frontend. -pub trait Services: Send + Sync { - fn read_file(&self, path: &str) -> std::io::Result>; - fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>; - /// Whether a file exists (the background footage check after open). - fn exists(&self, path: &str) -> bool { - std::path::Path::new(path).exists() - } - /// A file's size and modification time (nanoseconds since 1970), to notice that it changed - /// on disk (Settings ▸ Import ▸ Reload Footage Changed on Disk); `None` when it can't be read. - fn stamp(&self, path: &str) -> Option<(u64, u128)> { - let meta = std::fs::metadata(path).ok()?; - let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; - Some((meta.len(), modified.as_nanos())) - } - /// Keep a file the app made for itself (footage extracted from a template): like - /// [`Services::write_file`], creating its folder, but never offered as a download (web: it - /// goes to browser storage only). - fn store_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { - if let Some(d) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { - let _ = std::fs::create_dir_all(d); - } - self.write_file(path, data) - } - /// The files in folder `dir` (full paths; no folders, no hidden files), for image-sequence - /// detection on import. Empty when it can't be read. - fn list_dir(&self, dir: &str) -> Vec { - let Ok(rd) = std::fs::read_dir(if dir.is_empty() { "." } else { dir }) else { return vec![] }; - rd.filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && !e.file_name().to_string_lossy().starts_with('.')) - .map(|e| e.path().to_string_lossy().to_string()) - .collect() - } - /// Whether `path` is a folder (dropped or picked folders import their files). - fn is_dir(&self, path: &str) -> bool { - std::path::Path::new(path).is_dir() - } -} - -/// Native filesystem. -pub struct FsServices; -impl Services for FsServices { - fn read_file(&self, path: &str) -> std::io::Result> { - std::fs::read(path) - } - fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { - config::atomic_write(std::path::Path::new(path), data) - } -} - -/// Imports media files into the project (implemented by the media layer). -pub trait Importer: Send + Sync { - /// Probe one file and return footage metadata. Image sequences are the engine's - /// ([`sequence`]): it groups numbered stills and probes their first file here. - fn probe(&self, path: &str) -> std::result::Result; - /// A footage file's bytes are now at `path` (extracted from a template): hosts whose media - /// layer reads from memory (the browser) register them; file-based ones need nothing. - fn register(&self, _path: &str, _data: &[u8]) {} - /// An image sequence's other frames (its first was probed) are footage now: hosts whose - /// media layer reads from memory (the browser) register them; file-based ones need nothing. - fn add_frames(&self, _paths: &[String]) {} -} - -/// A keyframe reference: layer, property uid, key time (layer time). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct KeyRef { - pub layer: LayerId, - pub prop: Uid, - pub time: Tick, -} - -/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it -/// started from and the total offset so far, so every step is applied to the keys as they were -/// before the drag. A key passed over on the way is never overwritten; only the drop position -/// counts, as in After Effects. -#[derive(Clone, Debug)] -pub struct KeyMove { - pub merge: String, - pub from: Vec, - pub total: Tick, -} - -/// Keyframes of one property on the keyframe clipboard. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct KeyClip { - pub layer: LayerId, - /// Match-id path relative to the layer (`transform/position`), portable between layers. - pub path: String, - /// Keys with times relative to the earliest copied key (layer time). - pub keys: Vec, -} - -/// A selected mask vertex: layer, mask group uid, vertex index. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct VertexRef { - pub layer: LayerId, - pub mask: Uid, - pub index: usize, -} - -/// One composition's selection. The active comp's copy lives in [`EditorState::selected_layers`] -/// (and the property, key, and vertex lists); every other comp keeps its own here (#1001). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct CompSelection { - pub layers: Vec, - pub props: Vec<(LayerId, Uid)>, - pub keys: Vec, - pub vertices: Vec, -} - -impl CompSelection { - fn is_empty(&self) -> bool { - self.layers.is_empty() && self.props.is_empty() && self.keys.is_empty() && self.vertices.is_empty() - } -} - -/// Editing state that commands depend on (headless-relevant, serde for agents). -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct EditorState { - pub active_comp: Option, - /// Open compositions (viewer/timeline tabs), in tab order. - pub open_comps: Vec, - /// Compositions that were active before the current one, most recent first (Shift+Esc). - #[serde(default)] - pub recent_comps: Vec, - /// Current time per comp (the CTI). - pub times: std::collections::BTreeMap, - /// Layer selection of the active comp. Other comps' selections are in [`Self::comp_selections`]. - pub selected_layers: Vec, - /// Selections of comps that are not active, keyed by comp id (#1001). - #[serde(default)] - pub comp_selections: std::collections::BTreeMap, - /// Selected properties / groups (by uid) of selected layers. - pub selected_props: Vec<(LayerId, Uid)>, - pub selected_keys: Vec, - pub project_selection: Vec, - /// Layer clipboard (serialized layers) and keyframe clipboard. - #[serde(skip)] - pub clipboard: Vec, - /// Copied keyframes (times relative to the earliest copied key). - #[serde(skip)] - pub key_clipboard: Vec, - /// The last copy was keyframes (Paste pastes keys at the CTI). - #[serde(skip)] - pub clip_is_keys: bool, - /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; - /// Paste sets the same properties of the selected layers. - #[serde(skip)] - pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, - /// Copied effect instances, with comp-time key offsets from the earliest copied key. - /// Paste adds them to the selected layers at the destination comp's CTI. - #[serde(skip)] - pub effect_clipboard: Vec, - /// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in - /// a shape layer's Contents); Paste adds them to the selected shape layers. - #[serde(skip)] - pub contents_clipboard: Vec, - /// Selected mask vertices (viewer Selection tool / pen). - #[serde(default)] - pub selected_vertices: Vec, - pub snapping: bool, - /// Tools bar ▸ Snapping options (which features snap, Snap Edges Extended). - #[serde(default)] - pub snap_features: viewer::SnapFeatures, - /// Last applied effect id (Effect ▸ last effect). - pub last_effect: Option, - /// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region - /// of Interest). - #[serde(default)] - pub region_of_interest: Option<[f64; 4]>, - /// Property-link clipboard (Edit ▸ Copy with Property Links / Copy Expression Only). - #[serde(skip)] - pub link_clipboard: Option, - /// File ▸ Interpret Footage ▸ Remember Interpretation. - #[serde(skip)] - pub interpretation: Option, - /// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras). - #[serde(default)] - pub views3d: std::collections::BTreeMap, - /// Tracker panel ▸ Current Track: (tracked layer, tracker group uid) in the active comp. - #[serde(default)] - pub current_track: Option<(LayerId, Uid)>, - /// Tracker panel ▸ Method in mask mode (Track Mask). - #[serde(default)] - pub mask_track_method: mask_track::MaskMethod, - /// 3D Camera Tracker: the selected track points (ids) in the viewer. - #[serde(default)] - pub camera_points: Vec, - /// Mask Interpolation panel options. - #[serde(default)] - pub mask_interp: commands::mask_interp::MaskInterpOptions, - /// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools). - #[serde(default)] - pub paint: commands::paint::PaintOptions, - /// Roto Brush and Refine Edge tool options. - #[serde(default)] - pub roto: roto::RotoOptions, - /// Puppet tool options for new meshes. - #[serde(default)] - pub puppet: commands::puppet::PuppetOptions, - /// The shape tools' and the Pen's options (Tool Creates Shape / Mask, Fill and Stroke). - #[serde(default)] - pub shape_tool: commands::shape_tool::ShapeTool, - /// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer. - #[serde(default = "one_view")] - pub view_layout: u8, - /// View ▸ Switch View Layout ▸ Share View Options (grid, guides… in every view). - #[serde(default)] - pub share_view_options: bool, - /// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines). - #[serde(default)] - pub hide_locked_masks: bool, - /// On-canvas text editing (Type tool): the edited layer, selection and insertion style. - #[serde(default)] - pub text_edit: Option, - /// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only. - #[serde(skip)] - pub text_clipboard: Option, - /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). - #[serde(default)] - pub viewer: commands::viewer_cmds::ViewOptions, - /// Essential Graphics panel ▸ Primary composition (`None` = the active comp). - #[serde(default)] - pub essential_primary: Option, - /// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties - /// Essential Graphics can expose). - #[serde(default)] - pub essential_solo: bool, - /// File ▸ Watch Folder: the folder being watched for projects to render (frontends call - /// `file.watchFolder.poll` periodically while it is set). - #[serde(default)] - pub watch_folder: Option, - /// The Footage panel: the footage shown, its time and In/Out marks (source time). - #[serde(default)] - pub footage_panel: Option, - /// Media Browser: the folder shown and the favourites. - #[serde(default)] - pub media_browser: media_browser::BrowserState, - /// Content-Aware Fill panel settings. - #[serde(default)] - pub content_fill: commands::content_fill::FillSettings, - /// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view. - #[serde(default)] - pub locked_viewer: Option, -} - -fn one_view() -> u8 { - 1 -} - -/// `t` as a current time of `comp`: a frame within its duration. -fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { - comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) -} - -/// What Copy with Property Links / Copy Expression Only put on the clipboard. -#[derive(Clone, Debug, PartialEq)] -pub enum LinkClip { - /// Expressions that link back to the source properties: (match path, expression text). - Links { relative: bool, links: Vec<(String, String)> }, - /// Expressions only: (match path, expression). - Expressions(Vec<(String, effectcraft_project::Expression)>), -} - -/// Events for frontends (drained each frame). -#[derive(Clone, Debug, PartialEq, Serialize)] -pub enum Event { - ProjectChanged { - revision: u64, - }, - Toast { - message: String, - error: bool, - }, - OpenComp(ItemId), - OpenUrl(String), - /// A frontend-only command (viewer zoom, panels, dialogs…) ran: the UI performs it. Headless - /// sessions ignore these. - Frontend { - command: String, - params: Value, - }, - /// Drop cached frames / renders (Edit ▸ Purge). - PurgeCaches, - /// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is - /// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are - /// not serialized. - CopyImage { - width: u32, - height: u32, - #[serde(skip)] - rgba: Vec, - }, -} +pub use cmd::{animation_presets, text_presets}; +pub use doc_state::{CompSelection, EditorState, KeyClip, KeyMove, KeyRef, LinkClip, VertexRef}; +pub use error::{EngineError, Result}; +pub use event::Event; +pub use fonts::{FontRow, font_features, font_menu, font_preview, font_styles, text_families}; +pub use services::{FsServices, Importer, Services}; +pub use session::upgrade_effects; pub struct Session { pub project: Arc, pub history: History, pub revision: u64, + /// Process-unique monotonic revision of the on-canvas editor's committed text and session. + /// Unlike [`Session::revision`], unrelated project edits do not advance it; the IME receipt + /// uses it when surrounding text is unavailable. Read it with + /// [`commands::text_edit::ime_stamp_revision`]. + /// + /// Stamps come from a process-global checked allocator and are never rewound: a failed + /// atomic batch keeps the highest intermediate stamp. On allocator exhaustion no stamp is + /// issued, so an unavailable receipt cannot be accepted with a reused revision. + pub ime_revision: u64, pub saved_revision: u64, /// The project as last opened or saved: the project is unmodified while it is this very /// snapshot, so undoing back to the saved state clears the modified mark. @@ -521,6 +236,8 @@ pub struct Session { pub footage_stamps: std::collections::HashMap, /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). pub footage_scan: Option, + /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). + pub disk_salt: Option<(u64, u64)>, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one /// is, settings that guard what scripts may do can't change (see /// [`prefs::Scripting::same_permissions`]). @@ -553,6 +270,10 @@ pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value; pub type PluginLoader = fn(&[u8], &str) -> std::result::Result; impl Default for Session { + /// A new unsaved session: a default project that is both current and saved, no path, no + /// active jobs, the plain `FsServices`/`NoFootage` defaults, default preferences and keymaps, + /// empty history, events and journal, and a fresh process-wide IME stamp (`u64::MAX` when the + /// allocator is exhausted). fn default() -> Self { let project = Arc::new(Project::default()); Session { @@ -560,6 +281,8 @@ impl Default for Session { project, history: History::default(), revision: 0, + // A fresh session gets its own process-monotonic stamp even before its first edit. + ime_revision: commands::text_edit::next_ime_revision().unwrap_or(u64::MAX), saved_revision: 0, path: None, state: EditorState { snapping: true, view_layout: 1, ..Default::default() }, @@ -618,817 +341,14 @@ impl Default for Session { check_footage_on_open: false, stored_paths: Default::default(), footage_stamps: Default::default(), + disk_salt: None, footage_scan: None, script_calls: ScriptCalls::default(), } } } -impl Session { - pub fn new() -> Session { - Session::default() - } - - /// Run a command by id. - pub fn execute(&mut self, id: &str, params: Value) -> Result { - let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - if let Err(why) = (spec.enabled)(self) { - // Explicit targets (agents, scripts) don't need a UI selection. - let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok()) - || ["item", "items"].iter().any(|k| params.get(k).is_some()) - // An explicit composition (the enablement looks at the active one). - || params.get("comp").is_some_and(|c| !c.is_null() && self.resolve_comp(Some(c)).is_ok()); - if !explicit { - return Err(EngineError::Disabled(id.to_string(), why)); - } - } - // Last-resort guard: a panicking command reports an error instead of crashing the app. - let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms))?; - self.learn_observe(id, ¶ms); - if spec.journal { - self.journal.push((id.to_string(), params)); - if self.journal.len() > 10_000 { - self.journal.drain(..1000); - } - } - Ok(r) - } - - /// [`Session::execute`] for agents and scripts (MCP, CLI, control channel): unknown top-level - /// parameters are rejected with the accepted keys instead of being silently ignored. - pub fn execute_checked(&mut self, id: &str, params: Value) -> Result { - let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - commands::check_params(spec, ¶ms)?; - self.execute(id, params) - } - - /// [`Session::execute`] for a command a script asked for (the JavaScript engine's - /// `app.run`, ScriptUI callbacks…). The command and everything it runs count as the script's: - /// it may not change the settings that limit scripts (Settings ▸ Scripting & Expressions ▸ - /// Application Scripting), which only the user can, from the UI, CLI or control channel. - pub fn execute_for_script(&mut self, id: &str, params: Value) -> Result { - self.script_calls.0 = self.script_calls.0.saturating_add(1); - let r = self.execute(id, params); - self.script_calls.0 = self.script_calls.0.saturating_sub(1); - r - } - - /// Whether the command running was issued by a script ([`Session::execute_for_script`]). - pub fn running_for_script(&self) -> bool { - self.script_calls.0 > 0 - } - - pub fn is_enabled(&self, id: &str) -> bool { - commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) - } - - /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. - pub fn edit(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { - let before = self.project.clone(); - let mut p = (*self.project).clone(); - let mut st = self.state.clone(); - let r = f(&mut p, &mut st)?; - // Layer styles: one Global Light per comp, whichever layer edited it. - effectcraft_project::styles::sync_global_light(&before, &mut p); - // Essential Properties of precomp layers follow their comps' Essential Graphics. - effectcraft_project::essential::sync_project(&before, &mut p); - // Warp Stabilizer analyses made from other frames are cleared (and queued again). - for w in warp::invalidate(&before, &mut p) { - if !self.warp_pending.contains(&w) { - self.warp_pending.push(w); - } - } - // So are 3D Camera Tracker tracks (and solves made with other settings). - for w in camera_track::invalidate(&before, &mut p) { - if !self.camera_pending.contains(&w) { - self.camera_pending.push(w); - } - } - // Roto Brush Input Keys follow the frames; edited instances propagate again. - for r in roto::sync(&before, &mut p) { - if !self.roto_pending.contains(&r) { - self.roto_pending.push(r); - } - } - // Nothing changed (a value set to what it already was): no undo step, not modified. - if p.same_content(&before) { - self.state = st; - return Ok(r); - } - let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key; - if !same { - // Undone steps aren't lost: they stay in the History panel as a branch. - let levels = self.prefs.general.undo_levels.max(1) as usize; - self.history.record(label, before, levels); - } else { - self.history.abandon_redo(&before); - } - self.history.merge_key = merge.map(str::to_string); - self.project = Arc::new(p); - self.state = st; - self.bump(); - Ok(r) - } - - pub fn bump(&mut self) { - self.revision += 1; - // Back at the saved state (undo, redo, a history jump or a rolled-back batch). - if Arc::ptr_eq(&self.project, &self.saved_project) { - self.saved_revision = self.revision; - } - if self.disk_cache.is_some() { - self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project)); - } - self.events.push(Event::ProjectChanged { revision: self.revision }); - } - - /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes - /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay - /// valid ([`effectcraft_project::Comp::same_pixels`]). - pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> { - if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r { - return Ok(()); - } - Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r; - self.bump(); - Ok(()) - } - - /// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the - /// project before undo, redo or a history jump restored another state. - pub(crate) fn keep_view_settings(&mut self, from: &Project) { - let keep: Vec<(ItemId, effectcraft_project::Resolution)> = - self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect(); - if keep.is_empty() { - return; - } - let p = Arc::make_mut(&mut self.project); - for (id, r) in keep { - if let Some(c) = p.comp_mut(id) { - c.resolution = r; - } - } - } - - pub fn undo(&mut self) -> bool { - let Some((label, p)) = self.history.undo.pop() else { return false }; - let cur = std::mem::replace(&mut self.project, p); - self.keep_view_settings(&cur); - self.history.redo.push((label, cur)); - self.history.merge_key = None; - self.sanitize_state(); - self.bump(); - true - } - - pub fn redo(&mut self) -> bool { - let Some((label, p)) = self.history.redo.pop() else { return false }; - let cur = std::mem::replace(&mut self.project, p); - self.keep_view_settings(&cur); - self.history.undo.push((label, cur)); - self.history.merge_key = None; - self.sanitize_state(); - self.bump(); - true - } - - /// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id - /// counts only inside the comp that owns it, so the active comp never sees another comp's layers. - pub fn sanitize_state(&mut self) { - let p = self.project.clone(); - self.state.open_comps.retain(|c| p.comp(*c).is_some()); - self.state.comp_selections.retain(|cid, sel| { - let Some(c) = p.comp(*cid) else { return false }; - let ids: Vec = c.layers.iter().map(|l| l.id).collect(); - sel.layers.retain(|l| ids.contains(l)); - sel.props.retain(|(l, _)| ids.contains(l)); - sel.keys.retain(|k| ids.contains(&k.layer)); - sel.vertices.retain(|v| ids.contains(&v.layer)); - !sel.is_empty() - }); - if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) { - self.clear_live_selection(); - self.state.active_comp = self.state.open_comps.first().copied(); - if let Some(id) = self.state.active_comp { - self.recall_selection(id); - } - } - if let Some(c) = self.active_comp() { - let ids: Vec = c.layers.iter().map(|l| l.id).collect(); - self.state.selected_layers.retain(|l| ids.contains(l)); - self.state.selected_props.retain(|(l, _)| ids.contains(l)); - self.state.selected_keys.retain(|k| ids.contains(&k.layer)); - self.state.selected_vertices.retain(|v| ids.contains(&v.layer)); - } - if let Some((l, t)) = self.state.current_track - && self.active_comp().and_then(|c| c.layer(l)).and_then(|l| l.tracker(t)).is_none() - { - self.state.current_track = None; - } - self.state.project_selection.retain(|i| p.item(*i).is_some()); - commands::text_edit::sanitize(self); - } - - pub fn is_dirty(&self) -> bool { - self.revision != self.saved_revision - } - - /// The project as it is now is the saved one (after opening or saving it). - pub fn mark_saved(&mut self) { - self.saved_project = self.project.clone(); - self.saved_revision = self.revision; - } - - /// The project has changes its file doesn't (a session restored with unsaved changes): it - /// stays modified until it is saved, whatever is undone. - pub fn mark_unsaved(&mut self) { - self.saved_project = Arc::new(Project::default()); - self.bump(); - } - - pub fn active_comp(&self) -> Option<&Comp> { - self.project.comp(self.state.active_comp?) - } - /// [`Session::active_comp`] as a shared snapshot (an `Arc` clone, not a deep copy). - pub fn active_comp_arc(&self) -> Option> { - self.project.comp_arc(self.state.active_comp?) - } - pub fn active_comp_id(&self) -> Option { - self.state.active_comp.filter(|c| self.project.comp(*c).is_some()) - } - - /// Current time of a comp. - pub fn time_of(&self, comp: ItemId) -> Tick { - self.state.times.get(&comp).copied().unwrap_or(Tick::ZERO) - } - - /// Current time of the active comp. - pub fn time(&self) -> Tick { - self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO) - } - - pub fn set_time(&mut self, t: Tick) { - if let Some(c) = self.state.active_comp { - let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); - self.state.times.insert(c, t); - if self.prefs.general.sync_time_related_items { - self.sync_related_times(c, t); - } - } - } - - /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of - /// `comp` moves the current time of the comps nested in it (through their precomp layers' - /// timing) and of the comps it is nested in, so every related viewer shows the same moment. - pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) { - let mut seen = std::collections::BTreeSet::from([comp]); - let mut todo = vec![(comp, t)]; - let mut found = vec![]; - while let Some((cid, ct)) = todo.pop() { - if cid != comp { - found.push((cid, ct)); - } - let Some(c) = self.project.comp(cid) else { continue }; - // Down: the comps this one nests. - for l in &c.layers { - if let effectcraft_project::LayerSource::Comp { item } = &l.source - && seen.insert(*item) - { - todo.push((*item, l.layer_time(ct))); - } - } - // Up: the comps nesting this one. - for iid in self.project.items.keys() { - if let Some(pc) = self.project.comp(*iid) - && let Some(l) = pc.layers.iter().find(|l| matches!(&l.source, effectcraft_project::LayerSource::Comp { item } if *item == cid)) - && seen.insert(*iid) - { - todo.push((*iid, l.comp_time(ct))); - } - } - } - for (cid, ct) in found { - if let Some(c) = self.project.comp(cid) { - let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); - self.state.times.insert(cid, ct); - } - } - } - - /// Make `id` the active comp, parking the previous comp's selection and restoring this one's. - pub fn activate_comp(&mut self, id: ItemId) { - if self.project.comp(id).is_none() || self.state.active_comp == Some(id) { - return; - } - self.remember_selection(); - self.state.active_comp = Some(id); - self.recall_selection(id); - self.state.current_track = None; - } - - /// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`]. - pub fn activate_comp_none(&mut self) { - self.remember_selection(); - self.state.active_comp = None; - self.clear_live_selection(); - self.state.current_track = None; - } - - fn remember_selection(&mut self) { - let Some(id) = self.state.active_comp else { return }; - let sel = CompSelection { - layers: self.state.selected_layers.clone(), - props: self.state.selected_props.clone(), - keys: self.state.selected_keys.clone(), - vertices: self.state.selected_vertices.clone(), - }; - if sel.is_empty() { - self.state.comp_selections.remove(&id); - } else { - self.state.comp_selections.insert(id, sel); - } - } - - fn recall_selection(&mut self, id: ItemId) { - match self.state.comp_selections.remove(&id) { - Some(sel) => { - self.state.selected_layers = sel.layers; - self.state.selected_props = sel.props; - self.state.selected_keys = sel.keys; - self.state.selected_vertices = sel.vertices; - } - None => self.clear_live_selection(), - } - } - - fn clear_live_selection(&mut self) { - self.state.selected_layers.clear(); - self.state.selected_props.clear(); - self.state.selected_keys.clear(); - self.state.selected_vertices.clear(); - } - - /// Open a comp in the viewer/timeline and make it active. - pub fn open_comp(&mut self, id: ItemId) { - if self.project.comp(id).is_none() { - return; - } - if !self.state.open_comps.contains(&id) { - self.state.open_comps.push(id); - } - if self.state.active_comp != Some(id) { - let previous = self.state.active_comp; - self.state.recent_comps.retain(|c| *c != id && Some(*c) != previous); - if let Some(previous) = previous { - self.state.recent_comps.insert(0, previous); - self.state.recent_comps.truncate(32); - } - } - self.activate_comp(id); - self.events.push(Event::OpenComp(id)); - } - - /// The open compositions and their current times, as a project file records them - /// ([`effectcraft_project::SavedView`]). - pub fn saved_view(&self) -> effectcraft_project::SavedView { - let comp_times = self.state.times.iter().filter(|(id, _)| self.project.comp(**id).is_some()).map(|(id, t)| (*id, *t)).collect(); - effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone(), comp_times } - } - - /// Reopen the compositions a project file was saved with (#748), each at its saved current - /// time (#1714). Ids that no longer name a comp are skipped; with none left the project stays - /// on the comp [`Session::replace_project`] opened (its first), as files from before the view - /// was saved do. - pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) { - for (id, t) in &view.comp_times { - if let Some(comp) = self.project.comp(*id) { - let t = comp_frame_at(comp, *t); - self.state.times.insert(*id, t); - } - } - let mut open: Vec = Vec::new(); - for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) { - if !open.contains(&id) { - open.push(id); - } - } - let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return }; - self.state.open_comps = open; - self.open_comp(active); - } - - pub fn toast(&mut self, msg: impl Into) { - self.events.push(Event::Toast { message: msg.into(), error: false }); - } - - pub fn drain_events(&mut self) -> Vec { - std::mem::take(&mut self.events) - } - - /// The 3D view camera of a comp's viewer (None = the active camera). - pub fn view_camera(&self, comp: ItemId) -> Option { - let c = self.project.comp(comp)?; - if !c.has_3d() { - return None; - } - self.state.views3d.get(&comp)?.override_camera(c.width as f64, c.height as f64) - } - - /// Render a comp frame with the session's footage + expression hosts. - pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { - let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; - let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); - r.expr = self.expr.as_deref(); - r.cache = Some(&self.layer_cache); - r.accel = self.accel.as_deref(); - r.comp_frame(comp, t) - } - - /// Resolve a comp reference (item id number or name; `None`/null = the active comp). - pub fn resolve_comp(&self, comp: Option<&Value>) -> Result { - let p = match comp { - Some(v) if !v.is_null() => serde_json::json!({ "comp": v }), - _ => serde_json::json!({}), - }; - commands::comp_id(self, &p) - } - - /// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled - /// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba). - pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec)> { - let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; - let long = c.width.max(c.height).max(1) as f64; - let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; - let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - Ok((img.width, img.height, img.to_rgba8_over(c.background))) - } - - /// A Project panel thumbnail of an item (a comp at `t`, footage at its first frame), longest - /// side at most `max_side`, as a job that owns everything it reads: frontends run it off the - /// UI thread (a big comp takes far longer than a frame). `None` for items without pixels. - pub fn thumbnail_job(&self, item: ItemId, t: Tick, max_side: u32) -> Option { - let it = self.project.item(item)?; - let footage = self.footage.clone(); - match &it.kind { - effectcraft_project::ItemKind::Comp(c) => { - let (project, expr, cache) = (self.project.clone(), self.expr.clone(), self.layer_cache.clone()); - let long = c.width.max(c.height).max(1) as f64; - let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; - let opts = RenderOpts { - scale, - nested_switches: self.prefs.general.switches_affect_nested_comps, - draft_shadows: self.prefs.three_d.realtime_shadows, - ..Default::default() - }; - let bg = c.background; - Some(Box::new(move || { - let mut r = Renderer::new(&project, footage.as_ref(), opts); - r.expr = expr.as_deref(); - r.cache = Some(&cache); - let img = r.comp_frame(item, t); - Some((img.width, img.height, img.to_rgba8_over(bg))) - })) - } - effectcraft_project::ItemKind::Footage(f) if f.has_video => { - let f = f.clone(); - Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; - // Point-sample down to at most `max_side` on the long side. - let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; - let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); - let full = img.to_rgba8(); - let mut px = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let i = (((y * step) * img.width + x * step) * 4) as usize; - px.extend_from_slice(&full[i..i + 4]); - } - } - Some((w, h, px)) - })) - } - _ => None, - } - } - - /// [`Session::render_rgba8`], or with `transparent` the frame's own straight alpha (what a - /// render with RGB + Alpha channels writes) instead of compositing over the background. - pub fn render_rgba8_alpha(&self, comp: ItemId, t: Tick, max_side: u32, transparent: bool) -> Result<(u32, u32, Vec)> { - if !transparent { - return self.render_rgba8(comp, t, max_side); - } - let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; - let long = c.width.max(c.height).max(1) as f64; - let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; - let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - Ok((img.width, img.height, img.to_rgba8())) - } - - /// Make the media layer and the layer cache let go of what they decoded from `footage`'s - /// files (it keys frames by path, so an overwritten file would keep showing its old pixels). - pub(crate) fn forget_decoded(&self, footage: &effectcraft_project::Footage) { - for p in std::iter::once(&footage.path).chain(&footage.sequence) { - self.footage.forget(p); - } - self.layer_cache.clear(); - } - - /// Replace the whole project (open/new), resetting history and state. - pub fn replace_project(&mut self, mut p: Project, path: Option) { - if self.stop_render() - && let Some(mut job) = self.render_job.take() - { - // Let the worker notice the cancel; its updates refer to the old project. - job.wait(); - } - self.render_job = None; - self.stored_paths = Default::default(); - self.stop_track(); - self.track_job = None; - self.stop_mask_track(); - self.mask_job = None; - self.stop_warp(); - self.warp_job = None; - self.warp_pending.clear(); - self.stop_camera(); - self.camera_job = None; - self.camera_pending.clear(); - self.stop_roto(); - self.roto_job = None; - self.roto_pending.clear(); - self.drop_tasks(); - self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1); - p.fix_next_id(); - upgrade_effects(&mut p); - // The media layer keys decoded frames by path: files of the new project may have changed - // since an earlier project decoded them (#1070). - for it in p.items.values() { - if let effectcraft_project::ItemKind::Footage(f) = &it.kind { - self.forget_decoded(f); - } - } - self.layer_cache.clear(); - self.project = Arc::new(p); - self.history = History::default(); - self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() }; - self.path = path; - self.bump(); - self.mark_saved(); - let first = self.project.comps().next().map(|(id, _)| *id); - if let Some(c) = first { - self.open_comp(c); - } - } -} - -/// Bring effect instances saved by earlier versions up to date with the effect registry -/// (renamed / regrouped / added parameters, reordered popups; see -/// [`effectcraft_effects::migrate`]), and a project from before -/// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space -/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema. -pub fn upgrade_effects(p: &mut Project) { - let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE; - // Effect bounds per layer: (size, top-left in layer coordinates). - let mut bounds = std::collections::HashMap::new(); - for (cid, c) in p.comps() { - for l in &c.layers { - bounds.insert((*cid, l.id), effectcraft_render::effect_bounds(p, c, l)); - } - } - let mut next = p.next_id; - for (cid, it) in p.items.iter_mut() { - let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue }; - for l in &mut std::sync::Arc::make_mut(c).layers { - let (size, origin) = bounds.get(&(*cid, l.id)).copied().unwrap_or(([100.0, 100.0], [0.0; 2])); - // Effect space starts at the bounds' top-left: positions move by minus that corner. - // Adjustment layers run their effects on the comp below as it is: nothing moves. - let shift = |o: [f64; 2]| [-o[0], -o[1]]; - let d = if l.switches.adjustment { [0.0; 2] } else { shift(origin) }; - let clone_d = |src: Option| match src { - Some(id) => bounds.get(&(*cid, effectcraft_project::LayerId(id))).map_or([0.0; 2], |b| shift(b.1)), - None => d, - }; - let Some(fx) = l.props.sub_mut("effects") else { continue }; - for n in &mut fx.children { - let effectcraft_project::Node::Group(g) = n else { continue }; - let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue }; - if let Some(spec) = effectcraft_effects::find(effect) { - effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size); - if to_effect_space { - effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d); - } - } - } - } - } - p.next_id = next; - p.schema = effectcraft_project::SCHEMA_VERSION; -} - #[cfg(test)] mod rq_tests; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_3d; -#[cfg(test)] -mod tests_anim_presets; -#[cfg(test)] -mod tests_anim_tools; -#[cfg(test)] -mod tests_camera_track; -#[cfg(test)] -mod tests_codec_options; -#[cfg(test)] -mod tests_color_view; -#[cfg(test)] -mod tests_delete_keys; -#[cfg(test)] -mod tests_disk_cache; -#[cfg(test)] -mod tests_effects; -#[cfg(test)] -mod tests_essential; -#[cfg(test)] -mod tests_essential_more; -#[cfg(test)] -mod tests_face; -#[cfg(test)] -mod tests_fidelity; -#[cfg(test)] -mod tests_frame_export; -#[cfg(test)] -mod tests_fuzz_open; -#[cfg(test)] -mod tests_import_project; -#[cfg(test)] -mod tests_key_metadata_order; -#[cfg(test)] -mod tests_keylight; -#[cfg(test)] -mod tests_limits; -#[cfg(test)] -mod tests_lottie; -#[cfg(test)] -mod tests_m137; -#[cfg(test)] -mod tests_m145; -#[cfg(test)] -mod tests_markers; -#[cfg(test)] -mod tests_mask_warp; -#[cfg(test)] -mod tests_menu_cmds; -#[cfg(test)] -mod tests_model3d; -#[cfg(test)] -mod tests_motion_blur_depth; -#[cfg(test)] -mod tests_project_items; -#[cfg(test)] -mod tests_proxy; -#[cfg(test)] -mod tests_quick_offset; -#[cfg(test)] -mod tests_rig3d; -#[cfg(test)] -mod tests_sequence; -#[cfg(test)] -mod tests_settings; -#[cfg(test)] -mod tests_shortcut_cmds; -#[cfg(test)] -mod tests_stubs; -#[cfg(test)] -mod tests_styles; -#[cfg(test)] -mod tests_tail; -#[cfg(test)] -mod tests_text; -#[cfg(test)] -mod tests_text_edit; -#[cfg(test)] -mod tests_timeline; -#[cfg(test)] -mod tests_timeline_interchange; -#[cfg(test)] -mod tests_track; -#[cfg(test)] -mod tests_vector_import; -#[cfg(test)] -mod tests_viewer; - -/// 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() -} - -pub fn text_families() -> Vec { - effectcraft_text::families().into_iter().map(|(f, _)| f).collect() -} - -/// The styles a family offers (its own faces, in weight order), for the style menus. A family -/// that isn't installed offers the usual styles, which resolve to Inter's. -pub fn font_styles(family: &str) -> Vec { - effectcraft_text::families() - .into_iter() - .find(|(f, _)| f.eq_ignore_ascii_case(family)) - .map(|(_, st)| st) - .filter(|st| !st.is_empty()) - .unwrap_or_else(|| ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect()) -} - -/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to. -pub fn font_features(family: &str, style: &str) -> Vec { - effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() -} - -/// One row of the Character panel's font menu. -#[derive(Clone, Debug, PartialEq)] -pub struct FontRow { - /// The family to apply (empty for the separator after the recent fonts). - pub family: String, - /// What the menu shows: the English family name, or the font's own-language name when - /// Settings ▸ Type ▸ Show Font Names in English is off. - pub display: String, - pub recent: bool, - /// A variable font (the menu's Variable Fonts filter keeps only these). - pub variable: bool, -} - -/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for -/// the font menu's preview (Settings ▸ Type ▸ Show Font Preview). -pub fn font_preview(family: &str, size: f64) -> Vec> { - use kurbo::PathEl; - let st = effectcraft_keyframe::text_doc::CharStyle { font: family.to_string(), size, ..Default::default() }; - let mut out = vec![]; - let mut x = 0.0; - for ch in "Sample".chars() { - let (path, adv) = effectcraft_text::char_glyph_style(&st, ch); - let mut cur: Vec<[f32; 2]> = vec![]; - let pt = |p: kurbo::Point| [(p.x + x) as f32, p.y as f32]; - kurbo::flatten(&path, 0.2, |el| match el { - PathEl::MoveTo(p) => { - if cur.len() > 1 { - out.push(std::mem::take(&mut cur)); - } - cur = vec![pt(p)]; - } - PathEl::LineTo(p) => cur.push(pt(p)), - PathEl::ClosePath => { - if let Some(f) = cur.first().copied() { - cur.push(f); - } - if cur.len() > 1 { - out.push(std::mem::take(&mut cur)); - } - } - _ => {} - }); - if cur.len() > 1 { - out.push(cur); - } - x += adv; - } - out -} - -/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts -/// to Display), a separator, then every family. Each row says whether the family is variable, -/// for the menu's Variable Fonts filter. -pub fn font_menu(prefs: &prefs::Prefs) -> Vec { - let all = text_families(); - let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() }; - let variable = effectcraft_text::fonts::variable_families(); - let row = |f: &String, recent: bool| FontRow { - family: f.clone(), - display: native.get(f).cloned().unwrap_or_else(|| f.clone()), - recent, - variable: variable.contains(f), - }; - let mut out: Vec = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect(); - if !out.is_empty() { - out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false }); - } - out.extend(all.iter().map(|f| row(f, false))); - out -} -#[cfg(test)] -mod tests_history; -#[cfg(test)] -mod tests_models; -#[cfg(test)] -mod tests_paint; -#[cfg(test)] -mod tests_panels; -#[cfg(test)] -mod tests_roto; - -#[cfg(test)] -mod tests_key_time; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bc486a0c5..aa2e4ffe1 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -733,6 +733,7 @@ Layer Shape Layer | layer.newShape Adjustment Layer | layer.newAdjustment Content-Aware Fill Layer... | layer.newContentAwareFill + 3D Primitive... | layer.newPrimitive Cube | layer.new3dPrimitive {"kind":"cube"} Sphere | layer.new3dPrimitive {"kind":"sphere"} Plane | layer.new3dPrimitive {"kind":"plane"} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs deleted file mode 100644 index a2ce579ea..000000000 --- a/crates/engine/src/prefs.rs +++ /dev/null @@ -1,1514 +0,0 @@ -//! Settings (After Effects' Preferences): a versioned serde model, addressed by dotted keys -//! (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`), plus the page schema the -//! Settings dialog and agents (`prefs.pages`) are built from. -//! -//! Settings are stored as JSON (`prefs.json`) through the session's [`crate::config::ConfigStore`]. -//! Loading is forgiving: missing keys take their defaults, unknown keys (written by a newer -//! version) are kept and written back, and older layouts are migrated ([`migrate`]). -//! -//! Every setting in [`pages`] says whether it already changes behaviour (`live`). Settings that -//! don't yet are still shown (After Effects parity of the dialog) and are listed in -//! `docs/preferences.md`; a test keeps that list in step with the schema. - -use std::collections::BTreeMap; - -use effectcraft_color::Label; -use effectcraft_time::Tick; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; - -/// Current settings layout version. -pub const PREFS_VERSION: u32 = 2; -/// Settings file name in the config store. -pub const PREFS_FILE: &str = "prefs.json"; - -/// Settings ▸ General ▸ Language: (label, `general.language` value). -pub const LANGUAGES: &[(&str, &str)] = &[ - ("Match System", "system"), - ("English", "en"), - ("日本語", "ja"), - ("简体中文", "zh-hans"), - ("繁體中文", "zh-hant"), - ("Українська", "uk"), - ("Español", "es"), - ("Português (Brasil)", "pt-br"), - ("Русский", "ru"), - ("Français", "fr"), - ("Deutsch", "de"), - ("Čeština", "cs"), -]; - -/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). -pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")]; - -/// Settings ▸ Appearance ▸ Appearance Mode: (label, `appearance.appearanceMode` value). Auto -/// follows the operating system's light or dark appearance. -pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")]; - -/// The dark family of themes: (label, `appearance.darkTheme` value). -pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")]; - -/// The light family of themes: (label, `appearance.lightTheme` value). -pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")]; - -/// Every theme (the legacy single-theme `appearance.theme`). -pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]; - -/// The settings whose value must be one of their choices. -fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> { - match key { - "general.language" => Some(LANGUAGES), - "startup.windowGraphics" => Some(WINDOW_GRAPHICS), - "appearance.appearanceMode" => Some(APPEARANCE_MODES), - "appearance.darkTheme" => Some(DARK_THEMES), - "appearance.lightTheme" => Some(LIGHT_THEMES), - "appearance.theme" => Some(THEMES), - _ => None, - } -} - -fn is_choice(choices: &[(&str, &str)], v: &str) -> bool { - choices.iter().any(|(_, c)| *c == v) -} - -/// Unknown keys inside a page, kept so a newer version's settings survive a round trip. -type Extra = BTreeMap; - -macro_rules! page { - ($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $d:expr),* $(,)? }) => { - $(#[$m])* - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] - #[serde(default, rename_all = "camelCase")] - pub struct $name { - $($(#[$fm])* pub $f: $t,)* - #[serde(flatten)] - pub extra: Extra, - } - impl Default for $name { - fn default() -> Self { - $name { $($f: $d,)* extra: Extra::new() } - } - } - }; -} - -page!(General { - /// Interface language: `system` (the operating system's, where EffectCraft has it, else - /// English), or a language code from `LANGUAGES`. - language: String = "system".into(), - /// Levels of Undo (1–99). - undo_levels: u32 = 32, - /// Path Point and Handle Size (px). - path_point_size: u32 = 5, - show_tool_tips: bool = true, - /// Create New Layers at Composition Start Time (off: at the current time). - create_layers_at_comp_start: bool = true, - switches_affect_nested_comps: bool = true, - /// Default Spatial Interpolation to Linear. - default_spatial_linear: bool = false, - preserve_constant_vertex_count: bool = true, - /// Synchronize Time of All Related Items. - sync_time_related_items: bool = true, - expression_pick_whip_compact: bool = true, - create_split_layers_above: bool = false, - use_system_color_picker: bool = false, - /// Number of projects in File ▸ Open Recent. - recent_items: u32 = 10, -}); - -page!(Startup { - show_home_on_launch: bool = true, - show_home_on_open_project: bool = false, - /// After a crash, offer to open the most recent auto-save. - offer_crash_recovery: bool = true, - /// What the desktop window draws with, from the next launch: `auto` (the platform's best - /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that - /// stops as its window opens switches it to `gl` (#243). - window_graphics: String = "auto".into(), -}); - -page!(ProjectPrefs { - /// Open a template project for File ▸ New ▸ New Project. - use_template: bool = false, - template_path: String = String::new(), -}); - -page!(AutoSave { - enabled: bool = true, - interval_minutes: u32 = 20, - max_versions: u32 = 5, - /// `nextToProject` (an "EffectCraft Auto-Save" folder beside the project) or `custom`. - location: String = "nextToProject".into(), - folder: String = String::new(), - save_on_render_start: bool = false, -}); - -page!(CompositionPrefs { - /// Motion path: `all`, `none`, `seconds`, `keyframes`. - motion_path: String = "keyframes".into(), - motion_path_seconds: f64 = 15.0, - motion_path_keyframes: u32 = 15, - show_rendering_progress: bool = true, - hardware_accelerate_panels: bool = true, -}); - -page!(Previews { - /// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`. - adaptive_resolution_limit: String = "1/8".into(), - show_internal_wireframes: bool = false, - cache_frames_when_idle: bool = false, - fast_previews: bool = false, - /// `faster` or `moreAccurate`. - zoom_quality: String = "moreAccurate".into(), - show_gpu_info: bool = false, - /// The monitor's colour space for View ▸ Use Display Color Management: `srgb` or `p3`. - display_profile: String = "srgb".into(), -}); - -page!(Appearance { - /// The single theme before appearance modes (`dark`, `darker` or `light`), kept for older - /// settings files and automation: setting it picks its family's mode and theme. While the mode - /// is Dark or Light it mirrors the theme in use. - theme: String = "dark".into(), - /// `auto` (follow the operating system), `dark` or `light`. New installs stay Dark. - appearance_mode: String = "dark".into(), - /// The theme used in dark appearance: `dark` or `darker`. - dark_theme: String = "dark".into(), - /// The theme used in light appearance: `light`. - light_theme: String = "light".into(), - /// Theme shortcuts in the appearance context menu. - favorite_themes: Vec = Vec::new(), - /// User interface brightness, -1 (darker) … 1 (lighter). - brightness: f64 = 0.0, - /// The size of the whole interface in percent (75–200), on top of the display's own scale. - ui_scale: u32 = 100, - use_label_color_for_handles: bool = true, - use_label_color_for_tabs: bool = true, - cycle_mask_colors: bool = true, - use_gradients: bool = true, - /// macOS: draw the menu bar inside the window instead of the system menu bar. - in_window_menu_bar_mac: bool = false, -}); - -page!(Grids { - grid_color: String = "#7a9cff".into(), - grid_spacing: f64 = 100.0, - grid_subdivisions: u32 = 4, - /// `lines`, `dashed` or `dots`. - grid_style: String = "lines".into(), - proportional_horizontal: u32 = 4, - proportional_vertical: u32 = 4, - guide_color: String = "#3cc8f0".into(), - guide_style: String = "lines".into(), - action_safe: f64 = 10.0, - title_safe: f64 = 20.0, -}); - -page!(TypePrefs { - /// `latin` or `southAsian` (South Asian and Middle Eastern). - text_engine: String = "latin".into(), - font_preview: bool = true, - recent_fonts: u32 = 10, - font_names_in_english: bool = false, -}); - -page!(Import { - /// Still footage duration: `compLength` or `seconds`. - still_footage: String = "compLength".into(), - still_seconds: f64 = 5.0, - sequence_fps: f64 = 30.0, - report_missing_frames: bool = true, - /// Automatically Reload Footage whose file another app changed (size or modification time), - /// checked every two seconds and when the window comes back to the front: `off`, - /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. - auto_reload_footage: String = "nonSequence".into(), - /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. - unlabeled_alpha: String = "ask".into(), - /// Default drag import as: `footage`, `comp`, `compLayerSizes`. - drag_import_as: String = "footage".into(), -}); - -page!(Export { - default_output_folder: String = String::new(), - segment_sequences: bool = false, - segment_sequence_files: u32 = 700, - segment_movies: bool = false, - segment_movie_mb: u32 = 1024, - append_bits_to_name: bool = false, -}); - -page!(Audio { - /// Output device name (empty = the system default). - output_device: String = String::new(), - /// Output mapping: device channel for the left / right output (1-based). - output_left: u32 = 1, - output_right: u32 = 2, - preview_sample_rate: u32 = 48_000, -}); - -page!(Disk { - disk_cache_enabled: bool = true, - disk_cache_max_gb: u32 = 100, - disk_cache_folder: String = String::new(), - media_cache_folder: String = String::new(), - conformed_media_folder: String = String::new(), -}); - -page!(Memory { - ram_reserved_gb: u32 = 6, - /// Processed-layer cache budget (MB). - layer_cache_mb: u32 = 1024, - /// Decoded footage frame cache budget (MB). - media_cache_mb: u32 = 1024, - /// RAM preview (viewer frame) cache budget (MB). - preview_cache_mb: u32 = 3072, - reduce_cache_when_low: bool = true, -}); - -page!(Video { - enable_output: bool = false, - device: String = String::new(), - output_during_playback: bool = true, - mirror_on_monitor: bool = true, - disable_when_background: bool = false, -}); - -page!(ThreeD { - /// Default 3D renderer for new compositions: `classic` or `advanced`. - default_renderer: String = "classic".into(), - show_reference_axes: bool = true, - extended_viewer: bool = false, - realtime_shadows: bool = true, -}); - -page!( - /// Settings ▸ Roto Brush. - RotoPrefs { - /// The segmentation model Roto Brush 2.0 / 3.0 use: `classical` (built in) or a model id - /// from `effectcraft_segment::MODELS` (once installed). - model: String = "classical".into(), - } -); - -page!( - /// Settings ▸ Face Tracking. - FacePrefs { - /// The model Track Mask ▸ Face Tracking uses: `classical` (built in) or a model id from - /// `effectcraft_segment::MODELS` (once installed). - model: String = "classical".into(), - } -); - -page!(Scripting { - allow_scripts_write_files: bool = false, - warn_executing_files: bool = true, - enable_js_debugger: bool = false, - editor_font_size: u32 = 13, - syntax_highlighting: bool = true, - line_numbers: bool = true, - auto_complete: bool = true, - bracket_matching: bool = true, - word_wrap: bool = false, - error_banner: bool = true, -}); - -impl Scripting { - /// Whether `other` grants scripts the same as this: Application Scripting (file and network - /// access, the warning before executing files, the debugger). Scripts can't change these - /// ([`crate::Session::execute_for_script`]): a script could otherwise give itself the - /// access the user withheld. - pub fn same_permissions(&self, other: &Scripting) -> bool { - self.allow_scripts_write_files == other.allow_scripts_write_files - && self.warn_executing_files == other.warn_executing_files - && self.enable_js_debugger == other.enable_js_debugger - } - - /// Take `from`'s Application Scripting settings (see [`Scripting::same_permissions`]). - pub fn keep_permissions(&mut self, from: &Scripting) { - self.allow_scripts_write_files = from.allow_scripts_write_files; - self.warn_executing_files = from.warn_executing_files; - self.enable_js_debugger = from.enable_js_debugger; - } -} - -/// One editable label: name and `#rrggbb` colour. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct LabelPref { - pub name: String, - pub color: String, -} - -/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the -/// frame size, pixel aspect and frame rate, as in After Effects' own presets. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompPreset { - pub name: String, - pub width: u32, - pub height: u32, - pub pixel_aspect: f64, - pub frame_rate: f64, -} - -impl CompPreset { - /// Longest preset name kept. - pub const MAX_NAME: usize = 128; - - /// Values a composition accepts (a hand-edited settings file can hold anything). - pub fn is_valid(&self) -> bool { - let size = 4..=30000; - !self.name.trim().is_empty() - && self.name.chars().count() <= Self::MAX_NAME - && size.contains(&self.width) - && size.contains(&self.height) - && self.pixel_aspect.is_finite() - && (0.01..=100.0).contains(&self.pixel_aspect) - && self.frame_rate.is_finite() - && (1.0..=999.0).contains(&self.frame_rate) - } -} - -/// Saved composition presets, skipping entries that don't parse instead of losing the list. -fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let v = Vec::::deserialize(d)?; - Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect()) -} - -fn default_labels() -> Vec { - Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect() -} - -fn hex([r, g, b]: [u8; 3]) -> String { - format!("#{r:02x}{g:02x}{b:02x}") -} - -fn parse_hex(s: &str) -> Option<[u8; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 { - return None; - } - let p = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); - Some([p(0)?, p(2)?, p(4)?]) -} - -/// All settings. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Prefs { - pub version: u32, - pub general: General, - pub startup: Startup, - pub project: ProjectPrefs, - pub auto_save: AutoSave, - pub composition: CompositionPrefs, - pub previews: Previews, - pub appearance: Appearance, - pub grids: Grids, - /// The 16 labels (Red … Dark Green), in order. - pub labels: Vec, - #[serde(rename = "type")] - pub type_: TypePrefs, - pub import: Import, - pub export: Export, - pub audio: Audio, - pub disk: Disk, - pub memory: Memory, - pub video: Video, - #[serde(rename = "threeD")] - pub three_d: ThreeD, - pub scripting: Scripting, - pub roto: RotoPrefs, - pub face: FacePrefs, - /// File ▸ Open Recent, newest first. - pub recent_projects: Vec, - /// File ▸ Import Recent Footage, newest first. - pub recent_footage: Vec, - /// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest - /// first. - pub recent_presets: Vec, - /// Character panel: recently used font families, newest first. - pub recent_fonts: Vec, - /// Effects & Presets: the starred effects' ids, in the order starred. - pub effects_favorites: Vec, - /// Preview panel: every shortcut's options. - pub preview: crate::preview::PreviewSettings, - /// View ▸ Simulate Output ▸ My Custom RGB. - pub custom_rgb: crate::viewer::CustomRgb, - /// Composition Settings presets the user saved, in the order saved. - #[serde(deserialize_with = "lenient_comp_presets")] - pub comp_presets: Vec, - /// Top-level keys this version doesn't know (kept for newer versions). - #[serde(flatten)] - pub extra: Extra, -} - -impl Default for Prefs { - fn default() -> Self { - Prefs { - version: PREFS_VERSION, - general: General::default(), - startup: Startup::default(), - project: ProjectPrefs::default(), - auto_save: AutoSave::default(), - composition: CompositionPrefs::default(), - previews: Previews::default(), - appearance: Appearance::default(), - grids: Grids::default(), - labels: default_labels(), - type_: TypePrefs::default(), - import: Import::default(), - export: Export::default(), - audio: Audio::default(), - disk: Disk::default(), - memory: Memory::default(), - video: Video::default(), - three_d: ThreeD::default(), - scripting: Scripting::default(), - roto: RotoPrefs::default(), - face: FacePrefs::default(), - recent_projects: vec![], - recent_footage: vec![], - recent_presets: vec![], - recent_fonts: vec![], - effects_favorites: vec![], - preview: crate::preview::PreviewSettings::default(), - custom_rgb: crate::viewer::CustomRgb::default(), - comp_presets: vec![], - extra: Extra::new(), - } - } -} - -/// Bring an older settings document up to [`PREFS_VERSION`]. -/// -/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`, -/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level. -pub fn migrate(mut v: Value) -> Value { - let Some(obj) = v.as_object_mut() else { return json!({}) }; - let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0); - if version < 2 { - let moved = |from: &str, page: &str, key: &str, obj: &mut Map| { - if let Some(x) = obj.remove(from) { - let p = obj.entry(page.to_string()).or_insert_with(|| json!({})); - if let Some(p) = p.as_object_mut() { - p.entry(key.to_string()).or_insert(x); - } - } - }; - moved("undoLevels", "general", "undoLevels", obj); - moved("autoSaveEnabled", "autoSave", "enabled", obj); - moved("autoSaveMinutes", "autoSave", "intervalMinutes", obj); - moved("autoSaveVersions", "autoSave", "maxVersions", obj); - moved("theme", "appearance", "theme", obj); - if let Some(Value::Array(names)) = obj.remove("labelNames") { - let mut labels = default_labels(); - for (l, n) in labels.iter_mut().zip(names) { - if let Some(n) = n.as_str() { - l.name = n.to_string(); - } - } - obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default()); - } - } - // Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or - // Light mode, instead of switching established users to Auto. - if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut) - && !a.contains_key("appearanceMode") - && let Some(theme) = a.get("theme").and_then(Value::as_str).map(str::to_string) - && let Some((mode, slot)) = theme_family(&theme) - { - a.insert("appearanceMode".into(), json!(mode)); - a.insert(slot.into(), json!(theme)); - } - obj.insert("version".into(), json!(PREFS_VERSION)); - v -} - -/// The appearance mode and the per-mode setting a theme belongs to (`darker` → (`dark`, -/// `darkTheme`)); `None` for an unknown theme. -pub fn theme_family(theme: &str) -> Option<(&'static str, &'static str)> { - if is_choice(DARK_THEMES, theme) { - Some(("dark", "darkTheme")) - } else if is_choice(LIGHT_THEMES, theme) { - Some(("light", "lightTheme")) - } else { - None - } -} - -impl Appearance { - /// Show the legacy single `theme`: fix the mode to its family and remember it as that - /// family's theme. An unknown theme changes nothing. - pub fn select_theme(&mut self) { - match theme_family(&self.theme) { - Some((mode, "darkTheme")) => { - self.appearance_mode = mode.into(); - self.dark_theme = self.theme.clone(); - } - Some((mode, _)) => { - self.appearance_mode = mode.into(); - self.light_theme = self.theme.clone(); - } - None => {} - } - } - - /// The theme to show: the light or dark family's choice by the mode; Auto follows `system_light` - /// (`None` = the system said nothing: dark). - pub fn resolved_theme(&self, system_light: Option) -> &str { - let light = match self.appearance_mode.as_str() { - "light" => true, - "auto" => system_light == Some(true), - _ => false, - }; - if light { &self.light_theme } else { &self.dark_theme } - } -} - -impl Prefs { - /// Parse a settings document (migrating older layouts; unknown keys are kept, bad values - /// fall back to defaults). - pub fn from_json(text: &str) -> Prefs { - let v: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({})); - let v = migrate(v); - let mut p: Prefs = match serde_json::from_value(v.clone()) { - Ok(p) => p, - // One bad value shouldn't lose everything: take each page that parses. - Err(_) => { - let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default(); - if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) { - for (k, x) in o { - let mut trial = b.clone(); - trial.insert(k.clone(), x.clone()); - if serde_json::from_value::(Value::Object(trial)).is_ok() { - b.insert(k.clone(), x.clone()); - } - } - } - serde_json::from_value(base).unwrap_or_default() - } - }; - p.normalize(); - p - } - - pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap_or_default() - } - - /// Clamp values into their valid ranges and fill missing labels. - pub fn normalize(&mut self) { - self.version = PREFS_VERSION; - self.comp_presets.retain(CompPreset::is_valid); - let mut seen = std::collections::HashSet::new(); - self.effects_favorites.retain(|id| !id.is_empty() && seen.insert(id.clone())); - self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES); - if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) { - self.startup.window_graphics = Startup::default().window_graphics; - } - let g = &mut self.general; - if !is_choice(LANGUAGES, &g.language) { - g.language = General::default().language; - } - g.undo_levels = g.undo_levels.clamp(1, 99); - g.path_point_size = g.path_point_size.clamp(3, 20); - g.recent_items = g.recent_items.clamp(1, 30); - let a = &mut self.auto_save; - a.interval_minutes = a.interval_minutes.clamp(1, 240); - a.max_versions = a.max_versions.clamp(1, 99); - let ap = &mut self.appearance; - let mut seen = std::collections::BTreeSet::new(); - ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone())); - if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) { - ap.appearance_mode = Appearance::default().appearance_mode; - } - if !is_choice(DARK_THEMES, &ap.dark_theme) { - ap.dark_theme = Appearance::default().dark_theme; - } - if !is_choice(LIGHT_THEMES, &ap.light_theme) { - ap.light_theme = Appearance::default().light_theme; - } - // The legacy single theme mirrors the theme a fixed mode shows (Auto depends on the - // system, so it keeps the last one). - match ap.appearance_mode.as_str() { - "dark" => ap.theme = ap.dark_theme.clone(), - "light" => ap.theme = ap.light_theme.clone(), - _ if !is_choice(THEMES, &ap.theme) => ap.theme = ap.dark_theme.clone(), - _ => {} - } - self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0); - self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200); - let defaults = default_labels(); - self.labels.truncate(defaults.len()); - for d in defaults.iter().skip(self.labels.len()) { - self.labels.push(d.clone()); - } - for (l, d) in self.labels.iter_mut().zip(&defaults) { - if parse_hex(&l.color).is_none() { - l.color = d.color.clone(); - } - } - let m = &mut self.memory; - m.layer_cache_mb = m.layer_cache_mb.clamp(64, 1 << 20); - m.media_cache_mb = m.media_cache_mb.clamp(64, 1 << 20); - m.preview_cache_mb = m.preview_cache_mb.clamp(64, 1 << 20); - self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0); - let n = self.general.recent_items as usize; - self.recent_projects.truncate(n); - } - - /// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything. - pub fn get(&self, key: &str) -> Option { - let v = serde_json::to_value(self).ok()?; - if key.is_empty() { - return Some(v); - } - let ptr = format!("/{}", key.replace('.', "/")); - v.pointer(&ptr).cloned() - } - - /// Set the value at a dotted key. The key must exist and the value must have its type. - pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> { - if let Some(c) = choices(key) - && !value.as_str().is_some_and(|v| is_choice(c, v)) - { - let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); - return Err(format!("`{key}` expects one of {}", all.join(", "))); - } - let favorites = if key == "appearance.favoriteThemes" { - Some(&value) - } else if key == "appearance" { - value.get("favoriteThemes") - } else { - None - }; - if let Some(favorites) = favorites { - let values = favorites.as_array().ok_or("favorite themes must be a list")?; - let mut seen = std::collections::BTreeSet::new(); - for value in values { - let id = value.as_str().ok_or("favorite theme must be a string")?; - if !is_choice(THEMES, id) || !seen.insert(id) { - return Err("unknown or duplicate favorite theme".into()); - } - } - } - // Older clients set the single theme (by key, or inside the page object): it still picks - // a visible theme, fixing the mode to its family. - let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none()); - let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?; - let ptr = format!("/{}", key.replace('.', "/")); - let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?; - let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?; - *slot = value; - let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; - let (a, b) = (&self.appearance, &next.appearance); - let only_theme_changed = a.theme != b.theme && a.appearance_mode == b.appearance_mode && a.dark_theme == b.dark_theme && a.light_theme == b.light_theme; - if legacy_theme || only_theme_changed { - next.appearance.select_theme(); - } - next.normalize(); - *self = next; - Ok(()) - } - - /// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and - /// the saved composition presets are kept. - pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> { - let d = Prefs::default(); - let Some(page) = page else { - *self = Prefs { - recent_projects: std::mem::take(&mut self.recent_projects), - recent_footage: std::mem::take(&mut self.recent_footage), - recent_presets: std::mem::take(&mut self.recent_presets), - recent_fonts: std::mem::take(&mut self.recent_fonts), - effects_favorites: std::mem::take(&mut self.effects_favorites), - preview: std::mem::take(&mut self.preview), - comp_presets: std::mem::take(&mut self.comp_presets), - ..d - }; - return Ok(()); - }; - let keys: Vec<&str> = match page { - "project" => vec!["project", "autoSave"], - p => vec![section_key(p).ok_or_else(|| format!("unknown settings page `{p}`"))?], - }; - let dv = serde_json::to_value(&d).map_err(|e| e.to_string())?; - for k in keys { - if let Some(x) = dv.get(k) { - self.set(k, x.clone())?; - } - } - Ok(()) - } - - /// Display name of a label (Labels settings). - pub fn label_name(&self, l: Label) -> String { - match label_index(l) { - Some(i) => self.labels.get(i).map(|p| p.name.clone()).unwrap_or_else(|| l.name().to_string()), - None => l.name().to_string(), - } - } - - /// sRGB colour of a label (Labels settings). - pub fn label_rgb(&self, l: Label) -> [u8; 3] { - label_index(l).and_then(|i| self.labels.get(i)).and_then(|p| parse_hex(&p.color)).unwrap_or(l.rgb()) - } - - /// A label by its built-in name or its (renamed) display name. - pub fn label_from_name(&self, s: &str) -> Option