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/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..2095acad7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev libasound2-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index f8ac8a300..8967f9431 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -2,12 +2,10 @@ # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. name: FreeBSD -# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push -# or dependency change: a drifted dependency still shows up within a day, and always before a -# release ships. PRs that change the FreeBSD packaging itself still run it. +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: - schedule: - - cron: "47 2 * * *" push: branches: [release] pull_request: @@ -15,6 +13,8 @@ on: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "47 2 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 808e1628d..3cfbdc8b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,15 +10,14 @@ on: type: string default: "" -permissions: - contents: write - concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: release: + permissions: + contents: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..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/CLAUDE.md b/CLAUDE.md index a7c47fe3f..e52386942 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e - **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`). - **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32. - **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`). - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id. - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde. diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..f99e09a06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,6 +803,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" @@ -976,7 +985,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "hayro-syntax", "thiserror 2.0.21", @@ -1163,7 +1172,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.12.0" +version = "0.16.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1188,7 +1197,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1209,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.12.0" +version = "0.16.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.12.0" +version = "0.16.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1232,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.12.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1231,8 +1240,9 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "chrono", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", @@ -1248,8 +1258,9 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "chrono", "craft-docpath", "effectcraft-color", "effectcraft-effects", @@ -1282,7 +1293,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1323,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.12.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1340,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.12.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1348,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.12.0" +version = "0.16.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1359,14 +1370,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.12.0" +version = "0.16.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1402,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1421,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1433,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1451,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,7 +1480,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "image", @@ -1481,14 +1492,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.12.0" +version = "0.16.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1511,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1516,7 +1527,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1539,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1552,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.12.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1568,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1590,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.12.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1591,12 +1602,13 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "serde_json", + "socket2", "web-time", ] [[package]] name = "effectcraft-segment" -version = "0.12.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1605,7 +1617,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1629,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1641,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.12.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,8 +1661,9 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "chrono", "craft-ui", "effectcraft-color", "effectcraft-engine", @@ -1681,11 +1694,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.12.0" +version = "0.16.0" [[package]] name = "effectcraft-web" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -2713,8 +2726,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "hayro-ccitt", ] @@ -2728,7 +2740,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -4815,6 +4827,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" @@ -6625,9 +6647,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "chrono", "serde_json", + "tempfile", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7af58f009..a3935f047 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,14 +4,14 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.12.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/storytold/effectcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] } +craft-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" } @@ -52,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" @@ -137,9 +139,12 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +# Panics and backtraces keep file and line; full debug info only made the many test binaries large. +debug = "line-tables-only" # Pixel hot loops: keep debug builds interactive. [profile.dev.package.effectcraft-raster] @@ -211,4 +216,5 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" } diff --git a/README.md b/README.md index 914a40907..5fa28f0bb 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,18 @@

+

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

+

EffectCraft

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

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

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

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

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index 1887d7b82..f4c99d61f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had +Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had native-speaker review. ## Upcoming @@ -135,6 +135,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. +- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index c7a012c76..6e5b8d6cc 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -6,6 +6,7 @@ //! effectcraft-cli exec [--params JSON] run one command //! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order +//! effectcraft-cli run --args-file F the same, one argument per line of F //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property //! effectcraft-cli set [--time S] [--expression E] @@ -96,6 +97,7 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order + run --args-file F the same, one argument per line of F (no command-line length limit) props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property set [--time S] [--expression E] @@ -148,6 +150,7 @@ const VALUED: &[&str] = &[ "--expression", "--depth", "--eval", + "--args-file", // render "--template", "--values", @@ -515,10 +518,15 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { emit(&with_saved(r, saved), json_out); } "run" => { - if args.pos.is_empty() { + let mut pairs = args.pos.clone(); + // Windows caps a command line at 32 767 characters: long batches come from a file. + if let Some(f) = args.opt("--args-file") { + let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?; + pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from)); + } + if pairs.is_empty() { return usage_err("run needs [] pairs"); } - let pairs = args.pos.clone(); let mut b = backend(args, true)?; let mut results = vec![]; let mut i = 0; diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index 844c978b2..8cf5c124f 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -150,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map, import } let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?; let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?; - // Keep a partial final frame: layer.addItem snaps its default end to the frame grid. + // Keep a partial final frame: layer.addItem snaps its default end to the frame grid. Give the + // wrapper the source's exact name, not Duplicate's numbered one, for `[compName]` outputs. s.edit("Template Duration", None, |project, _| { + let name = project.item(ItemId(source)).ok_or_else(|| EngineError::Other("template composition is missing".into()))?.name.clone(); + project.item_mut(ItemId(parent)).ok_or_else(|| EngineError::Other("template render composition is missing".into()))?.name = name; let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?; let duration = comp.duration; let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?; diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs similarity index 96% rename from apps/effectcraft-cli/tests/cli.rs rename to apps/effectcraft-cli/tests/it/cli.rs index 1111db403..538887d9b 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -110,6 +110,29 @@ fn exec_set_get_with_saved_project() { assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); } +/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer +/// than a Windows command line (32 767 characters, os error 206) still runs. +#[test] +fn run_reads_arguments_from_a_file() { + let proj = tmp("args-file.ecproj"); + let p = proj.to_str().unwrap(); + let mut lines = vec!["comp.new".to_string(), json!({"name": "A", "width": 64, "height": 64}).to_string()]; + for i in 0..400 { + lines.push("layer.newSolid".into()); + lines.push(json!({"width": 8, "height": 8, "name": format!("Solid with a long name to pass the limit {i:04}")}).to_string()); + lines.push(String::new()); + } + let text = lines.join("\n"); + assert!(text.len() > 32_767); + let file = tmp("args-file.txt"); + std::fs::write(&file, text).unwrap(); + let v = ok_json(&["run", "--args-file", file.to_str().unwrap(), "--empty", "--save-as", p]); + assert_eq!(v["result"].as_array().map(Vec::len), Some(401), "{v}"); + let out = bin().args(["run", "--args-file", "no/such/file.txt", "--empty"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt")); +} + #[test] fn render_frame_writes_png() { let out = tmp("f.png"); diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..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 94% rename from apps/effectcraft-cli/tests/qa_template.rs rename to apps/effectcraft-cli/tests/it/qa_template.rs index 8b877755b..0618c0d63 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/it/qa_template.rs @@ -767,3 +767,45 @@ fn template_media_control_reports_a_path_that_cannot_be_imported() { assert!(!dir.join("missing-out.png").exists(), "{reply}"); let _ = std::fs::remove_dir_all(&dir); } + +/// Renders with `--template` name their outputs after the template's composition (#1937), not +/// the numbered name a duplicate would get. The name ends in a number and differs from the +/// archive's file name; an expression that looks the composition up by that name still finds it. +#[test] +fn template_render_outputs_keep_the_template_composition_name() { + let dir = std::env::temp_dir().join(format!("ec-qa-template-comp-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let build = BUILD.replace(r#"addComp("Lower Third""#, r#"addComp("Title Card 2""#).replace( + "var fill =", + "bar.property(\"ADBE Transform Group\").property(\"ADBE Position\").expression = '[comp(\"Title Card 2\").numLayers * 80, 65]';\nvar fill =", + ); + std::fs::write(dir.join("build.jsx"), build).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Title Card 2","path":"card.ectemplate"}"#, "t.ecproj"]); + let template_before = std::fs::read(dir.join("card.ectemplate")).unwrap(); + std::fs::write(dir.join("values.json"), r#"{"Title":"John Smith"}"#).unwrap(); + + for (case, values) in [("defaults", None), ("values", Some("values.json"))] { + let out = dir.join(case).join("[compName]_[#####].png"); + let mut args = + vec!["render", "--template", "card.ectemplate", "--out", out.to_str().unwrap(), "--format", "png", "--start", "1", "--end", "1.0833333333333333"]; + if let Some(values) = values { + args.extend(["--values", values]); + } + let rendered = ok(&dir, &args); + let rendered = rendered["rendered"].as_array().unwrap(); + assert_eq!(rendered.len(), 1, "{case}: {rendered:?}"); + assert_eq!(rendered[0]["comp"], "Title Card 2", "{case}: {rendered:?}"); + let frames: Vec<_> = std::fs::read_dir(dir.join(case)).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(frames.len(), 1, "{case}: {frames:?}"); + assert!(frames[0].starts_with("Title Card 2_") && frames[0].ends_with(".png"), "{case}: {frames:?}"); + // The template's own two layers place the bar; the one-layer wrapper would move it left. + let frame = image::open(dir.join(case).join(&frames[0])).unwrap().to_rgba8(); + let bar = frame.get_pixel(300, 75).0; + assert!(bar[0] > 240 && bar[1] < 15, "{case}: comp(\"Title Card 2\") is the template's composition: {bar:?}"); + } + assert_eq!(std::fs::read(dir.join("card.ectemplate")).unwrap(), template_before); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 6c30f9a56..28ea2f369 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [ "HtmlElement", "HtmlInputElement", "Location", + "Navigator", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 5c438550e..12cb90bfa 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -494,8 +494,12 @@ export async function cacheRead(name) { /// resolves with its size. export async function cacheWrite(fullName, bytes) { const [dir, name] = await entryDir(fullName); - const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype; - const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name; + const canMove = + typeof FileSystemHandle !== "undefined" && + "move" in FileSystemHandle.prototype && + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function"; + const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name; const fh = await dir.getFileHandle(tmp, { create: true }); if (fh.createSyncAccessHandle) { const h = await fh.createSyncAccessHandle(); diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..3f462bd2a 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -184,6 +184,11 @@ 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"); @@ -257,6 +262,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/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/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/src/main.rs b/apps/effectcraft/src/main.rs index a1fb6e4a5..2bc3265dd 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -371,15 +371,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 { @@ -539,6 +546,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() { diff --git a/contributors/people.toml b/contributors/people.toml index 625fcdb73..1012a37bd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -95,3 +95,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.ki2pixel] +display_name = "ki2pixel" diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md index 6ced20ba1..c922c95eb 100644 --- a/crates/av1enc/README.md +++ b/crates/av1enc/README.md @@ -4,8 +4,8 @@ Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0, no `unsafe`, builds for `wasm32-unknown-unknown`). Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in -`tests/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived -`filmcraft-av1` decoder (`tests/roundtrip.rs`): every decoded frame equals the encoder's own +`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived +`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own reconstruction, so prediction never drifts. ## What is implemented diff --git a/crates/av1enc/tests/bench.rs b/crates/av1enc/tests/it/bench.rs similarity index 97% rename from crates/av1enc/tests/bench.rs rename to crates/av1enc/tests/it/bench.rs index 6577f8f9f..b29817e1a 100644 --- a/crates/av1enc/tests/bench.rs +++ b/crates/av1enc/tests/it/bench.rs @@ -1,7 +1,7 @@ //! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default): -//! `cargo test -p effectcraft-av1enc --test bench -- --ignored --nocapture`. +//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`. -mod common; +use crate::common; use std::process::Command; use std::time::Instant; diff --git a/crates/av1enc/tests/common/mod.rs b/crates/av1enc/tests/it/common/mod.rs similarity index 100% rename from crates/av1enc/tests/common/mod.rs rename to crates/av1enc/tests/it/common/mod.rs diff --git a/crates/av1enc/tests/ffmpeg_oracle.rs b/crates/av1enc/tests/it/ffmpeg_oracle.rs similarity index 99% rename from crates/av1enc/tests/ffmpeg_oracle.rs rename to crates/av1enc/tests/it/ffmpeg_oracle.rs index 66f380ac2..b35cbf496 100644 --- a/crates/av1enc/tests/ffmpeg_oracle.rs +++ b/crates/av1enc/tests/it/ffmpeg_oracle.rs @@ -2,7 +2,7 @@ //! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1` //! decoder only drives hardware decoders, so it is not used. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/av1enc/tests/it/main.rs b/crates/av1enc/tests/it/main.rs new file mode 100644 index 000000000..c66bacde5 --- /dev/null +++ b/crates/av1enc/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod bench; +mod common; +mod ffmpeg_oracle; +mod roundtrip; diff --git a/crates/av1enc/tests/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs similarity index 99% rename from crates/av1enc/tests/roundtrip.rs rename to crates/av1enc/tests/it/roundtrip.rs index 1e777e509..282880e08 100644 --- a/crates/av1enc/tests/roundtrip.rs +++ b/crates/av1enc/tests/it/roundtrip.rs @@ -1,7 +1,7 @@ //! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the //! encoder's own reconstruction bit for bit. -mod common; +use crate::common; use common::*; use effectcraft_av1enc::{EncoderConfig, RateControl}; diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 1fae0ea90..2a0dfc50e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -39,7 +39,13 @@ impl Rgba { /// `#rrggbb` or `#rrggbbaa`. pub fn from_hex(s: &str) -> Option { let s = s.trim().trim_start_matches('#'); - let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok(); + let p = |i: usize| { + let pair = s.get(i..i + 2)?; + if !pair.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u8::from_str_radix(pair, 16).ok() + }; match s.len() { 6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)), 8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }), @@ -93,7 +99,7 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { let d = max - min; let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; let h = if max == r { - ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0 + (((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0) } else if max == g { ((b - r) / d + 2.0) / 6.0 } else { @@ -130,7 +136,7 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { let h = if d < 1e-7 { 0.0 } else if max == r { - ((g - b) / d).rem_euclid(6.0) / 6.0 + (((g - b) / d).rem_euclid(6.0) / 6.0).rem_euclid(1.0) } else if max == g { ((b - r) / d + 2.0) / 6.0 } else { @@ -141,7 +147,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { } pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { - let h6 = h.rem_euclid(1.0) * 6.0; + let mut h6 = h.rem_euclid(1.0) * 6.0; + // In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0. + if h6 >= 6.0 { + h6 = 0.0; + } let i = h6.floor(); let f = h6 - i; let p = v * (1.0 - s); @@ -252,6 +262,28 @@ impl Label { mod tests { use super::*; + #[test] + fn hsv_to_rgb_tiny_negative_hue_is_red() { + let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0); + assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}"); + } + + #[test] + fn hue_stays_below_one_for_tiny_blue() { + let (h, _, _) = rgb_to_hsv(1.0, 0.0, 1e-9); + assert!((0.0..1.0).contains(&h), "rgb_to_hsv hue {h} not in 0..1"); + let (h, _, _) = rgb_to_hsl(1.0, 0.0, 1e-9); + assert!((0.0..1.0).contains(&h), "rgb_to_hsl hue {h} not in 0..1"); + } + + #[test] + fn rejects_signed_hex_pairs() { + assert!(Rgba::from_hex("#+1+2+3").is_none()); + assert!(Rgba::from_hex("#1+2+3+").is_none()); + assert!(Rgba::from_hex("#ff00+0ff").is_none()); + assert!(Rgba::from_hex("#a1B2c3").is_some()); + } + #[test] fn srgb_roundtrip() { for i in 0..=100 { diff --git a/crates/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/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/color_fx.rs b/crates/effects/src/color_fx.rs index cf6698fee..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 @@ -333,6 +379,37 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted /// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright /// and black blocks (#1463), and 32-bpc overbrights divided by zero. +/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a +/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled +/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid +/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), +/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. +/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; + +pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] { + let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2]; + if !gray.is_finite() || gray <= 0.0 { + return [0.0; 3]; + } + if gray >= 1.0 { + return [gray; 3]; + } + let d = gray - lum(tint); + let c = tint.map(|v| v + d); + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut o = c; + if n < 0.0 && l - n > 1e-9 { + o = o.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 && x - l > 1e-9 { + o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + o +} + fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")] .map(|v| v as f32 / 100.0); @@ -347,13 +424,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f; let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); let gray = (min + (max - min) * wt).max(0.0); - if tint_on { - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); - let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0)); - [r, g, bl] - } else { - [gray, gray, gray] - } + if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] } }); b } @@ -418,15 +489,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { }); return b; } + // Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After + // Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with + // nothing of the original showing through (it is not a mix with the original). b.img.data.iter_mut().for_each(|px| { - let a = if invert { 1.0 - px[3] } else { px[3] }; - let f = [c[0] * a, c[1] * a, c[2] * a]; - for i in 0..3 { - px[i] += (f[i] - px[i]) * opacity; - } - if invert { - px[3] += (a - px[3]) * opacity; - } + let a = if invert { 1.0 - px[3] } else { px[3] } * opacity; + *px = [c[0] * a, c[1] * a, c[2] * a, a]; }); b } @@ -969,11 +1037,13 @@ pub fn specs() -> Vec { "Exposure", { let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))]; + // After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from + // older projects or Photoshop imports (0.01..9.99) are kept and still render. let group = |ids: [&'static str; 3]| { [ - p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)), p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)), - p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)), + p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)), ] }; v.extend(group(["master/exposure", "master/offset", "master/gamma"])); @@ -1173,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. @@ -1227,6 +1308,75 @@ 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). @@ -1321,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]; @@ -1462,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/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/generate.rs b/crates/effects/src/generate.rs index fd5a54123..637cc428e 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px { } } +/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point +/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100 +/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error +/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0. +pub fn four_color_softening(blend: f64, scale: f64) -> f64 { + let b = blend.max(0.0) * scale; + (b * b).max(1e-6) +} + fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect(); - let blend = ctx.params.f("blend").max(1.0); + let k = four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); @@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let mut wsum = 0.0; for ((qx, qy), c) in &pts { let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2); - let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5); + let w = 1.0 / (d2 + k); for i in 0..3 { acc[i] += c[i] as f64 * w; } @@ -423,6 +432,59 @@ mod generator_tests { } } + /// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 % + /// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10 + /// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5). + #[test] + fn four_color_blend_matches_after_effects() { + let pts = [ + ("positionsColors/point1", [19.2, 10.8]), + ("positionsColors/point2", [172.8, 10.8]), + ("positionsColors/point3", [19.2, 97.2]), + ("positionsColors/point4", [172.8, 97.2]), + ]; + let cols = [ + ("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]), + ("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]), + ("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]), + ("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]), + ]; + let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [ + ( + 10.0, + [ + (30, 20, [249.0, 240.0, 15.0]), + (150, 30, [20.0, 208.0, 47.0]), + (60, 90, [213.0, 56.0, 199.0]), + (100, 10, [115.0, 177.0, 78.0]), + (10, 50, [238.0, 145.0, 110.0]), + ], + ), + ( + 30.0, + [ + (30, 20, [236.0, 214.0, 41.0]), + (150, 30, [30.0, 189.0, 66.0]), + (60, 90, [203.0, 67.0, 188.0]), + (100, 10, [117.0, 173.0, 82.0]), + (10, 50, [233.0, 140.0, 115.0]), + ], + ), + ]; + for (blend, samples) in ae { + let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect(); + vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v)))); + vals.push(("blend", num(blend))); + let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()); + for (x, y, want) in samples { + let p = o.img.get(x, y); + for c in 0..3 { + assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]); + } + } + } + } + #[test] fn four_color_none_stays_inside_the_layer_alpha() { // #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box. diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 9d61ff8c8..4953d482c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -2,12 +2,13 @@ //! matte clean-up. Written from public behaviour descriptions and the standard colour-difference / //! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting"). -use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear}; +use effectcraft_color::{rgb_to_hsl, srgb_to_linear}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::{Image, gaussian_blur}; use rayon::prelude::*; +use crate::color_fx::luma_601; use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -135,46 +136,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Luma Key ---- +/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and +/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar +/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather +/// then soften the cut. fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let kind = ctx.params.e("keyType"); let thr = ctx.params.f("threshold") as f32 / 255.0; let tol = ctx.params.f("tolerance") as f32 / 255.0; let m = matte_of(&b.img, |c, _| { - let l = luminance(c[0], c[1], c[2]); - match kind { - 0 => { - if tol > 0.0 { - ((thr + tol - l) / tol).clamp(0.0, 1.0) - } else if l > thr { - 0.0 - } else { - 1.0 - } - } - 2 => { - if (l - thr).abs() <= tol { - 0.0 - } else { - 1.0 - } - } - 3 => { - if (l - thr).abs() <= tol { - 1.0 - } else { - 0.0 - } - } - _ => { - if tol > 0.0 { - ((l - (thr - tol)) / tol).clamp(0.0, 1.0) - } else if l < thr { - 0.0 - } else { - 1.0 - } - } - } + let l = luma_601([c[0], c[1], c[2]]); + let keep = match kind { + 0 => l <= thr, + 2 => (l - thr).abs() > tol, + 3 => (l - thr).abs() <= tol, + _ => l >= thr, + }; + if keep { 1.0 } else { 0.0 } }); let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale); apply_matte(&mut b.img, &m, 0); @@ -334,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf { 2 => c[1], 3 => c[2], 4 => a, - _ => luminance(c[0], c[1], c[2]), + _ => luma_601([c[0], c[1], c[2]]), }; let mut k = 1.0f32; if v < bp { @@ -802,6 +780,68 @@ mod tests { assert_eq!(out.get(1, 0)[3], 1.0); } + /// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y + /// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the + /// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it + /// out). + #[test] + fn luma_key_matches_after_effects() { + let ramp = || { + let mut img = Image::new(1, 1080); + for y in 0..1080 { + let v = (y as f32 + 0.5) / 1080.0; + img.set(0, y, [v, v, v, 1.0]); + } + img + }; + let key = |kind: u32, thin: f64, feather: f64| { + let set = + [("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))]; + let o = run("ec.key.luma", &set, ramp()); + (0..1080).map(|y| o.get(0, y)[3]).collect::>() + }; + // Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593. + let d = key(1, 0.0, 0.0); + assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]); + // Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the + // rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way. + let s = key(2, 0.0, 0.0); + assert!( + (0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)), + "{:?} {:?}", + &s[505..512], + &s[674..681] + ); + // Key Out Brighter and Dissimilar are the complements. + let b = key(0, 0.0, 0.0); + assert!(b[595] == 0.0 && b[590] == 1.0); + let ds = key(3, 0.0, 0.0); + assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0); + // A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719). + let o = + run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0])); + assert_eq!(o.get(0, 0)[3], 0.0); + // Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596. + let f = key(1, 1.0, 2.5); + for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] { + assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]); + } + } + + /// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure + /// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709 + /// would key out red and blue and keep green). + #[test] + fn luma_key_and_extract_use_rec601_as_after_effects() { + for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] { + let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]); + let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone()); + let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img); + let want = if kept { 1.0 } else { 0.0 }; + assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}"); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..cc0aad515 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -21,6 +21,7 @@ mod color2; mod color3; mod color_fx; pub mod controls; +pub mod crypto_select; mod distort; mod distort2; mod distort3; @@ -70,11 +71,11 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; -pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color_fx::{ - HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, - levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, + BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, + fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; +pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; pub use distort::transform_shutter; @@ -90,8 +91,8 @@ pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). -pub use blur2::{camera_lens_plain, camera_lens_spans}; -pub use generate::gen_mode; +pub use blur2::{camera_lens_plain, camera_lens_spans, radial_fast_pad}; +pub use generate::{four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ @@ -110,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ @@ -297,6 +298,43 @@ impl Buf { } } +/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: +/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its +/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp +/// origin in the buffer below (After Effects; a Transform after it brings the tiles into view, +/// #1727). An adjustment at 100 % keeps the buffer it was given; one whose frame would pass +/// 16 384 pixels a side does too. +pub fn motion_tile_frame(ctx: &EffectCtx, offset: [f64; 2], size: (u32, u32), scale: f64) -> (u32, u32, [f64; 2]) { + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let (pw, ph) = (ctx.params.f("outputWidth"), ctx.params.f("outputHeight")); + let ow = (lw * pw / 100.0).max(1.0 / scale); + let oh = (lh * ph / 100.0).max(1.0 / scale); + let (nw, nh) = ((ow * scale).round().max(1.0), (oh * scale).round().max(1.0)); + let grow = [(ow - lw) * 0.5 * scale, (oh - lh) * 0.5 * scale]; + if !ctx.adjustment { + return (nw as u32, nh as u32, grow); + } + // The grown rectangle's top-left in the buffer below, on whole pixels so the result lines + // up with the comp again when the stack ends. + let (x0, y0) = ((offset[0] - grow[0]).round(), (offset[1] - grow[1]).round()); + let fits = [x0, y0, nw, nh].iter().all(|v| v.is_finite()) && nw <= 16384.0 && nh <= 16384.0; + if (pw == 100.0 && ph == 100.0) || !fits { + return (size.0, size.1, offset); + } + (nw as u32, nh as u32, [offset[0] - x0, offset[1] - y0]) +} + +/// Where an adjustment stack's result (comp origin at `offset`, `size` pixels) lies on the canvas +/// it came from (comp origin at `canvas_offset`): the result pixel at canvas pixel (0, 0). +/// `None` when it already is the canvas, frame for frame. +pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u32), canvas: (u32, u32)) -> Option<(i64, i64)> { + let (dx, dy) = ((offset[0] - canvas_offset[0]).round(), (offset[1] - canvas_offset[1]).round()); + if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0 && size == canvas) { + return None; + } + Some((dx as i64, dy as i64)) +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -406,6 +444,8 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it). + pub float_depth: bool, /// The composition's shutter (angle and phase in degrees, samples per frame) when both the /// comp's and the layer's motion blur switches are on (Transform's Use Composition's /// Shutter Angle). diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 0e037022e..b5976b761 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -499,6 +499,22 @@ mod tests { assert_eq!(pr.ui, popup.ui); } + /// #1797: Shift Channels' menu put Half On before Full Off; a project saved then keeps the + /// option it chose, by label. + #[test] + fn shift_channels_half_on_and_full_off_are_remapped_by_label() { + const OLD: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"]; + for (saved, want) in [(9u32, 10u32), (10, 9), (1, 1)] { + let mut next = 1; + let (spec, mut g) = instance("ec.channel.shiftchannels", &mut next); + let pr = g.get_mut("takeGreenFrom").unwrap(); + pr.ui = ParamUi::Popup { options: OLD.iter().map(|s| s.to_string()).collect() }; + pr.value = Value::Enum(saved); + upgrade_instance(spec, &mut g, &mut Ids(&mut next), SIZE); + assert_eq!(g.get("takeGreenFrom").unwrap().value, Value::Enum(want), "saved {saved}"); + } + } + /// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were /// plain text; old projects get the Choose… file parameter, keeping the path. #[test] diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..7468c6b84 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -35,22 +35,44 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); } - b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment); + let (sx, sy) = (s * kx, s * ky); + // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). + b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) { + effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, repeat || ctx.adjustment) + } else { + gaussian_blur(&b.img, sx, sy, repeat || ctx.adjustment) + }; b } fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; - if r == 0 { + let radius = (ctx.params.f("radius") * b.scale).max(0.0); + let r = radius.floor() as usize; + let frac = (radius - r as f64) as f32; + // Fractional radius: blend the two neighbouring integer radii so the result changes continuously (#1939). + let frac = if frac < 1e-4 { 0.0 } else { frac }; + if r == 0 && frac == 0.0 { return b; } let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize; let (kx, ky) = dims_xy(ctx.params.e("dimensions")); let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { - b.pad((r * it) as u32 + 1); + let reach = if frac > 0.0 { r + 1 } else { r }; + b.pad((reach * it) as u32 + 1); + } + let edge = repeat || ctx.adjustment; + let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); + if frac == 0.0 { + b.img = lo; + return b; + } + let hi = effectcraft_raster::box_blur(&b.img, (r + 1) * kx as usize, (r + 1) * ky as usize, it, edge); + for ((l, h), o) in lo.data.iter().zip(hi.data.iter()).zip(b.img.data.iter_mut()) { + for c in 0..4 { + o[c] = l[c] + (h[c] - l[c]) * frac; + } } - b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment); b } @@ -738,6 +760,42 @@ mod tests { use super::*; use crate::{EffectEnv, run_fx}; + /// White left half, black right half (one vertical edge at x = 20). + fn edge() -> effectcraft_raster::Image { + let mut img = effectcraft_raster::Image::filled(40, 8, [0.0, 0.0, 0.0, 1.0]); + for y in 0..8 { + for x in 0..20 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + img + } + + fn blurred_row(id: &str, param: &str, v: f64) -> Vec { + let out = run_fx(id, &[(param, num(v)), ("repeatEdge", Value::Bool(true))], edge(), 0.0, EffectEnv::default()); + (16..24).map(|x| out.img.get(x, 4)[0]).collect() + } + + #[test] + fn small_gaussian_blurriness_is_continuous() { + // #1939: 0.9 used to be identical to 0, and 1.2 / 1.6 / 2.0 identical to each other. + let row = |v| blurred_row("ec.blur.gaussian", "blurriness", v); + assert_ne!(row(0.0), row(0.9)); + assert_ne!(row(0.9), row(1.2)); + assert_ne!(row(1.2), row(1.6)); + assert_ne!(row(1.6), row(2.0)); + assert_ne!(row(2.0), row(2.4)); + } + + #[test] + fn fractional_box_blur_radius_is_continuous() { + let row = |v| blurred_row("ec.blur.fastbox", "radius", v); + assert_ne!(row(0.0), row(0.5)); + assert_ne!(row(0.5), row(0.9)); + assert_ne!(row(1.2), row(1.6)); + assert_ne!(row(1.6), row(2.4)); + } + /// A bright square on mid grey. fn scene() -> effectcraft_raster::Image { let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]); diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 88a5c01d5..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/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 0e26cca58..7104bc1cc 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/commands/align_data.rs index 097bddc22..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. diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 019d56d1d..3630efeee 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(); @@ -201,6 +206,21 @@ 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(); 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/file.rs b/crates/engine/src/commands/file.rs index 8c4c384ea..0fea452dc 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t) .ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?; } + if let Some(f) = p.get("frameStart") { + // Frame Count: After Effects offers Start at 0 and Start at 1 only. Scripts and dialog + // number fields may send `1.0`. + proj.settings.frame_start = match f.as_f64() { + Some(0.0) => 0, + Some(1.0) => 1, + _ => return Err(bad(cmd, format!("frameStart: 0|1, not `{f}`"))), + }; + } Ok(()) })?; Ok(serde_json::to_value(&s.project.settings).unwrap_or_default()) @@ -745,7 +754,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16, frameStart?: 0|1 (Frame Count: Start at 0 / Start at 1)}", always, project_settings ), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b1..13037c88a 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}; @@ -307,6 +307,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,8 +514,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, - // Nulls have a Video switch too (it hides their outline in the viewer). - "video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id), + // 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; @@ -1628,6 +1727,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 9cac80412..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() || matches!(l.source, LayerSource::Null)) { - l.switches.video = false; - } + // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's + // eye cell shows): the selection stays shown. + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + l.switches.video = false; } Ok(()) })?; @@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { } fn reveal_source(s: &mut Session, p: &Value) -> Result { - let item = source_item(s, p, "layer.revealSource")?; - s.state.project_selection = vec![item]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": item.0})) + let (cid, ids) = layers_p(s, p)?; + let mut items: Vec = Vec::new(); + { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for layer in ids.iter().filter_map(|id| comp.layer(*id)) { + if let Some(item) = layer.source.item() + && s.project.item(item).is_some() + && !items.contains(&item) + { + items.push(item); + } + } + } + if items.is_empty() { + return Err(bad("layer.revealSource", "the selected layers have no source item")); + } + s.state.project_selection = items.clone(); + let raw: Vec = items.iter().map(|item| item.0).collect(); + frontend(s, "project.reveal", &json!({"items": raw}))?; + Ok(json!({"item": items[0].0, "items": raw})) } fn reveal_comp(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.state.project_selection = vec![cid]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": cid.0})) + frontend(s, "project.reveal", &json!({"items": [cid.0]}))?; + Ok(json!({"item": cid.0, "items": [cid.0]})) } impl Session { @@ -909,7 +925,7 @@ pub fn specs() -> Vec { cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op), cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source), cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder), - cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source), + cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source), cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp), cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors), cmd!( diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3af877d34..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 { @@ -179,7 +182,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: & pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value { let ct = layer.comp_time(t); let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()}); - if !layer.is_active_at(ct) { + // A key exactly on the out point ends a fade normally: it is outside the half-open visible + // range, but it is not a caller mistake, so it does not warn (#2084). + if ct < layer.in_point || ct > layer.out_point { out["warning"] = json!(format!( "Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).", ct.seconds(), diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 5dcb6d09a..9d12e458c 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -159,7 +159,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { } /// `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)?; 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/doc_path.rs b/crates/engine/src/doc_path.rs index c7416a8ce..00d6c33f1 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -46,13 +46,22 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result { + NotFollowed::NoProjectFolder + } + why => NotFollowed::Refused(why), + }) +} + +/// Whether `p` is a Windows drive or share path, which a project cannot make absolute outside Windows. +fn is_windows_rooted(p: &Path) -> bool { + let s = p.to_string_lossy(); + let b = s.as_bytes(); + (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || s.starts_with(r"\\") || s.starts_with("//") } /// The paths of `f` (its file and every frame of an image sequence) resolved against the document diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index b8dcbc190..d9d971247 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -34,9 +34,11 @@ impl Services for Recording { } } -/// Probes every file as a still. +/// Probes every file as a still. Only the Unix part of the test below imports files. +#[cfg(unix)] struct Stills; +#[cfg(unix)] impl crate::Importer for Stills { fn probe(&self, path: &str) -> Result { if !std::path::Path::new(path).is_file() { @@ -147,7 +149,23 @@ fn stored_paths_resolve_against_the_project() { let doc = doc.to_str().unwrap(); assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov"))); assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder)); + assert_eq!(super::resolve("../logo.png", None), Err(super::NotFollowed::NoProjectFolder)); + // A drive path is not a local file on Unix, with or without a project: it is not a missing project. + #[cfg(unix)] + for stored in [r"C:\x.mov", "C:x.mov"] { + assert_eq!(super::resolve(stored, None), Err(super::NotFollowed::Refused(super::Refused::Invalid)), "{stored}"); + } assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network))); + assert_eq!(super::resolve("jar:clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Url))); + // Windows reads a colon in a name as an alternate data stream; elsewhere it is a plain character. + if cfg!(windows) { + assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid))); + } else { + assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("shots").join("take:1.mov"))); + } + // A Windows drive path names no file on other platforms. + #[cfg(unix)] + assert_eq!(super::resolve(r"C:\art\clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid))); #[cfg(windows)] assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok()); } diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index fa12b27aa..9b3c135a8 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -129,17 +129,22 @@ pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result { /// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read /// (only without `undoable`). fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet)> { - if s.importer.is_none() { + let Some(importer) = s.importer.clone() else { return Err(crate::EngineError::Other("media import is not available in this build".into())); - } + }; let mut updates = vec![]; let mut paths = BTreeSet::new(); let mut failed = BTreeSet::new(); for i in ids { if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { - // Probed the way it imports by default, so a sequence is found as one. A path that - // is not followed is not read. - let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path)); + // A sequence is probed the way it imports by default, so its run is found again; any + // other item is its one file, not the run a numbered still sits in (#2074). A path + // that is not followed is not read. + let probed = s + .stored_paths + .check(f) + .map_err(|(p, why)| crate::doc_path::refusal(&p, why)) + .and_then(|_| if f.kind == FootageKind::Sequence { s.probe_footage(&f.path) } else { importer.probe(&f.path) }); let nf = match probed { Ok(p) => refreshed(s, f, p), Err(_) if undoable => Footage { missing: true, ..f.clone() }, @@ -193,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/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..382534a44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -521,6 +521,8 @@ pub struct Session { pub footage_stamps: std::collections::HashMap, /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). pub footage_scan: Option, + /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). + pub disk_salt: Option<(u64, u64)>, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one /// is, settings that guard what scripts may do can't change (see /// [`prefs::Scripting::same_permissions`]). @@ -618,6 +620,7 @@ impl Default for Session { check_footage_on_open: false, stored_paths: Default::default(), footage_stamps: Default::default(), + disk_salt: None, footage_scan: None, script_calls: ScriptCalls::default(), } @@ -736,12 +739,42 @@ impl Session { 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`]). @@ -1036,12 +1069,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). @@ -1169,6 +1208,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; @@ -1242,6 +1283,8 @@ 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; @@ -1290,6 +1333,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 d22875f17..c0e20be71 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option { std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } -/// Format seconds since the epoch as an ISO 8601 UTC date-time. +/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own +/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn iso_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ")) +} + +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days` +/// always fits an `i64`. +fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// File dates and size: (created, modified, size). @@ -506,5 +517,10 @@ mod tests { assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z"); assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z"); + assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z"); + assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z"); } } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bc486a0c5..aa2e4ffe1 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -733,6 +733,7 @@ Layer Shape Layer | layer.newShape Adjustment Layer | layer.newAdjustment Content-Aware Fill Layer... | layer.newContentAwareFill + 3D Primitive... | layer.newPrimitive Cube | layer.new3dPrimitive {"kind":"cube"} Sphere | layer.new3dPrimitive {"kind":"sphere"} Plane | layer.new3dPrimitive {"kind":"plane"} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a2ce579ea..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, 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/session_settings.rs b/crates/engine/src/session_settings.rs index ef3e748a6..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(); 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_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_effects.rs b/crates/engine/src/tests_effects.rs index 18fcc6f84..1ea57f4cf 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -772,6 +772,27 @@ fn preset_round_trips_effect_properties_by_match_name() { } } +/// #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] 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 fe0a16936..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] diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 6da07dd40..5131bf3f4 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -111,6 +111,29 @@ 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). @@ -725,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(); 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 a4f4d89eb..c35d4f3a4 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -1064,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_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 243a9c676..b4d2ff8c0 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -392,3 +392,38 @@ fn set_text_schema_accepts_every_opentype_key_and_requires_none() { s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap(); assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20)); } + +#[test] +fn deselect_all_ends_the_open_edit() { + let (mut s, t) = session("Hello"); + s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap(); + s.execute("text.insert", json!({"text": "!"})).unwrap(); + assert_eq!(doc(&s, t).text, "Hello!"); + s.execute("edit.deselectAll", json!({})).unwrap(); + assert!(s.state.text_edit.is_none(), "Deselect All ends the open text edit on every route"); + assert!(s.state.selected_layers.is_empty(), "and leaves nothing selected"); + assert_eq!(doc(&s, t).text, "Hello!", "the text stays committed"); + // Ending an edit is no undoable step of its own (as with text.endEdit): undo takes + // the typed text, it does not reopen the edit. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(&s, t).text, "Hello", "undo reverts the typed text, not the deselection"); + assert!(s.state.text_edit.is_none(), "undo does not reopen the edit"); +} + +#[test] +fn deselect_all_discards_an_empty_type_tool_layer() { + let (mut s, _keep) = session("Hello"); + let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); + assert!( + s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)), + "the Type tool opens the edit on its new, empty layer" + ); + let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len()); + let before = count(&s); + s.execute("edit.deselectAll", json!({})).unwrap(); + assert!(s.state.text_edit.is_none()); + assert!(s.state.selected_layers.is_empty()); + assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); +} diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 29168e17a..33dac7795 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -613,6 +613,51 @@ fn time_set_reads_frames_style_digits_as_a_frame_count() { assert_eq!(r["display"], "00100"); } +/// Project Settings ▸ Time Display Style ▸ Frames ▸ Frame Count: Start at 0 / Start at 1 +/// (`app.project.framesCountType`), undoable and saved with the project (#1807). +#[test] +fn project_settings_frame_count_starts_at_one() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Count", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012"); + + s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00013"); + for bad in [json!(2), json!(-1), json!("one")] { + assert!(s.execute("file.projectSettings", json!({"frameStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.project.settings.frame_start, 1); + + let dir = std::env::temp_dir().join(format!("ec-frame-start-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("count.ecproj").to_string_lossy().to_string(); + s.execute("file.save", json!({"path": path})).unwrap(); + let mut reopened = Session::default(); + reopened.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_dir_all(dir); + assert_eq!(reopened.project.settings.frame_start, 1); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012"); +} + +/// A Duration is a count of frames: Start at 1 and the comp's Start Timecode number positions, +/// not lengths (#1807). +#[test] +fn durations_ignore_the_frame_count_and_the_start_timecode() { + use crate::commands::time::display_duration; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Len", "width": 64, "height": 64, "frameRate": 30, "duration": 10, "startTimecode": "0:00:01:00"})).unwrap(); + s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap(); + let comp = s.active_comp().unwrap().clone(); + let len = comp.frame_rate.tick_of(30); + for (style, want) in [("frames", "00030"), ("feet35", "0001+14"), ("timecode", "0:00:01:00")] { + s.execute("file.projectSettings", json!({"timeDisplay": style})).unwrap(); + assert_eq!(display_duration(&s, &comp, len), want, "{style}"); + } +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); diff --git a/crates/engine/src/tests_timeline_interchange.rs b/crates/engine/src/tests_timeline_interchange.rs index b32e19c49..45be0f7a1 100644 --- a/crates/engine/src/tests_timeline_interchange.rs +++ b/crates/engine/src/tests_timeline_interchange.rs @@ -1,5 +1,5 @@ //! File ▸ Import / Export ▸ Adobe Premiere Pro Project… (timeline interchange) through the command -//! registry, without media decoding (pixels: `crates/host/tests/timeline_interop.rs`). +//! registry, without media decoding (pixels: `crates/host/tests/it/timeline_interop.rs`). use serde_json::json; diff --git a/crates/engine/tests/key_time_post_snap_bounds.rs b/crates/engine/tests/it/key_time_post_snap_bounds.rs similarity index 100% rename from crates/engine/tests/key_time_post_snap_bounds.rs rename to crates/engine/tests/it/key_time_post_snap_bounds.rs diff --git a/crates/engine/tests/key_time_reply_contract.rs b/crates/engine/tests/it/key_time_reply_contract.rs similarity index 100% rename from crates/engine/tests/key_time_reply_contract.rs rename to crates/engine/tests/it/key_time_reply_contract.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..ac1484cfb --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod key_time_post_snap_bounds; +mod key_time_reply_contract; +mod parenting_3d_position; diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs similarity index 100% rename from crates/engine/tests/parenting_3d_position.rs rename to crates/engine/tests/it/parenting_3d_position.rs diff --git a/crates/export/tests/audio_sample_positions.rs b/crates/export/tests/it/audio_sample_positions.rs similarity index 100% rename from crates/export/tests/audio_sample_positions.rs rename to crates/export/tests/it/audio_sample_positions.rs diff --git a/crates/export/tests/hevc_av1_roundtrip.rs b/crates/export/tests/it/hevc_av1_roundtrip.rs similarity index 100% rename from crates/export/tests/hevc_av1_roundtrip.rs rename to crates/export/tests/it/hevc_av1_roundtrip.rs diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs new file mode 100644 index 000000000..0f89a641d --- /dev/null +++ b/crates/export/tests/it/main.rs @@ -0,0 +1,14 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod audio_sample_positions; +mod hevc_av1_roundtrip; +#[cfg(not(target_arch = "wasm32"))] +mod mov_audio_sample_positions; +mod ms_rounded_footage; +#[cfg(not(target_arch = "wasm32"))] +mod native_audio_sample_positions; +mod opus_roundtrip; +mod render_settings; +mod roundtrip; +mod uncompressed_mov; +mod vp9_roundtrip; diff --git a/crates/export/tests/mov_audio_sample_positions.rs b/crates/export/tests/it/mov_audio_sample_positions.rs similarity index 99% rename from crates/export/tests/mov_audio_sample_positions.rs rename to crates/export/tests/it/mov_audio_sample_positions.rs index c58d2b14d..a46a49271 100644 --- a/crates/export/tests/mov_audio_sample_positions.rs +++ b/crates/export/tests/it/mov_audio_sample_positions.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_arch = "wasm32"))] - //! Tiny native WAV to ProRes PCM MOV sample-position proof. use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; use effectcraft_export::{Job, JobOptions, Sink, export}; diff --git a/crates/export/tests/ms_rounded_footage.rs b/crates/export/tests/it/ms_rounded_footage.rs similarity index 100% rename from crates/export/tests/ms_rounded_footage.rs rename to crates/export/tests/it/ms_rounded_footage.rs diff --git a/crates/export/tests/native_audio_sample_positions.rs b/crates/export/tests/it/native_audio_sample_positions.rs similarity index 99% rename from crates/export/tests/native_audio_sample_positions.rs rename to crates/export/tests/it/native_audio_sample_positions.rs index 1b59e8c11..9222d0f23 100644 --- a/crates/export/tests/native_audio_sample_positions.rs +++ b/crates/export/tests/it/native_audio_sample_positions.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_arch = "wasm32"))] - //! Native MediaPool decoding and native WAV export preserve selected sample positions. use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; use effectcraft_export::{Job, JobOptions, Sink, export}; diff --git a/crates/export/tests/opus_roundtrip.rs b/crates/export/tests/it/opus_roundtrip.rs similarity index 100% rename from crates/export/tests/opus_roundtrip.rs rename to crates/export/tests/it/opus_roundtrip.rs diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/it/render_settings.rs similarity index 100% rename from crates/export/tests/render_settings.rs rename to crates/export/tests/it/render_settings.rs diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/it/roundtrip.rs similarity index 100% rename from crates/export/tests/roundtrip.rs rename to crates/export/tests/it/roundtrip.rs diff --git a/crates/export/tests/uncompressed_mov.rs b/crates/export/tests/it/uncompressed_mov.rs similarity index 100% rename from crates/export/tests/uncompressed_mov.rs rename to crates/export/tests/it/uncompressed_mov.rs diff --git a/crates/export/tests/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs similarity index 100% rename from crates/export/tests/vp9_roundtrip.rs rename to crates/export/tests/it/vp9_roundtrip.rs diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 4da7bc63b..df6ed3ff4 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -461,7 +461,7 @@ Comp.prototype = { var l = this.layer(i); if (l.__info[10] === 'Camera' && l.active) return l; } - __err('Composition "' + this.name + '" has no active camera'); + return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index 55e9800aa..6b0ea539f 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -528,8 +528,9 @@ fn rewrite_seq(items: &[Item]) -> String { let end = assignment_end(items, j + 1); let rhs = rewrite_seq(&items[j + 1..end]); let h = helper(&op.text[..1]).unwrap_or("__add"); + let target = compound_assignment_target(items, i, j); out.push_str(&p.ws); - out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body)); + out.push_str(&format!("{target} ={}{h}({target}, ({rhs}))", op.ws)); i = end; continue; } @@ -543,6 +544,34 @@ fn rewrite_seq(items: &[Item]) -> String { out } +fn raw_ref(item: &Item) -> String { + match item { + Item::Tok(t) => format!("{}{}", t.ws, t.text), + Item::Group { open, inner, close } => { + let mut out = format!("{}{}", open.ws, open.text); + if has_assign(inner) { + // shortcut: nested assignments stay verbatim (rewriting them would double per level), so arithmetic beside them is not array-aware + for item in inner { + out.push_str(&raw_ref(item)); + } + } else { + out.push_str(&rewrite_seq(inner)); + } + if let Some(close) = close { + out.push_str(&close.text); + } + out + } + } +} + +fn has_assign(items: &[Item]) -> bool { + items.iter().any(|it| match it { + Item::Tok(t) => ASSIGN.contains(&t.text.as_str()), + Item::Group { inner, .. } => has_assign(inner), + }) +} + fn push_piece(out: &mut String, p: Piece) { out.push_str(&p.ws); out.push_str(&p.body); @@ -554,6 +583,18 @@ fn push_piece(out: &mut String, p: Piece) { /// nested brackets, so deeper expressions are rejected with an error instead. Constructs that /// nest without brackets count as part of a level ([`too_nested`]). pub const MAX_NESTING: usize = 32; +/// Hard limit on bytes emitted by the array-math source rewrite. +pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024; + +fn over_rewrite_limit(text: &str) -> bool { + text.len() > MAX_REWRITTEN_BYTES +} + +fn compound_assignment_target(items: &[Item], start: usize, end: usize) -> String { + // Rewriting the whole left side duplicates nested computed assignment targets. Keep the + // original target syntax for both reads/writes; source length is bounded above. + items[start..end].iter().map(raw_ref).collect() +} /// Stack cost of one level of brackets (also of a template substitution, a regex group and a /// prefix operator, which cost the parser about as much or are never chained in real code). @@ -746,12 +787,18 @@ fn too_nested_from(toks: &[Tok], base: usize) -> bool { /// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite). pub fn check_nesting(src: &str) -> Result<(), String> { + if over_rewrite_limit(src) { + return Err(too_large()); + } let (toks, tail) = tokenize(src); if too_nested(&toks) { return Err(too_deep()); } let mut out = rewrite_seq(&build(toks)); out.push_str(&tail); + if over_rewrite_limit(&out) { + return Err(too_large()); + } if too_nested(&tokenize(&out).0) { return Err(too_deep()); } @@ -765,12 +812,18 @@ fn too_deep() -> String { /// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved. /// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error. pub fn rewrite(src: &str) -> String { + if over_rewrite_limit(src) { + return throw_too_large(); + } let (toks, tail) = tokenize(src); if too_nested(&toks) { return throw_too_deep(); } let mut out = rewrite_seq(&build(toks)); out.push_str(&tail); + if over_rewrite_limit(&out) { + return throw_too_large(); + } if too_nested(&tokenize(&out).0) { return throw_too_deep(); } @@ -781,6 +834,14 @@ fn throw_too_deep() -> String { format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: ")) } +fn too_large() -> String { + format!("Error: expression exceeds the {MAX_REWRITTEN_BYTES}-byte rewrite limit") +} + +fn throw_too_large() -> String { + format!("throw new RangeError({:?})", too_large().trim_start_matches("Error: ")) +} + #[cfg(test)] mod tests { use super::{MAX_NESTING, check_nesting, rewrite}; @@ -821,6 +882,8 @@ mod tests { #[test] fn compound_assignment_and_functions() { assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));")); + assert_eq!(n(&rewrite("obj[key] += 1")), n("obj[key] = __add(obj[key], (1))")); + assert_eq!(n(&rewrite("o[a + b] += 1")), n("o[__add(a, b)] = __add(o[__add(a, b)], (1))")); assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }")); assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)")); assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')")); @@ -828,6 +891,20 @@ mod tests { assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)")); } + #[test] + fn source_expansion_is_bounded_before_rewriting() { + let src = "x += 1;".repeat(super::MAX_REWRITTEN_BYTES / 6 + 1); + assert!(check_nesting(&src).unwrap_err().contains("rewrite limit")); + assert!(rewrite(&src).starts_with("throw new RangeError(")); + } + + #[test] + fn output_expansion_is_bounded() { + let src = format!("{} += 1", "a".repeat(super::MAX_REWRITTEN_BYTES / 2)); + assert!(check_nesting(&src).unwrap_err().contains("rewrite limit")); + assert!(rewrite(&src).starts_with("throw new RangeError(")); + } + #[test] fn deep_nesting_is_an_error_not_a_stack_overflow() { // Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs index bff5a2149..28f830bb2 100644 --- a/crates/expr/src/sample.rs +++ b/crates/expr/src/sample.rs @@ -99,8 +99,8 @@ pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2], }; let mut acc = [0.0f64; 4]; let mut n = 0.0; - for y in y0..=y1.min(y0 + 4096) { - for x in x0..=x1.min(x0 + 4096) { + for y in y0..=y1.min(y0.saturating_add(4096)) { + for x in x0..=x1.min(x0.saturating_add(4096)) { let p = b.img.get(x, y); for k in 0..4 { acc[k] += p[k] as f64; diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 18b725084..7d2790255 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1166,6 +1166,13 @@ fn sample_image_reads_rendered_pixels() { assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0); } +/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds. +#[test] +fn sample_image_huge_point_is_transparent() { + let f = fx(); + assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0); +} + fn data_item(p: &mut Project, name: &str, text: &str) { let f = effectcraft_project::Footage { path: format!("/data/{name}"), diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index a03c9d46c..d86784f1e 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -360,6 +360,9 @@ impl Mat4 { } /// General inverse (Gauss-Jordan with partial pivoting). pub fn inverse(&self) -> Option { + if !self.0.iter().flatten().all(|v| v.is_finite()) { + return None; + } let mut a = self.0; let mut inv = Mat4::IDENTITY.0; for col in 0..4 { @@ -450,6 +453,16 @@ mod tests { (a - b).length() < 1e-9 } + #[test] + fn mat4_inverse_rejects_non_finite() { + for (r, c, v) in [(0, 0, f64::NAN), (2, 1, f64::NAN), (1, 3, f64::INFINITY)] { + let mut m = Mat4::IDENTITY; + m.0[r][c] = v; + assert!(m.inverse().is_none(), "{r},{c},{v}"); + } + assert!(Mat4::IDENTITY.inverse().is_some()); + } + #[test] fn layer_2d_maps_anchor_to_position() { let m = Mat3::layer_2d(vec2(50.0, 25.0), vec2(960.0, 540.0), vec2(200.0, 50.0), 30.0); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7229aa5ad..52d27afe3 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -462,6 +462,12 @@ pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Opt buffer } +/// The descriptor for a device the compositor owns: `MemoryUsage` so freed textures give their +/// allocator blocks back instead of a few long-lived objects pinning large ones. +pub(crate) fn device_descriptor(required_limits: wgpu::Limits) -> wgpu::DeviceDescriptor<'static> { + wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, memory_hints: wgpu::MemoryHints::MemoryUsage, ..Default::default() } +} + impl GpuContext { /// [`buffer_init`] on this context's device. pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { @@ -870,10 +876,7 @@ impl GpuContext { max_buffer_size: limits.max_buffer_size, ..wgpu::Limits::default() }; - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() }) - .await - .map_err(|e| match by_name { + let (device, queue) = adapter.request_device(&device_descriptor(required_limits)).await.map_err(|e| match by_name { true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name), false => format!("no device: {e}"), })?; @@ -1246,6 +1249,24 @@ impl<'g> Enc<'g> { ); } + /// A `w` × `h` image showing `src` with its pixel (`x`, `y`) at the top-left, transparent + /// where `src` doesn't reach (Image::crop). + pub fn crop(&mut self, src: &GpuImage, x: i64, y: i64, w: u32, h: u32) -> GpuImage { + let dst = self.image(w, h); + let span = |o: i64, n: u32, len: u32| { + let (a, b) = (o.max(0), (o + n as i64).min(len as i64)); + (b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32)) + }; + let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst }; + let enc = self.encoder(); + enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: cw, height: ch, depth_or_array_layers: 1 }, + ); + dst + } + /// Submit recorded work. pub fn submit(&mut self) { if let Some((enc, id)) = self.enc.take() { diff --git a/crates/gpu/src/deferred.rs b/crates/gpu/src/deferred.rs index 3ac2c67e6..e96b54a1d 100644 --- a/crates/gpu/src/deferred.rs +++ b/crates/gpu/src/deferred.rs @@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option) -> u12 k.u64(e.effect_index as u64); k.debug(&e.working_space); k.u64(e.working_linear as u64); + k.u64(e.float_depth as u64); k.debug(&e.shutter); } k.finish() diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..5041582f9 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -261,6 +261,10 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { return Some(b); } let (kx, ky) = dims_xy(ctx.params.e("dimensions")); + // Small sigmas use the CPU's sampled kernel (#1939). + if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) { + return None; + } let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; @@ -270,7 +274,12 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { - let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + let radius = (ctx.params.f("radius") * b.scale).max(0.0); + // Fractional radii blend two integer radii on the CPU (#1939). + if radius - radius.floor() >= 1e-4 { + return None; + } + let r = radius.floor() as usize; if r == 0 { return Some(b); } @@ -388,7 +397,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { // ---------------------------------------------------------------- per-pixel fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id))); + let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id))); if parsed.iter().all(Option::is_none) { return Some(b); } diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index b9ae5282c..18bd8409f 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.color.blackwhite" => { let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0); let tc = pr.color("tintColor"); - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); p.u[0] = [3, pr.b("tint") as u32, 0, 0]; p.f[0] = [w[0], w[1], w[2], w[3]]; p.f[1] = [w[4], w[5], 0.0, 0.0]; - p.f[2] = [th, ts, 0.0, 0.0]; + p.f[2] = [tc[0], tc[1], tc[2], 0.0]; } "ec.color.tritone" => { p.u[0][0] = 4; diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index 4931afe2a..1bb0e49e1 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -278,7 +278,11 @@ fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest); // Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A), on all // cores with the rank planes resolved once. - let img = effectcraft_effects::crypto_aux_image(a, &layer, &sel); + // The ID colours are only read by the Colors display; Matte, Preview and Matte Only need the + // matte alone. + // `tests_fx_depth::cryptomatte_every_display_matches_the_cpu` guards this against the shader. + let what = effectcraft_effects::CryptoAux::for_display(pr.e("display"), pr.b("matteOnly")); + let img = effectcraft_effects::crypto_aux_image_with(a, &layer, &sel, what); let tex = e.g.upload_image(&img)?; let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]); let mut p = op(7); diff --git a/crates/gpu/src/fx_extra.rs b/crates/gpu/src/fx_extra.rs index 2e8bb85b8..cff516305 100644 --- a/crates/gpu/src/fx_extra.rs +++ b/crates/gpu/src/fx_extra.rs @@ -188,6 +188,10 @@ fn gaussian_legacy(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { 2 => (0.0, 1.0), _ => (1.0, 1.0), }; + // Small sigmas use the CPU's sampled kernel (#1939). + if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) { + return None; + } if !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; } diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 3744ec4fa..0089dd4f4 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -117,11 +117,14 @@ fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { run(e, "gen_radial_blur", &p, b, None, Some(&data)) } -fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5; if s <= 0.0 { return Some(b); } + if !ctx.adjustment { + b.pad(e, effectcraft_effects::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 mut p = Params::default(); p.u[0][0] = ctx.params.e("zoom"); @@ -353,10 +356,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); } - let blend = ctx.params.f("blend").max(1.0); + let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; - p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; run(e, "gen_fourcolor", &p, b, None, None) } diff --git a/crates/gpu/src/fx_key.rs b/crates/gpu/src/fx_key.rs index 1099bccd1..3bb5a87ab 100644 --- a/crates/gpu/src/fx_key.rs +++ b/crates/gpu/src/fx_key.rs @@ -438,8 +438,10 @@ fn refine(e: &mut Enc, ctx: &EffectCtx, b: GBuf, hard: bool) -> Option { const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"]; +/// Shift Channels' Take … From menu, in the order of `effects`' `SHIFT_ORDER`: the two must agree, +/// or the source depends on whether the effect renders on the GPU. 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]; + [Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half]; const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off]; @@ -527,7 +529,8 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = [level, pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0, 0.0, 0.0]; } "ec.channel.shiftchannels" => { - let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(10)])); + let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"] + .map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(SHIFT_ORDER.len() - 1)])); return route(e, b, s); } "ec.channel.removecolormatting" => { diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index f5b7708d9..d53367e76 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -637,13 +637,7 @@ fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { 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) = effectcraft_effects::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); if !e.g.fits(nw, nh) { return None; } diff --git a/crates/gpu/src/fx_transition.rs b/crates/gpu/src/fx_transition.rs index a0ad54658..60378491e 100644 --- a/crates/gpu/src/fx_transition.rs +++ b/crates/gpu/src/fx_transition.rs @@ -586,17 +586,19 @@ fn blobbylize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let soft = pr.f("softness").max(0.0) * b.scale; let blob = layer_or_self(e, ctx, &b, "blobLayer")?; let raw = plane(e, &blob, pr.e("property").min(10)); - let h = gaussian_blur(e, &raw, soft, soft, true); + let h = gaussian_blur(e, &raw, soft * 0.42, soft * 0.42, true); let lpos = b.to_px(pr.v2("lightPosition")); let lheight = pr.f("lightHeight"); let (d, el) = (pr.f("lightDirection").to_radians(), (lheight / 100.0).clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2); let ldir = [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()]; let span = (b.img.width.max(b.img.height) as f64).max(1.0); let mut p = Params::default(); - p.f[0] = f4([(soft * 0.5).max(1.0), (pr.f("cutAway") / 100.0).clamp(0.0, 0.99), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0]); + p.f[0] = f4([(pr.f("cutAway") / 100.0).clamp(0.0, 1.0), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0, soft * 1.5]); p.f[1] = f4([pr.f("diffuse") / 100.0, pr.f("specular") / 100.0, (1.0 / pr.f("roughness").clamp(0.001, 1.0)).min(500.0), pr.f("metal") / 100.0]); p.f[2] = f4([lpos.0, lpos.1, lheight / 100.0 * span, (pr.e("lightType") == 1) as u32 as f64]); p.f[3] = f4([ldir[0], ldir[1], ldir[2], 0.0]); p.f[4] = rgb(pr.color("lightColor"), 0.0); + p.f[5] = f4([if soft > 0.05 { span / 64.0 } else { 0.0 }, span * 0.34 * b.scale, (soft * 0.65).max(1.0), 1.25]); + p.f[6] = f4([span * 0.5, 0.0, 0.0, 0.0]); run(e, "ftr_blob", &p, b, Some(&h), None) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 1cff8be07..1064a216d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -461,3 +461,5 @@ mod tests_fx_warp; mod tests_init; #[cfg(test)] mod tests_particles; +#[cfg(test)] +mod tests_wgsl_grammar; diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 06becba49..4eea78c8c 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -179,7 +179,41 @@ fn equirect_uv(d: vec3, rot: f32) -> vec2 { return vec2(u - floor(u), v); } -fn shadow_factor(m: u32, p: vec3, n_l: f32) -> f32 { +fn shadow_texel_at(offset: u32, sz: i32, x: i32, y: i32) -> f32 { + if (x < 0 || y < 0 || x >= sz || y >= sz) { + return 1e30; // outside the map: the light was never blocked there (finite stand-in for the CPU path's infinity sentinel) + } + return shadow_texels[offset + u32(y * sz + x)]; +} + +fn tap_hash(x: i32, y: i32) -> u32 { + var h: u32 = (u32(x) * 0x9E3779B9u) ^ (u32(y) * 0x85EBCA6Bu); + h = h ^ (h >> 16u); + h = h * 0x2545F491u; + h = h ^ (h >> 15u); + return h; +} + +const PCF_MAX_TAPS: i32 = 10; +const PLANE_SLOPE_MAX: f32 = 64.0; + +// shade::shadow_factor's `open`: 1 where texel (x, y) does not occlude the receiver +// plane (depth slope -t in texel footprints per texel) at the texel's centre; the +// perspective compare runs on inverse depth (iz + g·iq), which is linear across the map. +fn shadow_open(offset: u32, sz: i32, x: i32, y: i32, ortho: bool, z: f32, texel: f32, bias: f32, t: vec2, u: f32, v: f32, iz: f32, iq: f32) -> f32 { + let s = shadow_texel_at(offset, sz, x, y) + bias; + let g = t.x * (f32(x) + 0.5 - u) + t.y * (f32(y) + 0.5 - v); + var lit: bool; + if (ortho) { + lit = z - g * texel <= s; + } else { + let inv = iz + g * iq; + lit = inv <= 0.0 || inv * s >= 1.0; + } + return select(0.0, 1.0, lit); +} + +fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { let r0 = shadows[m]; let r1 = shadows[m + 1u]; let r2 = shadows[m + 2u]; @@ -205,28 +239,66 @@ fn shadow_factor(m: u32, p: vec3, n_l: f32) -> f32 { } let z = q.z; let texel = select(abs(z) / focal, 1.0 / focal, ortho); - let bias = extra.x * texel * (1.0 + extra.y) * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0))); - let step = extra.y / 2.0; + // CPU mirror (shade::shadow_factor), step for step: slope-scaled bias at texel + // scale (independent of the PCF radius extra.y; a diffusion-scaled bias lifts + // shadows off their casters), each texel compared against the receiver plane's + // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid + // jittered by an integer hash (bit-identical across backends) of the sub-texel + // cell and tap index, and bilinear compares of the four nearest texel centres. + // extra.y is the wanted penumbra in output pixels; dividing by the receiver's + // texel footprint keeps it in output pixels at any map resolution, and the reach + // is at least two texels so hard edges ramp over texel steps instead of showing them. + let bias = extra.x * texel * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0))); let sz = i32(size); + let nv = vec3(dot(r0.xyz, ng), dot(r1.xyz, ng), dot(r2.xyz, ng)); + var den0 = nv.z; + if (!ortho) { + den0 = nv.x * ((u - c) / focal) + nv.y * ((v - c) / focal) + nv.z; + } + var t = vec2(0.0, 0.0); + if (den0 < -1e-6) { + t = vec2(clamp(nv.x / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), clamp(nv.y / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX)); + } + let iz = 1.0 / z; + let iq = 1.0 / (focal * z); + var reach = clamp(extra.y / texel, 2.0, 256.0); + if (!(reach < 1e30)) { + reach = 1.0; + } + let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); + let step = reach / f32(n - 1); + let half = f32(n - 1) * 0.5; + let cx = i32(floor(u * 8.0)); + let cy = i32(floor(v * 8.0)); var lit = 0.0; - for (var j = -2; j <= 2; j = j + 1) { - for (var i = -2; i <= 2; i = i + 1) { - let x = i32(floor(u + f32(i) * step)); - let y = i32(floor(v + f32(j) * step)); - if (x < 0 || y < 0 || x >= sz || y >= sz) { - lit = lit + 1.0; - continue; - } - let d = shadow_texels[offset + u32(y * sz + x)]; - if (z - bias <= d) { - lit = lit + 1.0; - } + for (var j = 0; j < n; j = j + 1) { + for (var i = 0; i < n; i = i + 1) { + let h = tap_hash(cx + i * 7919, cy + j * 7919); + let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5; + let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5; + let su = u + ((f32(i) - half) + jx) * step; + let sv = v + ((f32(j) - half) + jy) * step; + // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the + // four is compared against the receiver plane at that centre. + let tu = su - 0.5; + let tv = sv - 0.5; + let fx = tu - floor(tu); + let fy = tv - floor(tv); + let x0 = i32(floor(tu)); + let y0 = i32(floor(tv)); + let h00 = shadow_open(offset, sz, x0, y0, ortho, z, texel, bias, t, u, v, iz, iq); + let h10 = shadow_open(offset, sz, x0 + 1, y0, ortho, z, texel, bias, t, u, v, iz, iq); + let h01 = shadow_open(offset, sz, x0, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq); + let h11 = shadow_open(offset, sz, x0 + 1, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq); + let a = h00 + (h10 - h00) * fx; + let b = h01 + (h11 - h01) * fx; + lit = lit + a + (b - a) * fy; } } - return lit / 25.0; + return lit / f32(n * n); } -fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, p: vec3, n_l: f32) -> f32 { +fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, p: vec3, ng: vec3, n_l: f32) -> f32 { if (shadow < 0) { return 1.0; } @@ -242,7 +314,7 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, face = select(5u, 4u, d.z > 0.0); } } - let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, n_l); + let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, ng, n_l); return 1.0 - darkness * (1.0 - f); } @@ -330,7 +402,7 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 FOut { if (!front) { n = -n; } + // Shadow lookups follow the geometric surface, not the normal map. + let ng = n; let tex_n = i32(m3.z); if (tex_n >= 0) { let t = sample_tex(tex_n, i.uv); @@ -446,7 +520,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { } var sh = 1.0; if (receives_shadows) { - sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, n_l); + sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, ng, n_l); } let b = brdf(rgb, metallic, rough, spec_k, diff_k, n, v, to_l); acc = acc + b * l2.xyz * k * sh; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 6b8641dd9..f616a2d9d 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -100,11 +100,36 @@ fn fxc_sc_weights(c0: vec3) -> array { return w; } +// Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the +// gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray). +// The weights are effects::BW_TINT_LUMA (checked by the test shader_constants_match_the_cpu). +fn fxc_bw_tint(tint: vec3, gray: f32) -> vec3 { + if (!(gray > 0.0)) { + return vec3(0.0); + } + if (gray >= 1.0) { + return vec3(gray); + } + let w = vec3(0.3, 0.59, 0.11); + let c = tint + vec3(gray - dot(tint, w)); + let l = dot(c, w); + let n = min(min(c.x, c.y), c.z); + let x = max(max(c.x, c.y), c.z); + var o = c; + if (n < 0.0 && l - n > 1e-9) { + o = vec3(l) + (o - vec3(l)) * l / (l - n); + } + if (x > 1.0 && x - l > 1e-9) { + o = vec3(l) + (o - vec3(l)) * (1.0 - l) / (x - l); + } + return o; +} + // u[0].x = op: // 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity // 2 Vibrance f[0] = (vibrance, saturation) // 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas); -// u[0].y = tint; f[2] = (tint hue, tint saturation) +// u[0].y = tint; f[2] = tint colour // 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend // 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend) // 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome @@ -159,7 +184,7 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { let gray = max(mn + (mx - mn) * wt, 0.0); var o = vec3(gray); if (P.u[0].y != 0u) { - o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); + o = fxc_bw_tint(P.f[2].xyz, gray); } px = vec4(o * a, a); } @@ -167,7 +192,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { case 4u: { if (a > 0.0) { let c = px.xyz / a; - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as effects::color_fx::tritone: effects::LUMA_601 (checked by + // the test shader_constants_match_the_cpu). + let l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index f46b2f0ba..bca00ce1b 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { let dx = f32(p.x) + 0.5 - q.x; let dy = f32(p.y) + 0.5 - q.y; let d2 = dx * dx + dy * dy; - let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + let w = 1.0 / (d2 + bl); acc += P.f[4 + i].xyz * w; wsum += w; } diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index a28ecc65c..bee93ea65 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,6 +62,12 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. +// Luma Key / Extract luminance: Rec. 601, effects::LUMA_601 (checked by the test +// shader_constants_match_the_cpu). +fn fxk_key_luma(c: vec3) -> f32 { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); t = powz(t, 1.0 / max(gamma, 0.01)); @@ -195,27 +201,17 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { o = vec4(select(1.0, 0.0, d <= f0.w)); } case 2u: { - let l = luminance(c); + let l = fxk_key_luma(c); let thr = f0.x; let tol = f0.y; var m: f32; + // As effects::keying::luma_key: Brighter / Darker cut hard at the threshold + // (Tolerance only shapes Similar / Dissimilar), as After Effects. switch u0.y { - case 0u: { - if (tol > 0.0) { - m = clamp((thr + tol - l) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l > thr); - } - } + case 0u: { m = select(1.0, 0.0, l > thr); } case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); } case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); } - default: { - if (tol > 0.0) { - m = clamp((l - (thr - tol)) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l < thr); - } - } + default: { m = select(1.0, 0.0, l < thr); } } o = vec4(m); } @@ -241,7 +237,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { case 2u: { v = c.y; } case 3u: { v = c.z; } case 4u: { v = a; } - default: { v = luminance(c); } + default: { v = fxk_key_luma(c); } } v = 255.0 * v; let bp = f0.x; diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 1f62d4429..5e68e10c1 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -538,7 +538,7 @@ fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 { let z0 = floor(z); let tx = x - x0; let ty = y - y0; - let fz = fade(z - z0); + let fz = z - z0; let a = fxn_slice(i32(x0), i32(y0), i32(z0), tx, ty, seed, nt); let b = fxn_slice(i32(x0), i32(y0), i32(z0) + 1, tx, ty, seed, nt); return a + (b - a) * fz; diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index 39db34588..c99d199c5 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -20,6 +20,14 @@ fn fxt_rem360(x: f32) -> f32 { } // Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +// color_fx::levels_gamma: unclipped values below 0 mirror the curve. +fn fxt_gamma(t: f32, g: f32) -> f32 { + if (t >= 0.0) { + return powz(t, 1.0 / g); + } + return -powz(-t, 1.0 / g); +} + fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { let o = i * 6u; if (data[o + 5u] != 0.0) { @@ -32,7 +40,7 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { if (clip_w) { t = min(t, 1.0); } - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01)); } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). @@ -44,8 +52,7 @@ fn fxt_level(o: u32, v: f32) -> f32 { if (data[o + 6u] != 0.0) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / data[o + 2u]); - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, data[o + 2u]); } fn fxt_level_ident(o: u32) -> bool { @@ -177,7 +184,7 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let aa = max(a, 0.0); var nc: vec3; for (var i = 0u; i < 3u; i++) { - nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + nc[i] = fxt_lv(0u, fxt_lv(i + 1u, c[i], cb, cw), cb, cw); } let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); o = vec4(nc * na, na); diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl index 379b64469..5037ec9a6 100644 --- a/crates/gpu/src/shaders/fx_transition.wgsl +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -569,9 +569,11 @@ fn ftr_bender(@builtin(global_invocation_id) gid: vec3) { } // ---------------------------------------------------------------- CC Blobbylize (distort3::cc_blobbylize) -// aux = the blurred blob plane (x). f[0] = (depth, cut away, intensity, ambient); -// f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, point light height, -// point light); f[3] = distant light direction; f[4] = light colour. +// aux = the Softness-blurred blob plane (x). f[0] = (cut away, intensity, ambient, +// matte slope); f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, +// point light height, point); f[3] = distant light direction; f[4] = light colour; +// f[5] = (matte depth, RGB depth, light slope, matte antialias width); +// f[6].x = displacement clamp. @compute @workgroup_size(16, 16) fn ftr_blob(@builtin(global_invocation_id) gid: vec3) { @@ -579,32 +581,55 @@ fn ftr_blob(@builtin(global_invocation_id) gid: vec3) { if (!ftr_inside(p)) { return; } - let depth = P.f[0].x; - let cut = P.f[0].y; + let cut = P.f[0].x; + if (cut >= 1.0) { + // Endpoint guard: an opaque source can reach h = 1.0, where the gradient antialias + // would otherwise leave a half-covered pixel at Cut Away 100. + textureStore(out, p, vec4(0.0)); + return; + } let hv = textureLoad(aux, p, 0).x; let g = ftr_grad(p); + let grad = length(g); + var m = 0.0; + if (grad > 1e-9) { + let aa = max(P.f[5].w * grad, 5e-4); + m = clamp(0.5 + (hv - cut) / aa, 0.0, 1.0); + } else if (hv > cut) { + m = 1.0; + } + if (m <= 0.0) { + textureStore(out, p, vec4(0.0)); + return; + } let f = ftr_centre(p); - let s = sample_bilinear(src, f.x - g.x * depth * 2.0, f.y - g.y * depth * 2.0); - let c = ftr_unpremul(s); - let a = max(s.w, 0.0); - let m = clamp((hv - cut) / max((1.0 - cut) * 0.5, 1e-3), 0.0, 1.0); - let alpha = max(a, s.w) * m; + // Softness-scaled bevel warp of the source matte (normal z = 1). + let na = normalize(vec3(-g.x * P.f[0].w, -g.y * P.f[0].w, 1.0)); + let sa = sample_bilinear(src, f.x + na.x * P.f[5].x, f.y + na.y * P.f[5].x); + let alpha = max(sa.w, 0.0) * m; if (alpha <= 0.0) { textureStore(out, p, vec4(0.0)); return; } - let n = normalize(vec3(-g.x * depth, -g.y * depth, 1.0)); + // Height-field refraction of the colour (normal z = height), independent of Softness. + let hz = max(hv, 1e-3); + let d = -g * P.f[5].y / hz; + let lim = P.f[6].x; + let disp = vec2(clamp(d.x, -lim, lim), clamp(d.y, -lim, lim)); + let srgb = sample_bilinear(src, f.x + disp.x, f.y + disp.y); + let c = ftr_unpremul(srgb); + let n = normalize(vec3(-g.x * P.f[5].z, -g.y * P.f[5].z, 1.0)); var l = P.f[3].xyz; if (P.f[2].w != 0.0) { - l = normalize(vec3(P.f[2].x - f32(p.x), P.f[2].y - f32(p.y), P.f[2].z)); + l = normalize(vec3(P.f[2].x - f.x, P.f[2].y - f.y, P.f[2].z)); } - let intensity = P.f[0].z; + let intensity = P.f[0].y; let ndl = max(dot(n, l), 0.0); let h3 = normalize(vec3(l.x, l.y, l.z + 1.0)); let sp = powz(max(dot(n, h3), 0.0), P.f[1].z) * P.f[1].y; let lc = P.f[4].xyz; let metal = P.f[1].w; - let lit = (P.f[0].w + P.f[1].x * ndl * intensity) * lc; + let lit = (P.f[0].z + P.f[1].x * ndl * intensity) * lc; let spec_col = (1.0 - metal) * lc + metal * c * lc; let o = c * lit + (sp * intensity) * spec_col; textureStore(out, p, vec4(o * alpha, alpha)); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf4..5bb69693e 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -561,7 +561,7 @@ fn soft_linear_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { let wy = bspline(y + 0.5 - y0); let a = soft_linear_slice(i32(x0), i32(y0), i32(z0), wx, wy, seed); let b = soft_linear_slice(i32(x0), i32(y0), i32(z0) + 1, wx, wy, seed); - let n = a + (b - a) * fade(z - z0); + let n = a + (b - a) * (z - z0); return 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN; } @@ -605,7 +605,9 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as After Effects: effects::LUMA_601 (checked by the test + // shader_constants_match_the_cpu). + let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; let t = blk + (wht - blk) * l; @@ -623,14 +625,20 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } else if (P.u[0].y == 2u) { // Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681). var o = c; - let g = max(1.0 + 0.53 * ct, 0.05); + // Negative settings invert the positive curves (reciprocal exponents). + var g = max(1.0 + 0.53 * abs(ct), 0.05); + if (ct < 0.0) { + g = 1.0 / g; + } + var e = exp2(1.24 * abs(br)); + if (br < 0.0) { + e = 1.0 / e; + } for (var i = 0; i < 3; i++) { if (c[i] < 1.0) { var v = max(c[i], 0.0); - if (br > 0.0) { - v = 1.0 - powz(1.0 - v, exp2(1.24 * br)); - } else if (br < 0.0) { - v = powz(v, exp2(-1.32 * br)); + if (br != 0.0) { + v = 1.0 - powz(1.0 - v, e); } if (ct != 0.0) { if (v < 0.5) { @@ -702,7 +710,12 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].z != 0u) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / g); + // color_fx::levels_gamma: unclipped values below 0 mirror the curve. + if (t >= 0.0) { + t = powz(t, 1.0 / g); + } else { + t = -powz(-t, 1.0 / g); + } o[i] = ob + (ow - ob) * t; } px = vec4(o * a, a); @@ -713,14 +726,16 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = straight(px); var o = vec3(0.0); for (var i = 0; i < 3; i++) { - // Per-channel gain, offset, gamma; negative values are mirrored. + // Per-channel gain, offset, gamma; negative values are mirrored. The + // linear-light conversion is a pure 2.2 power: effects::EXPOSURE_LINEAR_GAMMA + // (checked by the test shader_constants_match_the_cpu). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); } else { - let lin = srgb_to_linear(max(c[i], 0.0)); + let lin = sign(c[i]) * powz(abs(c[i]), 2.2); let x = lin * P.f[0][i] + P.f[1][i]; - o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i])); + o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2); } } px = vec4(o * a, a); @@ -753,17 +768,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = P.f[0]; let invert = P.u[0].y != 0u; let opacity = P.f[1].x; + // The fill colour at the layer's (or inverted) alpha times Opacity (color_fx::fill). var fa = px.w; if (invert) { fa = 1.0 - px.w; } - let f = c.xyz * fa; - let rgb = px.xyz + (f - px.xyz) * opacity; - var alpha = px.w; - if (invert) { - alpha = px.w + (fa - px.w) * opacity; - } - px = vec4(rgb, alpha); + fa = fa * opacity; + px = vec4(c.xyz * fa, fa); } case 8u: { // effects::color2::map_ca: transparent pixels only change through the alpha curve. diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c9614..b2e1113e2 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -79,6 +79,35 @@ pub(crate) fn on_gl() -> bool { gpu().is_some_and(|g| g.ctx.name.ends_with("(Gl)")) } +/// The test device is Mesa's software rasteriser, llvmpipe: on OpenGL, or as lavapipe on Vulkan +/// (the Linux CI gate). +pub(crate) fn on_llvmpipe() -> bool { + gpu().is_some_and(|g| g.ctx.name.starts_with("llvmpipe")) +} + +thread_local! { + static LLVMPIPE_FLIPS: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Until the guard drops, comparisons on lavapipe get [`GL_BOUNDARY_FLIPS`] too. Only the tests +/// that measured such flips there opt in (CC HexTile cell edges, CC Threshold RGB thresholds); +/// every other comparison on the Linux gate stays exact. +pub(crate) fn allow_llvmpipe_boundary_flips() -> impl Drop { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + LLVMPIPE_FLIPS.with(|f| f.set(false)); + } + } + LLVMPIPE_FLIPS.with(|f| f.set(true)); + Reset +} + +/// The boundary-flip allowance applies: OpenGL, or llvmpipe in a test that opted in. +fn boundary_flips_allowed() -> bool { + on_gl() || (LLVMPIPE_FLIPS.with(std::cell::Cell::get) && on_llvmpipe()) +} + /// On GL, the fraction of pixels allowed past the tolerance: pixels on a decision boundary (a /// hex cell's edge, a threshold, a corner pin's coverage edge, a median's rank tie) may land on /// the other side, since GL drivers may divide through a reciprocal and GLSL's `fma` need not be @@ -255,7 +284,7 @@ pub(crate) fn check(label: &str, d: Option, allow: f64) { let frac = d.over as f64 / d.total as f64; // Quantised depths: rounding-boundary flips (see the module docs). let (allow, cap) = if d.quantized && allow == 0.0 { (0.003, 4.0 / 255.0 + 1e-6) } else { (allow, f32::INFINITY) }; - let (allow, cap) = if on_gl() { (allow.max(GL_BOUNDARY_FLIPS), f32::INFINITY) } else { (allow, cap) }; + let (allow, cap) = if boundary_flips_allowed() { (allow.max(GL_BOUNDARY_FLIPS), f32::INFINITY) } else { (allow, cap) }; assert!(frac <= allow && d.max <= cap, "{label}: {} of {} pixels over tolerance (max {:.6}); worst {:?}", d.over, d.total, d.max, d.worst); if d.over > 0 { eprintln!("{label}: {} of {} pixels over tolerance (max {:.6})", d.over, d.total, d.max); @@ -446,15 +475,19 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } + let expect_empty = id == "ec.distort.ccblobbylize" && vals.iter().any(|(k, v)| *k == "cutAway" && matches!(v, Value::Scalar(x) if *x == 100.0)); for scale in [1.0, 0.5] { let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.25, layer_size: size, seed: 11, adjustment, env: Default::default() }; let cpu = (spec.render)(&ctx(), buf.clone()); let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + if expect_empty { + assert!(out.img.data.iter().all(|p| p[3] == 0.0), "{id} {vals:?} adj {adjustment} scale {scale}: Cut Away 100 produced non-zero alpha"); + } assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry"); let d = diff(&cpu.img, &out.img, 1e-3); - let allow = if on_gl() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 }; + let allow = if boundary_flips_allowed() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 }; assert!( d.over <= allow, "{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", @@ -491,12 +524,27 @@ pub(crate) fn c(r: f64, g: f64, b: f64) -> Value { Value::Color([r, g, b, 1.0]) } +#[test] +fn small_blurs_fall_back_to_the_cpu() { + // #1939: small Gaussian sigmas and fractional box radii render on the CPU kernel. + for (id, vals) in [("ec.blur.gaussian", [("blurriness", n(0.9))]), ("ec.blur.fastbox", [("radius", n(1.5))])] { + let mut s = Scene::new(BitDepth::Bpc32); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(70, 44); + s.effect(&mut l, id, &vals); + s.push(l); + check(&format!("{id} {vals:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } +} + #[test] fn gpu_effects_match_cpu() { - effect_case("ec.blur.gaussian", &[("blurriness", n(9.0))]); - effect_case("ec.blur.gaussian", &[("blurriness", n(4.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]); - effect_case("ec.blur.fastbox", &[("radius", n(5.0)), ("iterations", n(2.0))]); - effect_case("ec.blur.fastbox", &[("radius", n(3.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]); + // Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939). + effect_case("ec.blur.gaussian", &[("blurriness", n(14.0))]); + effect_case("ec.blur.gaussian", &[("blurriness", n(12.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]); + effect_case("ec.blur.fastbox", &[("radius", n(6.0)), ("iterations", n(2.0))]); + effect_case("ec.blur.fastbox", &[("radius", n(4.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]); effect_case("ec.blur.directional", &[("direction", n(30.0)), ("length", n(12.0))]); effect_case("ec.stylize.glow", &[("threshold", n(40.0)), ("radius", n(14.0)), ("intensity", n(1.5))]); effect_case("ec.stylize.glow", &[("colors", Value::Enum(1)), ("operation", Value::Enum(1)), ("colorA", c(1.0, 0.8, 0.1))]); @@ -851,6 +899,52 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } } +/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB +/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`). +#[test] +fn gpu_tint_defaults_match_after_effects_greys() { + let Some(g) = gpu() else { + // Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead). + eprintln!("gpu_tint_defaults_match_after_effects_greys: skipped: no GPU adapter"); + return; + }; + for (col, 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 mut s = Scene::new(BitDepth::Bpc32); + s.p.settings.working_space = Some(ColorSpace::Srgb); + let mut l = s.solid(col, 97, 61); + s.effect(&mut l, "ec.color.tint", &[]); + s.push(l); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let o = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame").get(48, 30); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + +/// WGSL has no way to read the CPU's constants, so the kernels repeat them as literals; this +/// ties each literal to its Rust source (runs without a GPU): Rec. 601 luma for Tint (`pointwise` +/// op 1), Tritone and Luma Key / Extract, Black & White's tint weights and Exposure's 2.2 power. +#[test] +fn shader_constants_match_the_cpu() { + use effectcraft_effects::{BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, LUMA_601}; + let kernels = include_str!("shaders/kernels.wgsl"); + let color = include_str!("shaders/fx_color.wgsl"); + let key = include_str!("shaders/fx_key.wgsl"); + let [r, g, b] = LUMA_601; + let dot = format!("dot(c, vec3({r}, {g}, {b}))"); + assert!(kernels.contains(&format!("clamp({r} * c.x + {g} * c.y + {b} * c.z, 0.0, 1.0)")), "Tint luma in kernels.wgsl is not {LUMA_601:?}"); + assert!(color.contains(&format!("clamp({dot}, 0.0, 1.0)")), "Tritone luma in fx_color.wgsl is not {LUMA_601:?}"); + assert!(key.contains(&format!("return {dot};")), "fxk_key_luma in fx_key.wgsl is not {LUMA_601:?}"); + let [r, g, b] = BW_TINT_LUMA; + assert!(color.contains(&format!("let w = vec3({r}, {g}, {b});")), "fxc_bw_tint weights are not {BW_TINT_LUMA:?}"); + let gamma = EXPOSURE_LINEAR_GAMMA; + assert!( + kernels.contains(&format!("powz(abs(c[i]), {gamma})")) && kernels.contains(&format!("1.0 / {gamma})")), + "Exposure's power in kernels.wgsl is not {gamma}" + ); +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] @@ -1071,3 +1165,13 @@ fn upload_cache_drops_textures_of_freed_buffers() { let kept = g.ctx.upload_entries(); assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})"); } + +/// The device the compositor owns asks for `MemoryUsage` so freed textures give +/// their allocator blocks back, while its label and limits stay as they were. +#[test] +fn owned_devices_favour_memory_usage() { + let d = crate::context::device_descriptor(wgpu::Limits::default()); + assert!(matches!(d.memory_hints, wgpu::MemoryHints::MemoryUsage)); + assert_eq!(d.label, Some("effectcraft gpu")); + assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d); +} diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 231dc9763..21422aa6b 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -167,6 +167,44 @@ fn gpu_matches_cpu_shadow_catcher() { compare(&s, &g); } +#[test] +fn gpu_matches_cpu_wide_penumbrae() { + // Both lights cast with wide Shadow Diffusion. Penumbrae cover much of the frame, so a + // backend drift in the PCF (jitter hash, tap grid, receiver-plane compare) would hide + // inside the scene-wide 1.5 % silhouette budget; here every shadowed pixel off a + // silhouette must match the CPU within 2/255. + let Some(g) = gpu() else { return }; + let (mut p, cid) = scene_project(); + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[1], "lightOptions/shadowDiffusion", Value::Scalar(48.0)); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(true)); + set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0)); + let s = scene(&p, cid); + let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster")); + for l in &mut p.comp_mut(cid).unwrap().layers[..2] { + set(l, "lightOptions/castsShadows", Value::Bool(false)); + } + let unshadowed = adv::raster::render(&scene(&p, cid)); + let w = cpu.width as usize; + let luma = |c: &[f32; 4]| (c[0] + c[1] + c[2]) / 3.0; + // Off a silhouette: the pixel and its four neighbours see one continuous surface. + let smooth = |i: usize| -> bool { + let (x, y) = (i % w, i / w); + let d = cpu.depth[i]; + x > 0 + && y > 0 + && x + 1 < w + && i + w < cpu.depth.len() + && d.is_finite() + && [i - 1, i + 1, i - w, i + w].iter().all(|&j| (cpu.depth[j] - d).abs() < 0.01 * d.abs().max(1.0)) + }; + let shadowed: Vec = (0..cpu.color.len()).filter(|&i| luma(&unshadowed.color[i]) - luma(&cpu.color[i]) > 0.01 && smooth(i)).collect(); + let bad = shadowed.iter().filter(|&&i| (0..4).any(|k| (cpu.color[i][k] - gt.color[i][k]).abs() / cpu.color[i][k].abs().max(1.0) > 2.0 / 255.0)).count(); + eprintln!("adv3d wide penumbrae: {bad}/{} shadowed pixels beyond 2/255", shadowed.len()); + assert!(shadowed.len() > 2000, "the shadows cover a large area: {}", shadowed.len()); + assert!(bad as f64 <= 0.002 * shadowed.len() as f64, "{bad}/{} shadowed pixels differ", shadowed.len()); +} + #[test] fn gpu_raster_perf_smoke() { let Some(g) = gpu() else { return }; diff --git a/crates/gpu/src/tests_fx_depth.rs b/crates/gpu/src/tests_fx_depth.rs index b84b4849d..8a6665655 100644 --- a/crates/gpu/src/tests_fx_depth.rs +++ b/crates/gpu/src/tests_fx_depth.rs @@ -251,6 +251,18 @@ fn ids_and_cryptomatte() { case("ec.3d.cryptomatte", &[("selection", Value::Str("alpha".into())), ("matteOnly", on())]); } +/// The GPU skips the ID colours for displays that do not read them +/// ([`effectcraft_effects::CryptoAux::for_display`]): every display × Matte Only must still match +/// the CPU effect, so a shader that starts reading RGB in another mode fails here. +#[test] +fn cryptomatte_every_display_matches_the_cpu() { + for display in 0..3 { + for matte_only in [false, true] { + case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into())), ("display", e(display)), ("matteOnly", Value::Bool(matte_only))]); + } + } +} + #[test] fn extractor() { case("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("green", Value::Str("UV.U".into())), ("whitePoint", n(1000.0))]); diff --git a/crates/gpu/src/tests_fx_extra.rs b/crates/gpu/src/tests_fx_extra.rs index b7fe6cbd2..136a66d2f 100644 --- a/crates/gpu/src/tests_fx_extra.rs +++ b/crates/gpu/src/tests_fx_extra.rs @@ -46,6 +46,7 @@ fn bevels_and_legacy_gaussian() { effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]); effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]); effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]); - effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(7.0))]); - effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(5.0)), ("blurDimensions", e(2))]); + // Sigma 3 px and up at half scale too: smaller ones render on the CPU (#1939). + effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(14.0))]); + effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(12.0)), ("blurDimensions", e(2))]); } diff --git a/crates/gpu/src/tests_fx_generate.rs b/crates/gpu/src/tests_fx_generate.rs index e15abb37e..ebe2f2bf0 100644 --- a/crates/gpu/src/tests_fx_generate.rs +++ b/crates/gpu/src/tests_fx_generate.rs @@ -31,6 +31,8 @@ fn cc_radial_fast_blur() { effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]); effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]); effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]); + // Center outside the layer: the buffer grows so the streaks pass its edge (#2095). + effect_case("ec.blur.ccradialfast", &[("amount", n(80.0)), ("center", pt(-30.0, 20.0))]); } #[test] diff --git a/crates/gpu/src/tests_fx_key.rs b/crates/gpu/src/tests_fx_key.rs index 5ece27e69..35303bb0b 100644 --- a/crates/gpu/src/tests_fx_key.rs +++ b/crates/gpu/src/tests_fx_key.rs @@ -155,6 +155,9 @@ fn refine_mattes() { fn channel_effects() { effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]); effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]); + // #1797: the constant sources at the end of the menu, where the GPU's order could drift from + // the CPU's (Full On, Full Off, Half On). + effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(8)), ("takeGreenFrom", e(9)), ("takeBlueFrom", e(10))]); effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]); effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]); for op in [0, 2, 3, 4, 8, 10, 12] { diff --git a/crates/gpu/src/tests_fx_pixel2.rs b/crates/gpu/src/tests_fx_pixel2.rs index 899955b85..4a7886afe 100644 --- a/crates/gpu/src/tests_fx_pixel2.rs +++ b/crates/gpu/src/tests_fx_pixel2.rs @@ -12,7 +12,7 @@ use effectcraft_project::{BitDepth, MaskMode}; use effectcraft_render::RenderOpts; use effectcraft_time::Tick; -use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern, set}; +use crate::tests::{Scene, allow_llvmpipe_boundary_flips, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern, set}; pub(crate) fn e(v: u32) -> Value { Value::Enum(v) @@ -193,6 +193,7 @@ fn composites_and_colour() { #[test] fn stylize() { + let _flips = allow_llvmpipe_boundary_flips(); for (done, flags) in [(30.0, [true, true, true, false]), (55.0, [false, true, false, true]), (70.0, [true, false, false, false]), (10.0, [false, false, true, true])] { diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs index 44e156aec..df99e1ced 100644 --- a/crates/gpu/src/tests_fx_stylize.rs +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -6,7 +6,7 @@ use effectcraft_project::BitDepth; use effectcraft_render::RenderOpts; use effectcraft_time::Tick; -use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; +use crate::tests::{Scene, allow_llvmpipe_boundary_flips, c, check, compare_at, effect_case, n, opts, set}; fn e(v: u32) -> Value { Value::Enum(v) @@ -22,6 +22,7 @@ fn pt(x: f64, y: f64) -> Value { #[test] fn posterize_thresholds_strobe_vignette() { + let _flips = allow_llvmpipe_boundary_flips(); effect_case("ec.stylize.posterize", &[("level", n(4.0))]); effect_case("ec.stylize.posterize", &[("level", n(9.0))]); effect_case("ec.stylize.threshold", &[("level", n(100.0))]); @@ -88,6 +89,8 @@ fn tiles_kaleida_repetile() { "ec.stylize.motiontile", &[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))], ); + // Output under 100 % on an adjustment crops the comp below (#1727). + effect_case("ec.stylize.motiontile", &[("outputWidth", n(60.0)), ("outputHeight", n(300.0)), ("mirrorEdges", on())]); effect_case("ec.stylize.cckaleida", &[]); effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]); effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]); @@ -224,3 +227,42 @@ fn transform_motion_blur() { check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0); } } + +/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the +/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes +/// back onto the canvas as on the CPU (#1727). +#[test] +fn adjustment_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0), (60.0, 80.0, 100.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut adj = s.solid([1.0, 1.0, 1.0], 97, 61); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut adj, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(adj); + let label = format!("adjustment Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +/// The same on a full-frame footage layer (#1991). +#[test] +fn layer_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.footage(97, 61); + s.effect(&mut l, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut l, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(l); + let label = format!("layer Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} diff --git a/crates/gpu/src/tests_fx_transition.rs b/crates/gpu/src/tests_fx_transition.rs index b4b3572a0..abe9273dc 100644 --- a/crates/gpu/src/tests_fx_transition.rs +++ b/crates/gpu/src/tests_fx_transition.rs @@ -126,6 +126,7 @@ fn distort() { "ec.distort.ccblobbylize", &[("property", e(4)), ("softness", n(3.0)), ("cutAway", n(30.0)), ("lightType", e(1)), ("lightPosition", pt(20.0, 10.0))], ); + effect_case("ec.distort.ccblobbylize", &[("cutAway", n(100.0))]); effect_case("ec.distort.ccblobbylize", &[("property", e(5)), ("metal", n(30.0)), ("roughness", n(0.2))]); } diff --git a/crates/gpu/src/tests_fx_warp.rs b/crates/gpu/src/tests_fx_warp.rs index 39bcba13c..1482c268b 100644 --- a/crates/gpu/src/tests_fx_warp.rs +++ b/crates/gpu/src/tests_fx_warp.rs @@ -2,6 +2,8 @@ //! Emboss, Cartoon) vs the CPU effects (the oracle): direct on a buffer at full and half //! resolution, as adjustment, and composited at 8 and 32 bpc. +use std::io::Write; + use effectcraft_effects::MaskShape; use effectcraft_keyframe::{ShapePath, Value}; use effectcraft_project::build::{self, Ids}; @@ -9,7 +11,7 @@ use effectcraft_project::{BitDepth, MaskMode}; use effectcraft_render::RenderOpts; use effectcraft_time::Tick; -use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern}; +use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, on_llvmpipe, opts, pattern}; fn e(v: u32) -> Value { Value::Enum(v) @@ -182,6 +184,12 @@ fn smear_and_reshape() { ], ]; for vals in reshape { + if on_llvmpipe() && vals.iter().any(|(k, _)| *k == "boundaryMask") { + // Straight to stderr: libtest captures `eprintln!` of passing tests, and the skip + // should show in the log. + let _ = writeln!(std::io::stderr(), "tests_fx_warp::smear_and_reshape: skipping ec.distort.reshape {vals:?} on llvmpipe (crashes lavapipe, #2238)"); + continue; + } direct_with_masks("ec.distort.reshape", vals); composite_with_masks("ec.distort.reshape", vals); } diff --git a/crates/gpu/src/tests_wgsl_grammar.rs b/crates/gpu/src/tests_wgsl_grammar.rs new file mode 100644 index 000000000..18fef7a0f --- /dev/null +++ b/crates/gpu/src/tests_wgsl_grammar.rs @@ -0,0 +1,191 @@ +//! WGSL rules naga accepts but stricter front-ends (Tint, i.e. the browser's +//! WebGPU, which receives the WGSL source verbatim on wasm) reject. +//! +//! WGSL gives the bitwise operators `&`, `|` and `^` no precedence against any other +//! binary operator: `a * b ^ c` must be written `(a * b) ^ c`, and `a & b == c` as +//! `(a & b) == c`. Only a chain of the same bitwise operator may go unparenthesised. + +use std::fs; +use std::path::Path; + +#[derive(Clone, Copy, PartialEq)] +enum Tok<'a> { + Operand, + Open, + Close, + Op(&'a str), + /// A unary prefix (`!`, `~`): the operator after it cannot be binary. + Prefix, + /// Ends an expression: `,` `;` `{` `}` `:` `=` compound assignment, `->`, `@`. + Sep, +} + +const BITWISE: [&str; 3] = ["&", "|", "^"]; + +/// Drop comments and template argument lists (`vec3`, `array>`), whose +/// angle brackets are not relational operators. +fn strip(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let b = src.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i..].starts_with(b"//") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else if b[i..].starts_with(b"/*") { + i += 2; + while i + 1 < b.len() && !b[i..].starts_with(b"*/") { + out.push(if b[i] == b'\n' { '\n' } else { ' ' }); + i += 1; + } + i += 2; + } else { + out.push(b[i] as char); + i += 1; + } + } + loop { + let mut changed = false; + let mut next = String::with_capacity(out.len()); + let c: Vec = out.chars().collect(); + let mut i = 0; + while i < c.len() { + if c[i] == '<' && i > 0 && (c[i - 1].is_alphanumeric() || c[i - 1] == '_') { + let mut j = i + 1; + while j < c.len() && (c[j].is_alphanumeric() || c[j] == '_' || c[j] == ',' || c[j] == ' ') { + j += 1; + } + if j < c.len() && c[j] == '>' { + i = j + 1; + changed = true; + continue; + } + } + next.push(c[i]); + i += 1; + } + out = next; + if !changed { + return out; + } + } +} + +/// `(line, message)` for every bitwise operator mixed with another binary operator at +/// the same parenthesis level. +fn violations(src: &str) -> Vec<(usize, String)> { + let s = strip(src); + let b = s.as_bytes(); + let mut out = vec![]; + // Binary operators seen at each open parenthesis level. + let mut levels: Vec> = vec![vec![]]; + let mut prev = Tok::Sep; + let (mut i, mut line) = (0, 1); + const OPS: [&str; 18] = ["&&", "||", "==", "!=", "<=", ">=", "<<", ">>", "<", ">", "+", "-", "*", "/", "%", "&", "|", "^"]; + while i < b.len() { + let ch = b[i]; + if ch == b'\n' { + line += 1; + i += 1; + continue; + } + if ch.is_ascii_whitespace() { + i += 1; + continue; + } + let tok = if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'.' { + // Identifier, member access or number (`1e-6`, `0x9E3779B9u`). + let start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.') { + if (b[i] == b'e' || b[i] == b'E') + && b[start].is_ascii_digit() + && !s[start..i].starts_with("0x") + && i + 1 < b.len() + && (b[i + 1] == b'-' || b[i + 1] == b'+') + { + i += 1; + } + i += 1; + } + Tok::Operand + } else if ch == b'(' || ch == b'[' { + i += 1; + levels.push(vec![]); + Tok::Open + } else if ch == b')' || ch == b']' { + i += 1; + if levels.len() > 1 { + levels.pop(); + } + Tok::Close + } else if b[i..].starts_with(b"->") || b[i..].starts_with(b"++") || b[i..].starts_with(b"--") { + i += 2; + Tok::Sep + } else if matches!(ch, b',' | b';' | b'{' | b'}' | b':' | b'@') { + i += 1; + Tok::Sep + } else if let Some(op) = OPS.iter().find(|op| b[i..].starts_with(op.as_bytes())) { + i += op.len(); + if b.get(i) == Some(&b'=') && !matches!(*op, "==" | "!=" | "<=" | ">=") { + i += 1; + Tok::Sep // compound assignment + } else { + Tok::Op(op) + } + } else if ch == b'=' { + i += 1; + Tok::Sep + } else { + i += 1; + Tok::Prefix + }; + match tok { + Tok::Sep => { + if let Some(l) = levels.last_mut() { + l.clear(); + } + } + Tok::Op(op) if matches!(prev, Tok::Operand | Tok::Close) => { + let level = levels.last_mut().expect("level"); + let mixes = |a: &str, b: &str| a != b && (BITWISE.contains(&a) || BITWISE.contains(&b)); + if let Some(other) = level.iter().find(|o| mixes(o, op)) { + out.push((line, format!("mixing '{other}' and '{op}' requires parentheses"))); + } + level.push(op); + } + _ => {} + } + prev = tok; + } + out +} + +#[test] +fn checker_flags_unparenthesised_bitwise_mixes() { + assert_eq!(violations("let h = u32(x) * 3u ^ u32(y) * 5u;").len(), 2); + assert_eq!(violations("let a = b & c == d;").len(), 1); + assert!(violations("let h = (u32(x) * 3u) ^ (u32(y) * 5u);").is_empty()); + assert!(violations("let h = a ^ b ^ c; let m = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;").is_empty()); + assert!(violations("let v: vec3 = vec3(1e-6, -x, a < b); if (a < b && c > d) { h ^= h >> 16u; }").is_empty()); + assert!(violations("var p = &x; let y = -a & b;").is_empty()); +} + +#[test] +fn shaders_parenthesise_bitwise_operators() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders"); + let mut bad = vec![]; + let mut files = 0; + for entry in fs::read_dir(&dir).expect("shader dir") { + let path = entry.expect("entry").path(); + if path.extension().is_some_and(|e| e == "wgsl") { + files += 1; + let src = fs::read_to_string(&path).expect("shader source"); + for (line, msg) in violations(&src) { + bad.push(format!("{}:{line}: {msg}", path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default())); + } + } + } + assert!(files > 10, "found the shaders: {files}"); + assert!(bad.is_empty(), "WGSL a strict front-end rejects:\n{}", bad.join("\n")); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b1209070a..b6e78870c 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -26,9 +26,7 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick return None; } let ctx = r.eval_ctx(comp_id, t)?; - let s = r.opts.scale; - let w = ((ctx.comp.width as f64 * s).round() as u32).max(1); - let h = ((ctx.comp.height as f64 * s).round() as u32).max(1); + let (w, h) = effectcraft_render::frame_size(ctx.comp.width, ctx.comp.height, r.opts.scale); if r.depth() > 16 || !e.g.fits(w, h) { return None; } @@ -357,7 +355,11 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv }; let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels); r.run_effects_on(ctx, layer, true, &mut fx); - let (mut adjusted, _, _) = fx.finish()?; + let (mut adjusted, offset, _) = fx.finish()?; + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = effectcraft_effects::adjustment_crop(offset, [0.0; 2], (adjusted.width, adjusted.height), (w, h)) { + adjusted = e.crop(&adjusted, dx, dy, w, h); + } if adjusted.width != w || adjusted.height != h { return Some(()); } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index bdb4e2bc3..577492a53 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -82,7 +82,7 @@ impl Exporter for FileExporter { /// A new session with media, import, expressions, scripting and export enabled. pub fn session() -> Session { - Session { + let mut s = Session { // The desktop app, the CLI and the MCP server share installed Roto Brush models. models_dir: config_dir().map(|d| d.join("models")), exporter: Some(Arc::new(FileExporter::default())), @@ -90,11 +90,11 @@ pub fn session() -> Session { importer: Some(Arc::new(MediaImporter)), expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), - script: Some(effectcraft_script::runner), - script_ui: effectcraft_engine::scriptui::ScriptUi { dispatch: Some(effectcraft_script::dispatch_ui), ..Default::default() }, plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader), ..Default::default() - } + }; + effectcraft_script::install(&mut s); + s } /// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides): @@ -109,6 +109,20 @@ pub fn config_dir() -> Option { mod tests { use serde_json::json; + #[test] + fn host_runs_tasks_scheduled_by_panel_handlers() { + let mut s = super::session(); + let result = s.execute("script.run", json!({"code": "var w=new Window('palette','Timer regression'); var b=w.add('button',undefined,'Import'); b.onClick=function(){app.scheduleTask(\"app.project.items.addComp('Scheduled',64,64,1,1,25)\",0,false);};w.show();"})).unwrap(); + assert_eq!(result["ok"], true, "{result}"); + s.execute("scriptui.click", json!({"window":"Timer regression","widget":"Import"})).unwrap(); + assert!(s.project.items.is_empty()); + let tick = s.execute("script.runTasks", json!({})).unwrap(); + assert_eq!(tick["ran"], 1, "{tick}"); + assert_eq!(tick["errors"], json!([]), "{tick}"); + assert!(s.project.items.values().any(|item| item.name == "Scheduled")); + s.execute("scriptui.close", json!({"window":"Timer regression"})).unwrap(); + } + /// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions /// evaluate to the camera's focus on the target. #[test] diff --git a/crates/host/tests/background_presets.rs b/crates/host/tests/it/background_presets.rs similarity index 100% rename from crates/host/tests/background_presets.rs rename to crates/host/tests/it/background_presets.rs diff --git a/crates/host/tests/it/cryptomatte_pick.rs b/crates/host/tests/it/cryptomatte_pick.rs new file mode 100644 index 000000000..69092ce95 --- /dev/null +++ b/crates/host/tests/it/cryptomatte_pick.rs @@ -0,0 +1,127 @@ +//! The Cryptomatte eyedropper on real OpenEXR footage (#473): `effect.pickCryptomatte` reads the +//! file's Cryptomatte ranks and manifest, edits the Selection, and the rendered matte follows, +//! including undo. + +use effectcraft_engine::Session; +use effectcraft_engine::time::Tick; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use serde_json::json; + +const RIGHT: &str = "Right, \"Box\""; + +/// An 8 × 4 EXR: white RGBA, and a CryptoObject layer with `Left` in columns before `split` and +/// the comma-and-quotes name from there, both named in its manifest. +fn crypto_exr(tag: &str, split: usize) -> String { + use exr::prelude::*; + let (w, h) = (8usize, 4usize); + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < split { "Left" } else { RIGHT }))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let manifest = json!({"Left": format!("{:08x}", crypto_hash("Left")), RIGHT: format!("{:08x}", crypto_hash(RIGHT))}).to_string(); + for (k, v) in [("name", "CryptoObject".to_string()), ("manifest", manifest)] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{k}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + // A folder of its own: numbered siblings would import as an image sequence. + let dir = std::env::temp_dir().join(format!("ec-crypto-pick-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("crypto.exr"); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +/// A session with the EXR as a layer of a 2 s comp, under a Cryptomatte effect. +fn footage(tag: &str, split: usize) -> (Session, String) { + let path = crypto_exr(tag, split); + let mut s = effectcraft_host::session(); + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + s.execute("comp.new", json!({"name": "Crypto", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + (s, path) +} + +/// The matte's alpha over the left and right halves. +fn matte(s: &Session) -> [f32; 2] { + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + [img.get(1, 1)[3], img.get(6, 2)[3]] +} + +#[test] +fn picking_on_a_cryptomatte_exr_edits_the_selection_and_the_matte() { + let (mut s, path) = footage("edit", 4); + s.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 1})).unwrap(); + assert_eq!(matte(&s), [0.0, 0.0], "nothing selected"); + let pick = |s: &mut Session, x: f64, mode: &str| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1, "mode": mode})).unwrap(); + + assert_eq!(pick(&mut s, 1.0, "replace")["selection"], "Left"); + assert_eq!(matte(&s), [1.0, 0.0]); + let r = pick(&mut s, 6.0, "add"); + assert_eq!((r["name"].as_str(), r["layer"].as_str()), (Some(RIGHT), Some("CryptoObject")), "{r}"); + assert_eq!(r["selection"], r#"Left, "Right, \"Box\"""#); + assert_eq!(matte(&s), [1.0, 1.0], "the quoted name selects the right half"); + pick(&mut s, 2.0, "remove"); + assert_eq!(matte(&s), [0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 0.0]); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// At half resolution a preview pixel shows the aux texel at its centre (layer x 3 for preview +/// pixel 1): `scale` makes the pick read that one, so what is clicked is what lights up. +#[test] +fn a_pick_at_preview_resolution_reads_the_pixel_the_preview_shows() { + let (mut s, path) = footage("half", 3); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 2.5, "y": 1.5, "scale": 0.5})).unwrap(); + assert_eq!(r["name"], RIGHT, "{r}"); + let opts = effectcraft_engine::render::RenderOpts { scale: 0.5, ..Default::default() }; + assert_eq!(s.render(s.active_comp_id().unwrap(), Tick::ZERO, opts).get(1, 0)[3], 1.0); + // At full resolution the same point is Left's pixel. + let r = s.execute_checked("effect.cryptomatteAt", json!({"effect": 1, "x": 2.5, "y": 1.5})).unwrap(); + assert_eq!(r["name"], "Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A keyframed Selection picked at `time` is edited at that time, not at the current time. +#[test] +fn a_keyframed_selection_is_edited_where_it_was_read() { + let (mut s, path) = footage("key", 4); + for t in [0.0, 1.0] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": format!("<{:08x}>", crypto_hash(RIGHT))})).unwrap(); + } + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 0.5, "y": 1.5, "mode": "add", "time": 1.0})).unwrap(); + assert_eq!((r["changed"].as_bool(), r["selected"].as_bool()), (Some(true), Some(true)), "{r}"); + let sel = |s: &mut Session, t: f64| { + let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t})).unwrap(); + r["selected"].as_bool() + }; + assert_eq!((sel(&mut s, 0.0), sel(&mut s, 1.0)), (Some(false), Some(true)), "only the key at 1 s gains Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A precomp whose 3D layer an expression moves: the pick finds it where the render keys it +/// (the pick's aux pass runs the session's expressions). +#[test] +fn a_pick_on_a_precomp_follows_expressions() { + let mut s = effectcraft_host::session(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap()["comp"].clone(); + let l = s.execute("layer.newSolid", json!({"name": "Ball", "width": 6, "height": 6})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute_checked("prop.setExpression", json!({"layer": l, "path": "transform/position", "expression": "[33, 10, 0]"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": inner})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 33.5, "y": 10.5})).unwrap(); + assert_eq!(r["selection"], "Ball", "{r}"); + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + assert_eq!((img.get(33, 10)[3], img.get(20, 10)[3]), (1.0, 0.0)); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 20.5, "y": 10.5})).unwrap(), serde_json::Value::Null); +} diff --git a/crates/host/tests/determinism.rs b/crates/host/tests/it/determinism.rs similarity index 100% rename from crates/host/tests/determinism.rs rename to crates/host/tests/it/determinism.rs diff --git a/crates/host/tests/exr_layers.rs b/crates/host/tests/it/exr_layers.rs similarity index 100% rename from crates/host/tests/exr_layers.rs rename to crates/host/tests/it/exr_layers.rs diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/it/footage_reload.rs similarity index 100% rename from crates/host/tests/footage_reload.rs rename to crates/host/tests/it/footage_reload.rs diff --git a/crates/host/tests/gif_animates.rs b/crates/host/tests/it/gif_animates.rs similarity index 100% rename from crates/host/tests/gif_animates.rs rename to crates/host/tests/it/gif_animates.rs diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs new file mode 100644 index 000000000..73fc04841 --- /dev/null +++ b/crates/host/tests/it/main.rs @@ -0,0 +1,14 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod background_presets; +mod cryptomatte_pick; +mod determinism; +mod exr_layers; +mod footage_reload; +mod gif_animates; +mod plugins; +mod relink; +mod tga_import; +mod timeline_interop; +mod vector_import; +mod webm_metadata; diff --git a/crates/host/tests/plugins.rs b/crates/host/tests/it/plugins.rs similarity index 100% rename from crates/host/tests/plugins.rs rename to crates/host/tests/it/plugins.rs diff --git a/crates/host/tests/relink.rs b/crates/host/tests/it/relink.rs similarity index 100% rename from crates/host/tests/relink.rs rename to crates/host/tests/it/relink.rs diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/it/tga_import.rs similarity index 100% rename from crates/host/tests/tga_import.rs rename to crates/host/tests/it/tga_import.rs diff --git a/crates/host/tests/timeline_interop.rs b/crates/host/tests/it/timeline_interop.rs similarity index 100% rename from crates/host/tests/timeline_interop.rs rename to crates/host/tests/it/timeline_interop.rs diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/it/vector_import.rs similarity index 99% rename from crates/host/tests/vector_import.rs rename to crates/host/tests/it/vector_import.rs index 49f8d950c..36d83dd1a 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -121,8 +121,8 @@ fn psd_16bit_cmyk_composition_imports() { assert!(d < 2.0 / 255.0, "mean diff {d}"); } -const SVG: &[u8] = include_bytes!("../../svg/tests/fixtures/basic-shapes.svg"); -const SVG2: &[u8] = include_bytes!("../../svg/tests/fixtures/paths-gradients.svg"); +const SVG: &[u8] = include_bytes!("../../../svg/tests/fixtures/basic-shapes.svg"); +const SVG2: &[u8] = include_bytes!("../../../svg/tests/fixtures/paths-gradients.svg"); #[test] fn svg_footage_and_shapes_from_vector_layer() { diff --git a/crates/host/tests/webm_metadata.rs b/crates/host/tests/it/webm_metadata.rs similarity index 100% rename from crates/host/tests/webm_metadata.rs rename to crates/host/tests/it/webm_metadata.rs diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index e0ee137ba..83774764d 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -327,7 +327,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { let a = v3(&keys[i.saturating_sub(1)].value); let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; - ([-tout[0], -tout[1], -tout[2]], tout) + // A side facing a key at the same position gets no tangent, so the segment between two + // identical keys has zero length and the layer holds still (#1675). + let p = v3(&keys[i].value); + let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] }; + let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout }; + (tin, tout) } /// Effective spatial tangents of key `i` (auto or stored). @@ -928,6 +933,26 @@ mod tests { assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3])); } + #[test] + fn auto_spatial_holds_between_identical_keys() { + let path = [(0.0, [0.0, 540.0]), (0.6, [400.0, 540.0]), (4.4, [400.0, 540.0]), (5.0, [0.0, 540.0])]; + for eased in [false, true] { + let keys: Vec = path + .iter() + .map(|&(t, v)| { + let k = Keyframe::new(s(t), Value::Vec2(v)); + if eased { k.eased() } else { k } + }) + .collect(); + assert_eq!(spatial_tangents(&keys, 1).1, [0.0; 3]); + assert_eq!(spatial_tangents(&keys, 2).0, [0.0; 3]); + for t in [0.7, 1.0, 2.5, 4.0, 4.3] { + let p = evaluate(&keys, s(t), true).unwrap().as_vec3(); + assert!((p[0] - 400.0).abs() < 1e-6 && (p[1] - 540.0).abs() < 1e-6, "eased={eased} t={t} p={p:?}"); + } + } + } + #[test] fn set_key_replaces_same_time() { let mut keys = vec![]; diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index 1dff4477f..6609aed68 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -82,7 +82,18 @@ impl ShapePath { if i == 0 && f.segment == 0 && !self.closed { return false; } - if i > 0 && f.segment == i - 1 { + if i == 0 && self.closed && segs > 0 { + // The old closing segment and segment 0 merge into the new closing one. + if f.segment == segs { + f.segment = segs - 1; + f.t *= 0.5; + } else if f.segment == 0 { + f.segment = segs - 1; + f.t = 0.5 + f.t * 0.5; + } else { + f.segment -= 1; + } + } else if i > 0 && f.segment == i - 1 { f.t *= 0.5; } else if f.segment == i && i > 0 { f.segment = i - 1; diff --git a/crates/keyframe/tests/feather_remove.rs b/crates/keyframe/tests/feather_remove.rs new file mode 100644 index 000000000..06836bbf6 --- /dev/null +++ b/crates/keyframe/tests/feather_remove.rs @@ -0,0 +1,14 @@ +use effectcraft_keyframe::{FeatherPoint, ShapePath}; + +#[test] +fn removing_first_vertex_of_closed_path_merges_closing_segment() { + let mut p = ShapePath::rect([0.0, 0.0], 10.0, 10.0); + let fp = |segment| FeatherPoint { segment, t: 0.5, radius: 1.0, tension: 0.0 }; + p.feather = vec![fp(3), fp(0)]; + p.vertices.remove(0); + p.in_tangents.remove(0); + p.out_tangents.remove(0); + p.feather_after_remove(0); + let got: Vec<(usize, f64)> = p.feather.iter().map(|f| (f.segment, f.t)).collect(); + assert_eq!(got, vec![(2, 0.25), (2, 0.75)]); +} diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 7730017d8..b2eeece49 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -96,9 +96,11 @@ fn external_image(stored: &str, base_dir: &str) -> Result { let doc = std::path::Path::new(base_dir).join("lottie.json"); std::path::absolute(&doc).unwrap_or(doc) }); - match craft_docpath::resolve(stored, doc.as_deref()) { - Ok(path) if path.is_absolute() => Ok(path.to_string_lossy().into_owned()), - Ok(_) => Err(format!("{stored} is relative and the Lottie file's folder is not known")), + match craft_docpath::resolve_absolute(stored, doc.as_deref()) { + Ok(path) => Ok(path.to_string_lossy().into_owned()), + Err(craft_docpath::Refused::Invalid) if doc.is_none() && craft_docpath::resolve(stored, None).is_ok() => { + Err(format!("{stored} is relative and the Lottie file's folder is not known")) + } Err(why) => Err(format!("{stored}: {why}")), } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9a8ec5c28..4deefed35 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -45,6 +45,9 @@ struct Key { matte: [u32; 3], /// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded). size: (u32, u32), + /// A still at a reduced `size`: the footage's nominal size, which decides whether it is + /// resampled (see `Inner::still_at_size`); (0, 0) otherwise. + nominal: (u32, u32), } struct Entry { @@ -559,7 +562,7 @@ impl Inner { let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| { // Files the model names are read only when local or on the model's own network share. let resolve = |uri: &str| -> Option> { - let p = craft_docpath::resolve(uri, Some(std::path::Path::new(path))).ok().filter(|p| p.is_absolute())?; + let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?; self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec()) }; effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string()) @@ -678,7 +681,7 @@ impl Inner { FloatCurve::Gamma24 => 2, } << 2); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; - let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); @@ -708,16 +711,20 @@ impl Inner { } } - /// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that - /// size directly (see [`FootageSource::frame_at_size`]). + /// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]): + /// a movie's made at that size directly, a still's resampled once and kept at that size. fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } let mut loc = Self::locate(footage, t, float); let movie = loc.media_t.is_some(); - if movie && let Some(size) = size { + // A still asked for at its own size is the unsized frame (one decode, one entry). + if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) { loc.key.size = size; + if !movie { + loc.key.nominal = (footage.width, footage.height); + } } let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead @@ -773,34 +780,62 @@ impl Inner { #[cfg(target_arch = "wasm32")] fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} + /// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the + /// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a + /// reduced-resolution render neither decodes nor resamples a large still again for every frame + /// (#1812: 21120-px plates at Quarter). Like the renderer's `footage_buf`, an image decoded + /// larger than the footage's nominal size (a smart object's pixels) is resampled too, and one + /// decoded smaller is not (the renderer scales the layer instead). + fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image { + let (w, h) = loc.key.size; + let smaller = img.width == 0 || footage.width as f64 / img.width as f64 > 1.0 + 1e-9; + if w == 0 || h == 0 || smaller || (w, h) == (img.width, img.height) { + return img; + } + effectcraft_raster::resample(&img, w, h) + } + fn decode(&self, loc: &Loc, footage: &Footage) -> Result { + match loc.media_t { + None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)), + Some(_) => self.decode_media(loc, footage), + } + } + + fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result { + let op = AlphaOp::of(footage, loc.float); + let path = &loc.key.path; + if &**path == PLACEHOLDER { + return Ok(colour_bars(footage.width, footage.height)); + } + let bytes = self.read(path)?; + if let Some(img) = crate::dpx::decode(path, &bytes)? { + return Ok(dynamic_to_image(&img, op)); + } + if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { + return Ok(img); + } + // (an OpenEXR compression the decoder lacks came out transparent or as a missing + // channel error) + if path.to_ascii_lowercase().ends_with(".exr") + && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } + let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); + let img = match loaded { + Ok(img) => img, + // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. + Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + }; + Ok(dynamic_to_image(&img, op)) + } + + fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; match loc.media_t { - None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), - None => { - let bytes = self.read(path)?; - if let Some(img) = crate::dpx::decode(path, &bytes)? { - return Ok(dynamic_to_image(&img, op)); - } - if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { - return Ok(img); - } - // (an OpenEXR compression the decoder lacks came out transparent or as a missing - // channel error) - if path.to_ascii_lowercase().ends_with(".exr") - && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) - { - return Err(MediaError::Decode(format!("{path}: {why}"))); - } - let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); - let img = match loaded { - Ok(img) => img, - // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, - }; - Ok(dynamic_to_image(&img, op)) - } + None => self.decode_still(loc, footage), Some(mt) if gif_anim::is_animated(footage) => { let anim = self.gif(path)?; let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; @@ -1030,6 +1065,48 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // Regression test for #382 (#274): resampling footage audio divided by the requested mix + // rate, so a rate of 0 panicked (division by zero); the read must come back instead. + #[test] + fn decoding_audio_at_a_zero_mix_rate_does_not_divide_by_zero() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-zero-rate"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // One second of a 440 Hz tone, 44.1 kHz mono 16-bit PCM: not the 48 kHz mix rate. + let native = 44_100u32; + let pcm: Vec = + (0..native).flat_map(|n| (((n as f32 * 440.0 * std::f32::consts::TAU / native as f32).sin() * 8000.0) as i16).to_le_bytes()).collect(); + let mut wav = b"RIFF".to_vec(); + wav.extend((36 + pcm.len() as u32).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend([1u16, 1].iter().flat_map(|v| v.to_le_bytes())); + wav.extend(native.to_le_bytes()); + wav.extend((native * 2).to_le_bytes()); + wav.extend([2u16, 16].iter().flat_map(|v| v.to_le_bytes())); + wav.extend(b"data"); + wav.extend((pcm.len() as u32).to_le_bytes()); + wav.extend(&pcm); + let path = dir.join("tone.wav"); + std::fs::write(&path, &wav).unwrap(); + let footage = Footage { + path: path.to_string_lossy().into(), + kind: FootageKind::Audio, + duration: Tick::from_seconds_f64(1.0), + has_audio: true, + ..Default::default() + }; + let pool = MediaPool::new(); + // The file really is read, and resampled, at a normal rate... + let tone = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 1024, 48_000); + assert!(tone.iter().any(|v| v.abs() > 0.05), "the tone is audible at 48 kHz"); + // ...and a mix rate of 0 (which `audio_samples` itself turns away) returns a read of the + // requested length instead of dividing by it. + let degenerate = pool.try_decode_audio(&footage, Tick::from_seconds_f64(0.1), 64, 0).unwrap(); + assert_eq!(degenerate.len(), 128); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn frame_index_loops_and_clamps() { let r = FrameRate::FPS_30; @@ -1045,7 +1122,7 @@ mod tests { fn lru_respects_budget_and_recycles() { let mut c = Lru::default(); let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead - let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) }; + let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) }; let budget = 3 * (4096 + 64); for i in 0..10 { c.insert(k(i), img(), budget); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2793be251..6bba682b3 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -27,12 +27,21 @@ pub fn probe(path: impl AsRef) -> Result { } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { - let dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); - return probe_model(&path.to_string_lossy(), &bytes, &|uri| std::fs::read(dir.join(uri)).ok()); + return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path)); } probe_bytes(&path.to_string_lossy(), bytes.into()) } +/// Reads the files a model file names (buffers, textures, MTL files). Like the pool, it follows only +/// local files and files on the model's own network share: a URI is a path stored in the model. +fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> { + let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf()); + move |uri| { + let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?; + std::fs::read(p).ok() + } +} + /// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its /// longest animation. `resolve` reads sibling resources. pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { @@ -316,6 +325,20 @@ mod tests { (0..n).map(|k| (at(k + 1) - at(k)) as u32).collect() } + /// A model's side files are read from beside it; a URI to another computer is not followed. + #[test] + fn model_side_files_are_not_read_from_other_computers() { + let dir = std::env::temp_dir().join(format!("ec-probe-side-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("buf.bin"), b"abc").unwrap(); + let read = side_file_reader(&dir.join("model.gltf")); + assert_eq!(read("buf.bin"), Some(b"abc".to_vec())); + assert_eq!(read(r"\\192.0.2.1\share\buf.bin"), None); + assert_eq!(read("file://192.0.2.1/share/buf.bin"), None); + assert_eq!(read(r"C:\buf.bin"), None); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn millisecond_rounded_frame_times_average_to_their_rate() { assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]); diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/it/downmix.rs similarity index 100% rename from crates/media/tests/downmix.rs rename to crates/media/tests/it/downmix.rs diff --git a/crates/media/tests/exif_orientation.rs b/crates/media/tests/it/exif_orientation.rs similarity index 100% rename from crates/media/tests/exif_orientation.rs rename to crates/media/tests/it/exif_orientation.rs diff --git a/crates/media/tests/footage.rs b/crates/media/tests/it/footage.rs similarity index 94% rename from crates/media/tests/footage.rs rename to crates/media/tests/it/footage.rs index 13ca605e2..a2849dc1d 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -40,7 +40,11 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], "")); let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg"); assert!(st.success(), "ffmpeg failed for {name}"); - std::fs::rename(&tmp, &out).expect("rename"); + if std::fs::rename(&tmp, &out).is_err() { + // Another test made it first; on Windows the rename fails while that test has it open. + let _ = std::fs::remove_file(&tmp); + assert!(out.exists(), "rename {} to {name} failed", tmp.display()); + } Some(out) } @@ -580,6 +584,46 @@ fn sequence_gaps_show_placeholders() { let _ = std::fs::remove_dir_all(&dir); } +/// #1812: a still asked for at a reduced size comes back as the renderer would resample it, and +/// is kept at that size: later frames at that size neither decode nor resample it again (a +/// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter). +#[test] +fn stills_at_reduced_sizes_are_resampled_once() { + let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let p = dir.join("plate.png"); + let px = |x: u32, y: u32| { + image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }]) + }; + image::RgbaImage::from_fn(613, 97, px).save(&p).expect("png"); + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame"); + // At its own size it is the unsized frame: no second decode, no second full-size copy. + let misses = pool.stats().misses; + let native = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, f.width, f.height).expect("frame"); + assert!(Arc::ptr_eq(&full, &native), "the native size reuses the unsized frame"); + assert_eq!(pool.stats().misses, misses); + for (w, h) in [(306, 48), (153, 24), (77, 12)] { + let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample"); + let misses = pool.stats().misses; + let again = pool.frame_at_size(ItemId(1), &f, Tick(3 * TICKS_PER_SECOND), w, h).expect("frame"); + assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size"); + assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again"); + } + // Pixels larger than the nominal size (a smart object's) are resampled as the renderer does; + // smaller ones are left as they are (the renderer scales the layer up instead). + // One pool, one file, one requested size: the two decisions don't share a cache entry. + let larger = Footage { width: 306, height: 48, ..f.clone() }; + let smaller = Footage { width: 1226, height: 194, ..f.clone() }; + let sized = pool.frame_at_size(ItemId(2), &larger, Tick::ZERO, 153, 24).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24)); + let kept = pool.frame_at_size(ItemId(3), &smaller, Tick::ZERO, 153, 24).expect("frame"); + assert_eq!(*kept, *full); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn png_with_alpha() { let Some(p) = alpha_png() else { return }; @@ -833,7 +877,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() { } // ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries // channels 0 and 1 at full level and the others at half, so a multichannel file sounds as - // its stereo downmix (tests/downmix.rs checks that matrix channel by channel). + // its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel). let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false); let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::(); let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt(); diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs new file mode 100644 index 000000000..5f7fb8037 --- /dev/null +++ b/crates/media/tests/it/main.rs @@ -0,0 +1,7 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod downmix; +mod exif_orientation; +mod footage; +mod open_in_place; +mod tga; diff --git a/crates/media/tests/open_in_place.rs b/crates/media/tests/it/open_in_place.rs similarity index 100% rename from crates/media/tests/open_in_place.rs rename to crates/media/tests/it/open_in_place.rs diff --git a/crates/media/tests/tga.rs b/crates/media/tests/it/tga.rs similarity index 100% rename from crates/media/tests/tga.rs rename to crates/media/tests/it/tga.rs diff --git a/crates/opusenc/README.md b/crates/opusenc/README.md index 999d8a7e9..1ebf6fedb 100644 --- a/crates/opusenc/README.md +++ b/crates/opusenc/README.md @@ -130,7 +130,7 @@ weights) and the decoder-side normative arithmetic the encoder must reproduce bi allocation; SILK NLSF dequantisation and stabilisation, NLSF → LPC conversion, `log2lin`, the fixed-point division/inverse helpers and the LPC analysis filter) are mirrored from FilmCraft's first-party, spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0), which is also the -round-trip oracle in `tests/` and `crates/export/tests/opus_roundtrip.rs` (ffmpeg is a second, +round-trip oracle in `tests/` and `crates/export/tests/it/opus_roundtrip.rs` (ffmpeg is a second, external oracle when installed). All encoder-side analysis and search (pitch, LPC, NLSF and LTP search, noise feedback, rate control, stereo prediction, the decimator) is our own design. No third-party encoder or decoder source (libopus or otherwise) was consulted. diff --git a/crates/opusenc/tests/common/mod.rs b/crates/opusenc/tests/it/common/mod.rs similarity index 100% rename from crates/opusenc/tests/common/mod.rs rename to crates/opusenc/tests/it/common/mod.rs diff --git a/crates/opusenc/tests/ffmpeg.rs b/crates/opusenc/tests/it/ffmpeg.rs similarity index 99% rename from crates/opusenc/tests/ffmpeg.rs rename to crates/opusenc/tests/it/ffmpeg.rs index fcbb8c8a7..1294ed348 100644 --- a/crates/opusenc/tests/ffmpeg.rs +++ b/crates/opusenc/tests/it/ffmpeg.rs @@ -2,7 +2,7 @@ //! without errors with both ffmpeg's native `opus` decoder and `libopus`, honour the pre-skip //! and end granule, and reproduce the input. Skipped when ffmpeg is not installed. -mod common; +use crate::common; use std::process::Command; diff --git a/crates/opusenc/tests/it/main.rs b/crates/opusenc/tests/it/main.rs new file mode 100644 index 000000000..ba6a7895d --- /dev/null +++ b/crates/opusenc/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one binary (each module is a former `tests/.rs`). + +mod common; +mod ffmpeg; +mod modes; diff --git a/crates/opusenc/tests/modes.rs b/crates/opusenc/tests/it/modes.rs similarity index 99% rename from crates/opusenc/tests/modes.rs rename to crates/opusenc/tests/it/modes.rs index 843cf3d9f..c01f64e69 100644 --- a/crates/opusenc/tests/modes.rs +++ b/crates/opusenc/tests/it/modes.rs @@ -2,7 +2,7 @@ //! mode/bandwidth decodes, waveform quality on speech-like and music-like signals, bitrate //! tracking, stereo, the application tables and delay alignment. -mod common; +use crate::common; use common::*; use effectcraft_opusenc::{Application, Bandwidth, Mode, OpusEncoder}; diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 249eb9fdd..5069cb83e 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -133,7 +133,9 @@ pub fn from_kurbo(path: &BezPath) -> Vec { /// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right). pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs())); - let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0); + let max_rad = size[0].abs().min(size[1].abs()) / 2.0; + // `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness. + let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) }; if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) } } @@ -354,6 +356,11 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec { mod tests { use super::*; + #[test] + fn rect_with_nan_size_does_not_panic() { + let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0); + } + #[test] fn roundtrip_vertex_model() { let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0); diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 1d6bf3c13..e4970edc9 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -138,15 +138,17 @@ ffmpeg elsewhere) to check the code tables against an independent implementation ## Shared parser setup and bounds The workspace pins `craft-pdf` to `craft-libs` Git revision -`ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d`, with `default-features = false` and only the explicit `read` +`9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`, with `default-features = false` and only the explicit `read` feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's font and asset policies. The shared parser does not change the supported drawing operators, image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter. This revision removes an unchecked lazy-array read and preserves later values and pages after sign-only numeric entries; the app exercises those inputs through its object adapter, public -page parser and rasterizer on native and WASM targets. +page parser and rasterizer on native and WASM targets. It also bounds xref, object-stream and +graphics-state work, reads the full `/W[0]` xref type width, and caps JBIG2 segment references +and bitmap allocation. -The root Cargo patch pins Hayro-syntax to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses +The root Cargo patch pins Hayro-syntax and Hayro-jbig2 to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses an enabled Hayro interpreter, rasterizer or CMap dependency. The shared revision must be published before a fresh app checkout can resolve it; no sibling checkout is required. diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index 296878c31..2c27838a2 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -263,8 +263,8 @@ fn parse_cmap(data: &[u8]) -> CMap { } Obj::Array(list) => { for (k, s) in list.iter().enumerate() { - if let Obj::Str(s) = s { - m.unicode.insert(lo + k as u32, utf16(s)); + if let (Obj::Str(s), Some(c)) = (s, lo.checked_add(k as u32)) { + m.unicode.insert(c, utf16(s)); } } } @@ -401,13 +401,15 @@ impl Font { match w.get(i + 1).map(|x| file.resolve(x)) { Some(Obj::Array(list)) => { for (k, v) in list.iter().enumerate() { - f.cid_widths.insert(first + k as u32, file.resolve(v).num().unwrap_or(0.0) / 1000.0); + if let Some(cid) = first.checked_add(k as u32) { + f.cid_widths.insert(cid, file.resolve(v).num().unwrap_or(0.0) / 1000.0); + } } i += 2; } Some(Obj::Num(last)) => { let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0; - for c in first..=(*last as u32).min(first + 65535) { + for c in first..=(*last as u32).min(first.saturating_add(65535)) { f.cid_widths.insert(c, wv); } i += 3; @@ -432,13 +434,15 @@ impl Font { Some(Obj::Array(list)) => { let v: Vec = list.iter().filter_map(|x| file.resolve(x).num()).collect(); for (k, t) in v.as_chunks::<3>().0.iter().enumerate() { - f.cid_vmetrics.insert(first + k as u32, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); + if let Some(cid) = first.checked_add(k as u32) { + f.cid_vmetrics.insert(cid, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); + } } i += 2; } Some(Obj::Num(last)) => { let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break }; - for c in first..=(last.max(0.0) as u32).min(first + 65535) { + for c in first..=(last.max(0.0) as u32).min(first.saturating_add(65535)) { f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0)); } i += 5; @@ -609,7 +613,7 @@ impl Font { if self.cid_ranges.is_empty() { return code; } - self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0) + self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).and_then(|(lo, _, c)| c.checked_add(code - lo)).unwrap_or(0) } /// Vertical metrics of a code in text space (before the font size): the vertical diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs index 4d55fef8e..7006979c2 100644 --- a/crates/pdf/src/image.rs +++ b/crates/pdf/src/image.rs @@ -16,6 +16,7 @@ pub(crate) struct Decoded { /// The longest side of a decoded image (larger images are refused rather than exhausting /// memory). const MAX_SIDE: usize = 16384; +const MAX_PIXELS: usize = 64 << 20; fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> { file.get(d, long).or_else(|| file.get(d, short)) @@ -114,6 +115,11 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> { use zune_jpeg::zune_core::options::DecoderOptions; let mut probe = JpegDecoder::new(std::io::Cursor::new(data)); probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?; + // Bound the header-declared size before decode() allocates the output. + let (hw, hh) = probe.dimensions().ok_or_else(|| "image (JPEG)".to_string())?; + if hw.checked_mul(hh).is_none_or(|p| p > MAX_PIXELS) { + return Err("image (JPEG)".to_string()); + } let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3); let out = match n { 1 => ColorSpace::Luma, @@ -134,8 +140,17 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> { /// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers. fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec { - let row_bytes = (w * n * bpc).div_ceil(8); - let mut out = Vec::with_capacity(w * h * n); + let Some(samples) = w.checked_mul(h).and_then(|p| p.checked_mul(n)).filter(|count| *count <= MAX_PIXELS.saturating_mul(4)) else { return Vec::new() }; + let Some(row_bits) = w.checked_mul(n).and_then(|v| v.checked_mul(bpc)) else { return Vec::new() }; + let row_bytes = row_bits.div_ceil(8); + let Some(row_len) = row_bytes.checked_mul(h) else { return Vec::new() }; + if row_len > data.len() { + return Vec::new(); + } + let mut out = Vec::new(); + if out.try_reserve_exact(samples).is_err() { + return Vec::new(); + } for y in 0..h { let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]); for i in 0..w * n { @@ -157,10 +172,22 @@ fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec { /// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`. fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec { + let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else { return Vec::new() }; + if mw.checked_mul(mh).is_none_or(|n| n > m.len()) { + return Vec::new(); + } if mw == w && mh == h { - return m.to_vec(); + let mut out = Vec::new(); + if out.try_reserve_exact(pixels).is_err() { + return Vec::new(); + } + out.extend_from_slice(&m[..pixels]); + return out; + } + let mut out = Vec::new(); + if out.try_reserve_exact(pixels).is_err() { + return Vec::new(); } - let mut out = Vec::with_capacity(w * h); for y in 0..h { let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1)); for x in 0..w { @@ -176,12 +203,16 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize let Obj::Stream(d, raw) = file.resolve(o) else { return None }; let w = file.get_num(d, "Width")? as usize; let h = file.get_num(d, "Height")? as usize; - if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w.checked_mul(h).is_none_or(|p| p > MAX_PIXELS) { return None; } let (data, jpeg_n) = samples(file, d, raw).ok()?; + let pixels = w.checked_mul(h)?; if jpeg_n.is_some() { - return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h)); + let mut out = Vec::new(); + out.try_reserve_exact(pixels).ok()?; + out.extend(data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(pixels)); + return Some((out, w, h)); } let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 }; // Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably @@ -192,19 +223,21 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize let max = ((1u32 << bpc) - 1).max(1) as f64; let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2); let v = unpack(&data, w, h, 1, bpc); - let out = v - .into_iter() - .map(|s| { - let t = s as f64 / max; - let t = match &dec { - Some(dd) => dd[0] + t * (dd[1] - dd[0]), - None => t, - }; - // Explicit (stencil) masks: sample 1 masks out with the default Decode. - let a = if soft { t } else { 1.0 - t }; - (a.clamp(0.0, 1.0) * 255.0).round() as u8 - }) - .collect(); + if v.len() != pixels { + return None; + } + let mut out = Vec::new(); + out.try_reserve_exact(pixels).ok()?; + out.extend(v.into_iter().map(|s| { + let t = s as f64 / max; + let t = match &dec { + Some(dd) => dd[0] + t * (dd[1] - dd[0]), + None => t, + }; + // Explicit (stencil) masks: sample 1 masks out with the default Decode. + let a = if soft { t } else { 1.0 - t }; + (a.clamp(0.0, 1.0) * 255.0).round() as u8 + })); Some((out, w, h)) } @@ -212,15 +245,24 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result { let w = file.get_num(d, "Width").unwrap_or(0.0) as usize; let h = file.get_num(d, "Height").unwrap_or(0.0) as usize; - if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 { + let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else { + return Err("image (size)".into()); + }; + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { return Err("image (size)".into()); } let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true))); let (data, jpeg_n) = samples(file, d, raw)?; let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default(); - let mut rgba = vec![0u8; w * h * 4]; + let rgba_len = pixels.checked_mul(4).ok_or_else(|| "image (size)".to_string())?; + let mut rgba = Vec::new(); + rgba.try_reserve_exact(rgba_len).map_err(|_| "image (allocation)".to_string())?; + rgba.resize(rgba_len, 0); if stencil { let v = unpack(&data, w, h, 1, 1); + if v.len() != pixels { + return Err("image (sample allocation)".into()); + } let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 }; let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8); for (i, s) in v.into_iter().enumerate() { @@ -243,8 +285,14 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { return Err(format!("image ({bpc} bits per component)")); } + let Some(sample_count) = pixels.checked_mul(n).filter(|n| *n <= MAX_PIXELS.saturating_mul(4)) else { + return Err("image (size)".into()); + }; let max = ((1u32 << bpc) - 1) as f64; let raw_samples = unpack(&data, w, h, n, bpc); + if raw_samples.len() != sample_count { + return Err("image (sample allocation)".into()); + } // Decode ranges per component. let indexed = matches!(cs, Cs::Indexed { .. }); let range = |k: usize| -> (f64, f64) { @@ -262,7 +310,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()), _ => None, }; - for i in 0..w * h { + for i in 0..pixels { if let Some(pal) = &palette { let s = raw_samples[i] as f64 / max; let (a, b) = ranges[0]; @@ -288,7 +336,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill Some(Obj::Array(a)) => { let keys: Vec = a.iter().filter_map(|x| file.resolve(x).num()).collect(); if keys.len() >= 2 * n { - for i in 0..w * h { + for i in 0..pixels { if (0..n).all(|k| { let v = raw_samples[i * n + k] as f64; v >= keys[2 * k] && v <= keys[2 * k + 1] @@ -300,7 +348,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill } Some(m @ Obj::Stream(..)) => { if let Some((m, mw, mh)) = mask_image(file, m, false) { - for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() { rgba[i * 4 + 3] = a; } } @@ -310,7 +358,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill if let Some(sm) = file.get(d, "SMask") && let Some((m, mw, mh)) = mask_image(file, sm, true) { - for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() { rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8; } } diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 45db6c2ea..dd7350754 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -28,6 +28,8 @@ enum V { Name(Rc), /// A literal name (`/name`). Lit(Rc), + /// A built-in operator, as `load` returns it for a name no dictionary defines. + Op(Rc), Str(Rc>>), Proc(Rc>), Array(Rc>>), @@ -47,7 +49,7 @@ impl V { } fn key(&self) -> String { match self { - V::Name(n) | V::Lit(n) => n.to_string(), + V::Name(n) | V::Lit(n) | V::Op(n) => n.to_string(), V::Str(s) => String::from_utf8_lossy(&s.borrow()).into_owned(), V::Num(n) => format!("{n}"), V::Bool(b) => format!("{b}"), @@ -441,6 +443,12 @@ impl Interp { break; } } + V::Op(op) => { + r = self.spend(1).and_then(|()| self.builtin(op, src)); + if r.is_err() { + break; + } + } // Other items count against the budget as names do. other => { r = self.spend(1); @@ -454,7 +462,21 @@ impl Interp { self.calls -= 1; r } - V::Name(n) => self.exec_name(&n, src), + // An executable name can run another (`exec` of `/exec cvx`), so names count too. + V::Name(n) => { + if self.calls >= MAX_CALL_DEPTH { + self.b.skip("PostScript (recursion limit)"); + return Err(Flow::Quit); + } + self.calls += 1; + let r = self.exec_name(&n, src); + self.calls -= 1; + r + } + V::Op(op) => { + self.spend(1)?; + self.builtin(&op, src) + } other => { self.push(other); Ok(()) @@ -467,6 +489,12 @@ impl Interp { if let Some(v) = self.lookup(n) { return match v { V::Proc(_) => self.exec_obj(v, src), + // An operator stored under another name (`/mo /moveto load def`, as Illustrator's + // EPS prolog does for every path operator) runs that operator. It pushed the + // operator's name instead, so such files drew nothing (#955). + V::Op(op) => self.builtin(&op, src), + // An executable name (`/y /x cvx def`) runs what that name is bound to. + V::Name(_) => self.exec_obj(v, src), other => { self.push(other); Ok(()) @@ -963,7 +991,7 @@ impl Interp { } "load" => { let k = self.pop().key(); - let v = self.lookup(&k).unwrap_or(V::Name(k.into())); + let v = self.lookup(&k).unwrap_or(V::Op(k.into())); self.push(v); } "undef" => { @@ -1160,13 +1188,14 @@ impl Interp { } "xcheck" => { let v = self.pop(); - self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_)))); + self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_) | V::Op(_)))); } "type" => { let t = match self.pop() { V::Num(_) => "realtype", V::Bool(_) => "booleantype", V::Name(_) | V::Lit(_) => "nametype", + V::Op(_) => "operatortype", V::Str(_) => "stringtype", V::Proc(_) | V::Array(_) => "arraytype", V::Dict(_) => "dicttype", @@ -1203,6 +1232,98 @@ impl Interp { } } "currentglobal" | "currentpacking" | "currentshared" => self.push(V::Bool(false)), + // Operators that return values must push them, or the rest of the program reads a + // shifted stack: Illustrator's EPS prolog queries the device this way and then drew + // nothing (#955). Values are a plain device's defaults (PostScript Language + // Reference, 3rd ed., chapter 8). + "currentflat" => self.push(V::Num(1.0)), + "currentscreen" => { + self.push(V::Num(60.0)); + self.push(V::Num(45.0)); + self.push(V::Proc(Rc::new(vec![]))); + } + "currentblackgeneration" | "currentundercolorremoval" | "currenttransfer" => self.push(V::Proc(Rc::new(vec![]))), + "currentcolortransfer" => { + for _ in 0..4 { + self.push(V::Proc(Rc::new(vec![]))); + } + } + "currentpagedevice" | "currenthalftone" | "currentsystemparams" | "currentuserparams" | "currentdevparams" => { + if n == "currentdevparams" { + self.pop(); + } + self.push(V::Dict(new_dict())); + } + "setuserparams" | "setsystemparams" | "setcolortransfer" | "setblackgeneration" | "setundercolorremoval" => { + let k = if n == "setcolortransfer" { 4 } else { 1 }; + for _ in 0..k { + self.pop(); + } + } + // `file status` → false; `string status` → false (no such file). + "status" => { + self.pop(); + self.push(V::Bool(false)); + } + "dictstack" => { + self.pop(); + let ds: Vec = self.dicts.iter().map(|d| V::Dict(d.clone())).collect(); + self.push(array(ds)); + } + "maxlength" => { + let k = match self.pop() { + V::Dict(d) => d.borrow().len(), + _ => 0, + }; + self.push(V::Num(k.saturating_add(16) as f64)); + } + "internaldict" => { + self.pop(); + let d = self.dicts[0].clone(); + self.push(V::Dict(d)); + } + "undefineresource" => { + self.pop(); + self.pop(); + } + // `[ … /Name pdfmark`: a Distiller annotation; other interpreters discard it. + "pdfmark" => { + while let Some(v) = self.st.pop() { + if matches!(v, V::Mark) { + break; + } + } + } + // `source [params] /Name filter`. A SubFileDecode of the current file with a count of + // 0 reads to its end-of-data string: skip that data (an embedded XMP packet, which + // otherwise ran as PostScript). Other filters read what their source reads. + "filter" => { + let name = self.pop().key(); + let mut skipped = false; + let source = if name == "SubFileDecode" && !matches!(self.st.last(), Some(V::Dict(_))) { + let eod = match self.pop() { + V::Str(s) => s.borrow().clone(), + _ => vec![], + }; + let count = self.popn(); + let source = self.pop(); + if matches!(source, V::File) && count == 0.0 && !eod.is_empty() { + src.pos = match find_from(src.d, &eod, src.pos) { + Some(p) => p.saturating_add(eod.len()), + None => src.d.len(), + }; + skipped = true; + } + source + } else { + if matches!(self.st.last(), Some(V::Dict(_))) { + self.pop(); + } + self.pop() + }; + // Data already stepped over leaves nothing for `image` to skip. + self.push(if skipped { V::Null } else { source }); + } "vmstatus" => { for _ in 0..3 { self.push(V::Num(0.0)); @@ -1236,9 +1357,15 @@ impl Interp { let a: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); self.gs.dash = (!a.is_empty() && a.iter().any(|v| *v > 0.0)).then_some((a, off)); } - "setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "setscreen" | "settransfer" => { + "setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "settransfer" => { self.pop(); } + // frequency angle proc + "setscreen" => { + for _ in 0..3 { + self.pop(); + } + } "setgray" => { let g = self.popn().clamp(0.0, 1.0); self.gs.color = [g; 3]; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f2eff0466..40f064af7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1090,3 +1090,47 @@ fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() { assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page"); assert_eq!(offset, [0.0, 0.0]); } + +#[test] +fn cid_width_entries_at_the_top_of_the_cid_range_do_not_overflow() { + // A first CID of u32::MAX (larger values saturate to it) used to overflow `first + k` and `first + 65535`: + // a panic in debug builds; in release builds the list wrapped round to CID 0 and the range was dropped. + let load = |entry: &str| { + let type0 = "<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H /DescendantFonts [13 0 R] >>"; + let desc = format!("<< /Type /Font /Subtype /CIDFontType2 {entry} >>"); + let file = object::File::parse(&page_pdf("", "/Font << /F1 10 0 R >>", vec![(10, type0.into(), None), (13, desc, None)])).unwrap(); + let d = file.objects.get(&(10, 0)).and_then(object::Obj::dict).unwrap(); + font::Font::load(&file, d, &mut vec![]) + }; + let max = u32::MAX; + // c [w1 w2 ...] + let f = load("/W [4294967295 [500 600 700]]"); + assert_eq!((f.width(max), f.width(0)), (0.5, 1.0)); + // cfirst clast w + assert_eq!(load("/W [4294967295 4294967295 400]").width(max), 0.4); + // c [w1y vx vy ...] + let f = load("/W2 [4294967295 [1 2 3 4 5 6]]"); + assert_eq!((f.vmetrics(max), f.vmetrics(0)), ((0.001, 0.002, 0.003), (-1.0, 0.5, 0.88))); + // cfirst clast w1y vx vy + assert_eq!(load("/W2 [4294967295 4294967295 1 2 3]").vmetrics(max), (0.001, 0.002, 0.003)); +} + +#[test] +fn cmap_entries_at_the_top_of_the_code_range_do_not_overflow() { + // A bfrange array starting at code 0xFFFFFFFF and a cidrange mapping onto CID u32::MAX used to overflow + // `lo + k` and `c + (code - lo)`: a panic in debug builds, a wrap round to code / CID 0 in release builds. + let to_unicode = "beginbfrange [<0041> <0042>] endbfrange"; + let encoding = "begincodespacerange <0000> endcodespacerange begincidrange <0000> 4294967295 endcidrange"; + let objs: Objs = vec![ + (10, "<< /Type /Font /Subtype /Type0 /Encoding 15 0 R /ToUnicode 14 0 R /DescendantFonts [13 0 R] >>".into(), None), + (13, "<< /Type /Font /Subtype /CIDFontType2 /W [0 [700 800] 4294967295 [500]] >>".into(), None), + (14, "<< >>".into(), Some(to_unicode.as_bytes().to_vec())), + (15, "<< /Type /CMap >>".into(), Some(encoding.as_bytes().to_vec())), + ]; + let file = object::File::parse(&page_pdf("", "/Font << /F1 10 0 R >>", objs)).unwrap(); + let d = file.objects.get(&(10, 0)).and_then(object::Obj::dict).unwrap(); + let f = font::Font::load(&file, d, &mut vec![]); + assert_eq!((f.unicode(0xFFFF_FFFF), f.unicode(0)), ("A".to_string(), String::new())); + // Code 0 is CID u32::MAX; code 2 would be past it, so it falls back to CID 0 like an unmapped code. + assert_eq!((f.width(0), f.width(2)), (0.5, 0.7)); +} diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index b6a95859c..11ef72f8f 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -25,6 +25,55 @@ fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) { (x0, x1) } +/// #955: EPS files that name their operators (`/mo /moveto load def`, as Illustrator's do), +/// query the device and embed metadata read through a SubFileDecode filter draw their artwork. +/// They imported as an empty layer. +#[test] +fn eps_with_aliased_operators_device_queries_and_embedded_data_draws() { + let square = "1 0 0 setrgbcolor 50 25 mo 150 25 li 150 75 li 50 75 li cp f\n"; + let aliases = "/mo /moveto load def /li /lineto load def /cp /closepath load def /f /fill load def\n"; + let doc = eps(format!("{aliases}{square}").as_bytes()); + assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "aliased operators draw the square"); + // Every operator that returns a value pushes it, so the stack stays aligned. + let queries = "mark currentflat currentscreen currentpagedevice currenthalftone currentblackgeneration \ + currentundercolorremoval currentcolortransfer currentsystemparams counttomark 13 eq /ok exch def cleartomark\n"; + let doc = eps(format!("{aliases}{queries}ok {{ {square} }} if\n").as_bytes()); + assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "device queries leave 13 values"); + // Data up to the end-of-data string isn't run: its unbalanced brace would swallow the rest. + let data = "currentfile 0 (%END-OF-DATA) /SubFileDecode filter pop\n { ( not PostScript\n%END-OF-DATA\n"; + let doc = eps(format!("{aliases}{data}{square}").as_bytes()); + assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "drawing after the embedded data"); +} + +#[test] +fn eps_filter_on_a_string_source_does_not_skip_the_program() { + let square = "1 0 0 setrgbcolor 50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill\n"; + let img = "gsave 1 1 8 [1 0 0 1 0 0] (FF) /ASCIIHexDecode filter image grestore\n"; + // Images aren't drawn yet, so this parses without `eps`'s nothing-skipped check. + let doc = parse(format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n{img}{square}").as_bytes()).unwrap(); + assert_eq!(doc.skipped, ["image"]); + assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "drawing after an image read from a string"); +} + +/// A loaded operator runs that operator even after its name is redefined (the prolog idiom +/// `/_m /moveto load def /moveto {… _m} def`), and an executable name bound with `cvx` runs what +/// its name is bound to. +#[test] +fn eps_loaded_operators_and_executable_name_aliases() { + let saved = "/_m /moveto load def /moveto {_m} def 1 0 0 setrgbcolor 50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill\n"; + assert!(coverage(&render(&eps(saved.as_bytes())), 50, 25, 150, 75) > 4900.0, "a redefined moveto that calls the saved one"); + let alias = "/sq {50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill} def /y /sq cvx def 1 0 0 setrgbcolor y\n"; + assert!(coverage(&render(&eps(alias.as_bytes())), 50, 25, 150, 75) > 4900.0, "an executable name bound to a procedure's name"); +} + +/// Each `exec` of the executable name `exec` runs the next one: the chain is cut off at the call +/// depth instead of overflowing the stack. +#[test] +fn eps_exec_of_executable_names_stops_at_the_call_depth() { + let doc = parse(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n/exec cvx 99999 {dup} repeat exec\n").unwrap(); + assert!(doc.skipped.iter().any(|s| s.contains("recursion limit")), "{:?}", doc.skipped); +} + #[test] fn eps_text_with_bundled_fonts_and_show_variants() { // Not embedded: drawn with the bundled stand-in, like the same text in a PDF. diff --git a/crates/project/src/file_check.rs b/crates/project/src/file_check.rs new file mode 100644 index 000000000..f1b261c5a --- /dev/null +++ b/crates/project/src/file_check.rs @@ -0,0 +1,329 @@ +//! A cheap check that a project file will read back, without parsing it. +//! +//! `serde_json` writes a non-finite float as `null` and refuses to parse nesting deeper than 128 +//! containers, so a project holding either would be saved to a file that can't be opened. Walking +//! the value once, writing nothing, finds both without building the project again. + +use std::fmt; + +use serde::ser::{ + self, Serialize, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, SerializeTupleStruct, SerializeTupleVariant, + Serializer, +}; + +/// serde_json refuses to parse the 128th nested array/object (its recursion limit is 128). +const MAX_DEPTH: u32 = 127; + +const NOT_FINITE: &str = "a number that isn't finite (NaN or infinity)"; +const TOO_DEEP: &str = "nesting deeper than 127 levels"; + +/// Why a value can't be written to a project file. +#[derive(Debug)] +pub(crate) struct Unstorable(String); + +impl fmt::Display for Unstorable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Unstorable {} + +impl ser::Error for Unstorable { + fn custom(msg: T) -> Self { + Unstorable(msg.to_string()) + } +} + +/// Walk `value` as `serde_json` would write it, producing no text and allocating only on error. +pub(crate) fn check(value: &T) -> Result<(), Unstorable> { + let mut walker = Walker { depth: 0 }; + value.serialize(&mut walker) +} + +/// The current nesting depth of the value being walked. +struct Walker { + depth: u32, +} + +impl Walker { + /// Enter `levels` nested containers, refusing anything serde_json couldn't parse back. + fn enter(&mut self, levels: u32) -> Result<(), Unstorable> { + let depth = self.depth.checked_add(levels).ok_or_else(|| Unstorable(TOO_DEEP.into()))?; + if depth > MAX_DEPTH { + return Err(Unstorable(TOO_DEEP.into())); + } + self.depth = depth; + Ok(()) + } + fn leave(&mut self, levels: u32) { + self.depth = self.depth.saturating_sub(levels); + } +} + +/// One compound value being walked; leaving it when it ends keeps the depth exact. +struct Compound<'a> { + walker: &'a mut Walker, + levels: u32, +} + +impl<'a> Compound<'a> { + fn new(walker: &'a mut Walker, levels: u32) -> Result { + walker.enter(levels)?; + Ok(Self { walker, levels }) + } + fn value(&mut self, value: &T) -> Result<(), Unstorable> { + value.serialize(&mut *self.walker) + } + fn finish(self) -> Result<(), Unstorable> { + self.walker.leave(self.levels); + Ok(()) + } +} + +impl SerializeSeq for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_element(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeTuple for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_element(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeTupleStruct for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeTupleVariant for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeMap for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_key(&mut self, key: &T) -> Result<(), Unstorable> { + self.value(key) + } + fn serialize_value(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeStruct for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeStructVariant for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl<'a> Serializer for &'a mut Walker { + type Ok = (); + type Error = Unstorable; + type SerializeSeq = Compound<'a>; + type SerializeTuple = Compound<'a>; + type SerializeTupleStruct = Compound<'a>; + type SerializeTupleVariant = Compound<'a>; + type SerializeMap = Compound<'a>; + type SerializeStruct = Compound<'a>; + type SerializeStructVariant = Compound<'a>; + + fn serialize_bool(self, _v: bool) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i8(self, _v: i8) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i16(self, _v: i16) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i32(self, _v: i32) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i64(self, _v: i64) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i128(self, _v: i128) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u8(self, _v: u8) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u16(self, _v: u16) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u32(self, _v: u32) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u64(self, _v: u64) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u128(self, _v: u128) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_f32(self, v: f32) -> Result<(), Unstorable> { + if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) } + } + fn serialize_f64(self, v: f64) -> Result<(), Unstorable> { + if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) } + } + fn serialize_char(self, _v: char) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_str(self, _v: &str) -> Result<(), Unstorable> { + Ok(()) + } + /// serde_json writes bytes as an array of numbers: one level of nesting. + fn serialize_bytes(self, _v: &[u8]) -> Result<(), Unstorable> { + Compound::new(self, 1)?.finish() + } + /// Writes text for a `Display` value; it is a scalar, not a container. + fn collect_str(self, _v: &T) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_none(self) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_some(self, value: &T) -> Result<(), Unstorable> { + value.serialize(&mut *self) + } + fn serialize_unit(self) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_unit_struct(self, _name: &'static str) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_unit_variant(self, _name: &'static str, _index: u32, _variant: &'static str) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result<(), Unstorable> { + value.serialize(self) + } + fn serialize_newtype_variant(self, _name: &'static str, _index: u32, _variant: &'static str, value: &T) -> Result<(), Unstorable> { + let mut compound = Compound::new(self, 1)?; + compound.value(value)?; + compound.finish() + } + fn serialize_seq(self, _len: Option) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_tuple(self, _len: usize) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_tuple_struct(self, _name: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_tuple_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 2) + } + fn serialize_map(self, _len: Option) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_struct(self, _name: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_struct_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 2) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::check; + + #[test] + fn refuses_non_finite_numbers_anywhere() { + assert!(check(&1.5f64).is_ok()); + assert!(check(&vec![Some(1.0f64), None]).is_ok()); + assert!(check(&BTreeMap::from([("a".to_string(), 2.0f32)])).is_ok(), "finite floats in a map"); + assert!(check(&f64::NAN).is_err()); + assert!(check(&f64::INFINITY).is_err()); + assert!(check(&vec![Some(f64::NEG_INFINITY)]).is_err(), "inside an option in a list"); + assert!(check(&BTreeMap::from([("a".to_string(), f32::NAN)])).is_err(), "as a map value"); + } + + #[test] + fn depth_limit_matches_serde_json() { + for n in [127, 128] { + let mut v = serde_json::Value::Null; + for _ in 0..n { + v = serde_json::Value::Array(vec![v]); + } + let text = serde_json::to_string(&v).unwrap(); + let reads_back = serde_json::from_str::(&text).is_ok(); + assert_eq!(check(&v).is_ok(), reads_back, "depth {n} agrees with serde_json"); + assert_eq!(check(&v).is_ok(), n == 127, "depth {n}"); + } + } + + /// `n` nested lists around bytes, which serde_json writes as one more array. + struct BytesUnder(u32); + + impl serde::Serialize for BytesUnder { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeSeq; + let Some(inner) = self.0.checked_sub(1) else { + return s.serialize_bytes(&[1]); + }; + let mut seq = s.serialize_seq(Some(1))?; + seq.serialize_element(&BytesUnder(inner))?; + seq.end() + } + } + + #[test] + fn bytes_count_as_one_level() { + for n in [126, 127] { + let text = serde_json::to_string(&BytesUnder(n)).unwrap(); + let reads_back = serde_json::from_str::(&text).is_ok(); + assert_eq!(check(&BytesUnder(n)).is_ok(), reads_back, "depth {n} agrees with serde_json"); + assert_eq!(check(&BytesUnder(n)).is_ok(), n == 126, "depth {n}"); + } + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a4bffbe0c..e3cbf8f51 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -8,6 +8,7 @@ pub mod build; pub mod essential; +mod file_check; pub mod limits; pub mod props; pub mod render_queue; @@ -1478,11 +1479,32 @@ impl Project { } /// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles). pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool { + // A walk with a stack and a visited set rather than recursion (#1237): a damaged project + // may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long + // chain must not use up the call stack, and comps shared by many paths are visited once + // (recursion re-walked them for every path). if outer == inner { return true; } - let Some(c) = self.comp(outer) else { return false }; - c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner))) + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![outer]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(c) = self.comp(id) else { continue }; + for l in &c.layers { + if let LayerSource::Comp { item } = l.source { + if item == inner { + return true; + } + if !seen.contains(&item) { + stack.push(item); + } + } + } + } + false } /// Make sure `next_id` exceeds every id in the project (after loading or merging). pub fn fix_next_id(&mut self) { @@ -1519,13 +1541,20 @@ impl Project { && self.render_prefs == other.render_prefs } /// The project file's text: the project with the version that wrote it (`savedBy`), checked - /// to read back, so a save never writes a file that can't be opened (JSON has no NaN or - /// infinity: such a value would be written as `null`). + /// to read back, so a save never writes a file that can't be opened. Values JSON can't hold + /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every + /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { self.to_file_json_with_view(&SavedView::default()) } /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + if self.schema > SCHEMA_VERSION { + return Err(ProjectError::Invalid(format!( + "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", + self.schema + ))); + } #[derive(Serialize)] struct File<'a> { #[serde(rename = "savedBy")] @@ -1535,8 +1564,11 @@ impl Project { #[serde(flatten)] project: &'a Project, } - let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self }) - .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let file = File { saved_by: APP_VERSION, view, project: self }; + file_check::check(&file) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; + let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + #[cfg(debug_assertions)] Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; Ok(text) diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index 8cd52092f..fe36c7724 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -166,10 +166,33 @@ fn project_files_name_their_version_and_always_read_back() { p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::NAN); let e = p.to_file_json().unwrap_err().to_string(); assert!(e.contains("can't be saved"), "{e}"); + // Infinity too, in either profile: the check doesn't rely on reading the file back. + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::INFINITY); + let e = p.to_file_json().unwrap_err().to_string(); + assert!(e.contains("can't be saved"), "{e}"); assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9")); assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1")); } +#[test] +fn project_files_refuse_newer_schemas() { + let p = Project { schema: crate::SCHEMA_VERSION + 1, ..Project::default() }; + let expected = format!( + "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({}))", + p.schema, + crate::SCHEMA_VERSION + ); + assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); +} + +#[test] +fn saved_files_reopen_in_pretty_and_compact_form() { + let (p, _cid) = project_with_layer(); + assert_eq!(Project::from_json(&p.to_file_json().unwrap()).unwrap(), p, "the pretty project file"); + assert_eq!(Project::from_json(&p.to_json_compact()).unwrap(), p, "compact JSON"); +} + #[test] fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer(); @@ -212,3 +235,58 @@ fn item_users_count_layers_in_every_comp() { assert!(p.item_users(unused).is_empty()); assert!(p.item_users(main).is_empty()); } + +/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a` +/// (indexes into the returned ids). +fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec) { + let mut p = Project::default(); + let ids: Vec = (0..n) + .map(|i| { + p.add_item( + &format!("C{i}"), + effectcraft_color::Label::None, + None, + ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()), + ) + }) + .collect(); + for &(a, b) in links { + let outer = p.comp(ids[a]).unwrap().clone(); + let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None); + let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(c).layers.push(l); + } + (p, ids) +} + +#[test] +fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { + // A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237). + let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]); + assert!(!p.comp_contains(c[0], c[2])); + assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0])); + // A comp nesting itself. + let (p, c) = comp_graph(2, &[(0, 0)]); + assert!(!p.comp_contains(c[0], c[1])); + assert!(p.comp_contains(c[0], c[0])); + // A target reached through a cycle. + let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]); + assert!(p.comp_contains(c[0], c[2])); + assert!(!p.comp_contains(c[2], c[0])); + // 40 levels of diamonds: 2^40 paths, each comp visited once. + let mut links = vec![]; + for i in 0..40 { + let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3); + links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]); + } + let (p, c) = comp_graph(121, &links); + let t0 = std::time::Instant::now(); + assert!(p.comp_contains(c[0], c[120])); + assert!(!p.comp_contains(c[120], c[0])); + assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed()); + // A chain 20 000 comps deep (a deep recursion's stack frames). + let n = 20_000; + let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::>()); + assert!(p.comp_contains(c[0], c[n - 1])); + assert!(!p.comp_contains(c[n - 1], c[0])); +} diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 1c065d77b..46b749a9a 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -1,5 +1,5 @@ -//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional -//! and radial (spin/zoom) blurs. +//! Blurs: separable box passes (exact running sums) approximating Gaussians, a sampled Gaussian +//! kernel for small sigmas, plus directional and radial (spin/zoom) blurs. //! //! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over //! blocks of rows with a per-column running sum (no transposes), ping-ponging between two @@ -157,6 +157,50 @@ pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> I box_passes(img, &rx, &ry, repeat) } +/// Below this sigma (pixels) three integer-radius box passes are too coarse (sigma under ~0.6 is +/// a no-op and the result steps between a few shapes), so [`gaussian_blur_exact`] is used instead. +const EXACT_SIGMA_BELOW: f64 = 3.0; + +/// True when a Gaussian of these per-axis sigmas should use [`gaussian_blur_exact`]. +pub fn gaussian_is_exact(sigma_x: f64, sigma_y: f64) -> bool { + sigma_x.max(sigma_y) < EXACT_SIGMA_BELOW +} + +/// One separable pass of a sampled Gaussian (radius 3 sigma) along x or y. +fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image { + // Below 1e-3 the kernel is the identity in f64; tiny sigmas would also make 2σ² underflow to 0 (NaN taps). + if sigma.is_nan() || sigma <= 1e-3 || img.is_empty() { + return img.clone(); + } + let r = (sigma * 3.0).ceil() as i64; + let mut k: Vec = (-r..=r).map(|i| (-((i * i) as f64) / (2.0 * sigma * sigma)).exp()).collect(); + let total: f64 = k.iter().sum(); + k.iter_mut().for_each(|v| *v /= total); + let w = img.width as usize; + let mut out = Image::new(img.width, img.height); + out.rows_mut().for_each(|(y, row)| { + for x in 0..w { + let mut acc = [0.0f64; 4]; + for (i, wt) in k.iter().enumerate() { + let d = i as i64 - r; + let (sx, sy) = if vertical { (x as i64, y as i64 + d) } else { (x as i64 + d, y as i64) }; + let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) }; + for c in 0..4 { + acc[c] += p[c] as f64 * wt; + } + } + row[x] = acc.map(|v| v as f32); + } + }); + out +} + +/// Gaussian blur as a true sampled kernel, continuous in sigma (for small sigmas, see +/// [`gaussian_is_exact`]). +pub fn gaussian_blur_exact(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image { + gauss_pass(&gauss_pass(img, sigma_x, false, repeat), sigma_y, true, repeat) +} + /// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from /// up) over `length` pixels. pub fn directional_blur(img: &Image, angle_deg: f64, length: f64) -> Image { @@ -228,6 +272,17 @@ mod tests { assert!(b.get(32, 32)[3] < 0.05); } + #[test] + fn vanishing_or_nan_sigma_leaves_the_image_unchanged() { + let mut img = Image::new(5, 5); + img.set(2, 2, [1.0, 0.5, 0.25, 1.0]); + for s in [1e-200, f64::MIN_POSITIVE, f64::NAN] { + let b = gaussian_blur_exact(&img, s, s, false); + assert!(b.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "sigma {s}"); + assert_eq!(b, img, "sigma {s}"); + } + } + #[test] fn flat_image_stays_flat_with_repeat() { let img = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]); diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index b84b64b79..034fb50fd 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -148,6 +148,31 @@ impl AuxChannels { self.crypto_rank_planes(layer).iter().map(|(id, c)| (id[i], c[i])).collect() } + /// The Cryptomatte layer an effect asking for `want` (`CryptoObject`, …) reads: an exact + /// match, else one ending in it (`ViewLayer.CryptoObject`), else the first layer. + pub fn resolve_crypto_layer(&self, want: &str) -> Option { + let layers = self.crypto_layers(); + layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).or(layers.first()).cloned() + } + + /// The ID (its hash, `f32::to_bits`) with the most coverage at aux pixel `i` of Cryptomatte + /// layer `layer`, summed over the ranks it appears in. Ties go to the lower rank; IDs with + /// no positive coverage don't count. `None` when nothing covers the pixel. + pub fn crypto_id_at(&self, layer: &str, i: usize) -> Option { + let mut ids: Vec<(u32, f32)> = vec![]; + for (id, cov) in self.crypto_rank_planes(layer) { + let (Some(id), Some(&c)) = (id.get(i), cov.get(i)) else { continue }; + if c > 0.0 { + match ids.iter_mut().find(|(h, _)| *h == id.to_bits()) { + Some((_, sum)) => *sum += c, + None => ids.push((id.to_bits(), c)), + } + } + } + // `min_by` keeps the first of equal elements: a tie goes to the lower rank. + ids.into_iter().min_by(|a, b| b.1.total_cmp(&a.1)).map(|(h, _)| h) + } + /// Aux pixel index of a layer-space point (nearest; `None` outside). #[inline] pub fn index_at(&self, lx: f64, ly: f64) -> Option { @@ -211,6 +236,28 @@ pub fn crypto_float(hash: u32) -> f32 { mod tests { use super::*; + /// The picked ID: most coverage (summed over ranks), ties to the lower rank, nothing when + /// no rank covers the pixel; layers resolve as the Cryptomatte effect does. + #[test] + fn crypto_id_at_takes_the_top_coverage_and_layers_resolve_by_suffix() { + let mut a = AuxChannels::new(4, 1, 1.0); + let f = |n: &str| crypto_float(crypto_hash(n)); + let (x, y, z) = (f("x"), f("y"), f("z")); + for (c, v) in [("R", [x, x, x, x]), ("G", [0.3, 0.5, 0.0, 0.2]), ("B", [y, y, y, y]), ("A", [0.6, 0.5, 0.0, 0.3])] { + a.channels.push((format!("ViewLayer.CryptoObject00.{c}"), v.to_vec())); + } + for (c, v) in [("R", [z, z, z, x]), ("G", [0.1, 0.0, -1.0, 0.2])] { + a.channels.push((format!("ViewLayer.CryptoObject01.{c}"), v.to_vec())); + } + a.channels.push(("Other00.R".into(), vec![0.0; 4])); + let l = a.resolve_crypto_layer("CryptoObject").unwrap(); + assert_eq!(l, "ViewLayer.CryptoObject"); + assert_eq!(a.resolve_crypto_layer("CryptoMaterial").as_deref(), Some("Other"), "falls back to the first"); + let at = |i| a.crypto_id_at(&l, i); + assert_eq!([at(0), at(1), at(2), at(3), at(9)], [Some(crypto_hash("y")), Some(crypto_hash("x")), None, Some(crypto_hash("x")), None]); + assert_eq!(AuxChannels::new(1, 1, 1.0).resolve_crypto_layer("CryptoObject"), None); + } + #[test] fn murmur3_reference_values() { // Published MurmurHash3_x86_32 test vectors. diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index f17602829..dce8dda81 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -16,7 +16,7 @@ pub mod warp; use effectcraft_color::{BlendMode, blend_pixel}; use rayon::prelude::*; -pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur}; +pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur}; pub use channels3d::AuxChannels; pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample}; diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..89e9fd1c9 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use effectcraft_effects::Buf; -use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; +use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup}; use crate::eval::EvalCtx; @@ -168,15 +168,23 @@ impl LayerCache { // Disk cache: a hit comes back into memory. let (dc, dk) = self.disk_key(key)?; let buf = dc.get_layer(dk)?; - self.insert_mem(key, buf.clone()); - Some(buf) + Some(self.insert_mem(key, buf)) } } } - pub fn insert(&self, key: u64, buf: Arc) { + /// Keep `buf` under `key`, and return the buffer the cache holds for it: the one kept first + /// when another render of the same layer got there before (#1812: preview workers starting + /// together each rendered every layer; sharing the first buffer, the GPU compositor uploads + /// and packs it once instead of once per worker, which ran it out of memory). Renders don't + /// wait for each other: on the frame workers' shared thread pool a wait can end up under + /// the very work it waits for. + pub fn insert(&self, key: u64, buf: Arc) -> Arc { if self.gated() { - return; + return buf; + } + if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) { + return kept; } let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); if let Some((dc, dk)) = self.disk_key(key) { @@ -185,20 +193,24 @@ impl LayerCache { dc.put_layer(dk, &buf); } } - self.insert_mem(key, buf); + self.insert_mem(key, buf) } - fn insert_mem(&self, key: u64, buf: Arc) { + /// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first + /// or `buf` is too large to keep). + fn insert_mem(&self, key: u64, buf: Arc) -> Arc { let bytes = buf.img.data.len() * std::mem::size_of::(); - let Ok(mut g) = self.inner.lock() else { return }; + let Ok(mut g) = self.inner.lock() else { return buf }; if bytes > g.budget / 4 { - return; + return buf; } g.clock += 1; let now = g.clock; - if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) { - g.bytes -= old.bytes; + if let Some(e) = g.map.get_mut(&key) { + e.last_use = now; + return e.buf.clone(); } + g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now }); g.bytes += bytes; if g.bytes > g.budget { // Evict least-recently used entries down to 3/4 of the budget. @@ -214,6 +226,7 @@ impl LayerCache { } } } + buf } /// The memory budget in bytes. @@ -481,7 +494,7 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { } /// Cache key for the processed (source → masks → effects) buffer of `layer` at the context -/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment +/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { // Time effects see property values at other times. Keyframes are part of the hashed @@ -514,6 +527,34 @@ fn reads_other_times(layer: &Layer) -> bool { }) } +/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU +/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every +/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only. +fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool { + ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still)) +} + +/// Does the layer have an animated mask that motion blur may draw at other times (Layer ▸ Mask ▸ +/// Motion Blur On, or Same As Layer with the layer blurred)? +fn mask_motion_blur(ctx: &EvalCtx, layer: &Layer, blur: bool) -> bool { + use effectcraft_project::{GroupKind, MaskMotionBlur}; + fn animated(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => !p.keys.is_empty() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) + } + ctx.comp.enable_motion_blur + && layer.masks().is_some_and(|m| { + m.groups().any(|g| match g.kind { + GroupKind::Mask { motion_blur, .. } => { + (motion_blur == MaskMotionBlur::On || (motion_blur == MaskMotionBlur::SameAsLayer && blur)) && animated(g) + } + _ => false, + }) + }) +} + fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), @@ -552,14 +593,17 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage => { + LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { let it = ctx.project.item(*item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. hash_debug(&mut h, &it.proxy); item.hash(&mut h); - ctx.source_time(layer).0.hash(&mut h); + // A still is the same at every time (unless a proxy, which may be a movie, stands in). + if f.kind != FootageKind::Still || it.proxy.is_some() { + ctx.source_time(layer).0.hash(&mut h); + } // Frame blending mixes neighbouring source frames. ctx.comp.enable_frame_blending.hash(&mut h); } @@ -625,6 +669,11 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h); } } + // Mask motion blur draws animated masks at the shutter's other times too: their values at + // this time don't tell two frames apart (a hold before a move). + if mask_motion_blur(ctx, layer, blur) { + layer.layer_time(ctx.time).0.hash(&mut h); + } if time_dependent(layer, effects != Some(0)) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. @@ -668,3 +717,23 @@ pub fn derive(key: u64, i: u64) -> u64 { i.hash(&mut h); h.finish() } + +#[cfg(test)] +mod shared_insert_tests { + use std::sync::Arc; + + use super::LayerCache; + use effectcraft_effects::Buf; + use effectcraft_raster::Image; + + /// Two renders of one layer kept under one key: both use the buffer kept first. + #[test] + fn the_buffer_kept_first_is_shared() { + let cache = LayerCache::default(); + let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); + let (a, b) = (buf(), buf()); + assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a)); + assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer"); + assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a)); + } +} diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 4140b7923..f867a614a 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -779,8 +779,12 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(&[0xff]); } -/// A file's size and modification time (so edited footage gets new keys). -fn file_stamp(h: &mut Hash128, path: &str) { +/// Size and content hash of a path that has no native metadata (the browser file table). +pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync); + +/// A file's size and modification time (so edited footage gets new keys). When the path has no +/// native metadata, `virtual_id` supplies a durable size and content hash instead. +fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option>) { if path.is_empty() { return; } @@ -791,26 +795,36 @@ fn file_stamp(h: &mut Hash128, path: &str) { { h.write_u64(d.as_nanos() as u64); } + return; + } + if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) { + h.write_u64(len); + h.write_u64(hash); } } -fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) { - file_stamp(h, &f.path); +fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option>) { + file_stamp(h, &f.path, virtual_id); for p in &f.sequence { - file_stamp(h, p); + file_stamp(h, p, virtual_id); } } /// Salt for layer cache keys on disk: the renderer version and every footage file's identity /// (layer keys name footage items, not file contents). pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { + footage_salt_with(project, None) +} + +/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. +pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option>) -> u64 { let mut h = Hash128::default(); h.write(RENDER_VERSION.as_bytes()); for (id, it) in &project.items { if let effectcraft_project::ItemKind::Footage(f) = &it.kind { h.write_u64(id.0); hash_debug(&mut h, f); - footage_stamp(&mut h, f); + footage_stamp(&mut h, f, virtual_id); } } let v = h.finish(); @@ -822,6 +836,11 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { /// files' size and time). With expressions in play (which can read any comp) the whole project /// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { + comp_content_key_with(project, comp, None) +} + +/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. +pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); h.write(RENDER_VERSION.as_bytes()); @@ -855,11 +874,11 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { - footage_stamp(&mut h, f); + footage_stamp(&mut h, f, virtual_id); } hash_debug(&mut h, &it.proxy); if let Some(px) = &it.proxy { - footage_stamp(&mut h, &px.footage); + footage_stamp(&mut h, &px.footage, virtual_id); } } h.write_u64(comp.0); @@ -1082,4 +1101,29 @@ mod tests { assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img); } } + + /// A path with no native metadata takes its key from the caller's size and content hash (#1253). + #[test] + fn virtual_file_bytes_change_content_keys() { + use effectcraft_project::build; + use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project}; + use effectcraft_time::{FrameRate, Tick}; + + let mut p = Project::default(); + let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; + let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)]; + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + + let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); + let (one, two) = (id(1), id(2)); + let a = footage_salt_with(&p, Some(&one)); + let b = footage_salt_with(&p, Some(&two)); + let ka = comp_content_key_with(&p, cid, Some(&one)); + let kb = comp_content_key_with(&p, cid, Some(&two)); + assert_ne!(a, b, "replaced virtual media must not reuse the salt"); + assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key"); + assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None)); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..ebe0a018a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -314,26 +314,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } fn aux(&self) -> Option> { - match &self.layer.source { - LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; - let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?; - // Aux pixels per layer pixel (the layer is the footage at its nominal size). - if f.width > 0 && a.width > 0 { - let mut a = (*a).clone(); - a.scale = a.width as f64 / f.width as f64; - return Some(Arc::new(a)); - } - Some(a) - } - LayerSource::Comp { item } => { - if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) { - return None; - } - self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new) - } - _ => None, - } + self.r.layer_aux(self.ctx, self.layer) } fn params_at(&self, layer_time: f64) -> Option { @@ -1000,8 +981,7 @@ impl<'a> Renderer<'a> { pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image { let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) }; let s = self.opts.scale; - let w = ((comp.width as f64 * s).round() as u32).max(1); - let h = ((comp.height as f64 * s).round() as u32).max(1); + let (w, h) = frame_size(comp.width, comp.height, s); // Region of interest on a comp with 3D layers: render the frame and crop it. A region // reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the // planes project through the offset camera — and Advanced 3D as the frame, padded. @@ -1184,6 +1164,7 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, + float_depth: self.pipe.levels.is_none(), shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)), }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. @@ -1478,10 +1459,11 @@ impl<'a> Renderer<'a> { let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); let nb = Arc::new(nb); - if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) { - cache.insert(k, nb.clone()); + // The buffer the cache keeps (another worker's, if it kept one first). + match (self.cache_to_keep(), key) { + (Some(cache), Some(k)) => cache.insert(k, nb), + _ => nb, } - nb } fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { @@ -1496,9 +1478,11 @@ impl<'a> Renderer<'a> { return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, flat.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let flat = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, flat), + _ => flat, + }; Some((flat, key)) } @@ -1527,9 +1511,11 @@ impl<'a> Renderer<'a> { } let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, buf.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let buf = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, buf), + _ => buf, + }; Some((buf, key)) } @@ -1552,9 +1538,11 @@ impl<'a> Renderer<'a> { buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin); } let buf = Arc::new(buf); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, buf.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let buf = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, buf), + _ => buf, + }; Some(buf) } @@ -1582,12 +1570,13 @@ impl<'a> Renderer<'a> { t.cached = false; t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3)); } - let body = Arc::new(st.body); - let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); + let mut body = Arc::new(st.body); + let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); + // The buffers the cache keeps (another worker's, if it kept them first). if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, body.clone()); - for (i, (b, _)) in passes.iter().enumerate() { - c.insert(cache::derive(k, i as u64 + 1), b.clone()); + body = c.insert(k, body); + for (i, (b, _)) in passes.iter_mut().enumerate() { + *b = c.insert(cache::derive(k, i as u64 + 1), b.clone()); } } Some(StyledLayer { passes, body, content, key, ckey, plain: false }) @@ -2043,6 +2032,12 @@ impl<'a> Renderer<'a> { color::convert(&mut below.img, &c); } let mut adjusted = self.apply_effects(ctx, layer, below, true); + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = + effectcraft_effects::adjustment_crop(adjusted.offset, o, (adjusted.img.width, adjusted.img.height), (canvas.width, canvas.height)) + { + adjusted.img = adjusted.img.crop(dx, dy, canvas.width, canvas.height); + } if let Some(c) = self.pipe.to_blend() { color::convert(&mut adjusted.img, &c); } @@ -2103,6 +2098,34 @@ impl<'a> Renderer<'a> { Some(three_d::compose::aux_pass(self, &ctx)) } + /// The auxiliary channels effects on `layer` read ([`EffectHost::aux`]): its multi-layer + /// OpenEXR footage's at the layer's source time (Time Remap included), or a precomp's + /// generated 3D channels at its nested time. `None` for other layers. + pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + match &layer.source { + LayerSource::Footage { item } => { + let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; + let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + // Aux pixels per layer pixel (the layer is the footage at its nominal size). The + // planes are only copied when that changes the scale. + let scale = a.width as f64 / f.width as f64; + if f.width > 0 && a.width > 0 && a.scale != scale { + let mut a = (*a).clone(); + a.scale = scale; + return Some(Arc::new(a)); + } + Some(a) + } + LayerSource::Comp { item } => { + if self.depth > MAX_FX_DEPTH || self.project.comp_contains(*item, ctx.comp_id) { + return None; + } + self.nested().comp_aux(*item, ctx.nested_time(layer)).map(Arc::new) + } + _ => None, + } + } + /// Evaluation context of comp `comp_id` at comp time `t`. pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option> { Some(self.ctx(comp_id, self.project.comp(comp_id)?, t)) @@ -2215,9 +2238,11 @@ impl<'a> Renderer<'a> { let mut foot = self.source(ctx, layer)?; let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let foot = Arc::new(foot); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, foot.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let foot = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, foot), + _ => foot, + }; Some(foot) } @@ -2363,6 +2388,12 @@ pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option Some([x0, y0, x1 - x0, y1 - y0]) } +/// The size of a `width` x `height` comp frame rendered at `scale` (both compositors, and the +/// frame size Composition Settings shows for a Resolution). +pub fn frame_size(width: u32, height: u32, scale: f64) -> (u32, u32) { + (((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1)) +} + pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) } @@ -2420,6 +2451,8 @@ mod tests_mask_blur; #[cfg(test)] mod tests_mask_expansion; #[cfg(test)] +mod tests_motion_tile; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..100ceda2f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -254,6 +254,22 @@ fn effects_run_in_pipeline() { assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } +/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame +/// solids: the effect sees the encoded solid colour and weights it Rec. 601. +#[test] +fn tint_in_an_srgb_working_space_matches_after_effects_greys() { + for (col, 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 (mut p, cid, comp) = setup(); + p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb); + let mut l = solid(&mut p, &comp, col, 200, 100); + add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]); + p.comp_mut(cid).unwrap().layers.push(l); + let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place. #[test] fn card_dance_comp_camera_matches_the_default_view() { @@ -1010,3 +1026,32 @@ fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() { let (a, b) = (particles_x(&here), particles_x(&panned)); assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}"); } + +/// #1812: preview workers that render a layer at once end up with one buffer (the one kept +/// first), so the GPU compositor uploads and packs it once, not once per worker. +#[test] +fn workers_rendering_a_layer_at_once_share_one_buffer() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.2, 0.5, 0.8], 1600, 1200); + add_effect(&mut p, &mut l, "ec.blur.gaussian", [1600.0, 1200.0], &[("blurriness", Value::Scalar(60.0))]); + p.comp_mut(cid).unwrap().layers.push(l); + let cache = crate::LayerCache::default(); + let start = std::sync::Barrier::new(8); + let bufs: Vec<_> = std::thread::scope(|s| { + let hs: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); + r.cache = Some(&cache); + let c = p.comp(cid).unwrap(); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + start.wait(); + r.layer_buf(&ctx, &c.layers[0]).unwrap() + }) + }) + .collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); + assert_eq!(cache.stats().entries, 1); +} diff --git a/crates/render/src/tests_auto.rs b/crates/render/src/tests_auto.rs index c34321cea..22f1a6215 100644 --- a/crates/render/src/tests_auto.rs +++ b/crates/render/src/tests_auto.rs @@ -9,31 +9,31 @@ use effectcraft_time::{FrameRate, Tick}; use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer}; -/// An accelerator that takes `delay` per frame (or declines with `handle = false`) and keeps -/// Auto timing history. -struct TimedAccel { - delay: std::time::Duration, +/// An accelerator that returns a representative pixel (or declines) and keeps Auto history. +struct MockAccel { handle: bool, calls: AtomicUsize, pick: AutoPick, } -impl TimedAccel { - fn new(ms: u64, handle: bool) -> TimedAccel { - TimedAccel { delay: std::time::Duration::from_millis(ms), handle, calls: Default::default(), pick: Default::default() } +impl MockAccel { + /// Construct a renderer stand-in without making assumptions about scheduling or speed. + fn new(handle: bool) -> MockAccel { + MockAccel { handle, calls: Default::default(), pick: Default::default() } } + /// Number of full-frame requests dispatched to this accelerator. fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } } -impl Accelerator for TimedAccel { +impl Accelerator for MockAccel { fn name(&self) -> String { "timed".into() } + /// Count dispatched frames and return the same centre pixel as the CPU fixture. fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option { self.calls.fetch_add(1, Ordering::SeqCst); - std::thread::sleep(self.delay); // The centre of the comp, as the CPU draws it (a red solid). self.handle.then(|| { let mut img = crate::Image::new(200, 100); @@ -52,6 +52,7 @@ impl Accelerator for TimedAccel { } } +/// A small red solid whose centre pixel is shared by the CPU and mock GPU fixtures. fn project() -> (Project, ItemId) { let mut p = Project::default(); p.settings.gpu_acceleration = true; @@ -64,7 +65,7 @@ fn project() -> (Project, ItemId) { } /// Render `n` frames with `backend`; how many the accelerator was asked for. -fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) -> usize { +fn frames(p: &Project, cid: ItemId, a: &MockAccel, backend: Backend, n: usize) -> usize { let before = a.calls(); for _ in 0..n { let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() }); @@ -76,50 +77,60 @@ fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) a.calls() - before } +/// Drive the production selector with measured costs supplied by the test, not the OS clock. +fn measured_frames(pick: &AutoPick, key: AutoKey, cpu_ms: f64, gpu_ms: f64, n: usize) -> usize { + (0..n) + .filter(|_| { + let gpu = pick.choose(key); + pick.record(key, gpu, if gpu { gpu_ms } else { cpu_ms }); + gpu + }) + .count() +} + +/// A slower GPU is periodically re-probed; explicit GPU and Software Only ignore that history. #[test] fn auto_backend_moves_light_comps_to_the_cpu() { - // A light comp on a slow accelerator: after the warm-up (three frames on - // each side) Auto renders on the CPU, re-measuring the accelerator now and then. let (p, cid) = project(); - // Several times the CPU's frame time (unoptimised test builds are slow). - let t0 = std::time::Instant::now(); - for _ in 0..3 { - Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); - } - let cpu_ms = t0.elapsed().as_millis() as u64 / 3; - let slow = TimedAccel::new((cpu_ms * 5).max(20), true); + let slow = MockAccel::new(true); + let key = AutoKey::new(cid, 1.0, false); let n = 6 + 2 * AutoPick::REPROBE as usize; - let asked = frames(&p, cid, &slow, Backend::Auto, n); + let asked = measured_frames(&slow.pick, key, 1.0, 20.0, n); assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames"); - let s = slow.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap(); + let s = slow.pick.stats(key).unwrap(); assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}"); - // Backend::Gpu ignores the history; Software Only never asks. + // Check renderer wiring with known history before a real measurement can change it. + assert_eq!(frames(&p, cid, &slow, Backend::Auto, 1), 0); assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3); let mut sw = p.clone(); sw.settings.gpu_acceleration = false; assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0); } +/// Faster, tied and slightly noisier GPU costs stay on the GPU except during warm-up/probes. #[test] fn auto_backend_keeps_fast_accelerators() { - // As fast as the CPU (ties stay on the accelerator): every frame but the CPU warm-up and - // re-probes. let (p, cid) = project(); - let fast = TimedAccel::new(0, true); - let asked = frames(&p, cid, &fast, Backend::Auto, 40); - assert!(asked >= 36, "asked {asked} of 40"); + for gpu_ms in [1.0, 5.0, 5.5] { + let fast = MockAccel::new(true); + let asked = measured_frames(&fast.pick, AutoKey::new(cid, 1.0, false), 5.0, gpu_ms, 40); + assert!(asked >= 36, "asked {asked} of 40 for GPU cost {gpu_ms}"); + assert_eq!(frames(&p, cid, &fast, Backend::Auto, 1), 1); + } } +/// A declining accelerator is retried only periodically while the CPU supplies frames. #[test] fn auto_backend_falls_back_when_the_accelerator_declines() { let (p, cid) = project(); - let declining = TimedAccel::new(0, false); + let declining = MockAccel::new(false); let n = 2 * AutoPick::REPROBE as usize; let asked = frames(&p, cid, &declining, Backend::Auto, n); // Asked first, then only on the periodic re-probes. assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames"); } +/// Periodic probes detect a formerly slow GPU becoming faster, without mixing cache keys. #[test] fn auto_pick_follows_changing_costs() { let pick = AutoPick::default(); diff --git a/crates/render/src/tests_footage_size.rs b/crates/render/src/tests_footage_size.rs index 88df726e9..065941d8a 100644 --- a/crates/render/src/tests_footage_size.rs +++ b/crates/render/src/tests_footage_size.rs @@ -81,3 +81,28 @@ fn reduced_renders_take_footage_resampled_by_the_source() { Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO); assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty()); } + +/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to +/// the GPU once, not every frame); a movie's is fetched again at every time. +#[test] +fn still_layers_are_cached_across_times_and_movies_are_not() { + for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] { + let (mut p, cid) = project(BitDepth::Bpc8, false); + let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") }; + if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) { + f.kind = kind; + } + let cache = crate::LayerCache::default(); + let src = Clip { sizes: Some(Mutex::default()) }; + let opts = RenderOpts { scale: 0.5, ..Default::default() }; + for t in [0.0, 0.25, 0.5] { + let t = Tick::from_seconds_f64(t); + let mut r = Renderer::new(&p, &src, opts); + r.cache = Some(&cache); + let frame = r.comp_frame(cid, t); + assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}"); + } + // (each uncached render above fetches once more) + assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}"); + } +} diff --git a/crates/render/src/tests_mask_blur.rs b/crates/render/src/tests_mask_blur.rs index 12b88bbba..07bc1e95f 100644 --- a/crates/render/src/tests_mask_blur.rs +++ b/crates/render/src/tests_mask_blur.rs @@ -41,6 +41,31 @@ fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 { Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3] } +/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at +/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there. +#[test] +fn mask_motion_blur_keys_the_layer_cache_by_time() { + let (mut p, cid) = project(true); + let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0)); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") }; + m.get_mut("path").unwrap().keys = vec![ + Keyframe::new(Tick::ZERO, rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)), + ]; + let cache = crate::LayerCache::default(); + let render = |t: Tick, cached: bool| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() }); + r.cache = cached.then_some(&cache); + r.comp_frame(cid, t) + }; + let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2)); + assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move"); + render(f1, true); + assert_eq!(render(f2, true), render(f2, false)); +} + #[test] fn mask_motion_blur_follows_the_render_and_the_comp() { let (p, cid) = project(true); diff --git a/crates/render/src/tests_motion_tile.rs b/crates/render/src/tests_motion_tile.rs new file mode 100644 index 000000000..7d1645a02 --- /dev/null +++ b/crates/render/src/tests_motion_tile.rs @@ -0,0 +1,169 @@ +//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a +//! Transform after it that scales the tiled result into view (#1727, #1991). + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, render_frame}; + +const W: u32 = 200; +const H: u32 = 100; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]); + l +} + +/// A white frame with a red 20×20 square in its top-left corner. +fn picture(p: &mut Project, comp: &Comp) -> Vec { + vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])] +} + +/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`. +fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) { + let size = [W as f64, H as f64]; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]), + ("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); + } +} + +fn comp() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +/// The picture under an adjustment layer carrying the two effects. +fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]); + adj.switches.adjustment = true; + tile_then_scale(&mut p, &mut adj, out, scale); + let mut layers = vec![adj]; + layers.extend(picture(&mut p, &comp)); + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +/// The picture as a full-frame precomp layer carrying the two effects. +fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let layers = picture(&mut p, &inner); + p.comp_mut(iid).unwrap().layers = layers; + let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); + tile_then_scale(&mut p, &mut pre, out, scale); + p.comp_mut(cid).unwrap().layers.push(pre); + (p, cid) +} + +fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) { + let got = img.get(x, y); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}"); +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const WHITE: [f32; 4] = [1.0; 4]; +const CLEAR: [f32; 4] = [0.0; 4]; + +/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame +/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and +/// the frame is covered edge to edge, the tiles from beyond the frame included. +fn assert_tiled(img: &Image, what: &str) { + for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] { + is(img, x, y, RED, what); + } + for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] { + is(img, x, y, WHITE, what); + } +} + +#[test] +fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = adjustment_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %"); +} + +#[test] +fn layer_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = layer_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %"); +} + +/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent +/// (After Effects shows the comp background there). +#[test] +fn adjustment_motion_tile_output_under_100_crops_the_frame() { + let (p, cid) = adjustment_scene(50.0, 100.0); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + is(&img, 100, 50, WHITE, "inside the kept centre"); + is(&img, 60, 30, WHITE, "inside the kept centre"); + for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] { + is(&img, x, y, CLEAR, "outside the kept centre"); + } +} + +/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with +/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540). +/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540): +/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465. +#[test] +fn adjustment_motion_tile_matches_the_after_effects_frames() { + for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]); + adj.switches.adjustment = true; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]), + ( + "ec.distort.transform", + vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))], + ), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + adj.props.sub_mut("effects").unwrap().children.push(g.into()); + } + p.comp_mut(cid).unwrap().layers = vec![adj, red]; + // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). + let img = render_frame(&p, cid, Tick::ZERO, 0.25); + let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); + let what = format!("Output {out_w} %"); + for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] { + assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red"); + } + for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] { + assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty"); + } + if x1 < 1920.0 { + assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column"); + } + } +} diff --git a/crates/render/src/tests_styles.rs b/crates/render/src/tests_styles.rs index 147ff1590..b59f009de 100644 --- a/crates/render/src/tests_styles.rs +++ b/crates/render/src/tests_styles.rs @@ -475,3 +475,35 @@ fn gradient_glow_uses_gradient() { let px = img.straight(121, 50); assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}"); } + +/// #1812 (review of #1928): a styled layer rendered by several workers at once shares its body and +/// style passes, as unstyled layers share their buffer. +#[test] +fn workers_styling_a_layer_at_once_share_its_buffers() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "dropShadow"); + add(p, c, l, "stroke"); + }); + let cache = crate::LayerCache::default(); + let start = std::sync::Barrier::new(8); + let styled: Vec<_> = std::thread::scope(|s| { + let hs: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + start.wait(); + r.styled_layer(&ctx, &p.comp(cid).unwrap().layers[0]).unwrap() + }) + }) + .collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + let first = &styled[0]; + assert!(!first.passes.is_empty(), "the drop shadow is a pass"); + for st in &styled { + assert!(std::sync::Arc::ptr_eq(&st.body, &first.body), "one body"); + assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes"); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..496bc3a60 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -12,7 +12,7 @@ use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3}; use effectcraft_keyframe::{Justify, TextDoc, Value}; use effectcraft_path::{BezPath, FillRule, StrokeStyle}; use effectcraft_project::{Layer, PropGroup}; -use effectcraft_raster::Image; +use effectcraft_raster::{Image, Mask}; use effectcraft_text::path_text::{self, PathMeasure}; use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly}; use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc}; @@ -187,6 +187,14 @@ pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 { if units_index { (v * n as f64).round() } else { v * 100.0 } } +/// Whether property `uid` of `layer` is a Range Selector's Start, End or Offset while its Units +/// are Index: the value is then a count of characters (words, lines), not a percentage. +pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool { + let Some(sel) = layer.props.parent_of(uid).filter(|g| g.match_id == "rangeSelector") else { return false }; + let is_range_value = sel.props().any(|p| p.uid == uid && matches!(p.match_id.as_str(), "start" | "end" | "offset")); + is_range_value && sel.prop("advanced/units").is_some_and(|u| u.value == Value::Enum(1)) +} + /// Selection of every glyph by an animator's selectors, per dimension. pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> { let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default(); @@ -479,6 +487,9 @@ pub struct PlacedGlyph { pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, + /// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows + /// on every side, in character space (0 when not bold). + pub bold: f64, /// The character's style fills it (Character panel fill on). pub apply_fill: bool, /// Index of the character in the text. @@ -498,8 +509,30 @@ impl PlacedGlyph { pub fn path(&self) -> BezPath { effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0) } + /// [`Self::bold`] in layer space. + fn bold_in_layer(&self) -> f64 { + self.bold * self.m2().mean_scale() + } + /// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space: + /// no bold, or a matrix that only rotates and scales uniformly. + fn bold_is_even(&self) -> bool { + let [[a, b, _], [c, d, _], _] = self.m2().0; + let n = a * a + b * b + c * c + d * d; + self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n) + } } +/// How far a circle of radius `r` reaches along x and along y once mapped by `m`. +fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] { + let [[a, b, _], [c, d, _], _] = m.0; + [r * a.hypot(b), r * c.hypot(d)] +} + +/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter +/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a +/// missing bold face. +const FAUX_BOLD_RADIUS: f64 = 0.03; + /// Laid-out, animated text of a layer at the context time. pub struct TextGeom { pub doc: TextDoc, @@ -777,6 +810,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 }, apply_fill: st.apply_fill, char_index: g.char_index, rest, @@ -826,17 +860,41 @@ fn passes(geom: &TextGeom) -> Vec { v } +/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill +/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased. +fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask { + let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero); + if d.abs() < 1e-9 { + return cov; + } + let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() }; + let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h); + for (c, r) in cov.data.iter_mut().zip(&ring.data) { + *c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) }; + } + cov +} + /// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths /// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred -/// when the character has Blur. -fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image { +/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space. +fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> Image { let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32); let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m; - let cov = if stroke { + let cov = if stroke && bold > 0.0 { + // The stroke of the bold outline: the band between it grown and shrunk by half the width. + let half = g.stroke_width / 2.0; + let mut outer = grown_coverage(paths, &mm, w, h, bold + half); + let inner = grown_coverage(paths, &mm, w, h, bold - half); + for (o, i) in outer.data.iter_mut().zip(&inner.data) { + *o = o.min(1.0 - i); + } + outer + } else if stroke { let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; effectcraft_path::stroke_coverage(paths, &st, &mm, w, h) } else { - effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero) + grown_coverage(paths, &mm, w, h, bold) }; let col = if stroke { g.stroke } else { g.fill }; let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; @@ -892,10 +950,16 @@ fn runs(geom: &TextGeom) -> Vec<(Vec, bool)> { if g.xf.opacity <= 0.0 { continue; } + // A run grows its outline in layer space by one radius, which matches each glyph grown in + // its own space only when the bold stays round there. if geom.blend == BlendMode::Normal && solid_fill(g, stroke) + && g.bold_is_even() && let Some((run, false)) = out.last_mut() - && run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill) + && run.last().is_some_and(|&p| { + let q = &geom.glyphs[p]; + solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6 + }) { run.push(gi); continue; @@ -917,28 +981,31 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt // One glyph is drawn from its own outline; a run from its glyphs' outlines in layer // space, all wound the same way so overlaps add up instead of cancelling (NonZero). let local = Cow::Borrowed(std::slice::from_ref(&g.local)); - let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) { - (Some(_), _) => (local, *to_px), - (None, [_]) => (local, *to_px * g.m2()), + let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) { + (Some(_), _) => (local, *to_px, g.bold), + (None, [_]) => (local, *to_px * g.m2(), g.bold), (None, _) => { let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p }; - (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px) + (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer()) } }; let Some(b) = effectcraft_path::bounds(&paths) else { continue }; let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s]; - let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; + // Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach + // further along one axis than the other. + let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold); + let pad = 2.0 + blur[0].max(blur[1]) * 1.5; let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1)); let rect = [ - ((r.x0 - pad).floor() as i64).max(0), - ((r.y0 - pad).floor() as i64).max(0), - ((r.x1 + pad).ceil() as i64).min(w), - ((r.y1 + pad).ceil() as i64).min(h), + ((r.x0 - px - pad).floor() as i64).max(0), + ((r.y0 - py - pad).floor() as i64).max(0), + ((r.x1 + px + pad).ceil() as i64).min(w), + ((r.y1 + py + pad).ceil() as i64).min(h), ]; if rect[2] <= rect[0] || rect[3] <= rect[1] { continue; } - let patch = raster_pass(g, stroke, &paths, &m, rect, blur); + let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold); blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend }); } } @@ -950,8 +1017,9 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { let mut bounds: Option = None; for g in &geom.glyphs { if let Some(b) = effectcraft_path::bounds(&[g.path()]) { + let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold); let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; - let b = b.inflate(pad, pad); + let b = b.inflate(pad + bx, pad + by); bounds = Some(bounds.map_or(b, |a| a.union(b))); } } @@ -976,7 +1044,7 @@ pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)> continue; } let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue }; - let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let pad = g.stroke_width + 2.0 * g.bold + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; let b = b.inflate(pad, pad); let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096); let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..ea89e37b0 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -99,6 +99,24 @@ fn range_based_on_words_and_lines() { assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}"); } +/// With Units = Index a Range Selector's Start, End and Offset are character counts, not +/// percentages (#1814); the selector's other values, and other selectors, are unaffected. +#[test] +fn index_units_mark_start_end_and_offset() { + let (mut p, _cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCDEFG", 30.0)); + animator(&mut p, &mut l, &["opacity"], &["range", "wiggly"]); + let uid = |l: &Layer, path: &str| l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; + let base = "text/animators/#1/selectors/#1"; + let range_values = ["start", "end", "offset"].map(|k| uid(&l, &format!("{base}/{k}"))); + assert!(range_values.iter().all(|u| !in_index_units(&l, *u)), "Percentage, the default"); + set(&mut l, &format!("{base}/advanced/units"), Value::Enum(1)); + assert!(range_values.iter().all(|u| in_index_units(&l, *u))); + assert!(!in_index_units(&l, uid(&l, &format!("{base}/advanced/amount"))), "Amount stays a percentage"); + assert!(!in_index_units(&l, uid(&l, "text/animators/#1/properties/opacity"))); + assert!(!in_index_units(&l, uid(&l, "transform/opacity"))); +} + #[test] fn range_start_end_and_offset_are_capped() { let (mut p, cid, comp) = setup(400, 200); @@ -524,6 +542,7 @@ fn touching_glyphs_join_without_a_seam() { fill, stroke: [0.0; 4], stroke_width: 0.0, + bold: 0.0, apply_fill: true, char_index: 0, rest: [0.0; 2], @@ -551,6 +570,64 @@ fn touching_glyphs_join_without_a_seam() { assert!(multiply[3] < 0.9, "{multiply:?}"); } +/// #1676: Faux Bold, and the synthetic bold of a style the family has no face for, are drawn. +#[test] +fn faux_bold_and_synthetic_bold_thicken_the_glyphs() { + // Total alpha and the alpha bounding box of a rendered word. + let ink = |d: TextDoc| { + let (mut p, cid, comp) = setup(400, 120); + let mut l = text_layer(&mut p, &comp, TextDoc { text: "Hamburg".into(), size: 60.0, ..d }); + set(&mut l, "transform/position", Value::Vec3([40.0, 85.0, 0.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + (img.data.iter().map(|px| px[3]).sum::(), alpha_bbox(&img).unwrap()) + }; + let (plain, pb) = ink(TextDoc::default()); + let (bold, bb) = ink(TextDoc { faux_bold: true, ..Default::default() }); + assert!(bold > plain * 1.15, "faux bold adds ink: {plain} → {bold}"); + // 0.03 em = 1.8 px on every side. + let grow = (bb[2] - bb[0]) - (pb[2] - pb[0]); + assert!((3..=6).contains(&grow), "and widens the word by about 3.6 px: {pb:?} → {bb:?}"); + // Bundled Inter has no Bold Italic face: the italic face is drawn with synthetic bold. + let (italic, _) = ink(TextDoc { style: "Italic".into(), ..Default::default() }); + let (bold_italic, _) = ink(TextDoc { style: "Bold Italic".into(), ..Default::default() }); + assert!(bold_italic > italic * 1.15, "Bold Italic is bolder than Italic: {italic} → {bold_italic}"); + // A stroke follows the bold outline: as wide as before, moved out with it. + let stroked = TextDoc { apply_fill: false, apply_stroke: true, stroke: [1.0; 4], stroke_width: 4.0, ..Default::default() }; + let (ring, rb) = ink(stroked.clone()); + let (bold_ring, brb) = ink(TextDoc { faux_bold: true, ..stroked }); + assert!(bold_ring > ring * 0.95 && bold_ring < ring * 1.3, "the stroke keeps its width: {ring} → {bold_ring}"); + assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}"); +} + +/// #1676: under a non-uniform character Scale the bold is grown in the character's space, so it +/// reaches further along the stretched axis; it is neither clipped nor grown evenly in a run. +#[test] +fn faux_bold_under_a_non_uniform_scale() { + use kurbo::Shape; + let glyph = |x0: f64| PlacedGlyph { + local: kurbo::Rect::new(x0, 4.0, x0 + 4.0, 16.0).to_path(0.1), + m: Mat4::scale(vec3(9.0, 1.0, 1.0)), + xf: CharXf::default(), + fill: [1.0; 4], + stroke: [0.0; 4], + stroke_width: 0.0, + bold: 2.0, + apply_fill: true, + char_index: 0, + rest: [0.0; 2], + }; + let geom = |glyphs| TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend: BlendMode::Normal }; + assert_eq!(runs(&geom(vec![glyph(2.0), glyph(6.0)])).len(), 2, "bold glyphs stretched unevenly are drawn one by one"); + // x 6..10 → 54..90, grown by 2 × 9 = 18 → 36..108; y 4..16 grown by 2 → 2..18. + let mut img = crate::Image::new(120, 24); + draw_glyphs(&geom(vec![glyph(6.0)]), &mut img, &Mat3::IDENTITY, 1.0, None); + for (x, y) in [(37, 10), (106, 10), (70, 3)] { + assert!(img.get(x, y)[3] > 0.99, "bold ink at ({x}, {y}): {:?}", img.get(x, y)); + } + assert!(img.get(34, 10)[3] < 0.01 && img.get(110, 10)[3] < 0.01 && img.get(70, 0)[3] < 0.01); +} + /// #1011: what dragging a Range Selector bar to fraction `f` of the text writes. #[test] fn range_bar_drag_values() { diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 972a627bb..395057935 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -8,6 +8,9 @@ //! (already sorted back to front) are then shaded per fragment and blended over, depth-tested //! against the opaque surfaces. +use std::cmp::Reverse; +use std::collections::BinaryHeap; + use rayon::prelude::*; use super::scene::{Scene, ShadowMap, Vertex}; @@ -422,6 +425,81 @@ fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec { d } +/// Hard cap on the shadow depth texels one render allocates (f32 depth texels). +/// Quality map sizes derive from the raster resolution, and both the resolution and +/// the light count come from the project, so without a scene-wide budget an +/// arbitrarily large comp with arbitrarily many casting lights (point lights cost +/// six faces) could balloon the allocation. 26 M texels (≈ 104 MB) holds one point +/// light's full 2048-texel cube (6 × 2048² ≈ 25.2 M) and stays under WebGPU's default +/// 128 MiB storage-binding limit; adapters with less fall back to the CPU raster. +pub(crate) const MAX_SHADOW_TEXELS: u64 = 26_000_000; + +/// Long side of a quality shadow map: one map texel per output pixel, to a power of +/// two, clamped to 512..4096 (raster sizes are user data and may be absurd). Draft +/// (interactive fast-preview) renders keep the flat 512 budget, unchanged. +pub(crate) fn shadow_map_size(width: u32, height: u32, draft: bool) -> u32 { + if draft { + return 512; + } + let side = width.max(height).clamp(512, 4096); + let mut size = 512u32; + while size < side { + size *= 2; + } + size +} + +/// Per-face size of a point light's cube map: the full map size, since each face spans +/// 90° and anything smaller is coarser than a spot light's map in the same comp (a +/// point light low over a floor shows exactly that as stair-steps), capped at 2048 so +/// one cube fits [`MAX_SHADOW_TEXELS`]. Draft keeps half the map size as before. +pub(crate) fn shadow_point_face_size(size: u32, draft: bool) -> u32 { + if draft { (size / 2).max(1) } else { size.min(2048) } +} + +/// Per-light map sizes (`None` = this light gets no shadow map). `kinds`: 0 = no +/// shadows (costs nothing), 1 = single map (spot/parallel), 2 = point cube (budgeted +/// as its six faces). Lights start at their full size; while the scene exceeds +/// [`MAX_SHADOW_TEXELS`] the largest map above its floor is halved (power-of-two +/// steps, the earliest light first on ties), and once every light is at its floor the +/// last lights lose their shadows entirely. Deterministic, and O(n log n) in the +/// light count: each light is halved at most a handful of times and dropped once. +pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) -> Vec> { + let base = shadow_map_size(width, height, draft); + let faces = |k: u32| -> u64 { if k == 2 { 6 } else { 1 } }; + let floor = |k: u32| -> u32 { if k == 2 { 128 } else { 512 } }; + let mut sizes: Vec = kinds + .iter() + .map(|&k| match k { + 0 => 0, + 2 => shadow_point_face_size(base, draft), + _ => base, + }) + .collect(); + let cost = |z: u32, k: u32| -> u64 { (z as u64) * (z as u64) * faces(k) }; + let mut total: u64 = sizes.iter().zip(kinds).map(|(&z, &k)| cost(z, k)).sum(); + // Largest size first, then the lowest index. + let mut heap: BinaryHeap<(u32, Reverse)> = + sizes.iter().zip(kinds).enumerate().filter(|(_, (z, k))| **z > floor(**k)).map(|(i, (&z, _))| (z, Reverse(i))).collect(); + while total > MAX_SHADOW_TEXELS { + let Some((z, Reverse(i))) = heap.pop() else { break }; + let k = kinds[i]; + total -= cost(z, k) - cost(z / 2, k); + sizes[i] = z / 2; + if z / 2 > floor(k) { + heap.push((z / 2, Reverse(i))); + } + } + for i in (0..sizes.len()).rev() { + if total <= MAX_SHADOW_TEXELS { + break; + } + total -= cost(sizes[i], kinds[i]); + sizes[i] = 0; + } + sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() +} + /// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`). pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { let casters: Vec = (0..s.indices.len() / 3) @@ -443,15 +521,37 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { } let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5); let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0; - let size = if draft { 512 } else { 1024 }; + // Map sizes come from the raster (a quality map gives ~one texel per output + // pixel); the per-light budget keeps the scene allocation bounded because + // comp size and light count are user input (point lights cost six faces). + let kinds: Vec = s + .lights + .iter() + .map(|l| { + if l.shadow != 0 { + 0 + } else if l.kind > 1 { + 2 + } else { + 1 + } + }) + .collect(); + let sizes = shadow_sizes(s.width, s.height, draft, &kinds); let mut maps: Vec = vec![]; for li in 0..s.lights.len() { + let Some(size) = sizes.get(li).copied().flatten() else { + s.lights[li].shadow = -1; + continue; + }; if s.lights[li].shadow != 0 { s.lights[li].shadow = -1; continue; } let l = s.lights[li]; let first = maps.len() as i32; + // Penumbra width in output pixels (the shader turns it into a tap reach at + // the receiver); the sampling bias is deliberately independent of it. let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0); let pos = l.pos.map(|v| v as f64); let dir = l.dir.map(|v| v as f64); @@ -468,7 +568,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.5, radius: soft }); } _ => { - let fs = size / 2; + let fs = size; for (f, d) in [ ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index b75d5af7c..75b9c976f 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -157,7 +157,8 @@ pub struct ShadowMap { pub offset: u32, /// Depth bias in texels (scaled by the texel footprint at the receiver). pub bias: f32, - /// PCF spread (texels). + /// Penumbra width (output pixels) requested by Shadow Diffusion; the shader + /// converts it to a tap reach via the receiver's texel footprint. pub radius: f32, } diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 666f2e445..c7c1a40f1 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -206,36 +206,125 @@ pub fn shadow_coord(m: &ShadowMap, p: [f32; 3]) -> Option<(f32, f32, f32)> { } } -/// Fraction of light reaching `p` through shadow map `m` (1 = lit), PCF over a 5×5 grid -/// spread over `radius` texels. -pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f32 { +/// Integer tap jitter hash: pure u32 bit math so the WGSL backend computes +/// bit-identical jitter from the same inputs (a float hash would diverge between +/// backends and break CPU/GPU parity). +fn tap_hash(x: i32, y: i32) -> u32 { + let mut h = (x as u32).wrapping_mul(0x9E37_79B9) ^ (y as u32).wrapping_mul(0x85EB_CA6B); + h ^= h >> 16; + h = h.wrapping_mul(0x2545_F491); + h ^= h >> 15; + h +} + +/// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps +/// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment +/// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest). +const PCF_MAX_TAPS: i32 = 10; + +/// Steepest receiver-plane slope the PCF extrapolates, in texel footprints of depth per +/// texel (about 89°). Steeper planes are edge-on to the light, where their depth runs +/// away; they are clamped to this slope. +const PLANE_SLOPE_MAX: f32 = 64.0; + +/// Fraction of light reaching `p` through shadow map `m` (1 = lit). `ng` is the +/// receiver's geometric (not normal-mapped) normal facing the viewer. +/// +/// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived +/// from `radius` (the wanted penumbra in output pixels) divided by the receiver's +/// texel footprint, each tap jittered by an integer hash of the receiver's +/// sub-texel cell and the tap index, and each tap comparing against the four +/// nearest texel centres with bilinear weights. The jitter detaches the tap grid +/// from the texel axes (axis-aligned binary taps are what stair-step a straight +/// shadow edge), and keying it below texel size keeps the dither finer than an +/// output pixel even where one texel covers several. The bilinear compare makes +/// each tap's contribution vary smoothly inside a texel instead of snapping. +/// +/// Every texel is compared against the depth the receiver's own plane has at that +/// texel's centre, not the receiver point's depth: a wide kernel on a surface tilted +/// towards the light would otherwise see its own nearer depth and shadow itself. +/// With that, the bias stays at texel scale; a diffusion-scaled bias lifts shadows +/// off their casters. +pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 { let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 }; let size = m.size as i64; - // Slope-scaled bias (world units are pixels). - // Texel footprint (world units) × PCF reach, steeper for grazing light. + // Slope-scaled bias at texel scale (world units are pixels), steeper for + // grazing light: it absorbs depth-raster error and the gap between the shading + // normal's plane and the faceted surface the map rasterised. let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal }; - let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0))); - let step = m.radius / 2.0; + let bias = m.bias * texel * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0))); + let at = |xi: i64, yi: i64| -> f32 { + if xi < 0 || yi < 0 || xi >= size || yi >= size { + return f32::INFINITY; // outside the map: the light was never blocked there (WGSL mirrors this with a large finite depth) + } + texels.get(m.offset as usize + (yi * size + xi) as usize).copied().unwrap_or(f32::INFINITY) + }; + // The receiver plane in light space as the slope of its depth across the map, in + // texel footprints per texel (`tx`, `ty`; depth grows along −t), clamped where the + // plane grazes the light. `den0` is the plane normal against the light ray through + // the receiver (the depth axis for an orthographic map), negative while it faces it. + let w = &m.view; + let nv = [0, 1, 2].map(|r| w[r][0] * ng[0] + w[r][1] * ng[1] + w[r][2] * ng[2]); + let c = m.size as f32 * 0.5; + let den0 = if m.ortho { nv[2] } else { nv[0] * ((u - c) / m.focal) + nv[1] * ((v - c) / m.focal) + nv[2] }; + let (tx, ty) = if den0 < -1e-6 { + ((nv[0] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), (nv[1] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX)) + } else { + (0.0, 0.0) + }; + // 1 where texel (xi, yi) does not occlude the receiver plane at the texel's centre. + // Perspective depth is not linear across the map but its inverse is, so that + // compare runs on 1/depth (inv · (texel + bias) ≥ 1) without a division; a plane + // whose inverse depth reaches 0 has left the light's side and cannot be occluded. + let (iz, iq) = (1.0 / z, 1.0 / (m.focal * z)); + let open = |xi: i64, yi: i64| -> f32 { + let s = at(xi, yi) + bias; + let g = tx * (xi as f32 + 0.5 - u) + ty * (yi as f32 + 0.5 - v); + let lit = if m.ortho { + z - g * texel <= s + } else { + let inv = iz + g * iq; + inv <= 0.0 || inv * s >= 1.0 + }; + if lit { 1.0 } else { 0.0 } + }; + // `radius` is the wanted penumbra in output pixels; converting it through the + // receiver's texel footprint keeps the penumbra width in output pixels at any + // map resolution (and lets perspective penumbra grow with distance from the light). + // At least two texels: a hard edge then ramps across two texels, which hides the + // texel steps of the binary depth compare where one texel spans several output + // pixels (receivers grazing the light); a one-texel ramp leaves a sawtooth. + let mut reach = (m.radius / texel).clamp(2.0, 256.0); + if !reach.is_finite() { + reach = 1.0; + } + let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); + let step = reach / (n - 1) as f32; + let half = (n - 1) as f32 * 0.5; + let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32); let mut lit = 0.0; - for j in -2..=2 { - for i in -2..=2 { - let x = (u + i as f32 * step).floor() as i64; - let y = (v + j as f32 * step).floor() as i64; - if x < 0 || y < 0 || x >= size || y >= size { - lit += 1.0; - continue; - } - let d = texels[m.offset as usize + (y * size + x) as usize]; - if z - bias <= d { - lit += 1.0; - } + for j in 0..n { + for i in 0..n { + let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919))); + let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); + // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the + // four is compared against the receiver plane at that centre. + let (tu, tv) = (su - 0.5, sv - 0.5); + let (fx, fy) = (tu - tu.floor(), tv - tv.floor()); + let (x0, y0) = (tu.floor() as i64, tv.floor() as i64); + let (h00, h10, h01, h11) = (open(x0, y0), open(x0 + 1, y0), open(x0, y0 + 1), open(x0 + 1, y0 + 1)); + let a = h00 + (h10 - h00) * fx; + let b = h01 + (h11 - h01) * fx; + lit += a + (b - a) * fy; } } - lit / 25.0 + lit / (n * n) as f32 } /// The visibility of light `li` at `p` (1 = unshadowed), darkened by Shadow Darkness. -pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 { +pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 { if l.shadow < 0 { return 1.0; } @@ -258,7 +347,7 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 { s.shadows.get(first) }; let Some(m) = m else { return 1.0 }; - let f = shadow_factor(m, &s.shadow_texels, p, n_l); + let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); 1.0 - l.shadow_darkness * (1.0 - f) } @@ -367,6 +456,8 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { if !f.front { n = scale(n, -1.0); } + // Shadow lookups follow the geometric surface, not the normal map. + let ng = n; if let Some(t) = tex(m.tex_normal) { let tn = [(t[0] * 2.0 - 1.0) * m.normal_scale, (t[1] * 2.0 - 1.0) * m.normal_scale, t[2] * 2.0 - 1.0]; let tg = [f.tangent[0], f.tangent[1], f.tangent[2]]; @@ -392,7 +483,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { if n_l <= 0.0 { continue; } - let sh = if m.receives_shadows { light_shadow(s, l, f.pos, n_l) } else { 1.0 }; + let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 }; let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l); for c in 0..3 { out[c] += b[c] * l.color[c] * k * sh; @@ -433,7 +524,7 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] { if n_l <= 0.0 { continue; } - amount = amount.max(1.0 - light_shadow(s, l, f.pos, n_l)); + amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l)); } [0.0, 0.0, 0.0, alpha * amount] } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 694a5c2be..3f5ee4813 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -681,3 +681,369 @@ fn stroke_regions_are_rings() { assert_eq!(outlines.len(), 1); assert_eq!(outlines[0].holes.len(), 1); } + +// ---------------------------------------------------------------- shadow quality + +use super::raster::{MAX_SHADOW_TEXELS, shadow_map_size, shadow_point_face_size, shadow_sizes}; +use super::scene::ShadowMap; + +/// Geometric normal of a receiver squarely facing a `flat_map` light (which looks along +z). +const FACING: [f32; 3] = [0.0, 0.0, -1.0]; + +/// Identity-view orthographic map: map coords are world x, y × `focal` offset by +/// `size/2` (`focal` texels per world unit). +fn flat_map(size: u32, focal: f32, radius: f32) -> ShadowMap { + ShadowMap { view: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], focal, ortho: true, size, offset: 0, bias: 1.5, radius } +} + +/// Wall: texel columns ≥ a quarter of the map at depth 10, the rest unoccluded (1000). +/// Its edge sits at world x = −size / (4·focal). +fn wall_map(size: u32, focal: f32, radius: f32) -> (ShadowMap, Vec) { + let texels: Vec = (0..size * size).map(|i| if i % size >= size / 4 { 10.0 } else { 1000.0 }).collect(); + (flat_map(size, focal, radius), texels) +} + +/// Luma of a render of `cid` with its top layer (a light) set to cast shadows or not, +/// at the given Shadow Diffusion. +fn render_light_luma(p: &mut Project, cid: ItemId, casting: bool, diffusion: f64) -> Vec { + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(casting)); + set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(diffusion)); + render(p, cid).data.iter().map(|q| (q[0] + q[1] + q[2]) / 3.0).collect() +} + +#[test] +fn shadow_map_size_tracks_raster_resolution() { + assert_eq!(shadow_map_size(640, 360, false), 1024); + assert_eq!(shadow_map_size(1920, 1080, false), 2048); + assert_eq!(shadow_map_size(3840, 2160, false), 4096); + // Clamped to the pow2 range: small or absurd rasters stay inside 512..4096. + assert_eq!(shadow_map_size(64, 64, false), 512); + assert_eq!(shadow_map_size(0, 0, false), 512); + assert_eq!(shadow_map_size(u32::MAX, u32::MAX, false), 4096); + assert_eq!(shadow_map_size(7, 100000, false), 4096); + // The fast-preview (draft) budget stays at 512 regardless, so interactivity is + // untouched by the quality path's new sizing. + assert_eq!(shadow_map_size(3840, 2160, true), 512); + assert_eq!(shadow_map_size(0, 0, true), 512); +} + +#[test] +fn shadow_point_faces_are_capped() { + // Cube faces take the full map size up to 2048 (one 2048 cube ≈ 25 M f32 texels). + assert_eq!(shadow_point_face_size(4096, false), 2048); + assert_eq!(shadow_point_face_size(2048, false), 2048); + assert_eq!(shadow_point_face_size(1024, false), 1024); + assert_eq!(shadow_point_face_size(512, false), 512); + // Draft keeps the historical half-size face. + assert_eq!(shadow_point_face_size(512, true), 256); +} + +fn budget_texels(sizes: &[Option], kinds: &[u32]) -> u64 { + sizes.iter().zip(kinds).map(|(s, &k)| s.map(|z| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).unwrap_or(0)).sum() +} + +#[test] +fn shadow_sizes_stay_within_the_scene_budget() { + // Within budget a light gets its full size: one 4K point cube keeps 2048 faces. + assert_eq!(shadow_sizes(4096, 2160, false, &[2]), vec![Some(2048)]); + // Several cubes overflow: deterministically halved (largest first, earliest light + // on ties) until inside: three or four settle at 1024, five push two to 512. + assert_eq!(shadow_sizes(4096, 2160, false, &[2; 3]), vec![Some(1024); 3]); + assert_eq!(shadow_sizes(4096, 2160, false, &[2; 4]), vec![Some(1024); 4]); + let sizes = shadow_sizes(4096, 2160, false, &[2; 5]); + assert_eq!(sizes, vec![Some(512), Some(512), Some(1024), Some(1024), Some(1024)]); + assert!(budget_texels(&sizes, &[2; 5]) <= MAX_SHADOW_TEXELS); + // A hostile light count can never break the cap; sizes only shrink or drop to None. + let many = vec![2u32; 1000]; + let sizes = shadow_sizes(3840, 2160, false, &many); + assert_eq!(sizes.len(), 1000); + assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS); + assert!(sizes.iter().flatten().all(|&z| (128..=2048).contains(&z))); + // Deterministic: same inputs, same plan. + assert_eq!(sizes, shadow_sizes(3840, 2160, false, &many)); + // Non-point lights and a modest scene keep full quality sizes. + assert_eq!(shadow_sizes(1920, 1080, false, &[1; 2]), vec![Some(2048), Some(2048)]); + // A light that casts nothing costs nothing: it stays None and leaves its + // full-size budget to the lights that do cast. + assert_eq!(shadow_sizes(640, 360, false, &[0, 1]), vec![None, Some(1024)]); + assert!(shadow_sizes(640, 360, false, &[0; 500]).iter().all(Option::is_none)); +} + +#[test] +fn diffusion_does_not_lift_a_deep_shadow() { + // Shadow Diffusion may widen the penumbra but must not scale the depth bias: a + // point deep inside a shadow stays fully shadowed at any diffusion. + let (m, texels) = wall_map(64, 1.0, 1.0); + let deep = [0.0f32, 0.0, 13.5]; // u = 32: ±16 texels of margin around the edge at 16; z − bias 12 > 10 + let hard = shade::shadow_factor(&m, &texels, deep, FACING, 1.0); + assert!(hard < 0.01, "deep shadow at radius 1: {hard}"); + let wide = shade::shadow_factor(&flat_map(64, 1.0, 25.0), &texels, deep, FACING, 1.0); + assert!(wide < 0.01, "deep shadow at radius 25 (bias must not grow with radius): {wide}"); +} + +#[test] +fn pcf_varies_within_a_texel() { + // Nearest-texel PCF snaps the edge to texel boundaries: two receivers 1/20 texel + // apart get bit-identical factors, which is what stair-steps straight edges. + // The filtering must produce coverage that varies within a single texel. + let (m, texels) = wall_map(64, 1.0, 2.0); + let mut pairs = 0; + let mut varying = 0; + let mut u = -17.0f32; + while u < -15.3 { + let a = shade::shadow_factor(&m, &texels, [u, 0.0, 13.5], FACING, 1.0); + let b = shade::shadow_factor(&m, &texels, [u + 0.05, 0.0, 13.5], FACING, 1.0); + pairs += 1; + if (a - b).abs() > 1e-6 { + varying += 1; + } + assert!((0.0..=1.0).contains(&a) && (0.0..=1.0).contains(&b), "{a} {b}"); + u += 0.05; + } + assert!(pairs >= 30); + assert_eq!(varying, pairs, "coverage varies inside every texel along the edge"); +} + +#[test] +fn wide_diffusion_keeps_shadow_depth_and_contact() { + // Scene: camera-facing floor, sphere casting, point light behind the camera and + // off to the upper right so most of the shadow lands beside the sphere, not + // behind it. Raising Shadow Diffusion must neither wash the shadow out (a + // diffusion-scaled bias leaks light into it) nor move where it touches. + let (mut p, cid) = project(200, 150); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(1)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| { + set(l, "transform/position", Value::Vec3([180.0, 20.0, -300.0])); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let noshadow = render_light_luma(&mut p, cid, false, 0.0); + let d0 = render_light_luma(&mut p, cid, true, 0.0); + let d40 = render_light_luma(&mut p, cid, true, 40.0); + // Per-column darkening vs the shadowless render: depth (max darkening) and the + // deepest dark row (contact edge). Diffusion may blur the edge but must keep the + // shadow dark (no bias leak) and keep it where it touches (no lift). + let dep = |img: &[f32], x: usize| -> f32 { (20..140).map(|y| noshadow[y * 200 + x] - img[y * 200 + x]).fold(0.0f32, f32::max) }; + let cols: Vec = (30..170).filter(|&x| dep(&d0, x) > 0.1).collect(); + assert!(cols.len() > 30, "shadow covers many columns: {}", cols.len()); + // Core depth: the 90th-percentile darkening over the d0 shadow's pixels. A blur + // only softens the rim and leaves the core dark; a bias leak lightens all of it. + let shadow_px: Vec = (20..140).flat_map(|y| (30..170).map(move |x| y * 200 + x)).filter(|&i| noshadow[i] - d0[i] > 0.1).collect(); + let core = |img: &[f32]| -> f32 { + let mut v: Vec = shadow_px.iter().map(|&i| noshadow[i] - img[i]).collect(); + v.sort_by(f32::total_cmp); + v[v.len() * 9 / 10] + }; + let (core0, core40) = (core(&d0), core(&d40)); + // Width of the shadow across its centre row: a diffusion-scaled bias shrinks the + // shadow (light leaks under its edges), while a correct filter keeps the extent + // and only softens the boundary (threshold crossings move at most with the + // penumbra, not with the bias). + let width = |img: &[f32]| -> f32 { + (30..130) + .map(|y| { + let dark: Vec = (0..200).map(|x| noshadow[y * 200 + x] - img[y * 200 + x] > 0.06).collect(); + match (dark.iter().position(|&d| d), dark.iter().rposition(|&d| d)) { + (Some(l), Some(r)) if r > l => (r - l) as f32, + _ => 0.0, + } + }) + .fold(0.0f32, f32::max) + }; + let (w0, w40) = (width(&d0), width(&d40)); + let wratio = w40 / w0; + assert!(w0 > 20.0, "shadow is wide enough to judge: {w0}"); + assert!(core40 >= 0.9 * core0, "diffusion 40 keeps the shadow core dark: {core40:.4} vs {core0:.4}"); + assert!(wratio >= 0.8, "diffusion 40 does not shrink the shadow: width {w40} vs {w0}"); +} + +#[test] +fn penumbra_dithers_along_a_straight_edge() { + // Receivers at the same distance from a straight edge, one texel apart along it. + // A fixed tap grid gives each the same coverage, so the penumbra repeats texel by + // texel (stair-steps and bands); the jitter, keyed below texel size, must vary it. + let (m, texels) = wall_map(64, 1.0, 6.0); + let distinct = |vs: &[f32]| -> usize { + let mut bits: Vec = vs.iter().map(|v| v.to_bits()).collect(); + bits.sort_unstable(); + bits.dedup(); + bits.len() + }; + let along: Vec = (0..32).map(|k| shade::shadow_factor(&m, &texels, [-16.0, -16.0 + k as f32, 13.5], FACING, 1.0)).collect(); + assert!(distinct(&along) >= 16, "coverage along the edge takes {} distinct values", distinct(&along)); + // And finer than a texel: where one texel spans several output pixels (a grazing + // floor), dither keyed per texel would show as texel-sized blocks of grain. + let within: Vec = (0..8).map(|k| shade::shadow_factor(&m, &texels, [-16.0, 0.0625 + k as f32 * 0.125, 13.5], FACING, 1.0)).collect(); + assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); +} + +#[test] +fn penumbra_width_is_independent_of_map_resolution() { + // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and + // a 256-texel map at 4. Shadow Diffusion sets the penumbra in output pixels (world + // units here), so a finer map must not narrow it. + let width = |size: u32, focal: f32| -> f32 { + let (m, texels) = wall_map(size, focal, 8.0); + let xs: Vec = (0..400).map(|k| -30.0 + k as f32 * 0.07).collect(); + let f: Vec = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, 0.0, 13.5], FACING, 1.0)).collect(); + let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]); + first_below(0.1) - first_below(0.9) + }; + let (w64, w256) = (width(64, 1.0), width(256, 4.0)); + assert!(w64 > 4.0 && w64 < 12.0, "penumbra spans about the requested 8 units: {w64}"); + assert!((w256 / w64 - 1.0).abs() < 0.25, "same penumbra at any map resolution: {w64} vs {w256}"); +} + +#[test] +fn tilted_receivers_do_not_shadow_themselves() { + // A receiver plane z = 100 + 4x (76° from the light, n·l ≈ 0.24), each texel holding + // its depth half a texel off-centre the way a rasteriser can. Wide taps must compare + // against the plane rather than the receiver point, and the slope-scaled bias must + // absorb the half-texel error, at every diffusion. + let (size, g) = (128u32, 4.0f32); + let c = size as f32 * 0.5; + let texels: Vec = (0..size * size).map(|i| 100.0 + g * ((i % size) as f32 - c)).collect(); + let len = (1.0 + g * g).sqrt(); + let (ng, n_l) = ([g / len, 0.0, -1.0 / len], 1.0 / len); + for radius in [1.0f32, 6.0, 25.0] { + let m = flat_map(size, 1.0, radius); + for k in 0..40 { + let x = -20.0 + k as f32 * 0.97; + let f = shade::shadow_factor(&m, &texels, [x, 3.3, 100.0 + g * x], ng, n_l); + assert!(f > 0.99, "radius {radius}, x {x}: lit tilted receiver shadowed itself ({f})"); + } + } +} + +#[test] +fn lit_casters_do_not_shadow_themselves() { + // A sphere and a turned cube that cast shadows, lit by each light kind: the lit + // surface must render as bright as with shadows off at any Shadow Diffusion (a + // wide kernel on a surface tilted to the light must not see its own depth). + // 640×360: faces then span enough texels that near-grazing ones exercise the + // receiver plane's slope limit. + for (prim, rot_y) in [(PrimitiveKind::Sphere, 0.0), (PrimitiveKind::Cube, 35.0)] { + for kind in [LightKind::Point, LightKind::Spot, LightKind::Parallel] { + let (mut p, cid) = project(640, 360); + add(&mut p, cid, LayerSource::Primitive { kind: prim }, |l| { + set(l, "transform/position", Value::Vec3([320.0, 180.0, 100.0])); + if prim == PrimitiveKind::Sphere { + set(l, "geometryOptions/radius", Value::Scalar(120.0)); + } else { + for k in ["width", "height", "depth"] { + set(l, &format!("geometryOptions/{k}"), Value::Scalar(216.0)); + } + set(l, "transform/rotationX", Value::Scalar(-25.0)); + } + set(l, "transform/rotationY", Value::Scalar(rot_y)); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + set(l, "materialOptions/castsShadows", Value::Enum(1)); + }); + add(&mut p, cid, LayerSource::Light { kind }, |l| { + set(l, "transform/position", Value::Vec3([-280.0, -108.0, -600.0])); + if let Some(poi) = l.props.prop_mut("transform/pointOfInterest") { + poi.value = Value::Vec3([320.0, 180.0, 100.0]); + } + }); + let noshadow = render_light_luma(&mut p, cid, false, 0.0); + let lit: Vec = (0..noshadow.len()).filter(|&i| noshadow[i] > 0.15).collect(); + assert!(lit.len() > 10_000, "{prim:?} {kind:?}: lit area {}", lit.len()); + for diffusion in [0.0, 20.0, 48.0] { + let img = render_light_luma(&mut p, cid, true, diffusion); + let dark = lit.iter().filter(|&&i| img[i] < 0.9 * noshadow[i]).count() as f32 / lit.len() as f32; + assert!(dark <= 0.02, "{prim:?} {kind:?} diffusion {diffusion}: {:.1} % of the lit surface self-shadowed", dark * 100.0); + } + } + } +} + +#[test] +fn shadow_sizes_match_the_halve_largest_rule_and_scale_to_many_lights() { + // The specification `shadow_sizes` implements, step by step: halve the largest map + // above its floor (earliest light on ties) until the scene fits, then drop maps + // from the last light. + let spec = |w: u32, h: u32, draft: bool, kinds: &[u32]| -> Vec> { + let base = shadow_map_size(w, h, draft); + let mut sizes: Vec = kinds + .iter() + .map(|&k| { + if k == 0 { + 0 + } else if k == 2 { + shadow_point_face_size(base, draft) + } else { + base + } + }) + .collect(); + let total = |s: &[u32]| -> u64 { s.iter().zip(kinds).map(|(&z, &k)| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).sum() }; + while total(&sizes) > MAX_SHADOW_TEXELS { + let pick = (0..sizes.len()).filter(|&i| sizes[i] > if kinds[i] == 2 { 128 } else { 512 }).max_by_key(|&i| (sizes[i], std::cmp::Reverse(i))); + match pick { + Some(i) => sizes[i] /= 2, + None => match sizes.iter().rposition(|&z| z > 0) { + Some(i) => sizes[i] = 0, + None => break, + }, + } + } + sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() + }; + let mut seed = 0x2545_F491u32; + for case in 0..300 { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let n = (seed >> 8) as usize % 40; + let kinds: Vec = (0..n) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 16) % 3 + }) + .collect(); + let (w, h, draft) = ([640, 1920, 3840, 8000][case % 4], [360, 1080, 2160, 100][case % 4], case % 7 == 0); + assert_eq!(shadow_sizes(w, h, draft, &kinds), spec(w, h, draft, &kinds), "case {case}: {kinds:?}"); + } + // A hostile light count stays fast (n log n) and inside the budget. + let many = vec![2u32; 100_000]; + let sizes = shadow_sizes(3840, 2160, false, &many); + assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS); +} + +#[test] +fn wide_penumbrae_are_smooth() { + // Coverage at a fixed distance across a wide penumbra, sampled along a straight + // edge, should be one smooth value plus fine dither. A 5×5 grid stretched over a + // 20-texel reach leaves gaps the jitter turns into coarse grain (σ ≈ 0.04, ten + // 8-bit levels); the grid grows with the reach to keep it below 0.02. + let (m, texels) = wall_map(256, 1.0, 20.0); + for off in [-5.0f32, 0.0, 5.0] { + let vs: Vec = (0..200).map(|k| shade::shadow_factor(&m, &texels, [-64.0 + off, -100.0 + k as f32, 13.5], FACING, 1.0)).collect(); + let mean = vs.iter().sum::() / vs.len() as f32; + let sd = (vs.iter().map(|v| (v - mean).powi(2)).sum::() / vs.len() as f32).sqrt(); + assert!(sd < 0.02, "offset {off}: penumbra grain σ = {sd:.4}"); + } +} + +#[test] +fn hard_edges_ramp_over_two_texels() { + // At Shadow Diffusion 0 the edge still ramps across about two texels (10–90 %): + // where one texel spans several output pixels (a floor grazing a point light) a + // one-texel ramp leaves the binary compare's texel steps visible as a sawtooth. + let (m, texels) = wall_map(64, 1.0, 1.0); + for v0 in [0.0f32, 0.3, 7.6] { + let xs: Vec = (0..400).map(|k| -20.0 + k as f32 * 0.02).collect(); + let f: Vec = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, v0, 13.5], FACING, 1.0)).collect(); + let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]); + let w = first_below(0.1) - first_below(0.9); + assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); + } +} diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index 10c635ebc..6d03d1bdc 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -27,3 +27,6 @@ boa_engine = { workspace = true, features = ["js"] } [dev-dependencies] effectcraft-expr = { workspace = true } effectcraft-psd = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +socket2 = "0.6" diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 90b03ae59..23356c064 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -85,6 +85,7 @@ fn project(s: &Session) -> J { "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), "workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 }, "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, + "displayStartFrame": p.settings.frame_start, "renderQueue": p.render_queue.len(), "revision": s.revision, }) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2eeca86bf..5216db341 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -98,6 +98,8 @@ var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "CO ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS; ImportAsType.COMPOSITION = ImportAsType.COMP; var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012); +// After Effects' values (FC_START_0 = 2612), as in docsforadobe/Types-for-Adobe. +var FramesCountType = __enum("FramesCountType", ["FC_START_0", "FC_START_1", "FC_TIMECODE_CONVERSION"], 2612); var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212); var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0); @@ -869,8 +871,11 @@ Property.prototype.property = function () { return null; }; } if (info.dims > 1) { if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers"); + // After Effects rejects an array of the wrong length instead of truncating or broadcasting it + // (#1936). Two values for a 3-D property stay legal, as they are in After Effects: z is 0. + if (v.length !== info.dims && !(info.dims === 3 && v.length === 2)) throw __err("Array is not of the correct dimension"); var o = []; - for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1]))); + for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : 0); return o; } if (v instanceof Array) throw __err("value must be a number, not an array"); @@ -1301,7 +1306,14 @@ AVLayer.prototype.constructor = AVLayer; } P.sourcePointToComp = function (p) { return pointIn(this, p, true); }; P.compPointToSource = function (p) { return pointIn(this, p, false); }; - P.replaceSource = function () { throw __err("replaceSource is not supported yet"); }; + // Replace the layer's source item, keeping every layer property (After Effects' + // AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are + // replaced, so the property tree and the layer's name stay and expressions keep resolving. + P.replaceSource = function (newSource, fixExpressions) { + if (!newSource || newSource.__id === undefined) throw __err("replaceSource needs a project item"); + __call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions }); + return null; + }; P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; }; })(); @@ -1503,8 +1515,11 @@ function AVItem() {} AVItem.prototype = Object.create(Item.prototype); (function () { function def(name, get, set) { Object.defineProperty(AVItem.prototype, name, { get: get, set: set }); } - def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")) }); }); - def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")) }); }); + // `anchor: 0` (top left): After Effects' width and height setters resize the frame and leave the + // layers at their coordinates. Re-anchoring the old frame in the new one belongs to the + // Composition Settings dialog, which is what `comp.settings` defaults to (#1942). + def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")), anchor: 0 }); }); + def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")), anchor: 0 }); }); def("pixelAspect", function () { return this.__info().pixelAspect; }, function (v) { this.__settings({ pixelAspect: __num(v) }); }); def("duration", function () { return this.__info().duration; }, function (v) { this.__settings({ duration: __num(v, "duration") }); }); def("frameRate", function () { return this.__info().frameRate; }, function (v) { this.__settings({ frameRate: __num(v, "frameRate") }); }); @@ -1884,6 +1899,14 @@ Project.prototype = { set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); }, get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, + get framesCountType() { return this.__info().displayStartFrame === 1 ? FramesCountType.FC_START_1 : FramesCountType.FC_START_0; }, + set framesCountType(v) { + if (v === FramesCountType.FC_TIMECODE_CONVERSION) throw __err("framesCountType: Timecode Conversion is not supported, use FC_START_0 or FC_START_1"); + if (v !== FramesCountType.FC_START_0 && v !== FramesCountType.FC_START_1) throw __err("Bad framesCountType value " + v); + __call("file.projectSettings", { frameStart: v === FramesCountType.FC_START_1 ? 1 : 0 }); + }, + get displayStartFrame() { return this.__info().displayStartFrame; }, + set displayStartFrame(v) { __call("file.projectSettings", { frameStart: __num(v) }); }, get expressionEngine() { return "javascript-1.0"; }, set expressionEngine(v) {}, item: function (i) { diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index af1e14b5e..52c65efb6 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -840,7 +840,7 @@ function __uiJson(c, inherited) { o.value = Number(c.value) || 0; o.min = Number(c.minvalue) || 0; o.max = Number(c.maxvalue); - if (!(o.max === o.max)) o.max = 100; + if (Number.isNaN(o.max)) o.max = 100; } if (c.items) { o.items = c.items.map(function (it) { return String(it.text); }); diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 4030332de..9d1f87989 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -51,6 +51,7 @@ mod imp { use std::collections::BTreeMap; use std::io::{ErrorKind, Read, Write}; use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -65,9 +66,11 @@ mod imp { /// socket blocked on a slow peer doesn't hold up the others (#1248). struct Entry { sock: Mutex, - /// A second handle on a stream, so `close` can shut it down while another call is - /// blocked reading or writing it. + /// A second handle on a stream, so `close` can wake a blocked read. wake: Option, + /// Closed or unusable after a mode error. Writes poll it because Windows sends may not + /// be interrupted by shutdown alone; queued operations recheck it after locking. + closed: AtomicBool, } struct Table { @@ -85,7 +88,7 @@ mod imp { Sock::Stream { s, .. } => s.try_clone().ok(), Sock::Listener(_) => None, }; - let entry = Arc::new(Entry { sock: Mutex::new(s), wake }); + let entry = Arc::new(Entry { sock: Mutex::new(s), wake, closed: AtomicBool::new(false) }); let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); let id = t.next; t.next += 1; @@ -101,11 +104,23 @@ mod imp { /// Forget socket `id`; a call blocked on it fails at once. fn close(id: u32) { let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id); - if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) { - let _ = w.shutdown(Shutdown::Both); + if let Some(entry) = entry { + entry.closed.store(true, Ordering::Release); + if let Some(w) = &entry.wake { + let _ = w.shutdown(Shutdown::Both); + } } } + /// A test-only handle for establishing backpressure before exercising the public operations. + #[cfg(test)] + pub(super) fn test_stream(id: u32) -> TcpStream { + let entry = get(id).unwrap(); + let sock = entry.sock.lock().unwrap(); + let Sock::Stream { s, .. } = &*sock else { panic!("expected a stream") }; + s.try_clone().unwrap() + } + fn timeout(p: &J) -> Duration { Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) } @@ -150,7 +165,56 @@ mod imp { Ok(()) } + /// A failed mode transition leaves the stream unusable; reject queued operations and wake I/O. + fn change_mode(s: &TcpStream, entry: &Entry, nonblocking: bool, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> { + mode(s, nonblocking).map_err(|e| { + entry.closed.store(true, Ordering::Release); + let _ = s.shutdown(Shutdown::Both); + let action = if nonblocking { "enable nonblocking mode" } else { "restore blocking mode" }; + std::io::Error::other(format!("socket is unusable: cannot {action}: {e}")) + }) + } + + /// Write with one deadline and an explicit close signal. On Windows, shutdown alone does + /// not reliably interrupt a blocking send, so never leave a send blocked inside the OS. + /// The caller holds this socket's lock; restore blocking mode or retire the connection. + fn write(s: &mut TcpStream, data: &[u8], limit: Duration, entry: &Entry, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> { + change_mode(s, entry, true, mode)?; + let end = Instant::now() + limit; + let result = (|| { + let mut pending = data; + while !pending.is_empty() { + if entry.closed.load(Ordering::Acquire) { + return Err(std::io::Error::new(ErrorKind::ConnectionAborted, "the socket was closed")); + } + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::Error::from(ErrorKind::TimedOut)); + } + match s.write(pending) { + Ok(0) => return Err(std::io::Error::from(ErrorKind::WriteZero)), + Ok(n) => pending = pending.get(n..).ok_or_else(|| std::io::Error::from(ErrorKind::InvalidData))?, + Err(e) if e.kind() == ErrorKind::WouldBlock => std::thread::sleep(left.min(Duration::from_millis(5))), + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok(()) + })(); + match (result, change_mode(s, entry, false, mode)) { + (Err(write), Err(restore)) => Err(std::io::Error::other(format!("{restore}; write also failed: {write}"))), + (result, Ok(())) => result, + (Ok(()), Err(restore)) => Err(restore), + } + } + + /// Dispatch an authorized operation without holding the socket table during I/O. pub(super) fn run(op: &str, id: u32, p: &J) -> J { + run_with_mode(op, id, p, TcpStream::set_nonblocking) + } + + /// Dispatch with a mode setter so exceptional OS failures can be injected per operation. + pub(super) fn run_with_mode(op: &str, id: u32, p: &J, mode: impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> J { let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); match op { "open" => { @@ -198,6 +262,15 @@ mod imp { }; }; let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner()); + // Recheck after locking: close or a failed mode transition may have retired it + // while this caller waited with an Arc obtained from the table. + if entry.closed.load(Ordering::Acquire) { + return match op { + "connected" => json!({"value": false}), + "eof" => json!({"value": true}), + _ => err("the socket is closed or unusable"), + }; + } match (op, &mut *sock) { ("poll", Sock::Listener(l)) => match l.accept() { Ok((s, peer)) => { @@ -212,14 +285,18 @@ mod imp { ("connected", Sock::Stream { s, buf, eof }) => { if !*eof && buf.is_empty() { // Peek without blocking to notice a closed peer. - let _ = s.set_nonblocking(true); + if let Err(e) = change_mode(s, &entry, true, &mode) { + return err(e); + } let mut b = [0u8; 1]; match s.peek(&mut b) { Ok(0) => *eof = true, Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true, _ => {} } - let _ = s.set_nonblocking(false); + if let Err(e) = change_mode(s, &entry, false, &mode) { + return err(e); + } } json!({"value": !(*eof && buf.is_empty())}) } @@ -265,11 +342,8 @@ mod imp { // A peer that stops reading fills the buffers: give up after `timeout` // instead of blocking the script for good (#1248). let limit = timeout(p).max(Duration::from_millis(1)); - if let Err(e) = s.set_write_timeout(Some(limit)) { - return err(format!("cannot set the write timeout: {e}")); - } let data = p.get("data").and_then(J::as_str).unwrap_or(""); - match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { + match write(s, &encode(data, utf8), limit, &entry, &mode) { Ok(()) => json!({"ok": true}), Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!( "write timed out after {} s: the other side isn't reading (some of the data may have been sent)", @@ -289,6 +363,7 @@ mod imp { #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] mod tests { + use std::io::{ErrorKind, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -301,6 +376,7 @@ mod tests { /// `host:port` and the accepted stream's keeper (drop it to let the connection go). fn stalled_peer() -> (String, mpsc::Sender<()>) { let l = TcpListener::bind("127.0.0.1:0").unwrap(); + socket2::SockRef::from(&l).set_recv_buffer_size(4096).unwrap(); let host = l.local_addr().unwrap().to_string(); let (stop, wait) = mpsc::channel::<()>(); std::thread::spawn(move || { @@ -310,14 +386,32 @@ mod tests { (host, stop) } + /// Open a test connection through the production socket dispatcher. fn open(host: &str) -> u32 { let r = run("open", 0, &json!({"host": host, "timeout": 5})); r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32 } - /// More than the loopback buffers hold, so a peer that doesn't read stops the write. - fn big() -> String { - "x".repeat(64 * 1024 * 1024) + /// Fill bounded socket buffers nonblocking, rather than assuming a 64 MiB write stalls. + /// In particular, Windows may accept that entire write with its default send buffering. + fn backpressure(id: u32) { + let mut stream = super::imp::test_stream(id); + socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); + stream.set_nonblocking(true).unwrap(); + let start = Instant::now(); + let chunk = [b'x'; 4096]; + let mut sent = 0; + loop { + assert!(sent < 16 * 1024 * 1024 && start.elapsed() < Duration::from_secs(5), "could not establish backpressure"); + match stream.write(&chunk) { + Ok(0) => panic!("peer closed while establishing backpressure"), + Ok(n) => sent += n, + Err(e) if e.kind() == ErrorKind::WouldBlock => break, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => panic!("establishing backpressure: {e}"), + } + } + stream.set_nonblocking(false).unwrap(); } /// Run `f` on a thread; its result, or `None` if it takes longer than `limit`. @@ -329,31 +423,36 @@ mod tests { rx.recv_timeout(limit).ok() } + /// A bounded write times out even when the peer never drains its receive buffer. #[test] fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() { // #1248: it used to block for good. let (host, _keep) = stalled_peer(); let id = open(&host); - let data = big(); + backpressure(id); + let data = "x".repeat(64 * 1024); let t0 = Instant::now(); let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up"); assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}"); - assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); + assert!(t0.elapsed() >= Duration::from_millis(500) && t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); run("close", id, &J::Null); } + /// Closing a stalled stream interrupts its writer without blocking another socket. #[test] fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() { let (host_a, _keep_a) = stalled_peer(); let (host_b, _keep_b) = stalled_peer(); let (a, b) = (open(&host_a), open(&host_b)); // A write on A that would wait a minute. - let data = big(); + backpressure(a); + let data = "x".repeat(64 * 1024); let (tx, blocked) = mpsc::channel(); std::thread::spawn(move || { let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60}))); }); std::thread::sleep(Duration::from_millis(500)); + assert!(matches!(blocked.try_recv(), Err(mpsc::TryRecvError::Empty)), "A must still be writing before close"); // Socket B answers meanwhile (the table isn't locked by A's write). let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked"); assert_eq!(r["value"], true, "{r}"); @@ -363,4 +462,86 @@ mod tests { assert!(r.get("error").is_some(), "{r}"); run("close", b, &J::Null); } + + /// Failed mode changes retire the stream; subsequent I/O fails instead of using it. + #[test] + fn mode_restoration_failure_retires_streams() { + for (op, fail_nonblocking) in [("write", false), ("connected", false), ("write", true), ("connected", true)] { + let (host, _keep) = stalled_peer(); + let id = open(&host); + let result = super::imp::run_with_mode(op, id, &json!({"data": "hello"}), |s, nonblocking| { + if nonblocking == fail_nonblocking { Err(std::io::Error::other("injected mode failure")) } else { s.set_nonblocking(nonblocking) } + }); + assert!(result["error"].as_str().is_some_and(|e| e.contains("injected mode failure")), "{op}: {result}"); + assert_eq!(run("connected", id, &J::Null)["value"], false, "{op}: broken socket remains connected"); + assert_eq!(run("eof", id, &J::Null)["value"], true); + for operation in ["read", "readln", "write"] { + let r = run(operation, id, &json!({"count": 1, "timeout": 0.01, "data": "x"})); + assert!(r.get("error").is_some(), "{operation} must reject the unusable stream: {r}"); + } + run("close", id, &J::Null); + run("close", id, &J::Null); + } + } + + /// A preceding write error cannot hide the failure that makes the stream unusable. + #[test] + fn restoration_failure_preserves_the_write_error_too() { + let (host, _keep) = stalled_peer(); + let id = open(&host); + let result = super::imp::run_with_mode("write", id, &json!({"data": "hello"}), |s, nonblocking| { + if nonblocking { + s.set_nonblocking(true)?; + s.shutdown(std::net::Shutdown::Write) + } else { + Err(std::io::Error::other("injected restore failure")) + } + }); + let error = result["error"].as_str().unwrap(); + assert!(error.contains("injected restore failure") && error.contains("write also failed"), "{error}"); + assert_eq!(run("connected", id, &J::Null)["value"], false); + run("close", id, &J::Null); + } + + /// Both outgoing and accepted sockets deliver complete data, then return to blocking reads. + #[test] + fn writes_deliver_all_data_and_restore_blocking_reads() { + for accepted in [false, true] { + let (id, mut peer) = if accepted { + let listener = run("listen", 0, &json!({"port": 0})); + let listener_id = listener["id"].as_u64().unwrap() as u32; + let peer = TcpStream::connect(("127.0.0.1", listener["port"].as_u64().unwrap() as u16)).unwrap(); + let start = Instant::now(); + let id = loop { + if let Some(id) = run("poll", listener_id, &J::Null)["id"].as_u64() { + break id as u32; + } + assert!(start.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(1)); + }; + run("close", listener_id, &J::Null); + (id, peer) + } else { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let id = open(&listener.local_addr().unwrap().to_string()); + (id, listener.accept().unwrap().0) + }; + socket2::SockRef::from(&super::imp::test_stream(id)).set_send_buffer_size(4096).unwrap(); + peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let reader = std::thread::spawn(move || { + let mut data = vec![0; 256 * 1024]; + peer.read_exact(&mut data).unwrap(); + assert!(data.iter().all(|b| *b == b'x')); + // A read on a socket accidentally left nonblocking would return before this. + std::thread::sleep(Duration::from_millis(100)); + peer.write_all(b"ack").unwrap(); + }); + let r = within(Duration::from_secs(10), move || run("write", id, &json!({"data": "x".repeat(256 * 1024), "timeout": 5}))).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + let r = run("read", id, &json!({"count": 3, "timeout": 2})); + assert_eq!(r["data"], "ack", "{r}"); + run("close", id, &J::Null); + reader.join().unwrap(); + } + } } diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 6f87f7572..82f8ad289 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -611,6 +611,26 @@ fn engine_entry_points() { let _ = std::fs::remove_dir_all(&dir); } +/// `app.project.framesCountType` / `displayStartFrame`: Project Settings ▸ Frame Count (#1807). +#[test] +fn frames_count_type_sets_the_project_frame_count() { + let mut s = session(); + let o = ok( + &mut s, + "var p = app.project; var a = [p.framesCountType === FramesCountType.FC_START_0, p.displayStartFrame]; p.framesCountType = FramesCountType.FC_START_1; a.concat([p.framesCountType === FramesCountType.FC_START_1, p.displayStartFrame]).join('|')", + ); + assert_eq!(o.result, json!("true|0|true|1")); + // After Effects' enum values, so scripts that store the number keep working. + let o = ok(&mut s, "[FramesCountType.FC_START_0, FramesCountType.FC_START_1, FramesCountType.FC_TIMECODE_CONVERSION].join('|')"); + assert_eq!(o.result, json!("2612|2613|2614")); + assert_eq!(s.project.settings.frame_start, 1); + ok(&mut s, "app.project.displayStartFrame = 0"); + assert_eq!(s.project.settings.frame_start, 0); + let o = run_code(&mut s, "app.project.framesCountType = FramesCountType.FC_TIMECODE_CONVERSION", "test.jsx"); + assert!(o.error.is_some_and(|e| e.message.contains("Timecode Conversion"))); + assert_eq!(s.project.settings.frame_start, 0); +} + #[test] fn console_keeps_variables() { let mut s = session(); @@ -963,6 +983,37 @@ fn footage_proxies_sequences_and_conform_rate() { let _ = std::fs::remove_dir_all(&dir); } +/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and +/// properties; a folder is not a source. +#[test] +fn av_layer_replace_source_keeps_the_layer() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25); + var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1); + layer.transform.position.setValue([10, 20]); + layer.startTime = 1; + var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25); + layer.replaceSource(nested, false); + [layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem, + layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers] + "#, + ); + assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1])); + let bad = run_code( + &mut s, + r#" + var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25); + var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1); + layer.replaceSource(app.project.items.addFolder("F"), false); + "#, + "replace-source.jsx", + ); + assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}"); +} + /// A flat green footage source, so `sampleImage` on a footage layer has known pixels. struct Flat; impl effectcraft_engine::render::FootageSource for Flat { @@ -1585,3 +1636,68 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() { let (_, c) = comp_named(&s, "x"); assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0)); } + +/// #1936: an array whose length doesn't match the property's dimension is an error in After +/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. +#[test] +fn set_value_rejects_an_array_of_the_wrong_dimension() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30); + var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1); + var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001"); + function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } } + var before = "" + ramp.value; + var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); }); + var tooFew = attempt(function () { ramp.setValue([1]); }); + var untouched = "" + ramp.value; + ramp.setValue([7, 8]); + var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); }); + // A 3-D property still takes two values, as it does in After Effects: z stays 0. + l.property("Position").setValue([120, 34]); + [tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value] + "#, + ); + assert_eq!( + o.result, + json!([ + "Array is not of the correct dimension", + "Array is not of the correct dimension", + true, + "7,8", + "Array is not of the correct dimension", + "120,34,0" + ]) + ); + let (_, c) = comp_named(&s, "Dims"); + assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0])); +} + +/// #1942: the scripting width and height setters resize the frame only. Re-anchoring the old frame +/// in the new one, which moves every unparented layer, belongs to the Composition Settings dialog. +#[test] +fn setting_comp_width_and_height_leaves_the_layers_where_they_are() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("w", 400, 200, 1, 1, 25); + var l = c.layers.addSolid([1, 1, 1], "s", 50, 50, 1, 1); + var pos = l.property("ADBE Transform Group").property("ADBE Position"); + pos.setValue([100, 100]); + var k = c.layers.addSolid([1, 1, 1], "k", 50, 50, 1, 1); + var kpos = k.property("ADBE Transform Group").property("ADBE Position"); + kpos.setValueAtTime(0, [10, 20]); + kpos.setValueAtTime(1, [30, 40]); + var before = "" + pos.value; + c.width = 800; + c.height = 400; + [before, "" + pos.value, "" + kpos.keyValue(1), "" + kpos.keyValue(2), c.width, c.height] + "#, + ); + assert_eq!(o.result, json!(["100,100,0", "100,100,0", "10,20,0", "30,40,0", 800, 400])); + let (_, c) = comp_named(&s, "w"); + assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 100.0, 0.0])); +} diff --git a/crates/segment/src/pt.rs b/crates/segment/src/pt.rs index d85ff80ca..35ed57297 100644 --- a/crates/segment/src/pt.rs +++ b/crates/segment/src/pt.rs @@ -6,6 +6,15 @@ use std::collections::HashMap; +const MAX_ARCHIVE_BYTES: usize = 1 << 30; +const MAX_PICKLE_OPS: usize = 4_000_000; +const MAX_TENSORS: usize = 1_000_000; +const MAX_TENSOR_ELEMENTS: usize = 1 << 28; +const MAX_ENTRY_BYTES: usize = 1 << 30; +const MAX_PICKLE_NODES: usize = 4_000_000; +const MAX_TOTAL_ELEMENTS: usize = 1 << 29; +const MAX_TENSOR_DIMS: usize = 64; + /// A tensor read from a checkpoint: its shape and its values as f32 (row-major, contiguous). #[derive(Clone, Debug, PartialEq)] pub struct Tensor { @@ -38,6 +47,9 @@ struct Entry { /// The entries of a zip archive by name. fn zip_entries(b: &[u8]) -> Result> { + if b.len() > MAX_ARCHIVE_BYTES { + return Err("checkpoint exceeds the 1024 MiB archive limit".into()); + } // End of central directory: the last `PK\x05\x06` (a comment may follow it). let eocd = (0..b.len().saturating_sub(21)).rev().find(|&i| b.get(i..i + 4) == Some(b"PK\x05\x06")).ok_or("not a zip archive (no end of central directory)")?; @@ -96,6 +108,9 @@ fn read_entry<'a>(b: &'a [u8], e: &Entry) -> Result> match e.method { 0 => Ok(std::borrow::Cow::Borrowed(raw)), 8 => { + if e.size > MAX_ENTRY_BYTES { + return Err("zip entry exceeds the size limit".into()); + } miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map(std::borrow::Cow::Owned).map_err(|e| format!("bad deflate data: {e:?}")) } m => Err(format!("unsupported zip compression {m}")), @@ -142,7 +157,58 @@ fn int_list(o: &Obj) -> Option> { } } +/// Nodes in `o`, stopping once the count passes `budget`. +fn nodes(o: &Obj, budget: usize) -> usize { + let kids: Vec<&Obj> = match o { + Obj::Tuple(v) | Obj::List(v) => v.iter().collect(), + Obj::Dict(v) => v.iter().flat_map(|(k, x)| [k, x]).collect(), + _ => return 1, + }; + let mut n = 1; + for k in kids { + if n > budget { + break; + } + n += nodes(k, budget - n); + } + n +} + +fn tensor_element_count(name: &str, offset: usize, size: &[usize], stride: &[usize], values_len: usize) -> Result { + if size.len() != stride.len() || size.len() > MAX_TENSOR_DIMS { + return Err(format!("{name}: invalid tensor dimensionality")); + } + let n = size + .iter() + .try_fold(1usize, |n, &dim| n.checked_mul(dim)) + .filter(|n| *n <= MAX_TENSOR_ELEMENTS) + .ok_or_else(|| format!("{name}: tensor exceeds the element limit"))?; + if n == 0 { + return Ok(0); + } + let mut max_at = offset; + for (&dim, &step) in size.iter().zip(stride) { + max_at = max_at + .checked_add((dim - 1).checked_mul(step).ok_or_else(|| format!("{name}: tensor stride overflow"))?) + .ok_or_else(|| format!("{name}: tensor offset overflow"))?; + } + if max_at >= values_len { + return Err(format!("{name}: data out of range")); + } + Ok(n) +} + /// Run the pickle program and return its result. +/// A memoized object, charged against the clone budget (memo reuse can otherwise grow trees exponentially). +fn memo_get(memo: &HashMap, k: u64, cloned: &mut usize) -> Result { + let o = memo.get(&k).ok_or("pickle: bad memo")?; + *cloned = cloned.saturating_add(nodes(o, MAX_PICKLE_NODES)); + if *cloned > MAX_PICKLE_NODES { + return Err("pickle exceeds the object limit".into()); + } + Ok(o.clone()) +} + fn unpickle(p: &[u8]) -> Result { let mut stack: Vec = Vec::new(); let mut memo: HashMap = HashMap::new(); @@ -166,7 +232,10 @@ fn unpickle(p: &[u8]) -> Result { *i += n + 1; Ok(s) }; + let mut op_count = 0usize; + let mut cloned = 0usize; loop { + op_count = op_count.checked_add(1).filter(|n| *n <= MAX_PICKLE_OPS).ok_or("pickle exceeds the opcode limit")?; let op = *p.get(i).ok_or("truncated pickle")?; i += 1; match op { @@ -253,11 +322,11 @@ fn unpickle(p: &[u8]) -> Result { } b'h' => { let k = take(&mut i, 1)?[0] as u64; - stack.push(memo.get(&k).cloned().ok_or("pickle: bad memo")?); + stack.push(memo_get(&memo, k, &mut cloned)?); } b'j' => { let s = take(&mut i, 4)?; - stack.push(memo.get(&(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64)).cloned().ok_or("pickle: bad memo")?); + stack.push(memo_get(&memo, u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, &mut cloned)?); } b't' => { let items = pop_mark(&mut stack)?; @@ -290,9 +359,10 @@ fn unpickle(p: &[u8]) -> Result { stack.push(match f { Obj::Global(m, n) if m == "torch._utils" && n == "_rebuild_tensor_v2" => { match (args.first(), args.get(1), args.get(2).and_then(int_list), args.get(3).and_then(int_list)) { - (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => { - Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset: usize::try_from(*off).unwrap_or(0), size, stride } - } + (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => match usize::try_from(*off) { + Ok(offset) => Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset, size, stride }, + Err(_) => Obj::Other, + }, _ => Obj::Other, } } @@ -337,16 +407,24 @@ fn unpickle(p: &[u8]) -> Result { } } -/// Element bytes and conversion of a storage dtype. -fn to_f32(dtype: &str, bytes: &[u8]) -> Option> { +/// Bytes per element of a float storage dtype. +fn elem_size(dtype: &str) -> Option { match dtype { - "FloatStorage" => Some(bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()), - "HalfStorage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()), - "BFloat16Storage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()), + "FloatStorage" => Some(4), + "HalfStorage" | "BFloat16Storage" => Some(2), _ => None, } } +/// Element `i` of a storage (`i` must be below `bytes.len() / elem_size`). +fn elem_f32(dtype: &str, bytes: &[u8], i: usize) -> f32 { + match dtype { + "FloatStorage" => f32::from_le_bytes([bytes[4 * i], bytes[4 * i + 1], bytes[4 * i + 2], bytes[4 * i + 3]]), + "HalfStorage" => half_to_f32(u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]])), + _ => f32::from_bits((u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]) as u32) << 16), + } +} + pub(crate) fn half_to_f32(h: u16) -> f32 { let (s, e, m) = ((h >> 15) as u32, ((h >> 10) & 0x1f) as u32, (h & 0x3ff) as u32); let bits = match (e, m) { @@ -382,22 +460,42 @@ pub fn read(bytes: &[u8]) -> Result { Some((_, Obj::Dict(inner))) => inner.clone(), _ => dict, }; + if dict.len() > MAX_TENSORS { + return Err("checkpoint exceeds the tensor count limit".into()); + } let mut storages: HashMap> = HashMap::new(); let mut out = Weights::new(); + let mut total = 0usize; + let mut inflated = 0usize; for (k, v) in dict { let (Obj::Str(name), Obj::Tensor { dtype, key, offset, size, stride }) = (k, v) else { continue }; if !storages.contains_key(&key) { let e = entries.get(&format!("{prefix}data/{key}")).ok_or_else(|| format!("missing storage {key}"))?; + if e.method == 8 { + inflated = inflated.saturating_add(e.size); + if inflated > MAX_ENTRY_BYTES { + return Err("checkpoint exceeds the decompressed storage limit".into()); + } + } storages.insert(key.clone(), read_entry(bytes, e)?); } - let Some(values) = storages.get(&key).and_then(|b| to_f32(&dtype, b)) else { continue }; - let n: usize = size.iter().product(); - // Gather (any strides) into a contiguous row-major tensor. - let mut data = Vec::with_capacity(n); + let (Some(esz), Some(values)) = (elem_size(&dtype), storages.get(&key)) else { continue }; + // Check hostile stride math before allocating or indexing the backing storage. + let n = tensor_element_count(&name, offset, &size, &stride, values.len() / esz)?; + total = total.saturating_add(n); + if total > MAX_TOTAL_ELEMENTS { + return Err("checkpoint exceeds the total element limit".into()); + } + let mut data = Vec::new(); + data.try_reserve_exact(n).map_err(|_| format!("{name}: tensor allocation failed"))?; let mut idx = vec![0usize; size.len()]; for _ in 0..n { - let at = offset + idx.iter().zip(&stride).map(|(i, s)| i * s).sum::(); - data.push(*values.get(at).ok_or_else(|| format!("{name}: data out of range"))?); + let at = idx + .iter() + .zip(&stride) + .try_fold(offset, |at, (i, s)| i.checked_mul(*s).and_then(|v| at.checked_add(v))) + .ok_or_else(|| format!("{name}: tensor offset overflow"))?; + data.push(elem_f32(&dtype, values, at)); for d in (0..idx.len()).rev() { idx[d] += 1; if idx[d] < size[d] { @@ -490,6 +588,15 @@ mod tests { assert!(!w.contains_key("n"), "integer tensors are skipped"); } + #[test] + fn rejects_tensor_shape_overflow_and_invalid_strides() { + assert!(tensor_element_count("bad", 0, &[usize::MAX, 2], &[1, 1], 16).is_err()); + assert!(tensor_element_count("bad", 0, &[2, 2], &[usize::MAX, 1], 16).is_err()); + assert!(tensor_element_count("bad", 0, &[2, 2], &[1], 16).is_err()); + assert!(tensor_element_count("bad", usize::MAX, &[2], &[1], 16).is_err()); + assert_eq!(tensor_element_count("empty", 0, &[0, usize::MAX], &[0, 0], 0), Ok(0)); + } + #[test] fn damaged_files_are_errors() { assert!(read(b"not a zip").is_err()); diff --git a/crates/svg/src/color.rs b/crates/svg/src/color.rs index ba4183e75..9ef5faa0a 100644 --- a/crates/svg/src/color.rs +++ b/crates/svg/src/color.rs @@ -179,7 +179,11 @@ pub fn parse_color(s: &str) -> Option<[f64; 4]> { return None; } let comp = |t: &str| -> Option { - Some(if let Some(p) = t.strip_suffix('%') { p.trim().parse::().ok()? / 100.0 } else { t.parse::().ok()? / 255.0 }) + Some(if let Some(p) = t.strip_suffix('%') { + p.trim().parse::().ok().filter(|v| v.is_finite())? / 100.0 + } else { + t.parse::().ok().filter(|v| v.is_finite())? / 255.0 + }) }; let alpha = a.get(3).map_or(Some(1.0), |t| alpha(t))?; return Some([comp(a[0])?.clamp(0.0, 1.0), comp(a[1])?.clamp(0.0, 1.0), comp(a[2])?.clamp(0.0, 1.0), alpha]); @@ -189,9 +193,9 @@ pub fn parse_color(s: &str) -> Option<[f64; 4]> { if a.len() < 3 { return None; } - let h = a[0].trim_end_matches("deg").parse::().ok()?.rem_euclid(360.0) / 360.0; - let s = a[1].trim_end_matches('%').parse::().ok()?.clamp(0.0, 100.0) / 100.0; - let l = a[2].trim_end_matches('%').parse::().ok()?.clamp(0.0, 100.0) / 100.0; + let h = a[0].trim_end_matches("deg").parse::().ok().filter(|v| v.is_finite())?.rem_euclid(360.0) / 360.0; + let s = a[1].trim_end_matches('%').parse::().ok().filter(|v| v.is_finite())?.clamp(0.0, 100.0) / 100.0; + let l = a[2].trim_end_matches('%').parse::().ok().filter(|v| v.is_finite())?.clamp(0.0, 100.0) / 100.0; let al = a.get(3).map_or(Some(1.0), |t| alpha(t))?; let [r, g, b] = hsl(h, s, l); return Some([r, g, b, al]); @@ -208,7 +212,14 @@ fn split_args(s: &str) -> Vec<&str> { } fn alpha(t: &str) -> Option { - Some(if let Some(p) = t.strip_suffix('%') { p.parse::().ok()? / 100.0 } else { t.parse::().ok()? }.clamp(0.0, 1.0)) + Some( + if let Some(p) = t.strip_suffix('%') { + p.parse::().ok().filter(|v| v.is_finite())? / 100.0 + } else { + t.parse::().ok().filter(|v| v.is_finite())? + } + .clamp(0.0, 1.0), + ) } /// CSS HSL → RGB (CSS Color 3 §4.2.4). @@ -239,6 +250,22 @@ fn hsl(h: f64, s: f64, l: f64) -> [f64; 3] { mod tests { use super::parse_color; + #[test] + fn non_finite_rejected() { + for s in [ + "rgb(nan,0,0)", + "rgb(inf,0,0)", + "rgba(0,0,0,nan)", + "rgba(0,0,0,inf)", + "hsl(nan,100%,50%)", + "hsl(120,nan%,50%)", + "hsl(120,100%,inf%)", + "hsl(120,100%,50%,nan)", + ] { + assert_eq!(parse_color(s), None, "{s}"); + } + } + #[test] fn colors() { assert_eq!(parse_color("#f00"), Some([1.0, 0.0, 0.0, 1.0])); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 24cc584e0..60f39adc6 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -392,7 +392,17 @@ enum PaintRef { Current, } -/// A CSS rule: selector (type, class, id; `*`) with its specificity and declarations. +/// The most selectors (each entry of a comma list counts) the `{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("