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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" 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/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..01cb9cd66 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix: + # 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/nix.yml@main 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..e78fe1ea3 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +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: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + 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 38999ed0c..70fa5a612 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", @@ -222,6 +217,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -607,7 +603,7 @@ dependencies = [ "hashbrown 0.17.1", "indexmap", "once_cell", - "phf 0.14.0", + "phf", "rustc-hash 2.1.3", "static_assertions", ] @@ -747,6 +743,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.5.1" @@ -797,6 +799,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -962,10 +973,24 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +dependencies = [ + "hayro-syntax", + "thiserror 2.0.21", +] + [[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=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "serde", @@ -1133,9 +1158,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", @@ -1143,7 +1167,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.9.2" +version = "0.17.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1168,7 +1192,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1180,14 +1204,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.9.2" +version = "0.17.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.9.2" +version = "0.17.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1203,7 +1227,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.9.2" +version = "0.17.0" dependencies = [ "proptest", "serde", @@ -1211,8 +1235,9 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.9.2" +version = "0.17.0" dependencies = [ + "chrono", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", @@ -1228,8 +1253,10 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.9.2" +version = "0.17.0" dependencies = [ + "chrono", + "craft-docpath", "effectcraft-color", "effectcraft-effects", "effectcraft-geom", @@ -1261,7 +1288,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1291,7 +1318,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.9.2" +version = "0.17.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1308,7 +1335,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.9.2" +version = "0.17.0" dependencies = [ "proptest", "serde", @@ -1316,7 +1343,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.9.2" +version = "0.17.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1338,14 +1365,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.9.2" +version = "0.17.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1370,7 +1397,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1389,7 +1416,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1401,8 +1428,9 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.9.2" +version = "0.17.0" dependencies = [ + "craft-docpath", "effectcraft-color", "effectcraft-effects", "effectcraft-keyframe", @@ -1418,8 +1446,9 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.9.2" +version = "0.17.0" dependencies = [ + "craft-docpath", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -1440,12 +1469,13 @@ dependencies = [ "log", "rayon", "serde_json", + "symphonia", "thiserror 2.0.21", ] [[package]] name = "effectcraft-model" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-geom", "image", @@ -1457,14 +1487,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.9.2" +version = "0.17.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1476,8 +1506,9 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.9.2" +version = "0.17.0" dependencies = [ + "craft-pdf", "effectcraft-raster", "effectcraft-svg", "effectcraft-text", @@ -1485,12 +1516,13 @@ dependencies = [ "kurbo", "miniz_oxide 0.8.9", "skrifa", + "wasm-bindgen-test", "zune-jpeg", ] [[package]] name = "effectcraft-plugin" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1502,7 +1534,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1515,14 +1547,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.9.2" +version = "0.17.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1531,7 +1563,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1553,7 +1585,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.9.2" +version = "0.17.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1565,12 +1597,13 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "serde_json", + "socket2", "web-time", ] [[package]] name = "effectcraft-segment" -version = "0.9.2" +version = "0.17.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1579,7 +1612,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1591,7 +1624,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1603,7 +1636,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.9.2" +version = "0.17.0" dependencies = [ "proptest", "serde", @@ -1611,7 +1644,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1623,8 +1656,9 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.9.2" +version = "0.17.0" dependencies = [ + "chrono", "craft-ui", "effectcraft-color", "effectcraft-engine", @@ -1655,11 +1689,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.9.2" +version = "0.17.0" [[package]] name = "effectcraft-web" -version = "0.9.2" +version = "0.17.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1682,9 +1716,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", @@ -1706,6 +1739,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1717,9 +1751,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", @@ -1737,9 +1770,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", @@ -1757,31 +1789,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-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", @@ -1793,9 +1828,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", @@ -1817,9 +1851,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", ] @@ -1867,9 +1900,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", @@ -1885,16 +1917,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" @@ -2502,9 +2535,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", @@ -2678,6 +2711,39 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +dependencies = [ + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +dependencies = [ + "hayro-jbig2", + "hayro-jpeg2000", + "rustc-hash 2.1.3", + "smallvec", + "stringprep", + "zune-jpeg", +] + [[package]] name = "heck" version = "0.5.0" @@ -2987,12 +3053,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]] @@ -3167,6 +3233,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3311,6 +3387,24 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.5.1" @@ -3759,6 +3853,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "orbclient" version = "0.3.55" @@ -3788,6 +3888,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.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3849,14 +3959,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]] @@ -3865,18 +3975,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]] @@ -3886,20 +3987,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]] @@ -3908,22 +3996,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" @@ -4508,6 +4587,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -4730,6 +4815,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" @@ -4773,6 +4868,17 @@ dependencies = [ "serde", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strum" version = "0.28.0" @@ -5156,6 +5262,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" @@ -5248,6 +5360,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" @@ -5298,6 +5421,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5347,9 +5485,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", @@ -5362,9 +5500,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", @@ -5462,6 +5600,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" + [[package]] name = "wasm-encoder" version = "0.261.0" @@ -6319,8 +6496,7 @@ 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", @@ -6392,6 +6568,24 @@ 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" @@ -6469,9 +6663,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.2" +version = "0.17.0" dependencies = [ + "chrono", "serde_json", + "tempfile", ] [[package]] @@ -6753,3 +6949,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 47c8d417d..598a156a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,15 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.9.2" +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 = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } @@ -50,6 +52,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" @@ -91,6 +95,9 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned +# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. +symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } @@ -99,10 +106,13 @@ pollster = "0.4" # 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 = "25da1314d443171b4adeaad2cd9454b273f35c6f", 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). @@ -132,9 +142,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] @@ -204,3 +217,22 @@ opt-level = 3 [profile.release] lto = "thin" codegen-units = 1 + +[patch.crates-io] +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" } +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 65ce4c42c..5cf18a553 100644 --- a/NOTICE +++ b/NOTICE @@ -12,6 +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 a579491a8..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 @@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar + path into an editable Path 1 with the same geometry, as After Effects does; it used to + apply to mask RotoBezier only, so a parametric shape path could not be converted at all + and its vertices could not be edited or animated (#932). - 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking; strokes and undo history are preserved so restoring the timing allows the span edit again. - 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel @@ -131,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. @@ -154,6 +159,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | +| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | | 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | | 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fe5238075..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] @@ -16,6 +17,8 @@ //! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100] //! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] //! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings) +//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second: +//! `progress ` //! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE //! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png] //! effectcraft-cli render F.ecproj --queue render the project's Render Queue @@ -94,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] @@ -106,6 +110,8 @@ const USAGE: &str = "usage: effectcraft-cli ) render/render-frame --template T.ectemplate [--values V.json] render one template instance bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings; --play N renders N consecutive frames with/without the layer cache; @@ -144,6 +150,7 @@ const VALUED: &[&str] = &[ "--expression", "--depth", "--eval", + "--args-file", // render "--template", "--values", @@ -189,6 +196,7 @@ const FLAGS: &[&str] = &[ "--small", "--adv3d", "--autosave", + "--progress", ]; struct Args { @@ -263,8 +271,18 @@ fn value_arg(s: &str) -> Value { serde_json::from_str(s).unwrap_or_else(|_| json!(s)) } +/// The command line as text. `std::env::args` panics on an argument that is not Unicode (a +/// Latin-1 file name on Linux). Every argument is used as a string, and a lossy copy would read or +/// write another file than the one named, so such an argument is a usage error naming it. +fn utf8_args(raw: impl IntoIterator) -> Result, String> { + raw.into_iter().map(|a| a.into_string().map_err(|a| format!("argument `{}` is not valid Unicode", a.to_string_lossy()))).collect() +} + fn main() { - let raw: Vec = std::env::args().skip(1).collect(); + let raw = match utf8_args(std::env::args_os().skip(1)) { + Ok(raw) => raw, + Err(e) => fail_usage(&e), + }; if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) { say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION")); return; @@ -493,14 +511,22 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { }; let mut b = backend(args, true)?; let r = b.exec(&id, params)?; + if let Some(e) = failed_render(&id, &r) { + return Err(Failure::Error(e)); + } let saved = maybe_save(&mut b, args)?; 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; @@ -515,6 +541,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { }; i += 1; let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; + if let Some(e) = failed_render(id, &r) { + return Err(Failure::Error(format!("{id}: {e}"))); + } if !json_out { emit(&r, false); } @@ -637,6 +666,21 @@ fn positional(args: &Args, usage: &str) -> Result<[String; N], F Ok(std::array::from_fn(|i| args.pos[i].clone())) } +/// `renderQueue.render` reports a failed item in its result, not as an error. Under `run`/`exec` +/// that item fails the command (#1426), as it fails `render --queue`. +fn failed_render(id: &str, r: &Value) -> Option { + if id != "renderQueue.render" { + return None; + } + let failed: Vec = r["items"] + .as_array() + .into_iter() + .flatten() + .filter_map(|i| Some(format!("{}: {}", i["compName"].as_str().unwrap_or("?"), i["status"].get("Failed")?.as_str().unwrap_or("failed")))) + .collect(); + (!failed.is_empty()).then(|| failed.join("; ")) +} + fn with_saved(v: Value, saved: Option) -> Value { match saved { None => v, @@ -660,11 +704,10 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { let mut s = session(args)?; if args.opt("--template").is_some() { template::prepare(&mut s, args)?; - } else { - match &args.project { - Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?, - None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?, - }; + } else if let Some(p) = &args.project { + s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?; + } else if args.flag("--demo") || !args.flag("--empty") { + s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?; } let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string()); if !args.flag("--queue") { @@ -742,8 +785,21 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { } s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?; let tty = std::io::IsTerminal::is_terminal(&std::io::stderr()); + // `--progress`: one line per update for a program reading stderr (a terminal gets the live line). + let lines = args.flag("--progress") && !tty; + let (mut last_line, mut last_done) = (std::time::Instant::now(), u64::MAX); while s.is_rendering() { std::thread::sleep(std::time::Duration::from_millis(100)); + if lines + && last_line.elapsed() >= std::time::Duration::from_millis(250) + && let Some(p) = s.render_progress() + && p.done != last_done + { + last_line = std::time::Instant::now(); + last_done = p.done; + let left = p.remaining.map_or_else(|| "-".to_string(), |r| format!("{r:.1}")); + note!("progress {} {} {:.1} {left}", p.done, p.total, p.item_elapsed); + } if tty && let Some(p) = s.render_progress() { let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default(); let _ = write!( @@ -1270,6 +1326,18 @@ mod tests { assert!(a.flag("--work-area") && a.flag("--json")); } + /// `std::env::args` panicked on an argument that is not Unicode; it is a usage error naming + /// it, and Unicode arguments pass through unchanged. + #[cfg(unix)] + #[test] + fn non_unicode_arguments_are_refused() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + let args = [OsString::from("render-frame"), OsString::from("--out"), OsString::from_vec(b"caf\xe9.png".to_vec())]; + assert_eq!(utf8_args(args).unwrap_err(), "argument `caf\u{fffd}.png` is not valid Unicode"); + assert_eq!(utf8_args([OsString::from("info"), OsString::from("caf\u{e9}.png")]).unwrap(), ["info", "caf\u{e9}.png"]); + } + /// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused. #[test] fn every_option_read_is_declared() { diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index f4da23cd9..8cf5c124f 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -4,7 +4,7 @@ use std::io::Read; use effectcraft_engine::{ EngineError, Session, - project::{ItemId, LayerId}, + project::{ItemId, LayerId, essential::EgKind}, }; use serde_json::{Map, Value, json}; @@ -36,7 +36,20 @@ pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> { { for input in ["--template", "--values"] { if let Some(path) = args.opt(input) - && std::fs::canonicalize(path).is_ok_and(|path| path == output) + && std::fs::canonicalize(path).is_ok_and(|path| { + if path == output { + return true; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Distinct canonical paths can still name the same file through hard links. + if let (Ok(input), Ok(output)) = (std::fs::metadata(&path), std::fs::metadata(&output)) { + return input.dev() == output.dev() && input.ino() == output.ino(); + } + } + false + }) { return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}"))); } @@ -79,7 +92,7 @@ fn preflight(s: &Session, path: &str, values: &Map) -> Result<(), project.fix_next_id(); let mut check = Session::new(); check.project = project.into(); - Ok(instantiate(&mut check, source, values)) + Ok(instantiate(&mut check, source, values, false)) }) .map_err(|e| Failure::Error(e.to_string()))? } @@ -92,10 +105,27 @@ pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> { } let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?; let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?; - instantiate(s, source, &values) + instantiate(s, source, &values, true) } -fn instantiate(s: &mut Session, source: u64, values: &Map) -> Result<(), Failure> { +/// The file a Media Replacement control is given: a path, or `{path|file|media: path}`. +fn media_path(value: &Value) -> Option<&str> { + match value { + Value::String(path) => Some(path.as_str()), + Value::Object(m) => ["path", "file", "media"].iter().find_map(|k| m.get(*k)).and_then(Value::as_str), + _ => None, + } +} + +/// Whether the template's control of that name (looked up as `essential.set` does) is a Media Replacement. +fn is_media_control(s: &Session, source: u64, name: &str) -> bool { + let Some(eg) = s.project.comp(ItemId(source)).and_then(|c| c.essential.as_ref()) else { return false }; + let all = eg.flat(); + let control = all.iter().find(|c| c.name == name).or_else(|| all.iter().find(|c| c.name.eq_ignore_ascii_case(name))); + control.is_some_and(|c| matches!(c.kind, EgKind::Media { .. })) +} + +fn instantiate(s: &mut Session, source: u64, values: &Map, import_media: bool) -> Result<(), Failure> { // Duplicate and empty the source with commands: this preserves exact rational frame rate, // Tick duration, background, work area and renderer settings without round-tripping floats. let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?; @@ -120,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map) -> Res } 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()))?; @@ -132,6 +165,25 @@ fn instantiate(s: &mut Session, source: u64, values: &Map) -> Res // Let the wrapper carry the source composition's authored motion blur. execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?; for (control, value) in values { + if is_media_control(s, source, control) { + let path = media_path(value).ok_or_else(|| Failure::Error(format!("control `{control}` is a media control: give it a file path")))?; + // The preflight session cannot import files; the real run reports a bad path. + if !import_media { + continue; + } + let imported = execute(s, "file.import", json!({"paths": [path]}))?; + if let Some(err) = imported.get("errors").and_then(Value::as_array).and_then(|e| e.first()) { + return Err(Failure::Error(format!("control `{control}`: cannot import {path}: {err}"))); + } + let item = imported + .get("items") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_u64) + .ok_or_else(|| Failure::Error(format!("control `{control}`: {path} did not import as footage")))?; + execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "item": item}))?; + continue; + } execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?; } Ok(()) diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs similarity index 69% rename from apps/effectcraft-cli/tests/cli.rs rename to apps/effectcraft-cli/tests/it/cli.rs index 62aefd5d6..538887d9b 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -25,12 +25,19 @@ fn ok_json(args: &[&str]) -> Value { v } +/// `std::fs::canonicalize` without Windows' `\\?\` verbatim prefix, which a child's `current_dir()` never reports. +fn canonical(path: &std::path::Path) -> std::io::Result { + let path = std::fs::canonicalize(path)?; + let plain = path.to_str().and_then(|s| s.strip_prefix(r"\\?\")).filter(|s| s.as_bytes().get(1) == Some(&b':')); + Ok(plain.map(PathBuf::from).unwrap_or(path)) +} + fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); // macOS's `/var` is a symlink to `/private/var`: a child process resolves its working // directory through `current_dir()`, which is canonical, so compare canonical paths. - std::fs::canonicalize(&d).unwrap_or(d).join(name) + canonical(&d).unwrap_or(d).join(name) } #[test] @@ -103,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"); @@ -160,6 +190,21 @@ fn unknown_options_are_usage_errors() { } } +/// An argument that is not Unicode (a Latin-1 file name on Linux) is a usage error (exit 2) +/// naming it, before anything runs: `std::env::args` panicked on one (exit 101). +#[cfg(unix)] +#[test] +fn non_unicode_arguments_are_usage_errors() { + use std::os::unix::ffi::OsStrExt; + let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png"); + let out = bin().args(["render-frame", "--out"]).arg(name).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "{err}"); + assert!(err.contains("argument `caf\u{fffd}.png` is not valid Unicode"), "{err}"); + assert!(!err.contains("panicked"), "{err}"); + assert!(out.stdout.is_empty(), "nothing ran"); +} + /// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command /// still does its work and exits 0, without a panic (#167). #[test] @@ -260,7 +305,7 @@ fn render_out_is_relative_to_the_working_directory() { std::fs::create_dir_all(&proj).unwrap(); std::fs::create_dir_all(&elsewhere).unwrap(); // current_dir resolves aliases such as macOS /var -> /private/var. - let root = root.canonicalize().unwrap(); + let root = canonical(&root).unwrap(); let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere")); let project = proj.join("x.ecproj"); let p = project.to_str().unwrap(); @@ -462,6 +507,32 @@ fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() { std::fs::remove_dir_all(config).unwrap(); } +/// `run`/`exec renderQueue.render` (#1426): a failed item is a command error, exit 1 like +/// `render --queue`, not a success with the failure buried in the JSON. +#[test] +fn a_failed_render_under_run_and_exec_exits_1() { + let root = tmp("render-fails"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + // The output's folder is a file, so the encoder cannot open the output. + let blocker = root.join("not-a-folder"); + std::fs::write(&blocker, "x").unwrap(); + let out = blocker.join("out.gif"); + let add = json!({"comp": "T", "format": "gif", "output": out.to_str().unwrap()}).to_string(); + let project = root.join("q.ecproj"); + let steps = ["comp.new", r#"{"name":"T","width":16,"height":16,"duration":0.1}"#, "layer.newSolid", "{}", "renderQueue.add", &add]; + ok_json(&[&["run", "--empty", "--save-as", project.to_str().unwrap()][..], &steps[..]].concat()); + + let (code, v) = run_json(&[&["run", "--empty"][..], &steps[..], &["renderQueue.render", r#"{"wait":true}"#][..]].concat()); + assert_eq!(code, 1, "{v}"); + let e = v["error"].as_str().unwrap_or_else(|| panic!("{v}")); + assert!(e.starts_with("renderQueue.render: T: "), "{e}"); + + let (code, v) = run_json(&["exec", "renderQueue.render", "--project", project.to_str().unwrap()]); + assert_eq!(code, 1, "{v}"); + assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}"); +} + /// `render --bitrate` (#440) targets the bitrate in VP9 WebM. #[test] fn render_bitrate_applies_to_vp9_webm() { @@ -491,3 +562,131 @@ fn render_bitrate_applies_to_vp9_webm() { assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes"); let _ = std::fs::remove_dir_all(&root); } + +/// Render uses the same empty/project/demo selection as the other CLI commands. +#[test] +fn render_empty_does_not_load_the_demo() { + let root = tmp("render-empty"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let project = root.join("control.ecproj"); + let template = root.join("control.ectemplate"); + let project_path = project.to_str().unwrap(); + let template_path = template.to_str().unwrap(); + let build = r#" +var comp = app.project.items.addComp("Empty control", 64, 36, 1, 1, 30); +var solid = comp.layers.addSolid([0, 1, 0], "Green", 64, 36, 1); +solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity"); +"#; + ok_json(&["script", "--eval", build, "--save-as", project_path]); + let params = json!({"comp": "Empty control", "path": template_path}).to_string(); + let exported = ok_json(&["exec", "essential.exportTemplate", ¶ms, "--project", project_path]); + assert_eq!(exported["controls"], 1, "{exported}"); + let empty = ok_json(&["info", "--empty"]); + assert!(empty["project"]["items"].as_array().unwrap().is_empty(), "{empty}"); + + let render = |options: &[&str], name: &str| { + let output = root.join(name); + assert!(!output.exists()); + let mut args = vec!["render", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]; + args.extend_from_slice(options); + let reply = ok_json(&args); + assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}"); + assert_eq!(PathBuf::from(reply["rendered"][0]["output"].as_str().unwrap()), output); + let bytes = std::fs::read(&output).unwrap(); + assert!(bytes.len() >= 10 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")), "{reply}"); + let dimensions = (u16::from_le_bytes([bytes[6], bytes[7]]), u16::from_le_bytes([bytes[8], bytes[9]])); + (reply["rendered"][0]["comp"].clone(), dimensions) + }; + // Defaults still render the demo, and an explicit demo wins over --empty. + let demo = render(&[], "default.gif"); + assert_eq!(render(&["--demo", "--empty"], "demo.gif"), demo); + // A supplied project wins over both flags, while template mode also accepts --empty. + let project_result = render(&["--project", project_path, "--demo", "--empty"], "project.gif"); + assert_eq!(project_result, (json!("Empty control"), (16, 9))); + assert_eq!(render(&["--template", template_path, "--empty"], "template.gif").1, (16, 9)); + + let output = root.join("empty.gif"); + assert!(!output.exists()); + let (code, reply) = + run_json(&["render", "--empty", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]); + let output_exists = output.exists(); + assert!(!output_exists, "--empty must not render the demo into the requested output"); + assert_eq!(code, 1, "{reply}"); + assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// `WGPU_ADAPTER_NAME` that matches nothing is a clean `--gpu` error listing the adapters +/// found (wgpu's own helper panics). Read once per process, hence a subprocess. +#[test] +fn gpu_adapter_name_mismatch_is_a_clean_error() { + let out = bin() + .args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("nomatch.png").to_str().unwrap(), "--json"]) + .env("WGPU_ADAPTER_NAME", "NoSuchAdapter-xyz") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(1), "stdout: {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_str(stdout.trim()).unwrap(); + let e = v["error"].as_str().unwrap(); + assert!(e.contains("WGPU_ADAPTER_NAME=\"NoSuchAdapter-xyz\""), "{e}"); + assert!(e.contains("available: [") || e.contains("no adapters on the enabled backends"), "{e}"); +} + +/// An exported-but-empty `WGPU_ADAPTER_NAME` behaves like unset: it never produces the +/// adapter-name error (whether a GPU exists on this machine is not asserted). +#[test] +fn gpu_adapter_name_empty_is_unset() { + let out = bin() + .args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("emptyname.png").to_str().unwrap(), "--json"]) + .env("WGPU_ADAPTER_NAME", " ") + .output() + .unwrap(); + let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(!all.contains("WGPU_ADAPTER_NAME"), "{all}"); +} + +/// Renders two seconds of the demo at quarter resolution (long enough for several progress +/// updates) and returns stderr, which `output()` reads through a pipe: not a terminal. +fn render_demo_stderr(name: &str, extra: &[&str]) -> String { + let out = tmp(name); + let mut args = vec!["render", "--demo", "--format", "gif", "--start", "0", "--end", "2", "--resolution", "quarter", "--out", out.to_str().unwrap()]; + args.extend_from_slice(extra); + let o = bin().args(&args).output().unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr).to_string(); + assert!(o.status.success(), "{args:?}: {stderr}"); + let _ = std::fs::remove_file(out); + stderr +} + +/// `render --progress` with a piped stderr reports the render's frames as +/// `progress ` lines, one per +/// change (#1778). +#[test] +fn render_progress_reports_frames_on_a_piped_stderr() { + let stderr = render_demo_stderr("progress.gif", &["--progress"]); + let mut last_done = None; + let mut totals = vec![]; + for line in stderr.lines().filter(|l| l.starts_with("progress")) { + let fields: Vec<&str> = line.split(' ').collect(); + assert_eq!(fields.len(), 5, "{line}"); + let done: u64 = fields[1].parse().unwrap_or_else(|e| panic!("{line}: frames done: {e}")); + let total: u64 = fields[2].parse().unwrap_or_else(|e| panic!("{line}: frames total: {e}")); + let elapsed: f64 = fields[3].parse().unwrap_or_else(|e| panic!("{line}: seconds elapsed: {e}")); + assert!(fields[4] == "-" || fields[4].parse::().is_ok_and(|left| left >= 0.0), "{line}: seconds left"); + assert!(done <= total && total > 0 && elapsed >= 0.0, "{line}"); + assert!(last_done.is_none_or(|last| done > last), "one line per change, and frames only go up: {stderr}"); + last_done = Some(done); + totals.push(total); + } + assert!(!totals.is_empty(), "no progress line on a piped stderr: {stderr}"); + assert!(totals.iter().all(|&t| t == totals[0]), "one render, one total: {stderr}"); +} + +/// Without `--progress`, the same render writes no progress lines to a piped stderr (#1778). +#[test] +fn render_without_progress_writes_no_progress_lines() { + let stderr = render_demo_stderr("no-progress.gif", &[]); + assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}"); +} diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..17149f545 --- /dev/null +++ b/apps/effectcraft-cli/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod cli; +mod qa_template; diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs similarity index 80% rename from apps/effectcraft-cli/tests/qa_template.rs rename to apps/effectcraft-cli/tests/it/qa_template.rs index ac67280ee..0618c0d63 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/it/qa_template.rs @@ -247,7 +247,7 @@ fn template_output_alias_fixture(case: &str) -> PathBuf { dir } -fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) { +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. @@ -272,7 +272,8 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) { ); assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file"); assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file"); - assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input"); + assert_eq!(result.status.code(), Some(1), "an output alias must fail before rendering overwrites an input"); + serde_json::from_slice(&result.stdout).unwrap() } #[test] @@ -652,3 +653,159 @@ fn template_missing_output_does_not_replace_extracted_media() { assert!(media_preserved, "missing --out must fail before overwriting previously extracted media"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +#[test] +fn template_frame_output_cannot_alias_its_template_through_a_hard_link() { + use std::os::unix::fs::MetadataExt; + + let dir = template_output_alias_fixture("hard-linked-template"); + let input = dir.join("lt.ectemplate"); + let output = dir.join("hard-linked-template.png"); + std::fs::hard_link(&input, &output).unwrap(); + let input_metadata = std::fs::metadata(&input).unwrap(); + let output_metadata = std::fs::metadata(&output).unwrap(); + assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity"); + assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path"); + let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-template.png"); + assert_eq!(reply["error"], "--out hard-linked-template.png would overwrite --template lt.ectemplate", "{reply}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn template_frame_output_cannot_alias_its_values_through_a_hard_link() { + use std::os::unix::fs::MetadataExt; + + let dir = template_output_alias_fixture("hard-linked-values"); + let input = dir.join("values.json"); + let output = dir.join("hard-linked-values.png"); + std::fs::hard_link(&input, &output).unwrap(); + let input_metadata = std::fs::metadata(&input).unwrap(); + let output_metadata = std::fs::metadata(&output).unwrap(); + assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity"); + assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path"); + let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-values.png"); + assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A template whose `Clip` control is a Media Replacement showing `source.png` (blue), with +/// `other.png` (red) beside it to replace it with, exported as `lt.ectemplate` in a fresh `dir`. +fn media_template(case: &str) -> PathBuf { + let temp_root = std::env::temp_dir().canonicalize().unwrap(); + let dir = temp_root.join(format!("ec-qa-template-{case}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap(); + image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 20, 40, 255])).save(dir.join("other.png")).unwrap(); + let imported = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]); + let item = imported["result"]["items"][0].as_u64().unwrap(); + let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string(); + let added = ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]); + let layer = added["result"]["layer"].as_u64().unwrap(); + let params = serde_json::json!({"comp": "Lower Third", "layer": layer, "name": "Clip"}).to_string(); + ok(&dir, &["exec", "essential.addMedia", ¶ms, "t.ecproj", "--save"]); + ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + dir +} + +/// Renders the template's frame with `values` and checks that the control shows `other.png`. +fn assert_renders_the_replacement(dir: &PathBuf, values: &str) { + std::fs::write(dir.join("values.json"), values).unwrap(); + ok(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]); + let px = image::open(dir.join("out.png")).unwrap().to_rgba8().get_pixel(8, 8).0; + assert!( + (px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2, + "{values}: replacement media rendered: {px:?}" + ); +} + +#[test] +fn template_values_fill_a_media_replacement_control_with_a_file_path() { + let dir = media_template("media-control"); + assert_renders_the_replacement(&dir, r#"{"Clip":"other.png"}"#); + + // A control that is not given a path is rejected with a clear message. + std::fs::write(dir.join("bad.json"), r#"{"Clip":5}"#).unwrap(); + let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "bad.json", "--time", "1", "--out", "bad.png"]); + assert_eq!(code, 1, "{reply}"); + assert!(reply["error"].as_str().unwrap().contains("media control"), "{reply}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The object forms `{"path"|"file"|"media": …}` take a file path too, and the control is found +/// by name the way `essential.set` finds it: exactly, then ignoring case (#1787). +#[test] +fn template_media_control_takes_a_path_object_and_a_name_in_any_case() { + let dir = media_template("media-forms"); + for values in [ + r#"{"Clip":{"path":"other.png"}}"#, + r#"{"Clip":{"file":"other.png"}}"#, + r#"{"Clip":{"media":"other.png"}}"#, + r#"{"clip":"other.png"}"#, + r#"{"CLIP":{"path":"other.png"}}"#, + ] { + let _ = std::fs::remove_file(dir.join("out.png")); + assert_renders_the_replacement(&dir, values); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A media path that cannot be imported fails the render with an error naming the control and +/// the path, and writes no frame (#1787). +#[test] +fn template_media_control_reports_a_path_that_cannot_be_imported() { + let dir = media_template("media-missing"); + std::fs::write(dir.join("missing.json"), r#"{"Clip":"missing.png"}"#).unwrap(); + let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing.json", "--time", "1", "--out", "missing-out.png"]); + assert_eq!(code, 1, "{reply}"); + let error = reply["error"].as_str().unwrap(); + assert!(error.contains("control `Clip`: cannot import missing.png"), "{reply}"); + 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 3db6ad35c..12cb90bfa 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -57,6 +57,26 @@ function req(r) { return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); } +// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has +// committed. A request's success doesn't mean the write is stored: the transaction can still +// abort afterwards (quota at commit, the browser closing), rolling it back, and a write +// reported as saved then is never retried (#1242). A failed request aborts the transaction +// with its error; an explicit abort leaves no error, hence the AbortError. +function write(db, store, op) { + return new Promise((res, rej) => { + let tx = null; + try { + tx = db.transaction(store, "readwrite"); + tx.oncomplete = () => res(); + tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError")); + op(tx.objectStore(store)); + } catch (e) { + try { tx?.abort(); } catch {} + rej(e); + } + }); +} + async function idb() { if (!self.indexedDB) throw new Error("no IndexedDB"); const open = indexedDB.open(DB, 1); @@ -70,10 +90,10 @@ async function idb() { return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified })); }, async put(key, data, modified) { - await req(tx("readwrite").put({ key, data: data.slice().buffer, modified })); + await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified })); }, async del(key) { - await req(tx("readwrite").delete(key)); + await write(db, STORE, (s) => s.delete(key)); }, }; } @@ -315,7 +335,15 @@ function spawn(base) { /// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download), /// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message). export function workerRun(id, json, files, base, onMessage) { - const w = idle.pop() ?? spawn(base); + let w; + try { + w = idle.pop() ?? spawn(base); + } 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)); + return; + } running.set(id, w); w.ready.then(() => { w.worker.onmessage = (e) => { @@ -380,7 +408,17 @@ const frameWorkers = []; /// GPU effects on it. export function frameWorkerStart(base, onMessage, gpu) { const index = frameWorkers.length; - const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` }); + let worker; + try { + worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` }); + } catch (e) { + // The constructor throws at once when the page's origin is opaque (a sandboxed iframe). The + // 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"))); + 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); @@ -456,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(); @@ -550,7 +592,7 @@ async function openFolder(handle, remember) { if (remember) { try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle })); + await write(db, "folders", (s) => s.put({ name, handle })); } catch (e) { console.info("media browser: cannot remember the folder", e); } } return { name, entries }; @@ -602,7 +644,7 @@ export async function browseForget(name) { folders.delete(name); try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name)); + await write(db, "folders", (s) => s.delete(name)); } catch {} } diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..f09bf28a1 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -87,6 +87,17 @@ pub struct WebApp { impl eframe::App for WebApp { 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); } @@ -95,18 +106,14 @@ impl eframe::App for WebApp { 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 +121,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 +192,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 +213,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 +224,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 +273,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/files.rs b/apps/effectcraft-web/src/files.rs index 76b799129..3f3f3a99f 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -144,6 +144,10 @@ impl Services for WebServices { let dir = dir.trim_end_matches('/'); list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect() } + /// The table's files (the default asks `std::fs`, which has no files on wasm32). + fn exists(&self, path: &str) -> bool { + get(path).is_some() + } fn is_dir(&self, _path: &str) -> bool { false } 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/store.rs b/apps/effectcraft-web/src/store.rs index b3a858def..a44a2eac7 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,14 +152,31 @@ 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 { @@ -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 ee272a67d..3a6c1337c 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -441,6 +441,18 @@ try { const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`); if (!comps) throw new Error("reopened project has no active comp"); report.steps.reopen = { ...opened, activeComp: comps }; + + // Files the page holds (browser storage, no std::fs): a script opens one next to the project, + // and the footage check finds the imported media instead of marking it missing. + const hf = await js(`(async () => { + const p = await effectcraft.addFile(new File(["from the page"], "script-data.txt")); + await effectcraft.saveToBrowser("/host-files.ecproj"); + const run = await effectcraft.execute("script.run", {code: "var f = new File(" + JSON.stringify(p) + "); writeLn(f.open('r') ? f.read() : f.error);"}); + return {output: run.output, footage: await effectcraft.execute("footage.check", {wait: true})}; + })()`); + report.steps.hostFiles = hf; + check(hf.output === "from the page", `a script opens a file the page holds (${hf.output})`); + check(hf.footage.checked > 0 && hf.footage.missing === 0, `stored footage isn't missing (${JSON.stringify(hf.footage)})`); report.steps.final = { screenshot: await shot("02-after") }; // Persistence: the session, imported media, settings and a project saved to browser storage diff --git a/apps/effectcraft-web/tests/storage.mjs b/apps/effectcraft-web/tests/storage.mjs new file mode 100644 index 000000000..0855c1674 --- /dev/null +++ b/apps/effectcraft-web/tests/storage.mjs @@ -0,0 +1,180 @@ +// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no +// browser or Wasm build needed: +// +// node apps/effectcraft-web/tests/storage.mjs +// +// A write is acknowledged only once its transaction has committed, not when its request +// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be +// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); +globalThis.self = globalThis; + +// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite +// transaction commits once its requests are done, unless it was aborted (which rolls back its +// writes). `fault` makes the next request do something else. +let fault = null; // "abortAfterSuccess" | "requestError" | "throw" +const databases = new Map(); + +class Request { + constructor(tx) { + this.transaction = tx; + this.result = undefined; + this.error = null; + } +} + +class Store { + constructor(tx, rows) { + this.tx = tx; + this.rows = rows; + } + request(apply) { + const tx = this.tx; + if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError"); + const f = fault; + fault = null; + if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError"); + const r = new Request(tx); + tx.pending++; + setTimeout(() => { + tx.pending--; + if (tx.finished) return; + if (f === "requestError") { + r.error = new DOMException("the key already exists", "ConstraintError"); + const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + r.onerror?.(e); + tx.onerror?.(e); + if (!e.defaultPrevented) tx.fail(r.error); + return; + } + r.result = apply(tx.staged); + r.onsuccess?.({ target: r }); + if (f === "abortAfterSuccess") tx.abort(); + tx.maybeCommit(); + }, 0); + return r; + } + put(v) { + return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; }); + } + delete(k) { + return this.request((rows) => { rows.delete(k); }); + } + get(k) { + return this.request((rows) => rows.get(k)); + } + getAll() { + return this.request((rows) => [...rows.values()]); + } +} + +class Transaction { + constructor(db, name) { + this.db = db; + this.name = name; + this.staged = new Map(db.stores.get(name)); + this.pending = 0; + this.finished = false; + this.error = null; + setTimeout(() => this.maybeCommit(), 0); + } + objectStore(name) { + const s = new Store(this, this.staged); + s.keyPath = this.db.keyPaths.get(name); + return s; + } + maybeCommit() { + if (this.finished || this.pending > 0) return; + this.finished = true; + this.db.stores.set(this.name, this.staged); + setTimeout(() => this.oncomplete?.(), 0); + } + fail(error) { + this.error = error; + this.abort(); + } + abort() { + if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError"); + this.finished = true; + setTimeout(() => this.onabort?.(), 0); + } +} + +class Database { + constructor() { + this.stores = new Map(); + this.keyPaths = new Map(); + } + createObjectStore(name, { keyPath }) { + this.stores.set(name, new Map()); + this.keyPaths.set(name, keyPath); + } + transaction(name) { + if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError"); + return new Transaction(this, name); + } +} + +globalThis.indexedDB = { + open(name) { + const r = new Request(null); + setTimeout(() => { + let db = databases.get(name); + if (!db) { + db = new Database(); + databases.set(name, db); + r.result = db; + r.onupgradeneeded?.(); + } + r.result = db; + r.onsuccess?.({ target: r }); + }, 0); + return r; + }, +}; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); +assert.equal(await host.storeOpen("indexeddb"), "indexeddb"); +const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key); +const bytes = (s) => new TextEncoder().encode(s); +const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data)); + +// A healthy write resolves once committed, and the store has it then. +await host.storePut("project.json", bytes("v1"), 1); +assert.equal(text(stored("project.json")), "v1", "a resolved put is committed"); + +// The put's request succeeds, then the transaction aborts: the put fails, the old value stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back"); + +// The same for a delete: it fails and the entry stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back"); + +// A failed request aborts its transaction with the request's error. +fault = "requestError"; +await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" }); +assert.equal(text(stored("project.json")), "v1"); + +// A request that throws at once (a value that can't be stored) fails the write. +fault = "throw"; +await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" }); +assert.equal(text(stored("project.json")), "v1"); + +// The retry after a failure commits the latest value, and a later delete removes it. +await host.storePut("project.json", bytes("v5"), 5); +assert.equal(text(stored("project.json")), "v5"); +await host.storeDelete("project.json"); +assert.equal(stored("project.json"), undefined, "a resolved delete is committed"); +const all = await host.storeLoadAll(); +assert.deepEqual(all, [], "nothing else was written"); + +await tick(); +console.log("storage: ok"); diff --git a/apps/effectcraft-web/tests/workers.mjs b/apps/effectcraft-web/tests/workers.mjs index 15a77f1d5..58901885f 100644 --- a/apps/effectcraft-web/tests/workers.mjs +++ b/apps/effectcraft-web/tests/workers.mjs @@ -7,6 +7,8 @@ // (#218), and not when it is unchanged. // - A worker whose start fails fails its job and is terminated, instead of the job waiting for // "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start. +// - A `new Worker` that throws (a sandboxed iframe's opaque origin, #1586) fails the job, and a +// frame worker is reported dead, instead of the exception escaping into startup. // Exits non-zero on failure. import assert from "node:assert/strict"; import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs"; @@ -67,6 +69,35 @@ assert.equal(failed.kind, "error"); assert.ok(workers[1].terminated, "the failed worker is terminated"); assert.equal(host.workerStats().running, 0); +// A `new Worker` that throws (a sandboxed iframe's opaque origin): the job fails, and a frame +// worker is reported dead, instead of the exception escaping into startup. +failStart = false; +const RealWorker = globalThis.Worker; +globalThis.Worker = class { + constructor() { + throw new Error("SecurityError: cannot be accessed from origin 'null'"); + } +}; +// (Take the idle workers out of play first, so that a new one has to be built.) +for (let n = host.workerStats().idle, id = 100; n > 0; n--, id++) { + host.workerRun(id, "{}", [], "http://localhost/", () => {}); + host.workerCancel(id); +} +assert.equal(host.workerStats().idle, 0); +const threw = await run(6, []); +assert.equal(threw.kind, "error"); +assert.match(threw.json, /SecurityError/); +assert.equal(host.workerStats().running, 0); +let frameCall = null; +const frameDone = new Promise((resolve) => { + frameCall = host.frameWorkerStart("http://localhost/", (index, json, bytes, error) => resolve({ index, error }), false); +}); +const dead = await frameDone; +assert.equal(dead.index, frameCall, "the dead worker keeps its index"); +assert.match(dead.error, /SecurityError/); +host.frameWorkerPost(frameCall, { type: "frame" }); // no worker behind it: ignored +globalThis.Worker = RealWorker; + // #216, worker side: a rejected start is re-raised where the page's `onerror` sees it. const dir = mkdtempSync(join(tmpdir(), "ec-worker-")); copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js")); diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html index 70ab93ff5..85049007f 100644 --- a/apps/effectcraft-web/web/index.html +++ b/apps/effectcraft-web/web/index.html @@ -71,7 +71,13 @@ ...ec.info(), crossOriginIsolated: self.crossOriginIsolated, webgpu: !!navigator.gpu, - serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller), + serviceWorker: (() => { + try { + return !!(navigator.serviceWorker && navigator.serviceWorker.controller); + } catch { + return false; + } + })(), load: window.effectcraftLoad, }), // Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes. @@ -83,8 +89,14 @@ workerFrameCheck: (params) => ec.workerFrameCheck(params ?? {}).then(JSON.parse), }; // Offline start and install (PWA): the service worker caches the app's files. - if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) { - navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e)); + if (!new URLSearchParams(location.search).has("nosw")) { + try { + if ("serviceWorker" in navigator && navigator.serviceWorker) { + navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e)); + } + } catch (e) { + console.warn("service worker:", e); + } } try { // Compile once: the page and its render workers instantiate the same module. 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..8a1ea3ae3 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 diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 2f99bd5e0..a95e79a49 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -23,8 +23,8 @@ mod control_server; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; -// Built everywhere so it stays checked; only the macOS app uses it. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +// Built everywhere so it stays checked; the macOS and Linux apps use it. +#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))] mod sheet_dialogs; mod window; @@ -122,6 +122,22 @@ fn control_port(name: &str, value: Option<&str>) -> Result { } } +/// The commands' file dialogs open without blocking the frame where the host's backend would: +/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop +/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its +/// 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) { + #[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()))); + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let _ = (app, ctx); +} + fn main() -> eframe::Result { // 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 @@ -187,6 +203,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"), @@ -301,11 +318,11 @@ fn main() -> eframe::Result { let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string()) })); + // 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); #[cfg(target_os = "macos")] { - // The commands' file dialogs open as sheets instead of nested modal loops (#511). - let ctx = cc.egui_ctx.clone(); - app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone()))); app.hooks.app_action = Some(Box::new(native_menu::app_action)); app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text)); } @@ -355,15 +372,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,7 +435,8 @@ impl eframe::App for Desktop { 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); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -523,6 +548,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() { @@ -597,4 +633,17 @@ mod tests { assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`")); } } + + /// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the + /// compositor while it is open, so the commands' file dialogs go through the non-blocking + /// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages. + #[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()); + #[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"); + #[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"); + } } diff --git a/apps/effectcraft/src/sheet_dialogs.rs b/apps/effectcraft/src/sheet_dialogs.rs index 577eb0b64..7a75ef2a7 100644 --- a/apps/effectcraft/src/sheet_dialogs.rs +++ b/apps/effectcraft/src/sheet_dialogs.rs @@ -1,10 +1,14 @@ -//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`. +//! File dialogs that don't block the frame (macOS and Linux), for `Hooks::file_dialog_async`. //! //! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is //! inside winit's event handler. On macOS a file dragged over the window while that loop ran //! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app -//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once; -//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then. +//! (#511). On Linux the blocking dialog is worse than slow: rfd uses the XDG desktop portal, so +//! the main thread sits waiting on the portal's answer and never pumps winit's events — the +//! window stops answering the compositor and the desktop offers to force-quit the app while the +//! Open dialog is up (#1275). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window +//! (macOS) or through the portal from a worker thread (Linux) and returns at once; the answer +//! arrives when the dialog closes, outside the frame, and the app runs the command then. use effectcraft_ui_egui::{DialogDone, FileDialog}; use rfd::{AsyncFileDialog, FileHandle}; 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/contributors/people.toml b/contributors/people.toml index 705feb257..1012a37bd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -66,7 +66,7 @@ sync_github_name = true display_name = "Jason Brashear" [people.nanox333] -display_name = "nanox333" +display_name = "nnaannoo" [people.Nael-Nathanael] sync_github_name = true @@ -75,6 +75,26 @@ sync_github_name = true real_name = "Robert Lee" sync_github_name = true -[people.moecr7] +[people.onlymoein] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.arnav-hiwarkar] +display_name = "arnav-hiwarkar" +sync_github_name = false + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false + +[people.krinry] +display_name = "krinry" +sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false + +[people.ki2pixel] +display_name = "ki2pixel" diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index eab1c01db..77d69dd01 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -1,5 +1,5 @@ //! Blocking client for the desktop app's JSON-lines control channel (`docs/control-protocol.md`). -//! One persistent loopback connection, reconnected once if the app went away. +//! One persistent loopback connection. Only explicitly read-only queries can be resent. use std::io::{BufRead, BufReader, Write}; use std::net::{TcpStream, ToSocketAddrs}; @@ -50,16 +50,20 @@ impl BridgeClient { /// Call a control method; returns the reply's `result`, or the app's `error` as [`Error::App`]. pub fn call(&mut self, method: &str, params: Value) -> Result { + let retry_read = + matches!(method, "engine.commands" | "ui.menu.list" | "ui.inspect" | "ui.elements" | "ui.viewer.locate" | "ui.viewer.hit" | "ui.timeline.locate") + || (method == "ui.playback" && params["action"] == "status"); + let unknown = |error| Error::Bridge(format!("`{method}` request outcome is unknown: {error}; inspect the app before retrying")); let mut last = Error::Bridge("not attempted".into()); for attempt in 0..2 { if self.conn.is_none() { self.conn = Some(self.connect()?); } let id = self.next_id; - self.next_id += 1; + self.next_id = self.next_id.checked_add(1).ok_or_else(|| Error::Bridge("control request id space exhausted".into()))?; let Some((r, w)) = self.conn.as_mut() else { continue }; let line = format!("{}\n", json!({"id": id, "method": method, "params": params})); - // `Ok(Err(_))`: the connection is dead before a reply (safe to resend once); + // `Ok(Err(_))`: transmission/reply failed (the app may have applied the request); // `Err(_)`: a timeout or bad reply (never resent: the app may have run the request). let res = (|| -> Result> { if let Err(e) = w.write_all(line.as_bytes()) { @@ -80,8 +84,11 @@ impl BridgeClient { Ok(Ok(v)) if v.get("ok").and_then(Value::as_bool) == Some(true) => return Ok(v.get("result").cloned().unwrap_or(Value::Null)), Ok(Ok(v)) => return Err(Error::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())), Ok(Err(e)) => { - // Stale connection (app restarted): reconnect and resend once. self.conn = None; + if !retry_read { + return Err(unknown(e)); + } + // A read-only query can be repeated after a stale connection or lost reply. last = e; if attempt == 1 { break; @@ -90,7 +97,7 @@ impl BridgeClient { Err(e) => { // The stream may still deliver the late reply: start fresh next call. self.conn = None; - return Err(e); + return Err(if retry_read { e } else { unknown(e) }); } } } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6bb6fbc52..6e622b377 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -511,6 +511,128 @@ fn bridge_rejects_non_loopback() { assert!(Backend::bridge("localhost:9877").is_ok()); } +/// The app applies the first request but loses its reply. The next connection can be either +/// an automatic retry or an explicit follow-up; the returned counts distinguish them. +fn lost_reply_server(first_reply: Option<&'static str>) -> (String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let worker = std::thread::spawn(move || { + let mut session = Session::default(); + let mut mutations = 0; + for attempt in 0..2 { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut output = stream.try_clone().unwrap(); + let mut input = String::new(); + BufReader::new(stream).read_line(&mut input).unwrap(); + let request: Value = serde_json::from_str(&input).unwrap(); + let result = if request["method"] == "engine.execute" { + mutations += 1; + session.execute(request["params"]["command"].as_str().unwrap(), request["params"]["params"].clone()).unwrap() + } else if request["method"] == "ui.inspect" || (request["method"] == "ui.playback" && request["params"]["action"] == "status") { + json!({"mutations": mutations, "requests": attempt + 1, "comps": session.project.comps().count()}) + } else { + mutations += 1; + json!({"mutations": mutations}) + }; + if attempt == 1 { + writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap(); + } else if let Some(reply) = first_reply { + writeln!(output, "{reply}").unwrap(); + } + } + }); + (addr, worker) +} + +#[test] +fn bridge_does_not_replay_a_mutation_when_its_reply_is_lost() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err(); + assert!(error.to_string().contains("outcome is unknown"), "{error}"); + let state = backend.control("ui.inspect", json!({})).unwrap(); + assert_eq!(state["mutations"], 1, "the first command was applied exactly once"); + assert_eq!(state["comps"], 1); + worker.join().unwrap(); +} + +#[test] +fn bridge_retries_read_only_queries_after_a_lost_reply() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let state = backend.control("ui.inspect", json!({})).unwrap(); + assert_eq!(state["requests"], 2); + assert_eq!(state["mutations"], 0); + worker.join().unwrap(); +} + +#[test] +fn bridge_reports_unknown_outcome_after_a_malformed_mutation_reply() { + let (addr, worker) = lost_reply_server(Some("{invalid reply")); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("outcome is unknown") && message.contains("bad reply"), "{message}"); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["comps"], 1); + worker.join().unwrap(); +} + +#[test] +fn bridge_does_not_assume_an_unknown_method_is_read_only() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.control("future.customOperation", json!({})).unwrap_err(); + assert!(error.to_string().contains("outcome is unknown"), "{error}"); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1); + worker.join().unwrap(); +} + +#[test] +fn bridge_playback_edits_and_default_toggle_are_not_replayed() { + for params in [json!({}), json!({"action": "toggle"}), json!({"action": "play"}), json!({"action": "stop"})] { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.control("ui.playback", params).unwrap_err(); + assert!(error.to_string().contains("outcome is unknown"), "{error}"); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1); + worker.join().unwrap(); + } +} + +#[test] +fn bridge_playback_status_can_be_retried() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let state = backend.control("ui.playback", json!({"action": "status"})).unwrap(); + assert_eq!(state["requests"], 2); + assert_eq!(state["mutations"], 0); + worker.join().unwrap(); +} + +#[test] +fn bridge_connect_failure_can_be_retried_when_the_app_starts() { + let reserved = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = reserved.local_addr().unwrap(); + drop(reserved); + let mut backend = Backend::bridge(&addr.to_string()).unwrap(); + let error = backend.control("ui.inspect", json!({})).unwrap_err(); + assert!(error.to_string().contains("cannot connect"), "{error}"); + assert!(!error.to_string().contains("outcome is unknown"), "nothing was sent"); + let listener = TcpListener::bind(addr).unwrap(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut output = stream.try_clone().unwrap(); + let mut input = String::new(); + BufReader::new(stream).read_line(&mut input).unwrap(); + let request: Value = serde_json::from_str(&input).unwrap(); + writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": {"ready": true}})).unwrap(); + }); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["ready"], true); + worker.join().unwrap(); +} + #[test] fn script_ui_and_history_tools() { let mut sess = Session::default(); 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/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/catalog.rs b/crates/effects/src/catalog.rs index 82678eb86..0be2b04f5 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -98,6 +98,9 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { "ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false, "ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false, + // #1195: CC Particle World's motion trails (the averaged shutter samples) render on the + // CPU kernel until the GPU particle pass supports them. + "ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false, _ => {} } let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true }; @@ -199,6 +202,14 @@ mod tests { assert!(ok("ec.color.lumetri", &[])); assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))])); assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))])); + // #1195: CC Particle World's motion trails (shutter samples) render on the CPU. + { + let params = ctx_with("ec.sim.ccparticleworld", &[]); + let env = crate::EffectEnv { frame_rate: 30.0, shutter: Some((180.0, -90.0, 16)), ..Default::default() }; + let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env }; + assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx)); + } + assert!(ok("ec.sim.ccparticleworld", &[])); assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))])); assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))])); assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))])); diff --git a/crates/effects/src/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..39a1c49b1 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; @@ -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]] } }; @@ -814,6 +877,20 @@ mod tests { 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()] } @@ -835,6 +912,31 @@ mod tests { } } + /// 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. diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 71dee87b4..9547fd775 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 } @@ -1139,6 +1193,86 @@ mod tests { 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(); diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index c85ab8d09..4850710ab 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -301,7 +301,8 @@ impl OffsetCurve { } let n = ext.len(); // Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat - // segments so the curve never overshoots its points. + // segments, and at most three times either slope (Fritsch-Carlson) so the curve never + // overshoots its points, even beside a short steep segment. let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6); let m: Vec = (0..n) .map(|k| { @@ -309,7 +310,12 @@ impl OffsetCurve { return 0.0; } let (a, b) = (slope(k - 1), slope(k)); - if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 } + if a * b <= 0.0 { + 0.0 + } else { + let lim = 3.0 * a.abs().min(b.abs()); + ((a + b) * 0.5).clamp(-lim, lim) + } }) .collect(); let eval = |x: f32| -> f32 { @@ -989,6 +995,28 @@ mod tests { } } + #[test] + fn hue_saturation_curve_stays_between_its_points() { + // #1656: a point dragged to the bottom right next to another one. The steep short + // segment used to pull the long one beside it far outside the box (|offset| > 0.5). + // The third pair sits across the hue wrap (0.98 and 0.005 are neighbours). + let cases = [("0.3,0.4 0.31,0 0.7,0.6", true), ("0.1,0.3 0.3,0.5 0.31,1 0.7,0.5", false), ("0.5,0.6 0.98,0.4 0.005,0", true)]; + for (pts, periodic) in cases { + let c = OffsetCurve::parse(pts, periodic).unwrap(); + for i in 0..=1000 { + let x = i as f32 / 1000.0; + let v = c.at(x); + assert!((-0.5..=0.5).contains(&v), "{pts} at {x}: {v}"); + } + for p in parse_points(pts) { + assert!((c.at(p.0) - (p.1 - 0.5)).abs() < 0.02, "{pts}: passes through {p:?}"); + } + if periodic { + assert!((c.at(0.0) - c.at(1.0)).abs() < 1e-4, "{pts}: no seam at the wrap"); + } + } + } + fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps) } diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index e431ff22e..402d0ebe2 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 @@ -326,27 +372,59 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { v.abs().powf(p).copysign(v) } +/// Black & White: a pixel's gray is its smallest channel plus its chroma (largest − smallest +/// channel) times the colour sliders' weight for its hue. A neutral pixel keeps its value, a pure +/// hue becomes its slider's percentage (Reds 40 % turns pure red into 40 % gray), and the gray +/// never moves further from the smallest channel than the pixel's own chroma times the weight. +/// 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); let tint_on = ctx.params.b("tint"); let tc = ctx.params.color("tintColor"); b.img.map_straight(|c| { - let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]); + let (h, _, _) = rgb_to_hsl(c[0], c[1], c[2]); // Interpolate the six hue weights around the colour wheel. let hp = h * 6.0; let i = hp.floor() as usize % 6; let f = hp - hp.floor(); let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f; - let base = luminance(c[0], c[1], c[2]); - let gray = (base + (wt - 0.5) * s * 0.6).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] - } + 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 { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] } }); b } @@ -411,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 } @@ -962,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"])); @@ -1166,6 +1243,17 @@ mod tests { } } + /// 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. @@ -1220,6 +1308,135 @@ mod tests { 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]; @@ -1254,6 +1471,66 @@ mod tests { 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]; @@ -1395,6 +1672,30 @@ mod tests { 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]; 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/distort.rs b/crates/effects/src/distort.rs index a68de1094..534da8061 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -164,7 +164,10 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf { let kind = ctx.params.e("waveType"); let pin = ctx.params.e("pinning"); let seed = ctx.seed ^ 0x3a7e; - let (dx, dy) = (dir.sin(), -dir.cos()); + // Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave + // boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU. + let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v }; + let (dx, dy) = (axis(dir.sin()), -axis(dir.cos())); if !ctx.adjustment { b.pad(h.abs().ceil() as u32 + 1); } @@ -556,8 +559,21 @@ mod tests { let (a, b) = (o.img.get(pad, y + pad), im.get(0, y)); assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}"); } - let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im); + let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone()); assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b))); + // #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row, + // Bottom Edge (6) the last, and the rest of the layer still moves. + for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] { + let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)]; + let o = run("ec.distort.wavewarp", &set, im.clone()); + let pad = ((o.img.width - im.width) / 2) as i64; + for x in 0..40 { + let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row)); + assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}"); + } + let mid = 15; + assert!((0..40).any(|x| differs(o.img.get(x + pad, mid + pad), im.get(x, mid))), "pin {edge_row}: the middle of the layer must still distort"); + } } #[test] diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 99139483f..cb389d901 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, @@ -1127,6 +1175,83 @@ mod tests { } } + #[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 { diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs index d1d1b0c2c..b724a5830 100644 --- a/crates/effects/src/distort4.rs +++ b/crates/effects/src/distort4.rs @@ -589,7 +589,7 @@ pub fn specs() -> Vec { p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color), // The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command. p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden), - p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden), + p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Point), p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])), ], @@ -672,6 +672,35 @@ mod tests { assert_eq!(zero.img.data, img.data); let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default()); assert_eq!(out.img.data, again.img.data); + // #1194: Distortion Mesh Offset is an exposed, editable control (AE's Distortion ▸ + // Offset) and it moves the mesh: the pattern rendered at x renders at x + offset. + let spec = crate::find("ec.distort.liquify").unwrap(); + assert!( + spec.params.iter().any(|ps| ps.id == "distortionMeshOffset" && ps.ui == effectcraft_project::ParamUi::Point), + "offset is shown in Effect Controls" + ); + let shifted = run_fx( + "ec.distort.liquify", + &[("distortionMesh", Value::Str(s.to_line())), ("distortionMeshOffset", Value::Vec2([20.0, 0.0]))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_ne!(shifted.img.data, out.img.data, "the offset changes the render"); + // The displaced region's centroid moves right by the offset (the mesh is sampled at + // layer minus offset), and the row far left of the shifted mesh is back to untouched. + let centroid = |o: &Image| -> f32 { + let (mut wsum, mut msum) = (0.0f32, 0.0f32); + for (i, (p, q)) in o.data.iter().zip(img.data.iter()).enumerate() { + let d: f32 = (0..4).map(|k| (p[k] - q[k]).abs()).sum(); + wsum += d; + msum += d * (i % 64) as f32; + } + if wsum > 1e-6 { msum / wsum } else { 0.0 } + }; + let (co, cs) = (centroid(&out.img), centroid(&shifted.img)); + assert!((cs - co - 20.0).abs() < 5.0, "centroids {co} -> {cs}"); + assert_eq!(shifted.img.get(5, 32), img.get(5, 32), "left of the shifted mesh: untouched"); // Reconstruction undoes the warp; freeze protects. let mut rec = s.clone(); rec.tool = 9; diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index b29ff9151..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; } @@ -138,7 +147,10 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0); c = c.map(|v| v + n); } - *px = gen_blend(*px, [c[0] * op, c[1] * op, c[2] * op, op], mode); + // None keeps the layer's alpha (#1381), as the Gradient Ramp does: the gradient fills + // a shape or text, not its bounding box. + let a = if gen_mode(mode).is_none() { op * px[3] } else { op }; + *px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode); } }); b @@ -420,6 +432,78 @@ 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. + let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]); + for (i, p) in img.data.iter_mut().enumerate() { + let a = [0.0, 0.5, 1.0][i % 40 / 14]; + *p = p.map(|c| c * a); + } + for op in [100.0, 50.0] { + let o = run_fx("ec.generate.fourcolor", &[("opacity", num(op))], img.clone(), 0.0, EffectEnv::default()); + let k = op as f32 / 100.0; + for (src, out) in img.data.iter().zip(&o.img.data) { + assert!((src[3] * k - out[3]).abs() < 1e-6, "opacity {op}: alpha {} became {}", src[3], out[3]); + assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "opacity {op}: premultiplied colour above alpha"); + } + assert!(o.img.data.iter().any(|p| p[1] > 0.1), "opacity {op}: the gradient replaces the layer's colour"); + } + } + #[test] fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() { let set = [("width", num(8.0)), ("height", num(2.0))]; 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/keying2.rs b/crates/effects/src/keying2.rs index f594bfbb3..a03fe804d 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -363,7 +363,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay); let d = qx * nx + qy * ny; let ad = d.abs(); - if ad > half + soft_w { + // The wire is the segment A to B, not an endless line. + let along = qx * tx + qy * ty; + if !(0.0..=len).contains(&along) || ad > half + soft_w { continue; } // Positions just outside the wire on both sides. @@ -580,6 +582,24 @@ mod tests { } } + #[test] + fn wire_removal_stays_between_a_and_b() { + let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); + for y in 0..20 { + img.set(10, y, [0.0, 0.0, 0.0, 1.0]); + } + let out = run_fx( + "ec.key.ccsimplewireremoval", + &[("pointA", Value::Vec2([10.5, 5.0])), ("pointB", Value::Vec2([10.5, 15.0])), ("removalStyle", Value::Enum(0)), ("thickness", num(3.0))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "inside the segment"); + assert_eq!(out.img.get(10, 2), img.get(10, 2), "before A"); + assert_eq!(out.img.get(10, 18), img.get(10, 18), "after B"); + } + #[test] fn wire_removal_frame_offset_uses_neighbour_frame() { use crate::{EffectHost, LayerPixels}; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 645ae20bc..e2446484f 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 @@ -418,6 +456,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 0c0dad9d1..b5976b761 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -239,6 +239,8 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) { // A path typed as text, or set only by commands, that got a Choose… button (Apply Color // LUT's LUT, the OCIO files, #514). (ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(), + // An internal point that became a control (Liquify's Distortion Mesh Offset, #1194). + (ParamUi::Hidden, ParamUi::Point) => pr.ui = ParamUi::Point, // Same kind of value: take the spec's hints (units, ranges). (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(), _ => {} @@ -497,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] @@ -514,6 +532,19 @@ mod tests { } } + /// #1194: Liquify's Distortion Mesh Offset was hidden; old projects show it, keeping its value. + #[test] + fn a_hidden_point_that_became_a_control_is_shown() { + let mut next = 1; + let (spec, mut g) = instance("ec.distort.liquify", &mut next); + let pr = g.get_mut("distortionMeshOffset").unwrap(); + pr.ui = ParamUi::Hidden; + pr.value = Value::Vec2([20.0, -5.0]); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0])); + let pr = g.get("distortionMeshOffset").unwrap(); + assert_eq!((&pr.ui, &pr.value), (&ParamUi::Point, &Value::Vec2([20.0, -5.0]))); + } + #[test] fn upgrade_moves_params_into_twirl_down_groups() { let spec = find(crate::warp_stab::ID).unwrap(); 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..baa5fb50a 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 { @@ -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/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 ea664ba41..73550e5de 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -238,9 +238,22 @@ impl SpritePlan { } /// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`. - pub fn finish(&self, mut b: Buf) -> Buf { + pub fn finish(&self, b: Buf) -> Buf { let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc); - b.img = match self.post { + 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) => { @@ -1130,6 +1143,29 @@ fn pw_phys(ctx: &EffectCtx) -> Phys { } 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, @@ -1775,6 +1811,31 @@ mod tests { ] } + /// #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); 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/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 68e761ab0..7104bc1cc 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } effectcraft-time = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-color = { workspace = true } @@ -33,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/autosave.rs b/crates/engine/src/autosave.rs index e1c56ef26..37127ac56 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -47,6 +47,11 @@ pub struct AutoSaveState { #[derive(Clone)] pub struct PendingWrite { pub path: PathBuf, + pub(crate) slot: u32, + pub(crate) revision: u64, + /// Originating project identity: the path changes on Save As, the epoch on Open/New. + pub(crate) project_path: Option, + pub(crate) project_epoch: u64, handle: std::sync::Arc>>>>, } @@ -57,9 +62,16 @@ impl std::fmt::Debug for PendingWrite { } impl PendingWrite { - pub fn spawn(path: PathBuf, work: impl FnOnce() -> std::io::Result<()> + Send + 'static) -> std::io::Result { + pub fn spawn( + path: PathBuf, + slot: u32, + revision: u64, + project_path: Option, + project_epoch: u64, + work: impl FnOnce() -> std::io::Result<()> + Send + 'static, + ) -> std::io::Result { let h = std::thread::Builder::new().name("autosave".into()).spawn(work)?; - Ok(PendingWrite { path, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) }) + Ok(PendingWrite { path, slot, revision, project_path, project_epoch, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) }) } pub fn is_finished(&self) -> bool { self.handle.lock().map(|h| h.as_ref().is_none_or(|h| h.is_finished())).unwrap_or(true) diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/commands/align_data.rs index 1f18de5f2..38d9e7841 100644 --- a/crates/engine/src/commands/align_data.rs +++ b/crates/engine/src/commands/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. @@ -182,7 +184,7 @@ fn run(s: &mut Session, p: &Value) -> Result { let video = match p.get("videoStart").filter(|v| v.as_str() != Some("")) { Some(v) => parse_stamp(v, fps).ok_or_else(|| bad(c, "videoStart: ISO 8601 date-time, hh:mm:ss, timecode or seconds"))?, None => { - let (created, _, _) = crate::media_browser::file_dates(&footage.path); + let (created, _, _) = if footage.missing { (None, None, None) } else { crate::media_browser::file_dates(&footage.path) }; Stamp { secs: created.ok_or_else(|| bad(c, "the footage has no recording time; pass `videoStart`"))? as f64, dated: true } } }; diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3ea27b0df..325845115 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -1,8 +1,8 @@ //! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key //! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs. -use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease}; -use effectcraft_project::{LayerId, Property, Uid}; +use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease}; +use effectcraft_project::{ItemId, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if clip.is_empty() { return Err(bad("keys.paste", "no keyframes have been copied")); } + // A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425). + if let [c] = clip.as_slice() + && let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value) + && let Some(target) = path_target(s, p)? + { + return paste_path(s, p, target, path); + } let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = time_p(s, p, Some(comp)); @@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result { }) } +/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path: +/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none. +pub(super) fn path_target(s: &Session, p: &Value) -> Result> { + let (cid, targets) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_)); + let explicit = targets.first().and_then(|lid| { + let l = comp.layer(*lid)?; + let pr = match p.get("prop").and_then(Value::as_u64) { + Some(u) => l.props.find(u), + None => l.props.prop(str_p(p, "path")?), + }?; + Some((*lid, pr)) + }); + if p.get("prop").is_some() || p.get("path").is_some() { + return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid))); + } + let selected = s + .state + .selected_props + .iter() + .filter(|(lid, _)| targets.contains(lid)) + .find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion)); + Ok(selected.map(|(lid, uid)| (cid, *lid, *uid))) +} + +/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per +/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first +/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the +/// vertices are pasted as they are (layer-space numbers, not converted to the target's space). +pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = time_p(s, p, Some(comp)); + let target = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut order: Vec = (0..path.vertices.len()).collect(); + if path.closed && !order.is_empty() { + order.push(0); + } + if order.is_empty() { + return Err(bad("keys.paste", "the copied path has no points")); + } + let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX); + let at = |xy: Option<&[f64; 2]>| { + let [x, y] = xy.copied().unwrap_or_default(); + [x, y, 0.0] + }; + let last = order.len() - 1; + let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order + .iter() + .enumerate() + .map(|(i, &v)| { + let [x, y, z] = at(path.vertices.get(v)); + let value = match target.props.find(uid).map(|pr| &pr.value) { + Some(KValue::Vec2(_)) => KValue::Vec2([x, y]), + _ => KValue::Vec3([x, y, z]), + }; + let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX)))); + (time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last) + }) + .collect(); + s.edit("Paste Keyframes", merge_p(p), |proj, st| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?; + let mut sel = vec![]; + for (time, value, spatial_in, spatial_out, roving) in keys { + let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) }; + match pr.keys.binary_search_by(|x| x.time.cmp(&time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + sel.push(time); + } + // Re-timing keeps the keys' order, so the pasted keys are found again by index. + let pasted: Vec = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect(); + retime_roving(&mut pr.keys, pr.spatial); + st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); + Ok(json!(st.selected_keys.len())) + }) +} + /// Select every keyframe of the selected properties, or of the given / selected layers. fn select_all(s: &mut Session, p: &Value) -> Result { let (cid, mut layers) = layers_p(s, p)?; diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index a556d9a42..1bdf3fed7 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -10,7 +10,7 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, selected_leaf_props, str_p}; +use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p}; use crate::{EngineError, KeyRef, Result, Session, cmd}; /// Preset file format version tag (`*.ecpreset`, JSON). @@ -147,7 +147,8 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { } } for (path, src) in &props { - match l.props.prop_mut(path) { + let resolved = resolve_effect_path(l, path); + match l.props.prop_mut(&resolved) { Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => { pr.value = src.value.clone(); pr.keys = src diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 020880e80..b95dd4375 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/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) @@ -245,7 +267,8 @@ fn has_footage(s: &Session) -> std::result::Result<(), String> { /// `File.execute()` from scripts (and agents): open a file with its default application. /// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on, -/// the frontend asks first (`confirmed: true` skips the question). +/// the frontend asks first. `confirmed: true` is the frontend's answer to that question: a +/// script can't give it for itself (#1246), so a script's request always asks. fn execute_file(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?; if !s.prefs.scripting.allow_scripts_write_files { @@ -257,7 +280,8 @@ fn execute_file(s: &mut Session, p: &Value) -> Result { if !std::path::Path::new(path).exists() { return Err(bad("file.executeFile", format!("no file {path}"))); } - if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) { + let confirmed = b_p(p, "confirmed").unwrap_or(false) && !s.running_for_script(); + if s.prefs.scripting.warn_executing_files && !confirmed { s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) }); return Ok(json!({"needsConfirmation": path})); } @@ -702,6 +726,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/batch.rs b/crates/engine/src/commands/batch.rs index 4bfd2b2b1..e1fc37140 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -56,6 +56,9 @@ fn batch(s: &mut Session, p: &Value) -> Result { 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 +78,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 { diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 06ba43b1c..05015e6b4 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -1,6 +1,7 @@ //! Edit menu. use effectcraft_color::Label; +use effectcraft_keyframe::Value as KV; use effectcraft_project::{Comp, Expression, 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(); @@ -115,6 +120,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { if !items.is_empty() { return super::prop_groups::duplicate_contents(s, &items); } + // Text animators or their selectors selected → duplicate them inside their text layers. + let items = super::prop_groups::selected_animator_items(s); + if !items.is_empty() { + return super::prop_groups::duplicate_contents(s, &items); + } } let (cid, ids) = layers_p(s, p)?; let new = s.edit("Duplicate", None, |proj, st| { @@ -196,12 +206,30 @@ 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(()) })?; + // 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())) } @@ -228,9 +256,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { } // Keyframes selected → copy keys (pasted at the CTI). if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { - s.state.link_clipboard = None; - s.state.effect_clipboard.clear(); - s.state.contents_clipboard.clear(); + clear_clipboards(s); return s.execute("keys.copy", json!({})); } // Effects selected (Effect Controls / timeline) → copy the effects. @@ -256,12 +282,14 @@ fn copy(s: &mut Session, p: &Value) -> Result { } /// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After -/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and -/// an unanimated one its value, for Paste onto the same properties of other layers (#493). +/// Effects: an animated property copies its keyframes and an unanimated one its value, for Paste +/// onto the same properties of other layers (#493). With keyframes selected only those are copied, +/// even across several highlighted properties (#1552); a selected Transform group copies them all. /// `None` when no property (or Transform group) is selected. fn copy_properties(s: &mut Session) -> Result> { let Some(comp) = s.active_comp() else { return Ok(None) }; let mut leaves = vec![]; + let mut copy_all_keys = s.state.selected_keys.is_empty(); for (lid, uid) in &s.state.selected_props { let Some(l) = comp.layer(*lid) else { continue }; if l.props.find(*uid).is_some() { @@ -269,6 +297,7 @@ fn copy_properties(s: &mut Session) -> Result> { } else if let Some(g) = l.props.find_group(*uid) && l.props.match_path_of(*uid).as_deref() == Some("transform") { + copy_all_keys = true; g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid))); } } @@ -286,7 +315,7 @@ fn copy_properties(s: &mut Session) -> Result> { { values.push((path, pr.value.clone())); } - } else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { + } else if copy_all_keys && !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); } } @@ -327,7 +356,8 @@ fn paste_properties(s: &mut Session, p: &Value) -> Result { } let lt = l.layer_time(t); for (path, v) in &values { - if let Some(pr) = l.props.prop_mut(path) + let path = super::resolve_effect_path(l, path); + if let Some(pr) = l.props.prop_mut(&path) && let Some(v) = pr.value.coerce_json(&v.to_json()) { pr.set_value_at(lt, v); @@ -457,7 +487,8 @@ fn paste_links(s: &mut Session, clip: LinkClip) -> Result { for lid in &ids { let l = super::layer_mut(proj, cid, *lid)?; for (path, e) in &pairs { - if let Some(pr) = l.props.prop_mut(path) { + let path = super::resolve_effect_path(l, path); + if let Some(pr) = l.props.prop_mut(&path) { pr.expr = Some(e.clone()); n += 1; } @@ -495,6 +526,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + // A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425). + if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice() + && let Some(target) = super::anim::path_target(s, p)? + { + let path = path.clone(); + return super::anim::paste_path(s, p, target, &path); + } if !s.state.value_clipboard.is_empty() { return paste_properties(s, p); } @@ -807,8 +845,30 @@ fn original_path(s: &Session) -> Option { } } +/// Whether Edit Original may hand `path` to the system's default app: a regular file (after +/// following links) whose name, and the name it links to, have a media extension the footage +/// 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(); + 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()); + let real = std::fs::canonicalize(path).map_err(|e| format!("cannot open “{name}”: {e}"))?; + if !real.is_file() { + return Err(format!("“{name}” is not a file")); + } + if !media(std::path::Path::new(path)) || !media(&real) { + return Err(format!("Edit Original opens media files only; “{name}” is not one")); + } + Ok(()) +} + fn edit_original(s: &mut Session, _: &Value) -> Result { - let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?; + let stored = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?; + let path = s.stored_path(&stored).map_err(|why| super::bad("edit.editOriginal", crate::doc_path::refusal(&stored, why)))?; + let path = path.to_string_lossy(); + openable_original(&path).map_err(|e| super::bad("edit.editOriginal", e))?; let url = super::layer_menu::file_url(&path); s.events.push(crate::Event::OpenUrl(url.clone())); Ok(json!({"url": url})) @@ -951,20 +1011,67 @@ fn always_ok(_: &Session) -> std::result::Result<(), String> { mod tests { use serde_json::json; + fn tmp(name: &str) -> std::path::PathBuf { + let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + /// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains. #[test] fn edit_original_opens_an_encoded_file_url() { + let clip = tmp("edit-original-url").join("take #1.mov"); + std::fs::write(&clip, b"").unwrap(); + let clip = clip.to_string_lossy().into_owned(); + let url = super::super::layer_menu::file_url(&clip); + assert!(url.ends_with("/take%20%231.mov"), "{url}"); let mut s = crate::Session::default(); let id = s .edit("add", None, |p, _| { - let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() }; + let f = effectcraft_project::Footage { path: clip.clone(), ..Default::default() }; Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f))) }) .unwrap(); s.state.project_selection = vec![id]; s.drain_events(); let r = s.execute("edit.editOriginal", json!({})).unwrap(); - assert_eq!(r["url"], "file:///clips/take%20%231.mov"); - assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov"))); + assert_eq!(r["url"], url.as_str()); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == url))); + } + + /// Only regular media files reach the system opener. + #[test] + fn edit_original_opens_media_files_only() { + let d = tmp("edit-original-kinds"); + for f in ["clip.mov", "Still.PNG", "run.bat", "app.desktop", "clip.mov.lnk"] { + std::fs::write(d.join(f), b"").unwrap(); + } + for f in ["Tool.app", "folder.mov"] { + std::fs::create_dir_all(d.join(f)).unwrap(); + } + let ok = |f: &str| super::openable_original(&d.join(f).to_string_lossy()).is_ok(); + assert!(ok("clip.mov") && ok("Still.PNG")); + for f in ["run.bat", "app.desktop", "clip.mov.lnk", "Tool.app", "folder.mov", "missing.mov", ""] { + assert!(!ok(f), "{f}"); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("app.desktop"), d.join("link.png")).unwrap(); + assert!(!ok("link.png"), "a media name linking to another kind of file"); + } + // The command refuses without opening anything. + let mut s = crate::Session::default(); + let path = d.join("run.bat").to_string_lossy().into_owned(); + let id = s + .edit("add", None, |p, _| { + let f = effectcraft_project::Footage { path, ..Default::default() }; + Ok(p.add_item("bat", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f))) + }) + .unwrap(); + s.state.project_selection = vec![id]; + s.drain_events(); + assert!(s.execute("edit.editOriginal", json!({})).is_err()); + assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_)))); } } diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index ce773e545..4f263b6fb 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/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/commands/essential.rs b/crates/engine/src/commands/essential.rs index 7ffa02d8f..cb6702660 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -891,6 +891,22 @@ pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<( Ok((manifest, project, entries)) } +/// Where a template's `media//` entry is written: `//`. +/// `None` unless `` is all digits and `` is a plain file name (not empty, `.` or +/// `..`, no separators, and on Windows no drive, stream or device name), so every entry lands +/// in an item folder directly inside `media_dir`. +fn template_media_path(media_dir: &std::path::Path, entry: &str) -> Option { + let (item, file) = entry.strip_prefix("media/")?.split_once('/')?; + if item.is_empty() || !item.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + if !super::scripts::is_bare_name(file) || (cfg!(windows) && super::scripts::windows_unsafe_name(file)) { + return None; + } + let out = media_dir.join(item).join(file); + (out.parent().and_then(std::path::Path::parent) == Some(media_dir) && out.file_name() == Some(std::ffi::OsStr::new(file))).then_some(out) +} + fn import_template(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.importTemplate"; let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string(); @@ -905,8 +921,7 @@ fn import_template(s: &mut Session, p: &Value) -> Result { let media_dir = dir.join(format!("{stem} Media")); let mut written = std::collections::HashMap::new(); for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) { - let rel = k.trim_start_matches("media/"); - let out = media_dir.join(rel); + let Some(out) = template_media_path(&media_dir, k) else { continue }; if let Some(parent) = out.parent() { let _ = std::fs::create_dir_all(parent); } diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb633..0fea452dc 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -18,7 +18,10 @@ fn new_project(s: &mut Session, _: &Value) -> Result { if s.prefs.project.use_template && !tpl.is_empty() { let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?; let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?; - s.replace_project(Project::from_json(&text)?, None); + let mut proj = Project::from_json(&text)?; + crate::doc_path::resolve_footage(&mut proj, Some(&tpl)); + s.replace_project(proj, None); + s.stored_paths.add(&s.project, Some(&tpl)); s.update_sentinel(); return Ok(json!({"template": tpl})); } @@ -45,8 +48,12 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; let (mut proj, repaired) = Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; - resolve_relative_footage(&mut proj, path, s.services.as_ref()); + // Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or + // written by hand) is found next to the project file, as After Effects looks for footage + // relative to the project (#652). + let refused = crate::doc_path::resolve_footage(&mut proj, Some(path)); s.replace_project(proj, Some(path.to_string())); + s.stored_paths.add(&s.project, Some(path)); s.restore_view(&effectcraft_project::saved_view(&text)); s.note_project_path(path); // Values past the limits (a corrupt or hostile file) were brought within them (#788). @@ -56,6 +63,10 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let more = if more > 0 { format!(" and {more} more") } else { String::new() }; s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}.")); } + if let Some((_, first, why)) = refused.first() { + let n = refused.len(); + s.toast(format!("{n} footage item{} marked missing: {}.", if n == 1 { "" } else { "s" }, crate::doc_path::refusal(first, *why))); + } // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. let saved_by = effectcraft_project::saved_by(&text); if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) { @@ -72,28 +83,6 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired})) } -/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written -/// by hand) is found next to the project file, as After Effects looks for footage relative to -/// the project. It used to be looked up from the working directory: the CLI started in the -/// project's folder found it, the desktop app (started from the Finder, Explorer or a launcher, -/// in another folder) showed and rendered nothing (#652). A path that exists from the working -/// directory is kept. -fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn crate::Services) { - use std::path::Path; - let Some(dir) = Path::new(project_path).parent().filter(|d| !d.as_os_str().is_empty()) else { return }; - let beside = |f: &str| dir.join(f).to_string_lossy().to_string(); - for item in p.items.values_mut() { - let ItemKind::Footage(f) = &mut item.kind else { continue }; - if f.path.is_empty() || !Path::new(&f.path).is_relative() || services.exists(&f.path) || !services.exists(&beside(&f.path)) { - continue; - } - f.path = beside(&f.path); - for frame in f.sequence.iter_mut().filter(|q| Path::new(q.as_str()).is_relative()) { - *frame = beside(frame); - } - } -} - /// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. @@ -391,7 +380,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { Ok(b) if effectcraft_psd::is_psd(&b) => b, Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { // PDF / Illustrator / EPS: one layer per file layer. - match import_vector_comp(s, &path, &b, page) { + match import_vector_comp(s, &path, &b, page, retain) { Ok((comp, made)) => { comps.push(comp); items.extend(made); @@ -415,6 +404,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } } paths = rest; + // Composition imports already made their sources, so they bypass add_footage's + // probed-footage refresh. Refresh them before any background probe job starts (#1070). + for id in &items { + if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) { + s.forget_decoded(f); + } + } } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. @@ -459,6 +455,7 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec, retai /// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step). /// Returns (comp, items). -fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec)> { +fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec)> { let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); if page > 0 { name = format!("{name} (Page {})", page + 1); @@ -549,7 +546,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); let (comp, folder, items) = s.edit("Import", None, |proj, st| { - let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?; + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?; st.project_selection = vec![r.0]; Ok(r) })?; @@ -668,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()) @@ -748,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/commands/file_more.rs index e5de4de1c..74a86e1d8 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -356,13 +356,12 @@ fn find_missing(s: &mut Session, p: &Value) -> Result { Ok(json!({"effects": found})) } "fonts" => { - let families = crate::text_families(); let mut found = vec![]; for (cid, c) in s.project.comps() { for l in &c.layers { if let Some(pr) = l.props.prop("text/sourceText") && let Some(doc) = pr.value.as_text() - && !families.iter().any(|f| f.eq_ignore_ascii_case(&doc.font)) + && effectcraft_text::resolve(&doc.font, &doc.style).missing { found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font})); } @@ -856,12 +855,15 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { // Replacing a missing file relinks the other missing files that moved with it (as After Effects // does when you locate one). let moved = match s.project.item(item).map(|it| &it.kind) { - Some(ItemKind::Footage(previous)) if previous.missing || !s.services.exists(&previous.path) => { + Some(ItemKind::Footage(previous)) if previous.missing || !s.stored_paths.exists(s.services.as_ref(), &previous.path) => { super::relink_moved::moved_with(s, item, &previous.path, &path) } _ => vec![], }; let relinked: Vec = moved.iter().map(|(id, _)| id.0).collect(); + for nf in std::iter::once(&f).chain(moved.iter().map(|(_, nf)| nf)) { + s.stored_paths.allow(nf); + } s.edit("Replace Footage", None, |proj, _| { let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?; it.name = name; @@ -947,7 +949,7 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { Some(ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(), _ => return Err(bad("file.revealInFinder", "the item is not a file")), }; - let url = super::layer_menu::folder_url(&path); + let url = super::layer_menu::reveal_url(s, &path).map_err(|e| bad("file.revealInFinder", e))?; s.events.push(crate::Event::OpenUrl(url.clone())); Ok(json!({"url": url})) } diff --git a/crates/engine/src/commands/frame_export.rs b/crates/engine/src/commands/frame_export.rs index c939d67e1..baad9b6ef 100644 --- a/crates/engine/src/commands/frame_export.rs +++ b/crates/engine/src/commands/frame_export.rs @@ -140,9 +140,10 @@ fn srgb_to_linear(c: f32) -> f32 { if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } -/// A unique EXR layer name (no dots: they separate the layer from the channel). +/// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only: +/// the `exr` crate panics on characters it cannot store in a channel name (#1702). fn exr_name(name: &str, used: &mut Vec) -> String { - let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect(); + let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect(); let base = if base.trim().is_empty() { "Layer".to_string() } else { base }; let mut n = base.clone(); let mut k = 2; diff --git a/crates/engine/src/commands/import_project.rs b/crates/engine/src/commands/import_project.rs index f0474ee82..af37771a8 100644 --- a/crates/engine/src/commands/import_project.rs +++ b/crates/engine/src/commands/import_project.rs @@ -27,40 +27,24 @@ fn read_project(s: &Session, path: &str) -> Result<(Project, Option)> { Ok((project, effectcraft_project::saved_by(&text))) } -/// Footage paths relative to the imported project's folder become absolute when the file is -/// there; returns the footage paths that are missing. -fn resolve_footage(s: &Session, project: &mut Project, dir: &std::path::Path) -> Vec { +/// Footage paths stored in the imported project are resolved against its file +/// ([`crate::doc_path::resolve_footage`]): relative ones become paths beside it, and items whose +/// path is not followed are marked missing. Returns the footage paths that are missing, as stored. +fn resolve_footage(s: &Session, project: &mut Project, path: &str) -> Vec { + let stored: std::collections::HashMap = + project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.id, f.path.clone())) } else { None }).collect(); + let refused = crate::doc_path::resolve_footage(project, Some(path)); let services = s.services.clone(); - let resolve = |raw: &mut String| -> bool { - if raw.is_empty() || services.exists(raw) { - return true; - } - if std::path::Path::new(raw.as_str()).is_absolute() { - return false; - } - let joined = dir.join(raw.as_str()).to_string_lossy().to_string(); - if services.exists(&joined) { - *raw = joined; - true - } else { - false - } - }; - let mut missing = vec![]; - for it in project.items.values_mut() { - if let ItemKind::Footage(f) = &mut it.kind { - if f.sequence.is_empty() { - if !resolve(&mut f.path) { - missing.push(f.path.clone()); - } - } else { - let mut all = true; - for frame in &mut f.sequence { - all &= resolve(frame); - } - if !all { - missing.push(f.path.clone()); - } + let exists = |p: &String| p.is_empty() || services.exists(p); + let mut missing: Vec = refused.iter().map(|(_, p, _)| p.clone()).collect(); + for it in project.items.values() { + if let ItemKind::Footage(f) = &it.kind { + if refused.iter().any(|(id, _, _)| *id == it.id) { + continue; + } + let present = if f.sequence.is_empty() { exists(&f.path) } else { f.sequence.iter().all(exists) }; + if !present { + missing.push(stored.get(&it.id).cloned().unwrap_or_else(|| f.path.clone())); } } } @@ -79,8 +63,8 @@ fn import_project(s: &mut Session, p: &Value) -> Result { let (mut sub, saved_by) = read_project(s, &path)?; crate::upgrade_effects(&mut sub); let file = std::path::Path::new(&path); - let dir = file.parent().map(|d| d.to_path_buf()).unwrap_or_default(); - let missing = resolve_footage(s, &mut sub, &dir); + let missing = resolve_footage(s, &mut sub, &path); + s.stored_paths.add(&sub, Some(&path)); // The imported project's own render queue stays behind (as for templates). let base = s.project.next_id; if sub.next_id.checked_add(base).is_none() { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ca8aa865d..8ff43f32e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -4,8 +4,8 @@ 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, + 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}; @@ -200,7 +200,14 @@ fn new_text(s: &mut Session, p: &Value) -> Result { 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() }); + st.text_edit = Some(super::text_edit::TextEdit { + layer: id, + anchor: 0, + caret: n, + created: true, + session: super::text_edit::next_edit_session(), + ..Default::default() + }); } Ok(id) })?; @@ -307,6 +314,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0})) } +/// 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, + } +} + +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. +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})) +} + fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -417,6 +521,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, 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; @@ -1373,6 +1481,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result { } 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); @@ -1626,6 +1737,15 @@ pub fn specs() -> Vec { 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), diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 7737b8afb..0a0449376 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/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() { - 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(()) })?; @@ -677,6 +677,54 @@ pub(crate) fn folder_url(path: &str) -> String { file_url(&dir) } +/// Extensions of folders that macOS opens as an app, plug-in or other bundle rather than showing +/// their contents. +const BUNDLE_EXTENSIONS: &[&str] = &[ + "app", + "appex", + "bundle", + "component", + "dext", + "driver", + "framework", + "kext", + "mdimporter", + "mpkg", + "osax", + "pkg", + "plugin", + "prefpane", + "qlgenerator", + "saver", + "service", + "systemextension", + "vst", + "vst3", + "wdgt", + "workflow", + "xpc", +]; + +/// 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(); + ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str()) +} + +/// The folder of the footage file `stored` (a path stored in the project) as a `file://` URL for +/// Reveal in Finder. A path that is not followed ([`crate::doc_path`]), or a folder that is not a +/// plain folder after following links, is refused. +pub(crate) fn reveal_url(s: &Session, stored: &str) -> std::result::Result { + let path = s.stored_path(stored).map_err(|why| crate::doc_path::refusal(stored, why))?; + let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| format!("{stored} is not in a folder"))?; + let real = std::fs::canonicalize(dir).map_err(|e| format!("cannot show the folder of {stored}: {e}"))?; + if !real.is_dir() || opens_as_bundle(dir) || opens_as_bundle(&real) { + return Err(format!("the folder of {stored} is not a plain folder")); + } + Ok(file_url(&dir.to_string_lossy())) +} + /// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash /// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces, /// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file. @@ -700,23 +748,39 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { Some(effectcraft_project::ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(), _ => return Err(bad("layer.revealInFinder", "the layer's source is not a file")), }; - let url = folder_url(&path); + let url = reveal_url(s, &path).map_err(|e| bad("layer.revealInFinder", e))?; s.events.push(crate::Event::OpenUrl(url.clone())); Ok(json!({"url": url})) } 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 { @@ -861,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!( @@ -892,4 +956,27 @@ mod tests { fn folder_url_names_the_containing_folder() { assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips"); } + + /// Reveal in Finder shows plain folders only: a bundle the system would open as an app, or a + /// link to one, is refused. + #[test] + fn reveal_refuses_folders_that_open_as_apps() { + let d = std::env::temp_dir().join(format!("ec-reveal-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + for f in ["Clips", "Tool.app", "Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}"] { + std::fs::create_dir_all(d.join(f)).unwrap(); + } + let s = crate::Session::default(); + let reveal = |folder: &str| super::reveal_url(&s, &d.join(folder).join("a.mov").to_string_lossy()); + assert_eq!(reveal("Clips"), Ok(super::file_url(&d.join("Clips").to_string_lossy()))); + assert!(reveal("Tool.app").is_err()); + assert!(reveal("Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}").is_err()); + assert!(reveal("Gone").is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("Tool.app"), d.join("Footage")).unwrap(); + assert!(reveal("Footage").is_err(), "a link to a bundle"); + } + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 359de14d8..778dfb54c 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -132,6 +132,9 @@ fn edit_path_counted(s: &mut Session, p: &Value, label: &str, f: impl FnOnce( let roto = super::paths::is_roto(g); let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) }; + // A path read from a file may have fewer tangents than vertices; the edits below index + // all three arrays by vertex (#1250). + sp.fit_tangents(); let (r, op) = f(&mut sp)?; if roto { super::paths::roto_smooth(&mut sp); @@ -287,6 +290,7 @@ fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool, let roto = super::paths::is_roto(g); let Some(pr) = g.get_mut("path") else { continue }; let KV::Path(mut sp) = pr.value_at(lt) else { continue }; + sp.fit_tangents(); f(&mut sp, idx); if roto { super::paths::roto_smooth(&mut sp); diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 685d4dad8..23388594b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -442,6 +442,24 @@ pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_projec 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)> { diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index c74c3bb5e..96767d967 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -108,13 +108,89 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result { Ok(json!({"rotoBezier": on})) } +/// The parametric shape paths that Convert To Bezier Path replaces with an editable path. +const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"]; + +/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the +/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted +/// path is the shape that was on screen rather than an approximation of it; the defaults match +/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness). +fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option { + let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64()); + let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2()); + let path = match g.match_id.as_str() { + "rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)), + "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), + "star" => effectcraft_path::polystar( + g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + f("points", 5.0), + v2("position", [0.0; 2]), + f("rotation", 0.0), + f("innerRadius", 50.0), + f("outerRadius", 100.0), + f("innerRoundness", 0.0), + f("outerRoundness", 0.0), + ), + _ => return None, + }; + // A closed parametric shape is one subpath; anything else would not be this shape. + effectcraft_path::from_kurbo(&path).into_iter().next() +} + fn convert_to_bezier(s: &mut Session, p: &Value) -> Result { - edit_mask_groups(s, p, "Convert To Bezier Path", |g| { - if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind { - *roto_bezier = false; + // On a shape layer this replaces a Rectangle, Ellipse or Polystar path with an editable + // Path 1 of the same geometry, as After Effects does (#932). The parametric properties are + // read at the current time and the path that comes out is static: a size or roundness + // animation cannot survive the conversion, in After Effects either. + let (cid, items) = selected_shape_items(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let shapes: Vec<(LayerId, Uid)> = items + .into_iter() + .filter(|(l, u)| { + comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| PARAMETRIC.contains(&g.match_id.as_str())) + }) + .collect(); + if shapes.is_empty() { + return edit_mask_groups(s, p, "Convert To Bezier Path", |g| { + if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind { + *roto_bezier = false; + } + Ok(()) + }); + } + let t = s.time(); + let n = shapes.len(); + let made = s.edit("Convert To Bezier Path", None, |proj, st| { + let mut next = proj.next_id; + let mut made = Vec::new(); + for (lid, uid) in &shapes { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?; + let Some(old) = contents.find_group(*uid) else { continue }; + let Some(path) = parametric_path(old, lt) else { continue }; + // Path Direction is the shape's own, not a default: reversing is a separate command. + let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum()); + let enabled = old.enabled; + let mut g = build::shape_path(&mut Ids(&mut next), path); + if let Some(pr) = g.prop_mut("direction") { + pr.value = KV::Enum(direction); + } + g.enabled = enabled; + let new_uid = g.uid; + let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?; + let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue }; + parent.children[at] = Node::Group(g); + made.push((*lid, new_uid)); } - Ok(()) - }) + proj.next_id = next; + // The new paths are what is selected, so the vertices can be edited straight away. + if !made.is_empty() { + st.selected_props = made.clone(); + } + Ok(made) + })?; + Ok(json!({"paths": made.len(), "of": n})) } // ---------------------------------------------------------------- mask options diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 29da9c1e0..00232967e 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/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 { @@ -44,7 +47,8 @@ fn with_prop( ) -> Result { let t = s.time_of(cid); let linear = s.prefs.general.default_spatial_linear; - s.edit(label, merge, |proj, _| { + s.edit(label, merge, |proj, state| { + let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?; @@ -59,10 +63,61 @@ fn with_prop( k.spatial_out = [0.0; 3]; } } + if pr.match_id == "type" { + let removed = sync_polystar_type(l, uid, lt, &mut next)?; + if !removed.is_empty() { + state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop)); + state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop)); + } + } + proj.next_id = next; Ok(r) }) } +/// A static Polygon has no inner controls; switching to Star creates them in schema order +/// with AE's switch defaults (#1685). Animated/expression-driven types need the controls at +/// every time, including when the current value is Polygon. +fn sync_polystar_type(layer: &mut Layer, uid: Uid, time: Tick, next: &mut Uid) -> Result> { + if !matches!(layer.source, effectcraft_project::LayerSource::Shape) { + return Ok(vec![]); + } + let Some(group) = + layer.props.parent_of_mut(uid).filter(|g| g.match_id == "star" && matches!(g.kind, GroupKind::Indexed) && g.get("type").is_some_and(|p| p.uid == uid)) + else { + return Ok(vec![]); + }; + let Some(kind) = group.get("type") else { return Ok(vec![]) }; + let needs_inner = kind.is_animated() || kind.has_expression() || kind.value_at(time) == effectcraft_keyframe::Value::Enum(0); + if !needs_inner { + let mut removed = vec![]; + group.children.retain(|node| { + if let effectcraft_project::Node::Prop(p) = node + && matches!(p.match_id.as_str(), "innerRadius" | "innerRoundness") + { + removed.push(p.uid); + return false; + } + true + }); + return Ok(removed); + } + for (inner, name, outer, value, ui) in [ + ("innerRadius", "Inner Radius", "outerRadius", 50.0, effectcraft_project::ParamUi::Pixels), + ("innerRoundness", "Inner Roundness", "outerRoundness", 0.0, effectcraft_project::ParamUi::Percent), + ] { + if group.get(inner).is_none() { + let uid = *next; + *next = next.checked_add(1).ok_or_else(|| bad("prop", "property ids exhausted"))?; + let prop = Property::new(uid, inner, name, effectcraft_keyframe::Value::Scalar(value)).with_ui(ui); + let index = + group.children.iter().position(|n| matches!(n, effectcraft_project::Node::Prop(p) if p.match_id == outer)).unwrap_or(group.children.len()); + group.children.insert(index, effectcraft_project::Node::Prop(prop)); + } + } + Ok(vec![]) +} + /// Explicit command key times default to layer time; readers and the CTI use comp time. #[derive(Clone, Copy)] pub(crate) enum KeyTimeBase { @@ -127,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(), @@ -241,7 +298,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 { } /// `prop.set`; with `selected`, also on the other selected layers: the same value, or with -/// `offset` (a scrub) the same change from each layer's own value. With `offset` and +/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims` +/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers, +/// which keep the rest of their own value (#1434). With `offset` and /// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change /// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`, /// one that a continuing scrub keeps folding into). @@ -261,6 +320,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { .then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect()); let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len()))); let scalar = p.get("value").is_some_and(Value::is_number); + // Typed into some components only: the others stay each layer's own. + let dims: Option> = match (&delta, p.get("dims").and_then(Value::as_array), &new) { + (None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()), + _ => None, + }; + let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { + if let (Some(dims), Some(n)) = (&dims, &new) + && let Some(mut c) = current_components(s, cid, olid, ouid) + { + for &d in dims { + if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) { + *slot = *v; + } + } + q["value"] = json!(c); + } + }; // `p` aimed at (layer, prop) with its value moved by `k` × the change. let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| { if let Some(d) = &delta @@ -291,6 +367,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { } let mut q = twin_params(p, olid, ouid); shifted(s, &mut q, olid, ouid, k); + typed(s, &mut q, olid, ouid); // (a layer that can't take it, e.g. a clamped or static property, keeps its value) let _ = set(s, &q); } @@ -967,6 +1044,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result { let sel = s.state.selected_keys.clone(); s.edit("Delete Keyframes", None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut removed = 0usize; for k in &sel { if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop)) && let Some(i) = key_at(&pr.keys, k.time) @@ -975,10 +1053,13 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result { pr.value = pr.keys[0].value.clone(); } pr.keys.remove(i); + removed += 1; } } st.selected_keys.clear(); - Ok(json!(sel.len())) + // The keys actually removed: a selected key that is gone (or listed twice) isn't counted, + // so a caller can tell that a delete changed nothing. + Ok(json!(removed)) }) } @@ -1229,7 +1310,7 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}", has_layers, set_selected ), diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index e4d80b16d..6f7d74aef 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -100,6 +100,22 @@ pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> { out } +/// Text animators and their selectors selected on the active comp's text layers, top to bottom. +/// A selector inside a selected animator isn't listed, since it is copied with it. +pub(crate) fn selected_animator_items(s: &Session) -> Vec<(LayerId, Uid)> { + let mut out = vec![]; + let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out }; + for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Text)) { + if let Some(animators) = l.props.group("text/animators") { + let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u)); + let mut uids = vec![]; + collect_selected(animators, &selected, &mut uids, 0); + out.extend(uids.into_iter().map(|u| (l.id, u))); + } + } + out +} + fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec, depth: usize) { if depth > super::shape_stroke::MAX_DEPTH { return; @@ -127,8 +143,9 @@ pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result< } /// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's -/// Contents, above its topmost selected item (in that item's group), else on top. Names stay -/// unique in their group ("Rectangle 2"…), and the copies are selected. +/// Contents, above its topmost selected item (in that item's group), else on top (paints and path +/// operations alone: at the bottom). Names stay unique in their group ("Rectangle 2"…), and the +/// copies are selected. pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result { let c = "edit.paste"; let clip = s.state.contents_clipboard.clone(); @@ -140,6 +157,9 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result { return Err(bad(c, "select a shape layer to paste shape items into")); } let sel = selected_contents(s); + let acts_on_above = clip.iter().all(|g| !matches!(g.match_id.as_str(), "group" | "rect" | "ellipse" | "star" | "path")); + // Merge Paths clears the draws above it, so at the bottom it would erase the paints (#1681). + let has_merge = clip.iter().any(|g| g.match_id == "merge"); let label = match clip.as_slice() { [g] => format!("Paste {}", g.name), _ => "Paste".to_string(), @@ -154,11 +174,19 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result { let parent = l.props.parent_of(*u)?; Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?)) }); + let unselected = spot.is_none(); let (parent, at) = match spot { Some((pu, i)) => (l.props.find_group_mut(pu), i), None => (l.props.sub_mut("contents"), 0), }; let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?; + // Trim Paths, Wiggle Paths, fills… act on the items above them (as Add places them), + // so with nothing selected they go to the bottom: on top they'd affect nothing (#1681). + let at = match unselected { + true if has_merge => merge_spot(parent), + true if acts_on_above => parent.children.len(), + _ => at, + }; for (k, g) in clip.iter().enumerate() { let mut g = g.clone(); g.reassign_uids(&mut next); @@ -174,8 +202,21 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result { Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::>()})) } -/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and -/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected. +/// Where Merge Paths goes in `contents` when nothing is selected: before the first paint, so the +/// paints still draw the merged path (the renderer drops the draws above a Merge Paths), else after +/// the last path item. +fn merge_spot(contents: &PropGroup) -> usize { + let is = |n: &Node, ids: &[&str]| matches!(n, Node::Group(g) if ids.contains(&g.match_id.as_str())); + let paints = ["fill", "stroke", "gfill", "gstroke"]; + match contents.children.iter().position(|n| is(n, &paints)) { + Some(i) => i, + None => contents.children.iter().rposition(|n| is(n, &["rect", "ellipse", "star", "path"])).map_or(0, |i| i + 1), + } +} + +/// Edit ▸ Duplicate with shape items, text animators or selectors selected: each is copied in +/// place, above the original and named as After Effects does ("Rectangle 1" → "Rectangle 2"), +/// and the copies are selected. pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result { let c = "edit.duplicate"; let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; @@ -183,7 +224,7 @@ pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> R let mut next = proj.next_id; let mut out = vec![]; for (lid, uid) in items { - let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?; + let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the group is gone"))?; let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue }; let mut g = g.clone(); g.reassign_uids(&mut next); diff --git a/crates/engine/src/commands/relink.rs b/crates/engine/src/commands/relink.rs index 1c095ddac..2d7c2ea13 100644 --- a/crates/engine/src/commands/relink.rs +++ b/crates/engine/src/commands/relink.rs @@ -81,12 +81,13 @@ fn run(s: &mut Session, p: &Value) -> Result { } let importer = s.importer.clone().ok_or_else(|| bad(COMMAND, "media import is not available in this build"))?; let services = s.services.clone(); + let stored = s.stored_paths.clone(); let missing: Vec<(ItemId, Footage)> = s .project .items .values() .filter_map(|item| match &item.kind { - ItemKind::Footage(f) if !f.path.is_empty() && !services.exists(&f.path) => Some((item.id, f.clone())), + ItemKind::Footage(f) if !f.path.is_empty() && !stored.exists(services.as_ref(), &f.path) => Some((item.id, f.clone())), _ => None, }) .collect(); @@ -149,7 +150,8 @@ fn run(s: &mut Session, p: &Value) -> Result { // A background search must not overwrite edits made while it was running. for (id, old, new) in &updates { if s.project.item(*id).is_none_or(|i| !matches!(&i.kind, ItemKind::Footage(f) if f == old)) - || services.exists(&old.path) || !services.exists(&new.path) + || stored.exists(services.as_ref(), &old.path) + || !services.exists(&new.path) || (new.kind == FootageKind::Sequence && new.sequence.iter().any(|p| !services.exists(p))) { return Err(bad(COMMAND, "footage changed during search; run the search again")); @@ -157,6 +159,9 @@ fn run(s: &mut Session, p: &Value) -> Result { } let matched = updates.len(); if !dry_run && !updates.is_empty() { + for (_, _, new) in &updates { + s.stored_paths.allow(new); + } s.edit("Relink Missing Footage", None, |project, _| { for (id, _, new) in updates { if let Some(item) = project.item_mut(id) { diff --git a/crates/engine/src/commands/relink_moved.rs b/crates/engine/src/commands/relink_moved.rs index 4f70dd6ee..ecc531bc2 100644 --- a/crates/engine/src/commands/relink_moved.rs +++ b/crates/engine/src/commands/relink_moved.rs @@ -91,8 +91,8 @@ fn moved_footage(footage: &Footage, f: impl Fn(&str) -> Option) -> Optio Some(nf) } -fn present(s: &dyn Services, f: &Footage) -> bool { - !f.path.is_empty() && s.exists(&f.path) +fn present(s: &dyn Services, stored: &crate::doc_path::StoredPaths, f: &Footage) -> bool { + stored.exists(s, &f.path) } /// Footage items whose file is gone (flagged by the footage check, or not there now). @@ -101,7 +101,7 @@ fn missing_items(s: &Session) -> Vec<(ItemId, Footage)> { .items .values() .filter_map(|i| match &i.kind { - ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), f)) => { + ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), &s.stored_paths, f)) => { Some((i.id, f.clone())) } _ => None, @@ -126,17 +126,18 @@ pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage }) } -/// The folder a project file is in. +/// The folder a project file is in, with the path's own separator and its leading `/` or UNC `\\`. pub(crate) fn project_dir(project_path: &str) -> Option { let p = parts(project_path); - if p.len() < 2 { - return None; - } - let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?); - if !project_path.starts_with('/') { - dir = dir.trim_start_matches(['/', '\\']).to_string(); - } - Some(dir) + let dir = p.get(..p.len().checked_sub(1)?).filter(|d| !d.is_empty())?; + let root = if project_path.starts_with("\\\\") { + "\\\\" + } else if project_path.starts_with('/') { + "/" + } else { + "" + }; + Some(format!("{root}{}", dir.join(&sep_of(project_path).to_string()))) } /// Relink `found` in one undo step and say how many. @@ -145,6 +146,9 @@ fn apply(s: &mut Session, label: &str, found: Vec<(ItemId, Footage)>) -> Result< return Ok(json!({"relinked": [], "stillMissing": missing_items(s).len()})); } let ids: Vec = found.iter().map(|(id, _)| id.0).collect(); + for (_, nf) in &found { + s.stored_paths.allow(nf); + } s.edit(label, None, |proj: &mut Project, _| { for (id, nf) in found { if let Some(it) = proj.item_mut(id) { @@ -174,7 +178,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &s if id == item { continue; } - let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), nf)); + let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), &s.stored_paths, nf)); let same_folder = || { let dir = new_dir.as_deref()?; let dir_parts = parts(dir); @@ -182,7 +186,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &s let old_dir: Vec<&str> = parts(&f.path).get(..old_dir_len)?.to_vec(); moved_footage(&f, |q| remap(q, &old_dir, dir).or_else(|| Some(join(dir, &[file_name(q)])))).filter(|nf| { // Only frames in that one folder (a name match, not a guess deeper down). - present(services.as_ref(), nf) && parts(&nf.path).len() == dir_parts.len() + 1 + present(services.as_ref(), &s.stored_paths, nf) && parts(&nf.path).len() == dir_parts.len() + 1 }) }; if let Some(nf) = same_place.or_else(same_folder) { @@ -262,6 +266,10 @@ mod tests { ); assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y")); assert_eq!(project_dir("p.ecproj"), None); + // Windows paths keep their separator, and a UNC share its `\\`. + assert_eq!(project_dir("C:\\Work\\Job\\p.ecproj").as_deref(), Some("C:\\Work\\Job")); + assert_eq!(project_dir("\\\\server\\share\\Job\\p.ecproj").as_deref(), Some("\\\\server\\share\\Job")); + assert_eq!(moved_candidates("\\\\server\\share\\Job", "D:\\Old\\Job\\shots\\a.png")[2], "\\\\server\\share\\Job\\shots\\a.png"); // Hostile input doesn't panic. for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] { let _ = moved_root(a, b); @@ -302,11 +310,11 @@ mod tests { let b = add(&mut s, "/gone/proj/shots/b.mov"); let m = add(&mut s, "/gone/proj/audio/m.wav"); let other = add(&mut s, "/elsewhere/c.mov"); - let target = new.join("shots/a.mov").to_string_lossy().to_string(); + let target = new.join("shots").join("a.mov").to_string_lossy().to_string(); let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap(); assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}"); assert_eq!(r["stillMissing"], 1); - assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy()); + assert_eq!(path_of(&s, m), new.join("audio").join("m.wav").to_string_lossy()); assert_eq!(path_of(&s, other), "/elsewhere/c.mov"); // One undo step brings every path back. s.execute("edit.undo", json!({})).unwrap(); diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 3de5e6fe8..24049459c 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -132,7 +132,7 @@ fn is_script_name(name: &str) -> bool { } /// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL. -fn is_bare_name(name: &str) -> bool { +pub(crate) fn is_bare_name(name: &str) -> bool { !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) } @@ -140,7 +140,7 @@ fn is_bare_name(name: &str) -> bool { /// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device /// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, /// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`). -fn windows_unsafe_name(name: &str) -> bool { +pub(crate) fn windows_unsafe_name(name: &str) -> bool { let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase(); let port = |p: &str| { let mut n = stem.strip_prefix(p).unwrap_or_default().chars(); diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 228d48424..92ab23f17 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -6,6 +6,7 @@ use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Layer, LayerSource, Node, PropGroup}; use effectcraft_time::Tick; use serde_json::{Value, json}; +use std::sync::OnceLock; use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p}; use crate::{Result, Session, cmd, query}; @@ -13,8 +14,10 @@ use crate::{Result, Session, cmd, query}; /// The original text animator presets (`presets/text_animators.json`). pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json"); -fn presets() -> Vec { - serde_json::from_str::(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default() +fn presets() -> &'static [Value] { + static PRESETS: OnceLock> = OnceLock::new(); + PRESETS + .get_or_init(|| serde_json::from_str::(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()) } /// (id, name) of every preset. @@ -303,7 +306,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, C)?; let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string(); let preset = presets() - .into_iter() + .iter() .find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want))) .ok_or_else(|| bad(C, format!("no preset {want}")))?; let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string(); diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 5dcb6d09a..9de40c588 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -6,15 +6,58 @@ //! 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); + +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); + +pub(crate) fn next_ime_revision() -> Option { + allocate_ime_revision(&NEXT_IME_REVISION) +} + +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 +71,23 @@ 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 { + 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 +109,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 +271,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 +294,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 +304,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 +359,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 +520,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 +726,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); } @@ -594,6 +967,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/time.rs b/crates/engine/src/commands/time.rs index 8cb3071bf..556c02bcb 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/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/config.rs b/crates/engine/src/config.rs index c333c9734..00e7377b7 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -164,32 +164,50 @@ impl ConfigStore for DirConfig { } } -/// The temporary file an atomic write of `path` goes through. -pub fn temp_path(path: &Path) -> PathBuf { - let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); - name.push(".tmp"); - path.with_file_name(name) +/// A new, empty temporary file next to `path` for an atomic write of it, and its path. The name +/// is unique to this process and this write (`.effectcraft--.tmp`) and the file is created +/// exclusively, so two writers never share one and nothing already at that name (a file, a +/// symbolic link) is opened or followed (#1233). It ends in `.tmp`, so a file left by a hard +/// kill never counts as an auto-save. Its bounded name does not include the destination's name, +/// so a valid long destination name still fits the filesystem's component limit. +fn create_temp(path: &Path) -> std::io::Result<(std::fs::File, PathBuf)> { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let mut last = None; + for _ in 0..16 { + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let tmp = path.with_file_name(format!(".effectcraft-{}-{n}.tmp", std::process::id())); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(f) => return Ok((f, tmp)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), + Err(e) => return Err(e), + } + } + Err(last.unwrap_or_else(|| std::io::Error::other("no free temporary file name"))) } /// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then /// rename it over `path`. A crash at any point leaves either the old file or the new one, never a -/// torn file. +/// torn file, and concurrent writers each publish a whole file of their own. pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { - atomic_write_with(path, data, |tmp, data| { + atomic_write_with(path, data, |f, data| { use std::io::Write; - let mut f = std::fs::File::create(tmp)?; f.write_all(data)?; f.sync_all() }) } -/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A -/// failed write removes the temporary file and leaves `path` untouched. -pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { - let tmp = temp_path(path); - if let Err(e) = write(&tmp, data) { +/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write); it is +/// given the temporary file, already created. A failed write or rename removes the temporary +/// file and leaves `path` untouched. +pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&mut std::fs::File, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { + let (mut f, tmp) = create_temp(path)?; + let written = write(&mut f, data); + // Closed before the rename (Windows can't rename an open file). + drop(f); + if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) { let _ = std::fs::remove_file(&tmp); return Err(e); } - std::fs::rename(&tmp, path) + Ok(()) } diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 000000000..00d6c33f1 --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,167 @@ +//! Footage paths stored in a document (the project file, an imported project, the New Project +//! template) reach the file system only through [`resolve`]: a relative path is taken from the +//! 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 +//! 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 +//! Original and Reveal in Finder follow a path only through it. Paths the user imports or relinks +//! in this session are followed as given. + +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub use craft_docpath::Refused; +use effectcraft_project::{Footage, ItemId, ItemKind, Project}; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is not a local file, or is not on the project's own share. + Refused(Refused), + /// The path is relative and the project has no folder to take it from. + NoProjectFolder, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NoProjectFolder => f.write_str("the path is relative and the project has not been saved"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to open for `stored`, a footage path or `file:` URL stored in the project saved at +/// `project` (`None` for a project without a file), or why it is not followed. A relative +/// `project` is taken from the working directory, where it was opened from. +pub fn resolve(stored: &str, project: Option<&str>) -> Result { + let project = project.filter(|p| !p.is_empty()).map(|p| { + let p = Path::new(p); + std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()) + }); + craft_docpath::resolve_absolute(stored, project.as_deref()).map_err(|why| match why { + // Resolved, but still relative: it would be taken from the working directory, which the + // project does not choose. A Windows drive or share path (`C:\x`) stays invalid outside + // Windows even with a project. + Refused::Invalid if project.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_rooted(&p)) => { + 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 +/// saved at `document`, or why one of them is not followed. +fn footage_paths(f: &Footage, document: Option<&str>) -> Result<(String, Vec), (String, NotFollowed)> { + let one = |q: &String| -> Result { + if q.is_empty() { + return Ok(String::new()); + } + resolve(q, document).map(|p| p.to_string_lossy().into_owned()).map_err(|why| (q.clone(), why)) + }; + Ok((one(&f.path)?, f.sequence.iter().map(one).collect::>()?)) +} + +fn paths_of(f: &Footage) -> impl Iterator { + std::iter::once(&f.path).chain(&f.sequence).filter(|q| !q.is_empty()) +} + +/// Footage paths that came from a document, with the path of that document (`None`: one without +/// a file). Any other path was chosen in this session and is followed as given. +#[derive(Clone, Debug, Default)] +pub struct StoredPaths(Arc>>); + +impl StoredPaths { + /// Record the footage paths of `p`, loaded from the document at `document`. A path already + /// recorded keeps the document it came from first. + pub(crate) fn add(&mut self, p: &Project, document: Option<&str>) { + let map = Arc::make_mut(&mut self.0); + for item in p.items.values() { + if let ItemKind::Footage(f) = &item.kind { + for q in paths_of(f) { + map.entry(q.clone()).or_insert_with(|| document.map(str::to_string)); + } + } + } + } + + /// The user chose the files of `f` in this session (import, relink, Replace Footage): follow + /// them as given from now on. + pub(crate) fn allow(&mut self, f: &Footage) { + if paths_of(f).any(|q| self.0.contains_key(q)) { + let map = Arc::make_mut(&mut self.0); + for q in paths_of(f) { + map.remove(q); + } + } + } + + /// The file to open for footage path `path`. + pub fn follow(&self, path: &str) -> Result { + match self.0.get(path) { + Some(document) => resolve(path, document.as_deref()), + None => Ok(PathBuf::from(path)), + } + } + + /// Whether the file at footage path `path` exists. A path that is not followed is not looked + /// for. + pub(crate) fn exists(&self, services: &dyn crate::Services, path: &str) -> bool { + !path.is_empty() && self.follow(path).is_ok_and(|p| p.to_str().is_some_and(|p| services.exists(p))) + } + + /// Whether every file of `f` is followed, or the first that is not and why. + pub(crate) fn check(&self, f: &Footage) -> Result<(), (String, NotFollowed)> { + paths_of(f).try_for_each(|q| self.follow(q).map(|_| ()).map_err(|why| (q.clone(), why))) + } +} + +/// Resolve the footage paths stored in `p`, the project saved at `project`: followed paths are +/// replaced by the file they resolve to, and an item with a path that is not followed is marked +/// missing. Returns those items with the refused path and why. +pub(crate) fn resolve_footage(p: &mut Project, project: Option<&str>) -> Vec<(ItemId, String, NotFollowed)> { + let mut refused = vec![]; + for item in p.items.values_mut() { + let ItemKind::Footage(f) = &mut item.kind else { continue }; + match footage_paths(f, project) { + Ok((path, sequence)) => { + f.path = path; + f.sequence = sequence; + } + Err((path, why)) => { + f.missing = true; + refused.push((item.id, path, why)); + } + } + } + refused +} + +/// The message for footage whose stored path is not followed. +pub(crate) fn refusal(stored: &str, why: NotFollowed) -> String { + format!("the footage file {stored} is not opened: {why}") +} + +impl crate::Session { + /// The file to open for footage path `stored` in this session's project. + pub fn stored_path(&self, stored: &str) -> Result { + self.stored_paths.follow(stored) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs new file mode 100644 index 000000000..d9d971247 --- /dev/null +++ b/crates/engine/src/doc_path/tests.rs @@ -0,0 +1,171 @@ +use std::sync::{Arc, Mutex}; + +use effectcraft_project::{Footage, ItemId, ItemKind}; +use serde_json::json; + +use crate::{Event, Services, Session}; + +/// File access that records every path it is asked about. +#[derive(Default)] +struct Recording(Mutex>); + +impl Recording { + fn note(&self, path: &str) { + self.0.lock().unwrap().push(path.to_string()); + } +} + +impl Services for Recording { + fn read_file(&self, path: &str) -> std::io::Result> { + self.note(path); + std::fs::read(path) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + std::fs::write(path, data) + } + fn exists(&self, path: &str) -> bool { + self.note(path); + std::path::Path::new(path).exists() + } + fn stamp(&self, path: &str) -> Option<(u64, u128)> { + self.note(path); + let meta = std::fs::metadata(path).ok()?; + Some((meta.len(), meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos())) + } +} + +/// 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() { + return Err(format!("{path}: no such file")); + } + Ok(Footage { path: path.into(), kind: effectcraft_project::FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } +} + +fn footage_id(s: &Session, name: &str) -> ItemId { + s.project.items.values().find(|i| i.name == name).unwrap().id +} + +fn footage<'a>(s: &'a Session, name: &str) -> &'a Footage { + match &s.project.item(footage_id(s, name)).unwrap().kind { + ItemKind::Footage(f) => f, + _ => panic!("{name} is not footage"), + } +} + +/// Footage paths to other computers stored in a project are never looked at: not on open, by +/// the footage check, by the changed-on-disk scan, by Edit Original or by Reveal in Finder. A +/// relative path is taken from the project's folder, and a file the user imports in this session +/// is used as given until the project is saved and opened again. +#[test] +fn footage_on_other_computers_is_never_looked_at() { + let root = std::env::temp_dir().join(format!("ec-doc-path-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("job"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(root.join("media")).unwrap(); + std::fs::write(dir.join("clip.png"), b"x").unwrap(); + std::fs::write(root.join("media").join("far.png"), b"x").unwrap(); + let mut stored = vec![("unc", r"\\192.0.2.1\share\x.png".to_string()), ("url", "file://192.0.2.1/s/x.png".to_string()), ("beside", "clip.png".to_string())]; + // Linux reads `//tmp/…/far.png` as the local file: a sink that skipped the check would find it. + if cfg!(unix) { + stored.push(("slashes", format!("/{}", root.join("media").join("far.png").display()))); + } + let mut s = Session::default(); + for (name, path) in &stored { + let f = Footage { path: path.clone(), width: 64, height: 48, has_video: true, ..Default::default() }; + Arc::make_mut(&mut s.project).add_item(name, crate::color::Label::None, None, ItemKind::Footage(f)); + } + let project = dir.join("p.ecproj"); + s.execute("file.saveAs", json!({"path": project.to_string_lossy()})).unwrap(); + + let rec = Arc::new(Recording::default()); + let mut o = Session { services: rec.clone(), ..Default::default() }; + o.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap(); + for (name, _) in &stored { + assert_eq!(footage(&o, name).missing, *name != "beside", "{name}"); + } + assert_eq!(std::path::Path::new(&footage(&o, "beside").path), dir.join("clip.png")); + let r = o.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"].as_u64(), Some(stored.len() as u64 - 1), "{r}"); + o.execute("footage.reloadChanged", json!({})).unwrap(); + o.drain_events(); + for (name, _) in &stored { + o.state.project_selection = vec![footage_id(&o, name)]; + let opened = o.execute("edit.editOriginal", json!({})); + let revealed = o.execute("file.revealInFinder", json!({})); + assert_eq!((opened.is_ok(), revealed.is_ok()), (*name == "beside", *name == "beside"), "{name}: {opened:?} {revealed:?}"); + } + let urls: Vec = o.drain_events().into_iter().filter_map(|e| if let Event::OpenUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 2, "{urls:?}"); + + let seen = rec.0.lock().unwrap().clone(); + assert!(seen.iter().any(|p| std::path::Path::new(p) == dir.join("clip.png")), "{seen:?}"); + // Only files in the temporary folder (the project's folder, where moved footage is looked for). + let local = root.to_string_lossy().into_owned(); + for p in &seen { + assert!(p.starts_with(&local), "looked at {p}"); + } + + // Chosen in this session, the same file is followed as given (as a file on a NAS would be)… + #[cfg(unix)] + { + let far = stored.iter().find(|(n, _)| *n == "slashes").unwrap().1.clone(); + o.importer = Some(Arc::new(Stills)); + o.execute("file.import", json!({"paths": [far.clone()]})).unwrap(); + let imported = |s: &Session| -> Vec { + s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.path == far)).map(|i| i.id).collect() + }; + let id = *imported(&o).iter().find(|id| matches!(&o.project.item(**id).unwrap().kind, ItemKind::Footage(f) if !f.missing)).unwrap(); + o.state.project_selection = vec![id]; + o.execute("edit.editOriginal", json!({})).unwrap(); + o.execute("file.revealInFinder", json!({})).unwrap(); + assert!(o.execute("footage.check", json!({"wait": true, "items": [id.0]})).unwrap()["missing"] == 0); + // …and is a stored path again once the project is saved and opened. + o.execute("file.save", json!({})).unwrap(); + let mut q = Session::default(); + q.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap(); + let ids = imported(&q); + assert_eq!(ids.len(), 2); + for id in ids { + assert!(matches!(&q.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing)); + q.state.project_selection = vec![id]; + assert!(q.execute("edit.editOriginal", json!({})).is_err()); + assert!(q.execute("file.revealInFinder", json!({})).is_err()); + } + } + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn stored_paths_resolve_against_the_project() { + let doc = std::env::temp_dir().join("art").join("intro.ecproj"); + 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_check.rs index de14b06ca..6bb803f0d 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -31,10 +31,6 @@ enum Found { Moved(Box), } -fn exists(s: &dyn crate::Services, path: &str) -> bool { - !path.is_empty() && s.exists(path) -} - /// Start the check (all footage, or `items`). `wait` runs it to completion first (always on /// wasm32); the result is then `{checked, missing, probed}`. pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate::Result { @@ -54,6 +50,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: let services = s.services.clone(); let importer = s.importer.clone(); let rate = s.sequence_rate(); + let stored = s.stored_paths.clone(); let project_dir = s.path.as_deref().and_then(crate::commands::relink_moved::project_dir); let n = jobs.len(); s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| { @@ -64,13 +61,17 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: return Err(crate::render_queue::CANCELLED.into()); } let f = &j.footage; - let present = if f.sequence.is_empty() { - exists(services.as_ref(), &f.path) - } else { - // A sequence is there while any of its frames is (missing frames render as - // placeholders, as in After Effects). - exists(services.as_ref(), &f.path) || f.sequence.iter().any(|p| exists(services.as_ref(), p)) - }; + // A path that is not followed is never looked for (the item stays missing). + let followed = stored.check(f).is_ok(); + let exists = |p: &str| stored.exists(services.as_ref(), p); + let present = followed + && if f.sequence.is_empty() { + exists(&f.path) + } else { + // A sequence is there while any of its frames is (missing frames render as + // placeholders, as in After Effects). + exists(&f.path) || f.sequence.iter().any(|p| exists(p)) + }; // Moved with the project: the same place under the project's folder. if !present && let Some(dir) = &project_dir @@ -233,9 +234,11 @@ mod tests { let mut paths: Vec = o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect(); paths.sort(); - // Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed - // separators; `Path` equality compares components, so both spellings are the same file. - let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + // `Path` equality compares components, so `/` and `\` spell the same file on Windows. + let abs = crate::doc_path::resolve("/nonexistent/ec/abs.png", path.to_str()).unwrap(); + let gone = dir.join("media").join("ec-relative-gone.png"); + let mut expected = [abs.to_str().unwrap(), clip.to_str().unwrap(), gone.to_str().unwrap()]; + expected.sort(); assert_eq!(paths.len(), expected.len(), "{paths:?}"); for (got, want) in paths.iter().zip(expected) { assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}"); @@ -254,7 +257,7 @@ mod tests { std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap(); // Saved on another machine: the footage sat next to the project in /Users/x/Job. let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]); - let path = d.join("Job/p.ecproj"); + let path = d.join("Job").join("p.ecproj"); s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); let mut o = Session::default(); o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); @@ -269,7 +272,7 @@ mod tests { _ => None, }) .collect(); - assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}"); + assert!(paths.contains(&d.join("Job").join("shots").join("a.png").to_string_lossy().to_string()), "{paths:?}"); assert!(!o.is_dirty(), "finding the files is not an edit"); let _ = std::fs::remove_dir_all(d); } diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index a8f32a379..9b3c135a8 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -32,12 +32,13 @@ pub type Scan = Arc)>>>>; /// The files footage items read: each item's file and every frame of an image sequence. Image /// sequences count only with Automatically Reload Footage set to All Footage (After Effects' /// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up). +/// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched. fn watched(s: &Session) -> BTreeSet { let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { if let ItemKind::Footage(f) = &it.kind { - if !f.sequence.is_empty() && !sequences { + if (!f.sequence.is_empty() && !sequences) || s.stored_paths.check(f).is_err() { continue; } if !f.path.is_empty() { @@ -58,6 +59,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u Some((u32::try_from(found.index).ok()?, size)) } +fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> { + let bytes = s.services.read_file(&f.path).ok()?; + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?; + (!doc.root.children.is_empty()).then(|| doc.pixel_size()) +} + /// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`). fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { let mut nf = f.clone(); @@ -89,6 +96,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { match &f.layer { // A smart object's size is its embedded file's, recorded at import. Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height), + Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)), Some(l) => match psd_layer(s, &f.path, l) { Some((index, size)) => { if let Some(nl) = nf.layer.as_mut() { @@ -121,16 +129,23 @@ 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. - let nf = match 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() }, Err(_) => { @@ -183,6 +198,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/history.rs b/crates/engine/src/history.rs index 8b178a05c..d9577285b 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -341,13 +341,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/lib.rs b/crates/engine/src/lib.rs index 80956687a..83024dd81 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ 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; @@ -344,6 +345,11 @@ 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 { @@ -388,6 +394,15 @@ 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. @@ -495,6 +510,8 @@ pub struct Session { /// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one /// (`wait: true` over the control channel in the browser) learns its outcome. pub offload_log: Vec<(offload::JobKind, Option)>, + /// Number of older completion entries evicted from the bounded offload log. + pub offload_log_start: usize, /// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`]. pub tasks: Vec, /// Finished tasks, newest last (Progress panel, `jobs.list`). @@ -506,11 +523,15 @@ pub struct Session { /// event loop (the desktop app) turn this on; headless sessions run `footage.check` when /// they want it. pub check_footage_on_open: bool, + /// Footage paths that came from a document, with that document ([`doc_path`]). + pub stored_paths: doc_path::StoredPaths, /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). 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`]). @@ -550,6 +571,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() }, @@ -598,6 +621,7 @@ impl Default for Session { offload: None, offloaded: vec![], offload_log: vec![], + offload_log_start: 0, browser: None, storage: None, tasks: vec![], @@ -605,7 +629,9 @@ impl Default for Session { next_task_id: 1, learn: None, check_footage_on_open: false, + stored_paths: Default::default(), footage_stamps: Default::default(), + disk_salt: None, footage_scan: None, script_calls: ScriptCalls::default(), } @@ -671,7 +697,25 @@ impl Session { } /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. + /// + /// Every edit is an external boundary for the on-canvas text editor: if it replaced the + /// document the editor is showing, the selection is clamped and the editor gets a fresh IME + /// epoch. The editor's own typing goes through `edit_text_doc`, which owns that + /// document and must not treat its own keystrokes as a replacement. pub fn edit(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + let edited = commands::text_edit::active_editor_snapshot(self); + let out = self.edit_inner(label, merge, f)?; + commands::text_edit::after_external_change(self, edited.as_ref()); + Ok(out) + } + + /// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one + /// the editor owns, so a change here is typing, not a replacement behind the IME's back. + pub(crate) fn edit_text_doc(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + self.edit_inner(label, merge, f) + } + + fn edit_inner(&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(); @@ -718,18 +762,61 @@ impl Session { Ok(r) } + /// Note a change to the on-canvas editor's committed text or session. + /// + /// Frontends compare this with [`commands::text_edit::ime_stamp_revision`]; an unrelated + /// project change must not advance it. + pub(crate) fn note_text_edit_change(&mut self) { + // Checked process-wide allocation: a stamp is never wrapped or reused. On exhaustion + // keep the previous value; an unavailable before/after pair then compares equal and + // earns no receipt instead of accepting a stale revision. + if let Some(revision) = commands::text_edit::next_ime_revision() { + self.ime_revision = self.ime_revision.max(revision); + } + } + 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.refresh_disk_salt(false); self.events.push(Event::ProjectChanged { revision: self.revision }); } + /// Salt the disk cache's layer keys with the project's footage files + /// ([`effectcraft_render::disk_cache::footage_salt`]). Taking it reads the size and time of + /// every file, every frame of an image sequence included, so it is taken again only when the + /// footage items changed, or with `force` (a project opened, footage reloaded, the disk cache + /// attached), not at every edit: with a few large sequences that cost each keystroke in a + /// text field over half a second (#1926). + pub fn refresh_disk_salt(&mut self, force: bool) -> Option { + use std::hash::{Hash, Hasher}; + if self.disk_cache.is_none() { + self.disk_salt = None; + return None; + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + for (id, it) in &self.project.items { + if let effectcraft_project::ItemKind::Footage(f) = &it.kind { + id.hash(&mut h); + format!("{f:?}").hash(&mut h); + } + } + let footage = h.finish(); + if let Some((seen, salt)) = self.disk_salt + && seen == footage + && !force + { + return Some(salt); + } + let salt = effectcraft_render::disk_cache::footage_salt(&self.project); + self.disk_salt = Some((footage, salt)); + self.layer_cache.set_disk_salt(salt); + Some(salt) + } + /// 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`]). @@ -760,26 +847,48 @@ impl Session { pub fn undo(&mut self) -> bool { let Some((label, p)) = self.history.undo.pop() else { return false }; + let edited = commands::text_edit::active_editor_snapshot(self); 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.sanitize_after_replacement(edited.as_ref()); self.bump(); true } pub fn redo(&mut self) -> bool { let Some((label, p)) = self.history.redo.pop() else { return false }; + let edited = commands::text_edit::active_editor_snapshot(self); 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.sanitize_after_replacement(edited.as_ref()); self.bump(); true } + /// Keep selections valid after the project was replaced by another state (undo, redo, a + /// history jump, a rollback): the same layer id can name a different document, so the + /// on-canvas editor gets a fresh IME epoch only when its own document changed. + /// + /// Normally `before` is [`commands::text_edit::active_editor_snapshot`] taken from the + /// state this call is about to replace. Atomic batch rollback is the exception: it has + /// already restored the pre-batch state and passes the snapshot of the failing intermediate + /// state, so a transient restart or component switch inside the batch still rejects reviving + /// the old composition. A restored state that still has the same composition, layer and + /// editor session and shows byte/style-equivalent text (an opacity or other property undo) + /// keeps the editor's owner and selection; a different document, comp, layer or session clamps the + /// selection and starts a new epoch. A closed editor stays closed, and a fallback comp's + /// matching layer id never inherits the old comp's session. `None` means no editor was open + /// when the snapshot was taken; if one is open after the replacement it is treated as an + /// unverified document replacement, so it never inherits an old composition. + pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) { + self.sanitize_state(); + commands::text_edit::after_replacement(self, before); + } + /// 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) { @@ -796,6 +905,8 @@ impl Session { }); if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) { self.clear_live_selection(); + // A matching layer id in the fallback comp is not the document being edited. + self.state.text_edit = None; self.state.active_comp = self.state.open_comps.first().copied(); if let Some(id) = self.state.active_comp { self.recall_selection(id); @@ -855,17 +966,22 @@ impl Session { self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO) } + /// Move the active comp's current time. + /// + /// Time is an external boundary for the on-canvas text editor too: an animated Source Text + /// can show a different document at another time. The document is snapshotted only while an + /// editor is open, so playback with no text editor does no text copying. pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { - let comp = self.project.comp(c); - let t = match comp { - Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())), - None => t, - }; + let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); + let edited = commands::text_edit::active_editor_snapshot(self); self.state.times.insert(c, t); if self.prefs.general.sync_time_related_items { - self.sync_related_times(c, t); + // Direct call: this `set_time` owns the editor snapshot for both time changes, + // so a synced move does not take a second snapshot or start a second epoch. + self.sync_related_times_inner(c, t); } + commands::text_edit::after_external_change(self, edited.as_ref()); } } @@ -873,6 +989,14 @@ impl Session { /// `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) { + // Moving a related comp can move the active one, so the editor's document is checked + // here too; the snapshot is skipped entirely when no text editor is open. + let edited = commands::text_edit::active_editor_snapshot(self); + self.sync_related_times_inner(comp, t); + commands::text_edit::after_external_change(self, edited.as_ref()); + } + + fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { let mut seen = std::collections::BTreeSet::from([comp]); let mut todo = vec![(comp, t)]; let mut found = vec![]; @@ -916,6 +1040,9 @@ impl Session { self.state.active_comp = Some(id); self.recall_selection(id); self.state.current_track = None; + // A text-edit session belongs to the comp that owned it. Switching comps ends it; even + // a same-numbered layer in the new comp must not inherit the old IME composition. + self.state.text_edit = None; } /// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`]. @@ -924,6 +1051,7 @@ impl Session { self.state.active_comp = None; self.clear_live_selection(); self.state.current_track = None; + self.state.text_edit = None; } fn remember_selection(&mut self) { @@ -980,15 +1108,24 @@ impl Session { self.events.push(Event::OpenComp(id)); } - /// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]). + /// The open compositions and their current times, as a project file records them + /// ([`effectcraft_project::SavedView`]). pub fn saved_view(&self) -> effectcraft_project::SavedView { - effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() } + 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). 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. + /// 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) { @@ -1019,12 +1156,18 @@ impl Session { /// Render a comp frame with the session's footage + expression hosts. pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { + self.renderer(opts).comp_frame(comp, t) + } + + /// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and + /// accelerator, and the render prefs (nested switches, draft shadows) over `opts`. + pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> { 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) + r } /// Resolve a comp reference (item id number or name; `None`/null = the active comp). @@ -1125,6 +1268,7 @@ impl Session { job.wait(); } self.render_job = None; + self.stored_paths = Default::default(); self.stop_track(); self.track_job = None; self.stop_mask_track(); @@ -1151,6 +1295,8 @@ impl Session { } self.layer_cache.clear(); self.project = Arc::new(p); + // Its files may have changed since they were last stamped. + self.disk_salt = None; self.history = History::default(); self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() }; self.path = path; @@ -1224,6 +1370,10 @@ mod tests_codec_options; #[cfg(test)] mod tests_color_view; #[cfg(test)] +mod tests_crypto_pick; +#[cfg(test)] +mod tests_delete_keys; +#[cfg(test)] mod tests_disk_cache; #[cfg(test)] mod tests_effects; @@ -1270,6 +1420,8 @@ mod tests_proxy; #[cfg(test)] mod tests_quick_offset; #[cfg(test)] +mod tests_replace_source; +#[cfg(test)] mod tests_rig3d; #[cfg(test)] mod tests_sequence; diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs index 2c89466ae..ec5785f7e 100644 --- a/crates/engine/src/logging.rs +++ b/crates/engine/src/logging.rs @@ -93,28 +93,34 @@ fn thread_name() -> String { return "main".to_string(); } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. A clock set +/// absurdly far ahead keeps its real year (preserved) instead of reading as the epoch. fn timestamp(ms: u64) -> String { - let secs = ms / 1000; - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000) + let (secs, millis) = (ms / 1000, (ms % 1000) as u32); + let (y, t) = utc_parts(secs, millis * 1_000_000); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most -/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - 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 = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Unix seconds (+ nanos) → (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` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (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, nanos.min(999_999_999))); + 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 + } } /// One log line (without the newline): ` [] : `. @@ -358,7 +364,12 @@ mod tests { assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z"); assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z"); assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z"); - let _ = timestamp(u64::MAX); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(253_402_300_800_000), "10000-01-01T00:00:00.000Z"); + // A clock absurdly far ahead keeps its real year (years far outside chrono's own range are + // preserved) instead of collapsing to the epoch (reviewer rule from the sibling PRs). + assert_eq!(timestamp(10_000_000_000_000_000), "318857-05-20T17:46:40.000Z"); + assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z"); } /// Each launch keeps the previous runs' logs, so the log of a run that crashed survives. diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 5edd67ca3..c0e20be71 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -173,7 +173,7 @@ impl VirtualTree { }) }) .collect(); - out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + sort_entries(&mut out); Ok(out) } } @@ -193,6 +193,10 @@ pub struct Entry { pub kind: Option<&'static str>, } +fn sort_entries(entries: &mut [Entry]) { + entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); +} + const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; @@ -251,7 +255,7 @@ pub fn list(dir: &str, importable_only: bool) -> Result, String> { Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind }) }) .collect(); - out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + sort_entries(&mut out); Ok(out) } @@ -260,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). @@ -302,7 +317,7 @@ fn kind_name(k: FootageKind) -> &'static str { /// Metadata of footage (the fields the Metadata panel lists). pub fn footage_metadata(f: &Footage) -> Value { - let (created, modified, size) = file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path)); + let (created, modified, size) = if f.missing { (None, None, None) } else { file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path)) }; json!({ "path": f.path, "kind": kind_name(f.kind), @@ -312,7 +327,9 @@ pub fn footage_metadata(f: &Footage) -> Value { "pixelAspect": f.pixel_aspect, "frameRate": f.frame_rate.as_f64(), "duration": f.duration.seconds(), - "frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None }, + // Rounded like the media pool's frame count: Matroska stores the duration in whole + // milliseconds, a hair short of the last frame at 30 fps (#1429). + "frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.frame_rate.snap_nearest(f.duration))) } else { None }, "hasVideo": f.has_video, "hasAudio": f.has_audio, "alpha": format!("{:?}", f.alpha), @@ -407,6 +424,10 @@ mod tests { assert_eq!(media.len(), 3); assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str())); assert!(list(&dir.join("nope").to_string_lossy(), false).is_err()); + std::fs::write(dir.join("Ω.csv"), b"x").unwrap(); + std::fs::write(dir.join("作品.csv"), b"x").unwrap(); + let all = list(&d, false).unwrap(); + assert_eq!(all.iter().map(|e| e.name.as_str()).collect::>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]); std::fs::remove_dir_all(&dir).unwrap(); } @@ -436,10 +457,70 @@ mod tests { assert_eq!(vparent("/"), None); } + #[test] + fn listings_keep_folders_first_and_stable_unicode_order() { + let mut tree = VirtualTree::default(); + for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] { + tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None); + } + for name in ["zFolder", "Alpha"] { + tree.add_dir(&format!("/Shoot/{name}")); + } + let entries = tree.list("/Shoot", true).unwrap(); + assert_eq!( + entries.iter().map(|e| e.name.as_str()).collect::>(), + ["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"] + ); + assert_eq!(entries[2].path, "/files/A.csv"); + let mut reversed = entries.iter().rev().cloned().collect::>(); + sort_entries(&mut reversed); + assert_eq!( + reversed.iter().map(|e| e.name.as_str()).collect::>(), + ["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"] + ); + } + + #[test] + #[ignore = "Media Browser filename sorting benchmark"] + fn directory_sort_benchmark() { + let entries: Vec<_> = (0..1000) + .map(|i| Entry { + name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000), + path: format!("/Shoot/{i}.mov"), + is_dir: i % 7 == 0, + size: 1, + modified: None, + kind: Some("video"), + }) + .collect(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let mut old = entries.clone(); + let start = std::time::Instant::now(); + old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + before.push(start.elapsed()); + let mut new = entries.clone(); + let start = std::time::Instant::now(); + sort_entries(&mut new); + after.push(start.elapsed()); + assert_eq!(old, new); + std::hint::black_box(new); + } + before.sort(); + after.sort(); + println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]); + } + #[test] fn iso_dates() { 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/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/offload.rs b/crates/engine/src/offload.rs index 561ebe6c4..ad2c0cde0 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/offload.rs @@ -171,6 +171,20 @@ pub enum JobKind { } impl JobKind { + /// The corresponding `jobs.list` id, also used by waiting control commands. + pub fn job_id(self) -> &'static str { + match self { + JobKind::Render => "render", + JobKind::Warp => "warp", + JobKind::WarpPlan => "warpPlan", + JobKind::Camera => "camera", + JobKind::RotoFreeze | JobKind::RotoPropagate => "roto", + JobKind::Track => "track", + JobKind::MaskTrack => "maskTrack", + JobKind::ContentFill => "contentFill", + } + } + /// The undo step an analysis result becomes. pub fn label(self) -> &'static str { match self { @@ -296,13 +310,15 @@ pub fn next_id() -> u64 { NEXT_ID.fetch_add(1, Ordering::Relaxed) } -/// Footage files a project reads (paths, sequence frames). +/// Footage files a project reads (paths, sequence frames; missing items read none). pub fn footage_files(p: &Project) -> Vec { let mut v: Vec = p .items .values() .filter_map(|i| match &i.kind { - ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::>()), + ItemKind::Footage(f) if !f.path.is_empty() && !f.missing => { + Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::>()) + } _ => None, }) .flatten() @@ -699,6 +715,9 @@ pub struct OffloadedJob { /// Segmentations received (Roto Brush propagation). pub segs: u64, started: web_time::Instant, + /// The analysed group as the job started: its result is merged with what changed since + /// ([`merge_group`]). + base: Option>, } /// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]). @@ -730,6 +749,7 @@ impl Session { } let id = next_id(); let inbox = Arc::new(Inbox::default()); + let base = self.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().map(Box::new); let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job }; off.start(req, inbox.clone())?; self.offloaded.push(OffloadedJob { @@ -742,6 +762,7 @@ impl Session { progress: OffloadProgress::default(), segs: 0, started: web_time::Instant::now(), + base, }); self.events.push(Event::ProjectChanged { revision: self.revision }); Ok(()) @@ -779,7 +800,7 @@ impl Session { j.progress.done = done; j.progress.total = total; } - WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)), + WorkerReply::Group { comp, layer, group, message } => results.push((j.id, j.kind, comp, layer, group, message, j.base.clone())), WorkerReply::Fill { plan, files } => fills.push((plan, files)), WorkerReply::WarpPlan { key, summary } => effectcraft_effects::warp_stab::store_summary(key, *summary), WorkerReply::Segs { segs } => { @@ -802,7 +823,7 @@ impl Session { Err(e) => self.events.push(Event::Toast { message: format!("Content-Aware Fill: {e}"), error: true }), } } - for (kind, comp, layer, group, message) in results { + for (id, kind, comp, layer, group, message, base) in results { let uid = group.uid; let r = self.edit(kind.label(), None, |p, _| { let g = p @@ -810,12 +831,31 @@ impl Session { .and_then(|c| c.layer_mut(layer)) .and_then(|l| l.props.find_group_mut(uid)) .ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?; - *g = *group; + // Edits made while the job ran stay (#1249): only what the analysis changed is + // taken from its result. + *g = match base { + Some(base) => merge_group(&base, g, &group).ok_or_else(|| { + crate::EngineError::Other(format!( + "“{}” was changed during the analysis where its result changes it too, so nothing was applied: analyse again", + g.name + )) + })?, + None => *group, + }; Ok(()) }); match r { Ok(()) => self.events.push(Event::Toast { message, error: false }), - Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }), + Err(e) => { + // Completion consumers read the job's error, not its toasts. Keep an apply + // rejection on the originating job; Done logs and reports it just like a + // WorkerReply::Failed, without failing another concurrent analysis. + if let Some(job) = self.offloaded.iter_mut().find(|j| j.id == id) { + job.progress.error = Some(e.to_string()); + } else { + self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }); + } + } } } let mut done = vec![]; @@ -830,6 +870,7 @@ impl Session { self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string())))); if self.offload_log.len() > 32 { self.offload_log.remove(0); + self.offload_log_start = self.offload_log_start.saturating_add(1); } if let Some(e) = p.error { self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }); @@ -880,6 +921,76 @@ impl Session { } } +/// `live` if only it changed since `base`, `result` if only that did (or both the same way); +/// `None` when both changed it differently. +fn pick(base: &T, live: &T, result: &T) -> Option { + if live == base { + Some(result.clone()) + } else if result == base || result == live { + Some(live.clone()) + } else { + None + } +} + +fn node_uid(n: &effectcraft_project::Node) -> Uid { + match n { + effectcraft_project::Node::Prop(p) => p.uid, + effectcraft_project::Node::Group(g) => g.uid, + } +} + +/// Merge an analysis `result` into the `live` group (#1249). `base` is the group as the job +/// started. The group's own fields and each property are taken from whichever side changed +/// them, groups are merged child by child (matched by uid), and a child added or removed by one +/// side is added or removed. `None` when both sides changed the same field or property +/// differently, one side removed a child the other changed, or both reordered or added and +/// removed children differently: a conflict. +pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup) -> Option { + use effectcraft_project::Node; + if live == base { + return Some(result.clone()); + } + if result == base || result == live { + return Some(live.clone()); + } + let header = |g: &PropGroup| PropGroup { children: vec![], ..g.clone() }; + let mut out = pick(&header(base), &header(live), &header(result))?; + let ids = |g: &PropGroup| g.children.iter().map(node_uid).collect::>(); + let (bi, li, ri) = (ids(base), ids(live), ids(result)); + // The children's order and set: from the side that changed them. + let order = if li == bi { + ri + } else if ri == bi || ri == li { + li + } else { + return None; + }; + fn find(g: &PropGroup, u: Uid) -> Option<&effectcraft_project::Node> { + g.children.iter().find(|n| node_uid(n) == u) + } + // A child one side removed must be unchanged on the other, or that side's edit would be lost. + for u in bi.iter().filter(|u| !order.contains(u)) { + let b = find(base, *u); + if [find(live, *u), find(result, *u)].into_iter().flatten().any(|n| Some(n) != b) { + return None; + } + } + for u in order { + let node = match (find(base, u), find(live, u), find(result, u)) { + (Some(Node::Group(b)), Some(Node::Group(l)), Some(Node::Group(r))) => Node::Group(merge_group(b, l, r)?), + (Some(b), Some(l), Some(r)) => pick(b, l, r)?, + // Added by one side, or by both alike. + (None, Some(l), None) => l.clone(), + (None, None, Some(r)) => r.clone(), + (None, Some(l), Some(r)) if l == r => l.clone(), + _ => return None, + }; + out.children.push(node); + } + Some(out) +} + #[cfg(test)] mod tests { use super::*; @@ -892,6 +1003,69 @@ mod tests { 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(); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3abf336b0..30b806683 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -26,16 +26,19 @@ pub const PREFS_FILE: &str = "prefs.json"; pub const LANGUAGES: &[(&str, &str)] = &[ ("Match System", "system"), ("English", "en"), + // Alphabetical by the language's own name after English; Cyrillic, Georgian, then CJK names + // follow the Latin ones. + ("Čeština", "cs"), + ("Deutsch", "de"), + ("Español", "es"), + ("Français", "fr"), + ("Português (Brasil)", "pt-br"), + ("Русский", "ru"), + ("Українська", "uk"), + ("ქართული", "ka"), ("日本語", "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). @@ -110,7 +113,8 @@ page!(General { /// Synchronize Time of All Related Items. sync_time_related_items: bool = true, expression_pick_whip_compact: bool = true, - create_split_layers_above: bool = false, + /// Create Split Layers Above Original Layer (After Effects' default). + create_split_layers_above: bool = true, use_system_color_picker: bool = false, /// Number of projects in File ▸ Open Recent. recent_items: u32 = 10, @@ -428,6 +432,8 @@ pub struct Prefs { 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. @@ -468,6 +474,7 @@ impl Default for Prefs { 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![], @@ -597,6 +604,9 @@ impl Prefs { 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; } @@ -715,6 +725,7 @@ impl Prefs { 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 @@ -916,6 +927,23 @@ impl Prefs { push_mru(&mut self.recent_fonts, family, 30); } + /// Is effect `id` starred in Effects & Presets? + pub fn is_effect_favorite(&self, id: &str) -> bool { + self.effects_favorites.iter().any(|f| f == id) + } + + /// Star effect `id` in Effects & Presets, or un-star it; true when it is starred now. + pub fn toggle_effect_favorite(&mut self, id: &str) -> bool { + if self.is_effect_favorite(id) { + self.effects_favorites.retain(|f| f != id); + false + } else { + self.effects_favorites.push(id.to_string()); + self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES); + self.is_effect_favorite(id) + } + } + /// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display). pub fn recent_fonts_shown(&self) -> &[String] { let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len()); @@ -933,6 +961,9 @@ impl Prefs { } } +/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more). +const MAX_EFFECTS_FAVORITES: usize = 1000; + fn push_mru(list: &mut Vec, v: &str, max: usize) { list.retain(|x| x != v); list.insert(0, v.to_string()); diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs index 207367b07..7d592abe2 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/remote.rs @@ -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); diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 56754e7c1..74b333151 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -362,8 +362,15 @@ impl Session { // Settings ▸ Export ▸ Default Output Folder, else the project's folder. let folder = self.prefs.export.default_output_folder.trim(); let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); - let base = - base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); + // A project saved under a bare filename has the empty path as its parent. It sits in the + // working directory, so resolve against that rather than falling through to the + // unsaved-project default below (#1427). + let base = base.or_else(|| { + self.path + .as_deref() + .and_then(|s| std::path::Path::new(s).parent()) + .map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() }) + }); // Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory // is `/` for an app started from the macOS Finder, where nothing can be written (#446). let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from); diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 55c8e4154..a56d1849b 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -102,7 +102,7 @@ impl Session { /// and attach it to the layer cache. Without a folder setting the platform cache folder is /// used, but only by frontends with a settings store (headless sessions stay off disk). pub fn configure_disk_cache(&mut self) { - use effectcraft_render::disk_cache::{DiskCache, default_folder, footage_salt}; + use effectcraft_render::disk_cache::{DiskCache, default_folder}; let d = &self.prefs.disk; let folder = if d.disk_cache_folder.trim().is_empty() { (self.config.is_some() && !cfg!(target_arch = "wasm32")).then(default_folder) @@ -116,6 +116,11 @@ impl Session { self.layer_cache.set_disk(None); return; }; + // Tests: the Roto/Object Matte segment store below is one per process, so a test that + // opens a disk cache (often the platform folder, through a settings store) would take + // over the store of a roto test running beside it. Serialise them with the roto tests. + #[cfg(test)] + crate::tests_roto::hold_roto_cache_test_lock(); let dc = match &self.disk_cache { Some(c) if c.folder() == folder.as_path() => { c.set_max_bytes(max); @@ -131,7 +136,9 @@ impl Session { } }, }; - self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project)))); + self.disk_cache = Some(dc.clone()); + let salt = self.refresh_disk_salt(true).unwrap_or_default(); + self.layer_cache.set_disk(Some((dc.clone(), salt))); // Roto Brush / Object Matte segmentations persist there too (held weakly: switching the // disk cache off drops it). let store: std::sync::Arc = dc.clone(); @@ -225,36 +232,50 @@ impl Session { let prefs = self.autosave_prefs(); let fail = |e: std::io::Error| EngineError::Other(format!("auto-save failed: {e}")); let plan = autosave::plan_in(self.file_ops(), &prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot).map_err(fail)?; + let path = plan.path.to_string_lossy().into_owned(); if self.autosave.background && !cfg!(target_arch = "wasm32") { let project = self.project.clone(); let config = self.config.clone(); let job = plan.clone(); - let pending = autosave::PendingWrite::spawn(plan.path.clone(), move || { - let json = project.to_json_compact(); - match &config { - Some(c) => job.write(c.files(), json.as_bytes()), - None => job.write(&crate::config::StdFiles, json.as_bytes()), - } - }) - .map_err(fail)?; + let pending = + autosave::PendingWrite::spawn(plan.path.clone(), plan.slot, self.revision, self.path.clone(), self.script_ui.project_epoch, move || { + let json = project.to_json_compact(); + match &config { + Some(c) => job.write(c.files(), json.as_bytes()), + None => job.write(&crate::config::StdFiles, json.as_bytes()), + } + }) + .map_err(fail)?; self.autosave.pending = Some(pending); } else { let json = self.project.to_json_compact(); plan.write(self.file_ops(), json.as_bytes()).map_err(fail)?; + self.commit_autosave(path.clone(), plan.slot, self.revision); } - let (path, slot) = (plan.path, plan.slot); - let p = path.to_string_lossy().to_string(); + Ok(path) + } + + /// Advance recovery bookkeeping only for a successfully written snapshot. + fn commit_autosave(&mut self, path: String, slot: u32, revision: u64) { self.autosave.last_slot = Some(slot); - self.autosave.saved_revision = Some(self.revision); - self.autosave.last_path = Some(p.clone()); + self.autosave.saved_revision = Some(revision); + self.autosave.last_path = Some(path); self.update_sentinel(); - Ok(p) } /// Wait for a background auto-save ([`autosave::AutoSaveState::background`]) to land. pub fn autosave_wait(&mut self) -> Result<()> { match self.autosave.pending.take() { - Some(w) => w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}"))), + Some(w) => { + w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?; + // Open/New can replace the project before note_project_path collects this + // write. Save As changes only its path. Never publish the old snapshot as the + // new project's recovery record, even when that record's final reset fails. + if w.project_path == self.path && w.project_epoch == self.script_ui.project_epoch { + self.commit_autosave(w.path.to_string_lossy().into_owned(), w.slot, w.revision); + } + Ok(()) + } None => Ok(()), } } @@ -278,7 +299,7 @@ impl Session { return None; } self.autosave.last = Some(now); - if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() { + if !self.is_dirty() || self.autosave.pending.is_some() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() { return None; } if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() && self.autosave_folder_override.is_none() diff --git a/crates/engine/src/templates.rs b/crates/engine/src/templates.rs index a8009f476..500308b72 100644 --- a/crates/engine/src/templates.rs +++ b/crates/engine/src/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/tests.rs b/crates/engine/src/tests.rs index 72133c5d6..b3f0602b3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -686,3 +686,50 @@ fn reopening_a_project_restores_its_active_comp() { o.insert("openComps".into(), open); } } + +/// Layer times are saved as exact ticks and read back unchanged, and each comp reopens at the +/// current time it was saved at, not at 0, where a layer starting later shows nothing (#1714). +#[test] +fn reopening_a_project_restores_layer_times_and_current_time() { + use effectcraft_project::ItemId; + use effectcraft_time::{FrameRate, Tick}; + let dir = std::env::temp_dir().join(format!("ec-test-1714-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("times.ecproj").to_string_lossy().to_string(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 108, "height": 192, "duration": 60})).unwrap(); + s.execute("layer.newSolid", json!({"name": "crown"})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let comp = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + comp.frame_rate = FrameRate::FPS_59_94; + // The layer from the issue: 34f, 315f and 3033f at 59.94 fps. + let l = comp.layers.first_mut().unwrap(); + (l.start_time, l.in_point, l.out_point) = (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800)); + s.execute("time.set", json!({"time": 6.0})).unwrap(); + let t = s.time(); + // A second comp keeps its own time. + let b = ItemId(s.execute("comp.new", json!({"name": "B"})).unwrap()["comp"].as_u64().unwrap()); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + let tb = s.time(); + s.execute("comp.open", json!({"comp": "C"})).unwrap(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": path})).unwrap(); + let l = s2.project.comp(cid).unwrap().layers.first().unwrap(); + assert_eq!((l.start_time, l.in_point, l.out_point), (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800))); + assert_eq!((s2.time(), s2.state.times.get(&b).copied()), (t, Some(tb))); + assert!(l.in_point <= s2.time() && s2.time() < l.out_point, "the layer shows at the reopened time"); + assert!(!s2.is_dirty()); + + // Hand-edited times past the end or before the start land on the last or first frame. + let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + file["compTimes"] = json!({cid.0.to_string(): i64::MAX, b.0.to_string(): -5}); + std::fs::write(&path, file.to_string()).unwrap(); + let mut s3 = Session::default(); + s3.execute("file.open", json!({"path": path})).unwrap(); + let c = s3.project.comp(cid).unwrap(); + assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration())); + assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration())); + assert_eq!(s3.state.times.get(&b).copied(), Some(Tick::ZERO)); +} diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index cf9773f9a..07a48af1c 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -131,6 +131,23 @@ fn environment_light_is_listed_and_takes_a_source_layer() { assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None); } +/// Deleting the layer a Source points at leaves no dangling id: "None" means the comp's +/// Environment Layer, and a dead id rendered as nothing at all (#2153). +#[test] +fn deleting_a_source_layer_clears_the_reference() { + let mut s = session(); + let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap(); + let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap(); + assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky)); + s.execute("edit.clear", json!({"layers": [sky]})).unwrap(); + assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "the reference goes with the layer"); + // One history step with the delete, so undo brings both back. + s.undo(); + assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky)); + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); +} + #[test] fn three_d_switch_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests_crypto_pick.rs b/crates/engine/src/tests_crypto_pick.rs new file mode 100644 index 000000000..799027c7e --- /dev/null +++ b/crates/engine/src/tests_crypto_pick.rs @@ -0,0 +1,336 @@ +//! `effect.pickCryptomatte` / `effect.cryptomatteAt` (#473): the Cryptomatte eyedropper reads the +//! aux channels the effect renders from (footage at its source time, a 3D precomp's generated +//! pass) and edits the Selection text in one undo step. + +use std::sync::Arc; + +use effectcraft_project::{Footage, FootageKind, ItemId}; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_raster::{AuxChannels, Image}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::render::FootageSource; + +const COMMA: &str = "Green, \"Glossy\""; + +/// 8 × 4 footage with a 16 × 8 Cryptomatte (two aux pixels per layer pixel). Layer pixels in x: +/// 0-1 `left` (0.6) over `right` (0.4); 2-3 `left` (0.3) under the comma name (0.7), ranks out of +/// order; 4-5 `star*`; 6 an ID missing from the manifest; 7 a 0.5 / 0.5 tie of `left` and +/// `right`. Rows 3+ are empty. From 1 s of source time `late` replaces columns 0-1. +struct Crypto; + +fn aux_at(t: Tick) -> AuxChannels { + let (w, h) = (16usize, 8usize); + let mut planes = vec![vec![0.0f32; w * h]; 4]; + for (i, x) in (0..w * h).map(|i| (i, i % w)).filter(|(i, _)| i / w < 6) { + let late = if t >= Tick::from_seconds_f64(1.0) { "late" } else { "left" }; + let (a, ca, b, cb) = match x { + 0..4 => (late, 0.6, "right", 0.4), + 4..8 => ("left", 0.3, COMMA, 0.7), + 8..12 => ("star*", 1.0, "", 0.0), + 12..14 => ("ghost", 1.0, "", 0.0), + _ => ("left", 0.5, "right", 0.5), + }; + for (k, v) in [crypto_float(crypto_hash(a)), ca, crypto_float(crypto_hash(b)), cb].into_iter().enumerate() { + planes[k][i] = v; + } + } + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + for (k, c) in ["R", "G", "B", "A"].iter().enumerate() { + aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone())); + } + aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + aux +} + +impl FootageSource for Crypto { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +struct Probe; +impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + let duration = Tick::from_seconds_f64(2.0); + // A half-size proxy of the EXR. + let (width, height) = if path.contains("proxy") { (4, 2) } else { (8, 4) }; + Ok(Footage { + path: path.into(), + kind: FootageKind::Video, + width, + height, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration, + ..Default::default() + }) + } +} + +fn session() -> Session { + let mut s = Session { footage: Arc::new(Crypto), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s +} + +fn pick(s: &mut Session, x: f64, mode: &str) -> Value { + s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1.5, "mode": mode})).unwrap() +} + +fn selection(s: &Session) -> String { + let l = &s.active_comp().unwrap().layers[0]; + let g = l.effects().unwrap().groups().next().unwrap(); + match &g.prop("selection").unwrap().value { + effectcraft_keyframe::Value::Str(t) => t.clone(), + v => panic!("{v:?}"), + } +} + +#[test] +fn picks_replace_add_and_remove_in_one_undo_step_each() { + let mut s = session(); + let steps = s.history.undo.len(); + let r = pick(&mut s, 0.5, "replace"); + assert_eq!((r["name"].as_str(), r["selection"].as_str(), r["changed"].as_bool()), (Some("left"), Some("left"), Some(true)), "{r}"); + assert_eq!(r["hash"], format!("{:08x}", crypto_hash("left"))); + assert_eq!(s.history.undo.len(), steps + 1); + // The highest coverage wins even when it is not rank 0; the name is quoted. + assert_eq!(pick(&mut s, 2.5, "add")["selection"], r#"left, "Green, \"Glossy\"""#); + // Adding it again changes nothing and leaves no undo step. + let again = pick(&mut s, 3.5, "add"); + assert_eq!((again["changed"].as_bool(), s.history.undo.len()), (Some(false), steps + 2)); + // A `*` name and an unnamed ID go in as hashes; a tie goes to rank 0. + pick(&mut s, 4.5, "add"); + assert_eq!(pick(&mut s, 6.5, "add")["selection"], format!(r#"left, "Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 7.5, "y": 0.5})).unwrap()["name"], "left"); + let r = pick(&mut s, 0.0, "remove"); + assert_eq!(r["selection"], format!(r#""Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(r["selected"], false); + // The text parses back to exactly the picked IDs. + let manifest = aux_at(Tick::ZERO).manifests[0].1.clone(); + let got = effectcraft_effects::selection_hashes(&selection(&s), Some(&manifest)); + assert_eq!(got, [COMMA, "star*", "ghost"].map(crypto_hash)); + assert_eq!(s.history.undo.len(), steps + 5); + s.execute("edit.undo", json!({})).unwrap(); + assert!(selection(&s).starts_with("left, "), "{}", selection(&s)); + pick(&mut s, 2.0, "replace"); + assert_eq!(selection(&s), r#""Green, \"Glossy\"""#); + // The same selection with other spacing: no change, no undo step. + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": " \"Green, \\\"Glossy\\\"\" "})).unwrap(); + let n = s.history.undo.len(); + assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n)); +} + +#[test] +fn bad_points_layers_and_modes_are_errors_and_change_nothing() { + let mut s = session(); + let at = |s: &mut Session, x: f64, y: f64| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": y})); + for (x, y) in [(-0.1, 1.0), (8.0, 1.0), (1.0, 4.0), (1.0, 3.5), (1e300, -1e300)] { + assert!(at(&mut s, x, y).is_err(), "{x},{y}"); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": x, "y": y})).unwrap(), Value::Null); + } + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "mode": "toggle"})).is_err()); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1})).is_err()); + for scale in [0.0, -1.0, 2.0] { + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "scale": scale})).is_err(), "{scale}"); + } + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 2, "x": 1, "y": 1})).is_err(), "not a Cryptomatte effect"); + assert_eq!(selection(&s), ""); + // A layer without aux channels. + s.execute("layer.newSolid", json!({"name": "S"})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let e = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).unwrap_err().to_string(); + assert!(e.contains("no Cryptomatte channels"), "{e}"); + // An expression-driven Selection would ignore the edit. + let mut s = session(); + s.execute_checked("prop.setExpression", json!({"path": "effects/#1/selection", "expression": "\"right\""})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).is_err()); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1, "y": 1})).unwrap()["selected"], false); +} + +/// The pick reads the frame the render shows: Time Remap's source time, not the layer time. +#[test] +fn time_remap_picks_the_shown_source_frame() { + let mut s = session(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "left"); + s.execute_checked("layer.enableTimeRemap", json!({})).unwrap(); + s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "late"); +} + +/// A precomp of a 3D comp: the generated CryptoObject pass names its layers. +#[test] +fn picks_on_a_precomp_of_a_3d_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Red, Ball", "width": 40, "height": 20})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap(); + assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}"); +} + +/// The matte the rendered Cryptomatte effect gives the picked ID at the buffer pixel under +/// `{x, y}`: the pick has to name the ID the effect's own input buffer samples there. +fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (String, f32) { + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale, "mode": "replace"})).unwrap(); + let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().position(|g| g.match_id == "ec.3d.cryptomatte").unwrap() + 1; + s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); + let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); + let layer = &comp.layers[0]; + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); + let (px, py) = buf.to_px([x, y]); + let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; + (r["name"].as_str().unwrap_or("?").to_string(), m) +} + +/// Between 3/4 and full resolution footage keeps its native pixels: at 0.8 the effect samples +/// layer pixel 2 (the comma name, 0.7) at x = 2.2, not the 0.8-grid pixel snapped back to 1. +#[test] +fn pick_at_scale_0_8_matches_the_native_buffer_the_effect_renders() { + let mut s = session(); + let (name, m) = rendered_matte_of_pick(&mut s, 2.2, 1.5, 0.8); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, COMMA); +} + +/// A half-size proxy renders a buffer at 0.5 layer pixels per pixel even at full resolution: +/// x = 6.5 lies in buffer pixel 3, which samples layer x = 7 (the `left` / `right` tie). +#[test] +fn pick_on_a_proxy_matches_the_proxy_sized_buffer() { + let mut s = session(); + let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0; + s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/crypto_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 6.5, 1.5, 1.0); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, "left"); +} + +/// 1920-px footage with `left` up to x = 120 and `right` after it, and a 104-px proxy. +struct Edge; +impl FootageSource for Edge { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let (w, h) = (1920usize, 40usize); + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + let ids = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < 120 { "left" } else { "right" }))).collect(); + aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])]; + aux.manifests = vec![("CryptoObject".into(), ["left", "right"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + Some(Arc::new(aux)) + } +} + +/// Proxy buffer pixel 6's centre maps to layer x = 6.5 / (104 / 1920): 120.0 by division, but +/// 119.99999999999999 by the reciprocal the effect multiplies with. The pick must agree. +#[test] +fn pick_maps_pixel_centres_bit_exactly_as_the_effect() { + let mut s = Session { footage: Arc::new(Edge), ..Default::default() }; + let at = |path: &str, width| Footage { + path: path.into(), + kind: FootageKind::Video, + width, + height: 40, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + s.importer = Some(Arc::new(EdgeProbe(at("/renders/edge.exr", 1920), at("/renders/edge_proxy.exr", 104)))); + let item = s.execute("file.import", json!({"paths": ["/renders/edge.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 40, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute("file.setProxy", json!({"item": item, "path": "/renders/edge_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 118.0, 5.0, 1.0); + assert_eq!((name.as_str(), m), ("left", 1.0)); +} + +struct EdgeProbe(Footage, Footage); +impl crate::Importer for EdgeProbe { + fn probe(&self, path: &str) -> Result { + Ok(if path.contains("proxy") { self.1.clone() } else { self.0.clone() }) + } +} + +/// Footage whose decoded frames are `num / den` of the probed size (a file re-rendered at another +/// resolution, a relinked file): the render stretches them over the layer. +struct OffNominal(u32, u32); +impl FootageSource for OffNominal { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width * self.0 / self.1, f.height * self.0 / self.1))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +/// Points along rows `ys` where the picked ID isn't lit in the rendered matte under the point. +fn mismatches(s: &mut Session, scales: &[f64], xs: impl Iterator + Clone, ys: &[f64]) -> Vec { + let mut bad = vec![]; + for &scale in scales { + for (x, &y) in ys.iter().flat_map(|y| xs.clone().map(move |x| (x, y))) { + if s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale})).unwrap().is_null() { + continue; + } + let (name, m) = rendered_matte_of_pick(s, x, y, scale); + if m < 0.5 { + bad.push(format!("scale {scale} ({x:.2}, {y}): picked {name:?}, matte {m}")); + } + } + } + bad +} + +const PREVIEW_SCALES: [f64; 7] = [1.0, 0.8, 0.75, 0.74, 0.5, 0.33, 0.25]; + +/// The pick follows the decoded frame's real size, not the footage's nominal one. +#[test] +fn pick_matches_the_render_when_frames_decode_off_nominal_size() { + for (num, den) in [(2, 1), (1, 2), (3, 4)] { + let mut s = Session { footage: Arc::new(OffNominal(num, den)), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..80).map(|i| i as f64 * 0.1 + 0.05), &[0.3, 1.7, 2.6]); + assert!(bad.is_empty(), "decode {num}/{den}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} + +/// A precomp of a 3D comp, with and without Preserve Resolution (drawn at full size below full +/// resolution), behind a disabled effect: the pick names the ID the rendered matte shows. +#[test] +fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() { + for preserve in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "width": 40, "height": 20})).unwrap()["layer"].clone(); + let b = s.execute("layer.newSolid", json!({"name": "B", "width": 7, "height": 20})).unwrap()["layer"].clone(); + s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [3.5, 10, -10]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap(); + let inner = *s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0; + s.execute_checked("comp.settings", json!({"comp": inner.0, "preserveResolution": preserve})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("effect.toggle", json!({"effect": 1, "value": false})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..93).map(|i| i as f64 * 0.15 + 0.05), &[10.0]); + assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} diff --git a/crates/engine/src/tests_delete_keys.rs b/crates/engine/src/tests_delete_keys.rs new file mode 100644 index 000000000..b1b9af316 --- /dev/null +++ b/crates/engine/src/tests_delete_keys.rs @@ -0,0 +1,45 @@ +//! `keys.delete` reports the keys it removed, not the keys it was asked to remove. + +use effectcraft_project::LayerId; +use serde_json::json; + +use crate::{KeyRef, Session}; + +fn setup() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap(); + let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for t in 0..3 { + s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap(); + } + let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/opacity").unwrap().uid; + (s, layer, prop) +} + +fn keys_of(s: &Session, layer: u64, prop: u64) -> Vec { + let keys = &s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys; + keys.iter().map(|k| KeyRef { layer: LayerId(layer), prop, time: k.time }).collect() +} + +#[test] +fn delete_keys_counts_only_the_keys_it_removed() { + let (mut s, layer, prop) = setup(); + let keys = keys_of(&s, layer, prop); + assert_eq!(keys.len(), 3); + // Two real keys, one of them listed twice, and a key of a property that does not exist. + let missing = KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[1].time }; + s.state.selected_keys = vec![keys[0], keys[2], keys[0], missing]; + let removed = s.execute_checked("keys.delete", json!({})).unwrap(); + assert_eq!(removed, json!(2)); + assert_eq!(keys_of(&s, layer, prop), vec![keys[1]]); +} + +#[test] +fn delete_keys_reports_zero_when_no_selected_key_exists() { + let (mut s, layer, prop) = setup(); + let keys = keys_of(&s, layer, prop); + s.state.selected_keys = vec![KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[0].time }]; + let removed = s.execute_checked("keys.delete", json!({})).unwrap(); + assert_eq!(removed, json!(0)); + assert_eq!(keys_of(&s, layer, prop), keys); +} diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 6f87b4aea..1ea57f4cf 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -741,3 +741,77 @@ fn effect_colour_labels_set_clear_undo_and_round_trip() { let l = old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().clone(); assert_eq!(l.effects().unwrap().groups().next().unwrap().label, None); } + +/// #1746: a preset saved from an effect property (`effects/ec.control.slider/slider`, `#2` for the second +/// instance) applies back to the same layer instead of landing in `skipped`. +#[test] +fn preset_round_trips_effect_properties_by_match_name() { + let (mut s, a, _) = comp_with_two_solids(); + apply(&mut s, a, "Slider Control"); + apply(&mut s, a, "Slider Control"); + let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value { + KV::Scalar(v) => v, + ref other => panic!("not a scalar: {other:?}"), + }; + for (n, value) in [(1, 30.0), (2, 70.0)] { + s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": value})).unwrap(); + } + for n in [1, 2] { + let path = std::env::temp_dir().join(format!("ec-1746-{}-{n}.ecpreset", std::process::id())).to_string_lossy().to_string(); + s.execute("prop.select", json!({"layer": a, "path": format!("effects/#{n}/slider")})).unwrap(); + s.execute("anim.savePreset", json!({"path": path})).unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + let want = if n == 1 { "effects/ec.control.slider/slider" } else { "effects/ec.control.slider#2/slider" }; + assert!(saved.contains(want), "{saved}"); + s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": 0.0})).unwrap(); + let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(r["applied"], 1, "{r}"); + assert_eq!(r["skipped"], json!([]), "{r}"); + assert_eq!(slider(&s, n - 1), if n == 1 { 30.0 } else { 70.0 }); + } +} + +/// #2064: `prop.addKey` accepts the `effects/[#n]/` path that +/// `essential.list` reports. +#[test] +fn add_key_resolves_effect_match_name_paths() { + let (mut s, a, _) = comp_with_two_solids(); + apply(&mut s, a, "Slider Control"); + apply(&mut s, a, "Slider Control"); + let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value { + KV::Scalar(v) => v, + ref other => panic!("not a scalar: {other:?}"), + }; + for (path, n, value) in [("effects/ec.control.slider/slider", 0, 30.0), ("effects/ec.control.slider#2/slider", 1, 70.0)] { + let reply = s.execute("prop.addKey", json!({"layer": a, "path": path, "time": 0, "value": value})).unwrap(); + assert_eq!(reply, json!(1), "{path}"); + let got = s.execute("prop.get", json!({"layer": a, "path": path, "time": 0})).unwrap(); + assert_eq!(got["value"], json!(value), "{path}"); + assert_eq!(got["keys"].as_array().unwrap().len(), 1, "{path}"); + assert_eq!(slider(&s, n), 0.0, "the static value stays put; the key carries {value}"); + } +} + +/// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path, +/// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect. +#[test] +fn copied_effect_property_pastes_onto_the_same_effect() { + let (mut s, a, b) = comp_with_two_solids(); + apply(&mut s, a, "Gaussian Blur"); + apply(&mut s, b, "Gaussian Blur"); + s.execute("prop.set", json!({"layer": a, "path": "effects/#1/blurriness", "value": 12.5})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(r["values"], 1, "{r}"); + assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.5)); + s.execute("prop.setExpression", json!({"layer": a, "path": "effects/#1/blurriness", "expression": "time * 10"})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap(); + s.execute("edit.copyExpressionOnly", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let r = s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(r["expressions"], 1, "{r}"); + assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().expr.as_ref().unwrap().text, "time * 10"); +} diff --git a/crates/engine/src/tests_essential.rs b/crates/engine/src/tests_essential.rs index 0732020ae..191416b9d 100644 --- a/crates/engine/src/tests_essential.rs +++ b/crates/engine/src/tests_essential.rs @@ -274,6 +274,55 @@ fn template_export_import_round_trip_with_overrides() { assert!(t.execute("essential.importTemplate", json!({"path": dir.join("bad.ectemplate").to_string_lossy()})).is_err()); } +#[test] +fn template_import_writes_media_only_into_its_media_folder() { + let root = out_dir("ectemplate-names"); + let dir = root.join("a").join("b"); + std::fs::create_dir_all(&dir).unwrap(); + let (mut s, card, _solid, _ids) = card(); + let src = dir.join("Src.ectemplate"); + s.execute("essential.exportTemplate", json!({"comp": card.0, "path": src.to_string_lossy()})).unwrap(); + let mut entries = effectcraft_lottie::zip::read_stored(&std::fs::read(&src).unwrap()); + std::fs::remove_file(&src).unwrap(); + let abs = root.join("abs").join("x").to_string_lossy().replace('\\', "/"); + for name in [ + "../x", + "media/../../x", + "media/1/../../../x", + &format!("media/{abs}"), + &format!("media/6{abs}"), + "media/C:\\x", + "media/7/C:\\x", + "media/x/clip.png", + "media/8/sub/clip.png", + "media/2/..", + "media/3/", + "media/4/clip.png", + "media/5/clip.png", + ] { + entries.push((name.to_string(), b"data".to_vec())); + } + let path = dir.join("T.ectemplate"); + std::fs::write(&path, effectcraft_lottie::zip::store(&entries)).unwrap(); + s.execute("essential.importTemplate", json!({"path": path.to_string_lossy()})).unwrap(); + + let media = dir.join("T Media"); + fn files(d: &std::path::Path, out: &mut Vec) { + for e in std::fs::read_dir(d).unwrap().map(|e| e.unwrap().path()) { + if e.is_dir() { files(&e, out) } else { out.push(e) } + } + } + let mut found = vec![]; + files(&root, &mut found); + let in_item = |f: &std::path::Path| f.parent().and_then(std::path::Path::parent) == Some(media.as_path()); + for f in &found { + assert!(f == &path || in_item(f), "written outside an item folder of the media folder: {f:?}"); + } + let mut names: Vec = found.iter().filter(|f| in_item(f)).map(|f| f.strip_prefix(&media).unwrap().to_string_lossy().replace('\\', "/")).collect(); + names.sort(); + assert_eq!(names, ["4/clip.png", "5/clip.png"]); +} + #[test] fn media_replacement_swaps_footage_per_instance() { use std::sync::Arc; diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs index 87b0864ac..a45c5d444 100644 --- a/crates/engine/src/tests_frame_export.rs +++ b/crates/engine/src/tests_frame_export.rs @@ -77,6 +77,19 @@ fn proexr_writes_layer_prefixed_channels_and_the_composite() { assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)"); } +#[test] +fn proexr_names_a_non_ascii_layer_with_underscores_instead_of_panicking() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Frame", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "你好", "color": "#ff0000"})).unwrap(); + let dir = out_dir("frame-exr-non-ascii"); + let path = dir.join("frame.exr").to_string_lossy().to_string(); + let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap(); + let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect(); + assert!(names.contains(&"__.R") && names.contains(&"__.A"), "{names:?}"); + assert!(std::path::Path::new(&path).exists()); +} + /// Save a 32×32 comp holding one opaque solid of `color` (an sRGB-encoded triple) as ProEXR and /// return the centre pixel of the composite and of the solid's own layer: `[R, G, B]` each. fn proexr_centre(color: [f64; 3], bit_depth: u64) -> ([f32; 3], [f32; 3]) { diff --git a/crates/engine/src/tests_import_project.rs b/crates/engine/src/tests_import_project.rs index 6aa52b411..cbc161c9c 100644 --- a/crates/engine/src/tests_import_project.rs +++ b/crates/engine/src/tests_import_project.rs @@ -113,7 +113,7 @@ fn import_project_merges_with_fresh_ids_and_one_undo() { // A relative footage path resolves next to the imported project; a missing one is reported. let data = t.project.items.values().find(|i| i.name == "data.json").unwrap(); let ItemKind::Footage(f) = &data.kind else { panic!("footage") }; - assert_eq!(std::path::Path::new(&f.path), dir.join("data.json")); + assert_eq!(std::fs::canonicalize(&f.path).unwrap(), std::fs::canonicalize(dir.join("data.json")).unwrap()); assert_eq!(r["missingMedia"], json!(["/offline/gone.mov"])); // One undo step removes all of it; redo brings it back. diff --git a/crates/engine/src/tests_key_time.rs b/crates/engine/src/tests_key_time.rs index 4d4ab367b..d42d6d4a0 100644 --- a/crates/engine/src/tests_key_time.rs +++ b/crates/engine/src/tests_key_time.rs @@ -57,7 +57,10 @@ fn default_layer_times_are_preserved_and_outside_keys_warn() { assert!(selected.get("warning").is_some()); let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap(); assert_seconds(&moved["compTime"], 20.0); - assert!(moved.get("warning").is_some(), "out point is exclusive"); + assert!(moved.get("warning").is_none(), "a key on the out point is accepted"); + let past = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": 20.1, "timeBase": "comp", "value": 45})).unwrap(); + assert_seconds(&past["compTime"], 20.1); + assert!(past.get("warning").is_some(), "a key past the out point warns"); } #[test] diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs index 68f2c04c0..524719bbf 100644 --- a/crates/engine/src/tests_limits.rs +++ b/crates/engine/src/tests_limits.rs @@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() { let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap(); assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); } + +#[test] +fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() { + // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A + // contains C walked the A ⇄ B cycle until the stack ran out (#1237). + let mut s = Session::default(); + let mut ids = vec![]; + for name in ["A", "B", "C"] { + s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + ids.push(s.active_comp_id().unwrap()); + } + let (a, b, c) = (ids[0], ids[1], ids[2]); + { + let proj = std::sync::Arc::make_mut(&mut s.project); + for (outer, inner) in [(a, b), (b, a)] { + let comp = proj.comp(outer).unwrap().clone(); + let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None); + let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(cc).layers.push(l); + } + } + let path = tmp("cycle.ecproj"); + std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + t.open_comp(c); + // Adding A into C asks whether A contains C: it doesn't, so the layer goes in. + let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap(); + assert!(r["layer"].as_u64().is_some(), "{r}"); + // Adding C into A would nest C in itself through A: refused, as before. + assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err()); + // C renders: inside A the renderer skips B's copy of A instead of looping. + let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!((img.width, img.height), (32, 32)); +} diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index e52e3ffbf..68b897617 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -42,14 +42,14 @@ fn split_layers_go_above_or_below_the_original() { let mut s = session(); let a = solid(&mut s, "#ff0000"); s.execute("time.set", json!({"time": 1.0})).unwrap(); - let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); - let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); - assert_eq!(order, vec![a, below], "off (default): the new part goes below"); - s.execute("edit.undo", json!({})).unwrap(); - s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap(); let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); - assert_eq!(order, vec![above, a]); + assert_eq!(order, vec![above, a], "on (default): the new part goes above"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": false})).unwrap(); + let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); + let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + assert_eq!(order, vec![a, below]); } #[test] @@ -280,6 +280,28 @@ fn import_export_helpers() { assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]); } +#[test] +fn render_queue_resolves_a_bare_project_filename_against_the_working_directory() { + use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem}; + let mut s = session(); + let cid = s.active_comp_id().unwrap(); + let mut item = RenderQueueItem::new(1, cid); + item.output.output = "clip.mp4".into(); + item.output.format = OutputFormat::H264; + + // `--project ./proj.ecproj`: the parent is `.`, so the output goes beside the project. + s.path = Some("./proj.ecproj".into()); + let dotted = s.resolve_output(&item).unwrap(); + + // `--project proj.ecproj`: the parent is the empty path, which must mean the same folder + // rather than the unsaved-project default (#1427). + s.path = Some("proj.ecproj".into()); + let bare = s.resolve_output(&item).unwrap(); + + assert_eq!(PathBuf::from(&bare), PathBuf::from(&dotted), "a bare filename resolved to {bare} instead of {dotted}"); + assert_eq!(PathBuf::from(&bare), PathBuf::from("./clip.mp4")); +} + #[test] fn render_queue_uses_default_folder_bit_depth_names_and_segments() { use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem}; @@ -349,6 +371,34 @@ fn execute_file_needs_the_gate_and_asks_first() { assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some()); } +#[test] +fn a_script_cannot_confirm_executing_a_file_for_itself() { + // Warn User When Executing Files: only the user answers the question (#1246). + let mut s = session(); + let f = tmp("script-run.txt"); + std::fs::write(&f, "x").unwrap(); + let path = f.to_string_lossy().to_string(); + let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_))); + // Without the gate a script can't run files at all, confirmed or not. + assert!(s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).is_err()); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + s.drain_events(); + // A script asserting `confirmed` still gets the question, directly or in a batch. + let r = s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).unwrap(); + assert!(r.get("needsConfirmation").is_some(), "{r}"); + assert!(!opened(&mut s)); + let steps = json!({"steps": [{"command": "file.executeFile", "params": {"path": path, "confirmed": true}}]}); + let r = s.execute_for_script("engine.batch", steps).unwrap(); + assert!(r.to_string().contains("needsConfirmation"), "{r}"); + assert!(!opened(&mut s)); + // The user's answer from the frontend's question still opens it. + assert!(s.execute("file.executeFile", json!({"path": path, "confirmed": true})).unwrap().get("opened").is_some()); + assert!(opened(&mut s)); + // With the warning off a script's request opens the file without a question, as before. + s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap(); + assert!(s.execute_for_script("file.executeFile", json!({"path": path})).unwrap().get("opened").is_some()); +} + // ---------------------------------------------------------------- Edit ▸ History, recent lists #[test] diff --git a/crates/engine/src/tests_mask_warp.rs b/crates/engine/src/tests_mask_warp.rs index e08ce2e66..137feb1cb 100644 --- a/crates/engine/src/tests_mask_warp.rs +++ b/crates/engine/src/tests_mask_warp.rs @@ -412,6 +412,162 @@ impl Deferred { } } +/// #1249: a setting changed while the analysis runs in a worker survives its result, which +/// used to replace the whole effect with the copy the job started from. +#[test] +fn an_edit_made_during_an_offloaded_analysis_survives_its_result() { + let (mut s, clip, solid) = setup(shaky); + s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [clip.0]})).unwrap(); + s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap(); + set_param(&mut s, clip, "stabilization/smoothness", json!(37.5)); + let off = std::sync::Arc::new(Deferred::default()); + s.offload = Some(off.clone()); + s.execute_checked("warp.analyze", json!({"layer": clip.0})).unwrap(); + // While the job runs: another setting of the same effect. + set_param(&mut s, clip, "stabilization/smoothness", json!(12.5)); + let fx = |s: &Session| s.active_comp().unwrap().layer(clip).unwrap().props.prop("effects/#1/stabilization/smoothness").unwrap().value.clone(); + off.run(s.footage.clone()); + assert!(s.poll_offload()); + assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })), "applied without a conflict"); + let st = s.execute("warp.status", json!({})).unwrap(); + assert_eq!(st["analyzed"], true, "the analysis landed: {st}"); + assert_eq!(fx(&s), KV::Scalar(12.5), "the edit made during the analysis stays"); + // One undo step takes the analysis back and keeps the edit. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(fx(&s), KV::Scalar(12.5)); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false); +} + +fn point_track_pose(f: u32) -> ([f64; 2], f64, f64) { + ([150.0 + 2.0 * f as f64, 110.0 + f as f64], 0.0, 1.0) +} + +/// Hold an actual point-tracking worker request so commands can edit its target before the +/// serialized worker reply is delivered. +fn held_point_track() -> (Session, LayerId, Uid, std::sync::Arc) { + let (mut s, clip, solid) = setup(patch_frames(point_track_pose)); + s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [clip.0]})).unwrap(); + let tracker = s.execute("track.motion", json!({})).unwrap()["tracker"].as_u64().unwrap(); + s.execute("track.setPoint", json!({"point": 1, "center": point_track_pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); + let off = std::sync::Arc::new(Deferred::default()); + s.offload = Some(off.clone()); + s.execute_checked("track.analyze", json!({"direction": "forward"})).unwrap(); + assert_eq!(off.kinds(), [crate::offload::JobKind::Track]); + (s, clip, tracker, off) +} + +fn tracked_center(s: &Session, clip: LayerId, tracker: Uid) -> Uid { + s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().track_points().next().unwrap().get("featureCenter").unwrap().uid +} + +#[test] +fn offloaded_conflict_preserves_state_and_reports_completion_failure() { + let (mut s, clip, tracker, off) = held_point_track(); + let center = tracked_center(&s, clip, tracker); + s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap(); + s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Redo survives"})).unwrap(); + assert!(s.undo()); + let before = s.project.clone(); + let state = serde_json::to_value(&s.state).unwrap(); + let steps = |s: &Session| { + let entries = + |v: &[(String, std::sync::Arc)]| v.iter().map(|(label, p)| (label.clone(), p.to_json())).collect::>(); + ( + entries(&s.history.undo), + entries(&s.history.redo), + s.history.branches.iter().map(|b| (b.parent.to_json(), entries(&b.steps))).collect::>(), + s.history.merge_key.clone(), + ) + }; + let history = steps(&s); + let inbox = s.offloaded(crate::offload::JobKind::Track).unwrap().inbox.clone(); + off.run(s.footage.clone()); + let (done, replies): (Vec<_>, Vec<_>) = inbox.take().into_iter().partition(|r| matches!(r, crate::offload::WorkerReply::Done)); + assert_eq!(done.len(), 1); + for reply in replies { + inbox.push(reply); + } + s.drain_events(); + assert!(s.poll_offload()); + let pending = s.offloaded(crate::offload::JobKind::Track).unwrap(); + assert!(pending.progress.error.as_ref().is_some_and(|e| e.contains("nothing was applied"))); + assert!(!pending.progress.finished); + assert!(s.offload_log.is_empty(), "the error is kept before completion is logged"); + // The current completion remains identifiable after the bounded log evicts an old entry. + s.offload_log = vec![(crate::offload::JobKind::Warp, None); 32]; + for reply in done { + inbox.push(reply); + } + assert!(s.poll_offload()); + assert!(std::sync::Arc::ptr_eq(&s.project, &before), "a rejected result changes no document content"); + assert_eq!(serde_json::to_value(&s.state).unwrap(), state); + assert_eq!(steps(&s), history); + let errors: Vec<_> = s + .drain_events() + .into_iter() + .filter_map(|e| match e { + crate::Event::Toast { error: true, message } => Some(message), + _ => None, + }) + .collect(); + assert_eq!(errors.len(), 1, "the apply rejection is reported once: {errors:?}"); + assert!(errors[0].contains("nothing was applied")); + assert_eq!(s.offload_log.len(), 32); + assert_eq!(s.offload_log_start, 1); + let (kind, error) = s.offload_log.last().unwrap(); + assert_eq!(*kind, crate::offload::JobKind::Track); + assert!(error.as_ref().is_some_and(|e| e.contains("nothing was applied")), "a rejected result must report failure to JobWaiter"); + assert!(s.redo()); + assert_eq!(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().name, "Redo survives"); +} + +#[test] +fn offloaded_disjoint_edit_reports_success_and_keeps_one_undo_step() { + let (mut s, clip, tracker, off) = held_point_track(); + s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Live track"})).unwrap(); + s.execute("prop.setGroupEnabled", json!({"layer": clip.0, "prop": tracker, "value": false})).unwrap(); + let undo = s.history.undo.len(); + off.run(s.footage.clone()); + s.drain_events(); + assert!(s.poll_offload()); + let layer = s.active_comp().unwrap().layer(clip).unwrap(); + let track = layer.props.find_group(tracker).unwrap(); + assert_eq!(track.name, "Live track"); + assert!(!track.enabled); + assert_eq!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.len(), 25); + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(s.offload_log.last(), Some(&(crate::offload::JobKind::Track, None))); + assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. }))); + let applied = s.project.to_json(); + assert!(s.undo()); + let track = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap(); + assert_eq!(track.name, "Live track"); + assert!(!track.enabled); + assert!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); + assert!(s.redo()); + assert_eq!(s.project.to_json(), applied); +} + +#[test] +fn offloaded_conflict_does_not_fail_a_concurrent_analysis() { + let (mut s, clip, tracker, off) = held_point_track(); + let effect = s.execute("effect.apply", json!({"effect": "Warp Stabilizer", "layers": [clip.0]})).unwrap()["effects"][0].as_u64().unwrap(); + s.execute_checked("warp.analyze", json!({"layer": clip.0, "effect": effect})).unwrap(); + assert_eq!(off.kinds(), [crate::offload::JobKind::Track, crate::offload::JobKind::Warp]); + let center = tracked_center(&s, clip, tracker); + s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap(); + off.run(s.footage.clone()); + assert!(s.poll_offload()); + let track = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Track).unwrap(); + assert!(track.1.as_ref().is_some_and(|e| e.contains("nothing was applied"))); + let warp = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Warp).unwrap(); + assert!(warp.1.is_none(), "the other analysis completed successfully: {warp:?}"); + assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": effect})).unwrap()["analyzed"], true); + assert!(s.active_comp().unwrap().layer(clip).unwrap().props.find(center).unwrap().keys.is_empty()); +} + /// M13.32: where jobs run elsewhere (the browser), `warp.status` never solves the /// stabilization plan on the page's thread (seconds for a long Subspace Warp clip): the /// analysis job sends the plan's summary with its result, and a settings change has the plan diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f6..5131bf3f4 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -111,6 +111,61 @@ fn set_switch_others() { assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err()); } +/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking +/// it (and Hide Other Video) takes them along with the rest. +#[test] +fn video_others_takes_cameras_and_lights() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l))); + // The camera and the light are hidden: Alt-click on the solid's eye brings them back, since + // they stand apart with it, and the next Alt-click hides them with every other layer. + s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); + // Hide Other Video takes them too. + s.execute("layer.showAllVideo", json!({})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); +} + +/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles, +/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is +/// hidden (parenting ignores the eye). +#[test] +fn null_video_switch() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video"})).unwrap(); + assert!(!layer(&s, n).switches.video); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, n).switches.video); + // Alt-click on the solid's eye hides the null too; on the null's, the solid. + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video); + s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video", "others": true})).unwrap(); + assert!(!layer(&s, a).switches.video && layer(&s, n).switches.video); + s.execute("layer.showAllVideo", json!({})).unwrap(); + s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap(); + assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video); + // The hidden null still carries its child. + s.execute("layer.setParent", json!({"layers": [a], "parent": n})).unwrap(); + s.execute("prop.set", json!({"layer": n, "path": "transform/position", "value": [420, 180, 0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let c = s.project.comp(cid).unwrap().clone(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &c, Tick::ZERO); + let child = c.layer(effectcraft_project::LayerId(a)).unwrap(); + let (m, _) = ctx.layer_to_comp(child); + let centre = m.apply(effectcraft_geom::vec2(100.0, 50.0)); + assert!((centre.x - 420.0).abs() < 1e-6 && (centre.y - 180.0).abs() < 1e-6, "{centre:?}"); +} + #[test] fn layer_switches_menu() { let mut s = comp(); @@ -433,8 +488,10 @@ fn copy_a_selected_property_pastes_onto_the_same_property() { s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap(); s.execute("time.set", json!({"time": 1.0})).unwrap(); s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + assert_eq!(s.state.selected_keys.len(), 2); let transform = layer(&s, a).props.sub("transform").unwrap().uid; - s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap(); + s.execute("prop.select", json!({"layer": a, "prop": transform, "selectKeys": false})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); s.execute("layer.select", json!({"layers": [b]})).unwrap(); s.execute("time.set", json!({"time": 2.0})).unwrap(); @@ -691,6 +748,37 @@ fn reveal_properties() { assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps")); } +#[test] +fn reveal_layer_source_selects_each_layers_source_item() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let ia = layer(&s, a).source.item().unwrap(); + let ib = layer(&s, b).source.item().unwrap(); + assert_ne!(ia, ib); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["item"], ia.0); + assert_eq!(r["items"], json!([ia.0, ib.0])); + assert_eq!(s.state.project_selection, vec![ia, ib]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]); + // A null has no source. Alone it is refused; in a mixed selection only the sources are revealed. + let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [n]})).unwrap(); + assert!(s.execute("layer.revealSource", json!({})).is_err()); + s.execute("layer.select", json!({"layers": [n, a]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["items"], json!([ia.0])); + assert_eq!(s.state.project_selection, vec![ia]); + // Reveal Composition in Project opens the same panel and folders. + let cid = s.active_comp_id().unwrap(); + s.drain_events(); + let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap(); + assert_eq!(r["item"], cid.0); + assert_eq!(s.state.project_selection, vec![cid]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]); +} + #[test] fn sequence_layers() { let mut s = comp(); @@ -706,6 +794,21 @@ fn sequence_layers() { assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2); } +/// File ▸ Dependencies ▸ Find Missing Fonts lists a text layer only when no face matches its +/// font by family, PostScript or full name. +#[test] +fn fonts_named_by_their_postscript_or_full_name_are_not_missing() { + let mut s = comp(); + // The first three name bundled faces by family, PostScript and full name; no face has the fourth. + let fonts = ["Noto Serif", "Inter-Bold", "Inter SemiBold", "No Such Font Family 123"]; + let ids: Vec = fonts.iter().map(|f| s.execute("layer.newText", json!({"text": "Aa", "font": f})).unwrap()["layer"].as_u64().unwrap()).collect(); + let r = s.execute("file.findMissing", json!({"what": "fonts"})).unwrap(); + let listed: Vec<(u64, &str)> = r["fonts"].as_array().unwrap().iter().map(|f| (f["layer"].as_u64().unwrap(), f["font"].as_str().unwrap())).collect(); + assert_eq!(listed, [(ids[3], fonts[3])], "{r}"); + let f = s.execute("text.fontFeatures", json!({"layer": ids[1]})).unwrap(); + assert_eq!((&f["family"], &f["style"], &f["missing"]), (&json!("Inter"), &json!("Bold"), &json!(false)), "{f}"); +} + #[test] fn file_menu_project_ops() { let mut s = comp(); @@ -1396,6 +1499,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() assert_eq!(vals, vec![10.0, 0.0], "reversed"); } +/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion +/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI, +/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers +/// as they are, so the source layer's and the target parent's transforms don't move them. +#[test] +fn a_copied_path_pastes_onto_position_as_a_motion_path() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + // A moved and scaled source: converting to comp space would give 2v + (200, 100). + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap(); + let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]); + s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap(); + // B's parent would shift a converted Position by (0, −50). + let parent = solid(&mut s, "#0000ff"); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap(); + let b = solid(&mut s, "#00ff00"); + s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap(); + let motion = |s: &Session, id: u64| { + let l = layer(s, id); + let pr = l.props.prop("transform/position").unwrap().clone(); + pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::>() + }; + let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| { + let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]]; + assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}"); + for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() { + assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}"); + assert_eq!(*roving, i != 0 && i != 4, "key {i} roving"); + assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}"); + } + assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI"); + assert!(keys.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent"); + }; + // A Mask Path keyframe, pasted at 1 s. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, b), 1.0); + // The same path as an unanimated mask, pasted at 5 s onto an unparented layer. + s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let c = solid(&mut s, "#ffffff"); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, c), 5.0); +} + #[test] fn j_and_k_stop_at_keys_markers_and_the_work_area() { let mut s = comp(); @@ -1492,6 +1649,40 @@ fn shape_contents_copy_cut_and_paste_between_shape_layers() { assert!(s.execute("edit.paste", json!({})).is_err()); } +/// Pasting Trim Paths into another shape layer with nothing selected puts it below the shapes, +/// where it acts on them (it acts on the items above it); on top it would trim nothing (#1681, #1629). +#[test] +fn pasted_path_operator_goes_below_the_shapes_it_acts_on() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + let trim = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "trim"})).unwrap()["uid"].as_u64().unwrap(); + let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": a, "prop": trim})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(contents_names(&s, b), ["Polystar 1", "Trim Paths 1"]); +} + +/// Pasting Merge Paths onto a painted layer with nothing selected keeps the paint: the renderer +/// drops the draws above a Merge Paths, so it goes between the path and the paints (#1681). +#[test] +fn pasted_merge_paths_keeps_the_targets_paint() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "none", "name": "A"})).unwrap()["layer"].as_u64().unwrap(); + let merge = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "merge"})).unwrap()["uid"].as_u64().unwrap(); + let b = s.execute("layer.newShape", json!({"kind": "none", "name": "B"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addShapeItem", json!({"layer": b, "kind": "rect"})).unwrap(); + s.execute("layer.addShapeItem", json!({"layer": b, "kind": "fill"})).unwrap(); + let before = opaque_centroid(&s).2; + s.execute("prop.select", json!({"layer": a, "prop": merge})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(contents_names(&s, b), ["Rectangle Path 1", "Merge Paths 1", "Fill 1"]); + assert_eq!(opaque_centroid(&s).2, before); +} + /// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the /// original and named "Rectangle 2", not the layer (#227). #[test] @@ -1556,6 +1747,31 @@ fn interpret_footage_loop_extends_video_layers() { assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0))); } +/// #1068: the same for an interpreted PNG sequence — the loop count survives interpretation and +/// a freshly dragged layer plays every cycle. +#[test] +fn interpret_footage_loop_extends_sequence_layers() { + let mut s = comp(); + let p = s.execute("file.importPlaceholder", json!({"name": "Seq", "width": 320, "height": 240, "duration": 0.4})).unwrap()["item"].as_u64().unwrap(); + let id = effectcraft_project::ItemId(p); + // Make it a sequence like the importer's grouped PNGs. + s.edit("Seq", None, |proj, _| { + if let Some(ItemKind::Footage(f)) = proj.item_mut(id).map(|i| &mut i.kind) { + f.kind = effectcraft_project::FootageKind::Sequence; + f.sequence = (0..12).map(|n| format!("/tmp/seq_{n:04}.png")).collect(); + f.sync_sequence_duration(); + } + Ok(()) + }) + .unwrap(); + s.state.project_selection = vec![id]; + s.execute("file.interpretFootage", json!({"loop": 15.0})).unwrap(); + let l = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap(); + let dur = (layer.out_point - layer.in_point).seconds(); + assert!((dur - 6.0).abs() < 0.05, "15 loops of a 0.4 s sequence: {dur}"); +} + #[test] fn percentage_guides_follow_the_comp_size() { let mut s = comp(); diff --git a/crates/engine/src/tests_replace_source.rs b/crates/engine/src/tests_replace_source.rs new file mode 100644 index 000000000..a5cb252ce --- /dev/null +++ b/crates/engine/src/tests_replace_source.rs @@ -0,0 +1,158 @@ +//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/ +//! replace an AV layer's source while every other layer property stays. + +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid}; +use serde_json::json; + +use crate::Session; + +fn comp() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone() +} + +fn solid(s: &mut Session, name: &str) -> u64 { + s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap() +} + +fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId { + let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() }; + Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f)) +} + +/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch, +/// switches, blending, parenting, track matte and the whole property tree (keys, expression, +/// effect, mask) stay. +#[test] +fn replace_source_keeps_every_layer_property() { + let mut s = comp(); + let a = solid(&mut s, "Hero"); + let parent = solid(&mut s, "Parent"); + let matte = solid(&mut s, "Matte"); + s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap(); + s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap(); + s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap(); + s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap(); + s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap(); + s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap(); + s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap(); + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap(); + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap(); + s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap(); + let before = layer(&s, a); + let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false); + s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Footage { item }); + assert_eq!(after.name, before.name); + assert_eq!(after.label, before.label); + assert_eq!(after.comment, before.comment); + assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch)); + assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency)); + assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte)); + assert_eq!(after.props, before.props); + assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source"); +} + +/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio +/// group when the layer has none, and an existing Audio group is kept when the next source has no +/// audio. +#[test] +fn replace_source_crosses_types_and_adds_audio() { + let mut s = comp(); + let a = solid(&mut s, "Layer"); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap(); + let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id; + s.execute("comp.open", json!({"comp": main})).unwrap(); + let before = layer(&s, a); + s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) }); + assert_eq!(after.name, before.name); + assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio"); + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false); + s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Footage { item: footage }); + assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source"); + let new_solid = Arc::make_mut(&mut s.project).add_item( + "New Solid", + Label::None, + None, + ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }), + ); + s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap(); + assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid }); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage }); +} + +/// Folders, data files, 3D models, non-source layers and composition cycles are refused before +/// anything is edited. +#[test] +fn replace_source_refuses_invalid_sources_targets_and_cycles() { + let mut s = comp(); + let a = solid(&mut s, "Layer"); + let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder); + let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false); + let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false); + for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] { + let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string(); + assert!(err.contains(needle), "{err}"); + } + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string(); + assert!(err.contains("no source item"), "{err}"); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id; + s.execute("comp.open", json!({"comp": main})).unwrap(); + let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string(); + assert!(err.contains("contain"), "{err}"); +} + +/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected +/// layer; locked layers are left alone (and an all-locked selection is an error). +#[test] +fn replace_source_uses_the_project_selection_and_skips_locked_layers() { + let mut s = comp(); + let a = solid(&mut s, "A"); + let b = solid(&mut s, "B"); + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false); + s.state.project_selection = vec![footage]; + let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap(); + assert_eq!(r["item"], json!(footage.0)); + assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage)); + assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + let old_b = layer(&s, b).source; + let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap(); + assert_eq!(r["layers"], json!([a])); + assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage)); + assert_eq!(layer(&s, b).source, old_b); + let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); +} + +/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax). +#[test] +fn replace_source_is_bound_to_ctrl_alt_slash() { + let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable"); + assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults); +} diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index 7f937596f..3e81961ce 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -8,14 +8,16 @@ use serde_json::{Value, json}; use crate::Session; -/// Probes every file as a 64×32 still (the media layer's single-file probe). +/// Probes every file as a still 64 + its byte length wide and 32 high (the media layer's +/// single-file probe). struct Stills; impl crate::Importer for Stills { fn probe(&self, path: &str) -> Result { if !std::path::Path::new(path).is_file() { return Err(format!("{path}: no such file")); } - Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) + let width = 64 + std::fs::metadata(path).map_or(0, |m| m.len() as u32); + Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) } } @@ -196,6 +198,19 @@ fn replace_footage_and_reload_keep_sequences() { assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5)); } +/// #2074: reloading a numbered still probed it as its run, so it took the first file's size. +#[test] +fn reloading_a_numbered_still_measures_its_own_file() { + let dir = folder(&RUN); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": false})).unwrap()["items"][0].clone(); + std::fs::write(at(&dir, "shot_0002.png"), [0; 16]).unwrap(); + s.execute("file.reloadFootage", json!({"items": [item]})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.width, f.sequence.len()), ("shot_0002.png", FootageKind::Still, 80, 0)); + assert_eq!(f.path, at(&dir, "shot_0002.png")); +} + /// #756: numbered DPX and Cineon scans import as sequences, as in After Effects (`.cin` was not /// an image to the Media Browser, so its runs came in one file at a time). #[test] @@ -207,3 +222,31 @@ fn dpx_and_cineon_runs_import_as_sequences() { got.sort(); assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}"); } + +/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences +/// again for the disk cache's salt (with large sequences each keystroke in a text field took over +/// half a second); Reload Footage does, and so does new footage. +#[test] +fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() { + let dir = folder(&RUN); + let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&cache); + let mut s = session(); + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap(); + let item = r["items"][0].clone(); + let salt = s.refresh_disk_salt(false).expect("a disk cache"); + // A frame rewritten in place: same footage settings, another size on disk. + std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap(); + s.execute("layer.newText", json!({"text": "title"})).unwrap(); + s.execute("layer.setText", json!({"text": "title 2"})).unwrap(); + assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again"); + s.execute("file.reloadFootage", json!({"items": [item]})).unwrap(); + let reloaded = s.refresh_disk_salt(false).unwrap(); + assert_ne!(reloaded, salt, "Reload Footage stamps them again"); + std::fs::write(at(&dir, "extra.png"), b"").unwrap(); + s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap(); + assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts"); + let _ = std::fs::remove_dir_all(&cache); +} diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 458c886fc..c35d4f3a4 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::json; -use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path}; +use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with}; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize}; use crate::{Session, autosave}; @@ -212,6 +212,34 @@ fn prefs_commands_get_set_reset_and_persist() { ); } +/// Effects & Presets' starred effects live in the settings (#1692): they come back in the next +/// session, un-starring sticks too, Reset All keeps them, and a hand-edited list loses its +/// repeats and empty ids on load. +#[test] +fn effects_favorites_persist_across_sessions() { + let store = Arc::new(MemoryConfig::default()); + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + assert!(s.prefs.toggle_effect_favorite("ADBE Gaussian Blur 2")); + assert!(s.prefs.toggle_effect_favorite("ADBE Glo2")); + s.save_prefs(); + let reopen = |store: &Arc| { + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + s.load_settings(); + s + }; + let mut s2 = reopen(&store); + assert_eq!(s2.prefs.effects_favorites, ["ADBE Gaussian Blur 2", "ADBE Glo2"]); + assert!(!s2.prefs.toggle_effect_favorite("ADBE Glo2"), "un-starred"); + s2.save_prefs(); + let mut s3 = reopen(&store); + assert_eq!(s3.prefs.effects_favorites, ["ADBE Gaussian Blur 2"]); + s3.execute("prefs.reset", json!({})).unwrap(); + assert!(s3.prefs.is_effect_favorite("ADBE Gaussian Blur 2"), "Reset All keeps the stars"); + let edited = store.read(PREFS_FILE).unwrap().replace("\"effectsFavorites\": [", "\"effectsFavorites\": [\"\", \"ADBE Glo2\", \"ADBE Glo2\", "); + store.write(PREFS_FILE, &edited).unwrap(); + assert_eq!(reopen(&store).prefs.effects_favorites, ["ADBE Glo2", "ADBE Gaussian Blur 2"]); +} + /// Composition Settings presets the user saves live in the settings: they persist, a save with /// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited /// settings file) are dropped on load. @@ -548,6 +576,276 @@ fn frontend_commands_are_bindable_with_panel_scope() { // ---------------------------------------------------------------- auto-save / recovery +/// The names in `d`, sorted. +fn names(d: &std::path::Path) -> Vec { + let mut v: Vec = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + v.sort(); + v +} + +#[cfg(unix)] +#[test] +fn atomic_write_overwrites_a_long_valid_filename() { + let d = tmp("atomic-long-name"); + // The old `.tmp` fits a 255-byte component limit for this 251-byte project name. + // Including the full name plus a process id and counter made an otherwise valid save fail. + let name = format!("{}.ecproj", "x".repeat(244)); + assert_eq!(name.len(), 251); + let f = d.join(&name); + std::fs::write(&f, b"original project").unwrap(); + let replacement = b"replacement project\0bytes\xff"; + atomic_write(&f, replacement).unwrap(); + assert_eq!(std::fs::read(&f).unwrap(), replacement); + assert_eq!(names(&d), [name], "no temporary file is left after overwriting the project"); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn concurrent_atomic_writes_each_publish_a_whole_file() { + // Writers of the same file used to share one `.tmp`: they truncated each other's data and + // the later rename failed once the first had moved it away (#1233). + let d = tmp("atomic-concurrent"); + let f = d.join("shared.ecproj"); + for round in 0..10 { + let contents: Vec> = (0..8u8).map(|w| vec![b'a' + w; 256 * 1024 + usize::from(w) * 1000]).collect(); + std::thread::scope(|sc| { + for c in &contents { + let f = &f; + sc.spawn(move || atomic_write(f, c).expect("every writer succeeds")); + } + }); + let got = std::fs::read(&f).unwrap(); + assert!(contents.contains(&got), "round {round}: the file is one writer's whole data ({} bytes)", got.len()); + assert_eq!(names(&d), ["shared.ecproj"], "round {round}: no temporary file is left"); + } + let _ = std::fs::remove_dir_all(d); +} + +#[cfg(unix)] +#[test] +fn atomic_write_does_not_follow_a_link_at_the_old_temp_name() { + let d = tmp("atomic-link"); + let f = d.join("p.ecproj"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"keep").unwrap(); + // The temporary name used to be `.tmp`, opened with File::create (following links). + std::os::unix::fs::symlink(&victim, d.join("p.ecproj.tmp")).unwrap(); + atomic_write(&f, b"new project").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep", "the link's target is untouched"); + assert_eq!(std::fs::read(&f).unwrap(), b"new project"); + let _ = std::fs::remove_dir_all(d); +} + +struct AutosaveTestStore { + config: DirConfig, + fail_next: std::sync::atomic::AtomicBool, + gate: std::sync::Mutex, std::sync::mpsc::Receiver<()>)>>, + release_on_prefs: std::sync::Mutex>>, + fail_reset_for: std::sync::Mutex>, + failed_resets: std::sync::atomic::AtomicUsize, +} + +impl ConfigStore for AutosaveTestStore { + fn read(&self, name: &str) -> Option { + self.config.read(name) + } + fn write(&self, name: &str, data: &str) -> std::io::Result<()> { + // note_project_path saves preferences after the project/path switch, before joining + // the pending autosave. Release that writer at this exact transition, without sleeps. + if name == PREFS_FILE + && let Some(release) = self.release_on_prefs.lock().unwrap().take() + { + release.send(()).unwrap(); + } + if name == autosave::SENTINEL + && let Some(path) = self.fail_reset_for.lock().unwrap().as_ref() + { + let record: autosave::Sentinel = serde_json::from_str(data).unwrap(); + if record.project.as_ref() == Some(path) && record.autosave.is_none() { + self.failed_resets.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + return Err(std::io::Error::other("injected final recovery reset failure")); + } + } + self.config.write(name, data) + } + fn remove(&self, name: &str) -> std::io::Result<()> { + self.config.remove(name) + } + fn dir(&self) -> Option { + self.config.dir() + } + fn files(&self) -> &dyn crate::config::FileOps { + self + } +} + +impl crate::config::FileOps for AutosaveTestStore { + fn list(&self, dir: &std::path::Path) -> Vec<(String, std::time::SystemTime)> { + crate::config::StdFiles.list(dir) + } + fn write(&self, path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { + if let Some((started, release)) = self.gate.lock().unwrap().take() { + started.send(()).unwrap(); + release.recv().unwrap(); + } + if self.fail_next.swap(false, std::sync::atomic::Ordering::SeqCst) { + return Err(std::io::Error::other("injected autosave write failure")); + } + crate::config::StdFiles.write(path, data) + } + fn remove(&self, path: &std::path::Path) -> std::io::Result<()> { + crate::config::StdFiles.remove(path) + } + fn is_file(&self, path: &std::path::Path) -> bool { + crate::config::StdFiles.is_file(path) + } +} + +fn background_autosave_session(dir: &std::path::Path) -> (Session, Arc) { + let store = Arc::new(AutosaveTestStore { + config: DirConfig::new(dir.join("config")), + fail_next: Default::default(), + gate: Default::default(), + release_on_prefs: Default::default(), + fail_reset_for: Default::default(), + failed_resets: Default::default(), + }); + let mut s = session_with_comp(); + s.config = Some(store.clone()); + s.prefs.auto_save.enabled = true; + s.prefs.auto_save.interval_minutes = 1; + s.prefs.auto_save.location = "custom".into(); + s.prefs.auto_save.folder = dir.join("saves").to_string_lossy().into_owned(); + s.autosave.background = true; + s.begin_recovery(); + (s, store) +} + +#[test] +fn background_autosave_failure_retries_the_same_revision_and_retains_recovery() { + let dir = tmp("autosave-retry"); + let (mut s, store) = background_autosave_session(&dir); + let first = s.autosave_now().unwrap(); + s.autosave_wait().unwrap(); + let committed = s.autosave.saved_revision; + s.execute("layer.newNull", json!({"name": "work to recover"})).unwrap(); + let revision = s.revision; + store.fail_next.store(true, std::sync::atomic::Ordering::SeqCst); + assert!(s.autosave_tick(1000.0).is_none()); + let failed_path = s.autosave_tick(1061.0).unwrap().unwrap(); + assert!(s.autosave_wait().unwrap_err().to_string().contains("injected autosave write failure")); + assert_eq!(s.autosave.saved_revision, committed); + assert_eq!(s.autosave.last_slot, Some(1)); + assert_eq!(s.autosave.last_path.as_deref(), Some(first.as_str())); + let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap(); + assert_eq!(sentinel.autosave.as_deref(), Some(first.as_str())); + assert!(!std::path::Path::new(&failed_path).is_file()); + let retried = s.autosave_tick(1122.0).expect("retry without another edit").unwrap(); + assert_eq!(retried, failed_path, "failed writes do not rotate the slot"); + s.autosave_wait().unwrap(); + assert_eq!(s.revision, revision); + assert_eq!(s.autosave.saved_revision, Some(revision)); + assert_eq!(s.autosave.last_slot, Some(2)); + assert!(std::fs::read_to_string(&retried).unwrap().contains("work to recover")); + let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap(); + assert_eq!(sentinel.autosave.as_deref(), Some(retried.as_str())); + assert!(s.autosave_tick(1183.0).is_none(), "committed revision needs no duplicate write"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn background_autosave_commits_only_the_snapshot_that_finished() { + let dir = tmp("autosave-pending"); + let (mut s, store) = background_autosave_session(&dir); + let (started, started_rx) = std::sync::mpsc::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + *store.gate.lock().unwrap() = Some((started, release_rx)); + let revision = s.revision; + let path = s.autosave_now().unwrap(); + started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + assert!(s.autosave_tick(1000.0).is_none()); + let saved = s.autosave.saved_revision; + let last_path = s.autosave.last_path.clone(); + let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap(); + s.execute("layer.newNull", json!({"name": "edited during save"})).unwrap(); + assert!(s.autosave_tick(1061.0).is_none(), "a pending write must not block the next tick or launch a duplicate"); + release.send(()).unwrap(); + s.autosave_wait().unwrap(); + assert_eq!(saved, None, "an in-flight save is not committed"); + assert_eq!(last_path, None); + assert_eq!(sentinel.autosave, None); + assert_eq!(s.autosave.saved_revision, Some(revision), "completion must not mark newer edits as saved"); + assert_eq!(s.autosave.last_path.as_deref(), Some(path.as_str())); + assert!(!std::fs::read_to_string(&path).unwrap().contains("edited during save")); + let newer = s.autosave_tick(1122.0).unwrap().unwrap(); + s.autosave_wait().unwrap(); + assert_eq!(s.autosave.saved_revision, Some(s.revision)); + assert!(std::fs::read_to_string(newer).unwrap().contains("edited during save")); + std::fs::remove_dir_all(dir).unwrap(); +} + +fn autosave_project_transition_with_failed_reset(command: &str, reopen: bool) { + let dir = tmp("autosave-project-transition"); + let a = dir.join("A.ecproj").to_string_lossy().into_owned(); + let b = if reopen { a.clone() } else { dir.join("B.ecproj").to_string_lossy().into_owned() }; + let (mut s, store) = background_autosave_session(&dir); + s.execute("file.saveAs", json!({"path": a})).unwrap(); + if !reopen { + let mut other = session_with_comp(); + other.execute("layer.newNull", json!({"name": "project B"})).unwrap(); + other.execute("file.saveAs", json!({"path": b})).unwrap(); + } + let b_bytes = std::fs::read(&b).unwrap(); + s.execute("layer.newNull", json!({"name": "unsaved project A"})).unwrap(); + s.update_sentinel(); + let (started, started_rx) = std::sync::mpsc::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + *store.gate.lock().unwrap() = Some((started, release_rx)); + let snapshot = s.autosave_now().unwrap(); + started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + assert!(!s.autosave.pending.as_ref().unwrap().is_finished()); + let previous_record = store.read(autosave::SENTINEL).unwrap(); + *store.fail_reset_for.lock().unwrap() = Some(b.clone()); + *store.release_on_prefs.lock().unwrap() = Some(release); + s.execute(command, json!({"path": b})).unwrap(); + assert_eq!(s.path.as_deref(), Some(b.as_str())); + assert_eq!(store.failed_resets.load(std::sync::atomic::Ordering::SeqCst), 1, "the final recovery reset really failed"); + assert_eq!( + store.read(autosave::SENTINEL).as_deref(), + Some(previous_record.as_str()), + "completion must not publish A's snapshot under the switched project" + ); + assert_eq!(s.autosave.last_path, None); + assert_eq!(s.autosave.saved_revision, None); + if command == "file.open" { + assert_eq!(std::fs::read(&b).unwrap(), b_bytes, "opening B must not alter its file bytes"); + } + let prefs = s.prefs.clone(); + drop(s); // Simulate an unclean exit, leaving the failed-reset sentinel in place. + let mut restart = Session { config: Some(store), prefs, ..Default::default() }; + let recovery = restart.begin_recovery().expect("previous project remains recoverable"); + assert_eq!(recovery.project.as_deref(), Some(a.as_str())); + assert_eq!(recovery.autosave.as_deref(), Some(snapshot.as_str())); + assert!(recovery.dirty); + assert!(std::fs::read_to_string(snapshot).unwrap().contains("unsaved project A")); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn opening_another_project_during_autosave_keeps_recovery_consistent_if_the_reset_fails() { + autosave_project_transition_with_failed_reset("file.open", false); +} + +#[test] +fn reopening_the_same_path_during_autosave_keeps_recovery_consistent_if_the_reset_fails() { + autosave_project_transition_with_failed_reset("file.open", true); +} + +#[test] +fn save_as_during_autosave_keeps_recovery_consistent_if_the_reset_fails() { + autosave_project_transition_with_failed_reset("file.saveAs", false); +} + #[test] fn atomic_write_survives_a_crash_mid_write() { let d = tmp("atomic"); @@ -555,12 +853,12 @@ fn atomic_write_survives_a_crash_mid_write() { atomic_write(&f, b"version 1").unwrap(); // The writer dies halfway through the temporary file. let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| { - std::fs::write(tmp, &data[..5])?; + std::io::Write::write_all(tmp, &data[..5])?; Err(std::io::Error::other("power cut")) }); assert!(r.is_err()); assert_eq!(std::fs::read(&f).unwrap(), b"version 1"); - assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up"); + assert_eq!(names(&d), ["p.ecproj"], "the partial temp file is cleaned up"); // A temp file left by a hard kill doesn't count as an auto-save. std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap(); assert!(autosave::existing(&d, "p").is_empty()); @@ -766,6 +1064,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Georgian too. + assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + let mut ka = Session { config: Some(store.clone()), ..Default::default() }; + ka.load_settings(); + assert_eq!(ka.prefs.general.language, "ka"); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let mut reloaded = Session { config: Some(store), ..Default::default() }; reloaded.load_settings(); diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index edf773552..84321ee7e 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -476,3 +476,110 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() { assert!(!s.is_enabled(id), "{id}"); } } + +/// The first path item in a shape layer's Contents, at any depth: (uid, match_id). A new shape +/// layer nests it in a group ("Rectangle 1" ▸ "Rectangle Path 1"), as After Effects does. +fn first_shape(s: &Session, l: u64) -> (u64, String) { + fn walk(g: &effectcraft_project::PropGroup) -> Option<(u64, String)> { + if ["rect", "ellipse", "star", "path"].contains(&g.match_id.as_str()) { + return Some((g.uid, g.match_id.clone())); + } + g.groups().find_map(walk) + } + walk(layer(s, l).props.sub("contents").unwrap()).unwrap() +} + +fn shape_path_of(s: &Session, l: u64, uid: u64) -> effectcraft_keyframe::ShapePath { + let ly = layer(s, l); + let g = ly.props.sub("contents").unwrap().find_group(uid).unwrap(); + let _ = &g; + g.get("path").unwrap().value.as_path().unwrap().clone() +} + +/// Convert To Bezier Path on a shape layer's parametric path gives an editable Path 1 with the +/// same geometry, as After Effects does (#932). The command only turned RotoBezier off for masks, +/// so a Rectangle, Ellipse or Polystar path could not be converted at all. +#[test] +fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() { + let convert = |args: serde_json::Value| { + let mut s = comp(); + let l = s.execute("layer.newShape", args).unwrap()["layer"].as_u64().unwrap(); + let (uid, was) = first_shape(&s, l); + assert_ne!(was, "path", "it starts out parametric"); + s.state.selected_props = vec![(LayerId(l), uid)]; + let out = s.execute("path.convertToBezier", json!({})).unwrap(); + assert_eq!(out["paths"].as_u64(), Some(1)); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path", "it is a bezier path now"); + // The new path is selected, so its vertices can be edited straight away. + assert_eq!(s.state.selected_props, vec![(LayerId(l), new_uid)]); + let path = shape_path_of(&s, l, new_uid); + assert!(path.closed, "a closed shape stays closed"); + (s, l, was, path) + }; + let radius = |v: [f64; 2]| v[0].hypot(v[1]); + + // A 120 x 80 rectangle is its four corners, joined by straight edges. + let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]})); + assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]); + assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}"); + // Undo brings the parametric shape back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(first_shape(&s, l).1, was, "undo restores the parametric shape"); + + // An ellipse is four cubic arcs: a vertex on each axis, with tangents of 0.5523 x the radius + // (the usual circle approximation). Straight lines here would be a diamond. + let (_, _, _, ell) = convert(json!({"kind": "ellipse", "size": [120, 80]})); + assert_eq!(ell.vertices, vec![[0.0, -40.0], [60.0, 0.0], [0.0, 40.0], [-60.0, 0.0]]); + let kappa = 4.0 / 3.0 * (std::f64::consts::SQRT_2 - 1.0); + for (i, want) in [60.0 * kappa, 40.0 * kappa, 60.0 * kappa, 40.0 * kappa].into_iter().enumerate() { + let t = ell.in_tangents[i]; + assert!((radius(t) - want).abs() < 1e-6, "vertex {i}: tangent {t:?} should be {want}"); + } + + // A five-point star alternates the outer and inner radius, so ten vertices. + let (_, _, _, star) = convert(json!({"kind": "star", "size": [120, 120]})); + assert_eq!(star.vertices.len(), 10); + for (i, v) in star.vertices.iter().enumerate() { + let want = if i % 2 == 0 { 60.0 } else { 30.0 }; + assert!((radius(*v) - want).abs() < 1e-6, "vertex {i} at {v:?} should be {want} from the centre"); + } + + // A polygon has one vertex per point, all at the outer radius. + let (_, _, _, poly) = convert(json!({"kind": "polygon", "size": [120, 120]})); + assert!(poly.vertices.len() >= 3); + assert!(poly.vertices.iter().all(|v| (radius(*v) - 60.0).abs() < 1e-6), "{:?}", poly.vertices); + assert_ne!(poly.vertices.len(), star.vertices.len(), "a polygon is not a star"); +} + +/// The shape's own values are read, not defaults: Roundness rounds the corners and Position moves +/// the path, both at the time the conversion is made. +#[test] +fn convert_to_bezier_reads_the_shape_as_it_is_set() { + let mut s = comp(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + // Properties are addressed by their own uid. + let pr_uid = |s: &Session, id: &str| { + let ly = layer(s, l); + ly.props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid + }; + let (round, pos) = (pr_uid(&s, "roundness"), pr_uid(&s, "position")); + s.execute("prop.set", json!({"layer": l, "path": format!("@{round}"), "value": 20.0})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": format!("@{pos}"), "value": [10.0, 5.0]})).unwrap(); + + s.state.selected_props = vec![(LayerId(l), uid)]; + s.execute("path.convertToBezier", json!({})).unwrap(); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path"); + let path = shape_path_of(&s, l, new_uid); + + // Rounded corners need more than four vertices, and curves between them. + assert!(path.vertices.len() > 4, "roundness rounds the corners: {} vertices", path.vertices.len()); + assert!(path.in_tangents.iter().chain(&path.out_tangents).any(|t| *t != [0.0, 0.0]), "with curves"); + // Position moves the path: the bounds are centred on it, not on the origin. + let xs: Vec = path.vertices.iter().map(|v| v[0]).collect(); + let ys: Vec = path.vertices.iter().map(|v| v[1]).collect(); + let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0; + assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys)); +} diff --git a/crates/engine/src/tests_text.rs b/crates/engine/src/tests_text.rs index 7b9650062..84f88fd33 100644 --- a/crates/engine/src/tests_text.rs +++ b/crates/engine/src/tests_text.rs @@ -2,7 +2,7 @@ //! undo/redo and serde round-trips). use effectcraft_keyframe::Value as KV; -use effectcraft_project::{Node, Project, PropGroup}; +use effectcraft_project::{LayerId, Node, Project, PropGroup}; use serde_json::json; use crate::Session; @@ -164,6 +164,64 @@ fn presets_apply_render_and_round_trip() { assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string())); } +#[test] +fn cached_text_presets_keep_the_catalog_and_application_independent() { + let expected = [ + ("typewriter", "Typewriter"), + ("fadeUpCharacters", "Fade Up Characters"), + ("bounceInWords", "Bounce In Words"), + ("trackingIn", "Tracking In"), + ("scramble", "Scramble"), + ("blurIn", "Blur In"), + ("jitter", "Jitter"), + ("dropInLines", "Drop In Lines"), + ]; + let (mut s, layer) = session(); + let catalog = s.execute("text.presets", json!({})).unwrap(); + let mut returned = catalog.clone(); + returned[0]["name"] = json!("Edited copy"); + let project = s.project.clone(); + for _ in 0..2 { + let names = crate::text_presets(); + assert_eq!(names.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect::>(), expected); + for (id, _) in expected { + let result = s.execute("layer.applyTextPreset", json!({"layer": layer, "preset": id})).unwrap(); + assert!(!result["animators"].as_array().unwrap().is_empty(), "{id}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, project, "{id}: undo restores the document"); + } + assert_eq!(s.execute("text.presets", json!({})).unwrap(), catalog); + } +} + +#[test] +#[ignore = "warm text-preset listing benchmark"] +fn text_preset_listing_benchmark() { + let old = || { + let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap(); + let presets = v["presets"].as_array().unwrap().clone(); + presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::>() + }; + assert_eq!(old(), crate::text_presets()); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(old()); + } + before.push(start.elapsed()); + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(crate::text_presets()); + } + after.push(start.elapsed()); + } + before.sort(); + after.sort(); + println!("Text preset names, median per warm lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000); +} + #[test] fn menus_have_text_animation_entries() { let (s, _) = session(); @@ -186,3 +244,27 @@ fn menus_have_text_animation_entries() { assert!(s.is_enabled("layer.enablePerChar3D")); assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator"); } + +/// Duplicate with a Range Selector selected under a text animator copies the selector, not the +/// whole text layer (#1665); a selected animator is copied the same way. +#[test] +fn duplicate_copies_selected_text_selector_not_the_layer() { + let (mut s, t) = session(); + s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap(); + let anims = |s: &Session| s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.group("text/animators").unwrap().clone(); + let selectors = |s: &Session| -> Vec { anims(s).groups().next().unwrap().sub("selectors").unwrap().groups().map(|g| g.uid).collect() }; + let sel = selectors(&s)[0]; + + s.execute("prop.select", json!({"layer": t, "prop": sel, "selectKeys": false})).unwrap(); + s.execute("edit.duplicate", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "no second text layer"); + assert_eq!(selectors(&s).len(), 2, "the selector is copied"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(selectors(&s), vec![sel]); + + let anim = anims(&s).groups().next().unwrap().uid; + s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap(); + s.execute("edit.duplicate", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(anims(&s).groups().count(), 2, "the animator is copied"); +} diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 3e41688d2..17d0345d8 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -6,7 +6,7 @@ use effectcraft_keyframe::{BaselineOption, Justify, Kerning, TextDoc}; use serde_json::json; use crate::Session; -use crate::commands::text_edit::layer_doc; +use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc}; fn session(text: &str) -> (Session, u64) { let mut s = Session::default(); @@ -24,6 +24,98 @@ fn sel(s: &Session) -> (usize, usize) { (e.anchor, e.caret) } +/// Start editing the text layer with everything selected (no caret given). +fn begin_edit(s: &mut Session, t: u64) { + s.execute("text.edit", json!({"layer": t})).unwrap(); +} + +/// Start editing the text layer with the caret at `caret`. +fn begin_edit_at(s: &mut Session, t: u64, caret: usize) { + s.execute("text.edit", json!({"layer": t, "caret": caret})).unwrap(); +} + +/// A session with one text layer already open in the editor at `caret`. +fn editing(text: &str, caret: usize) -> (Session, u64) { + let (mut s, t) = session(text); + begin_edit_at(&mut s, t, caret); + (s, t) +} + +/// A session with one text layer already open in the editor with everything selected. +fn editing_all(text: &str) -> (Session, u64) { + let (mut s, t) = session(text); + begin_edit(&mut s, t); + (s, t) +} + +/// The IME owner token of the open editor. +fn ime_owner(s: &Session) -> u64 { + s.state.text_edit.as_ref().unwrap().session +} + +fn undo_steps(s: &Session) -> usize { + s.history.undo.len() +} + +/// The editor kept its document (same IME owner). +fn assert_ime_owner(s: &Session, expected: u64, why: &str) { + assert_eq!(ime_owner(s), expected, "{why}"); +} + +/// The editor's document was replaced (new IME owner/epoch). +fn assert_ime_epoch_changed(s: &Session, previous: u64, why: &str) { + assert_ne!(ime_owner(s), previous, "{why}"); +} + +fn assert_text(s: &Session, t: u64, expected: &str) { + assert_eq!(doc(s, t).text, expected); +} + +/// A new, empty composition called "Other" (the standard second comp). +fn new_other_comp(s: &mut Session) -> u64 { + s.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap()["comp"].as_u64().unwrap() +} + +/// Stopwatch the layer's Source Text (one key at the CTI). +fn animate_source(s: &mut Session, t: u64) { + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); +} + +/// Add a Source Text key at `time`. +fn add_source_key(s: &mut Session, t: u64, time: f64, value: &str) { + s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap(); +} + +/// Write Source Text as a static value (a replacement when it differs from the shown text). +fn set_source(s: &mut Session, t: u64, value: &str) { + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": value})).unwrap(); +} + +/// Write the Source Text key at `time`. +fn set_source_key(s: &mut Session, t: u64, time: f64, value: &str) { + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap(); +} + +/// Set the Source Text key at the CTI (`keys.set`). +fn set_source_cti_key(s: &mut Session, t: u64, time: f64, value: &str) { + s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap(); +} + +/// Replace the whole Source Text of the layer (`layer.setText`). +fn replace_text(s: &mut Session, t: u64, text: &str) { + s.execute("layer.setText", json!({"layer": t, "text": text})).unwrap(); +} + +/// Select the character range `start..end` in the open editor. +fn select(s: &mut Session, start: usize, end: usize) { + s.execute("text.setSelection", json!({"start": start, "end": end})).unwrap(); +} + +/// Set both selection ends, keeping their direction (`anchor` is the other end). +fn select_anchor(s: &mut Session, anchor: usize, caret: usize) { + s.execute("text.setSelection", json!({"anchor": anchor, "caret": caret})).unwrap(); +} + #[test] fn set_text_with_a_range_creates_runs_and_undoes() { let (mut s, t) = session("Hello World"); @@ -200,7 +292,596 @@ fn edit_state_round_trips_through_serde() { s.execute("text.edit", json!({"layer": t, "select": [1, 2]})).unwrap(); let j = serde_json::to_value(&s.state).unwrap(); let back: crate::EditorState = serde_json::from_value(j).unwrap(); - assert_eq!(back.text_edit, s.state.text_edit); + let before = s.state.text_edit.clone().expect("editing"); + let after = back.text_edit.clone().expect("editing"); + assert_eq!((after.layer, after.anchor, after.caret), (before.layer, before.anchor, before.caret)); + assert_ne!(after.session, before.session, "a restored editor gets a process-unique replacement epoch"); + assert_ne!(after.session, 0, "the restored epoch is always a real session token"); +} + +#[test] +fn ime_session_epoch_tracks_replaced_text_and_layer_restoration() { + let (mut s, t) = editing_all("abc"); + let first = ime_owner(&s); + + // Replacing Source Text is a new document even though the layer id stays. + replace_text(&mut s, t, "replaced"); + let replaced = ime_owner(&s); + assert_ne!(replaced, first, "layer.setText starts a new IME epoch"); + + // Undo restores another document: it must not revive the token from before either edit. + s.execute("edit.undo", json!({})).unwrap(); + let undone = ime_owner(&s); + assert_ne!(undone, replaced); + assert_ne!(undone, first); + + // Clear + immediate undo in the same command sequence: the layer is gone long enough to + // end the session, and the restored layer cannot inherit a composition. + begin_edit(&mut s, t); + s.execute("edit.clear", json!({"layers": [t]})).unwrap(); + assert!(s.state.text_edit.is_none(), "clearing the edited layer ends the session"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.state.text_edit.is_none(), "undo does not recreate the old composition session"); + begin_edit(&mut s, t); + assert_ne!(ime_owner(&s), first); +} + +#[test] +fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() { + use effectcraft_keyframe::Value as KV; + let (mut s, t) = session("abc"); + // Legacy/imported text can still be stored as a plain Str rather than a TextDoc. + let cid = s.active_comp_id().unwrap(); + let pr = std::sync::Arc::make_mut(&mut s.project) + .comp_mut(cid) + .unwrap() + .layer_mut(effectcraft_project::LayerId(t)) + .unwrap() + .props + .prop_mut("text/sourceText") + .unwrap(); + pr.value = KV::Str("abc".into()); + begin_edit_at(&mut s, t, 0); + let steps = undo_steps(&s); + + // A non-zero before count at the document start deletes nothing. + s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap(); + + let pr = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().props.prop("text/sourceText").unwrap(); + assert!(matches!(&pr.value, KV::Str(_)), "a no-op keeps the static Str variant"); + assert!(pr.keys.is_empty()); + assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step"); +} + +#[test] +fn no_op_ime_deletion_does_not_add_a_keyframe_to_animated_source_text() { + let (mut s, t) = editing("abc", 0); + animate_source(&mut s, t); + let lid = effectcraft_project::LayerId(t); + let keys = |s: &Session| s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap().keys.len(); + assert_eq!(keys(&s), 1, "one key from the stopwatch"); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + let steps = undo_steps(&s); + + // A non-zero before count at the document start deletes nothing, at a time with no key. + s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap(); + + assert_eq!(keys(&s), 1, "a no-op does not key Source Text at the CTI"); + assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step"); +} + +#[test] +fn property_writes_replace_the_edited_document_only_when_the_text_changes() { + let (mut s, t) = session("abc"); + let other = s.execute("layer.newText", json!({"text": "other"})).unwrap()["layer"].as_u64().unwrap(); + begin_edit_at(&mut s, t, 1); + let owner = ime_owner(&s); + let steps = undo_steps(&s); + + // Writing the text the document already has is a no-op: same document, same owner, no step. + set_source(&mut s, t, "abc"); + assert_ime_owner(&s, owner, "a no-op write keeps the IME owner"); + assert_eq!(undo_steps(&s), steps, "a no-op write records no undo step"); + replace_text(&mut s, t, "abc"); + assert_ime_owner(&s, owner, "a no-op layer.setText keeps the IME owner"); + assert_eq!(undo_steps(&s), steps, "a no-op layer.setText records no undo step"); + + // Another property of the edited layer leaves its document alone. + s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap(); + assert_ime_owner(&s, owner, "an unrelated property keeps the IME owner"); + + // Source Text of another layer is not the editor's document either. + set_source(&mut s, other, "changed"); + assert_ime_owner(&s, owner, "another layer's text keeps the IME owner"); + + // Replacing the edited layer's Source Text is a document replacement: a new IME epoch. + set_source(&mut s, t, "replaced"); + assert_text(&s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "replacing the document changes the IME owner"); +} + +#[test] +fn undo_and_redo_of_an_unrelated_property_keep_the_ime_owner_and_selection() { + let (mut s, t) = editing("abcdef", 3); + let owner = ime_owner(&s); + + s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap(); + assert_ime_owner(&s, owner, "the opacity write keeps the owner"); + assert_eq!(sel(&s), (3, 3), "the opacity write keeps the selection"); + + // The opacity step does not change Source Text: restoring it keeps the same editor. + s.execute("edit.undo", json!({})).unwrap(); + assert_ime_owner(&s, owner, "undoing only opacity keeps the owner"); + assert_eq!(sel(&s), (3, 3), "undoing only opacity keeps the selection"); + + s.execute("edit.redo", json!({})).unwrap(); + assert_ime_owner(&s, owner, "redoing only opacity keeps the owner"); + assert_eq!(sel(&s), (3, 3), "redoing only opacity keeps the selection"); +} + +#[test] +fn a_style_only_text_change_and_its_undo_replace_the_editor_epoch() { + let (mut s, t) = editing("abcdef", 3); + let owner = ime_owner(&s); + + // The same characters with another run style are still a different document. + s.execute("layer.setText", json!({"layer": t, "range": [1, 4], "size": 12})).unwrap(); + assert_eq!(doc(&s, t).text, "abcdef"); + assert_eq!(doc(&s, t).style_at(2).size, 12.0); + assert_ime_epoch_changed(&s, owner, "a style-only change starts a new IME epoch"); + assert_eq!(sel(&s), (3, 3), "a same-length style change keeps the caret in range"); + let styled = ime_owner(&s); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(&s, t).style_at(2).size, 60.0); + assert_ime_epoch_changed(&s, styled, "undoing a style change starts a new IME epoch"); +} + +#[test] +fn history_jumps_keep_the_owner_over_unrelated_steps_and_replace_it_when_the_text_changes() { + let (mut s, t) = editing("abcdef", 3); + let owner = ime_owner(&s); + s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap(); + s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 25})).unwrap(); + let states = s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().unwrap().clone(); + let cur = states.iter().find(|n| n["current"] == true).unwrap()["index"].as_u64().unwrap(); + let parent = states.iter().find(|n| n["index"].as_u64() == Some(cur)).unwrap()["parent"].as_u64().unwrap(); + + // Both states show the same Source Text: jumping between them keeps the editor. + s.execute("edit.history.goto", json!({"index": parent})).unwrap(); + assert_ime_owner(&s, owner, "jumping to another opacity state keeps the owner"); + assert_eq!(sel(&s), (3, 3), "and the selection"); + s.execute("edit.history.goto", json!({"index": cur})).unwrap(); + assert_ime_owner(&s, owner, "jumping back keeps the owner"); + assert_eq!(sel(&s), (3, 3), "and the selection"); + + // A history state whose Source Text differs is a document replacement. + set_source(&mut s, t, "abc"); + let changed = ime_owner(&s); + assert_ne!(changed, owner); + s.execute("edit.history.goto", json!({"index": parent})).unwrap(); + assert_text(&s, t, "abcdef"); + assert_ime_epoch_changed(&s, changed, "jumping to a different document starts a new epoch"); +} + +#[test] +fn atomic_batch_rollback_keeps_the_owner_for_unrelated_steps_and_replaces_it_for_text_changes() { + let (mut s, t) = editing("abcdef", 3); + let owner = ime_owner(&s); + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 50}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_ime_owner(&s, owner, "rolling back an unrelated step keeps the owner"); + assert_eq!(sel(&s), (3, 3), "and the selection"); + + // A partially applied text replacement was still shown before the rollback restores the + // original document, so the editor must not revive the composition it had before the batch. + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "layer.setText", "params": {"layer": t, "text": "ab"}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_text(&s, t, "abcdef"); + assert_ime_epoch_changed(&s, owner, "a rolled-back text change does not revive the old owner"); + assert_eq!(sel(&s), (3, 3), "the restored document keeps the caret in range"); +} + +#[test] +fn atomic_batch_rollback_after_a_transient_editor_restart_bumps_the_epoch() { + let (mut s, t) = editing("abcdef", 3); + let owner = ime_owner(&s); + + // `text.edit` restarts the session even on the layer that is already edited. The rollback + // restores the pre-batch TextEdit (with the old token), so the intermediate restart must be + // detected from the snapshot: neither the old composition nor its token may be revived. + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "text.edit", "params": {"layer": t, "caret": 1}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_eq!(sel(&s), (3, 3), "the restored selection is the pre-batch one"); + assert_ime_epoch_changed(&s, owner, "a canceled intermediate editor must not revive the old token"); +} + +#[test] +fn atomic_batch_rollback_after_a_cross_comp_editor_switch_bumps_the_epoch() { + let (mut s, t) = session("abcdef"); + let original = s.active_comp_id().unwrap(); + // Give the other comp a layer with the same id and the same Source Text, so only the comp + // and editor-session identity distinguishes the two editors. + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone(); + let other = new_other_comp(&mut s); + let other_id = effectcraft_project::ItemId(other); + std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer); + s.execute("comp.open", json!({"comp": original.0})).unwrap(); + begin_edit_at(&mut s, t, 3); + let owner = ime_owner(&s); + + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "comp.open", "params": {"comp": other}}, + {"command": "text.edit", "params": {"layer": t, "caret": 1}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp"); + assert_eq!(sel(&s), (3, 3), "and the pre-batch selection"); + assert_ime_epoch_changed(&s, owner, "an identical layer in another comp must not revive the old token"); +} + +#[test] +fn an_atomic_batch_drops_the_events_its_failed_steps_queued() { + let (mut s, _) = session("abcdef"); + let original = s.active_comp_id().unwrap(); + // `comp.new` opens the comp it creates; go back so the batch's switch is a real one. + let other = new_other_comp(&mut s); + s.execute("comp.open", json!({"comp": original.0})).unwrap(); + s.drain_events(); + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "comp.open", "params": {"comp": other}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp"); + assert!( + !s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(id) if id.0 == other)), + "the rolled-back step's OpenComp is not queued for the UI" + ); +} + +#[test] +fn atomic_batch_rollback_after_closing_the_editor_does_not_revive_it() { + let (mut s, t) = session("abcdef"); + let original = s.active_comp_id().unwrap(); + let other = new_other_comp(&mut s); + s.execute("comp.open", json!({"comp": original.0})).unwrap(); + begin_edit_at(&mut s, t, 3); + let owner = ime_owner(&s); + + // The failing state has no editor at all: the restoration brings the pre-batch one back and + // must treat it as unverified rather than revive its composition. + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "comp.open", "params": {"comp": other}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert!(s.state.text_edit.is_some(), "the batch restored the editor state"); + assert_eq!(sel(&s), (3, 3)); + assert_ime_epoch_changed(&s, owner, "a canceled editor close must not revive the old token"); +} + +#[test] +fn restoring_without_an_editor_snapshot_replaces_an_open_editor() { + let (mut s, _) = editing("abcdef", 3); + let owner = ime_owner(&s); + + // `None` means no editor was open when the state was replaced; an editor that is open + // afterwards cannot prove it kept its document. + s.sanitize_after_replacement(None); + assert_ime_epoch_changed(&s, owner, "an unverified restored editor starts a new epoch"); + assert_eq!(sel(&s), (3, 3)); +} + +#[test] +fn undo_to_a_shorter_document_clamps_the_caret() { + let (mut s, t) = editing("abc", 3); + replace_text(&mut s, t, "abcdef"); + select_anchor(&mut s, 6, 6); + let owner = ime_owner(&s); + assert_eq!(sel(&s), (6, 6)); + + s.execute("edit.undo", json!({})).unwrap(); + assert_text(&s, t, "abc"); + assert_eq!(sel(&s), (3, 3), "undoing to a shorter document clamps the caret"); + assert_ime_epoch_changed(&s, owner, "and starts a new epoch"); +} + +#[test] +fn the_ime_stamp_revision_follows_the_editor_not_unrelated_project_edits() { + let (mut s, t) = editing("abcdef", 6); + let revision = ime_stamp_revision(&s); + + s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap(); + assert_eq!(ime_stamp_revision(&s), revision, "an unrelated property write does not advance it"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ime_stamp_revision(&s), revision, "undoing it does not either"); + + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_eq!(ime_stamp_revision(&s), revision, "a rolled-back unrelated batch restores it"); + + s.execute("text.insert", json!({"text": "x"})).unwrap(); + assert!(ime_stamp_revision(&s) > revision, "typing advances it"); + let typed = ime_stamp_revision(&s); + s.execute("layer.setText", json!({"layer": t, "text": "y"})).unwrap(); + assert!(ime_stamp_revision(&s) > typed, "a replaced document advances it too"); +} + +#[test] +fn a_failed_atomic_batch_with_a_transient_edit_keeps_its_high_water_ime_revision() { + let (mut s, t) = editing("abcdef", 6); + let revision = ime_stamp_revision(&s); + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "text.insert", "params": {"text": "x"}}, + {"command": "text.delete", "params": {}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_text(&s, t, "abcdef"); + // The intermediate document edit is real even though the final document equals the + // pre-batch one: its stamp must remain the high-water mark. + assert!(ime_stamp_revision(&s) > revision, "a transient edit must not rewind the IME revision"); + let after_transient = ime_stamp_revision(&s); + + // An unrelated failed batch really did not edit the document: it must preserve the revision + // exactly, or every wide-selection composition would be canceled by unrelated work. + let r = s.execute( + "engine.batch", + json!({"atomic": true, "steps": [ + {"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(r.is_err()); + assert_eq!(ime_stamp_revision(&s), after_transient, "an unrelated rolled-back batch preserves the revision"); +} + +#[test] +fn a_replacing_property_write_clamps_the_selection_so_the_next_backspace_deletes() { + let (mut s, t) = editing("abcdef", 6); + let owner = ime_owner(&s); + + set_source(&mut s, t, "a"); + assert_text(&s, t, "a"); + assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range"); + assert_ne!(ime_owner(&s), owner); + + // Backspace at the clamped caret removes the character instead of no-opping on 5..6. + s.execute("text.delete", json!({})).unwrap(); + assert_text(&s, t, ""); + assert_eq!(sel(&s), (0, 0)); +} + +#[test] +fn keyframe_writes_on_source_text_replace_the_edited_document_only_when_the_cti_value_changes() { + let (mut s, t) = editing("abc", 1); + // Keying the text the CTI already shows leaves the document equal. + animate_source(&mut s, t); + let owner = ime_owner(&s); + + set_source_cti_key(&mut s, t, 0.0, "abc"); + assert_ime_owner(&s, owner, "a no-op key write keeps the IME owner"); + + // `keys.set` changes the key at the CTI: a document replacement. + set_source_cti_key(&mut s, t, 0.0, "replaced"); + assert_text(&s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "a changed keyframe changes the IME owner"); +} + +#[test] +fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_noop() { + let (mut s, t) = editing("abc", 1); + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "later"); + let owner = ime_owner(&s); + let lid = effectcraft_project::LayerId(t); + let pr = s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap(); + let uid = pr.uid; + let times: Vec<_> = pr.keys.iter().map(|k| k.time).collect(); + assert_eq!(times.len(), 2); + + // Deleting the key at the CTI (0s) while the 1s key remains shows the later text: replacing + // the document starts a new epoch. + s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[0] }]; + assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1)); + assert_text(&s, t, "later"); + let replaced = ime_owner(&s); + assert_ne!(replaced, owner, "deleting the CTI key changed the document"); + + // Removing the last key turns its value into the static CTI value. That value is what the + // CTI already showed, so the document does not change and the new owner stays. + s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[1] }]; + assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1)); + assert_text(&s, t, "later"); + assert_ime_owner(&s, replaced, "removing the last key is a document no-op"); +} + +#[test] +fn moving_the_cti_to_a_shorter_animated_key_clamps_and_starts_a_new_epoch() { + let (mut s, t) = session("abcdef"); + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "a"); + begin_edit_at(&mut s, t, 6); + let owner = ime_owner(&s); + + // The same time still shows the same document: no new epoch. + s.execute("time.set", json!({"time": 0.0})).unwrap(); + assert_text(&s, t, "abcdef"); + assert_ime_owner(&s, owner, "an unchanged CTI document keeps the IME owner"); + + // The key at 1s holds shorter text: the document changes, the selection is clamped and the + // editor gets a new epoch. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + assert_text(&s, t, "a"); + assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range"); + let replaced = ime_owner(&s); + assert_ne!(replaced, owner, "a changed CTI document starts a new IME epoch"); + + // Backspace at the clamped caret removes the character instead of no-opping on 5..6. + s.execute("text.delete", json!({})).unwrap(); + assert_text(&s, t, ""); + assert_eq!(sel(&s), (0, 0)); + assert_ime_owner(&s, replaced, "typing after the replacement keeps its owner"); + + // Staying at the same time after the edit is a no-op for the document too. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + assert_text(&s, t, ""); + assert_ime_owner(&s, replaced, "a no-op time move keeps the IME owner"); +} + +#[test] +fn sync_related_times_replaces_the_edited_document_when_it_moves_the_active_comp() { + let (mut s, t) = session("abcdef"); + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "a"); + let outer = s.active_comp_id().unwrap(); + let r = s.execute("layer.precompose", json!({"layers": [t], "name": "Inner", "open": true})).unwrap(); + let inner = effectcraft_project::ItemId(r["comp"].as_u64().unwrap()); + assert_eq!(s.active_comp_id(), Some(inner)); + + begin_edit_at(&mut s, t, 6); + let owner = ime_owner(&s); + s.sync_related_times(outer, effectcraft_time::Tick::from_seconds_f64(1.0)); + + assert_text(&s, t, "a"); + assert_eq!(sel(&s), (1, 1), "the related time move clamps the selection"); + assert_ime_epoch_changed(&s, owner, "the related time move changed the document"); +} + +#[test] +fn applying_a_preset_that_writes_source_text_replaces_the_edited_document() { + let (mut s, t) = editing("abc", 1); + let owner = ime_owner(&s); + let preset = json!({ + "effectcraftAnimationPreset": 1, + "properties": [{"path": "text/sourceText", "value": {"t": "Text", "v": {"text": "replaced"}}, "keys": []}], + }); + s.execute("anim.applyPreset", json!({"preset": preset, "layers": [t]})).unwrap(); + assert_text(&s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "a preset that replaces source text changes the IME owner"); +} + +#[test] +fn a_failed_property_write_keeps_the_edited_document_and_its_owner() { + let (mut s, t) = editing("abc", 1); + let owner = ime_owner(&s); + let steps = undo_steps(&s); + + // null cannot coerce to a Text value: the edit fails before anything is committed. + assert!(s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": null})).is_err()); + assert_text(&s, t, "abc"); + assert_ime_owner(&s, owner, "a failed write keeps the IME owner"); + assert_eq!(undo_steps(&s), steps, "a failed write records no undo step"); +} + +#[test] +fn property_writes_off_the_cti_keep_the_owner_until_they_change_the_cti_document() { + let (mut s, t) = editing("abc", 1); + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "later"); + let owner = ime_owner(&s); + + // Writing a key at 1s leaves the CTI (0s) value as it was. + set_source_key(&mut s, t, 1.0, "changed"); + assert_text(&s, t, "abc"); + assert_ime_owner(&s, owner, "an off-CTI write keeps the IME owner"); + + // Writing the CTI value itself replaces the document. + set_source(&mut s, t, "cti"); + assert_text(&s, t, "cti"); + assert_ime_epoch_changed(&s, owner, "a CTI write changes the IME owner"); +} + +#[test] +fn unrelated_project_cleanup_keeps_the_ime_owner_but_replacement_changes_it() { + let (mut s, t) = editing("abc", 1); + let owner = ime_owner(&s); + + // file.removeUnusedFootage calls sanitize_state while the edited document is untouched. + s.execute("file.importSolid", json!({"name": "Unused"})).unwrap(); + let removed = s.execute("file.removeUnusedFootage", json!({})).unwrap(); + assert_eq!(removed["removed"], json!(1), "the unused solid was actually removed"); + assert_ime_owner(&s, owner, "unrelated cleanup keeps the IME owner"); + + // Replacing the Source Text is a document replacement: a new owner. + replace_text(&mut s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "replacing the document changes the owner"); +} + +#[test] +fn ime_session_does_not_cross_a_composition_switch() { + let (mut s, _) = editing_all("abc"); + let other = new_other_comp(&mut s); + s.execute("comp.open", json!({"comp": other})).unwrap(); + assert!(s.state.text_edit.is_none(), "the old comp's editor is not reused in another comp"); +} + +#[test] +fn removing_the_active_comp_does_not_retarget_its_text_editor() { + for replacement in [false, true] { + let (mut s, t) = session("original"); + let original = s.active_comp_id().unwrap(); + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone(); + let other = new_other_comp(&mut s); + let other_id = effectcraft_project::ItemId(other); + std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer); + s.execute("comp.open", json!({"comp": other})).unwrap(); + s.execute("comp.open", json!({"comp": original.0})).unwrap(); + begin_edit_at(&mut s, t, 1); + assert!(s.state.text_edit.is_some()); + + // Reproduce cleanup after a project replacement, bypassing Session::edit's earlier + // document guard so this specifically exercises sanitize_state's fallback-comp branch. + let edited = crate::commands::text_edit::active_editor_snapshot(&s); + std::sync::Arc::make_mut(&mut s.project).items.remove(&original); + if replacement { + s.sanitize_after_replacement(edited.as_ref()); + } else { + s.sanitize_state(); + } + assert_eq!(s.active_comp_id(), Some(other_id)); + assert!(s.state.text_edit.is_none(), "the same-numbered layer belongs to a different comp"); + assert!(s.execute("text.insert", json!({"text": "wrong document"})).is_err()); + assert_eq!(doc(&s, t).text, "original", "the fallback comp was not edited"); + } } #[test] @@ -260,26 +941,32 @@ fn fonts_lists_bundled_and_installed_families() { assert_eq!(crate::font_styles("No Such Family").len(), 5); } -/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at -/// 400 (so the helpers that look for an installed font with a real axis range never pick it). -fn pinned_variable_font(family: &str) -> Vec { - let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; - let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect(); - let mut name = u16s(&[0, 2, 30]); +/// A big-endian `u16` array, as sfnt tables store them. +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A `name` table with one Windows/Unicode English record per `(id, text)`. +fn name_table(strings: &[(u16, String)]) -> Vec { + let enc: Vec<(u16, Vec)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect(); + let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]); let mut off = 0; - for (id, st) in strings.iter().enumerate() { - name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off])); + for (id, st) in &enc { + name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off])); off += st.len() as u16; } - strings.iter().for_each(|st| name.extend(st)); - let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]); - fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256]))); - let mut out = u16s(&[1, 0, 2, 0, 0, 0]); + enc.iter().for_each(|(_, st)| name.extend(st)); + name +} + +/// A single-face font with `tables` in order (zero checksums, offsets after the directory). +fn sfnt(tables: &[(&[u8; 4], Vec)]) -> Vec { + let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]); let mut body: Vec = vec![]; - for (tag, data) in [(b"fvar", fvar), (b"name", name)] { - out.extend(tag); + for (tag, data) in tables { + out.extend(*tag); out.extend([0u8; 4]); - out.extend(((12 + 32 + body.len()) as u32).to_be_bytes()); + out.extend(((12 + 16 * tables.len() + body.len()) as u32).to_be_bytes()); out.extend((data.len() as u32).to_be_bytes()); body.extend(data); } @@ -287,6 +974,15 @@ fn pinned_variable_font(family: &str) -> Vec { out } +/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at +/// 400 (so the helpers that look for an installed font with a real axis range never pick it). +fn pinned_variable_font(family: &str) -> Vec { + let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]); + fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256]))); + let name = name_table(&[(1, family.to_owned()), (2, "Regular".to_owned())]); + sfnt(&[(b"fvar", fvar), (b"name", name)]) +} + /// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}` /// lists only families with variation axes, every listing says which families are variable, and /// the font menu's rows carry the flag the filter uses. @@ -317,6 +1013,47 @@ fn fonts_filter_variable_families() { assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable)); } +/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style +/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name +/// `-{rects}"#).into_bytes() +} + +#[test] +fn comma_selector_lists_share_one_declaration_block() { + // Before #2140 every selector of a comma list got its own copy of the declaration block, so + // `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused. + let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(","); + let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("selectors"), "{e}"); +} + +#[test] +fn a_style_sheet_with_too_many_declarations_errors() { + let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("rect{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("declarations"), "{e}"); +} + +#[test] +fn selector_lists_keep_the_css_cascade() { + let green = Some(Paint::Color([0.0, 1.0, 0.0])); + let blue = Some(Paint::Color([0.0, 0.0, 1.0])); + let red = Some(Paint::Color([1.0, 0.0, 0.0])); + let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }"; + let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]); + let doc = parse(&svg).unwrap(); + let shapes = doc.flatten(); + let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect(); + // Type selector; class beats type; id beats class; inline style beats every rule; a later, + // more specific rule beats the presentation attribute. + assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]); + assert!(shapes[0].2.stroke.is_none()); + // The second list's block reaches both of its selectors. + for i in [1, 5] { + assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}"); + } +} + +#[test] +fn a_comma_list_stores_its_declarations_once() { + let sels = vec!["rect"; 1000].join(","); + let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect(); + let mut sheet = StyleSheet::default(); + parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 1000); + assert_eq!(sheet.blocks.len(), 1); + assert_eq!(sheet.decls, 50); +} + +#[test] +fn rules_of_unsupported_selectors_store_no_blocks() { + let mut sheet = StyleSheet::default(); + parse_css(&"a b{}".repeat(100_000), &mut sheet).unwrap(); + parse_css("a b{fill:red} a>b, rect{fill:blue} c:hover, d{fill:red}", &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 2); + assert_eq!(sheet.blocks.len(), 2); + assert!(sheet.rules.iter().all(|r| sheet.blocks.get(r.block).is_some_and(|b| b.len() == 1))); +} + +#[test] +fn huge_class_lists_count_against_the_matching_budget() { + // One rule, but a 1 MB class attribute re-split on each of 100 `` copies (#2140). + let class = "a ".repeat(500_000); + let uses: String = (0..100).map(|i| format!(r##""##)).collect(); + let svg = |uses: &str| { + format!( + r##"{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("