diff --git a/.config/nextest.toml b/.config/nextest.toml index 2a4cd2cb9..38f939c31 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +1,14 @@ # Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI # installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). nextest-version = "0.9.150" + +[test-groups] +gpu = { max-threads = 1 } + +# Coverage runs each test in a separate process, so the GPU suite's in-process lock +# cannot protect software adapters from competing devices and readback timeouts. +# UI harnesses can create a device lazily when rendering a screenshot, so include +# the whole UI package instead of an incomplete list of GPU-named tests. +[[profile.default.overrides]] +filter = 'package(effectcraft-gpu) | package(effectcraft-ui-egui) | package(effectcraft-sam2) | (package(effectcraft-cli) & test(/^cli::gpu_adapter_name_/))' +test-group = 'gpu' diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index b903f82ea..ccf74f43a 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e08c7c42..ffbe49afb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,10 @@ name: Release on: + # A nightly dry run on main: every release build except macOS, nothing signed or released + # (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day. + schedule: + - cron: "32 1 * * *" push: branches: [release] workflow_dispatch: @@ -15,16 +19,25 @@ concurrency: cancel-in-progress: false jobs: + # Regenerates contributors/contributors.json for the release commit; every build bundles it. + credits: + permissions: + contents: read + pull-requests: read + uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main release: + needs: credits permissions: contents: write packages: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: + dry-run: ${{ github.event_name == 'schedule' }} app-name: effectcraft app-title: EffectCraft version: ${{ inputs.version }} + contributors-artifact: ${{ needs.credits.outputs.artifact-name }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 93b34b9bf..3c1706cc3 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -64,7 +64,7 @@ and licence texts are in [`NOTICE`](NOTICE). | `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 | | `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 | -One small After Effects test project of `crates/aep` is in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them. +Three small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar), `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/text-synthetic.aep` (original work, made with After Effects 2026 by script from own data, working space None, no colour profile embedded, nothing changed after the save; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them. ## Screenshots @@ -99,7 +99,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall (sidecars `.attribution`). - Bundled extension (ScriptUI panel) with its twelve built-in ease presets: `extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`). -- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly). +- Example effect plug-in: `examples/plugins/posterize-bands` (WebAssembly ABI 1). ## Trained models (optional downloads, not bundled) @@ -129,6 +129,14 @@ configuration from these Apache-2.0 projects: - `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything architectures and their Apache-2.0 reference code. +## Frozen matte regression fixture + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `crates/engine/src/tests/fixtures/analysed-0.24.ecproj` | arnav-hiwarkar | Original work, synthetic project keyed by EffectCraft 0.24.1 | MIT OR Apache-2.0 | +| `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | +| `crates/engine/src/tests/fixtures/tracked-0.22.ecproj` | arnav-hiwarkar | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | + ## First-party brand marks The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29521ad73..8ce4b85aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ that variable set. ```sh cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace -cargo xtask ci # what every commit must pass +cargo xtask ci # what every commit must pass (dev-profile checks and tests) ``` The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces diff --git a/Cargo.lock b/Cargo.lock index 5b426c8f8..e6450c078 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,30 +1089,70 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-aep" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" + +[[package]] +name = "craft-audio" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)", + "thiserror 2.0.21", +] + [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" + +[[package]] +name = "craft-dpx" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "thiserror 2.0.21", +] [[package]] name = "craft-ecproj-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ca1b5e2edb8568335c6c14f390307bd5beae8238#ca1b5e2edb8568335c6c14f390307bd5beae8238" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "serde", "serde_json", "thiserror 2.0.21", ] +[[package]] +name = "craft-eps" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" + +[[package]] +name = "craft-exr" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "serde_json", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" + +[[package]] +name = "craft-gif" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1132,6 +1172,23 @@ dependencies = [ "writeable", ] +[[package]] +name = "craft-lottie" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-media" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-media" version = "0.1.0" @@ -1140,19 +1197,39 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-mesh" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "miniz_oxide 0.9.1", + "serde_json", +] + [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "hayro-syntax", "thiserror 2.0.21", ] +[[package]] +name = "craft-psd" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "encoding_rs", + "flate2", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "dispatch2", "egui", @@ -1160,10 +1237,22 @@ dependencies = [ "objc2-app-kit 0.3.2", ] +[[package]] +name = "craft-svg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "craft-xml", + "flate2", + "kurbo", + "roxmltree", + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1184,7 +1273,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "chrono", "dunce", @@ -1194,6 +1283,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.5", "semver", "serde", "serde_json", @@ -1207,6 +1297,33 @@ dependencies = [ "zip 9.1.3", ] +[[package]] +name = "craft-video" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)", + "rayon", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-webp" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1420,8 +1537,9 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "chrono", "cpal", "craft-update", "effectcraft-engine", @@ -1439,6 +1557,8 @@ dependencies = [ "rfd", "serde", "serde_json", + "sysinfo", + "tempfile", "winit", "winresource", "zbus", @@ -1446,13 +1566,15 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-aep", "effectcraft-color", "effectcraft-effects", "effectcraft-keyframe", "effectcraft-project", "effectcraft-time", + "log", "serde", "serde_json", "thiserror 2.0.21", @@ -1460,7 +1582,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1468,19 +1590,13 @@ dependencies = [ "effectcraft-script", "image", "serde_json", + "tempfile", "thiserror 2.0.21", ] -[[package]] -name = "effectcraft-av1enc" -version = "0.21.0" -dependencies = [ - "filmcraft-av1", -] - [[package]] name = "effectcraft-cli" -version = "0.21.0" +version = "0.24.1" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1499,7 +1615,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.21.0" +version = "0.24.1" dependencies = [ "proptest", "serde", @@ -1507,7 +1623,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.21.0" +version = "0.24.1" dependencies = [ "chrono", "effectcraft-color", @@ -1525,7 +1641,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.21.0" +version = "0.24.1" dependencies = [ "chrono", "craft-docpath", @@ -1564,12 +1680,13 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.21.0" +version = "0.24.1" dependencies = [ - "effectcraft-av1enc", + "craft-audio", + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)", + "craft-video", "effectcraft-color", "effectcraft-effects", - "effectcraft-hevcenc", "effectcraft-keyframe", "effectcraft-media", "effectcraft-opusenc", @@ -1577,25 +1694,22 @@ dependencies = [ "effectcraft-raster", "effectcraft-render", "effectcraft-time", - "effectcraft-vp9enc", "exr", - "filmcraft-aac", - "filmcraft-h264enc", - "filmcraft-isobmff", - "filmcraft-opus", - "filmcraft-prores", + "filmcraft-av1", + "filmcraft-hevc", "filmcraft-vp9", "gif", "image", "log", "rayon", + "tempfile", "thiserror 2.0.21", "web-time", ] [[package]] name = "effectcraft-expr" -version = "0.21.0" +version = "0.24.1" dependencies = [ "boa_engine", "effectcraft-color", @@ -1612,7 +1726,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.21.0" +version = "0.24.1" dependencies = [ "proptest", "serde", @@ -1620,7 +1734,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "effectcraft-color", @@ -1643,7 +1757,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1652,16 +1766,9 @@ dependencies = [ "serde_json", ] -[[package]] -name = "effectcraft-hevcenc" -version = "0.21.0" -dependencies = [ - "filmcraft-hevc", -] - [[package]] name = "effectcraft-host" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1688,7 +1795,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1707,7 +1814,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1719,9 +1826,10 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.21.0" +version = "0.24.1" dependencies = [ "craft-docpath", + "craft-lottie", "effectcraft-color", "effectcraft-effects", "effectcraft-keyframe", @@ -1737,10 +1845,17 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-audio", "craft-docpath", + "craft-dpx", + "craft-exr", + "craft-gif", + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)", + "craft-webp", "effectcraft-color", + "effectcraft-effects", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -1750,12 +1865,9 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", - "filmcraft-aac", "filmcraft-codecs", "filmcraft-color", "filmcraft-frame", - "filmcraft-isobmff", - "filmcraft-matroska", "filmcraft-media", "filmcraft-mpegts", "filmcraft-time", @@ -1764,32 +1876,32 @@ dependencies = [ "log", "rayon", "serde_json", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "effectcraft-model" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-mesh", "effectcraft-geom", "image", "log", - "miniz_oxide 0.9.1", "serde", - "serde_json", "thiserror 2.0.21", ] [[package]] name = "effectcraft-opusenc" -version = "0.21.0" +version = "0.24.1" dependencies = [ - "filmcraft-opus", + "craft-audio", ] [[package]] name = "effectcraft-path" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1801,8 +1913,9 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-eps", "craft-pdf", "effectcraft-raster", "effectcraft-svg", @@ -1817,7 +1930,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1829,7 +1942,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.21.0" +version = "0.24.1" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1843,15 +1956,17 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-psd", "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-exr", "effectcraft-color", "effectcraft-geom", "rayon", @@ -1860,7 +1975,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1877,12 +1992,13 @@ dependencies = [ "lz4_flex", "rayon", "serde", + "tempfile", "web-time", ] [[package]] name = "effectcraft-sam2" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "candle-core", @@ -1897,7 +2013,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.21.0" +version = "0.24.1" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1915,7 +2031,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.21.0" +version = "0.24.1" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1924,19 +2040,21 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-svg", "effectcraft-geom", "effectcraft-path", "effectcraft-raster", "kurbo", + "log", "rayon", "roxmltree", ] [[package]] name = "effectcraft-text" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1948,7 +2066,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.21.0" +version = "0.24.1" dependencies = [ "proptest", "serde", @@ -1956,7 +2074,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1968,7 +2086,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "chrono", @@ -2007,13 +2125,9 @@ dependencies = [ "web-time", ] -[[package]] -name = "effectcraft-vp9enc" -version = "0.21.0" - [[package]] name = "effectcraft-web" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -2478,7 +2592,7 @@ name = "filmcraft-bitstream" version = "0.12.0" source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" dependencies = [ - "craft-media", + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4)", ] [[package]] @@ -2579,16 +2693,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "filmcraft-h264enc" -version = "0.12.0" -source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" -dependencies = [ - "filmcraft-bitstream", - "rayon", - "thiserror 2.0.21", -] - [[package]] name = "filmcraft-hevc" version = "0.12.0" @@ -2621,7 +2725,7 @@ name = "filmcraft-isobmff" version = "0.12.0" source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" dependencies = [ - "craft-media", + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4)", ] [[package]] @@ -2629,7 +2733,7 @@ name = "filmcraft-matroska" version = "0.12.0" source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" dependencies = [ - "craft-media", + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4)", ] [[package]] @@ -3065,7 +3169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.5", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3318,7 +3422,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "hayro-ccitt", ] @@ -3332,7 +3436,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -3665,7 +3769,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.21", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3811,7 +3915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4117,6 +4221,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4431,6 +4544,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4632,7 +4755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -4661,7 +4784,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6068,6 +6191,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "tag_ptr" version = "0.1.0" @@ -7221,16 +7358,38 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", + "windows-collections 0.3.2", "windows-core 0.62.2", - "windows-future", - "windows-numerics", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -7252,6 +7411,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -7260,9 +7432,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", - "windows-strings", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -7272,8 +7455,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", - "windows-link", - "windows-threading", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7298,12 +7481,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -7311,7 +7510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core 0.62.2", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7320,9 +7519,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", - "windows-strings", + "windows-strings 0.5.1", ] [[package]] @@ -7334,13 +7533,31 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -7349,7 +7566,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7385,7 +7602,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7419,13 +7636,22 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7708,7 +7934,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.21.0" +version = "0.24.1" dependencies = [ "chrono", "pkg-config", @@ -7956,7 +8182,6 @@ dependencies = [ "nix", "typed-path 0.13.0", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -7971,18 +8196,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.5.3" @@ -8047,18 +8260,3 @@ dependencies = [ "syn 3.0.6", "winnow", ] - -[[patch.unused]] -name = "egui_demo_lib" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - -[[patch.unused]] -name = "egui_inspection" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - -[[patch.unused]] -name = "egui_system_font_provider" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index abb2256c2..1b80048f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,19 +4,29 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.21.0" +version = "0.24.1" 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-dpx = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-eps = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-exr = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-gif = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-psd = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-svg = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-webp = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-lottie = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } +craft-mesh = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", default-features = false, features = ["stl", "obj", "gltf", "fbx"] } +craft-aep = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "47107f660191de2f0b425569506f4daa4faadac9", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } @@ -41,7 +51,6 @@ effectcraft-interchange = { path = "crates/interchange" } effectcraft-pdf = { path = "crates/pdf" } effectcraft-psd = { path = "crates/psd" } effectcraft-aep = { path = "crates/aep" } -effectcraft-vp9enc = { path = "crates/vp9enc" } effectcraft-svg = { path = "crates/svg" } effectcraft-engine = { path = "crates/engine" } effectcraft-host = { path = "crates/host" } @@ -49,8 +58,6 @@ effectcraft-ui-egui = { path = "crates/ui-egui" } effectcraft-automation = { path = "crates/automation" } effectcraft-opusenc = { path = "crates/opusenc" } effectcraft-script = { path = "crates/script" } -effectcraft-hevcenc = { path = "crates/hevcenc" } -effectcraft-av1enc = { path = "crates/av1enc" } effectcraft-plugin = { path = "crates/plugin" } effectcraft-helper-host = { path = "crates/helper-host" } @@ -82,25 +89,23 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] same-file = "1.0" tempfile = "3.27" -# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). +# codecs + containers shared by the Craft apps (crates/media, crates/export); keep all three on one rev. +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", default-features = false, features = ["isobmff", "matroska"] } +craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", default-features = false, features = ["aac-decode", "aac-encode", "opus-decode"] } +craft-video = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", features = ["h264-encode", "prores", "hevc-encode", "vp9-encode", "av1", "threads"] } + +# footage decoding: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } -# encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } -# container headers read for AAC channel layouts (crates/media) -filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } +# MPEG TS / PS headers read for AAC channel layouts (crates/media); craft-media has no MPEG TS reader filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } -# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } +# decoders used as the round-trip oracles of craft-video's VP9, HEVC and AV1 encoders (crates/export +# dev-dependencies only); craft-video has no decoder for them filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } -# decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) @@ -117,7 +122,7 @@ half = "2.7" # ui # Shared frontend components; keep this dependency above the engine boundary. # Shared UI and the complete egui family select reviewed immutable commits below. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", features = ["serde"] } # `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()` # and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate. egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } @@ -137,11 +142,6 @@ sys-locale = "0.3" # filmcraft-frame = { path = "../filmcraft/crates/frame" } # filmcraft-color = { path = "../filmcraft/crates/color" } # filmcraft-time = { path = "../filmcraft/crates/time" } -# filmcraft-h264enc = { path = "../filmcraft/crates/h264enc" } -# filmcraft-prores = { path = "../filmcraft/crates/prores" } -# filmcraft-aac = { path = "../filmcraft/crates/aac" } -# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" } -# filmcraft-matroska = { path = "../filmcraft/crates/matroska" } # filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" } [workspace.lints.rust] @@ -200,50 +200,39 @@ opt-level = 3 [profile.dev.package.effectcraft-export] opt-level = 3 -[profile.dev.package.effectcraft-vp9enc] +# craft-libs codec hot loops. +[profile.dev.package.craft-video] opt-level = 3 -[profile.dev.package.effectcraft-hevcenc] +[profile.dev.package.craft-audio] opt-level = 3 -[profile.dev.package.effectcraft-av1enc] +[profile.dev.package.craft-media] opt-level = 3 # FilmCraft codec hot loops (same settings as FilmCraft's own workspace). [profile.dev.package.filmcraft-h264] opt-level = 3 -[profile.dev.package.filmcraft-h264enc] -opt-level = 3 - [profile.dev.package.filmcraft-hevc] opt-level = 3 [profile.dev.package.filmcraft-vp9] opt-level = 3 -[profile.dev.package.filmcraft-prores] -opt-level = 3 - [profile.dev.package.filmcraft-dnx] opt-level = 3 [profile.dev.package.filmcraft-av1] opt-level = 3 -[profile.dev.package.filmcraft-aac] -opt-level = 3 - -[profile.dev.package.filmcraft-opus] -opt-level = 3 - [profile.release] lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", version = "=0.3.0" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } @@ -254,8 +243,7 @@ egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968 egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +# Patch a crate only together with the dependency that needs it: an unused patch makes the Nix +# build, which uses vendored sources, fail (`cargo xtask lock-patches`). winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/README.md b/README.md index 81b5a0ba5..57eddebdc 100644 --- a/README.md +++ b/README.md @@ -129,18 +129,14 @@ or into the Timeline, between layers and at the time you point to. 308 built-in effects across its categories, each with its parameter names, order and defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full -color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, +color-grading panel…), distort (Warp, Bulge, Turbulent Displace, Perspective 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…), +**simulation** (Particle Emitter 3D, Rainfall, Shatter, Card Dance, Caustics, Wave World…), +stylize (Glow, Glass Relief…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…), **audio** (Reverb, Parametric EQ, Delay, Stereo Mixer…), text, transitions, utility and expression controls. All nine **Layer Styles** (Drop Shadow, Inner/Outer Glow, Bevel and Emboss, Satin, overlays, Stroke) with Global Light. Preview plays audio in sync, with meters and waveforms. -

- A CC Particle World burst with Glow, and the Effect Controls panel listing the particle system's parameters -

- ## 3D Classic 3D compositing: 3D layers with orientation and material options, @@ -160,7 +156,7 @@ MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and resize, Render Settings and Output Module templates with defaults, post-render actions, storage overflow and render logs. The same queue runs from the command line. Every encoder is pure -Rust (FilmCraft's H.264, ProRes and AAC; EffectCraft's own VP9, AV1, HEVC and Opus); there is no +Rust (craft-libs' H.264, ProRes, HEVC, VP9, AV1 and AAC; EffectCraft's own Opus); there is no FFmpeg inside. **Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape, diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 2981519e9..f52401a00 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -31,12 +31,13 @@ //! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes //! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame) //! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script +//! effectcraft-cli replay SCRIPT.jsonl [--keep-going] replay a repro script (docs/agents.md) //! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio //! //! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty) //! Saving: --save (back to --project) | --save-as F.ecproj -//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists; -//! the default is the CPU (Mercury Software Only) +//! GPU: --gpu renders on the GPU compositor (GPU Acceleration) when an adapter exists; +//! the default is the CPU (Software Rendering) //! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session //! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1) //! ``` @@ -97,7 +98,7 @@ fn stdout_broken() -> bool { STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe) } -const USAGE: &str = "usage: effectcraft-cli [args] [--json] +const USAGE: &str = "usage: effectcraft-cli [args] [--json] info project + engine summary commands [--filter TEXT] [--enabled] list engine commands exec [--params JSON] run one engine command (exec --list: list them, as `commands`) @@ -138,6 +139,9 @@ const USAGE: &str = "usage: effectcraft-cli Result { } /// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's -/// renderer setting, Mercury GPU Acceleration by default). +/// renderer setting, GPU Acceleration by default). fn session(args: &Args) -> Result { let mut s = effectcraft_host::session(); if args.flag("--autosave") { @@ -552,6 +557,59 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> { Ok(()) } +/// `replay SCRIPT.jsonl`: run a repro script against a headless session (its start line picks +/// the project) and report every step. Exits 1 when the replay fails. +fn replay_cmd(args: &Args, json_out: bool) -> Result<(), Failure> { + use effectcraft_engine::replay; + let Some(file) = args.pos.first() else { return usage_err("replay needs a .jsonl script") }; + if args.opt("--bridge").is_some() || args.project.is_some() || args.flag("--demo") || args.flag("--empty") { + return usage_err("replay runs headless from the script's start line ({\"start\": \"new\" | \"demo\" | {\"project\": path}})"); + } + use std::io::Read; + let path = std::path::Path::new(file); + let input = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?; + // Bound the read itself: metadata can be stale or describe a pipe rather than its contents. + let mut script = String::new(); + input.take(replay::MAX_SCRIPT_BYTES as u64 + 1).read_to_string(&mut script).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?; + if script.len() > replay::MAX_SCRIPT_BYTES { + return Err(Failure::Error(format!("{file} is larger than {} MB", replay::MAX_SCRIPT_BYTES >> 20))); + } + let mut s = session(args)?; + let opts = replay::Options { keep_going: args.flag("--keep-going"), base_dir: path.parent().map(std::path::Path::to_path_buf) }; + let report = replay::run(&mut s, &script, &opts); + if json_out { + emit(&serde_json::to_value(&report).unwrap_or_else(|e| json!({"error": e.to_string()})), true); + } else { + for st in &report.steps { + let what = st.command.as_deref().unwrap_or(st.kind); + match (&st.error, st.ok) { + (None, _) => say!("line {:>4} ok {what}", st.line), + (Some(e), true) => say!("line {:>4} ok {what} (tolerated error: {e})", st.line), + (Some(e), false) => say!("line {:>4} FAIL {what}: {e}", st.line), + } + } + } + let Some(first) = &report.first_failure else { + if !json_out { + say!("replay ok: {} steps", report.steps.len()); + } + return Ok(()); + }; + let msg = format!( + "failed at line {} ({}){}: {}{}", + first.line, + first.command.as_deref().unwrap_or(first.kind), + if report.panicked { ", a command panicked" } else { "" }, + first.error.as_deref().unwrap_or(""), + if report.skipped > 0 { format!(" ({} later steps not run)", report.skipped) } else { String::new() } + ); + if json_out { + note!("effectcraft-cli replay: {msg}"); + std::process::exit(1); + } + Err(Failure::Error(msg)) +} + /// Print a result: compact JSON with `--json`, else pretty JSON. fn emit(v: &Value, json_out: bool) { if json_out { @@ -614,6 +672,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { "commands" => list_commands_cmd(args, json_out)?, "exec" if args.flag("--list") => list_commands_cmd(args, json_out)?, "script" => script_cmd(args, json_out)?, + "replay" => replay_cmd(args, json_out)?, "exec" => { let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") }; let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) { @@ -1443,7 +1502,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option Result<(), Failure> { use effectcraft_render::Backend; diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index 363eb8e52..4510f16c9 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -1223,3 +1223,39 @@ fn render_exr_compression_and_depth() { assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}"); let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn replay_reports_steps_and_the_first_failure() { + let repro = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros/comp-zero-frame-rate.jsonl"); + let v = ok_json(&["replay", repro.to_str().unwrap()]); + assert_eq!(v["ok"], true, "{v}"); + assert!(v["steps"].as_array().unwrap().len() > 5); + let bad = tmp("bad-repro.jsonl"); + std::fs::write( + &bad, + "{\"start\": \"new\"}\n{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\nnot json\n", + ) + .unwrap(); + let (code, v) = run_json(&["replay", bad.to_str().unwrap()]); + assert_eq!(code, 1); + assert_eq!(v["ok"], false); + assert_eq!(v["first_failure"]["line"], 3, "{v}"); + assert_eq!(v["skipped"], 1); + let out = bin().args(["replay", bad.to_str().unwrap(), "--keep-going"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("line 2 ok comp.new") && text.contains("line 4 FAIL invalid: not JSON"), "{text}"); + assert!(String::from_utf8_lossy(&out.stderr).contains("failed at line 3 (comp.info)")); + // The start line picks the project. + assert_eq!(bin().args(["replay", bad.to_str().unwrap(), "--demo"]).output().unwrap().status.code(), Some(2)); +} + +/// Text editors can prefix an exported JSON-lines repro with a UTF-8 BOM. +#[test] +fn replay_accepts_a_utf8_bom() { + let path = tmp("bom-repro.jsonl"); + std::fs::write(&path, "\u{feff}{\"command\":\"comp.new\"}\n").unwrap(); + let (code, report) = run_json(&["replay", path.to_str().unwrap()]); + assert_eq!(code, 0, "{report}"); + assert_eq!(report["ok"], true); +} diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 2882b16cf..99e71117f 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -329,7 +329,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))); + app.frames.set_virtual_id(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/diskcache.rs b/apps/effectcraft-web/src/diskcache.rs index ef9d46f8c..72e9a9e11 100644 --- a/apps/effectcraft-web/src/diskcache.rs +++ b/apps/effectcraft-web/src/diskcache.rs @@ -4,23 +4,28 @@ //! //! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key //! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes -//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access -//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports -//! `FrameReply::Stored`. +//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4, +//! checksum) with a synchronous access handle into a temporary file moved into place +//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't +//! all be named is not stored. //! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache): //! rebuilt at startup from the folder listing (oldest files first), updated by the workers' //! `Stored` replies and by reads; eviction deletes the least recently used files. //! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead -//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten. +//! of rendering it (`crate::frames`), if the files its entry lists still have the size and +//! content hash it was made of (the file table's identities); a damaged or missing entry, or +//! one whose files changed, is a miss and is deleted. //! //! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a //! synchronous render, where the browser's file system can't be read, so the frame workers //! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache //! misses; before the next request the page names the recent misses that are on disk //! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries -//! into memory before rendering, so the lookups find them. Slow buffers are written by the -//! worker after its frame and indexed here like frames (one least-recently-used order for -//! both kinds). +//! into memory before rendering, so the lookups find them; an entry is used only while the +//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose +//! files can't all be named is not written (#3184). Slow buffers are written by the worker +//! after its frame and indexed here like frames (one least-recently-used order for both +//! kinds). use std::cell::RefCell; @@ -187,11 +192,12 @@ pub fn note_miss() { } /// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or -/// damaged. +/// damaged, or the source files it was made of changed since. pub async fn read(key: u128) -> Option { let name = disk_cache::entry_name(key); let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::().ok()).map(|b| b.to_vec()); - let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry); + let identity = |path: &str| crate::files::STORE.file_identity(path); + let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity))); ST.with(|s| { let mut s = s.borrow_mut(); if frame.is_some() { diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index 69089e308..0159fe23b 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -473,10 +473,13 @@ pub async fn worker_frame(json: String) { g.settled().await; replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default()); } + // The source files the frame read, kept with its entry (checked by the page before use); a + // frame whose files can't all be named is not stored (#3184). + let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default()); for (r, px) in replies { post_reply(&r, px.as_deref()); - if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) { - let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px); + if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) { + let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue }; let name = format!("{key}.ecc"); match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await { Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None), diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 0cc877948..a4947600b 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -20,7 +20,6 @@ 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}; @@ -187,13 +186,12 @@ pub struct Store { 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 { + /// An empty store with no mirror contents, change callback or clock. fn default() -> Self { - Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) } + Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() } } } @@ -218,22 +216,12 @@ impl Store { pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) { let now = self.now(); 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)> { @@ -541,11 +529,9 @@ 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/Cargo.toml b/apps/effectcraft/Cargo.toml index 3030ee96d..2c8224f65 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -25,6 +25,9 @@ rfd = { workspace = true } pollster = { workspace = true } cpal = { workspace = true } log = { workspace = true } +chrono = { workspace = true } +# MIT; safe process handles and target-only memory observation for the crash helper. +sysinfo = { version = "=0.37.2", default-features = false, features = ["system"] } [target.'cfg(target_os = "linux")'.dependencies] # Native GNOME/Wayland controls without a bundled title font. @@ -38,6 +41,9 @@ zbus = "5.19" # Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" +[dev-dependencies] +tempfile = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] # Keep the control channel responsive when the window is covered (App Nap / occlusion throttling). objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] } diff --git a/apps/effectcraft/src/crash_observation.rs b/apps/effectcraft/src/crash_observation.rs new file mode 100644 index 000000000..b67dc07fe --- /dev/null +++ b/apps/effectcraft/src/crash_observation.rs @@ -0,0 +1,264 @@ +//! Bounded observations made outside the failed GUI. Measurements are evidence, not a cause. +use std::io::{self, Write}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant, SystemTime}; + +use sysinfo::{MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + +pub fn utc() -> String { + chrono::DateTime::::from(SystemTime::now()).to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +#[derive(Default)] +struct Memory { + samples: u64, + resident_peak: u64, + virtual_peak: u64, + available_min: Option, + total: u64, + last_at: Option, + last_uptime_secs: Option, +} + +impl Memory { + fn sample(&mut self, resident: u64, virtual_bytes: u64, available: u64, total: u64) { + self.samples = self.samples.saturating_add(1); + self.resident_peak = self.resident_peak.max(resident); + self.virtual_peak = self.virtual_peak.max(virtual_bytes); + // Zero total means the platform could not supply physical-memory information. + if total != 0 { + self.available_min = Some(self.available_min.map_or(available, |old| old.min(available))); + self.total = total; + } + self.last_at = Some(utc()); + } +} + +pub struct Observation { + pid: Pid, + system: System, + birth: Option, + started: Instant, + started_at: String, + detected_at: Option, + last_sample: Instant, + memory: Memory, + // Only Windows can retrieve an unrelated process's ExitStatus through a retained handle. + exit: Receiver>, + status: Option, + alive: Option, +} + +fn target_system(pid: Pid) -> System { + let mut system = System::new(); + system.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), false, ProcessRefreshKind::nothing().with_memory()); + system +} + +impl Observation { + /// Acquire the Windows handle before acknowledging reporter readiness, so even an immediate + /// GUI abort leaves an observable process object. Never enumerate commands or environments. + pub fn start(pid: u32) -> Self { + let pid = Pid::from_u32(pid); + let system = target_system(pid); + let birth = system.process(pid).map(|p| p.start_time()); + let (send, exit) = mpsc::sync_channel(1); + #[cfg(windows)] + { + let waiting = target_system(pid); + let _ = std::thread::Builder::new().name("ec-exit-status".into()).spawn(move || { + let status = waiting.process(pid).and_then(|p| p.wait()); + let _ = send.send(status); + }); + } + #[cfg(not(windows))] + drop(send); + let now = Instant::now(); + let mut result = Self { + pid, + system, + birth, + started: now, + started_at: utc(), + detected_at: None, + last_sample: now, + memory: Memory::default(), + exit, + status: None, + alive: None, + }; + result.sample(); + result + } + + fn sample(&mut self) { + self.system.refresh_processes_specifics(ProcessesToUpdate::Some(&[self.pid]), false, ProcessRefreshKind::nothing().with_memory()); + self.system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram()); + let process = self.system.process(self.pid).filter(|p| Some(p.start_time()) == self.birth && p.exists()); + self.alive = self.birth.map(|_| process.is_some()); + if let Some(process) = process { + self.memory.last_uptime_secs = Some(process.run_time()); + self.memory.sample(process.memory(), process.virtual_memory(), self.system.available_memory(), self.system.total_memory()); + } + if let Ok(status) = self.exit.try_recv() { + self.status = status; + } + self.last_sample = Instant::now(); + } + + pub fn tick(&mut self) { + if self.last_sample.elapsed() >= Duration::from_secs(1) { + self.sample(); + } + } + + /// Pipe closure can precede the OS signalling process termination. Bound that grace period; + /// a broken pipe or early reporter drop must never be called a confirmed process exit. + pub fn finish(&mut self) { + self.detected_at = Some(utc()); + if self.status.is_none() + && let Ok(status) = self.exit.recv_timeout(Duration::from_millis(750)) + { + self.status = status; + } + self.sample(); + } + + pub fn confirmed_exit(&self) -> bool { + self.status.is_some() + } + + pub fn write(&self, report: &mut impl Write) -> io::Result<()> { + writeln!( + report, + "Detection UTC: {}\nObserver started UTC: {}\nObserved duration: {} ms", + self.detected_at.as_deref().unwrap_or("not captured"), + self.started_at, + self.started.elapsed().as_millis() + )?; + writeln!( + report, + "Process observation: {}", + if self.confirmed_exit() { + "process termination observed" + } else if self.alive == Some(true) { + "GUI still present when the control channel ended" + } else { + "target absent from refresh or process identity/status unavailable (not proof of termination)" + } + )?; + match &self.status { + Some(status) => { + writeln!(report, "OS exit status: {status}")?; + #[cfg(windows)] + if let Some(code) = status.code() { + write_windows_status(report, code as u32)?; + } + } + None => writeln!(report, "OS exit status: unavailable (not inferred from the control pipe)")?, + } + let memory = &self.memory; + writeln!(report, "\nMemory evidence (1 s sampling; sampled maxima, not OS peaks):\nTarget-present sampling attempts: {}", memory.samples)?; + if let Some(uptime) = memory.last_uptime_secs { + writeln!(report, "GUI uptime at last target-present observation: {uptime} s")?; + } + #[cfg(windows)] + let virtual_label = "Peak sampled private committed bytes"; + #[cfg(not(windows))] + let virtual_label = "Peak sampled virtual bytes"; + if memory.samples != 0 { + writeln!( + report, + "Last target sampling attempt UTC: {}\nPeak sampled resident bytes: {}\n{virtual_label}: {}", + memory.last_at.as_deref().unwrap_or("unavailable"), + memory.resident_peak, + memory.virtual_peak + )?; + } + if let Some(available) = memory.available_min { + writeln!(report, "Minimum sampled system available RAM bytes: {available}\nSystem total RAM bytes at last sample: {}", memory.total)?; + } + writeln!( + report, + "Samples can miss short spikes or retain stale/zero readings when an OS query fails; per-field success is not exposed by sysinfo. Windows private commit is not GPU VRAM; on other platforms virtual memory is not physical RAM. System commit limit/usage and GPU memory budgets were not collected. No memory-pressure threshold establishes the cause of this incident.\nThe memory observer does not collect process command lines, environments, documents, native dumps or system event logs. The existing session-log tail below may contain paths or other logged details; review it before sharing." + ) + } +} + +#[cfg(any(windows, test))] +fn write_windows_status(report: &mut impl Write, code: u32) -> io::Result<()> { + let meaning = match code { + 0xc0000005 => "STATUS_ACCESS_VIOLATION", + 0xc0000017 => "STATUS_NO_MEMORY", + 0xc000012d => "STATUS_COMMITMENT_LIMIT", + 0xc0000374 => "STATUS_HEAP_CORRUPTION", + 0xc0000409 => "STATUS_STACK_BUFFER_OVERRUN / fast fail", + 0x40000015 => "STATUS_FATAL_APP_EXIT", + _ => "unclassified", + }; + writeln!( + report, + "Windows status bits: 0x{code:08X} ({meaning})\nThese are process exit bits, not a verified exception record. Software can set an exit code explicitly; an access violation does not identify the faulting module or prove a driver crash." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_target_without_exit_status_does_not_confirm_termination() { + let (send, exit) = mpsc::sync_channel(1); + drop(send); + let now = Instant::now(); + let observation = Observation { + pid: Pid::from_u32(1), + system: System::new(), + birth: Some(1), + started: now, + started_at: utc(), + detected_at: Some(utc()), + last_sample: now, + memory: Memory::default(), + exit, + status: None, + alive: Some(false), + }; + assert!(!observation.confirmed_exit()); + let mut report = Vec::new(); + observation.write(&mut report).unwrap(); + let report = String::from_utf8(report).unwrap(); + assert!(report.contains("not proof of termination")); + assert!(!report.contains("process termination observed")); + } + + #[test] + fn memory_high_water_marks_are_bounded_and_not_a_causal_diagnosis() { + let mut memory = Memory::default(); + memory.sample(40, 80, 30, 100); + memory.sample(20, 90, 50, 100); + assert_eq!((memory.samples, memory.resident_peak, memory.virtual_peak, memory.available_min), (2, 40, 90, Some(30))); + memory.sample(0, 0, 0, 0); + assert_eq!(memory.available_min, Some(30)); + } + + #[test] + fn windows_status_bits_preserve_unsigned_codes_without_claiming_a_driver_crash() { + for (code, name) in + [(0xc0000005, "STATUS_ACCESS_VIOLATION"), (0xc0000017, "STATUS_NO_MEMORY"), (0xc000012d, "STATUS_COMMITMENT_LIMIT"), (42, "unclassified")] + { + let mut text = Vec::new(); + write_windows_status(&mut text, code).unwrap(); + let text = String::from_utf8(text).unwrap(); + assert!(text.contains(name)); + assert!(text.contains(&format!("0x{code:08X}"))); + assert!(text.contains("not a verified exception record")); + } + } + + #[test] + fn utc_timestamps_are_parseable_and_zero_offset() { + let time = chrono::DateTime::parse_from_rfc3339(&utc()).unwrap(); + assert_eq!(time.offset().local_minus_utc(), 0); + } +} diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs new file mode 100644 index 000000000..3080c12bb --- /dev/null +++ b/apps/effectcraft/src/crash_report.rs @@ -0,0 +1,803 @@ +//! A small, separate process reports an unexpected desktop exit (#3357). +//! +//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a +//! helper which never loads a project, font database or GPU. Only an explicit marker after +//! normal teardown suppresses the report. EOF alone identifies a lost control channel, not +//! a process exit or its cause. System-wide exhaustion can still prevent the helper or its native dialog working. + +use std::collections::VecDeque; +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +#[path = "crash_observation.rs"] +mod observation; + +const FLAG: &str = "--internal-crash-reporter"; +const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n"; +const LOG_BYTES: usize = 64 * 1024; +const HEADER_BYTES: usize = 128; +const READY: &[u8] = b"READY\n"; +const EVENT_BYTES: usize = 128; +const EVENT_COUNT: usize = 32; + +/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes +/// the pipe and reports the incident; there is intentionally no clean-exit Drop hook. +pub struct Reporter { + pipe: ChildStdin, + child: Child, +} + +impl Reporter { + /// Call only after application teardown, including handled startup/device-loss errors. + pub fn finish(mut self) -> io::Result<()> { + let sent = self.pipe.write_all(CLEAN).and_then(|()| self.pipe.flush()); + drop(self.pipe); + // Do not let a suspended/broken helper hang shutdown. Reap it before the GUI + // exits where possible, so Windows updates do not race an old executable. + let cleanup = finish_child(&mut self.child, Duration::from_secs(2)); + match (sent, cleanup) { + (Err(sent), Err(cleanup)) => Err(io::Error::other(format!("clean marker failed: {sent}; cleanup failed: {cleanup}"))), + (Err(error), _) | (_, Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } + } +} + +fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> { + let start = Instant::now(); + while start.elapsed() < grace { + if let Some(status) = child.try_wait()? { + return if status.success() { Ok(()) } else { Err(io::Error::other(format!("crash reporter exited with {status}"))) }; + } + std::thread::sleep(Duration::from_millis(20)); + } + child.kill()?; + // Even after termination, do not introduce an unbounded wait on a stuck OS process. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(1) { + if child.try_wait()?.is_some() { + return Err(io::Error::other("crash reporter did not finish promptly and was terminated")); + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(io::Error::other("crash reporter termination could not be confirmed")) +} + +/// Shared ownership lets macOS finish in on_exit, while main retains the startup-error path. +#[derive(Clone, Default)] +pub struct Shutdown(std::sync::Arc>>); + +impl Shutdown { + /// Install the one reporter created for this desktop launch. + pub fn install(&self, reporter: Reporter) -> io::Result<()> { + let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?; + *slot = Some(reporter); + Ok(()) + } + + /// Keep only fixed-vocabulary, bounded lifecycle facts outside the GUI. Callers emit each + /// category once; their combined payload fits well below the smallest OS pipe buffer. + pub fn record(&self, event: &'static str) -> io::Result<()> { + if event.len() > 100 || event.contains(['\n', '\r']) { + return Err(io::Error::other("invalid diagnostic observation")); + } + let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?; + if let Some(reporter) = slot.as_mut() { + writeln!(reporter.pipe, "EVENT {event}")?; + reporter.pipe.flush()?; + } + Ok(()) + } + + /// Consume the reporter exactly once, after the applicable platform teardown boundary. + pub fn finish(&self) -> io::Result<()> { + let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take(); + reporter.map_or(Ok(()), Reporter::finish) + } +} + +/// A panic during teardown must leave the reporter armed. macOS terminates after this hook +/// without returning to main; other platforms pass None and finish after run_native returns. +pub fn after_teardown(reporter: Option<&Shutdown>, teardown: impl FnOnce()) -> io::Result<()> { + teardown(); + reporter.map_or(Ok(()), Shutdown::finish) +} + +/// Each process/run has its own report, including simultaneous launches. +pub fn run_id() -> String { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + format!("{}-{nanos}", std::process::id()) +} + +pub fn session_marker(id: &str) -> String { + format!("crash-report session {id}") +} + +/// Start before heavy work. A missing helper is a warning, never a reason to refuse the app. +pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> io::Result { + let exe = std::env::current_exe()?; + let args = [ + OsString::from(FLAG), + OsString::from(id), + log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()), + reports.as_os_str().to_owned(), + OsString::from(if interactive { "1" } else { "0" }), + OsString::from(std::process::id().to_string()), + ]; + spawn(&exe, &args) +} + +fn spawn(exe: &Path, args: &[OsString]) -> io::Result { + let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?; + if let Err(error) = ready(&mut child) { + let _ = child.kill(); + let _ = finish_child(&mut child, Duration::from_millis(100)); + return Err(error); + } + match child.stdin.take() { + Some(pipe) => Ok(Reporter { pipe, child }), + None => { + let _ = child.kill(); + let _ = child.wait(); + Err(io::Error::other("crash reporter has no control pipe")) + } + } +} + +/// A fixed acknowledgement means the helper holds the process observation before heavy GUI +/// work begins. A suspended helper cannot hold startup indefinitely. +fn ready(child: &mut Child) -> io::Result<()> { + let mut output = child.stdout.take().ok_or_else(|| io::Error::other("crash reporter has no readiness pipe"))?; + let (send, receive) = mpsc::sync_channel(1); + std::thread::Builder::new().name("ec-reporter-ready".into()).spawn(move || { + let mut bytes = [0; READY.len()]; + #[cfg(not(test))] + let result = + output.read_exact(&mut bytes).and_then(|()| if bytes == READY { Ok(()) } else { Err(io::Error::other("invalid reporter acknowledgement")) }); + // libtest prints its bounded banner before invoking the helper role. + #[cfg(test)] + let result = (|| { + for _ in 0..1024 { + bytes.rotate_left(1); + output.read_exact(&mut bytes[READY.len() - 1..])?; + if bytes == READY { + return Ok(()); + } + } + Err(io::Error::other("reporter acknowledgement missing")) + })(); + let _ = send.send(result); + // Keep the pipe open for libtest's trailing result (and future helper output), without + // storing it. Dropping it at READY would turn a clean helper exit into BrokenPipe. + let _ = io::copy(&mut output, &mut io::sink()); + })?; + receive.recv_timeout(Duration::from_secs(3)).map_err(|_| io::Error::other("crash reporter readiness timed out"))? +} + +/// Handle the internal mode before logging, argument parsing, updates or GUI initialization. +/// The callback is a native dialog on this helper's main thread, not on the failed GUI's. +pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool { + if args.first().map(OsString::as_os_str) != Some(OsStr::new(FLAG)) { + return false; + } + let parsed = match args { + [_, id, log, reports, interactive, pid] => id + .to_str() + .filter(|id| valid_id(id)) + .zip(match interactive.to_str() { + Some("0") => Some(false), + Some("1") => Some(true), + _ => None, + }) + .zip(pid.to_str().and_then(|p| p.parse::().ok()).filter(|pid| *pid != 0)) + .map(|((id, interactive), pid)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive, pid)), + _ => None, + }; + let Some((id, log, reports, interactive, pid)) = parsed else { + eprintln!("EffectCraft crash reporter: invalid internal arguments"); + return true; + }; + // Keep the exact file open while the GUI is alive: a later rotation must not substitute + // another instance's log. If rotation won the startup race, the session marker rejects it. + let mut log = SessionLog::open(log, id); + let mut observation = observation::Observation::start(pid); + // A GUI that dies during helper startup can already have closed this read end. Keep + // watching its control pipe and save the report rather than losing that early failure. + let _ = io::stdout().write_all(READY).and_then(|()| io::stdout().flush()); + // The reader has one bounded queue; the monitor never waits on a live pipe, so it can + // sample the target even when no log or lifecycle message is emitted. + let (send, receive) = mpsc::sync_channel(1); + let reader = std::thread::Builder::new().name("ec-reporter-pipe".into()).spawn(move || { + let mut input = io::stdin().lock(); + loop { + let message = read_message(&mut input); + let terminal = !matches!(message, Ok(Message::Event(_))); + if send.send(message).is_err() || terminal { + break; + } + } + }); + let mut events = VecDeque::with_capacity(EVENT_COUNT); + let failure = if reader.is_err() { + Some("control reader could not start".to_owned()) + } else { + loop { + observation.tick(); + match receive.recv_timeout(Duration::from_millis(250)) { + Ok(Ok(Message::Clean)) => return true, + Ok(Ok(Message::Event(event))) => { + if events.len() == EVENT_COUNT { + events.pop_front(); + } + events.push_back(format!("{} {event}", observation::utc())); + } + Ok(Ok(Message::Ended)) => break None, + Ok(Err(error)) => break Some(error.to_string()), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break Some("control reader disconnected".to_owned()), + } + } + }; + observation.finish(); + let saved = write_report(reports, id, &mut log, failure.as_deref(), &observation, &events); + let description = description(&saved, observation.confirmed_exit()); + eprintln!("{description}"); + if interactive { + show(&description); + } + true +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-') +} + +enum Message { + Clean, + Event(String), + Ended, +} + +/// Every line and the retained event ring are bounded, including malformed control input. +fn read_message(input: &mut impl Read) -> io::Result { + let mut line = [0u8; EVENT_BYTES]; + for i in 0..line.len() { + let byte = line.get_mut(i).ok_or_else(|| io::Error::other("invalid control offset"))?; + loop { + match input.read(std::slice::from_mut(byte)) { + Ok(0) => return Ok(Message::Ended), + Ok(_) => break, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + if *byte == b'\n' { + let text = line.get(..=i).ok_or_else(|| io::Error::other("invalid control line"))?; + if text == CLEAN { + return Ok(Message::Clean); + } + if let Some(event) = text.strip_prefix(b"EVENT ") { + let event = std::str::from_utf8(event).map_err(|_| io::Error::other("non-UTF8 diagnostic observation"))?; + return Ok(Message::Event(event.trim_end().to_owned())); + } + return Ok(Message::Ended); + } + } + Err(io::Error::other("control line exceeded its fixed limit")) +} + +#[cfg(test)] +fn clean_exit(input: &mut impl Read) -> io::Result { + Ok(matches!(read_message(input)?, Message::Clean)) +} + +enum SessionLog { + Captured { file: File, marker: String }, + Unavailable(String), +} + +impl SessionLog { + fn open(path: Option<&Path>, id: &str) -> Self { + let opened = (|| { + let path = path.ok_or_else(|| io::Error::other("no session log was opened"))?; + let mut file = File::open(path)?; + let mut header = [0u8; HEADER_BYTES]; + let marker = format!("{}\n", session_marker(id)); + let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?; + file.read_exact(prefix)?; + if prefix != marker.as_bytes() { + return Err(io::Error::other("matching session marker unavailable; the log may have rotated")); + } + Ok(file) + })(); + match opened { + Ok(file) => Self::Captured { file, marker: format!("{}\n", session_marker(id)) }, + Err(e) => Self::Unavailable(e.to_string()), + } + } + + fn write_tail(&mut self, report: &mut File) -> io::Result<()> { + match self { + Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"), + Self::Captured { file, marker } => { + // An older concurrent launcher may have truncated the captured file. + // Recheck its identity before copying any content into this report. + file.seek(SeekFrom::Start(0))?; + let mut header = [0u8; HEADER_BYTES]; + let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?; + if file.read_exact(prefix).is_err() || prefix != marker.as_bytes() { + return writeln!(report, "Recent log unavailable: session identity changed"); + } + let size = file.metadata()?.len(); + file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?; + writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?; + io::copy(&mut file.take(LOG_BYTES as u64), report)?; + Ok(()) + } + } + } +} + +fn write_report( + reports: &Path, + id: &str, + log: &mut SessionLog, + pipe_error: Option<&str>, + observation: &observation::Observation, + events: &VecDeque, +) -> io::Result { + if !valid_id(id) { + return Err(io::Error::other("invalid report identity")); + } + std::fs::create_dir_all(reports)?; + let path = reports.join(format!("crash-{id}.txt")); + let mut report = OpenOptions::new().write(true).create_new(true).open(&path)?; + writeln!( + report, + "EffectCraft unexpected-exit diagnostic\nVersion: {}\nPlatform: {} / {}\nSession: {id}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + )?; + writeln!( + report, + "The GUI control channel ended without its clean-exit marker.\nThe exact cause is not established. GPU fallback, memory pressure and process exit are separate observations, not a proven causal chain.\nThis report was saved locally; it was not sent anywhere." + )?; + observation.write(&mut report)?; + writeln!(report, "\nRetained lifecycle observations (last {EVENT_COUNT}; UTC is helper receipt time):")?; + for event in events { + writeln!(report, "{event}")?; + } + if let Some(error) = pipe_error { + writeln!(report, "Control pipe error: {error}")?; + } + if let Err(error) = log.write_tail(&mut report) { + writeln!(report, "\nCould not read the recent session log: {error}")?; + } + report.flush()?; + Ok(path) +} + +fn description(saved: &io::Result, confirmed_exit: bool) -> String { + let diagnostic = match saved { + Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()), + Err(error) => format!("A diagnostic report could not be saved: {error}"), + }; + let incident = + if confirmed_exit { "EffectCraft stopped unexpectedly." } else { "EffectCraft lost its diagnostic control channel; process exit is not confirmed." }; + format!( + "{incident}\n\n{diagnostic}\n\nThe exact cause is unknown. If EffectCraft has closed, reopen it to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + static NEXT: AtomicUsize = AtomicUsize::new(0); + const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE"; + + fn test_report(dir: &Path, id: &str, log: &mut SessionLog) -> io::Result { + write_report(dir, id, log, None, &observation::Observation::start(std::process::id()), &VecDeque::new()) + } + + fn folder() -> PathBuf { + let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn test_name(name: &str) -> String { + // Also usable from an isolated harness with the reporter dependencies. + let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest); + format!("{prefix}::{name}") + } + + fn helper(dir: &Path, id: &str) -> Reporter { + helper_mode(dir, id, false) + } + + fn helper_mode(dir: &Path, id: &str, interactive: bool) -> Reporter { + let _spawn_lock = crate::test_child_spawn_lock(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("reporter_process"), "--ignored", "--nocapture"]) + .env(ROLE, "reporter") + .env("EC_REPORT_DIR", dir) + .env("EC_REPORT_ID", id) + .env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" }) + .env("EC_REPORT_PID", std::process::id().to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + ready(&mut child).unwrap(); + Reporter { pipe: child.stdin.take().unwrap(), child } + } + + fn wait_file(path: &Path) { + let until = Instant::now() + Duration::from_secs(10); + while !path.is_file() && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + } + assert!(path.is_file(), "report did not arrive: {}", path.display()); + } + + #[test] + fn bounded_protocol_rejects_eof_and_partial_or_wrong_markers() { + let mut clean = CLEAN; + assert!(clean_exit(&mut clean).unwrap()); + for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] { + assert!(!clean_exit(&mut input).unwrap()); + } + assert!(clean_exit(&mut &vec![b'x'; 100_000][..]).is_err()); + } + + #[test] + fn report_is_bounded_and_keeps_its_session_after_log_rotation() { + let dir = folder(); + let original = dir.join("effectcraft.log"); + let id = "123-456"; + std::fs::write(&original, format!("{}\n", session_marker(id))).unwrap(); + let mut log = SessionLog::open(Some(&original), id); + std::fs::rename(&original, dir.join("effectcraft.1.log")).unwrap(); + std::fs::write(&original, "OTHER SESSION MUST NOT APPEAR").unwrap(); + let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap(); + old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap(); + old.write_all(b"\nGPU context retired\n").unwrap(); + let report = test_report(&dir, id, &mut log).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.len() < LOG_BYTES + 4096); + assert!(text.contains("GPU context retired")); + assert!(!text.contains("OTHER SESSION")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn wrong_session_and_missing_logs_are_reported_without_reading_other_content() { + let dir = folder(); + let path = dir.join("log"); + std::fs::write(&path, "private other session").unwrap(); + for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] { + let report = test_report(&dir, id, &mut SessionLog::open(source, id)).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.contains("Recent log unavailable")); + assert!(!text.contains("private other session")); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn captured_log_rejects_a_session_overwritten_after_open() { + let dir = folder(); + let path = dir.join("log"); + std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap(); + let mut log = SessionLog::open(Some(&path), "1-2"); + std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap(); + let report = test_report(&dir, "1-2", &mut log).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.contains("Recent log unavailable")); + assert!(!text.contains("private other session")); + assert!(!text.contains("session 3-4")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn session_identity_requires_the_exact_first_line() { + let dir = folder(); + let path = dir.join("log"); + for header in ["crash-report session 1-20\n", "unrelated\ncrash-report session 1-2\n"] { + std::fs::write(&path, header).unwrap(); + assert!(matches!(SessionLog::open(Some(&path), "1-2"), SessionLog::Unavailable(_))); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn report_write_failure_is_described_and_existing_report_is_not_overwritten() { + let dir = folder(); + let mut log = SessionLog::Unavailable("no log".into()); + let first = test_report(&dir, "2-1", &mut log).unwrap(); + let before = std::fs::read(&first).unwrap(); + let result = test_report(&dir, "2-1", &mut log); + assert!(result.is_err()); + assert!(description(&result, false).contains("could not be saved")); + assert_eq!(std::fs::read(first).unwrap(), before); + assert!(test_report(&dir, "../escape", &mut log).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn normal_and_handled_shutdown_send_clean_marker_without_a_report() { + for id in ["3-1", "3-2"] { + let dir = folder(); + let r = helper(&dir, id); + r.finish().unwrap(); + // A completion file proves the helper consumed the marker, not merely that it + // hadn't yet produced a report. + wait_file(&dir.join(format!("ready-{id}"))); + assert!(!dir.join(format!("crash-{id}.txt")).exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + } + + #[test] + fn accepted_native_close_finishes_before_process_termination() { + let dir = folder(); + let _spawn_lock = crate::test_child_spawn_lock(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "native-close") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(status.success()); + wait_file(&dir.join("ready-8-1")); + assert!(!dir.join("crash-8-1.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_teardown_panic_does_not_mark_the_reporter_clean() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-2")).unwrap(); + let result = std::panic::catch_unwind(|| { + let _ = after_teardown(Some(&reporter), || panic!("simulated teardown failure")); + }); + assert!(result.is_err()); + drop(reporter); + wait_file(&dir.join("ready-8-2")); + assert!(dir.join("crash-8-2.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn platforms_returning_to_main_keep_the_reporter_armed_until_then() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-3")).unwrap(); + after_teardown(None, || {}).unwrap(); + assert!(reporter.0.lock().unwrap().is_some()); + reporter.clone().finish().unwrap(); + reporter.finish().unwrap(); + wait_file(&dir.join("ready-8-3")); + assert!(!dir.join("crash-8-3.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_aborting_parent_leaves_one_diagnostic_report() { + let dir = folder(); + let _spawn_lock = crate::test_child_spawn_lock(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "abort") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(!status.success()); + wait_file(&dir.join("ready-4-1")); + let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap(); + assert!(text.contains("exact cause is not established")); + assert!(text.contains("Detection UTC:")); + #[cfg(windows)] + assert!(text.contains("Windows status bits:")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unrelated_child_does_not_keep_the_control_pipe_open() { + let dir = folder(); + let r = helper(&dir, "5-1"); + let _spawn_lock = crate::test_child_spawn_lock(); + let mut unrelated = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + drop(r); + wait_file(&dir.join("ready-5-1")); + assert!(unrelated.try_wait().unwrap().is_none()); + unrelated.kill().unwrap(); + unrelated.wait().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unresponsive_helper_cannot_hang_clean_shutdown() { + let _spawn_lock = crate::test_child_spawn_lock(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let pipe = child.stdin.take().unwrap(); + let started = Instant::now(); + let result = Reporter { pipe, child }.finish(); + assert!(result.unwrap_err().to_string().contains("was terminated")); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn simultaneous_sessions_write_separate_reports() { + let dir = folder(); + let a = helper(&dir, "6-1"); + let b = helper(&dir, "6-2"); + drop(a); + drop(b); + wait_file(&dir.join("ready-6-1")); + wait_file(&dir.join("ready-6-2")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn interactive_failure_calls_dialog_once_after_saving_the_report() { + let dir = folder(); + drop(helper_mode(&dir, "7-1", true)); + wait_file(&dir.join("ready-7-1")); + let text = std::fs::read_to_string(dir.join("dialog-7-1")).unwrap(); + assert!(text.contains("crash-7-1.txt")); + assert!(text.contains("No report has been sent")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn lost_pipe_keeps_bounded_lifecycle_evidence_without_claiming_process_exit() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "9-1")).unwrap(); + for _ in 0..40 { + reporter.record("GPU observation: wgpu validation error").unwrap(); + } + drop(reporter); + wait_file(&dir.join("ready-9-1")); + let text = std::fs::read_to_string(dir.join("crash-9-1.txt")).unwrap(); + assert!(text.contains("GUI still present")); + assert!(!text.contains("process termination observed")); + assert_eq!(text.matches("GPU observation: wgpu validation error").count(), EVENT_COUNT); + assert!(text.contains("Target-present sampling attempts:")); + assert!(text.len() < 8192); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn clean_exit_after_lifecycle_events_does_not_write_a_report() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "9-2")).unwrap(); + reporter.record("Backend selection source: default").unwrap(); + reporter.finish().unwrap(); + wait_file(&dir.join("ready-9-2")); + assert!(!dir.join("crash-9-2.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + #[cfg(windows)] + fn windows_report_preserves_real_unsigned_exit_bits() { + let dir = folder(); + let _spawn_lock = crate::test_child_spawn_lock(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "windows-status") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert_eq!(status.code().map(|code| code as u32), Some(0xc0000017)); + wait_file(&dir.join("ready-9-3")); + let text = std::fs::read_to_string(dir.join("crash-9-3.txt")).unwrap(); + assert!(text.contains("Windows status bits: 0xC0000017 (STATUS_NO_MEMORY)")); + assert!(text.contains("not a verified exception record")); + assert!(text.contains("process termination observed")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn internal_mode_rejects_invalid_arguments_without_launching_the_app() { + assert!(!maybe_run(&[], |_| panic!("no dialog"))); + assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog"))); + assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog"))); + let args = [FLAG, "../escape", "", "", "1", "1"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + let args = [FLAG, "1-1", "", "", "invalid", "1"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + for pid in ["0", "not-a-pid", "4294967296"] { + let args = [FLAG, "1-1", "", "", "0", pid].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + } + } + + #[test] + fn missing_helper_is_an_error_not_a_panic() { + assert!(spawn(Path::new("/nonexistent-effectcraft-crash-helper"), &[]).is_err()); + } + + // Roles are ignored by ordinary test runs. Explicit subprocess invocations exercise + // real EOF/abort behavior without allocating a huge image or killing the test runner. + #[test] + #[ignore] + fn reporter_process() { + assert_eq!(std::env::var(ROLE).unwrap(), "reporter"); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + let id = std::env::var("EC_REPORT_ID").unwrap(); + let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap(); + let args = [ + OsString::from(FLAG), + OsString::from(&id), + OsString::new(), + dir.as_os_str().to_owned(), + OsString::from(&interactive), + std::env::var_os("EC_REPORT_PID").unwrap(), + ]; + assert!(maybe_run(&args, |description| { + assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog"); + assert!(dir.join(format!("crash-{id}.txt")).exists()); + let mut shown = OpenOptions::new().write(true).create_new(true).open(dir.join(format!("dialog-{id}"))).unwrap(); + shown.write_all(description.as_bytes()).unwrap(); + })); + std::fs::write(dir.join(format!("ready-{id}")), b"done").unwrap(); + } + + #[test] + #[ignore] + fn aborting_parent() { + let role = std::env::var(ROLE).unwrap(); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + if role == "native-close" { + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-1")).unwrap(); + after_teardown(Some(&reporter.clone()), || {}).unwrap(); + std::process::exit(0); // Models macOS not returning from its native event loop. + } + if role == "windows-status" { + let _reporter = helper(&dir, "9-3"); + // The real OS status is intentionally set, not actual memory exhaustion. The + // report must preserve this distinction rather than claiming a verified exception. + std::process::exit(0xc0000017u32 as i32); + } + assert_eq!(role, "abort"); + let _reporter = helper(&dir, "4-1"); + std::process::abort(); + } + + #[test] + #[ignore] + fn lingering_child() { + assert_eq!(std::env::var(ROLE).unwrap(), "linger"); + thread::sleep(Duration::from_secs(30)); + } +} diff --git a/apps/effectcraft/src/diagnostic_state.rs b/apps/effectcraft/src/diagnostic_state.rs new file mode 100644 index 000000000..042e6e88c --- /dev/null +++ b/apps/effectcraft/src/diagnostic_state.rs @@ -0,0 +1,147 @@ +//! Fixed-vocabulary observations retained by the crash helper independently of the log tail. +//! Driver messages and environment text can contain private data; never forward either here. + +use std::ffi::OsStr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +use crate::crash_report::Shutdown; + +pub fn record(reporter: &Shutdown, event: &'static str) { + log::info!("diagnostic observation: {event}"); + if let Err(error) = reporter.record(event) { + log::warn!("could not retain diagnostic observation: {error}"); + } +} + +/// Mirror only wgpu's documented names/aliases, without calling its parser: that parser +/// logs unknown tokens verbatim. Unknown, non-Unicode and oversized values remain private. +pub fn backend_override_events(value: &OsStr) -> Vec<&'static str> { + let Some(value) = value.to_str().filter(|value| value.len() <= 4096) else { + return vec!["WGPU_BACKEND: non-Unicode or oversized value omitted"]; + }; + let mut selected = [false; 6]; + let mut unknown = false; + for token in value.split(',').map(str::trim) { + let aliases: &[&[&str]] = &[&["vulkan", "vk"], &["dx12", "d3d12"], &["metal", "mtl"], &["opengl", "gles", "gl"], &["webgpu"], &["noop"]]; + if let Some(index) = aliases.iter().position(|names| names.iter().any(|name| token.eq_ignore_ascii_case(name))) { + if let Some(flag) = selected.get_mut(index) { + *flag = true; + } + } else { + unknown = true; + } + } + let events = [ + "WGPU_BACKEND requests: vulkan", + "WGPU_BACKEND requests: dx12", + "WGPU_BACKEND requests: metal", + "WGPU_BACKEND requests: gl", + "WGPU_BACKEND requests: webgpu", + "WGPU_BACKEND requests: noop", + ]; + let mut result: Vec<_> = selected.into_iter().zip(events).filter_map(|(present, event)| present.then_some(event)).collect(); + if unknown { + result.push("WGPU_BACKEND: unrecognized or empty tokens omitted"); + } + result +} + +/// At most eight events per device, even when a driver floods its callbacks. A wgpu OOM +/// observation is not proof of system RAM exhaustion; device loss is not a driver-crash verdict. +#[derive(Clone)] +pub struct GpuObservations { + seen: Arc, + reporter: Shutdown, +} + +impl GpuObservations { + pub fn new(reporter: &Shutdown) -> Self { + Self { seen: Arc::new(AtomicU8::new(0)), reporter: reporter.clone() } + } + + pub fn uncaptured(&self, error: &eframe::wgpu::Error) { + let (bit, event) = match error { + eframe::wgpu::Error::OutOfMemory { .. } => (1, "GPU observation: wgpu out-of-memory error (not proof of system RAM exhaustion)"), + eframe::wgpu::Error::Validation { .. } => (2, "GPU observation: wgpu validation error"), + eframe::wgpu::Error::Internal { .. } => (4, "GPU observation: wgpu internal error"), + }; + self.once(bit, event); + } + + pub fn scoped(&self, error: effectcraft_ui_egui::gpu_failure::ScopedError) { + use effectcraft_ui_egui::gpu_failure::ScopedError; + let (bit, event) = match error { + ScopedError::OutOfMemory => (32, "GPU scoped observation: out-of-memory (not proof of system RAM exhaustion)"), + ScopedError::Internal => (64, "GPU scoped observation: internal error (not confirmed out-of-memory)"), + ScopedError::Validation => (128, "GPU scoped observation: validation error"), + }; + self.once(bit, event); + } + + pub fn device_lost(&self, reason: eframe::wgpu::DeviceLostReason) { + match reason { + eframe::wgpu::DeviceLostReason::Unknown => self.once(8, "GPU observation: device lost, reason unknown (not proof of a driver crash)"), + eframe::wgpu::DeviceLostReason::Destroyed => self.once(16, "GPU observation: device-lost callback, explicit device destruction"), + }; + } + + fn once(&self, bit: u8, event: &'static str) -> bool { + if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 { + return false; + } + record(&self.reporter, event); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn override_names_are_normalized_deduplicated_and_private() { + let events = backend_override_events(OsStr::new(" Vk, VULKAN, d3d12, MTL, gles, webgpu, noop, private-token")); + assert_eq!(events.len(), 7); + assert_eq!(events.first(), Some(&"WGPU_BACKEND requests: vulkan")); + assert!(!events.join(" ").contains("private-token")); + assert!(events.iter().all(|event| event.len() <= 100)); + assert_eq!(backend_override_events(OsStr::new("")).len(), 1); + assert_eq!(backend_override_events(OsStr::new(&"x".repeat(4097))), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]); + } + + #[test] + fn gpu_categories_are_latched_independently() { + let observations = GpuObservations::new(&Shutdown::default()); + observations.uncaptured(&eframe::wgpu::Error::OutOfMemory { source: Box::new(std::io::Error::other("private driver detail")) }); + observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown); + observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown); + assert_eq!(observations.seen.load(Ordering::Relaxed), 9); + observations.device_lost(eframe::wgpu::DeviceLostReason::Destroyed); + observations.uncaptured(&eframe::wgpu::Error::Validation { + source: Box::new(std::io::Error::other("private driver detail")), + description: "private validation detail".into(), + }); + observations.uncaptured(&eframe::wgpu::Error::Internal { + source: Box::new(std::io::Error::other("private driver detail")), + description: "private internal detail".into(), + }); + assert_eq!(observations.seen.load(Ordering::Relaxed), 31); + use effectcraft_ui_egui::gpu_failure::ScopedError; + observations.scoped(ScopedError::Internal); + assert_eq!(observations.seen.load(Ordering::Relaxed), 95, "internal scope must not set scoped OOM bit"); + observations.scoped(ScopedError::OutOfMemory); + observations.scoped(ScopedError::Validation); + observations.scoped(ScopedError::OutOfMemory); + assert_eq!(observations.seen.load(Ordering::Relaxed), 255); + assert!(!observations.once(1, "GPU observation: duplicate")); + assert!(!observations.once(8, "GPU observation: duplicate")); + } + + #[test] + #[cfg(unix)] + fn non_unicode_override_is_not_copied() { + use std::os::unix::ffi::OsStrExt; + assert_eq!(backend_override_events(OsStr::from_bytes(b"private\xff")), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]); + } +} diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs index fac91c05b..85d1fdd57 100644 --- a/apps/effectcraft/src/launch_guard.rs +++ b/apps/effectcraft/src/launch_guard.rs @@ -8,9 +8,9 @@ //! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A //! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those //! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still -//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile -//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to -//! Automatic ([`opengl_failed`]) rather than failing every launch. +//! wins over both. A separate guard file is locked while its launch runs, so a second copy opened +//! meanwhile isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics +//! back to Automatic ([`opengl_failed`]) rather than failing every launch. use std::fs::File; use std::path::{Path, PathBuf}; @@ -21,6 +21,46 @@ use eframe::wgpu::Backends; /// Left in the settings folder from a launch until its window has settled. const MARKER: &str = "launch-pending"; +const LOCK: &str = "launch.lock"; + +/// Keep this file in place: unlinking a locked file lets a second inode acquire a new lock. +fn acquire_guard(dir: &Path) -> Option { + let file = match std::fs::create_dir_all(dir) + .and_then(|()| std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK))) + { + Ok(file) => file, + Err(e) => { + log::warn!("launch guard unavailable: {e}"); + return None; + } + }; + match file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return None, + Err(e) => { + log::warn!("launch guard lock unavailable: {e}"); + return None; + } + } + // Older versions lock the marker itself. Probe only the lock: Windows also prevents + // reads through another handle while it is held. An unreadable marker is not a stale one. + match File::open(dir.join(MARKER)) { + Ok(marker) => match marker.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return None, + Err(e) => { + log::warn!("launch marker lock unavailable: {e}"); + return None; + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + log::warn!("launch marker unavailable: {e}"); + return None; + } + } + Some(file) +} /// How long the window keeps drawing after its first frame before the launch counts as made. /// A driver that fails as the window is shown and its surface re-created does so within the @@ -28,16 +68,39 @@ const MARKER: &str = "launch-pending"; /// switch back in Settings. pub const SETTLE: Duration = Duration::from_secs(3); +/// Why this launch requested its window graphics APIs. An interrupted startup is an +/// observation, not proof that a driver caused the previous exit. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum BackendSource { + #[default] + Default, + SavedPreference, + InterruptedStartup, + EnvironmentOverride, +} + +impl BackendSource { + pub fn event(self) -> &'static str { + match self { + Self::Default => "backend source: default", + Self::SavedPreference => "backend source: saved OpenGL preference", + Self::InterruptedStartup => "backend source: previous interrupted startup; OpenGL fallback", + Self::EnvironmentOverride => "backend source: WGPU_BACKEND present", + } + } +} + /// This launch's window graphics. #[derive(Debug, Default)] pub struct Launch { /// The graphics APIs the window may use (`None`: eframe's default). pub backends: Option, + pub source: BackendSource, /// Switched to OpenGL because the previous launch stopped as its window opened: the message /// to show. pub notice: Option, marker: Option, - /// Holds the marker's lock until the launch settles (or the process ends). + /// Holds the separate guard file's lock until the launch settles (or the process ends). lock: Option, /// When the window drew its first frame, until the launch settles. first_frame: Option, @@ -48,14 +111,22 @@ impl Launch { /// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this /// launch's marker. `env_override`: `WGPU_BACKEND` is set. pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch { - let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() }; + if env_override { + return Launch { source: BackendSource::EnvironmentOverride, ..Launch::default() }; + } + let Some(dir) = dir else { return Launch::default() }; let prefs_path = dir.join(PREFS_FILE); let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); let marker = dir.join(MARKER); - // Locked by another launch that is still opening its window: not a crash. - let held = File::open(&marker).is_ok_and(|f| matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + let Some(lock) = acquire_guard(dir) else { + let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL); + let source = if backends.is_some() { BackendSource::SavedPreference } else { BackendSource::Default }; + return Launch { backends, source, ..Launch::default() }; + }; + // Read again while owning the guard: another launch may have just changed preferences. + prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); let mut notice = None; - if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { + if marker.exists() && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { prefs.startup.window_graphics = "gl".into(); match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) { Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"), @@ -69,15 +140,21 @@ impl Launch { ); } let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL); - let (marker, lock) = match std::fs::create_dir_all(dir).and_then(|()| File::create(&marker)) { - // Where locks aren't available the guard works as before. - Ok(f) => (Some(marker), f.try_lock().is_ok().then_some(f)), + let source = if notice.is_some() { + BackendSource::InterruptedStartup + } else if backends.is_some() { + BackendSource::SavedPreference + } else { + BackendSource::Default + }; + let marker = match File::create(&marker) { + Ok(_) => Some(marker), Err(e) => { log::warn!("launch marker: {e}"); - (None, None) + None } }; - Launch { backends, notice, marker, lock, first_frame: None } + Launch { backends, source, notice, marker, lock: Some(lock), first_frame: None } } /// The window drew a frame. Until the launch has settled, returns how long that still takes @@ -104,18 +181,19 @@ impl Launch { /// The launch made it: release and remove its marker. fn settled(&mut self) { - self.lock = None; if let Some(m) = self.marker.take() && let Err(e) = std::fs::remove_file(&m) { log::warn!("launch marker: {e}"); } + self.lock = None; } } /// The window couldn't open with OpenGL (an error, not a crash): back to Automatic, and no marker. pub fn opengl_failed(dir: Option<&Path>) { let Some(dir) = dir else { return }; + let Some(_lock) = acquire_guard(dir) else { return }; let prefs_path = dir.join(PREFS_FILE); let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); prefs.startup.window_graphics = "auto".into(); @@ -136,12 +214,13 @@ mod tests { /// A launch that never drew switches the next one to OpenGL, which then stays (#243). #[test] fn a_launch_that_never_drew_switches_the_next_to_opengl() { - let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("never-drew"); // A first launch that draws: nothing changes. Its first frames don't settle it (a driver // may still crash as the window is shown); frames for `SETTLE` do. let mut l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (None, false)); + assert_eq!(l.source, BackendSource::Default); assert!(dir.join(MARKER).exists()); let t0 = Instant::now(); assert_eq!(l.frame_drawn_at(t0), Some(SETTLE)); @@ -159,14 +238,18 @@ mod tests { } else { assert_eq!(l.backends, Some(Backends::GL)); assert!(l.notice.is_some()); + assert_eq!(l.source, BackendSource::InterruptedStartup); assert_eq!(window_graphics(&dir), "gl", "saved in Settings"); l.exited(); let l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice"); + assert_eq!(l.source, BackendSource::SavedPreference); } // `WGPU_BACKEND` wins, and leaves no marker. let _ = std::fs::remove_file(dir.join(MARKER)); - assert_eq!(Launch::begin(Some(&dir), true).backends, None); + let overridden = Launch::begin(Some(&dir), true); + assert_eq!(overridden.backends, None); + assert_eq!(overridden.source, BackendSource::EnvironmentOverride); assert!(!dir.join(MARKER).exists()); let _ = std::fs::remove_dir_all(&dir); } @@ -176,8 +259,8 @@ mod tests { /// normal quit in that time doesn't. #[test] fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() { - let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("first-frame"); let mut l = Launch::begin(Some(&dir), false); assert!(l.frame_drawn().is_some()); drop(l); @@ -203,8 +286,8 @@ mod tests { /// open a window either goes back to Automatic instead of failing every launch. #[test] fn a_second_copy_or_failed_opengl_doesnt_strand_window_graphics() { - let dir = std::env::temp_dir().join(format!("ec-launch-guard-2-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("second-copy"); let _first = Launch::begin(Some(&dir), false); assert_eq!(Launch::begin(Some(&dir), false).backends, None); assert_eq!(window_graphics(&dir), "auto"); @@ -217,4 +300,240 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// A fresh settings folder for one test. + fn fresh_dir(case: &str) -> PathBuf { + tempfile::Builder::new().prefix(&format!("ec-launch-guard-{case}-")).tempdir().unwrap().keep() + } + + /// Window Graphics saved as OpenGL. + fn save_opengl(dir: &Path) { + let mut prefs = Prefs::default(); + prefs.startup.window_graphics = "gl".into(); + std::fs::write(dir.join(PREFS_FILE), prefs.to_json()).unwrap(); + } + + #[test] + fn a_second_copy_reports_the_saved_backend_without_owning_the_guard() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("second-source"); + save_opengl(&dir); + let mut first = Launch::begin(Some(&dir), false); + let mut second = Launch::begin(Some(&dir), false); + assert!(second.lock.is_none()); + assert_eq!((second.backends, second.source), (Some(Backends::GL), BackendSource::SavedPreference)); + second.exited(); + first.exited(); + let _ = std::fs::remove_dir_all(dir); + } + + /// A second copy that quits normally while the first is still opening doesn't remove the first + /// one's marker (#477). + #[test] + fn a_second_copy_quitting_leaves_the_running_launchs_marker() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("second-quits"); + let mut first = Launch::begin(Some(&dir), false); + let mut second = Launch::begin(Some(&dir), false); + second.exited(); + let kept = dir.join(MARKER).exists(); + first.exited(); + let _ = std::fs::remove_dir_all(&dir); + assert!(kept, "the second copy removed the running launch's marker"); + } + + /// A second copy whose OpenGL window fails doesn't remove a running launch's marker. + #[test] + fn failed_opengl_in_a_second_copy_leaves_the_running_launchs_marker() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("second-gl-fails"); + let mut first = Launch::begin(Some(&dir), false); + let second = Launch::begin(Some(&dir), false); + opengl_failed(Some(&dir)); + let kept = dir.join(MARKER).exists(); + drop(second); + first.exited(); + let _ = std::fs::remove_dir_all(&dir); + assert!(kept, "OpenGL cleanup removed another running launch's marker"); + } + + /// With `WGPU_BACKEND` set, a stale marker and a saved OpenGL choice are left as they are. + #[test] + fn wgpu_backend_leaves_a_stale_marker_and_saved_opengl_alone() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("env-override"); + save_opengl(&dir); + std::fs::write(dir.join(MARKER), b"stale-marker").unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut l = Launch::begin(Some(&dir), true); + assert_eq!(l.backends, None); + assert!(l.notice.is_none()); + l.exited(); + assert!(dir.join(MARKER).exists()); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + let _ = std::fs::remove_dir_all(&dir); + } + + /// After OpenGL fails to open a window, the next launch is back on Automatic, without a notice. + #[test] + fn failed_opengl_puts_the_next_launch_back_on_automatic() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("gl-recovers"); + save_opengl(&dir); + let l = Launch::begin(Some(&dir), false); + assert_eq!(l.backends, Some(Backends::GL)); + drop(l); + opengl_failed(Some(&dir)); + assert!(!dir.join(MARKER).exists()); + assert_eq!(Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics, "auto"); + let mut l = Launch::begin(Some(&dir), false); + assert_eq!(l.backends, None); + assert!(l.notice.is_none()); + l.exited(); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The guard file is never removed or replaced: unlinking a locked file would let the next + /// launch lock a new one while the old lock is still held. + #[cfg(unix)] + #[test] + fn the_guard_file_stays_the_same_file_across_launches() { + let _spawn_lock = crate::test_child_spawn_lock(); + use std::os::unix::fs::MetadataExt; + let dir = fresh_dir("guard-identity"); + let mut a = Launch::begin(Some(&dir), false); + let before = std::fs::metadata(dir.join(LOCK)).unwrap(); + let t0 = Instant::now(); + assert_eq!(a.frame_drawn_at(t0), Some(SETTLE)); + assert_eq!(a.frame_drawn_at(t0 + SETTLE), None); + assert!(!dir.join(MARKER).exists()); + let after = std::fs::metadata(dir.join(LOCK)).unwrap(); + assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino())); + let mut b = Launch::begin(Some(&dir), false); + assert!(b.lock.is_some()); + let reused = std::fs::metadata(dir.join(LOCK)).unwrap(); + assert_eq!((before.dev(), before.ino()), (reused.dev(), reused.ino())); + b.exited(); + let _ = std::fs::remove_dir_all(dir); + } + + /// A second copy's frames and quit neither settle nor unlock the running launch. + #[test] + fn a_second_copys_frames_dont_settle_the_running_launch() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("second-frames"); + let mut a = Launch::begin(Some(&dir), false); + std::fs::write(dir.join(MARKER), b"owner-marker").unwrap(); + let mut b = Launch::begin(Some(&dir), false); + assert!(b.lock.is_none()); + assert!(b.marker.is_none()); + let t0 = Instant::now(); + assert_eq!(b.frame_drawn_at(t0), None); + assert_eq!(b.frame_drawn_at(t0 + SETTLE), None); + b.exited(); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"owner-marker"); + let probe = File::open(dir.join(LOCK)).unwrap(); + assert!(matches!(probe.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + a.exited(); + let _ = std::fs::remove_dir_all(dir); + } + + /// Older versions lock the marker itself: while one runs, a new launch changes nothing, and + /// once it has stopped without settling the next launch still switches to OpenGL. + #[test] + fn a_running_older_version_holding_the_marker_is_left_alone() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("older-version"); + std::fs::write(dir.join(MARKER), b"old-owner").unwrap(); + std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap(); + let old = File::open(dir.join(MARKER)).unwrap(); + old.try_lock().unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut b = Launch::begin(Some(&dir), false); + assert!(b.lock.is_none()); + assert!(b.marker.is_none()); + assert!(b.backends.is_none()); + assert!(b.notice.is_none()); + b.exited(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + // Windows locks forbid reading through another handle. Inspect the contents only after + // releasing the old owner's lock, but before recovery can truncate the marker. + drop(old); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner"); + let mut recovered = Launch::begin(Some(&dir), false); + if cfg!(not(target_os = "macos")) { + assert_eq!(recovered.backends, Some(Backends::GL)); + } + recovered.exited(); + let _ = std::fs::remove_dir_all(dir); + } + + /// A Windows sharing violation is not evidence that a marker is stale or absent. + #[cfg(windows)] + #[test] + fn an_unopenable_marker_leaves_settings_and_marker_alone() { + let _spawn_lock = crate::test_child_spawn_lock(); + use std::os::windows::fs::OpenOptionsExt; + + let dir = fresh_dir("marker-unopenable"); + std::fs::write(dir.join(MARKER), b"old-owner").unwrap(); + std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap(); + let old = std::fs::OpenOptions::new().read(true).write(true).share_mode(0).open(dir.join(MARKER)).unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut launch = Launch::begin(Some(&dir), false); + assert!(launch.lock.is_none()); + assert!(launch.marker.is_none()); + assert!(launch.backends.is_none()); + assert!(launch.notice.is_none()); + launch.exited(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + drop(old); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner"); + let _ = std::fs::remove_dir_all(dir); + } + + /// A guard file that can't be opened leaves Window Graphics and the marker as they are. + #[test] + fn an_unusable_guard_file_leaves_settings_and_marker_alone() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("guard-unusable"); + std::fs::create_dir(dir.join(LOCK)).unwrap(); + std::fs::write(dir.join(MARKER), b"stale-marker").unwrap(); + std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap(); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + let mut l = Launch::begin(Some(&dir), false); + assert!(l.backends.is_none()); + assert!(l.notice.is_none()); + assert!(l.lock.is_none()); + assert!(l.marker.is_none()); + l.exited(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"stale-marker"); + let _ = std::fs::remove_dir_all(dir); + } + + /// OpenGL cleanup from elsewhere waits until the running launch has let go; then it resets + /// Window Graphics to Automatic and removes the marker. + #[test] + fn failed_opengl_cleanup_waits_for_the_running_launch() { + let _spawn_lock = crate::test_child_spawn_lock(); + let dir = fresh_dir("gl-cleanup-waits"); + save_opengl(&dir); + let launch = Launch::begin(Some(&dir), false); + assert_eq!(launch.backends, Some(Backends::GL)); + assert!(launch.lock.is_some()); + let before = std::fs::read(dir.join(PREFS_FILE)).unwrap(); + opengl_failed(Some(&dir)); + assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before); + assert!(dir.join(MARKER).exists(), "cleanup elsewhere can't remove a running launch's marker"); + drop(launch); + opengl_failed(Some(&dir)); + let after = Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics; + assert_eq!(after, "auto", "once the launch has let go, failed OpenGL resets Window Graphics"); + assert!(!dir.join(MARKER).exists()); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 07d327a21..b07e3ce43 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -22,6 +22,8 @@ mod appimage; mod apple_events; mod audio_out; mod control_server; +mod crash_report; +mod diagnostic_state; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; @@ -33,6 +35,17 @@ mod window; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; +// On Unix, a subprocess briefly inherits every non-CLOFORK descriptor between fork and exec. +// The app's guard-lock tests hold an advisory lock while crash-reporter tests spawn child +// processes; coordinate just those intervals so an unrelated test child cannot extend a lock. +#[cfg(test)] +static TEST_CHILD_SPAWN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +fn test_child_spawn_lock() -> std::sync::MutexGuard<'static, ()> { + TEST_CHILD_SPAWN_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by /// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock. /// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too). @@ -180,6 +193,23 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) { /// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then /// reports startup failure through `startup_failed`. Returns the event loop's result. fn main() -> eframe::Result { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if crash_report::maybe_run(&args, |description| { + rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft diagnostic report").set_description(description).show(); + }) { + return Ok(()); + } + let reporter = crash_report::Shutdown::default(); + let result = run_desktop(&reporter); + // run_desktop has dropped the app and handled any startup error with its existing dialog. + // An unwinding panic or abort never reaches this marker. + if let Err(error) = reporter.finish() { + log::warn!("could not mark crash reporter clean: {error}"); + } + result +} + +fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { @@ -205,20 +235,38 @@ fn main() -> eframe::Result { // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so // `--version` leaves no file behind; records logged until now are written to it first. + let run_id = crash_report::run_id(); if let Some(dir) = effectcraft_host::config_dir() { - match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) { + let header = format!("{}\n", crash_report::session_marker(&run_id)); + match effectcraft_engine::logging::attach_session_log_with_header(&effectcraft_engine::logging::log_dir(Some(&dir)), &header) { Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), Err(e) => log::warn!("no log file: {e}"), } } + let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref()); + match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) { + Ok(started) => { + if let Err(error) = reporter.install(started) { + log::warn!("crash reporter could not be retained: {error}"); + } + } + Err(error) => log::warn!("crash reporter could not start: {error}"), + } // The font menus list the installed fonts: read their names while the window opens. effectcraft_engine::text::fonts::scan_system_in_background(); // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. #[cfg(target_os = "linux")] appimage::integrate_from_env(ICON_PNG); // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch - // stopped as its window opened (a crashing graphics driver, #243). - let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); + // stopped as its window opened (the marker alone does not establish a cause). + let backend_override = std::env::var_os("WGPU_BACKEND"); + let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), backend_override.is_some()); + diagnostic_state::record(reporter, launch.source.event()); + if let Some(value) = backend_override { + for event in diagnostic_state::backend_override_events(&value) { + diagnostic_state::record(reporter, event); + } + } let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); let notice = launch.notice.take(); let options = eframe::NativeOptions { @@ -245,22 +293,40 @@ fn main() -> eframe::Result { let apple_events = apple_events::AppleEvents::install(); #[cfg(target_os = "macos")] let apple_events = &apple_events; + let desktop_reporter = reporter.clone(); let result = eframe::run_native( "EffectCraft", options, Box::new(move |cc| { EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx); match &cc.wgpu_render_state { - Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()), - None => log::info!("graphics: no wgpu device"), + Some(rs) => { + let info = rs.adapter.get_info(); + log::info!("graphics: {info:?}"); + let event = match info.backend { + eframe::wgpu::Backend::Noop => "actual GPU backend: noop", + eframe::wgpu::Backend::Vulkan => "actual GPU backend: vulkan", + eframe::wgpu::Backend::Metal => "actual GPU backend: metal", + eframe::wgpu::Backend::Dx12 => "actual GPU backend: dx12", + eframe::wgpu::Backend::Gl => "actual GPU backend: gl", + eframe::wgpu::Backend::BrowserWebGpu => "actual GPU backend: webgpu", + }; + diagnostic_state::record(&desktop_reporter, event); + } + None => diagnostic_state::record(&desktop_reporter, "actual GPU backend: no wgpu device"), } // This executable owns the shared device's handlers. Install them before // EffectCraft's compositor pipelines are built, without changing handlers from // inside Gpu::new. eframe has already constructed its presentation renderer here. let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx); + let observations = diagnostic_state::GpuObservations::new(&desktop_reporter); + let scoped_observed = observations.clone(); + gpu_failures.set_scoped_error_observer(move |error| scoped_observed.scoped(error)); if let Some(rs) = &cc.wgpu_render_state { + let errors_observed = observations.clone(); let errors = gpu_failures.clone(); rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| { + errors_observed.uncaptured(&error); let message = format!("uncaptured GPU error: {error}"); if errors.report(&message, false) { log::error!("{message}"); @@ -268,6 +334,7 @@ fn main() -> eframe::Result { })); let lost = gpu_failures.clone(); rs.device.set_device_lost_callback(move |reason, message| { + observations.device_lost(reason); lost.report(&format!("GPU device lost ({reason:?}): {message}"), true); }); } @@ -276,9 +343,6 @@ fn main() -> eframe::Result { // written on background threads (M13.14). session.check_footage_on_open = true; session.autosave.background = true; - // The disk cache's footage stamps too: an imported or opened project with thousands of - // sequence frames on a network share took half a minute to stamp. - session.disk_salt_background = true; // Settings, shortcut presets and the crash-recovery sentinel live in the platform // config directory. Agent-driven runs (`--control`) skip crash recovery. if let Some(dir) = effectcraft_host::config_dir() { @@ -401,6 +465,7 @@ fn main() -> eframe::Result { window: window::Tracker::new(opened, saved_window, control_port.is_none()), launch, drawn: false, + reporter: desktop_reporter, })) }), ); @@ -494,6 +559,7 @@ struct Desktop { launch: launch_guard::Launch, /// The first frame was drawn (logged once). drawn: bool, + reporter: crash_report::Shutdown, } impl eframe::App for Desktop { @@ -528,15 +594,20 @@ impl eframe::App for Desktop { } fn on_exit(&mut self) { - // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends - // before `eframe::run_native` returns (#341). - log::info!("the window is closing"); - self.launch.exited(); - self.window.save(self.app.session.config.as_deref()); - self.app.on_exit(); - effectcraft_host::stop_helpers(); - // Scripts in Scripts/Shutdown run with the project closed. - let _ = self.app.session.execute("file.runShutdownScripts", json!({})); + // macOS terminates after this hook without returning from run_native (#341). + // Other platforms keep the reporter armed until main sees complete event-loop teardown. + let reporter = cfg!(target_os = "macos").then(|| self.reporter.clone()); + if let Err(error) = crash_report::after_teardown(reporter.as_ref(), || { + log::info!("the window is closing"); + self.launch.exited(); + self.window.save(self.app.session.config.as_deref()); + self.app.on_exit(); + effectcraft_host::stop_helpers(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); + }) { + log::warn!("could not mark crash reporter clean after teardown: {error}"); + } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/contributors/people.toml b/contributors/people.toml index e9144f9ea..beb91a413 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -135,3 +135,6 @@ display_name = "Jonathan Cardoso" [people.nazar-gavrylyk] sync_github_name = true + +[people.andreaaazo] +sync_github_name = true diff --git a/crates/aep/Cargo.toml b/crates/aep/Cargo.toml index b1bf3f9fe..0523edb9b 100644 --- a/crates/aep/Cargo.toml +++ b/crates/aep/Cargo.toml @@ -10,18 +10,15 @@ rust-version.workspace = true workspace = true [dependencies] +craft-aep = { workspace = true } effectcraft-time = { workspace = true } effectcraft-color = { workspace = true } effectcraft-keyframe = { workspace = true } effectcraft-project = { workspace = true } effectcraft-effects = { workspace = true } +log = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } [dev-dependencies] serde_json = { workspace = true } - -# The tool's own tests run with the crate's (examples are only built by default). -[[example]] -name = "stubicc" -test = true diff --git a/crates/aep/README.md b/crates/aep/README.md index 965bea317..0e65d86f8 100644 --- a/crates/aep/README.md +++ b/crates/aep/README.md @@ -3,7 +3,9 @@ After Effects projects (`.aep`, `.aepx`) in one crate, the way `effectcraft-lottie` handles Lottie: - `container`: reads and writes the container as a lossless chunk tree, with a typed, read-only - view (below; its API is re-exported at the crate root); + view (below). This is `craft-aep` from [storytold/craft-libs](https://github.com/storytold/craft-libs), + re-exported at the crate root; its container tests and the `aepdump`/`stubicc` tools are there. + Only `effect_map` and `style_map` (After Effects effects and layer styles to EffectCraft's) are here; - `import`: maps the typed view into an EffectCraft project and reports everything it does not import ([`docs/aep-import.md`](../../docs/aep-import.md)); - `export`: writes an EffectCraft project as an `.aep` with every chunk built in code, and reports everything it does not export @@ -21,7 +23,9 @@ tree; pad bytes, odd bodies and the bytes after the container, an XMP packet, ar The XML form of a project (`.aepx`) is read into the same tree by `read_aepx` (a small bounded XML tokenizer, no dependency); `parse_any` picks the reader by content, so the typed view and the -importer take either form. The mapping is in `docs/aep-format.md`, section 1. +importer take either form. The mapping is in `docs/aep-format.md`, section 1. The importer, which +only reads, does not build the owned tree: it reads either form through the flat index +`TreeIndex`, an `.aep` in place and an `.aepx` decoded into one buffer first. Bodies that look like lists but are not chunk sequences (for example `LIST btdk`, raw text) stay opaque rather than failing the parse. Input is untrusted: sizes are checked against the remaining @@ -69,25 +73,30 @@ tests reach them only through `tests/it/common/mod.rs`, which hands a test its c of its own cut from the combined file; a test whose fixture is missing (or with the variable not set) prints `SKIPPED: fixture missing` and passes. -`tests/fixtures/synthetic.aep` (115 KB) is the one project in the repository: made in After Effects 2026 +`tests/fixtures/synthetic.aep` (115 KB) is one of the two projects in the repository: made in After Effects 2026 with the working space set to None (one composition, an image sequence of three 8x8 PNGs, a solid, a folder, a Levels and a Glow effect with expressions). Every After Effects project embeds the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk; the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file. +`tests/fixtures/text-synthetic.aep` is the other: made in After Effects 2026 the same way (working +space None, nothing changed after the save), one composition with two text layers that hold a +text animator's Stroke Opacity (static and keyed) and Source Text expressions (enabled and +disabled); `tests/it/import/text_synthetic.rs` reads it (#3170). + +`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs +named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a +Null parented to a Null (generated by `tests/fixtures/tools/ai-mask-3099.py` and +`ai-mask-3099.jsx`, scrubbed with `scrub.py` and `stubicc`). `tests/it/import/ai_mask.rs` checks +the import against After Effects' read-back, and the host's `vector_import` tests render it. ## Tools - `tests/fixtures/tools/`: the generator of the test projects (regenerates the corpus with After Effects; its README says how). -- `examples/aepdump.rs`: prints the chunk tree of a file (ids, sizes, short data, match names): - `cargo run -p effectcraft-aep --example aepdump -- file.aep`. -- `examples/stubicc.rs`: replaces every embedded ICC profile of a saved project by a stub that keeps - only the profile's description: the JSON `colorProfileData` of the working space (any white space - around the colon, `\/` escapes, quotes as `"`) in an `.aep` chunk or the `.aepx` text, and a raw - profile of an imported image (an `.aep` chunk, or the `bdata` hex of an `.aepx`): - `cargo run -p effectcraft-aep --example stubicc -- in.aep out.aep`. A profile it cannot replace - stops it before anything is written. The importer reads the working space's name from the - description only, so the project imports the same; no vendor colour data stays in the test - project. Check the result independently, not by the tool's count (the corpus repository has - `tools/aep/inspect-embedded.py`, which scans the raw bytes, base64 and hex). +- `aepdump` (prints the chunk tree of a file) and `stubicc` (replaces every embedded ICC profile of a + saved project by a stub) are examples of `craft-aep` in + [storytold/craft-libs](https://github.com/storytold/craft-libs): + `cargo run -p craft-aep --example stubicc -- in.aep out.aep` there. Check the result independently, + not by the tool's count (the corpus repository has `tools/aep/inspect-embedded.py`, which scans the + raw bytes, base64 and hex). diff --git a/crates/aep/examples/aepdump.rs b/crates/aep/examples/aepdump.rs deleted file mode 100644 index 2fe492696..000000000 --- a/crates/aep/examples/aepdump.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Developer tool: prints the chunk tree of an .aep (ids, sizes, short data), match names inline. -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use effectcraft_aep::{Body, Chunk}; - -/// Ends this developer tool with a message instead of a panic. -trait OrExit { - fn or_exit(self, what: &str) -> T; -} - -impl OrExit for Option { - fn or_exit(self, what: &str) -> T { - match self { - Some(v) => v, - None => exit(what), - } - } -} - -impl OrExit for Result { - fn or_exit(self, what: &str) -> T { - match self { - Ok(v) => v, - Err(e) => exit(&format!("{what}: {e:?}")), - } - } -} - -fn exit(msg: &str) -> ! { - eprintln!("{msg}"); - std::process::exit(1) -} - -fn show(c: &Chunk<'_>, depth: usize, max: usize, hex: usize) { - let ind = " ".repeat(depth); - let id = String::from_utf8_lossy(&c.id).to_string(); - match &c.body { - Body::Data(d) if &c.id == b"tdmn" => { - let n: String = d.iter().take_while(|b| **b != 0).map(|b| *b as char).collect(); - println!("{ind}tdmn {n}"); - } - Body::Data(d) => { - let h: String = d.iter().take(hex).map(|b| format!("{b:02x}")).collect(); - let a: String = d.iter().take(hex).map(|b| if (32..127).contains(b) { *b as char } else { '.' }).collect(); - println!("{ind}{id} [{}] {h} |{a}|", d.len()); - } - Body::Chunks(k) => { - println!("{ind}{id} (nested)"); - if depth < max { - for x in k { - show(x, depth + 1, max, hex); - } - } - } - Body::List { list_type, children } => { - println!("{ind}LIST {} ({} children)", String::from_utf8_lossy(list_type), children.len()); - if depth < max { - for x in children { - show(x, depth + 1, max, hex); - } - } - } - Body::OpaqueList { list_type, data } => println!("{ind}LIST {} opaque [{}]", String::from_utf8_lossy(list_type), data.len()), - } -} -fn main() { - let a: Vec = std::env::args().collect(); - let bytes = std::fs::read(a.get(1).or_exit("usage: see the comment at the top of this file")).or_exit("read"); - let max: usize = a.get(2).and_then(|s| s.parse().ok()).unwrap_or(4); - let hex: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(32); - let f = effectcraft_aep::parse(&bytes).or_exit("parse"); - for c in &f.chunks { - show(c, 0, max, hex); - } - println!("trailer {} bytes", f.trailer.len()); -} diff --git a/crates/aep/examples/stubicc.rs b/crates/aep/examples/stubicc.rs deleted file mode 100644 index 9438da06d..000000000 --- a/crates/aep/examples/stubicc.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Developer tool: replaces every embedded ICC profile of an .aep / .aepx by a stub that keeps only the -//! profile's description. Fixture hygiene: a saved project carries the vendor's working-space profile -//! (a few KB of colour data); the importer reads only the profile's name, the `desc` tag, so the stub -//! (a 128-byte header, one `desc` tag, no colour data, made here) imports identically. -//! -//! `cargo run -p effectcraft-aep --example stubicc -- in.aep out.aep` -//! -//! It handles: -//! - the JSON value `"colorProfileData": ""` in any chunk of an .aep and anywhere in the text -//! of an .aepx (white space around the colon, `\/` escapes and quotes written as `"` allowed); -//! - a raw ICC profile stored as a chunk of its own (the profile of an imported image), in an .aep -//! as the chunk's bytes and in an .aepx as its `bdata=""` attribute. -//! -//! A profile it cannot replace (no readable description, data that does not decode) stops the tool -//! before anything is written. Check the result independently (the corpus has -//! `tools/aep/inspect-embedded.py`), not by this tool's own count. -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use effectcraft_aep::{Body, Chunk}; - -const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -/// Ends this developer tool with a message instead of a panic. -trait OrExit { - fn or_exit(self, what: &str) -> T; -} - -impl OrExit for Option { - fn or_exit(self, what: &str) -> T { - match self { - Some(v) => v, - None => exit(what), - } - } -} - -impl OrExit for Result { - fn or_exit(self, what: &str) -> T { - match self { - Ok(v) => v, - Err(e) => exit(&format!("{what}: {e:?}")), - } - } -} - -fn exit(msg: &str) -> ! { - eprintln!("{msg}"); - std::process::exit(1) -} - -fn b64_decode(text: &[u8]) -> Option> { - let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32); - for &c in text.iter().filter(|c| **c != b'=') { - let v = u32::try_from(B64.iter().position(|b| *b == c)?).ok()?; - acc = (acc << 6 | v) & 0x00ff_ffff; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push(((acc >> bits) & 0xff) as u8); - } - } - Some(out) -} - -fn b64_encode(data: &[u8]) -> Vec { - let mut out = Vec::new(); - for ch in data.chunks(3) { - let at = |i: usize| u32::from(ch.get(i).copied().unwrap_or(0)); - let n = (at(0) << 16) | (at(1) << 8) | at(2); - for i in 0..4usize { - if i <= ch.len() { - out.push(B64.get(((n >> (18 - 6 * i)) & 63) as usize).copied().unwrap_or(b'A')); - } else { - out.push(b'='); - } - } - } - out -} - -fn be32(d: &[u8], at: usize) -> Option { - let b: [u8; 4] = d.get(at..at.checked_add(4)?)?.try_into().ok()?; - usize::try_from(u32::from_be_bytes(b)).ok() -} - -/// The profile description (ICC.1 `desc`: v2 ASCII or v4 `mluc`, first record). -fn description(icc: &[u8]) -> Option { - let count = be32(icc, 128)?; - let tag = (0..count.min(1024)).find_map(|i| { - let at = 132usize.checked_add(i.checked_mul(12)?)?; - (icc.get(at..at.checked_add(4)?)? == b"desc").then_some(at) - })?; - let off = be32(icc, tag.checked_add(4)?)?; - let body = icc.get(off..off.checked_add(be32(icc, tag.checked_add(8)?)?)?)?; - let text = match body.get(..4)? { - b"desc" => { - let n = be32(body, 8)?; - String::from_utf8_lossy(body.get(12..12usize.checked_add(n)?)?.split(|b| *b == 0).next()?).into_owned() - } - b"mluc" => { - let (n, at) = (be32(body, 20)?, be32(body, 24)?); - let units: Vec = body.get(at..at.checked_add(n)?)?.chunks(2).filter_map(|p| Some(u16::from_be_bytes([*p.first()?, *p.get(1)?]))).collect(); - String::from_utf16_lossy(&units) - } - _ => return None, - }; - Some(text.trim_end_matches('\0').trim().to_owned()) -} - -/// A profile with a header, one `desc` tag and no colour data. -fn stub(desc: &str) -> Option> { - let mut body = b"desc\0\0\0\0".to_vec(); - body.extend_from_slice(&u32::try_from(desc.len().checked_add(1)?).ok()?.to_be_bytes()); - body.extend_from_slice(desc.as_bytes()); - body.push(0); - body.extend_from_slice(&[0; 4 + 4 + 2 + 1 + 67]); - let mut p = vec![0u8; 128]; - for (at, v) in [(8, &0x0240_0000u32.to_be_bytes()[..]), (12, b"mntr"), (16, b"RGB "), (20, b"XYZ "), (36, b"acsp")] { - p.get_mut(at..at + v.len())?.copy_from_slice(v); - } - p.extend_from_slice(&1u32.to_be_bytes()); - p.extend_from_slice(b"desc"); - p.extend_from_slice(&144u32.to_be_bytes()); - p.extend_from_slice(&u32::try_from(body.len()).ok()?.to_be_bytes()); - p.extend_from_slice(&body); - let size = u32::try_from(p.len()).ok()?.to_be_bytes(); - p.get_mut(..4)?.copy_from_slice(&size); - Some(p) -} - -/// The JSON key, without its quotes. -const KEY: &[u8] = b"colorProfileData"; -/// How a JSON quote is written: as is (a `Utf8` chunk, the `` text of an .aepx) and as an -/// XML entity (JSON inside an attribute of an .aepx). -const QUOTES: [&[u8]; 2] = [b"\"", b"""]; - -/// What the tool did, counted. -#[derive(Default)] -struct Tally { - stubbed: usize, - /// Values too short to be a profile (no ICC header), kept as they are. - kept_short: usize, - /// Profiles that could not be replaced (no readable description, undecodable data): the tool - /// fails, so no profile is ever left behind silently. - failed: Vec, -} - -fn is_profile(d: &[u8]) -> bool { - d.len() > 132 && d.get(36..40) == Some(b"acsp") -} - -/// The stub for the bytes `d` of a value, or `None` (counted) when it holds no profile or the -/// profile has no readable description. -fn stub_of(d: &[u8], at: usize, tally: &mut Tally) -> Option> { - if !is_profile(d) { - tally.kept_short += 1; - return None; - } - let s = description(d).and_then(|t| stub(&t)); - if s.is_none() { - tally.failed.push(format!("profile at byte {at}: no readable description")); - } - s -} - -/// At the start of `t`: the key in quotes, optional white space, a colon, optional white space and -/// the opening quote of the value. Returns the length up to and including that quote, and the quote. -fn value_start(t: &[u8]) -> Option<(usize, &'static [u8])> { - let ws = |mut i: usize| { - while t.get(i).is_some_and(u8::is_ascii_whitespace) { - i += 1; - } - i - }; - QUOTES.into_iter().find_map(|q| { - let key_end = q.len().checked_mul(2)?.checked_add(KEY.len())?; - let quoted = t.get(..key_end)?; - if !(quoted.starts_with(q) && quoted.get(q.len()..q.len() + KEY.len())? == KEY && quoted.ends_with(q)) { - return None; - } - let colon = ws(key_end); - if t.get(colon) != Some(&b':') { - return None; - } - let open = ws(colon + 1); - t.get(open..)?.starts_with(q).then_some((open + q.len(), q)) - }) -} - -fn hex_decode(h: &[u8]) -> Option> { - if !h.len().is_multiple_of(2) { - return None; - } - h.chunks(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).ok()?, 16).ok()).collect() -} - -fn hex_encode(d: &[u8], upper: bool) -> Vec { - d.iter().flat_map(|b| if upper { format!("{b:02X}") } else { format!("{b:02x}") }.into_bytes()).collect() -} - -/// Replaces, in `text`, every `colorProfileData` JSON value (base64; `\/` escapes, white space -/// around the colon and quotes written as `"` allowed) and every .aepx `bdata=""` -/// attribute that holds a raw profile. -fn stub_text(text: &[u8], tally: &mut Tally) -> Vec { - const BDATA: &[u8] = b"bdata=\""; - let (mut out, mut i) = (Vec::with_capacity(text.len()), 0); - while let Some(&b) = text.get(i) { - let rest = text.get(i..).unwrap_or_default(); - if let Some((open, q)) = value_start(rest) - && let Some(len) = rest.get(open..).and_then(|v| v.windows(q.len()).position(|w| w == q)) - { - let raw = rest.get(open..open + len).unwrap_or_default(); - let unescaped: Vec = raw.iter().copied().filter(|c| *c != b'\\').collect(); - let replaced = match b64_decode(&unescaped) { - Some(d) => stub_of(&d, i, tally).map(|s| b64_encode(&s)), - None => { - tally.failed.push(format!("colorProfileData at byte {i}: not base64")); - None - } - }; - out.extend_from_slice(rest.get(..open).unwrap_or_default()); - match replaced { - Some(enc) => { - out.extend_from_slice(&enc); - tally.stubbed += 1; - } - None => out.extend_from_slice(raw), - } - i += open + len; - continue; - } - if rest.starts_with(BDATA) - && let Some(len) = rest.get(BDATA.len()..).and_then(|v| v.iter().position(|c| *c == b'"')) - { - let hex = rest.get(BDATA.len()..BDATA.len() + len).unwrap_or_default(); - let replaced = hex_decode(hex).filter(|d| is_profile(d)).and_then(|d| stub_of(&d, i, tally)); - out.extend_from_slice(BDATA); - match replaced { - Some(s) => { - out.extend_from_slice(&hex_encode(&s, hex.iter().any(u8::is_ascii_uppercase))); - tally.stubbed += 1; - } - None => out.extend_from_slice(hex), - } - i += BDATA.len() + len; - continue; - } - out.push(b); - i += 1; - } - out -} - -fn walk(chunks: &mut [Chunk<'_>], tally: &mut Tally) { - for c in chunks.iter_mut() { - // Only a data chunk is rewritten as one: an opaque `LIST` also has bytes, but replacing it by - // a data chunk would drop its list type. - let data = if let Body::Data(d) = &c.body { Some(d.to_vec()) } else { None }; - match data { - Some(d) if d.windows(KEY.len()).any(|w| w == KEY) => { - let new = stub_text(&d, tally); - *c = Chunk::data(c.id, new); - } - Some(d) if is_profile(&d) => { - if let Some(s) = stub_of(&d, 0, tally) { - *c = Chunk::data(c.id, s); - tally.stubbed += 1; - } - } - _ => { - if let Body::List { children, .. } | Body::Chunks(children) = &mut c.body { - walk(children, tally); - } - } - } - } -} - -fn main() { - let args: Vec = std::env::args().collect(); - let (Some(input), Some(output)) = (args.get(1), args.get(2)) else { exit("usage: stubicc ") }; - let bytes = std::fs::read(input).or_exit("read the input"); - let mut tally = Tally::default(); - let out = if input.ends_with(".aepx") { - stub_text(&bytes, &mut tally) - } else { - let mut file = effectcraft_aep::parse(&bytes).or_exit("parse the input"); - walk(&mut file.chunks, &mut tally); - effectcraft_aep::write(&file).or_exit("write the output") - }; - if !tally.failed.is_empty() { - exit(&format!("nothing written: {} profile(s) could not be replaced:\n{}", tally.failed.len(), tally.failed.join("\n"))); - } - std::fs::write(output, out).or_exit("write the output"); - println!("stubbed {} profile(s); kept {} short value(s) that hold no profile", tally.stubbed, tally.kept_short); -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - fn profile(desc: &str) -> Vec { - // A profile with colour data after the description (like a real one): the stub of `desc` - // plus 3000 bytes, its size field updated. - let mut p = stub(desc).unwrap(); - p.extend(std::iter::repeat_n(0x5a, 3000)); - let size = u32::try_from(p.len()).unwrap().to_be_bytes(); - p[..4].copy_from_slice(&size); - p - } - - fn stubbed(text: &[u8]) -> (Vec, Tally) { - let mut tally = Tally::default(); - (stub_text(text, &mut tally), tally) - } - - #[test] - fn json_values_in_every_spelling_are_replaced() { - let b64 = String::from_utf8(b64_encode(&profile("sRGB IEC61966-2.1"))).unwrap(); - let want = String::from_utf8(b64_encode(&stub("sRGB IEC61966-2.1").unwrap())).unwrap(); - for (before, after) in [ - (format!("{{\"colorProfileData\":\"{b64}\"}}"), format!("{{\"colorProfileData\":\"{want}\"}}")), - (format!("{{\"colorProfileData\" : \n\t\"{b64}\"}}"), format!("{{\"colorProfileData\" : \n\t\"{want}\"}}")), - (format!("{{\"colorProfileData\":\"{}\"}}", b64.replace('/', "\\/")), format!("{{\"colorProfileData\":\"{want}\"}}")), - (format!("{{"colorProfileData": "{b64}"}}"), format!("{{"colorProfileData": "{want}"}}")), - ] { - let (out, tally) = stubbed(before.as_bytes()); - assert_eq!(String::from_utf8(out).unwrap(), after); - assert_eq!((tally.stubbed, tally.failed.len()), (1, 0)); - } - } - - #[test] - fn a_raw_profile_in_aepx_hex_is_replaced_and_other_hex_kept() { - let hex = String::from_utf8(hex_encode(&profile("Rec.709 Gamma 2.4"), false)).unwrap(); - let want = String::from_utf8(hex_encode(&stub("Rec.709 Gamma 2.4").unwrap(), false)).unwrap(); - let text = format!("", "ab".repeat(200)); - let (out, tally) = stubbed(text.as_bytes()); - assert_eq!(String::from_utf8(out).unwrap(), format!("", "ab".repeat(200))); - assert_eq!((tally.stubbed, tally.failed.len()), (1, 0)); - // Upper-case hex stays upper case. - let (out, _) = stubbed(format!("bdata=\"{}\"", hex.to_uppercase()).as_bytes()); - assert_eq!(String::from_utf8(out).unwrap(), format!("bdata=\"{}\"", want.to_uppercase())); - } - - #[test] - fn a_profile_that_cannot_be_replaced_is_a_failure_not_a_silent_copy() { - // An ICC header whose tag table has no `desc`. - let mut p = profile("x"); - p[132..136].copy_from_slice(b"wtpt"); - let (_, tally) = stubbed(format!("\"colorProfileData\":\"{}\"", String::from_utf8(b64_encode(&p)).unwrap()).as_bytes()); - assert_eq!((tally.stubbed, tally.failed.len()), (0, 1)); - let (_, tally) = stubbed(b"\"colorProfileData\":\"not*base64\""); - assert_eq!(tally.failed.len(), 1); - // A short value holds no profile: kept and counted, not a failure. - let (out, tally) = stubbed(b"\"colorProfileData\":\"AAAA\""); - assert_eq!((out.as_slice(), tally.kept_short, tally.failed.len()), (&b"\"colorProfileData\":\"AAAA\""[..], 1, 0)); - } -} diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index b6c3b4490..d9dbcfb14 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -1,59 +1,7 @@ -//! After Effects project (`.aep`) container reader and writer. -//! -//! An `.aep` is a `RIFX` container (big-endian RIFF) with form type `Egg!`. This crate reads it -//! into a lossless chunk tree ([`AepFile`]) and writes it back byte for byte. The XML form of a -//! project (`.aepx`) is read into the same tree by [`read_aepx`]; [`parse_any`] picks the reader -//! by content. See -//! `docs/aep-format.md` for the observed format and how it was established (clean room: only -//! files we generated ourselves, one change per file). -//! -//! [`Project::new`] opens a typed, read-only view over the tree: items, compositions -//! ([`CompHeader`]), layers ([`LayerHeader`], [`Transform`], effects, masks, text documents, -//! markers), properties, keyframes and footage. Only fields whose layout was verified against the -//! values the test files were made with are decoded; every accessor returns `None` (or an empty -//! list) on damaged data instead of failing. +//! The `.aep` / `.aepx` container and its typed view come from `craft-aep`; only the tables that map +//! After Effects effects and layer styles onto EffectCraft's are kept here. -mod aepx; -mod arbitrary; -mod bytes; -mod camera; -mod chunk; pub mod effect_map; -mod effects; -mod error; -mod footage; -mod header; -mod keys; -mod masks; -mod matte; -mod read; -mod settings; -mod shapes; pub mod style_map; -mod textdoc; -mod typed; -mod values; -mod version; -mod write; -pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits}; -pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut}; -pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM}; -pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk}; -pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue}; -pub use error::AepError; -pub use footage::{Footage, SequenceInfo}; -pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer}; -pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey}; -pub use masks::{Mask, MaskAtom, MaskMode, PathVertex, ShapePath}; -pub use matte::{MatteMode, TrackMatte}; -pub use read::{Limits, parse, parse_with_limits}; -pub use settings::{ProjectSettings, TimeDisplay, WorkingSpace}; -pub use shapes::{GradientType, PolystarType, ShapeDetail, ShapeGroup, ShapeItem, Stroke, StrokeCap, StrokeJoin}; -pub use textdoc::{ - AutoKern, Caps, CueType, FontBaseline, Justification, KernRun, Marker, ParagraphRun, StyleRun, TextBox, TextDocument, TextValue, parse_text_dict, -}; -pub use typed::{Comp, Item, ItemKind, KeyLayout, KeyList, Keyframe, Layer, ListHeader, Project, PropEntry, PropGroup, PropNode, Property, TimeBase}; -pub use values::{Orientation, OrientationKey, TimeRemap, TimeRemapKey, Tracked, Transform, ValueSource}; -pub use version::AeVersion; -pub use write::{write, write_to}; +pub use craft_aep::*; diff --git a/crates/aep/src/container/aepx.rs b/crates/aep/src/container/aepx.rs deleted file mode 100644 index a45cbae1d..000000000 --- a/crates/aep/src/container/aepx.rs +++ /dev/null @@ -1,640 +0,0 @@ -//! Reading the XML form of a project (`.aepx`) into the same chunk tree as an `.aep`. -//! -//! An `.aepx` is the `RIFX` chunk tree written as XML: one element per chunk, named by the chunk -//! id (or the list type of a `LIST`), with the body as hex in a `bdata` attribute. A few chunks -//! are written as text instead (`Utf8` as ``, the numbers `numS` and `ppSn`, the footage -//! alias `alas` as ``), and the XMP packet that follows the container of an `.aep` -//! is the element ``. The mapping, with the cases it was found on, is in -//! `docs/aep-format.md` (section 1.1). -//! -//! The reader is a small XML tokenizer for exactly that subset: elements, attributes, text, the -//! five predefined entities and character references, CDATA, comments and processing -//! instructions. A document type declaration is refused (no entity expansion). It runs without -//! recursion; the nesting depth and the element count are bounded by [`Limits`], and everything -//! it allocates is bounded by the size of the input. - -use std::borrow::Cow; - -use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS}; -use crate::container::error::AepError; -use crate::container::read::{Limits, parse}; - -/// The root element of an `.aepx`. -const ROOT: &[u8] = b"AfterEffectsProject"; -/// The element that holds the XMP packet (the trailer of an `.aep`). -const XMP: &[u8] = b"ProjectXMPMetadata"; -/// The element of a `Utf8` chunk. -const STRING: &[u8] = b"string"; -/// The element of an `alas` chunk (footage alias), written as attributes instead of JSON. -const FILE_REFERENCE: &[u8] = b"fileReference"; - -/// List types whose body is not a chunk sequence: the `.aepx` writes them like data chunks (with -/// `bdata`), the `.aep` as an opaque `LIST`. -const OPAQUE_LIST_TYPES: [[u8; 4]; 1] = [*b"btdk"]; - -/// True when `data` looks like an `.aepx`: optional UTF-8 byte order mark and white space, then -/// an XML declaration or the `AfterEffectsProject` root element. -pub fn is_aepx(data: &[u8]) -> bool { - let rest = data.strip_prefix(&[0xef, 0xbb, 0xbf][..]).unwrap_or(data); - let start = rest.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(rest.len()); - let rest = rest.get(start..).unwrap_or(&[]); - rest.starts_with(b" Result, AepError> { - if is_aepx(data) { read_aepx(data) } else { parse(data) } -} - -/// Reads an `.aepx` with the default [`Limits`] into the chunk tree an `.aep` of the same project -/// gives. -/// -/// # Errors -/// [`AepError::Xml`] for input that is not well-formed XML of the `.aepx` subset (bad UTF-8, an -/// unknown entity, an unclosed or mismatched tag, invalid or odd-length hex, a number that does -/// not parse, a document type declaration, a root element other than `AfterEffectsProject`); -/// [`AepError::TooDeep`] / [`AepError::TooManyChunks`] beyond the limits. -pub fn read_aepx(data: &[u8]) -> Result, AepError> { - read_aepx_with_limits(data, Limits::default()) -} - -/// Like [`read_aepx`] with explicit [`Limits`]: `max_depth` bounds the element nesting (the root -/// element's children are depth 1), `max_chunks` the number of elements. -/// -/// # Errors -/// See [`read_aepx`]. -pub fn read_aepx_with_limits(data: &[u8], limits: Limits) -> Result, AepError> { - let text = match std::str::from_utf8(data) { - Ok(t) => t.as_bytes(), - Err(e) => return Err(xml(e.valid_up_to(), "the file is not valid UTF-8")), - }; - Reader { data: text, pos: 0, limits, count: 0, stack: Vec::new(), top: None, trailer: None }.run() -} - -fn xml(offset: usize, what: &'static str) -> AepError { - AepError::Xml { offset, what } -} - -/// What an open element becomes when it closes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Kind { - /// The root element: its children are the top-level chunks. - Root, - /// A chunk with this id (or a `LIST` with this list type). - Chunk([u8; 4]), - /// A `Utf8` chunk. - Utf8, - /// An `alas` chunk built from the attributes. - Alias, - /// The XMP packet (only directly below the root). - Xmp, - /// An element without a chunk counterpart (a name that is no chunk id); skipped with its content. - Skip, -} - -struct Frame { - kind: Kind, - /// Offset of the start tag (for errors). - at: usize, - /// The name as written, to match the end tag. - name: (usize, usize), - /// The decoded `bdata` attribute. - bdata: Option>, - /// The attributes of a `fileReference`, in document order. - attrs: Vec<(String, String)>, - /// Collected text (only for `string` and the XMP packet). - text: Vec, - children: Vec>, -} - -struct Reader<'d> { - data: &'d [u8], - pos: usize, - limits: Limits, - count: usize, - stack: Vec, - /// The top-level chunks once the root element has closed. - top: Option>>, - trailer: Option>, -} - -impl Reader<'_> { - fn run(mut self) -> Result, AepError> { - if self.data.starts_with(&[0xef, 0xbb, 0xbf]) { - self.pos = 3; - } - while self.pos < self.data.len() { - if self.rest().starts_with(b"<") { - self.markup()?; - } else { - self.text()?; - } - } - if let Some(open) = self.stack.last() { - return Err(xml(open.at, "an element is not closed before the end of the file")); - } - let chunks = self.top.ok_or_else(|| xml(self.data.len(), "no AfterEffectsProject root element"))?; - Ok(AepFile { chunks, trailer: Cow::Owned(self.trailer.unwrap_or_default()) }) - } - - fn rest(&self) -> &[u8] { - self.data.get(self.pos..).unwrap_or(&[]) - } - - /// The offset of `needle` at or after `from`. - fn find(&self, from: usize, needle: &[u8]) -> Option { - let hay = self.data.get(from..)?; - if needle.is_empty() || hay.len() < needle.len() { - return None; - } - let first = *needle.first()?; - let mut i = 0; - while let Some(p) = hay.get(i..)?.iter().position(|b| *b == first) { - let at = i.checked_add(p)?; - if hay.get(at..)?.starts_with(needle) { - return from.checked_add(at); - } - i = at.checked_add(1)?; - } - None - } - - /// Skips to just after `close`, which must follow. - fn skip_past(&mut self, close: &[u8], what: &'static str) -> Result { - let at = self.find(self.pos, close).ok_or(xml(self.pos, what))?; - self.pos = at.saturating_add(close.len()); - Ok(at) - } - - fn markup(&mut self) -> Result<(), AepError> { - let rest = self.rest(); - if rest.starts_with(b"", "a processing instruction is not closed")?; - } else if rest.starts_with(b"", "a comment is not closed")?; - } else if rest.starts_with(b"", "a CDATA section is not closed")?; - let content = self.data.get(start..end).unwrap_or(&[]); - let at = start; - match self.stack.last_mut() { - Some(frame) => { - if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) { - frame.text.extend_from_slice(content); - } - } - None => return Err(xml(at, "CDATA outside the root element")), - } - } else if rest.starts_with(b" bool { - !(b.is_ascii_whitespace() || matches!(b, b'<' | b'>' | b'/' | b'=' | b'"' | b'\'' | b'&')) - } - - fn name(&mut self) -> Result<(usize, usize), AepError> { - let start = self.pos; - let len = self.rest().iter().position(|b| !Self::is_name_byte(*b)).unwrap_or(self.rest().len()); - if len == 0 { - return Err(xml(start, "a tag or attribute has no name")); - } - self.pos = start.saturating_add(len); - Ok((start, self.pos)) - } - - fn skip_ws(&mut self) { - let n = self.rest().iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(self.rest().len()); - self.pos = self.pos.saturating_add(n); - } - - fn slice(&self, (a, b): (usize, usize)) -> &[u8] { - self.data.get(a..b).unwrap_or(&[]) - } - - fn start_tag(&mut self) -> Result<(), AepError> { - let at = self.pos; - self.pos = self.pos.saturating_add(1); - let name = self.name()?; - let mut attrs: Vec<((usize, usize), Vec)> = Vec::new(); - let self_closing = loop { - self.skip_ws(); - let rest = self.rest(); - if rest.starts_with(b"/>") { - self.pos = self.pos.saturating_add(2); - break true; - } - if rest.starts_with(b">") { - self.pos = self.pos.saturating_add(1); - break false; - } - if rest.is_empty() { - return Err(xml(at, "a start tag is not closed")); - } - let key = self.name()?; - self.skip_ws(); - if !self.rest().starts_with(b"=") { - return Err(xml(self.pos, "an attribute has no value")); - } - self.pos = self.pos.saturating_add(1); - self.skip_ws(); - let quote = *self.rest().first().ok_or(xml(self.pos, "a start tag is not closed"))?; - if quote != b'"' && quote != b'\'' { - return Err(xml(self.pos, "an attribute value is not quoted")); - } - let vstart = self.pos.saturating_add(1); - let vend = self.find(vstart, &[quote]).ok_or(xml(vstart, "an attribute value is not closed"))?; - let raw = self.data.get(vstart..vend).unwrap_or(&[]); - if raw.contains(&b'<') { - return Err(xml(vstart, "`<` inside an attribute value")); - } - let mut value = Vec::new(); - decode_entities(raw, vstart, &mut value)?; - self.pos = vend.saturating_add(1); - attrs.push((key, value)); - }; - let kind = self.kind_of(name, at)?; - if kind != Kind::Root { - self.count = self.count.saturating_add(1); - if self.count > self.limits.max_chunks { - return Err(AepError::TooManyChunks); - } - // The root's children are depth 1, like the top-level chunks of an `.aep`. - if self.stack.len() > self.limits.max_depth { - return Err(AepError::TooDeep); - } - } - let mut frame = Frame { kind, at, name, bdata: None, attrs: Vec::new(), text: Vec::new(), children: Vec::new() }; - if matches!(kind, Kind::Chunk(_)) { - if let Some((key, value)) = attrs.iter().find(|(k, _)| self.slice(*k) == b"bdata") { - frame.bdata = Some(decode_hex(value, key.1)?); - } - } else if kind == Kind::Alias { - frame.attrs = attrs.iter().map(|(k, v)| (String::from_utf8_lossy(self.slice(*k)).into_owned(), String::from_utf8_lossy(v).into_owned())).collect(); - } - self.stack.push(frame); - if self_closing { - self.close()?; - } - Ok(()) - } - - /// Classifies an element by its name and where it is. - fn kind_of(&self, name: (usize, usize), at: usize) -> Result { - let n = self.slice(name); - let parent = match self.stack.last() { - None => { - if self.top.is_some() { - return Err(xml(at, "a second root element")); - } - if n != ROOT { - return Err(xml(at, "the root element is not AfterEffectsProject")); - } - return Ok(Kind::Root); - } - Some(p) => p.kind, - }; - Ok(match parent { - Kind::Skip | Kind::Utf8 | Kind::Xmp | Kind::Alias => Kind::Skip, - Kind::Root | Kind::Chunk(_) => { - if n == STRING { - Kind::Utf8 - } else if n == FILE_REFERENCE { - Kind::Alias - } else if n == XMP && parent == Kind::Root { - Kind::Xmp - } else if let Some(id) = chunk_id(n) { - Kind::Chunk(id) - } else { - Kind::Skip - } - } - }) - } - - fn end_tag(&mut self) -> Result<(), AepError> { - let at = self.pos; - self.pos = self.pos.saturating_add(2); - let name = self.name()?; - self.skip_ws(); - if !self.rest().starts_with(b">") { - return Err(xml(at, "an end tag is not closed")); - } - self.pos = self.pos.saturating_add(1); - let open = self.stack.last().ok_or(xml(at, "an end tag without a start tag"))?; - if self.slice(open.name) != self.slice(name) { - return Err(xml(at, "an end tag does not match its start tag")); - } - self.close() - } - - /// Closes the innermost element and hands its chunk to the parent. - fn close(&mut self) -> Result<(), AepError> { - let frame = self.stack.pop().ok_or(xml(self.pos, "an end tag without a start tag"))?; - let chunk = match frame.kind { - Kind::Root => { - self.top = Some(frame.children); - return Ok(()); - } - Kind::Skip => return Ok(()), - Kind::Xmp => { - if self.trailer.is_none() { - self.trailer = Some(frame.text); - } - return Ok(()); - } - Kind::Utf8 => Chunk::data(*b"Utf8", frame.text), - Kind::Alias => Chunk::data(*b"alas", alias_json(&frame.attrs).into_bytes()), - Kind::Chunk(id) => chunk_of(id, frame)?, - }; - if let Some(parent) = self.stack.last_mut() { - parent.children.push(chunk); - } - Ok(()) - } - - fn text(&mut self) -> Result<(), AepError> { - let start = self.pos; - let len = self.rest().iter().position(|b| *b == b'<').unwrap_or(self.rest().len()); - let end = start.saturating_add(len); - self.pos = end; - let raw = self.data.get(start..end).unwrap_or(&[]); - match self.stack.last_mut() { - Some(frame) if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) => decode_entities(raw, start, &mut frame.text), - Some(_) => Ok(()), - None if raw.iter().all(u8::is_ascii_whitespace) => Ok(()), - None => Err(xml(start, "text outside the root element")), - } - } -} - -/// The chunk id of an element name: 1 to 4 printable ASCII bytes, padded with spaces (`Pin` is -/// the list type `Pin `). -fn chunk_id(name: &[u8]) -> Option<[u8; 4]> { - if name.is_empty() || name.len() > 4 || !name.iter().all(|b| b.is_ascii_graphic()) { - return None; - } - let mut id = [b' '; 4]; - for (d, s) in id.iter_mut().zip(name) { - *d = *s; - } - Some(id) -} - -/// The chunk of a closed element named by a chunk id. -fn chunk_of(id: [u8; 4], frame: Frame) -> Result, AepError> { - if let Some(bytes) = frame.bdata { - if OPAQUE_LIST_TYPES.contains(&id) { - let pad = if bytes.len() % 2 == 1 { Some(0) } else { None }; - return Ok(Chunk { id: LIST, body: Body::OpaqueList { list_type: id, data: Cow::Owned(bytes) }, pad }); - } - return Ok(Chunk::data(id, bytes)); - } - // Numbers written as text: `0` and `150.000000000`. - if &id == b"numS" || &id == b"ppSn" { - let text = match frame.children.as_slice() { - [only] if only.is(b"Utf8") => only.bytes().unwrap_or(&[]), - _ => return Err(xml(frame.at, "a numS or ppSn element without its number")), - }; - let text = std::str::from_utf8(text).map_err(|_| xml(frame.at, "a number is not text"))?.trim(); - let body = if &id == b"numS" { - // Only `0` is sampled, so whether the field is signed is open: accept both readings of 4 bytes. - let n = text.parse::().ok().filter(|n| (i64::from(i32::MIN)..=i64::from(u32::MAX)).contains(n)); - let n = n.ok_or(xml(frame.at, "numS is not a 32-bit integer"))?; - (n as u32).to_be_bytes().to_vec() - } else { - text.parse::().map_err(|_| xml(frame.at, "ppSn is not a number"))?.to_be_bytes().to_vec() - }; - return Ok(Chunk::data(id, body)); - } - if NESTED_IDS.contains(&id) { - if frame.children.is_empty() { - return Ok(Chunk::data(id, Vec::new())); - } - return Ok(Chunk { id, body: Body::Chunks(frame.children), pad: None }); - } - Ok(Chunk::list(id, frame.children)) -} - -/// Appends `raw` with the predefined entities and character references decoded. -fn decode_entities(raw: &[u8], offset: usize, out: &mut Vec) -> Result<(), AepError> { - let mut i = 0; - while let Some(rest) = raw.get(i..) { - let Some(amp) = rest.iter().position(|b| *b == b'&') else { - out.extend_from_slice(rest); - break; - }; - out.extend_from_slice(rest.get(..amp).unwrap_or(&[])); - let at = offset.saturating_add(i).saturating_add(amp); - let after = rest.get(amp.saturating_add(1)..).unwrap_or(&[]); - let semi = after.iter().take(12).position(|b| *b == b';').ok_or(xml(at, "an entity is not terminated"))?; - let name = after.get(..semi).unwrap_or(&[]); - let ch = match name { - b"amp" => '&', - b"lt" => '<', - b"gt" => '>', - b"quot" => '"', - b"apos" => '\'', - _ => { - let code = if let Some(hex) = name.strip_prefix(b"#x").or_else(|| name.strip_prefix(b"#X")) { - std::str::from_utf8(hex).ok().and_then(|h| u32::from_str_radix(h, 16).ok()) - } else if let Some(dec) = name.strip_prefix(b"#") { - std::str::from_utf8(dec).ok().and_then(|d| d.parse::().ok()) - } else { - return Err(xml(at, "an unknown entity")); - }; - code.filter(|c| *c != 0).and_then(char::from_u32).ok_or(xml(at, "an invalid character reference"))? - } - }; - let mut buf = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); - i = i.saturating_add(amp).saturating_add(semi).saturating_add(2); - } - Ok(()) -} - -/// Decodes a `bdata` value (hex digits, two per byte). -fn decode_hex(text: &[u8], offset: usize) -> Result, AepError> { - if text.len() % 2 == 1 { - return Err(xml(offset, "bdata has an odd number of hex digits")); - } - let digit = |b: u8| match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - }; - let (pairs, _) = text.as_chunks::<2>(); - pairs - .iter() - .map(|[hi, lo]| digit(*hi).zip(digit(*lo)).map(|(h, l)| (h << 4) | l)) - .collect::>>() - .ok_or(xml(offset, "bdata holds a character that is not a hex digit")) -} - -/// The JSON body of an `alas` chunk from the attributes of a `fileReference`, keys in document -/// order (alphabetical in every sample, as in the `.aep`): the two `ascendcount_*` values as -/// numbers, `platform` `Win` as `1`, `target_is_folder` `0` / `1` as `false` / `true`, everything -/// else as a string. -fn alias_json(attrs: &[(String, String)]) -> String { - let mut out = String::from("{"); - for (i, (key, value)) in attrs.iter().enumerate() { - if i > 0 { - out.push(','); - } - json_str(&mut out, key); - out.push(':'); - let number = value.parse::().ok(); - match (key.as_str(), value.as_str()) { - ("ascendcount_base" | "ascendcount_target", _) if number.is_some() => out.push_str(value), - ("platform", "Win") => out.push('1'), - ("platform", _) if number.is_some() => out.push_str(value), - ("target_is_folder", "0" | "false") => out.push_str("false"), - ("target_is_folder", "1" | "true") => out.push_str("true"), - _ => json_str(&mut out, value), - } - } - out.push('}'); - out -} - -fn json_str(out: &mut String, s: &str) { - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))), - c => out.push(c), - } - } - out.push('"'); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn doc(body: &str) -> String { - format!("\n{body}\n") - } - - #[test] - fn maps_elements_to_chunks() { - let x = doc(concat!( - "\n", - "A & BA-3", - "150.000000000x", - "]]>" - )); - let f = read_aepx(x.as_bytes()).unwrap(); - assert_eq!(f.trailer.as_ref(), b""); - let ids: Vec<_> = f.chunks.iter().map(|c| (c.id, c.list_type())).collect(); - assert_eq!( - ids, - vec![ - (*b"head", None), - (LIST, Some(*b"Pin ")), - (LIST, Some(*b"Pefl")), - (*b"tdsn", None), - (*b"numS", None), - (*b"ppSn", None), - (LIST, Some(*b"btdk")) - ] - ); - assert_eq!(f.chunks[1].children()[0].bytes(), Some(&[1u8][..])); - assert_eq!(f.chunks[1].children()[0].pad, Some(0)); - assert_eq!(f.chunks[3].children()[0].bytes(), Some(&b"A & BA"[..])); - assert_eq!(f.chunks[4].bytes(), Some(&(-3i32).to_be_bytes()[..])); - assert_eq!(f.chunks[5].bytes(), Some(&150f64.to_be_bytes()[..])); - assert_eq!(f.chunks[6].bytes(), Some(&b"ABC"[..])); - // The tree writes as a valid `.aep` that parses back to itself. - let bin = crate::container::write(&f).unwrap(); - assert_eq!(parse(&bin).unwrap(), f); - } - - #[test] - fn file_reference_becomes_alias_json() { - let x = doc(concat!( - "" - )); - let f = read_aepx(x.as_bytes()).unwrap(); - let alas = f.chunks[0].children()[0].bytes().unwrap(); - assert_eq!( - std::str::from_utf8(alas).unwrap(), - r#"{"ascendcount_base":1,"ascendcount_target":1,"fullpath":"C:\\a\\\"b.png","platform":1,"server_name":"N","server_volume_name":"V","target_is_folder":false}"# - ); - } - - #[test] - fn nums_takes_signed_and_unsigned_32_bit_values() { - for (text, bytes) in [("-2147483648", [0x80, 0, 0, 0]), ("4294967295", [0xff; 4]), (" 7 ", [0, 0, 0, 7])] { - let f = read_aepx(doc(&format!("{text}")).as_bytes()).unwrap(); - assert_eq!(f.chunks[0].bytes(), Some(&bytes[..]), "{text}"); - } - for text in ["-2147483649", "4294967296", "1.5"] { - assert!(read_aepx(doc(&format!("{text}")).as_bytes()).is_err(), "{text}"); - } - } - - #[test] - fn detection() { - assert!(is_aepx(b"\xef\xbb\xbf ")); - assert!(is_aepx(b"")); - assert!(!is_aepx(b"RIFX\0\0\0\x04Egg!")); - assert!(parse_any(b"RIFX\0\0\0\x04Egg!").is_ok()); - assert!(parse_any(doc("").as_bytes()).is_ok()); - } - - #[test] - fn bad_input_is_an_error() { - let bad = [ - doc(""), - doc(""), - doc(" "), - doc("�"), - doc("&"), - doc(""), - doc(""), - doc("x"), - doc(""), - doc(""), - doc(""), - "]>".into(), - "".into(), - "".into(), - "".into(), - "junk".into(), - "]]>"), 2); assert_eq!(super::import::json_depth(br#"{"a": "[[[{{", "b": [[1], {"c": "\\\"["}]}"#), 3); } + +/// A hostile project: footage in a folder whose parents loop back to it. Export walked the +/// folder's parents recursively and never reached the cache insert, so the stack ran out. +#[test] +fn export_survives_a_folder_parent_cycle() { + let (mut p, cid) = ae_project(); + let a = p.add_item("A", effectcraft_color::Label::None, None, ItemKind::Folder); + let b = p.add_item("B", effectcraft_color::Label::None, Some(a), ItemKind::Folder); + p.items.get_mut(&a).unwrap().parent = Some(b); + let footage: Vec = p.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.id).collect(); + for f in footage { + p.items.get_mut(&f).unwrap().parent = Some(a); + } + let out = export(&p, cid, &ExportOptions::default(), &HashMap::new()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("contains itself")), "{:?}", out.warnings); +} + +/// Precomps that use each other, and a chain of comps that each use the next one twice: the +/// audio check recursed forever on the first and 2^depth times on the second. +#[test] +fn export_audio_check_ends_on_precomp_cycles_and_shared_chains() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let comp = || Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + // A chain of 60 comps, each using the next twice; the last one is silent footage. + let mut next = { + let mut c = comp(); + let l = effectcraft_project::build::layer(&mut p, &c, "a", LayerSource::Footage { item: a }, (W, H), None); + c.layers.push(l); + comp_item(&mut p, "Leaf", c) + }; + for i in 0..60 { + let mut c = comp(); + for n in 0..2 { + let l = effectcraft_project::build::layer(&mut p, &c, &format!("use {n}"), LayerSource::Comp { item: next }, (W, H), None); + c.layers.push(l); + } + next = comp_item(&mut p, &format!("Chain {i}"), c); + } + let started = std::time::Instant::now(); + export(&p, next, &ExportOptions::default(), &HashMap::new()).unwrap(); + assert!(started.elapsed() < std::time::Duration::from_secs(30), "{:?}", started.elapsed()); + // Two comps nesting each other. + let x = comp_item(&mut p, "X", comp()); + let mut y = comp(); + let l = effectcraft_project::build::layer(&mut p, &y, "x", LayerSource::Comp { item: x }, (W, H), None); + y.layers.push(l); + let y = comp_item(&mut p, "Y", y); + let mut xc = comp(); + let l = effectcraft_project::build::layer(&mut p, &xc, "y", LayerSource::Comp { item: y }, (W, H), None); + xc.layers.push(l); + if let Some(ItemKind::Comp(c)) = p.items.get_mut(&x).map(|i| &mut i.kind) { + *c = Arc::new(xc); + } + export(&p, x, &ExportOptions::default(), &HashMap::new()).unwrap(); +} + +/// A flat edit with many clips and many transitions: each clip finds its own transitions (it +/// scanned every transition of the track per clip), and they still extend and fade it. +#[test] +fn import_finds_each_clips_transitions_without_scanning_the_track() { + let mut p = fp::Project::new("Flat"); + let a = media(&mut p, "a.png", "/media/a.mov", filmcraft_media::MediaKind::Movie, true, false, None); + let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() }; + let main = p.new_sequence("Main", settings, 1, 0, None); + const N: usize = 500; + let mut items = Vec::with_capacity(N); + let mut transitions = Vec::with_capacity(N); + for i in 0..N { + let ti = item(&mut p, a, fp::TrackKind::Video, i as f64 * 0.4, 0.0, 0.4); + if let Some(prev) = items.last().map(|x: &fp::TrackItem| x.id) { + transitions.push(fp::Transition { + id: fp::TransitionId(p.alloc_id()), + effect: fp::find_effect("cross_dissolve").unwrap().instance(), + start: secs(i as f64 * 0.4 - 0.08), + duration: secs(0.16), + from: Some(prev), + to: Some(ti.id), + align: fp::TransitionAlign::CenterAtCut, + reverse: false, + }); + } + items.push(ti); + } + let seq = p.sequence_mut(main).unwrap(); + seq.video_tracks[0].items = items; + seq.video_tracks[0].transitions = transitions; + let (bytes, _) = fc::export(&p, main, fc::Format::Otio, &Default::default()).unwrap(); + let (q, r) = import_doc(&bytes, "Flat.otio", &mut probe); + let m = q.comp(r.comps[0]).unwrap(); + assert_eq!(m.layers.len(), N); + // A middle clip reaches over both of its transitions' handles. + let mid = m.layers.iter().find(|l| near(l.in_point, t(100.0 - 0.08))).expect("clip 250 extended into its incoming transition"); + assert!(near(mid.out_point, t(100.4 + 0.08)), "{}", mid.out_point.seconds()); +} diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 88f9f3249..68823b76b 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -165,6 +165,27 @@ fn gradient_tracks_sample_separately() { assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0)); } +/// A gradient's stop count multiplies every painted pixel's sampling: plain JSON with more than +/// `MAX_GRADIENT_STOPS` is refused, and a longer stored list is sampled through its first ones. +#[test] +fn gradient_stop_count_is_bounded() { + use crate::value::MAX_GRADIENT_STOPS; + let g = Value::Gradient(Gradient::default()); + let many: Vec = (0..=MAX_GRADIENT_STOPS).map(|_| serde_json::json!("#ff0000")).collect(); + assert_eq!(g.coerce_json(&serde_json::Value::Array(many.clone())), None); + assert!(g.coerce_json(&serde_json::Value::Array(many[..MAX_GRADIENT_STOPS].to_vec())).is_some()); + let ops: Vec = (0..=MAX_GRADIENT_STOPS).map(|i| serde_json::json!([i as f64 / MAX_GRADIENT_STOPS as f64, 1.0])).collect(); + assert_eq!(g.coerce_json(&serde_json::json!({"colors": ["#000000", "#ffffff"], "opacities": ops})), None); + // The tagged (typed) form is bounded the same way. + let tagged = Value::Gradient(Gradient { colors: (0..=MAX_GRADIENT_STOPS).map(|i| (i as f64, [0.0; 4])).collect(), opacities: vec![(0.0, 1.0)] }); + assert_eq!(g.coerce_json(&tagged.to_json()), None); + // A stored gradient with a huge tail: stops past the limit don't take part in sampling. + let mut colors: Vec<(f64, [f32; 4])> = (0..MAX_GRADIENT_STOPS).map(|i| (i as f64 / MAX_GRADIENT_STOPS as f64 * 0.5, [0.0, 0.0, 0.0, 1.0])).collect(); + colors.extend((0..100_000).map(|_| (1.0, [1.0, 1.0, 1.0, 1.0]))); + let big = Gradient { colors, opacities: vec![(0.0, 1.0)] }; + assert_eq!(big.sample(1.0), [0.0, 0.0, 0.0, 1.0]); +} + /// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser /// rejects) and each stop's RGBA come back exactly. #[test] diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 96a8b6be3..e8fcb52be 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -85,13 +85,13 @@ impl Justify { } pub fn parse(s: &str) -> Option { Some(match s.to_ascii_lowercase().replace(['_', ' ', '-'], "").as_str() { - "left" | "leftjustify" => Justify::Left, - "center" | "centre" | "centerjustify" => Justify::Center, - "right" | "rightjustify" => Justify::Right, + "left" | "leftjustify" | "alignleft" => Justify::Left, + "center" | "centre" | "centerjustify" | "aligncenter" => Justify::Center, + "right" | "rightjustify" | "alignright" => Justify::Right, "justify" | "justifyleft" | "justifylastleft" | "fulljustifylastlineleft" => Justify::JustifyLastLeft, "justifycenter" | "justifylastcenter" | "fulljustifylastlinecenter" => Justify::JustifyLastCenter, "justifyright" | "justifylastright" | "fulljustifylastlineright" => Justify::JustifyLastRight, - "justifyall" | "fulljustifylastlinefull" | "fulljustify" => Justify::JustifyAll, + "justifyall" | "justifyfull" | "fulljustifylastlinefull" | "fulljustify" => Justify::JustifyAll, _ => return None, }) } @@ -977,7 +977,19 @@ impl TextDoc { if r.start >= r.end && n > 0 { return Ok(true); } - self.apply_style(r.start..r.end.max(r.start + usize::from(n == 0)), |s| { + // Empty text: apply_style ignores the bounds, so a fixed range avoids `start + 1` overflow. + let range = if n == 0 { 0..1 } else { r.start..r.end }; + // Check every affected run first, so a value one run refuses changes none of them. + let mut err = None; + self.apply_style(range.clone(), |s| { + if let Err(e) = apply_char_attr(&mut s.clone(), key, v) { + err.get_or_insert(e); + } + }); + if let Some(e) = err { + return Err(e); + } + self.apply_style(range, |s| { let _ = apply_char_attr(s, key, v); }); return Ok(true); @@ -1057,6 +1069,11 @@ pub const CHAR_ATTRS: &[&str] = &[ pub const PARA_ATTRS: &[&str] = &["justify", "indentLeft", "indentRight", "indentFirst", "spaceBefore", "spaceAfter", "direction", "composer", "hangingPunctuation"]; +/// Most variable font axes one style can set. Real fonts have a handful (OpenType registers +/// five; custom axes rarely bring the total past twenty); a `variations` object with more is +/// rejected before any of it is applied. +pub const MAX_VARIATION_AXES: usize = 64; + /// Set one character attribute; Ok(false) for keys that aren't character attributes. pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { match key { @@ -1173,20 +1190,28 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result match v { J::Null => s.variations.clear(), J::Object(m) => { - if m.is_empty() { - s.variations.clear(); + if m.len() > MAX_VARIATION_AXES { + return Err(format!("variations: at most {MAX_VARIATION_AXES} axes")); } + // One keyed merge (the result sorted by tag), applied only when every entry is valid. + let mut axes: std::collections::BTreeMap = if m.is_empty() { Default::default() } else { s.variations.iter().cloned().collect() }; for (tag, val) in m { if tag.is_empty() || tag.len() > 4 { return Err(format!("variations: `{tag}` is not an axis tag")); } - s.variations.retain(|(t, _)| t != tag); match val { - J::Null => {} - _ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)), + J::Null => { + axes.remove(tag); + } + _ => { + axes.insert(tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32); + } } } - s.variations.sort_by(|a, b| a.0.cmp(&b.0)); + if axes.len() > MAX_VARIATION_AXES { + return Err(format!("variations: at most {MAX_VARIATION_AXES} axes")); + } + s.variations = axes.into_iter().collect(); } _ => return Err("variations: expected {tag: number, …}".into()), }, @@ -1321,6 +1346,44 @@ pub fn word_at(text: &str, ci: usize) -> Range { mod tests { use super::*; + #[test] + fn variations_merge_by_tag_and_reject_too_many_axes() { + let mut doc = TextDoc::plain("ab"); + assert!(doc.set_attr("variations", &serde_json::json!({"wght": 650, "wdth": 90}), None).unwrap()); + assert!(doc.set_attr("variations", &serde_json::json!({"wght": 700, "wdth": null, "opsz": 12}), None).unwrap()); + assert_eq!(doc.style_at(0).variations, vec![("opsz".to_string(), 12.0), ("wght".to_string(), 700.0)]); + // More axes than any font has: refused up front, the style untouched. + let many: serde_json::Map = (0..MAX_VARIATION_AXES + 1).map(|i| (format!("a{i:03}"), J::from(1.0))).collect(); + assert!(doc.set_attr("variations", &J::Object(many), None).is_err()); + assert_eq!(doc.style_at(0).variations.len(), 2); + // A merge that would grow past the limit is refused too. + let fill: serde_json::Map = (0..MAX_VARIATION_AXES - 2).map(|i| (format!("b{i:03}"), J::from(1.0))).collect(); + assert!(doc.set_attr("variations", &J::Object(fill), None).unwrap()); + assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES); + // (The probe on an empty style passes; the styles already at the limit keep their axes.) + let _ = doc.set_attr("variations", &serde_json::json!({"zzzz": 1}), None); + assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES); + assert!(!doc.style_at(0).variations.iter().any(|(t, _)| t == "zzzz")); + } + + #[test] + fn refused_variation_leaves_every_run_unchanged() { + // "ab" is at the axis limit; "cd" has none. Three new axes over both runs must be refused + // for both, not applied to "cd" only. + let mut doc = TextDoc::plain("abcd"); + let fill: serde_json::Map = (0..MAX_VARIATION_AXES).map(|i| (format!("b{i:03}"), J::from(1.0))).collect(); + assert!(doc.set_attr("variations", &J::Object(fill), Some(0..2)).unwrap()); + assert!(doc.set_attr("variations", &serde_json::json!({"x1": 1, "x2": 1, "x3": 1}), None).is_err()); + assert_eq!(doc.style_at(3).variations.len(), 0); + assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES); + } + + #[test] + fn empty_document_accepts_an_extreme_range_without_overflow() { + let mut doc = TextDoc::plain(""); + assert!(doc.set_attr("size", &J::from(20), Some(usize::MAX..usize::MAX)).unwrap()); + } + #[test] fn mixed_line_breaks_keep_original_byte_and_character_ranges() { let text = "é\r\nβ\rx\n\n"; diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index 6609aed68..6e87f8933 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -210,7 +210,13 @@ impl Gradient { } } +/// Most stops (colour or opacity) a gradient is sampled through. Gradients are sampled per +/// pixel, so the stop count multiplies every painted pixel's work: stops past this are ignored +/// when sampling, and a gradient written as JSON with more is refused. +pub const MAX_GRADIENT_STOPS: usize = 256; + fn sample_stops(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option { + let stops = &stops[..stops.len().min(MAX_GRADIENT_STOPS)]; let first = stops.first()?; if t <= first.0 { return Some(first.1); @@ -243,7 +249,7 @@ fn gradient_json(j: &serde_json::Value) -> Option { } fn stops(a: &[J]) -> Option> { let n = a.len(); - if n < 2 { + if !(2..=MAX_GRADIENT_STOPS).contains(&n) { return None; } a.iter() @@ -259,6 +265,7 @@ fn gradient_json(j: &serde_json::Value) -> Option { J::Object(o) => { let colors = stops(o.get("colors")?.as_array()?)?; let op = match o.get("opacities") { + Some(J::Array(a)) if a.len() > MAX_GRADIENT_STOPS => return None, Some(J::Array(a)) => Some( a.iter() .map(|s| { @@ -469,9 +476,10 @@ impl Value { } Value::Layer(_) => Some(Value::Layer(j.as_u64())), Value::Path(_) => serde_json::from_value::(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::(j.clone()).ok()), - Value::Gradient(_) => { - serde_json::from_value::(j.clone()).ok().filter(|v| matches!(v, Value::Gradient(_))).or_else(|| gradient_json(j).map(Value::Gradient)) - } + Value::Gradient(_) => serde_json::from_value::(j.clone()) + .ok() + .filter(|v| matches!(v, Value::Gradient(g) if g.colors.len() <= MAX_GRADIENT_STOPS && g.opacities.len() <= MAX_GRADIENT_STOPS)) + .or_else(|| gradient_json(j).map(Value::Gradient)), } } /// JSON for display/automation (numbers/arrays for numeric kinds). diff --git a/crates/lottie/Cargo.toml b/crates/lottie/Cargo.toml index a9f64b3ba..9a95dca13 100644 --- a/crates/lottie/Cargo.toml +++ b/crates/lottie/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-lottie = { workspace = true } craft-docpath = { workspace = true } effectcraft-time = { workspace = true } effectcraft-color = { workspace = true } diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index 5b6dabf50..62550f86f 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -1,26 +1,20 @@ -//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes. -//! -//! A Lottie keyframe holds the start value `s` of a segment and the segment's normalised cubic -//! Bezier ease: `o` (out tangent of this key) and `i` (in tangent of the next key), each with `x` -//! (time fraction) and `y` (value progress fraction), per dimension. After Effects' temporal ease -//! is speed (units/s) and influence (fraction of the segment): for a segment of duration `d` and -//! value change `Δv`, -//! -//! ```text -//! o.x = influence_out o.y = speed_out · influence_out · d / Δv -//! i.x = 1 − influence_in i.y = 1 − speed_in · influence_in · d / Δv -//! ``` -//! -//! which is exactly the Bezier the keyframe evaluator uses. Spatial properties ease along the -//! motion path (Δv = arc length, one dimension) and carry `to`/`ti` spatial tangents. Hold keys -//! set `h: 1`. +//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes, through `craft-lottie`'s +//! property encoding ([`craft_lottie::read_prop`], [`craft_lottie::write_prop`]) and its +//! conversion between a segment's normalised Bezier ease and After Effects' speed and influence +//! ([`Tangents`]). Spatial properties ease along the motion path (Δv = arc length, one +//! dimension) and carry `to`/`ti` spatial tangents. +pub(crate) use craft_lottie::nums; +use craft_lottie::{Animated, Json, KeyOut, PropValue, Tangents, read_prop, round6}; use effectcraft_keyframe::{self as kf, Ease, Interp, Keyframe, Value}; use effectcraft_project::{Expression, Property}; -use serde_json::{Value as Json, json}; +use serde_json::json; use crate::Timebase; +/// Most distinct warnings an import or export reports. +const MAX_WARNINGS: usize = 200; + /// Export state shared by the whole document. pub(crate) struct Ex { pub tb: Timebase, @@ -31,7 +25,8 @@ pub(crate) struct Ex { impl Ex { pub fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Each warning is looked for among the earlier ones: past a screenful, more add nothing. + if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } @@ -46,7 +41,8 @@ pub(crate) struct Im { impl Im { pub fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Each warning is looked for among the earlier ones: past a screenful, more add nothing. + if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } @@ -57,15 +53,11 @@ fn secs(t: effectcraft_time::Tick) -> f64 { } fn r6(v: f64) -> f64 { - if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 } + round6(v) } -/// A key value as Lottie's `s` (numbers wrapped in an array, objects in a one-element array). -fn wrap(j: Json) -> Json { - match j { - Json::Array(_) => j, - other => json!([other]), - } +fn lottie_ease(e: Ease) -> craft_lottie::Ease { + craft_lottie::Ease { speed: e.speed, influence: e.influence } } fn is_spatial(p: &Property) -> bool { @@ -75,7 +67,7 @@ fn is_spatial(p: &Property) -> bool { /// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions). /// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and /// the remaining keys carry the animation the renderer shows. -pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json { +pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Animated { let muted = p.keys.iter().any(|k| k.muted); if muted { ex.warn(format!("{label}: muted keyframes are not exported")); @@ -87,146 +79,102 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js } else { &p.keys }; - let mut out = - if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) }; + let mut expression = None; if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) { if ex.include_expressions { - out["x"] = json!(e.text); + expression = Some(e.text.as_str()); } else { ex.warn(format!("{label}: expression not exported (enable includeExpressions to export it as `x`)")); } } - out + if keys.is_empty() { + return Animated(craft_lottie::write_prop(conv(&p.value), &[], expression)); + } + Animated::keyed(&export_keys(ex, keys, is_spatial(p), p.hold_only, conv), expression) } -fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec { +fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec { let mut out = Vec::with_capacity(keys.len()); for (i, k) in keys.iter().enumerate() { - let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))}); - if i + 1 == keys.len() { - if k.out_interp == Interp::Hold || hold_only { - o["h"] = json!(1); + let mut o = KeyOut { t: ex.tb.frame(k.time), s: conv(&k.value), hold: false, ease: vec![], spatial: None }; + let last = i + 1 == keys.len(); + if k.out_interp == Interp::Hold || hold_only || (!last && !k.value.interpolates()) { + o.hold = true; + } else if !last { + o.ease = segment_ease(keys, i, spatial); + if spatial { + let (_, to) = kf::spatial_tangents(keys, i); + let (ti, _) = kf::spatial_tangents(keys, i + 1); + let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) }; + o.spatial = Some((to.get(..n).unwrap_or_default().to_vec(), ti.get(..n).unwrap_or_default().to_vec())); } - out.push(o); - continue; - } - if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() { - o["h"] = json!(1); - out.push(o); - continue; - } - let (ox, oy, ix, iy) = segment_ease(keys, i, spatial); - o["o"] = json!({"x": ox, "y": oy}); - o["i"] = json!({"x": ix, "y": iy}); - if spatial { - let (_, to) = kf::spatial_tangents(keys, i); - let (ti, _) = kf::spatial_tangents(keys, i + 1); - let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) }; - o["to"] = json!(to[..n].iter().map(|v| r6(*v)).collect::>()); - o["ti"] = json!(ti[..n].iter().map(|v| r6(*v)).collect::>()); } out.push(o); } out } -/// Normalised ease of segment `i → i+1`: (o.x, o.y, i.x, i.y) per dimension. -fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec, Vec, Vec, Vec) { - let (a, b) = (&keys[i], &keys[i + 1]); +/// Normalised ease of segment `i → i+1`, one entry per dimension. +fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> Vec { + let (Some(a), Some(b)) = (keys.get(i), keys.get(i + 1)) else { return vec![] }; let dur = (secs(b.time) - secs(a.time)).max(1e-12); let ca = a.value.components(); let cb = b.value.components(); let numeric = !ca.is_empty() && ca.len() == cb.len(); let dims = if spatial || !numeric { 1 } else { ca.len() }; - let (mut ox, mut oy, mut ix, mut iy) = (vec![], vec![], vec![], vec![]); - for d in 0..dims { - let (dv, eo, ei) = if spatial { - let len = kf::spatial_segment_length(keys, i); - (len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false)) - } else if numeric { - (cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false)) - } else { - // Paths, gradients: overall progress 0 → 1. - let side = |k: &Keyframe, out: bool| { - let interp = if out { k.out_interp } else { k.in_interp }; - let list = if out { &k.out_ease } else { &k.in_ease }; - if interp == Interp::Bezier { - list.first().copied().unwrap_or_default() - } else { - Ease { speed: 1.0 / dur, influence: kf::LINEAR_SIDE_INFLUENCE } - } + (0..dims) + .map(|d| { + let (dv, eo, ei) = if spatial { + let len = kf::spatial_segment_length(keys, i); + (len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false)) + } else if numeric { + (cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false)) + } else { + // Paths, gradients: overall progress 0 → 1. + let side = |k: &Keyframe, out: bool| { + let interp = if out { k.out_interp } else { k.in_interp }; + let list = if out { &k.out_ease } else { &k.in_ease }; + if interp == Interp::Bezier { + list.first().copied().unwrap_or_default() + } else { + Ease { speed: 1.0 / dur, influence: kf::LINEAR_SIDE_INFLUENCE } + } + }; + (1.0, Some(side(a, true)), Some(side(b, false))) }; - (1.0, Some(side(a, true)), Some(side(b, false))) - }; - let lin = Ease { speed: dv / dur, influence: kf::LINEAR_SIDE_INFLUENCE }; - let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin)); - // Crossing time handles must retain the independently specified influences. - let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0)); - let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) }; - ox.push(r6(i0)); - oy.push(r6(y0)); - ix.push(r6(1.0 - i1)); - iy.push(r6(y1)); - } - (ox, oy, ix, iy) + let lin = Ease { speed: dv / dur, influence: kf::LINEAR_SIDE_INFLUENCE }; + // Crossing time handles keep the independently specified influences. + Tangents::from_speed(lottie_ease(eo.unwrap_or(lin)), lottie_ease(ei.unwrap_or(lin)), dv, dur) + }) + .collect() } // ---------------------------------------------------------------- import -/// Whether a Lottie property value is keyframed. -pub(crate) fn is_animated(j: &Json) -> bool { - if j.get("a").and_then(Json::as_i64) == Some(1) { - return true; - } - j.get("k").and_then(Json::as_array).and_then(|a| a.first()).is_some_and(|f| f.get("t").is_some()) -} - -/// Component `d` of an ease tangent field (`x` / `y` may be a number or an array). -fn comp(j: &Json, field: &str, d: usize) -> Option { - match j.get(field)? { - Json::Number(n) => n.as_f64(), - Json::Array(a) => a.get(d).or(a.first()).and_then(Json::as_f64), - _ => None, - } -} - -fn vec3_of(j: Option<&Json>) -> [f64; 3] { - let a = j.and_then(Json::as_array); - let g = |i: usize| a.and_then(|a| a.get(i)).and_then(Json::as_f64).unwrap_or(0.0); - [g(0), g(1), g(2)] -} - /// Import a Lottie animatable property into `p` (value, keys, expression). `conv` turns a Lottie /// value (as found in `k` or a key's `s`, possibly wrapped in a one-element array) into a value of /// the property's type. pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Json) -> Option) { - if !j.is_object() { - if let Some(v) = conv(j) { - p.value = v; - } - return; - } - if let Some(x) = j.get("x").and_then(Json::as_str).filter(|x| !x.trim().is_empty()) { + let prop = read_prop(j); + if let Some(x) = prop.expression { p.expr = Some(Expression { text: x.to_string(), enabled: true }); } - let Some(k) = j.get("k") else { return }; - if !is_animated(j) { - if let Some(v) = conv(k) { - p.value = v; + let list = match prop.value { + PropValue::None => return, + PropValue::Static(v) => { + if let Some(v) = conv(v) { + p.value = v; + } + return; } - return; - } - let Some(list) = k.as_array() else { return }; + PropValue::Keys(list) => list, + }; let spatial = is_spatial(p); let mut keys: Vec = Vec::with_capacity(list.len()); - let mut kept: Vec<&Json> = Vec::with_capacity(list.len()); - let mut prev_end: Option<&Json> = None; + let mut kept = Vec::with_capacity(list.len()); for kj in list { - let t = kj.get("t").and_then(Json::as_f64).unwrap_or(0.0); - let sv = kj.get("s").or(prev_end); - prev_end = kj.get("e"); - let Some(v) = sv.and_then(conv) else { continue }; - let mut key = Keyframe::new(im.tb.tick(t), v); + let Some(v) = kj.s.and_then(conv) else { continue }; + let mut key = Keyframe::new(im.tb.tick(kj.t), v); key.spatial_auto = false; if p.hold_only || !key.value.interpolates() { key = key.hold(); @@ -240,16 +188,14 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js let n = keys.len(); for i in 0..n.saturating_sub(1) { let Some(kj) = kept.get(i) else { break }; - if kj.get("h").and_then(Json::as_i64) == Some(1) || p.hold_only { + if kj.hold || p.hold_only { keys[i].out_interp = Interp::Hold; keys[i + 1].in_interp = Interp::Hold; continue; } if spatial { - let to = vec3_of(kj.get("to")); - let ti = vec3_of(kj.get("ti")); - keys[i].spatial_out = to; - keys[i + 1].spatial_in = ti; + keys[i].spatial_out = kj.to; + keys[i + 1].spatial_in = kj.ti; } } // Eases need the spatial tangents (arc length), so they come second. @@ -258,7 +204,7 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js if keys[i].out_interp == Interp::Hold { continue; } - let (Some(o), Some(ii)) = (kj.get("o"), kj.get("i")) else { + let (Some(o), Some(ii)) = (kj.o, kj.i) else { keys[i].out_interp = Interp::Linear; keys[i + 1].in_interp = Interp::Linear; continue; @@ -272,13 +218,8 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js let mut outs = vec![]; let mut ins = vec![]; for d in 0..dims { - let ox = comp(o, "x", d).unwrap_or(1.0 / 3.0).clamp(0.0, 1.0); - let oy = comp(o, "y", d).unwrap_or(ox); - let ix = comp(ii, "x", d).unwrap_or(2.0 / 3.0).clamp(0.0, 1.0); - let iy = comp(ii, "y", d).unwrap_or(ix); - if (ox - oy).abs() > 1e-4 || (ix - iy).abs() > 1e-4 { - linear = false; - } + let t = Tangents::read(o, ii, d); + linear &= t.is_linear(); let dv = if spatial { kf::spatial_segment_length(&keys, i) } else if numeric { @@ -286,10 +227,9 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js } else { 1.0 }; - let inf_o = ox.max(0.001); - let inf_i = (1.0 - ix).max(0.001); - outs.push(Ease { speed: oy * dv / (inf_o * dur), influence: inf_o }); - ins.push(Ease { speed: (1.0 - iy) * dv / (inf_i * dur), influence: inf_i }); + let (out, into) = t.to_speed(dv, dur); + outs.push(Ease { speed: out.speed, influence: out.influence }); + ins.push(Ease { speed: into.speed, influence: into.influence }); } if linear { keys[i].out_interp = Interp::Linear; @@ -305,6 +245,13 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js p.keys = keys; } +/// [`import_prop`] for a property of the model ([`Animated`]). +pub(crate) fn import_animated(im: &Im, a: Option<&Animated>, p: &mut Property, conv: &dyn Fn(&Json) -> Option) { + if let Some(a) = a { + import_prop(im, &a.0, p, conv); + } +} + // ---------------------------------------------------------------- value converters pub(crate) fn num(v: &Value) -> Json { @@ -330,23 +277,6 @@ pub(crate) fn arr3(v: &Value) -> Json { json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0)), r6(*c.get(2).unwrap_or(&0.0))]) } -/// Numbers of a Lottie value (`5`, `[5]`, `[1, 2, 3]`). -pub(crate) fn nums(j: &Json) -> Option> { - match j { - Json::Number(n) => Some(vec![n.as_f64()?]), - Json::Array(a) => { - // `[[x, y]]`-style wrapping. - if a.len() == 1 - && let Some(inner @ Json::Array(_)) = a.first() - { - return nums(inner); - } - a.iter().map(Json::as_f64).collect() - } - _ => None, - } -} - pub(crate) fn to_scalar(j: &Json) -> Option { nums(j).and_then(|v| v.first().copied()).map(Value::Scalar) } @@ -363,15 +293,8 @@ pub(crate) fn to_vec3(j: &Json, z: f64) -> Option { } pub(crate) fn to_color(j: &Json) -> Option { - let v = nums(j)?; - let mut c = [*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&0.0), *v.get(3).unwrap_or(&1.0)]; - // Some files use 0–255. - if c[..3].iter().any(|x| *x > 1.0) { - for x in &mut c[..3] { - *x /= 255.0; - } - } - Some(Value::Color(c)) + // Some files use 0–255: `craft_lottie::color` divides those. + craft_lottie::color(j).map(Value::Color) } pub(crate) fn to_bool(j: &Json) -> Option { diff --git a/crates/lottie/src/base64.rs b/crates/lottie/src/base64.rs deleted file mode 100644 index b45f86d4a..000000000 --- a/crates/lottie/src/base64.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Standard base64 (RFC 4648 §4) for embedded image assets. - -const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -pub fn encode(data: &[u8]) -> String { - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for chunk in data.chunks(3) { - let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; - let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; - for i in 0..4 { - if i <= chunk.len() { - out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); - } else { - out.push('='); - } - } - } - out -} - -pub fn decode(s: &str) -> Option> { - let val = |c: u8| -> Option { - Some(match c { - b'A'..=b'Z' => c - b'A', - b'a'..=b'z' => c - b'a' + 26, - b'0'..=b'9' => c - b'0' + 52, - b'+' | b'-' => 62, - b'/' | b'_' => 63, - _ => return None, - } as u32) - }; - let clean: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect(); - let mut out = Vec::with_capacity(clean.len() * 3 / 4); - for chunk in clean.chunks(4) { - let mut n = 0u32; - for (i, &c) in chunk.iter().enumerate() { - n |= val(c)? << (18 - 6 * i); - } - let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8]; - match chunk.len() { - 4 => out.extend_from_slice(&bytes), - 3 => out.extend_from_slice(&bytes[..2]), - 2 => out.push(bytes[0]), - _ => return None, - } - } - Some(out) -} - -/// Split a `data:;base64,` URI into (mime, bytes). -pub fn data_uri(s: &str) -> Option<(String, Vec)> { - let rest = s.strip_prefix("data:")?; - let (head, payload) = rest.split_once(',')?; - let mime = head.strip_suffix(";base64")?; - Some((mime.to_string(), decode(payload)?)) -} - -#[cfg(test)] -mod tests { - #[test] - fn roundtrip() { - for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] { - let e = super::encode(s.as_bytes()); - assert_eq!(super::decode(&e).unwrap(), s.as_bytes()); - } - assert_eq!(super::encode(b"foobar"), "Zm9vYmFy"); - assert_eq!(super::encode(b"fo"), "Zm8="); - } -} diff --git a/crates/lottie/src/export.rs b/crates/lottie/src/export.rs index eeb6e2737..99ca28e7e 100644 --- a/crates/lottie/src/export.rs +++ b/crates/lottie/src/export.rs @@ -2,11 +2,14 @@ use std::collections::BTreeSet; +use craft_lottie::model::{Asset, Effect, Font, Fonts, Layer as LottieLayer, LayerContent, Marker, Mask, PrecompLayer, SolidLayer, Transform}; +use craft_lottie::{Animated, Animation, Json}; use effectcraft_keyframe::Value; use effectcraft_project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, MatteKind, Project, PropGroup}; -use serde_json::{Map, Value as Json, json}; +use serde_json::json; use crate::anim::{self, Ex, export_prop}; +use crate::shapes::fixed; use crate::{LottieError, Timebase, shapes, text}; /// Export settings. @@ -134,28 +137,15 @@ struct Doc<'a> { opts: &'a ExportOptions, read: &'a dyn Fn(&str) -> Option>, ex: Ex, - assets: Vec, + assets: Vec, done: BTreeSet, - fonts: Vec, + fonts: Vec, any_3d: bool, } -fn hex(c: [f32; 3]) -> String { - let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2])) -} - -fn mime_of(path: &str) -> &'static str { - let lower = path.to_ascii_lowercase(); - if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { - "image/jpeg" - } else if lower.ends_with(".webp") { - "image/webp" - } else if lower.ends_with(".gif") { - "image/gif" - } else { - "image/png" - } +/// The image type `bytes` hold, by their signature; `None` for anything else. +pub(crate) fn image_mime(bytes: &[u8]) -> Option<&'static str> { + craft_lottie::image_mime(bytes) } impl Doc<'_> { @@ -163,84 +153,68 @@ impl Doc<'_> { self.ex.tb.frame(t) } - fn prop(&mut self, g: &PropGroup, m: &str, conv: &dyn Fn(&Value) -> Json, label: &str) -> Option { + fn prop(&mut self, g: &PropGroup, m: &str, conv: &dyn Fn(&Value) -> Json, label: &str) -> Option { let p = g.get(m)?; Some(export_prop(&mut self.ex, p, conv, &format!("{label} ▸ {}", p.name))) } - fn transform(&mut self, l: &Layer) -> Json { - let mut ks = Map::new(); - let Some(tr) = l.transform() else { return json!({}) }; + fn transform(&mut self, l: &Layer) -> Transform { + let mut ks = Transform::default(); + let Some(tr) = l.transform() else { return ks }; let label = format!("{} ▸ Transform", l.name); let three = l.is_3d(); - if let Some(a) = self.prop(tr, "anchor", &anim::arr3, &label) { - ks.insert("a".into(), a); - } + ks.anchor = self.prop(tr, "anchor", &anim::arr3, &label); if tr.get("positionX").is_some() { let mut p = json!({"s": true}); for (k, m) in [("x", "positionX"), ("y", "positionY"), ("z", "positionZ")] { if (k != "z" || three) && let Some(v) = self.prop(tr, m, &anim::num, &label) { - p[k] = v; + p[k] = v.0; } } - ks.insert("p".into(), p); - } else if let Some(p) = self.prop(tr, "position", &anim::arr3, &label) { - ks.insert("p".into(), p); - } - if let Some(s) = self.prop(tr, "scale", &anim::arr3, &label) { - ks.insert("s".into(), s); + ks.position = Some(Animated(p)); + } else { + ks.position = self.prop(tr, "position", &anim::arr3, &label); } + ks.scale = self.prop(tr, "scale", &anim::arr3, &label); if three { - for (k, m) in [("rx", "rotationX"), ("ry", "rotationY"), ("rz", "rotation")] { - if let Some(v) = self.prop(tr, m, &anim::num, &label) { - ks.insert(k.into(), v); - } - } - if let Some(v) = self.prop(tr, "orientation", &anim::arr3, &label) { - ks.insert("or".into(), v); - } - } else if let Some(r) = self.prop(tr, "rotation", &anim::num, &label) { - ks.insert("r".into(), r); + ks.rotation_x = self.prop(tr, "rotationX", &anim::num, &label); + ks.rotation_y = self.prop(tr, "rotationY", &anim::num, &label); + ks.rotation_z = self.prop(tr, "rotation", &anim::num, &label); + ks.orientation = self.prop(tr, "orientation", &anim::arr3, &label); + } else { + ks.rotation = self.prop(tr, "rotation", &anim::num, &label); } - if let Some(o) = self.prop(tr, "opacity", &anim::num, &label) { - ks.insert("o".into(), o); - } - Json::Object(ks) + ks.opacity = self.prop(tr, "opacity", &anim::num, &label); + ks } - fn masks(&mut self, l: &Layer) -> Vec { + fn masks(&mut self, l: &Layer) -> Vec { let Some(masks) = l.masks() else { return vec![] }; let mut out = vec![]; for m in masks.groups() { let GroupKind::Mask { mode, inverted, .. } = m.kind else { continue }; let label = format!("{} ▸ {}", l.name, m.name); - let mut o = json!({"nm": m.name, "mode": mask_mode(mode), "inv": inverted}); - if let Some(v) = self.prop(m, "path", &shapes::path_value, &label) { - o["pt"] = v; - } - if let Some(v) = self.prop(m, "opacity", &anim::num, &label) { - o["o"] = v; - } - if let Some(v) = self.prop(m, "expansion", &anim::num, &label) { - o["x"] = v; - } + let mut o = Mask { name: Some(m.name.clone()), mode: Some(mask_mode(mode).into()), inverted: Some(inverted), ..Default::default() }; + o.path = self.prop(m, "path", &shapes::path_value, &label); + o.opacity = self.prop(m, "opacity", &anim::num, &label); + o.expansion = self.prop(m, "expansion", &anim::num, &label); if let Some(f) = m.get("feather") { if f.is_animated() || f.value.components().iter().any(|v| *v != 0.0) { self.ex.warn(format!("{label}: mask feather is exported as `f` (not every Lottie player renders it)")); } - o["f"] = export_prop(&mut self.ex, f, &anim::arr2, &format!("{label} ▸ Mask Feather")); + o.extra.insert("f".into(), export_prop(&mut self.ex, f, &anim::arr2, &format!("{label} ▸ Mask Feather")).0); } if !m.enabled { - o["mode"] = json!("n"); + o.mode = Some("n".into()); } out.push(o); } out } - fn effects(&mut self, l: &Layer) -> Vec { + fn effects(&mut self, l: &Layer) -> Vec { let Some(fx) = l.effects() else { return vec![] }; let mut out = vec![]; for (i, g) in fx.groups().enumerate() { @@ -266,11 +240,27 @@ impl Doc<'_> { }; (p.name.clone(), export_prop(&mut self.ex, p, &conv, &format!("{label} ▸ {}", p.name))) } - None => (format!("Param {}", pi + 1), json!({"a": 0, "k": konst})), + None => (format!("Param {}", pi + 1), fixed(json!(konst))), }; - params.push(json!({"ty": ty, "nm": nm, "mn": m, "ix": pi + 1, "v": v})); + params.push(Effect { + kind: Some(i64::from(*ty)), + name: Some(nm), + match_name: Some((*m).into()), + index: Some(pi as i64 + 1), + value: Some(v), + ..Default::default() + }); } - out.push(json!({"ty": map.ty, "nm": g.name, "mn": effect, "ix": i + 1, "en": g.enabled as u8, "np": params.len(), "ef": params})); + out.push(Effect { + kind: Some(i64::from(map.ty)), + name: Some(g.name.clone()), + match_name: Some(effect.clone()), + index: Some(i as i64 + 1), + enabled: Some(i64::from(g.enabled)), + count: Some(params.len() as i64), + values: Some(params), + ..Default::default() + }); } out } @@ -286,8 +276,22 @@ impl Doc<'_> { self.ex.warn(format!("{}: cannot read image `{}` (layer exported as a null)", l.name, f.path)); return None; }; - let uri = format!("data:{};base64,{}", mime_of(&f.path), crate::base64::encode(&bytes)); - self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1})); + // What a project calls a still is embedded only when it is one: the path can name any + // file (an imported animation's asset), and the export would carry its bytes verbatim. + let Some(mime) = image_mime(&bytes) else { + self.ex.warn(format!("{}: `{}` is not a PNG, JPEG, GIF or WebP image (layer exported as a null)", l.name, f.path)); + return None; + }; + self.assets.push(Asset { + id: Some(id.clone()), + name: Some(it.name.clone()), + width: Some(f64::from(f.width)), + height: Some(f64::from(f.height)), + dir: Some(String::new()), + path: Some(craft_lottie::base64::to_data_uri(mime, &bytes)), + embedded: Some(1), + ..Default::default() + }); self.done.insert(id.clone()); Some(id) } @@ -301,11 +305,19 @@ impl Doc<'_> { let c = it.as_comp()?; self.done.insert(id.clone()); let layers = self.layers(c); - self.assets.push(json!({"id": id, "nm": it.name, "fr": c.frame_rate.as_f64(), "w": c.width, "h": c.height, "layers": layers})); + self.assets.push(Asset { + id: Some(id.clone()), + name: Some(it.name.clone()), + frame_rate: Some(c.frame_rate.as_f64()), + width: Some(f64::from(c.width)), + height: Some(f64::from(c.height)), + layers: Some(layers), + ..Default::default() + }); Some(id) } - fn layers(&mut self, comp: &Comp) -> Vec { + fn layers(&mut self, comp: &Comp) -> Vec { let mattes: BTreeSet<_> = comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect(); let mut out = vec![]; for (idx, l) in comp.layers.iter().enumerate() { @@ -314,11 +326,9 @@ impl Doc<'_> { out } - fn layer(&mut self, comp: &Comp, idx: usize, l: &Layer, is_matte: bool) -> Json { + fn layer(&mut self, comp: &Comp, idx: usize, l: &Layer, is_matte: bool) -> LottieLayer { let name = l.name.clone(); - let mut o = Map::new(); - o.insert("ind".into(), json!(idx + 1)); - o.insert("nm".into(), json!(name)); + let mut o = LottieLayer { index: Some(idx as i64 + 1), name: Some(name.clone()), ..Default::default() }; let three = l.is_3d(); if three { self.any_3d = true; @@ -326,34 +336,30 @@ impl Doc<'_> { self.ex.warn(format!("{name}: 3D layer exported with ddd: 1 (most Lottie players render 2D only)")); } } - o.insert("ddd".into(), json!(three as u8)); + o.three_d = Some(i64::from(three)); let mut hidden = !l.switches.video || l.switches.guide; - let mut ty = 3; + o.content = LayerContent::Null; match &l.source { LayerSource::Shape => { - ty = 4; let items = l.props.sub("contents").map(|c| shapes::export_items(&mut self.ex, c, &name)).unwrap_or_default(); - o.insert("shapes".into(), json!(items)); + o.content = LayerContent::Shape(items); } LayerSource::Text => { if self.opts.text_as_shapes { - ty = 4; - let items = text::glyph_shapes(&mut self.ex, l); - o.insert("shapes".into(), json!(items)); + o.content = LayerContent::Shape(text::glyph_shapes(&mut self.ex, l)); } else { - ty = 5; - let t = text::export_text(&mut self.ex, l, &mut self.fonts); - o.insert("t".into(), t); + o.content = LayerContent::Text(Box::new(text::export_text(&mut self.ex, l, &mut self.fonts))); } } LayerSource::Solid { item } => { let sol = self.project.item(*item).and_then(|i| if let ItemKind::Solid(s) = &i.kind { Some(s.clone()) } else { None }); match sol { Some(s) if !l.switches.adjustment => { - ty = 1; - o.insert("sc".into(), json!(hex(s.color))); - o.insert("sw".into(), json!(s.width)); - o.insert("sh".into(), json!(s.height)); + o.content = LayerContent::Solid(SolidLayer { + color: Some(craft_lottie::to_hex(s.color)), + width: Some(f64::from(s.width)), + height: Some(f64::from(s.height)), + }); } _ => { self.ex.warn(format!("{name}: adjustment layers are not part of Lottie (exported as a null)")); @@ -363,15 +369,9 @@ impl Doc<'_> { } LayerSource::Comp { item } => { if let Some(id) = self.precomp_asset(*item) { - ty = 0; let (w, h) = self.project.comp(*item).map(|c| (c.width, c.height)).unwrap_or((comp.width, comp.height)); - o.insert("refId".into(), json!(id)); - o.insert("w".into(), json!(w)); - o.insert("h".into(), json!(h)); - if let Some(tm) = l.props.get("timeRemap") { - let v = export_prop(&mut self.ex, tm, &anim::num, &format!("{name} ▸ Time Remap")); - o.insert("tm".into(), v); - } + let time_remap = l.props.get("timeRemap").map(|tm| export_prop(&mut self.ex, tm, &anim::num, &format!("{name} ▸ Time Remap"))); + o.content = LayerContent::Precomp(PrecompLayer { ref_id: Some(id), width: Some(f64::from(w)), height: Some(f64::from(h)), time_remap }); } } LayerSource::Footage { item } => { @@ -379,8 +379,7 @@ impl Doc<'_> { match kind { Some((FootageKind::Still, true)) => { if let Some(id) = self.image_asset(*item, l) { - ty = 2; - o.insert("refId".into(), json!(id)); + o.content = LayerContent::Image(Some(id)); } else { hidden = true; } @@ -405,20 +404,16 @@ impl Doc<'_> { hidden = true; } } - o.insert("ty".into(), json!(ty)); - if let Some(p) = l.parent.and_then(|p| comp.index_of(p)) { - o.insert("parent".into(), json!(p)); - } - o.insert("sr".into(), json!(l.stretch / 100.0)); + o.parent = l.parent.and_then(|p| comp.index_of(p)).map(|p| p as i64); + o.stretch = Some(l.stretch / 100.0); if l.stretch < 0.0 { self.ex.warn(format!("{name}: reversed time stretch is not supported by most Lottie players")); } - let ks = self.transform(l); - o.insert("ks".into(), ks); - o.insert("ao".into(), json!((l.auto_orient == effectcraft_project::AutoOrient::AlongPath) as u8)); - o.insert("ip".into(), json!(self.frame(l.in_point))); - o.insert("op".into(), json!(self.frame(l.out_point))); - o.insert("st".into(), json!(self.frame(l.start_time))); + o.transform = Some(self.transform(l)); + o.auto_orient = Some(i64::from(l.auto_orient == effectcraft_project::AutoOrient::AlongPath)); + o.in_point = Some(self.frame(l.in_point)); + o.out_point = Some(self.frame(l.out_point)); + o.start_time = Some(self.frame(l.start_time)); let bm = match crate::blend_to_lottie(l.blend_mode) { Some(b) => b, None => { @@ -426,32 +421,32 @@ impl Doc<'_> { 0 } }; - o.insert("bm".into(), json!(bm)); + o.blend_mode = Some(i64::from(bm)); let masks = self.masks(l); if !masks.is_empty() { - o.insert("hasMask".into(), json!(true)); - o.insert("masksProperties".into(), json!(masks)); + o.has_mask = Some(true); + o.masks = Some(masks); } let ef = self.effects(l); if !ef.is_empty() { - o.insert("ef".into(), json!(ef)); + o.effects = Some(ef); } if let Some(tm) = l.track_matte && let Some(mi) = comp.index_of(tm.layer) { - o.insert("tt".into(), json!(matte_code(tm.kind))); - o.insert("tp".into(), json!(mi)); + o.matte_mode = Some(i64::from(matte_code(tm.kind))); + o.matte_parent = Some(mi as i64); if mi != idx { self.ex.warn(format!("{name}: track matte layer is not directly above (players without `tp` support use the layer above)")); } } if is_matte { - o.insert("td".into(), json!(1)); + o.is_matte = Some(1); if l.switches.video { self.ex.warn(format!("{name}: visible track matte layer (Lottie always hides matte layers)")); } } else if hidden { - o.insert("hd".into(), json!(true)); + o.hidden = Some(true); } if !l.markers.is_empty() { self.ex.warn(format!("{name}: layer markers are not part of Lottie (composition markers are exported)")); @@ -463,7 +458,7 @@ impl Doc<'_> { if l.preserve_transparency { self.ex.warn(format!("{name}: Preserve Underlying Transparency is not part of Lottie")); } - Json::Object(o) + o } } @@ -485,24 +480,27 @@ pub fn export_comp(project: &Project, comp: ItemId, opts: &ExportOptions, read: }; doc.done.insert(format!("comp_{}", comp.0)); let layers = doc.layers(c); - let markers: Vec = c.markers.iter().map(|m| json!({"tm": doc.frame(m.time), "cm": m.comment, "dr": doc.frame(m.duration)})).collect(); - let mut out = json!({ - "v": "5.12.0", - "fr": c.frame_rate.as_f64(), - "ip": 0, - "op": doc.frame(c.duration), - "w": c.width, - "h": c.height, - "nm": item.name, - "ddd": doc.any_3d as u8, - "assets": doc.assets, - "layers": layers, - "markers": markers, - }); - if !doc.fonts.is_empty() { - out["fonts"] = json!({"list": doc.fonts}); - } - Ok(ExportResult { json: out, warnings: doc.ex.warnings }) + let markers: Vec = c + .markers + .iter() + .map(|m| Marker { time: Some(doc.frame(m.time)), comment: Some(m.comment.clone()), duration: Some(doc.frame(m.duration)), ..Default::default() }) + .collect(); + let animation = Animation { + version: Some("5.12.0".into()), + frame_rate: Some(c.frame_rate.as_f64()), + in_point: Some(0.0), + out_point: Some(doc.frame(c.duration)), + width: Some(f64::from(c.width)), + height: Some(f64::from(c.height)), + name: Some(item.name.clone()), + three_d: Some(i64::from(doc.any_3d)), + assets: Some(doc.assets), + layers: Some(layers), + markers: Some(markers), + fonts: (!doc.fonts.is_empty()).then(|| Fonts { list: Some(doc.fonts), ..Default::default() }), + ..Default::default() + }; + Ok(ExportResult { json: animation.to_json(), warnings: doc.ex.warnings }) } /// Package an export as a dotLottie archive (`manifest.json` + `animations/.json`). diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 53b1ba71c..6534727dc 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -1,8 +1,10 @@ //! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; +use craft_lottie::model::{Asset, Effect, Layer as LottieLayer, LayerContent, Mask, Transform}; +use craft_lottie::{Animated, Animation, Json}; use effectcraft_color::Label; use effectcraft_keyframe::{ShapePath, Value}; use effectcraft_project::build::{self, Ids}; @@ -11,12 +13,14 @@ use effectcraft_project::{ PropGroup, Property, Solid, TrackMatte, }; use effectcraft_time::{FrameRate, Tick}; -use serde_json::Value as Json; -use crate::anim::{self, Im, import_prop}; +use crate::anim::{self, Im, import_animated, import_prop}; use crate::export::{fx_by_id, fx_by_type, fx_params, mask_mode_from}; use crate::{LottieError, Timebase, shapes, text}; +/// Longest font family or style name kept (each text key using the font holds a copy). +const MAX_FONT_NAME_CHARS: usize = 256; + /// An embedded image asset handed to the caller to store (the returned path becomes the /// footage item's file). #[derive(Clone, Debug)] @@ -37,24 +41,15 @@ pub struct ImportResult { pub warnings: Vec, } -fn f(j: &Json, k: &str) -> Option { - j.get(k).and_then(Json::as_f64) -} - -fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> { - j.get(k).and_then(Json::as_str) -} - -fn hex_color(sc: &str) -> [f32; 3] { - let h = sc.trim_start_matches('#'); - let c = |i: usize| { - h.get(i..i + 2).filter(|x| x.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0 +/// The Lottie document inside raw bytes (plain JSON, or a stored dotLottie archive), read into +/// `craft-lottie`'s model within its default limits. +fn document(bytes: &[u8]) -> Result { + let read = |b: &[u8]| { + Animation::read(b).map_err(|e| match e { + craft_lottie::LottieError::Parse(m) => LottieError::Parse(m), + other => LottieError::Invalid(other.to_string()), + }) }; - [c(0), c(2), c(4)] -} - -/// The Lottie JSON inside raw bytes (plain JSON, or a stored dotLottie archive). -fn document(bytes: &[u8]) -> Result { if bytes.starts_with(b"PK") { let entries = crate::zip::read_stored(bytes); let anim = entries @@ -62,9 +57,9 @@ fn document(bytes: &[u8]) -> Result { .find(|(n, _)| n.starts_with("animations/") && n.ends_with(".json")) .or_else(|| entries.iter().find(|(n, _)| n.ends_with(".json") && n != "manifest.json")) .ok_or_else(|| LottieError::Parse("dotLottie archive without an uncompressed animation".into()))?; - return serde_json::from_slice(&anim.1).map_err(|e| LottieError::Parse(e.to_string())); + return read(&anim.1); } - serde_json::from_slice(bytes).map_err(|e| LottieError::Parse(e.to_string())) + read(bytes) } struct Ctx<'a> { @@ -118,27 +113,28 @@ pub fn import( store: &mut dyn FnMut(&ImportedImage) -> Option, ) -> Result { let doc = document(bytes)?; - let (Some(w), Some(h)) = (f(&doc, "w"), f(&doc, "h")) else { return Err(LottieError::Parse("missing `w`/`h`".into())) }; - let fr = f(&doc, "fr").filter(|v| *v > 0.0).unwrap_or(30.0); + let (Some(w), Some(h)) = (doc.width, doc.height) else { return Err(LottieError::Parse("missing `w`/`h`".into())) }; + let fr = doc.frame_rate.filter(|v| *v > 0.0).unwrap_or(30.0); let rate = FrameRate::from_f64(fr); let tb = Timebase { rate }; - let name = s(&doc, "nm").filter(|n| !n.is_empty()).unwrap_or(fallback_name).to_string(); - let ip = f(&doc, "ip").unwrap_or(0.0); - let op = f(&doc, "op").unwrap_or(ip + fr).max(ip + 1.0); + let name = doc.name.as_deref().filter(|n| !n.is_empty()).unwrap_or(fallback_name).to_string(); + let ip = doc.in_point.unwrap_or(0.0); + let op = doc.out_point.unwrap_or(ip + fr).max(ip + 1.0); let mut cx = Ctx { im: Im { tb, warnings: vec![] }, assets: BTreeMap::new(), fonts: vec![], store, base_dir, items: vec![], folder: None, name: name.clone() }; - if let Some(list) = doc.get("fonts").and_then(|f| f.get("list")).and_then(Json::as_array) { - for fo in list { - let n = s(fo, "fName").unwrap_or("").to_string(); - let fam = s(fo, "fFamily").unwrap_or(&n).to_string(); - let st = s(fo, "fStyle").unwrap_or("Regular").to_string(); - cx.fonts.push((n, fam, st)); - } + for fo in doc.fonts.as_ref().and_then(|f| f.list.as_deref()).unwrap_or_default() { + // Every text key referring to the font copies its family and style: keep them to + // a font name's length. + let short = |v: &str| v.chars().take(MAX_FONT_NAME_CHARS).collect::(); + let n = fo.name.clone().unwrap_or_default(); + let fam = short(fo.family.as_deref().unwrap_or(&n)); + let st = short(fo.style.as_deref().unwrap_or("Regular")); + cx.fonts.push((n, fam, st)); } // Glyph outlines embedded for players without the fonts. Text is set in installed fonts. - if let Some(chars) = doc.get("chars").and_then(Json::as_array).filter(|c| !c.is_empty()) { + if let Some(chars) = doc.chars.as_deref().filter(|c| !c.is_empty()) { let mut fams: Vec<&str> = vec![]; - for fam in chars.iter().filter_map(|c| s(c, "fFamily")).filter(|f| !f.trim().is_empty()) { + for fam in chars.iter().filter_map(|c| c.family.as_deref()).filter(|f| !f.trim().is_empty()) { if !fams.contains(&fam) { fams.push(fam); } @@ -149,24 +145,27 @@ pub fn import( let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32); let duration = tb.tick(op); // Pass 1: asset items (precomps empty for now, images stored). - let assets = doc.get("assets").and_then(Json::as_array).cloned().unwrap_or_default(); - for a in &assets { - let Some(id) = s(a, "id") else { continue }; - if a.get("layers").is_some() { + let assets: &[Asset] = doc.assets.as_deref().unwrap_or_default(); + let top: &[LottieLayer] = doc.layers.as_deref().unwrap_or_default(); + let layer_sizes = precomp_layer_sizes(top, assets); + for a in assets { + let Some(id) = a.id.as_deref() else { continue }; + let size = a.width.zip(a.height).map(|(x, y)| (x as u32, y as u32)); + if a.layers.is_some() { let folder = cx.folder(project); - let layer = precomp_layer_size(&doc, &assets, id); - let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w); - let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h); - let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate); + let layer = layer_sizes.get(id).copied(); + let aw = a.width.map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w); + let ah = a.height.map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h); + let arate = a.frame_rate.map(FrameRate::from_f64).unwrap_or(rate); let c = Comp::new(aw, ah, arate, duration); - let nm = s(a, "nm").unwrap_or(id).to_string(); + let nm = a.name.as_deref().unwrap_or(id).to_string(); let item = project.add_item(&nm, Label::Sandstone, Some(folder), ItemKind::Comp(Arc::new(c))); cx.items.push(item); - cx.assets.insert(id.to_string(), (item, f(a, "w").zip(f(a, "h")).map(|(x, y)| (x as u32, y as u32)))); - } else if let Some(p) = s(a, "p") { - let (aw, ah) = (f(a, "w").unwrap_or(0.0) as u32, f(a, "h").unwrap_or(0.0) as u32); - let nm = s(a, "nm").unwrap_or(id).to_string(); - let path = if let Some((mime, data)) = crate::base64::data_uri(p) { + cx.assets.insert(id.to_string(), (item, size)); + } else if let Some(p) = a.path.as_deref() { + let (aw, ah) = (a.width.unwrap_or(0.0) as u32, a.height.unwrap_or(0.0) as u32); + let nm = a.name.as_deref().unwrap_or(id).to_string(); + let path = if let Some((mime, data)) = craft_lottie::base64::data_uri(p) { let ext = match mime.as_str() { "image/jpeg" | "image/jpg" => "jpg", "image/webp" => "webp", @@ -176,7 +175,7 @@ pub fn import( }; (cx.store)(&ImportedImage { id: id.to_string(), name: nm.clone(), bytes: data, ext: ext.into() }) } else { - let u = s(a, "u").unwrap_or(""); + let u = a.dir.as_deref().unwrap_or(""); match external_image(&format!("{u}{p}"), cx.base_dir) { Ok(path) => Some(path), Err(why) => { @@ -218,11 +217,11 @@ pub fn import( } } // Pass 2: precomp contents. - for a in &assets { - let (Some(id), Some(layers)) = (s(a, "id"), a.get("layers").and_then(Json::as_array)) else { continue }; + for a in assets { + let (Some(id), Some(layers)) = (a.id.as_deref(), a.layers.as_deref()) else { continue }; let Some((item, _)) = cx.assets.get(id).copied() else { continue }; let Some(mut c) = project.comp(item).cloned() else { continue }; - let end = layers.iter().filter_map(|l| f(l, "op")).fold(0.0f64, f64::max); + let end = layers.iter().filter_map(|l| l.out_point).fold(0.0f64, f64::max); if end > 0.0 { c.duration = tb.tick(end); c.work_area = (Tick::ZERO, c.duration); @@ -237,62 +236,64 @@ pub fn import( if ip > 0.0 { comp.work_area = (tb.tick(ip), duration); } - if let Some(ms) = doc.get("markers").and_then(Json::as_array) { - for m in ms { - comp.markers.push(Marker { - time: tb.tick(f(m, "tm").unwrap_or(0.0)), - duration: tb.tick(f(m, "dr").unwrap_or(0.0)), - comment: s(m, "cm").unwrap_or("").to_string(), - ..Default::default() - }); - } + for m in doc.markers.as_deref().unwrap_or_default() { + comp.markers.push(Marker { + time: tb.tick(m.time.unwrap_or(0.0)), + duration: tb.tick(m.duration.unwrap_or(0.0)), + comment: m.comment.clone().unwrap_or_default(), + ..Default::default() + }); } - let layers = doc.get("layers").and_then(Json::as_array).cloned().unwrap_or_default(); - build_layers(&mut cx, project, &mut comp, &layers); + build_layers(&mut cx, project, &mut comp, top); let id = project.add_item(&name, Label::Sandstone, None, ItemKind::Comp(Arc::new(comp))); cx.items.push(id); project.fix_next_id(); Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings }) } -/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`. -/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect. -fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> { - let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers"))); - lists.filter_map(Json::as_array).flatten().find_map(|l| { - if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) { - return None; - } - let (lw, lh) = f(l, "w").zip(f(l, "h"))?; - Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)) - }) +/// By asset id, the `w`/`h` of the first precomp layer, in the main comp or in any asset, that +/// shows it. lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its +/// clipping rect. One pass over all layers, not one per asset. +fn precomp_layer_sizes<'a>(top: &'a [LottieLayer], assets: &'a [Asset]) -> HashMap<&'a str, (u32, u32)> { + let mut out = HashMap::new(); + let lists = std::iter::once(top).chain(assets.iter().filter_map(|a| a.layers.as_deref())); + for l in lists.flatten() { + let LayerContent::Precomp(p) = &l.content else { continue }; + let (Some(id), Some((lw, lh))) = (p.ref_id.as_deref(), p.width.zip(p.height)) else { continue }; + out.entry(id).or_insert((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)); + } + out } -fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) { - let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option| { - if let (Some(j), Some(p)) = (j, tr.get_mut(m)) { - import_prop(im, j, p, conv); +fn transform(im: &Im, ks: &Transform, tr: &mut PropGroup, next: &mut u64) { + let set = |tr: &mut PropGroup, m: &str, j: Option<&Animated>, conv: &dyn Fn(&Json) -> Option| { + if let Some(p) = tr.get_mut(m) { + import_animated(im, j, p, conv); } }; - set(tr, "anchor", ks.get("a"), &|v| anim::to_vec3(v, 0.0)); - set(tr, "scale", ks.get("s"), &|v| anim::to_vec3(v, 100.0)); - set(tr, "rotation", ks.get("r").or(ks.get("rz")), &anim::to_scalar); - set(tr, "rotationX", ks.get("rx"), &anim::to_scalar); - set(tr, "rotationY", ks.get("ry"), &anim::to_scalar); - set(tr, "orientation", ks.get("or"), &|v| anim::to_vec3(v, 0.0)); - set(tr, "opacity", ks.get("o"), &anim::to_scalar); - let Some(p) = ks.get("p") else { return }; - if p.get("s").and_then(Json::as_bool) == Some(true) { + set(tr, "anchor", ks.anchor.as_ref(), &|v| anim::to_vec3(v, 0.0)); + set(tr, "scale", ks.scale.as_ref(), &|v| anim::to_vec3(v, 100.0)); + set(tr, "rotation", ks.rotation.as_ref().or(ks.rotation_z.as_ref()), &anim::to_scalar); + set(tr, "rotationX", ks.rotation_x.as_ref(), &anim::to_scalar); + set(tr, "rotationY", ks.rotation_y.as_ref(), &anim::to_scalar); + set(tr, "orientation", ks.orientation.as_ref(), &|v| anim::to_vec3(v, 0.0)); + set(tr, "opacity", ks.opacity.as_ref(), &anim::to_scalar); + let Some(p) = &ks.position else { return }; + if let Some(dims) = p.split() { // Separated dimensions: X/Y/Z Position after Position. let at = tr.children.iter().position(|c| c.match_id() == "position").map(|i| i + 1).unwrap_or(tr.children.len()); let base = tr.get("position").map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]); - for (d, (key, m, name)) in - [("x", "positionX", "X Position"), ("y", "positionY", "Y Position"), ("z", "positionZ", "Z Position")].iter().enumerate().rev() + for (d, (j, m, name)) in dims + .iter() + .zip([("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")]) + .map(|(j, (m, n))| (j, m, n)) + .enumerate() + .rev() { let mut pr = Property::new(*next, m, name, Value::Scalar(base[d])).with_ui(ParamUi::Number); *next += 1; pr.three_d_only = d == 2; - if let Some(j) = p.get(*key) { + if let Some(j) = j { import_prop(im, j, &mut pr, &anim::to_scalar); } tr.children.insert(at, Node::Prop(pr)); @@ -302,50 +303,50 @@ fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) { } } -fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) { +fn masks(im: &Im, ids: &mut Ids, list: &[Mask], g: &mut PropGroup) { for (i, m) in list.iter().enumerate() { - let mode = mask_mode_from(s(m, "mode").unwrap_or("a")); - let name = s(m, "nm").map(str::to_string).unwrap_or(format!("Mask {}", i + 1)); + let mode = mask_mode_from(m.mode.as_deref().unwrap_or("a")); + let name = m.name.clone().unwrap_or(format!("Mask {}", i + 1)); let color = build::MASK_COLORS[i % build::MASK_COLORS.len()]; let mut mg = build::mask(ids, &name, ShapePath::default(), mode, color); if let GroupKind::Mask { inverted, .. } = &mut mg.kind { - *inverted = m.get("inv").and_then(Json::as_bool).unwrap_or(false); + *inverted = m.inverted.unwrap_or(false); } - let mut set = |k: &str, mid: &str, conv: &dyn Fn(&Json) -> Option| { - if let (Some(j), Some(p)) = (m.get(k), mg.get_mut(mid)) { + let mut set = |j: Option<&Json>, mid: &str, conv: &dyn Fn(&Json) -> Option| { + if let (Some(j), Some(p)) = (j, mg.get_mut(mid)) { import_prop(im, j, p, conv); } }; - let closed = m.get("cl").and_then(Json::as_bool).unwrap_or(false); - set("pt", "path", &|v| shapes::to_path(v, closed)); - set("o", "opacity", &anim::to_scalar); - set("x", "expansion", &anim::to_scalar); - set("f", "feather", &anim::to_vec2); + let closed = m.closed.unwrap_or(false); + set(m.path.as_ref().map(|a| &a.0), "path", &|v| shapes::to_path(v, closed)); + set(m.opacity.as_ref().map(|a| &a.0), "opacity", &anim::to_scalar); + set(m.expansion.as_ref().map(|a| &a.0), "expansion", &anim::to_scalar); + set(m.extra.get("f"), "feather", &anim::to_vec2); g.children.push(Node::Group(mg)); } } -fn effects(im: &mut Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup, size: [f64; 2]) { +fn effects(im: &mut Im, ids: &mut Ids, list: &[Effect], g: &mut PropGroup, size: [f64; 2]) { for e in list { - let ty = e.get("ty").and_then(Json::as_u64).unwrap_or(0); - let params = e.get("ef").and_then(Json::as_array).cloned().unwrap_or_default(); - let inner_ty = params.first().and_then(|p| p.get("ty")).and_then(Json::as_u64); - let map = s(e, "mn").and_then(fx_by_id).or_else(|| fx_by_type(ty, inner_ty)); - let nm = s(e, "nm").unwrap_or("Effect"); + let ty = e.kind.and_then(|t| u64::try_from(t).ok()).unwrap_or(0); + let params: &[Effect] = e.values.as_deref().unwrap_or_default(); + let inner_ty = params.first().and_then(|p| p.kind).and_then(|t| u64::try_from(t).ok()); + let map = e.match_name.as_deref().and_then(fx_by_id).or_else(|| fx_by_type(ty, inner_ty)); + let nm = e.name.as_deref().unwrap_or("Effect"); let Some(map) = map else { im.warn(format!("effect `{nm}` (type {ty}) is not supported (skipped)")); continue; }; let Some(spec) = effectcraft_effects::find(map.id) else { continue }; let mut fg = effectcraft_effects::instantiate(spec, ids, nm, size); - if e.get("en").and_then(Json::as_u64) == Some(0) { + if e.enabled == Some(0) { fg.enabled = false; } for (i, (m, pty, scale, _)) in fx_params(map).iter().enumerate() { if m.is_empty() { continue; } - let Some(pj) = params.get(i).and_then(|p| p.get("v")) else { continue }; + let Some(pj) = params.get(i).and_then(|p| p.value.as_ref()) else { continue }; let Some(p) = fg.get_mut(m) else { continue }; let (pty, scale) = (*pty, *scale); let conv = move |v: &Json| -> Option { @@ -357,155 +358,162 @@ fn effects(im: &mut Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup, size: [ _ => anim::to_scalar(v).map(|x| Value::Scalar(x.as_f64() / scale)), } }; - import_prop(im, pj, p, &conv); + import_prop(im, &pj.0, p, &conv); } g.children.push(Node::Group(fg)); } } -fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> Option<(LayerSource, (u32, u32))> { - let ty = j.get("ty").and_then(Json::as_u64).unwrap_or(99); +fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &LottieLayer) -> Option<(LayerSource, (u32, u32))> { let full = (comp.width, comp.height); - let asset = |cx: &Ctx| s(j, "refId").and_then(|r| cx.assets.get(r).copied()); - Some(match ty { - 0 => { - let Some((item, _)) = asset(cx) else { - cx.im.warn(format!("{}: missing precomp asset", s(j, "nm").unwrap_or("layer"))); + let nm = j.name.as_deref(); + let asset = |cx: &Ctx, r: Option<&str>| r.and_then(|r| cx.assets.get(r).copied()); + Some(match &j.content { + LayerContent::Precomp(p) => { + let Some((item, _)) = asset(cx, p.ref_id.as_deref()) else { + cx.im.warn(format!("{}: missing precomp asset", nm.unwrap_or("layer"))); return None; }; let size = project.comp(item).map(|c| (c.width, c.height)).unwrap_or(full); (LayerSource::Comp { item }, size) } - 1 => { - let sw = f(j, "sw").unwrap_or(comp.width as f64).round().max(1.0) as u32; - let sh = f(j, "sh").unwrap_or(comp.height as f64).round().max(1.0) as u32; - let color = hex_color(s(j, "sc").unwrap_or("#000000")); - let name = s(j, "nm").unwrap_or("Solid"); + LayerContent::Solid(sl) => { + let sw = sl.width.unwrap_or(comp.width as f64).round().max(1.0) as u32; + let sh = sl.height.unwrap_or(comp.height as f64).round().max(1.0) as u32; + let color = craft_lottie::hex_color(sl.color.as_deref().unwrap_or("#000000")); + let name = nm.unwrap_or("Solid"); let folder = cx.folder(project); let item = project.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: sw, height: sh, pixel_aspect: 1.0 })); cx.items.push(item); (LayerSource::Solid { item }, (sw, sh)) } - 2 => { - let Some((item, size)) = asset(cx) else { - cx.im.warn(format!("{}: missing image asset", s(j, "nm").unwrap_or("layer"))); + LayerContent::Image(r) => { + let Some((item, size)) = asset(cx, r.as_deref()) else { + cx.im.warn(format!("{}: missing image asset", nm.unwrap_or("layer"))); return None; }; (LayerSource::Footage { item }, size.unwrap_or(full)) } - 3 => (LayerSource::Null, (100, 100)), - 4 => (LayerSource::Shape, full), - 5 => (LayerSource::Text, full), - 13 => { - cx.im.warn(format!("{}: camera layers are not imported", s(j, "nm").unwrap_or("camera"))); - return None; - } - other => { - cx.im.warn(format!("{}: layer type {other} is not supported (skipped)", s(j, "nm").unwrap_or("layer"))); - return None; - } + LayerContent::Null => (LayerSource::Null, (100, 100)), + LayerContent::Shape(_) => (LayerSource::Shape, full), + LayerContent::Text(_) => (LayerSource::Text, full), + LayerContent::Other(ty) => match ty.as_u64().unwrap_or(99) { + // A shape or text layer whose content is missing or unreadable is an empty one. + 4 => (LayerSource::Shape, full), + 5 => (LayerSource::Text, full), + 13 => { + cx.im.warn(format!("{}: camera layers are not imported", nm.unwrap_or("camera"))); + return None; + } + other => { + cx.im.warn(format!("{}: layer type {other} is not supported (skipped)", nm.unwrap_or("layer"))); + return None; + } + }, }) } /// Build the layers of a comp (Lottie order: first = top = layer #1). -fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) { +fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[LottieLayer]) { let tb = cx.im.tb; let mut by_ind: BTreeMap = BTreeMap::new(); - // (layer id, parent ind, matte: (kind, explicit tp ind), json position) - let mut pending: Vec<(LayerId, Option, Option<(MatteKind, Option)>, usize)> = vec![]; + // (layer id, its index in `comp.layers`, parent ind, matte: (kind, explicit tp ind), json position) + let mut pending: Vec<(LayerId, usize, Option, Option<(MatteKind, Option)>, usize)> = vec![]; let mut pos_ind: Vec> = vec![]; for (ji, j) in layers.iter().enumerate() { - let ind = j.get("ind").and_then(Json::as_i64); + let ind = j.index; pos_ind.push(ind); let Some((source, size)) = layer_source(cx, project, comp, j) else { continue }; - let name = s(j, "nm").unwrap_or(source.type_name()).to_string(); + let name = j.name.clone().unwrap_or_else(|| source.type_name().to_string()); let mut l: Layer = build::layer(project, comp, &name, source.clone(), size, None); if let Some(i) = ind { by_ind.insert(i, l.id); } - let ip = f(j, "ip").unwrap_or(0.0); - let op = f(j, "op").unwrap_or(tb.frame(comp.duration)); + let ip = j.in_point.unwrap_or(0.0); + let op = j.out_point.unwrap_or(tb.frame(comp.duration)); l.in_point = tb.tick(ip); l.out_point = tb.tick(op.max(ip)); - l.start_time = tb.tick(f(j, "st").unwrap_or(0.0)); + l.start_time = tb.tick(j.start_time.unwrap_or(0.0)); // Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too. - let sr = f(j, "sr").unwrap_or(1.0); + let sr = j.stretch.unwrap_or(1.0); l.stretch = if sr.is_finite() && sr.abs() >= 0.01 { - sr * 100.0 + // Past ±9900 % too (#3247): the largest stretch a layer supports, sign kept. + let pct = effectcraft_project::limits::stretch(sr * 100.0); + if pct.to_bits() != (sr * 100.0).to_bits() { + cx.im.warn(format!("{name}: a time stretch of {} % is not supported ({pct} % used)", sr * 100.0)); + } + pct } else { cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0)); 100.0 }; - if j.get("ddd").and_then(Json::as_u64) == Some(1) { + if j.three_d == Some(1) { l.switches.three_d = true; } - if let Some(bm) = j.get("bm").and_then(Json::as_u64) { + if let Some(bm) = j.blend_mode.and_then(|b| u64::try_from(b).ok()) { l.blend_mode = crate::blend_from_lottie(bm); } - if j.get("ao").and_then(Json::as_u64) == Some(1) { + if j.auto_orient == Some(1) { l.auto_orient = AutoOrient::AlongPath; } - if j.get("hd").and_then(Json::as_bool) == Some(true) { + if j.hidden == Some(true) { l.switches.video = false; } - if j.get("td").and_then(Json::as_u64) == Some(1) { + if j.is_matte == Some(1) { l.switches.video = false; } let mut next = project.next_id; - if let (Some(ks), Some(tr)) = (j.get("ks"), l.props.sub_mut("transform")) { + if let (Some(ks), Some(tr)) = (&j.transform, l.props.sub_mut("transform")) { transform(&cx.im, ks, tr, &mut next); } let mut ids = Ids(&mut next); - match &source { - LayerSource::Shape => { - let items = j.get("shapes").and_then(Json::as_array).cloned().unwrap_or_default(); - let groups = shapes::import_items(&mut cx.im, &mut ids, &items); + match (&source, &j.content) { + (LayerSource::Shape, LayerContent::Shape(items)) => { + let groups = shapes::import_items(&mut cx.im, &mut ids, &mut items.iter()); if let Some(c) = l.props.sub_mut("contents") { c.children.extend(groups.into_iter().map(Node::Group)); } } - LayerSource::Text => { - if let (Some(t), Some(tg)) = (j.get("t"), l.props.sub_mut("text")) { + (LayerSource::Text, LayerContent::Text(t)) => { + if let Some(tg) = l.props.sub_mut("text") { text::import_text(&mut cx.im, &mut ids, t, tg, &cx.fonts); } } - LayerSource::Comp { .. } => { - if let Some(tm) = j.get("tm") { + (LayerSource::Comp { .. }, LayerContent::Precomp(p)) => { + if let Some(tm) = &p.time_remap { let mut pr = ids.prop("timeRemap", "Time Remap", Value::Scalar(0.0)).with_ui(ParamUi::Number); - import_prop(&cx.im, tm, &mut pr, &anim::to_scalar); + import_prop(&cx.im, &tm.0, &mut pr, &anim::to_scalar); l.props.children.insert(0, Node::Prop(pr)); } } _ => {} } - if let Some(list) = j.get("masksProperties").and_then(Json::as_array) + if let Some(list) = &j.masks && let Some(g) = l.props.sub_mut("masks") { masks(&cx.im, &mut ids, list, g); } - if let Some(list) = j.get("ef").and_then(Json::as_array) + if let Some(list) = &j.effects && let Some(g) = l.props.sub_mut("effects") { effects(&mut cx.im, &mut ids, list, g, [size.0 as f64, size.1 as f64]); } - if j.get("sy").and_then(Json::as_array).is_some_and(|a| !a.is_empty()) { + if j.extra.get("sy").and_then(Json::as_array).is_some_and(|a| !a.is_empty()) { cx.im.warn(format!("{name}: layer styles are not imported")); } project.next_id = next; - let parent = j.get("parent").and_then(Json::as_i64); - let tt = j.get("tt").and_then(Json::as_u64).unwrap_or(0); - let matte = match tt { + let matte = match j.matte_mode.unwrap_or(0) { 1 => Some(MatteKind::Alpha), 2 => Some(MatteKind::AlphaInverted), 3 => Some(MatteKind::Luma), 4 => Some(MatteKind::LumaInverted), _ => None, } - .map(|k| (k, j.get("tp").and_then(Json::as_i64))); - pending.push((l.id, parent, matte, ji)); + .map(|k| (k, j.matte_parent)); + pending.push((l.id, comp.layers.len(), j.parent, matte, ji)); comp.layers.push(l); } - for (id, parent, matte, ji) in pending { + for (id, at, parent, matte, ji) in pending { let parent_id = parent.and_then(|p| by_ind.get(&p).copied()).filter(|p| *p != id); let matte_id = matte.and_then(|(k, tp)| { let target = match tp { @@ -515,21 +523,9 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ }; target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k }) }); - if let Some(l) = comp.layer_mut(id) { + if let Some(l) = comp.layers.get_mut(at) { l.parent = parent_id; l.track_matte = matte_id; } } } - -#[cfg(test)] -mod hex_color_tests { - use super::hex_color; - - #[test] - fn hex_color_rejects_plus_sign() { - assert_eq!(hex_color("#+f0000"), [0.0, 0.0, 0.0]); - assert_eq!(hex_color("#zz0000"), [0.0, 0.0, 0.0]); - assert_eq!(hex_color("#ff0000"), [1.0, 0.0, 0.0]); - } -} diff --git a/crates/lottie/src/lib.rs b/crates/lottie/src/lib.rs index 1b12cc8be..0a1f4bc96 100644 --- a/crates/lottie/src/lib.rs +++ b/crates/lottie/src/lib.rs @@ -1,7 +1,7 @@ //! Lottie JSON import and export. //! -//! Implemented from the public Lottie format description (the lottie-spec 1.0 schema and the -//! LottieFiles format documentation); no player or exporter source was used. +//! The format itself (the document model, property and keyframe encoding, paths, gradients, +//! embedded images) is `craft-lottie`; this crate maps it onto EffectCraft's project. //! //! **Export** ([`export_comp`]) turns a composition into a Lottie document: nested compositions //! become precomp assets, still footage becomes embedded base64 image assets, and shape, solid, @@ -21,7 +21,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod anim; -mod base64; mod export; mod import; mod shapes; diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index fab8d3646..f28e73a04 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -1,23 +1,27 @@ //! Shape layer contents ↔ Lottie shape items (`gr`, `rc`, `el`, `sr`, `sh`, `fl`, `st`, `gf`, -//! `gs`, `tm`, `rp`, `rd`, `op`, `pb`, `tw`, `zz`, `mm`, `tr`). +//! `gs`, `tm`, `rp`, `rd`, `op`, `pb`, `tw`, `zz`, `mm`, `tr`), on `craft-lottie`'s model. +use craft_lottie::model::{ + Dash, Ellipse, Fill, GradientColors, GradientFill, GradientStroke, Group, Map, Merge, OffsetPath, Path, PolyStar, PuckerBloat, Rect, Repeater, + RepeaterTransform, RoundCorners, Shape, ShapeKind, Stroke, Transform, Trim, Twist, ZigZag, +}; +use craft_lottie::{Animated, Bezier, Json}; use effectcraft_color::BlendMode; use effectcraft_keyframe::{Gradient, ShapePath, Value}; use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; use effectcraft_project::{Node, ParamUi, PropGroup}; -use serde_json::{Map, Value as Json, json}; +use serde_json::json; use crate::anim::{self, Ex, Im, export_prop, import_prop}; // ---------------------------------------------------------------- shared value converters -pub(crate) fn shape_json(p: &ShapePath) -> Json { - let pts = |v: &[[f64; 2]]| v.iter().map(|q| json!([r(q[0]), r(q[1])])).collect::>(); - json!({"c": p.closed, "v": pts(&p.vertices), "i": pts(&p.in_tangents), "o": pts(&p.out_tangents)}) +pub(crate) fn bezier(p: &ShapePath) -> Bezier { + Bezier { closed: p.closed, vertices: p.vertices.clone(), in_tangents: p.in_tangents.clone(), out_tangents: p.out_tangents.clone() } } -fn r(v: f64) -> f64 { - (v * 1e6).round() / 1e6 +pub(crate) fn shape_json(p: &ShapePath) -> Json { + bezier(p).to_json() } pub(crate) fn path_value(v: &Value) -> Json { @@ -30,29 +34,8 @@ pub(crate) fn path_value(v: &Value) -> Json { /// A Lottie path. `closed` is used when the path has no `c`: Bodymovin 3.x and early 4.x keep the /// flag outside the path, as `cl` on a mask and `closed` on an `sh` item. pub(crate) fn to_path(j: &Json, closed: bool) -> Option { - let o = match j { - Json::Array(a) => a.first()?, - o => o, - }; - let pts = |k: &str| -> Vec<[f64; 2]> { - o.get(k) - .and_then(Json::as_array) - .map(|a| a.iter().map(|p| anim::nums(p).map(|v| [*v.first().unwrap_or(&0.0), *v.get(1).unwrap_or(&0.0)]).unwrap_or([0.0; 2])).collect()) - .unwrap_or_default() - }; - let vertices = pts("v"); - let n = vertices.len(); - let fit = |mut v: Vec<[f64; 2]>| { - v.resize(n, [0.0; 2]); - v - }; - Some(Value::Path(ShapePath { - in_tangents: fit(pts("i")), - out_tangents: fit(pts("o")), - vertices, - closed: o.get("c").and_then(Json::as_bool).unwrap_or(closed), - feather: Vec::new(), - })) + let b = Bezier::from_json(j, closed)?; + Some(Value::Path(ShapePath { vertices: b.vertices, in_tangents: b.in_tangents, out_tangents: b.out_tangents, closed: b.closed, feather: Vec::new() })) } /// Gradient as Lottie's flat stop list: `[pos, r, g, b]…` then `[pos, alpha]…`. @@ -61,29 +44,19 @@ pub(crate) fn gradient_flat(v: &Value) -> Json { Value::Gradient(g) => g.clone(), _ => Gradient::default(), }; - let mut out = vec![]; - for (p, c) in &g.colors { - out.extend([r(*p), r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)]); + craft_lottie::Gradient { + colors: g.colors.iter().map(|(p, c)| (*p, [f64::from(c[0]), f64::from(c[1]), f64::from(c[2])])).collect(), + opacities: g.opacities.iter().map(|(p, a)| (*p, f64::from(*a))).collect(), } - let plain = g.opacities.iter().all(|(_, a)| (*a - 1.0).abs() < 1e-6); - if !plain { - for (p, a) in &g.opacities { - out.extend([r(*p), r(*a as f64)]); - } - } - json!(out) + .to_flat() } pub(crate) fn to_gradient(j: &Json, ncolors: usize) -> Option { - let v = anim::nums(j)?; - let n = ncolors.min(v.len() / 4); - let colors: Vec<(f64, [f32; 4])> = (0..n).map(|i| (v[i * 4], [v[i * 4 + 1] as f32, v[i * 4 + 2] as f32, v[i * 4 + 3] as f32, 1.0])).collect(); - let rest = &v[n * 4..]; - let mut opacities: Vec<(f64, f32)> = rest.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1] as f32)).collect(); - if opacities.is_empty() { - opacities = vec![(0.0, 1.0), (1.0, 1.0)]; - } - Some(Value::Gradient(Gradient { colors, opacities })) + let g = craft_lottie::Gradient::from_flat(j, ncolors)?; + Some(Value::Gradient(Gradient { + colors: g.colors.iter().map(|(p, [r, gr, b])| (*p, [*r as f32, *gr as f32, *b as f32, 1.0])).collect(), + opacities: g.opacities.iter().map(|(p, a)| (*p, *a as f32)).collect(), + })) } fn blend_index(m: BlendMode) -> u32 { @@ -92,22 +65,18 @@ fn blend_index(m: BlendMode) -> u32 { // ---------------------------------------------------------------- export +/// A group's properties, exported with the warnings they need. struct Out<'a, 'b> { ex: &'a mut Ex, g: &'b PropGroup, - o: Map, label: String, } impl Out<'_, '_> { - fn put(&mut self, key: &str, m: &str, conv: &dyn Fn(&Value) -> Json) { - if let Some(p) = self.g.get(m) { - let v = export_prop(self.ex, p, conv, &format!("{} ▸ {}", self.label, p.name)); - self.o.insert(key.into(), v); - } - } - fn raw(&mut self, key: &str, v: Json) { - self.o.insert(key.into(), v); + /// The property `m` of the group, when it has one. + fn put(&mut self, m: &str, conv: &dyn Fn(&Value) -> Json) -> Option { + let p = self.g.get(m)?; + Some(export_prop(self.ex, p, conv, &format!("{} ▸ {}", self.label, p.name))) } fn e(&self, m: &str) -> u32 { self.g.get(m).map(|p| p.value.as_enum()).unwrap_or(0) @@ -121,34 +90,46 @@ impl Out<'_, '_> { fn animated(&self, m: &str) -> bool { self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression()) } - fn blend(&mut self) { + fn blend(&mut self) -> i64 { let i = self.e("blend"); let m = BlendMode::ALL.get(i as usize).copied().unwrap_or_default(); match crate::blend_to_lottie(m) { - Some(bm) => self.raw("bm", json!(bm)), + Some(bm) => i64::from(bm), None => { self.ex.warn(format!("{}: blend mode {m:?} has no Lottie equivalent (Normal used)", self.label)); - self.raw("bm", json!(0)); + 0 } } } - fn done(self) -> Json { - Json::Object(self.o) + fn direction(&self) -> i64 { + if self.e("direction") == 1 { 3 } else { 1 } } } -/// Stroke fields shared by `st` and `gs`. -fn stroke_fields(o: &mut Out) { - o.put("w", "width", &anim::num); - o.raw("lc", json!(o.e("cap") + 1)); - o.raw("lj", json!(o.e("join") + 1)); - o.raw("ml", json!(o.f("miter"))); +/// A static property (`{a: 0, k}`). +pub(crate) fn fixed(k: Json) -> Animated { + Animated(json!({"a": 0, "k": k})) +} + +/// Stroke fields shared by `st` and `gs`: width, cap, join, miter limit, dashes. +struct StrokeOut { + width: Option, + cap: i64, + join: i64, + miter: f64, + dashes: Option>, +} + +fn stroke_fields(o: &mut Out) -> StrokeOut { + let width = o.put("width", &anim::num); + let (cap, join, miter) = (i64::from(o.e("cap")) + 1, i64::from(o.e("join")) + 1, o.f("miter")); let used = |g: Option<&PropGroup>, m: &str| g.and_then(|g| g.get(m)).is_some_and(|p| p.is_animated() || p.value.as_f64().abs() > 1e-9); let (taper, wave) = (o.g.sub("taper"), o.g.sub("wave")); if used(taper, "startLength") || used(taper, "endLength") || used(wave, "amount") { let msg = format!("{}: stroke Taper and Wave have no Lottie equivalent (uniform width exported)", o.label); o.ex.warn(msg); } + let mut dashes = None; if let Some(d) = o.g.sub("dashes") { let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0); if dash > 0.0 || d.any_animated() { @@ -156,202 +137,257 @@ fn stroke_fields(o: &mut Out) { for (n, m) in [("d", "dash"), ("g", "gap"), ("d", "dash2"), ("g", "gap2"), ("d", "dash3"), ("g", "gap3"), ("o", "offset")] { if let Some(p) = d.get(m) { let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name)); - list.push(json!({"n": n, "nm": p.name, "v": v})); + list.push(Dash { kind: Some(n.into()), name: Some(p.name.clone()), length: Some(v), ..Default::default() }); } } - o.raw("d", json!(list)); + dashes = Some(list); } } + StrokeOut { width, cap, join, miter, dashes } } -fn shape_transform(ex: &mut Ex, tr: &PropGroup, label: &str) -> Json { - let mut o = Out { ex, g: tr, o: Map::new(), label: format!("{label} ▸ Transform") }; - o.raw("ty", json!("tr")); - o.put("a", "anchor", &anim::arr2); - o.put("p", "position", &anim::arr2); - o.put("s", "scale", &anim::arr2); - o.put("r", "rotation", &anim::num); - o.put("o", "opacity", &anim::num); - o.put("sk", "skew", &anim::num); - o.put("sa", "skewAxis", &anim::num); - o.put("so", "startOpacity", &anim::num); - o.put("eo", "endOpacity", &anim::num); - o.done() +/// The `tr` of a group or a repeater: anchor, position, scale, rotation, opacity, skew, and the +/// repeater's start and end opacity, by their Lottie keys. +fn shape_transform(ex: &mut Ex, tr: &PropGroup, label: &str) -> Vec<(&'static str, Animated)> { + let mut o = Out { ex, g: tr, label: format!("{label} ▸ Transform") }; + let mut out = vec![]; + for (k, m, conv) in [ + ("a", "anchor", anim::arr2 as fn(&Value) -> Json), + ("p", "position", anim::arr2), + ("s", "scale", anim::arr2), + ("r", "rotation", anim::num), + ("o", "opacity", anim::num), + ("sk", "skew", anim::num), + ("sa", "skewAxis", anim::num), + ("so", "startOpacity", anim::num), + ("eo", "endOpacity", anim::num), + ] { + if let Some(v) = o.put(m, &conv) { + out.push((k, v)); + } + } + out +} + +/// The `tr` item that ends a group's items. +fn group_transform(keys: Vec<(&'static str, Animated)>) -> Shape { + let (mut t, mut extra) = (Transform::default(), Map::new()); + for (k, v) in keys { + match k { + "a" => t.anchor = Some(v), + "p" => t.position = Some(v), + "s" => t.scale = Some(v), + "r" => t.rotation = Some(v), + "o" => t.opacity = Some(v), + "sk" => t.skew = Some(v), + "sa" => t.skew_axis = Some(v), + _ => { + extra.insert(k.into(), v.0); + } + } + } + Shape { kind: ShapeKind::Transform(Box::new(t)), extra, ..Default::default() } +} + +/// A repeater's `tr`, which players read with `ty: "tr"` like a group's. +fn repeater_transform(keys: Vec<(&'static str, Animated)>) -> RepeaterTransform { + let mut t = RepeaterTransform::default(); + t.extra.insert("ty".into(), json!("tr")); + for (k, v) in keys { + match k { + "a" => t.anchor = Some(v), + "p" => t.position = Some(v), + "s" => t.scale = Some(v), + "r" => t.rotation = Some(v), + "so" => t.start_opacity = Some(v), + "eo" => t.end_opacity = Some(v), + "sk" => t.skew = Some(v), + "sa" => t.skew_axis = Some(v), + _ => { + t.extra.insert(k.into(), v.0); + } + } + } + t } /// Export a contents group's items (first item = top, as in the timeline). -pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Vec { +pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Vec { let mut out = vec![]; for node in &contents.children { let Node::Group(g) = node else { continue }; let lbl = format!("{label} ▸ {}", g.name); - let ty = match g.match_id.as_str() { - "group" => "gr", - "rect" => "rc", - "ellipse" => "el", - "star" => "sr", - "path" => "sh", - "fill" => "fl", - "stroke" => "st", - "gfill" => "gf", - "gstroke" => "gs", - "trim" => "tm", - "repeater" => "rp", - "round" => "rd", - "offset" => "op", - "pucker" => "pb", - "twist" => "tw", - "zigzag" => "zz", - "merge" => "mm", - other => { - ex.warn(format!("{lbl}: shape item `{other}` has no Lottie equivalent (skipped)")); - continue; + let mut o = Out { ex, g, label: lbl.clone() }; + let mut blend = None; + let kind = match g.match_id.as_str() { + "group" => { + let mut items = g.sub("contents").map(|c| export_items(o.ex, c, &lbl)).unwrap_or_default(); + items.push(match g.sub("transform") { + Some(t) => group_transform(shape_transform(o.ex, t, &lbl)), + None => group_transform(vec![ + ("a", fixed(json!([0, 0]))), + ("p", fixed(json!([0, 0]))), + ("s", fixed(json!([100, 100]))), + ("r", fixed(json!(0))), + ("o", fixed(json!(100))), + ]), + }); + blend = Some(o.blend()); + ShapeKind::Group(Group { count: Some(items.len() as f64), items: Some(items) }) } - }; - let mut o = Out { ex, g, o: Map::new(), label: lbl.clone() }; - o.raw("ty", json!(ty)); - o.raw("nm", json!(g.name)); - if !g.enabled { - o.raw("hd", json!(true)); - } - let dir = |o: &mut Out| { - let d = if o.e("direction") == 1 { 3 } else { 1 }; - o.raw("d", json!(d)); - }; - match ty { - "gr" => { - let items = g.sub("contents").map(|c| export_items(o.ex, c, &lbl)).unwrap_or_default(); - let mut items = items; - let tr = match g.sub("transform") { - Some(t) => shape_transform(o.ex, t, &lbl), - None => { - json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}}) - } - }; - items.push(tr); - o.raw("np", json!(items.len())); - o.raw("it", json!(items)); - o.blend(); - } - "rc" => { - dir(&mut o); - o.put("s", "size", &anim::arr2); - o.put("p", "position", &anim::arr2); - if o.flag("perCorner") { + "rect" => { + let direction = Some(o.direction()); + let size = o.put("size", &anim::arr2); + let position = o.put("position", &anim::arr2); + let roundness = if o.flag("perCorner") { let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"]; let radii = names.map(|m| o.f(m)); // Lottie `rc` has one `r`: four equal static radii export as that value. // `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number. if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 { - o.raw("r", json!({"a": 0, "k": r(radii[0])})); + Some(fixed(json!(craft_lottie::round6(radii[0])))) } else { o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label)); - o.raw("r", json!({"a": 0, "k": 0})); + Some(fixed(json!(0))) } } else { - o.put("r", "roundness", &anim::num); - } + o.put("roundness", &anim::num) + }; + ShapeKind::Rect(Rect { direction, position, size, roundness }) } - "el" => { - dir(&mut o); - o.put("s", "size", &anim::arr2); - o.put("p", "position", &anim::arr2); + "ellipse" => { + let direction = Some(o.direction()); + let size = o.put("size", &anim::arr2); + ShapeKind::Ellipse(Ellipse { direction, position: o.put("position", &anim::arr2), size }) } - "sr" => { - dir(&mut o); - o.raw("sy", json!(o.e("type") + 1)); - o.put("pt", "points", &anim::num); - o.put("p", "position", &anim::arr2); - o.put("r", "rotation", &anim::num); - o.put("ir", "innerRadius", &anim::num); - o.put("or", "outerRadius", &anim::num); - o.put("is", "innerRoundness", &anim::num); - o.put("os", "outerRoundness", &anim::num); + "star" => ShapeKind::PolyStar(PolyStar { + direction: Some(o.direction()), + star_type: Some(i64::from(o.e("type")) + 1), + points: o.put("points", &anim::num), + position: o.put("position", &anim::arr2), + rotation: o.put("rotation", &anim::num), + inner_radius: o.put("innerRadius", &anim::num), + outer_radius: o.put("outerRadius", &anim::num), + inner_roundness: o.put("innerRoundness", &anim::num), + outer_roundness: o.put("outerRoundness", &anim::num), + }), + "path" => ShapeKind::Path(Path { direction: Some(o.direction()), path: o.put("path", &path_value), closed: None }), + "fill" => { + let f = Fill { color: o.put("color", &anim::arr), opacity: o.put("opacity", &anim::num), rule: Some(i64::from(o.e("rule")) + 1) }; + blend = Some(o.blend()); + ShapeKind::Fill(f) } - "sh" => { - dir(&mut o); - o.put("ks", "path", &path_value); + "stroke" => { + let (color, opacity) = (o.put("color", &anim::arr), o.put("opacity", &anim::num)); + let st = stroke_fields(&mut o); + blend = Some(o.blend()); + ShapeKind::Stroke(Stroke { + color, + opacity, + width: st.width, + cap: Some(st.cap), + join: Some(st.join), + miter_limit: Some(st.miter), + miter_limit_animated: None, + dashes: st.dashes, + }) } - "fl" => { - o.put("c", "color", &anim::arr); - o.put("o", "opacity", &anim::num); - o.raw("r", json!(o.e("rule") + 1)); - o.blend(); - } - "st" => { - o.put("c", "color", &anim::arr); - o.put("o", "opacity", &anim::num); - stroke_fields(&mut o); - o.blend(); - } - "gf" | "gs" => { - o.put("o", "opacity", &anim::num); - o.put("s", "start", &anim::arr2); - o.put("e", "end", &anim::arr2); - o.raw("t", json!(o.e("type") + 1)); - o.put("h", "highlightLength", &anim::num); - o.put("a", "highlightAngle", &anim::num); + m @ ("gfill" | "gstroke") => { + let opacity = o.put("opacity", &anim::num); + let (start, end) = (o.put("start", &anim::arr2), o.put("end", &anim::arr2)); + let gradient_type = Some(i64::from(o.e("type")) + 1); + let (highlight_length, highlight_angle) = (o.put("highlightLength", &anim::num), o.put("highlightAngle", &anim::num)); let ncolors = match g.get("colors").map(|p| &p.value) { Some(Value::Gradient(gr)) => gr.colors.len(), _ => 2, }; - if let Some(p) = g.get("colors") { - let k = export_prop(o.ex, p, &gradient_flat, &format!("{lbl} ▸ Colors")); - o.raw("g", json!({"p": ncolors, "k": k})); - } - if ty == "gf" { - o.raw("r", json!(o.e("rule") + 1)); + let colors = g.get("colors").map(|p| GradientColors { + count: Some(ncolors as i64), + stops: Some(export_prop(o.ex, p, &gradient_flat, &format!("{lbl} ▸ Colors"))), + ..Default::default() + }); + let kind = if m == "gfill" { + ShapeKind::GradientFill(GradientFill { + colors, + start, + end, + gradient_type, + highlight_length, + highlight_angle, + opacity, + rule: Some(i64::from(o.e("rule")) + 1), + }) } else { - stroke_fields(&mut o); - } - o.blend(); + let st = stroke_fields(&mut o); + ShapeKind::GradientStroke(GradientStroke { + colors, + start, + end, + gradient_type, + highlight_length, + highlight_angle, + opacity, + width: st.width, + cap: Some(st.cap), + join: Some(st.join), + miter_limit: Some(st.miter), + miter_limit_animated: None, + dashes: st.dashes, + }) + }; + blend = Some(o.blend()); + kind } - "tm" => { - o.put("s", "start", &anim::num); - o.put("e", "end", &anim::num); - o.put("o", "offset", &anim::num); - o.raw("m", json!(o.e("mode") + 1)); - } - "rp" => { - o.put("c", "copies", &anim::num); - o.put("o", "offset", &anim::num); + "trim" => ShapeKind::Trim(Trim { + start: o.put("start", &anim::num), + end: o.put("end", &anim::num), + offset: o.put("offset", &anim::num), + multiple: Some(i64::from(o.e("mode")) + 1), + }), + "repeater" => { + let (copies, offset) = (o.put("copies", &anim::num), o.put("offset", &anim::num)); // Lottie: 1 = Above, 2 = Below; EffectCraft: 0 = Below, 1 = Above. - o.raw("m", json!(if o.e("composite") == 1 { 1 } else { 2 })); - if let Some(t) = g.sub("transform") { - let tr = shape_transform(o.ex, t, &lbl); - o.raw("tr", tr); - } + let composite = Some(if o.e("composite") == 1 { 1 } else { 2 }); + let transform = g.sub("transform").map(|t| repeater_transform(shape_transform(o.ex, t, &lbl))); + ShapeKind::Repeater(Repeater { copies, offset, composite, transform }) } - "rd" => o.put("r", "radius", &anim::num), - "op" => { - o.put("a", "amount", &anim::num); - o.raw("lj", json!(o.e("join") + 1)); - o.put("ml", "miter", &anim::num); + "round" => ShapeKind::RoundCorners(RoundCorners { radius: o.put("radius", &anim::num) }), + "offset" => { + let amount = o.put("amount", &anim::num); + let join = Some(i64::from(o.e("join")) + 1); + let miter_limit = o.put("miter", &anim::num); if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 { o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)")); } + ShapeKind::OffsetPath(OffsetPath { amount, join, miter_limit }) } - "pb" => o.put("a", "amount", &anim::num), - "tw" => { - o.put("a", "angle", &anim::num); - o.put("c", "center", &anim::arr2); + "pucker" => ShapeKind::PuckerBloat(PuckerBloat { amount: o.put("amount", &anim::num) }), + "twist" => ShapeKind::Twist(Twist { angle: o.put("angle", &anim::num), center: o.put("center", &anim::arr2) }), + "zigzag" => ShapeKind::ZigZag(ZigZag { + frequency: o.put("size", &anim::num), + amount: o.put("ridges", &anim::num), + point_type: Some(fixed(json!(o.e("points") + 1))), + }), + "merge" => ShapeKind::Merge(Merge { mode: Some(i64::from(o.e("mode")) + 1) }), + other => { + o.ex.warn(format!("{lbl}: shape item `{other}` has no Lottie equivalent (skipped)")); + continue; } - "zz" => { - o.put("s", "size", &anim::num); - o.put("r", "ridges", &anim::num); - o.raw("pt", json!({"a": 0, "k": o.e("points") + 1})); - } - "mm" => o.raw("mm", json!(o.e("mode") + 1)), - _ => {} - } - out.push(o.done()); + }; + out.push(Shape { name: Some(g.name.clone()), hidden: (!g.enabled).then_some(true), blend_mode: blend, kind, ..Default::default() }); } out } // ---------------------------------------------------------------- import -fn set(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option) { +fn set(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Animated>, conv: &dyn Fn(&Json) -> Option) { + if let (Some(j), Some(p)) = (j, g.get_mut(m)) { + import_prop(im, &j.0, p, conv); + } +} + +fn set_json(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option) { if let (Some(j), Some(p)) = (j, g.get_mut(m)) { import_prop(im, j, p, conv); } @@ -363,43 +399,60 @@ fn set_enum(g: &mut PropGroup, m: &str, v: u32) { } } -fn int(j: &Json, k: &str) -> Option { - let v = j.get(k)?; +/// A whole number however a file writes it: `2`, `2.0`, `2.4` (rounded) or a property `{k: 2}`. +fn int_json(v: Option<&Json>) -> Option { + let v = v?; v.as_i64().or_else(|| v.as_f64().map(|f| f.round() as i64)).or_else(|| v.get("k").and_then(anim::nums).and_then(|n| n.first().map(|f| f.round() as i64))) } -fn import_blend(j: &Json, g: &mut PropGroup) { - if let Some(bm) = j.get("bm").and_then(Json::as_u64) { +/// An integer key of the model, or the value the model kept in `extra` when it was written +/// another way. +fn int(field: Option, extra: &Map, key: &str) -> Option { + field.or_else(|| int_json(extra.get(key))) +} + +fn import_blend(s: &Shape, g: &mut PropGroup) { + if let Some(bm) = int(s.blend_mode, &s.extra, "bm").and_then(|b| u64::try_from(b).ok()) { set_enum(g, "blend", blend_index(crate::blend_from_lottie(bm))); } } -fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) { - set(im, g, "width", j.get("w"), &anim::to_scalar); - if let Some(lc) = int(j, "lc") { +/// The stroke keys of `st` and `gs`. +struct StrokeIn<'a> { + width: Option<&'a Animated>, + cap: Option, + join: Option, + miter: Option, + miter_animated: Option<&'a Animated>, + dashes: Option<&'a [Dash]>, +} + +fn import_stroke(im: &Im, ids: &mut Ids, st: StrokeIn<'_>, g: &mut PropGroup) { + set(im, g, "width", st.width, &anim::to_scalar); + if let Some(lc) = st.cap { set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32); } - if let Some(lj) = int(j, "lj") { + if let Some(lj) = st.join { set_enum(g, "join", lj.saturating_sub(1).clamp(0, 2) as u32); } if let Some(p) = g.get_mut("miter") { - if let Some(ml) = j.get("ml").and_then(Json::as_f64) { + if let Some(ml) = st.miter { p.value = Value::Scalar(ml); - } else if let Some(ml2) = j.get("ml2") { - import_prop(im, ml2, p, &anim::to_scalar); + } else if let Some(ml2) = st.miter_animated { + import_prop(im, &ml2.0, p, &anim::to_scalar); } } - if let Some(list) = j.get("d").and_then(Json::as_array) + if let Some(list) = st.dashes && let Some(d) = g.sub_mut("dashes") { // The n-th `d` and `g` are the n-th Dash/Gap pair; the extra pairs are created as the Dashes "+" does. let (mut dashes, mut gaps) = (0, 0); for e in list { - let (n, pair) = match e.get("n").and_then(Json::as_str) { + let (n, pair) = match e.kind.as_deref() { Some("d") => (&mut dashes, 0), Some("g") => (&mut gaps, 1), Some("o") => { - set(im, d, "offset", e.get("v"), &anim::to_scalar); + set(im, d, "offset", e.length.as_ref(), &anim::to_scalar); continue; } _ => continue, @@ -418,205 +471,299 @@ fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) { [dm, gm][pair] } }; - set(im, d, m, e.get("v"), &anim::to_scalar); + set(im, d, m, e.length.as_ref(), &anim::to_scalar); } } } -fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) { - set(im, tr, "anchor", j.get("a"), &anim::to_vec2); - set(im, tr, "position", j.get("p"), &anim::to_vec2); - set(im, tr, "scale", j.get("s"), &anim::to_vec2); - set(im, tr, "rotation", j.get("r"), &anim::to_scalar); - set(im, tr, "opacity", j.get("o"), &anim::to_scalar); - set(im, tr, "skew", j.get("sk"), &anim::to_scalar); - set(im, tr, "skewAxis", j.get("sa"), &anim::to_scalar); - set(im, tr, "startOpacity", j.get("so"), &anim::to_scalar); - set(im, tr, "endOpacity", j.get("eo"), &anim::to_scalar); +/// A `tr` (of a group or a repeater), looked up by its Lottie keys. +fn import_shape_transform(im: &Im, get: &dyn Fn(&str) -> Option, tr: &mut PropGroup) { + for (k, m, conv) in [ + ("a", "anchor", anim::to_vec2 as fn(&Json) -> Option), + ("p", "position", anim::to_vec2), + ("s", "scale", anim::to_vec2), + ("r", "rotation", anim::to_scalar), + ("o", "opacity", anim::to_scalar), + ("sk", "skew", anim::to_scalar), + ("sa", "skewAxis", anim::to_scalar), + ("so", "startOpacity", anim::to_scalar), + ("eo", "endOpacity", anim::to_scalar), + ] { + set_json(im, tr, m, get(k).as_ref(), &conv); + } +} + +/// A named key of the model, or the same key kept in `extra`. +fn key(a: Option<&Animated>, extra: &Map, k: &str) -> Option { + a.map(|a| a.0.clone()).or_else(|| extra.get(k).cloned()) +} + +fn group_tr_get<'a>(t: &'a Transform, item: &'a Map) -> impl Fn(&str) -> Option + 'a { + move |k| { + let named = match k { + "a" => t.anchor.as_ref(), + "p" => t.position.as_ref(), + "s" => t.scale.as_ref(), + "r" => t.rotation.as_ref(), + "o" => t.opacity.as_ref(), + "sk" => t.skew.as_ref(), + "sa" => t.skew_axis.as_ref(), + _ => None, + }; + key(named, &t.extra, k).or_else(|| item.get(k).cloned()) + } +} + +fn repeater_tr_get(t: &RepeaterTransform) -> impl Fn(&str) -> Option + '_ { + move |k| { + let named = match k { + "a" => t.anchor.as_ref(), + "p" => t.position.as_ref(), + "s" => t.scale.as_ref(), + "r" => t.rotation.as_ref(), + "so" => t.start_opacity.as_ref(), + "eo" => t.end_opacity.as_ref(), + "sk" => t.skew.as_ref(), + "sa" => t.skew_axis.as_ref(), + _ => None, + }; + key(named, &t.extra, k) + } } /// Import Lottie shape items into shape contents groups. -pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { +pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &mut dyn Iterator) -> Vec { let mut out = vec![]; - for j in items { - let ty = j.get("ty").and_then(Json::as_str).unwrap_or(""); - let dir = |g: &mut PropGroup| { - if int(j, "d") == Some(3) { + for s in items { + let x = &s.extra; + let dir = |g: &mut PropGroup, d: Option| { + if int(d, x, "d") == Some(3) { set_enum(g, "direction", 1); } }; - let mut g = match ty { - "gr" => { - let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default(); - let inner: Vec = list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr")).cloned().collect(); - let children = import_items(im, ids, &inner); + let mut g = match &s.kind { + ShapeKind::Group(gr) => { + let list = gr.items.as_deref().unwrap_or_default(); + // Borrowed, not cloned: each nesting level copying its subtree costs memory + // quadratic in the depth. + let children = import_items(im, ids, &mut list.iter().filter(|i| !matches!(i.kind, ShapeKind::Transform(_)))); let mut g = build::shape_group(ids, "Group 1", children); - if let Some(tr) = list.iter().find(|i| i.get("ty").and_then(Json::as_str) == Some("tr")) - && let Some(t) = g.sub_mut("transform") + if let Some(tr) = list.iter().find(|i| matches!(i.kind, ShapeKind::Transform(_))) + && let ShapeKind::Transform(t) = &tr.kind + && let Some(tg) = g.sub_mut("transform") { - import_shape_transform(im, tr, t); + import_shape_transform(im, &group_tr_get(t, &tr.extra), tg); } - import_blend(j, &mut g); + import_blend(s, &mut g); g } - "rc" => { + ShapeKind::Rect(r) => { let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], 0.0); - dir(&mut g); - set(im, &mut g, "size", j.get("s"), &anim::to_vec2); - set(im, &mut g, "position", j.get("p"), &anim::to_vec2); - set(im, &mut g, "roundness", j.get("r"), &anim::to_scalar); + dir(&mut g, r.direction); + set(im, &mut g, "size", r.size.as_ref(), &anim::to_vec2); + set(im, &mut g, "position", r.position.as_ref(), &anim::to_vec2); + set(im, &mut g, "roundness", r.roundness.as_ref(), &anim::to_scalar); // Lottie has one roundness: keep the per-corner values in step so ticking the box // on an imported rectangle keeps the shape. for m in ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"] { - set(im, &mut g, m, j.get("r"), &anim::to_scalar); + set(im, &mut g, m, r.roundness.as_ref(), &anim::to_scalar); } g } - "el" => { + ShapeKind::Ellipse(e) => { let mut g = build::shape_ellipse(ids, [100.0, 100.0], [0.0, 0.0]); - dir(&mut g); - set(im, &mut g, "size", j.get("s"), &anim::to_vec2); - set(im, &mut g, "position", j.get("p"), &anim::to_vec2); + dir(&mut g, e.direction); + set(im, &mut g, "size", e.size.as_ref(), &anim::to_vec2); + set(im, &mut g, "position", e.position.as_ref(), &anim::to_vec2); g } - "sr" => { - let star = int(j, "sy").unwrap_or(1) != 2; + ShapeKind::PolyStar(p) => { + let star = int(p.star_type, x, "sy").unwrap_or(1) != 2; let mut g = build::shape_star(ids, star, 5.0, [0.0, 0.0], 100.0, 50.0); - dir(&mut g); - set(im, &mut g, "points", j.get("pt"), &anim::to_scalar); - set(im, &mut g, "position", j.get("p"), &anim::to_vec2); - set(im, &mut g, "rotation", j.get("r"), &anim::to_scalar); - set(im, &mut g, "innerRadius", j.get("ir"), &anim::to_scalar); - set(im, &mut g, "outerRadius", j.get("or"), &anim::to_scalar); - set(im, &mut g, "innerRoundness", j.get("is"), &anim::to_scalar); - set(im, &mut g, "outerRoundness", j.get("os"), &anim::to_scalar); + dir(&mut g, p.direction); + set(im, &mut g, "points", p.points.as_ref(), &anim::to_scalar); + set(im, &mut g, "position", p.position.as_ref(), &anim::to_vec2); + set(im, &mut g, "rotation", p.rotation.as_ref(), &anim::to_scalar); + set(im, &mut g, "innerRadius", p.inner_radius.as_ref(), &anim::to_scalar); + set(im, &mut g, "outerRadius", p.outer_radius.as_ref(), &anim::to_scalar); + set(im, &mut g, "innerRoundness", p.inner_roundness.as_ref(), &anim::to_scalar); + set(im, &mut g, "outerRoundness", p.outer_roundness.as_ref(), &anim::to_scalar); g } - "sh" => { + ShapeKind::Path(p) => { let mut g = build::shape_path(ids, ShapePath::default()); - dir(&mut g); - let closed = j.get("closed").and_then(Json::as_bool).unwrap_or(false); - set(im, &mut g, "path", j.get("ks"), &|v| to_path(v, closed)); + dir(&mut g, p.direction); + let closed = p.closed.unwrap_or(false); + set(im, &mut g, "path", p.path.as_ref(), &|v| to_path(v, closed)); g } - "fl" => { + ShapeKind::Fill(f) => { let mut g = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]); - set(im, &mut g, "color", j.get("c"), &anim::to_color); - set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar); - if int(j, "r") == Some(2) { + set(im, &mut g, "color", f.color.as_ref(), &anim::to_color); + set(im, &mut g, "opacity", f.opacity.as_ref(), &anim::to_scalar); + if int(f.rule, x, "r") == Some(2) { set_enum(&mut g, "rule", 1); } - import_blend(j, &mut g); + import_blend(s, &mut g); g } - "st" => { + ShapeKind::Stroke(st) => { let mut g = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0); - set(im, &mut g, "color", j.get("c"), &anim::to_color); - set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar); - import_stroke(im, ids, j, &mut g); - import_blend(j, &mut g); - g - } - "gf" | "gs" => { - let radial = int(j, "t") == Some(2); - let mut g = if ty == "gf" { - build::shape_gradient_fill(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default()) - } else { - build::shape_gradient_stroke(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default(), 2.0) + set(im, &mut g, "color", st.color.as_ref(), &anim::to_color); + set(im, &mut g, "opacity", st.opacity.as_ref(), &anim::to_scalar); + let fields = StrokeIn { + width: st.width.as_ref(), + cap: int(st.cap, x, "lc"), + join: int(st.join, x, "lj"), + miter: st.miter_limit, + miter_animated: st.miter_limit_animated.as_ref(), + dashes: st.dashes.as_deref(), }; - set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar); - set(im, &mut g, "start", j.get("s"), &anim::to_vec2); - set(im, &mut g, "end", j.get("e"), &anim::to_vec2); - set(im, &mut g, "highlightLength", j.get("h"), &anim::to_scalar); - set(im, &mut g, "highlightAngle", j.get("a"), &anim::to_scalar); - if let Some(gj) = j.get("g") { - let n = gj.get("p").and_then(Json::as_u64).unwrap_or(2) as usize; - if let Some(k) = gj.get("k") { - set(im, &mut g, "colors", Some(k), &move |v| to_gradient(v, n)); - } - } - if ty == "gf" { - if int(j, "r") == Some(2) { - set_enum(&mut g, "rule", 1); - } - } else { - import_stroke(im, ids, j, &mut g); - } - import_blend(j, &mut g); + import_stroke(im, ids, fields, &mut g); + import_blend(s, &mut g); g } - "tm" => { + ShapeKind::GradientFill(gf) => { + let radial = int(gf.gradient_type, x, "t") == Some(2); + let mut g = build::shape_gradient_fill(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default()); + gradient_common( + im, + &mut g, + gf.opacity.as_ref(), + gf.start.as_ref(), + gf.end.as_ref(), + gf.highlight_length.as_ref(), + gf.highlight_angle.as_ref(), + gf.colors.as_ref(), + ); + if int(gf.rule, x, "r") == Some(2) { + set_enum(&mut g, "rule", 1); + } + import_blend(s, &mut g); + g + } + ShapeKind::GradientStroke(gs) => { + let radial = int(gs.gradient_type, x, "t") == Some(2); + let mut g = build::shape_gradient_stroke(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default(), 2.0); + gradient_common( + im, + &mut g, + gs.opacity.as_ref(), + gs.start.as_ref(), + gs.end.as_ref(), + gs.highlight_length.as_ref(), + gs.highlight_angle.as_ref(), + gs.colors.as_ref(), + ); + let fields = StrokeIn { + width: gs.width.as_ref(), + cap: int(gs.cap, x, "lc"), + join: int(gs.join, x, "lj"), + miter: gs.miter_limit, + miter_animated: gs.miter_limit_animated.as_ref(), + dashes: gs.dashes.as_deref(), + }; + import_stroke(im, ids, fields, &mut g); + import_blend(s, &mut g); + g + } + ShapeKind::Trim(t) => { let mut g = build::shape_trim(ids, 0.0, 100.0, 0.0); - set(im, &mut g, "start", j.get("s"), &anim::to_scalar); - set(im, &mut g, "end", j.get("e"), &anim::to_scalar); - set(im, &mut g, "offset", j.get("o"), &anim::to_scalar); - if int(j, "m") == Some(2) { + set(im, &mut g, "start", t.start.as_ref(), &anim::to_scalar); + set(im, &mut g, "end", t.end.as_ref(), &anim::to_scalar); + set(im, &mut g, "offset", t.offset.as_ref(), &anim::to_scalar); + if int(t.multiple, x, "m") == Some(2) { set_enum(&mut g, "mode", 1); } g } - "rp" => { + ShapeKind::Repeater(r) => { let mut g = build::shape_repeater(ids, 3.0, [100.0, 0.0]); - set(im, &mut g, "copies", j.get("c"), &anim::to_scalar); - set(im, &mut g, "offset", j.get("o"), &anim::to_scalar); - set_enum(&mut g, "composite", if int(j, "m") == Some(1) { 1 } else { 0 }); - if let (Some(tr), Some(t)) = (j.get("tr"), g.sub_mut("transform")) { - import_shape_transform(im, tr, t); + set(im, &mut g, "copies", r.copies.as_ref(), &anim::to_scalar); + set(im, &mut g, "offset", r.offset.as_ref(), &anim::to_scalar); + set_enum(&mut g, "composite", if int(r.composite, x, "m") == Some(1) { 1 } else { 0 }); + if let (Some(tr), Some(t)) = (r.transform.as_ref(), g.sub_mut("transform")) { + import_shape_transform(im, &repeater_tr_get(tr), t); } g } - "rd" | "op" | "pb" | "tw" | "zz" | "mm" => { - let kind = match ty { - "rd" => "round", - "op" => "offset", - "pb" => "pucker", - "tw" => "twist", - "zz" => "zigzag", - _ => "merge", - }; - let Some(mut g) = build::shape_simple_op(ids, kind) else { continue }; - match ty { - "rd" => set(im, &mut g, "radius", j.get("r"), &anim::to_scalar), - "op" => { - set(im, &mut g, "amount", j.get("a"), &anim::to_scalar); - if let Some(lj) = int(j, "lj") { - set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32); - } - set(im, &mut g, "miter", j.get("ml"), &anim::to_scalar); - } - "pb" => set(im, &mut g, "amount", j.get("a"), &anim::to_scalar), - "tw" => { - set(im, &mut g, "angle", j.get("a"), &anim::to_scalar); - set(im, &mut g, "center", j.get("c"), &anim::to_vec2); - } - "zz" => { - set(im, &mut g, "size", j.get("s"), &anim::to_scalar); - set(im, &mut g, "ridges", j.get("r"), &anim::to_scalar); - if int(j, "pt") == Some(2) { - set_enum(&mut g, "points", 1); - } - } - _ => { - if let Some(m) = int(j, "mm") { - set_enum(&mut g, "mode", m.saturating_sub(1).clamp(0, 4) as u32); - } - } + ShapeKind::RoundCorners(r) => { + let Some(mut g) = build::shape_simple_op(ids, "round") else { continue }; + set(im, &mut g, "radius", r.radius.as_ref(), &anim::to_scalar); + g + } + ShapeKind::OffsetPath(op) => { + let Some(mut g) = build::shape_simple_op(ids, "offset") else { continue }; + set(im, &mut g, "amount", op.amount.as_ref(), &anim::to_scalar); + if let Some(lj) = int(op.join, x, "lj") { + set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32); + } + set(im, &mut g, "miter", op.miter_limit.as_ref(), &anim::to_scalar); + g + } + ShapeKind::PuckerBloat(pb) => { + let Some(mut g) = build::shape_simple_op(ids, "pucker") else { continue }; + set(im, &mut g, "amount", pb.amount.as_ref(), &anim::to_scalar); + g + } + ShapeKind::Twist(tw) => { + let Some(mut g) = build::shape_simple_op(ids, "twist") else { continue }; + set(im, &mut g, "angle", tw.angle.as_ref(), &anim::to_scalar); + set(im, &mut g, "center", tw.center.as_ref(), &anim::to_vec2); + g + } + ShapeKind::ZigZag(zz) => { + let Some(mut g) = build::shape_simple_op(ids, "zigzag") else { continue }; + set(im, &mut g, "size", zz.frequency.as_ref(), &anim::to_scalar); + set(im, &mut g, "ridges", zz.amount.as_ref(), &anim::to_scalar); + if int_json(zz.point_type.as_ref().map(|a| &a.0)) == Some(2) { + set_enum(&mut g, "points", 1); } g } - "tr" => continue, - other => { - im.warn(format!("shape item `{other}` is not supported (skipped)")); + ShapeKind::Merge(mm) => { + let Some(mut g) = build::shape_simple_op(ids, "merge") else { continue }; + if let Some(m) = int(mm.mode, x, "mm") { + set_enum(&mut g, "mode", m.saturating_sub(1).clamp(0, 4) as u32); + } + g + } + ShapeKind::Transform(_) => continue, + ShapeKind::Other(ty) => { + im.warn(format!("shape item `{}` is not supported (skipped)", ty.as_str().unwrap_or(""))); continue; } }; - if let Some(nm) = j.get("nm").and_then(Json::as_str) { - g.name = nm.to_string(); + if let Some(nm) = &s.name { + g.name = nm.clone(); } - if j.get("hd").and_then(Json::as_bool) == Some(true) { + if s.hidden == Some(true) { g.enabled = false; } out.push(g); } out } + +/// The keys `gf` and `gs` share. +#[allow(clippy::too_many_arguments)] +fn gradient_common( + im: &Im, + g: &mut PropGroup, + opacity: Option<&Animated>, + start: Option<&Animated>, + end: Option<&Animated>, + highlight_length: Option<&Animated>, + highlight_angle: Option<&Animated>, + colors: Option<&GradientColors>, +) { + set(im, g, "opacity", opacity, &anim::to_scalar); + set(im, g, "start", start, &anim::to_vec2); + set(im, g, "end", end, &anim::to_vec2); + set(im, g, "highlightLength", highlight_length, &anim::to_scalar); + set(im, g, "highlightAngle", highlight_angle, &anim::to_scalar); + if let Some(c) = colors { + let n = c.count.and_then(|n| usize::try_from(n).ok()).unwrap_or(2); + set(im, g, "colors", c.stops.as_ref(), &move |v| to_gradient(v, n)); + } +} diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index d26b3642d..35c52026d 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -381,7 +381,7 @@ fn offset_paths_miter_uses_animatable_lottie_value() { assert_eq!(shapes[0]["ml"]["k"][1]["s"], json!([9.0])); assert_eq!(shapes[1]["ml"], json!({"a": 0, "k": 6.0})); assert_eq!(shapes[2]["ty"], "st"); - assert_eq!(shapes[2]["ml"], json!(7.0)); + assert_eq!(shapes[2]["ml"].as_f64(), Some(7.0)); assert!(compare_comps(&p, cid, &q, nc) > 10); let restored = &q.comp(nc).unwrap().layers[0]; assert_eq!(restored.props.group("contents/offset").unwrap().get("miter").unwrap().keys.len(), 2); @@ -520,6 +520,21 @@ fn zero_time_stretch_is_not_imported() { assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings); } +#[test] +fn a_time_stretch_past_9900_percent_is_held_to_it() { + // `sr` 1e300 was imported as 1e302 % (#3247); the sign of a reversed layer is kept. + for (sr, want) in [(1e300, 9900.0), (-1e300, -9900.0), (99.0, 9900.0)] { + let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], + "layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": sr, "ip": 0, "op": 30, "st": 0, "ks": {}}]}); + let mut q = Project::default(); + let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap(); + let l = &q.comp(imp.comp).unwrap().layers[0]; + assert_eq!(l.stretch, want, "sr {sr}"); + let warned = imp.warnings.iter().any(|w| w.contains("time stretch")); + assert_eq!(warned, sr != 99.0, "sr {sr}: {:?}", imp.warnings); + } +} + #[test] fn embedded_glyphs_are_reported_when_dropped() { let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}}); @@ -710,7 +725,7 @@ fn effects_expressions_and_warnings() { #[test] fn images_embed_as_base64_and_come_back() { let (mut p, cid) = setup(); - let png: Vec = vec![0x89, b'P', b'N', b'G', 1, 2, 3, 4, 5]; + let png: Vec = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]; let fid = p.add_item( "pic.png", Label::Lavender, @@ -760,6 +775,17 @@ fn images_embed_as_base64_and_come_back() { assert!(f.path.starts_with("/tmp/image_") && f.path.ends_with(".png"), "{}", f.path); } +#[test] +fn image_signatures_are_matched_in_full() { + use crate::export::image_mime; + assert_eq!(image_mime(b"\x89PNG\r\n\x1a\n\0"), Some("image/png")); + assert_eq!(image_mime(b"GIF89a\0"), Some("image/gif")); + assert_eq!(image_mime(b"GIF87a\0"), Some("image/gif")); + // Near matches (a prefix of the signature, or text that starts with it) are not images. + assert_eq!(image_mime(b"\x89PNG\x01\x02\x03\x04"), None); + assert_eq!(image_mime(b"GIF8 is a text file"), None); +} + #[test] fn dotlottie_archive_roundtrip() { let (mut p, cid) = setup(); @@ -970,3 +996,69 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { assert_eq!(rc(&json)["r"]["k"], json!(5.0)); assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); } + +#[test] +fn many_layers_and_precomps_import_with_their_parents_and_sizes() { + // 5 000 null layers each parented to the previous, and 2 000 empty precomp assets: the + // relationship and size passes index the layers instead of searching them per layer. (Each + // has its own name: `Comp::unique_layer_name` numbering one repeated name is a separate cost.) + let n = 5_000; + let layers: Vec = (1..=n).map(|i| json!({"ty": 3, "nm": format!("N{i}"), "ind": i, "parent": i - 1, "ip": 0, "op": 30, "st": 0, "ks": {}})).collect(); + let mut assets: Vec = (0..2_000).map(|i| json!({"id": format!("c{i}"), "layers": []})).collect(); + assets.push(json!({"id": "last", "layers": []})); + let mut layers = layers; + layers.push(json!({"ty": 0, "ind": n + 1, "refId": "last", "w": 50, "h": 40, "ip": 0, "op": 30, "st": 0, "ks": {}})); + let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 30, "assets": assets, "layers": layers}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let c = q.comp(nc).unwrap(); + assert_eq!(c.layers.len(), n as usize + 1); + assert_eq!(c.layers.get(0).unwrap().parent, None); + assert_eq!(c.layers.get(1).unwrap().parent, Some(c.layers.get(0).unwrap().id)); + let LayerSource::Comp { item } = c.layers.get(n as usize).unwrap().source else { panic!("not a precomp") }; + let pc = q.comp(item).unwrap(); + assert_eq!((pc.width, pc.height), (50, 40)); +} + +#[test] +fn deeply_nested_shape_groups_and_long_font_names_import() { + let mut g = json!({"ty": "rc", "s": {"a": 0, "k": [10, 10]}, "p": {"a": 0, "k": [0, 0]}, "r": {"a": 0, "k": 0}}); + // 50 levels: each is two levels of JSON, within serde_json's recursion limit of 128. + for _ in 0..50 { + g = json!({"ty": "gr", "it": [g, {"ty": "tr", "o": {"a": 0, "k": 100}}]}); + } + let family = "F".repeat(100_000); + let keys: Vec = (0..50).map(|i| json!({"t": i, "s": {"t": "x", "f": "A", "s": 10}})).collect(); + let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 60, + "fonts": {"list": [{"fName": "A", "fFamily": family, "fStyle": "Regular"}]}, + "layers": [ + {"ty": 4, "ind": 1, "ip": 0, "op": 60, "st": 0, "ks": {}, "shapes": [g]}, + {"ty": 5, "ind": 2, "ip": 0, "op": 60, "st": 0, "ks": {}, "t": {"d": {"k": keys}}} + ]}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let c = q.comp(nc).unwrap(); + let st = c.layers.get(1).unwrap().props.prop("text/sourceText").unwrap(); + assert_eq!(st.keys.len(), 50); + let Value::Text(d) = &st.keys[0].value else { panic!("not text") }; + assert_eq!(d.font.len(), 256); +} + +/// A footage item called a still whose file is not an image is not embedded. +#[test] +fn only_image_bytes_are_embedded() { + let (mut p, cid) = setup(); + let fid = p.add_item( + "secret.png", + Label::Lavender, + None, + ItemKind::Footage(Footage { path: "/virtual/secret.png".into(), kind: FootageKind::Still, width: 8, height: 8, has_video: true, ..Default::default() }), + ); + let c = comp(&p, cid); + let l = build::layer(&mut p, &c, "secret.png", LayerSource::Footage { item: fid }, (8, 8), None); + push(&mut p, cid, l); + let read = |path: &str| (path == "/virtual/secret.png").then(|| b"root:x:0:0:root:/root:/bin/sh\n".to_vec()); + let res = export_comp(&p, cid, &ExportOptions::default(), &read).unwrap(); + assert!(res.json["assets"].as_array().is_none_or(|a| a.is_empty()), "{}", res.json["assets"]); + assert!(res.warnings.iter().any(|w| w.contains("not a PNG")), "{:?}", res.warnings); +} diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 125835c2b..39199cfb3 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -1,14 +1,18 @@ //! Text layers ↔ Lottie text data (`t`: document keys `d`, animators `a`, more options `m`), //! and text converted to glyph shapes. +use craft_lottie::model::{ + Font, Map, Path, Shape, ShapeKind, TextAlign, TextData, TextDocKey, TextDocs, TextDocument, TextFollowPath, TextRange, TextSelector, TextStyle, +}; +use craft_lottie::{Animated, Json}; use effectcraft_keyframe::{Justify, TextDoc, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_time::Tick; -use serde_json::{Map, Value as Json, json}; +use serde_json::json; use crate::anim::{self, Ex, Im, export_prop, import_prop}; -use crate::shapes; +use crate::shapes::{self, fixed}; const JUSTIFY: [Justify; 7] = [Justify::Left, Justify::Right, Justify::Center, Justify::JustifyLastLeft, Justify::JustifyLastRight, Justify::JustifyLastCenter, Justify::JustifyAll]; @@ -18,47 +22,49 @@ pub(crate) fn font_name(family: &str, style: &str) -> String { format!("{}-{}", family.replace(' ', ""), style.replace(' ', "")) } -fn rgb(c: [f32; 4]) -> Json { - json!([c[0] as f64, c[1] as f64, c[2] as f64]) +fn rgb(c: [f32; 4]) -> Vec { + vec![f64::from(c[0]), f64::from(c[1]), f64::from(c[2])] } -fn doc_json(d: &TextDoc) -> Json { - let mut o = json!({ - "t": d.text.replace("\r\n", "\r").replace('\n', "\r"), - "f": font_name(&d.font, &d.style), - "s": d.size, - "j": JUSTIFY.iter().position(|j| *j == d.justify).unwrap_or(0), - "tr": d.tracking, - "lh": d.leading.unwrap_or(d.size * 1.2), - "ls": d.baseline_shift, - "of": d.stroke_over_fill, - }); - if d.apply_fill { - o["fc"] = rgb(d.fill); +fn doc_json(d: &TextDoc) -> TextDocument { + let stroke = d.apply_stroke && d.stroke_width > 0.0; + TextDocument { + text: Some(d.text.replace("\r\n", "\r").replace('\n', "\r")), + font: Some(font_name(&d.font, &d.style)), + size: Some(d.size), + justify: Some(JUSTIFY.iter().position(|j| *j == d.justify).unwrap_or(0) as i64), + tracking: Some(d.tracking), + line_height: Some(d.leading.unwrap_or(d.size * 1.2)), + baseline_shift: Some(d.baseline_shift), + stroke_over_fill: Some(d.stroke_over_fill), + fill: d.apply_fill.then(|| rgb(d.fill)), + stroke: stroke.then(|| rgb(d.stroke)), + stroke_width: stroke.then_some(d.stroke_width), + caps: if d.all_caps { + Some(1) + } else if d.small_caps { + Some(2) + } else { + None + }, + box_size: d.box_size.map(|b| b.to_vec()), + box_position: d.box_size.map(|_| d.box_pos.to_vec()), + ..Default::default() } - if d.apply_stroke && d.stroke_width > 0.0 { - o["sc"] = rgb(d.stroke); - o["sw"] = json!(d.stroke_width); - } - if d.all_caps { - o["ca"] = json!(1); - } else if d.small_caps { - o["ca"] = json!(2); - } - if let Some(b) = d.box_size { - o["sz"] = json!(b); - o["ps"] = json!(d.box_pos); - } - o } -fn doc_from(j: &Json, fonts: &[(String, String, String)]) -> TextDoc { +/// A colour of a text document (`fc`, `sc`), 0..=1 or 0..=255. +fn doc_color(c: Option<&Vec>, extra: &Map, key: &str) -> Option<[f32; 4]> { + let j = c.filter(|c| !c.is_empty()).map(|c| json!(c)).or_else(|| extra.get(key).cloned())?; + anim::to_color(&j).map(|v| v.as_color()) +} + +fn doc_from(j: &TextDocument, fonts: &[(String, String, String)]) -> TextDoc { let mut d = TextDoc::default(); - let f = |k: &str| j.get(k).and_then(Json::as_f64); - if let Some(t) = j.get("t").and_then(Json::as_str) { + if let Some(t) = &j.text { d.text = t.replace(['\r', '\u{3}'], "\n"); } - if let Some(name) = j.get("f").and_then(Json::as_str) { + if let Some(name) = &j.font { match fonts.iter().find(|(n, _, _)| n == name) { Some((_, fam, st)) => { d.font = fam.clone(); @@ -71,40 +77,39 @@ fn doc_from(j: &Json, fonts: &[(String, String, String)]) -> TextDoc { } } } - if let Some(s) = f("s") { + if let Some(s) = j.size { d.size = s; } - let col = |k: &str| j.get(k).and_then(anim::to_color).map(|v| v.as_color()); - match col("fc") { + match doc_color(j.fill.as_ref(), &j.extra, "fc") { Some(c) => d.fill = c, None => d.apply_fill = false, } - if let Some(c) = col("sc") { + if let Some(c) = doc_color(j.stroke.as_ref(), &j.extra, "sc") { d.stroke = c; d.apply_stroke = true; - d.stroke_width = f("sw").unwrap_or(1.0); + d.stroke_width = j.stroke_width.unwrap_or(1.0); } - if let Some(of) = j.get("of").and_then(Json::as_bool) { + if let Some(of) = j.stroke_over_fill { d.stroke_over_fill = of; } - if let Some(i) = j.get("j").and_then(Json::as_u64) { - d.justify = JUSTIFY.get(i as usize).copied().unwrap_or_default(); + if let Some(i) = j.justify.and_then(|i| usize::try_from(i).ok()) { + d.justify = JUSTIFY.get(i).copied().unwrap_or_default(); } - d.tracking = f("tr").unwrap_or(0.0); - if let Some(lh) = f("lh") + d.tracking = j.tracking.unwrap_or(0.0); + if let Some(lh) = j.line_height && (lh - d.size * 1.2).abs() > 1e-6 { d.leading = Some(lh); } - d.baseline_shift = f("ls").unwrap_or(0.0); - match j.get("ca").and_then(Json::as_u64) { + d.baseline_shift = j.baseline_shift.unwrap_or(0.0); + match j.caps { Some(1) => d.all_caps = true, Some(2) => d.small_caps = true, _ => {} } - if let Some(sz) = j.get("sz").and_then(anim::nums).filter(|v| v.len() >= 2) { + if let Some(sz) = j.box_size.as_deref().filter(|v| v.len() >= 2) { d.box_size = Some([sz[0], sz[1]]); - if let Some(ps) = j.get("ps").and_then(anim::nums).filter(|v| v.len() >= 2) { + if let Some(ps) = j.box_position.as_deref().filter(|v| v.len() >= 2) { d.box_pos = [ps[0], ps[1]]; } } @@ -136,28 +141,86 @@ fn conv_for(v: &Value) -> fn(&Value) -> Json { } } +/// The field of a range animator's style for a Lottie key. +fn style_field<'a>(st: &'a mut TextStyle, key: &str) -> Option<&'a mut Option> { + Some(match key { + "p" => &mut st.position, + "a" => &mut st.anchor, + "s" => &mut st.scale, + "r" => &mut st.rotation, + "rx" => &mut st.rotation_x, + "ry" => &mut st.rotation_y, + "sk" => &mut st.skew, + "sa" => &mut st.skew_axis, + "o" => &mut st.opacity, + "fc" => &mut st.fill, + "fh" => &mut st.fill_hue, + "fs" => &mut st.fill_saturation, + "fb" => &mut st.fill_brightness, + "fo" => &mut st.fill_opacity, + "sc" => &mut st.stroke, + "sw" => &mut st.stroke_width, + "so" => &mut st.stroke_opacity, + "t" => &mut st.tracking, + "ls" => &mut st.line_spacing, + "bl" => &mut st.blur, + _ => return None, + }) +} + +fn style_set(st: &mut TextStyle, key: &str, v: Animated) { + match style_field(st, key) { + Some(f) => *f = Some(v), + None => { + st.extra.insert(key.into(), v.0); + } + } +} + +fn style_get(st: &TextStyle, key: &str) -> Option { + let named = match key { + "p" => &st.position, + "a" => &st.anchor, + "s" => &st.scale, + "r" => &st.rotation, + "sk" => &st.skew, + "sa" => &st.skew_axis, + "o" => &st.opacity, + "fc" => &st.fill, + "fh" => &st.fill_hue, + "fs" => &st.fill_saturation, + "fb" => &st.fill_brightness, + "sc" => &st.stroke, + "sw" => &st.stroke_width, + "t" => &st.tracking, + _ => &None, + }; + named.as_ref().map(|a| a.0.clone()).or_else(|| st.extra.get(key).cloned()) +} + /// Export a text layer's `t` object. Fonts used are added to `fonts`. -pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> Json { - let Some(tg) = layer.props.sub("text") else { return json!({}) }; +pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> TextData { + let Some(tg) = layer.props.sub("text") else { return TextData::default() }; let label = &layer.name; let mut docs = vec![]; let mut add_font = |d: &TextDoc| { let n = font_name(&d.font, &d.style); - if !fonts.iter().any(|f| f["fName"] == json!(n)) { - fonts.push(json!({"fName": n, "fFamily": d.font, "fStyle": d.style, "ascent": 75})); + if !fonts.iter().any(|f| f.name.as_deref() == Some(n.as_str())) { + fonts.push(Font { name: Some(n), family: Some(d.font.clone()), style: Some(d.style.clone()), ascent: Some(75.0), ..Default::default() }); } }; + let key = |t: f64, d: &TextDoc| TextDocKey { time: Some(t), document: Some(doc_json(d)), ..Default::default() }; if let Some(st) = tg.get("sourceText") { if st.keys.is_empty() { if let Value::Text(d) = &st.value { add_font(d); - docs.push(json!({"t": 0, "s": doc_json(d)})); + docs.push(key(0.0, d)); } } else { for k in &st.keys { if let Value::Text(d) = &k.value { add_font(d); - docs.push(json!({"t": ex.tb.frame(k.time), "s": doc_json(d)})); + docs.push(key(ex.tb.frame(k.time), d)); } } } @@ -184,22 +247,28 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> animators.push(export_animator(ex, a, &format!("{label} ▸ {}", a.name))); } } - let mut m = json!({"g": 1, "a": {"a": 0, "k": [0, 0]}}); + let mut m = TextAlign { grouping: Some(1), anchor: Some(fixed(json!([0, 0]))), ..Default::default() }; if let Some(more) = tg.sub("moreOptions") { - m["g"] = json!(more.get("anchorGrouping").map(|p| p.value.as_enum()).unwrap_or(0) + 1); + m.grouping = Some(i64::from(more.get("anchorGrouping").map(|p| p.value.as_enum()).unwrap_or(0)) + 1); if let Some(p) = more.get("groupingAlignment") { - m["a"] = export_prop(ex, p, &anim::arr2, &format!("{label} ▸ Grouping Alignment")); + m.anchor = Some(export_prop(ex, p, &anim::arr2, &format!("{label} ▸ Grouping Alignment"))); } } if tg.sub("pathOptions").and_then(|p| p.get("path")).is_some_and(|p| p.value.as_enum() > 0) { ex.warn(format!("{label}: text on a path is not exported")); } - json!({"d": {"k": docs}, "a": animators, "m": m, "p": {}}) + TextData { + documents: Some(TextDocs { keys: Some(docs), ..Default::default() }), + ranges: Some(animators), + alignment: Some(m), + follow_path: Some(TextFollowPath::default()), + ..Default::default() + } } /// Text animators reproducing the style runs of `d` (fill, stroke, stroke width, size, /// tracking, baseline shift) over their character ranges. -fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { +fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { let base = d.base_style(); let mut out = vec![]; let mut start = 0usize; @@ -211,63 +280,71 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { if *s == base || run.len == 0 { continue; } - let k = |v: Json| json!({"a": 0, "k": v}); - let mut props = Map::new(); + let k = fixed; + let mut props = TextStyle::default(); if s.apply_fill && s.fill != base.fill { - props.insert("fc".into(), k(json!([s.fill[0] as f64, s.fill[1] as f64, s.fill[2] as f64, 1.0]))); + props.fill = Some(k(json!([s.fill[0] as f64, s.fill[1] as f64, s.fill[2] as f64, 1.0]))); } if s.apply_stroke && s.stroke != base.stroke { - props.insert("sc".into(), k(json!([s.stroke[0] as f64, s.stroke[1] as f64, s.stroke[2] as f64, 1.0]))); + props.stroke = Some(k(json!([s.stroke[0] as f64, s.stroke[1] as f64, s.stroke[2] as f64, 1.0]))); } if (s.stroke_width - base.stroke_width).abs() > 1e-9 { - props.insert("sw".into(), k(json!(s.stroke_width - base.stroke_width))); + props.stroke_width = Some(k(json!(s.stroke_width - base.stroke_width))); } if (s.size - base.size).abs() > 1e-9 && base.size > 0.0 { let p = s.size / base.size * 100.0; - props.insert("s".into(), k(json!([p, p, 100.0]))); + props.scale = Some(k(json!([p, p, 100.0]))); } if (s.tracking - base.tracking).abs() > 1e-9 { // Lottie tracking is in 1/1000 em like the Character panel's. - props.insert("t".into(), k(json!(s.tracking - base.tracking))); + props.tracking = Some(k(json!(s.tracking - base.tracking))); } if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 { - props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); + props.position = Some(k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); } let lost = s.font != base.font || s.style != base.style || s.faux_bold != base.faux_bold || s.faux_italic != base.faux_italic; if lost && !warned { ex.warn(format!("{label}: per-character fonts and faux styles are not part of Lottie text (base font used)")); warned = true; } - if props.is_empty() { + if props == TextStyle::default() { continue; } - out.push(json!({ - "nm": format!("Style {a}–{b}"), - "s": { - "t": 0, "xe": k(json!(0)), "ne": k(json!(0)), "a": k(json!(100)), "b": 1, "rn": 0, "sh": 1, - "r": 2, "m": k(json!(1)), "s": k(json!(a)), "e": k(json!(b)), "o": k(json!(0)), - }, - "a": Json::Object(props), - })); + let mut sel = TextSelector { + kind: Some(0), + ease_high: Some(k(json!(0))), + ease_low: Some(k(json!(0))), + amount: Some(k(json!(100))), + based_on: Some(1), + randomize: Some(0), + shape: Some(1), + units: Some(2), + start: Some(k(json!(a))), + end: Some(k(json!(b))), + offset: Some(k(json!(0))), + ..Default::default() + }; + sel.extra.insert("m".into(), k(json!(1)).0); + out.push(TextRange { name: Some(format!("Style {a}–{b}")), selector: Some(sel), style: Some(props), ..Default::default() }); } out } -fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json { - let mut props = Map::new(); +fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> TextRange { + let mut props = TextStyle::default(); if let Some(pg) = a.sub("properties") { for p in pg.props() { match ANIM_PROPS.iter().find(|(_, _, m)| *m == p.match_id) { Some((key, _, _)) => { let v = export_prop(ex, p, &conv_for(&p.value), &format!("{label} ▸ {}", p.name)); - props.insert((*key).into(), v); + style_set(&mut props, key, v); } None if matches!(p.match_id.as_str(), "trackingType") => {} None => ex.warn(format!("{label} ▸ {}: text animator property not supported by Lottie", p.name)), } } } - let mut sel = json!({"t": 0}); + let mut sel = TextSelector { kind: Some(0), ..Default::default() }; let sels: Vec<&PropGroup> = a.sub("selectors").map(|s| s.groups().collect()).unwrap_or_default(); if sels.len() > 1 { ex.warn(format!("{label}: only the first selector is exported (Lottie animators have one)")); @@ -276,61 +353,56 @@ fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json { if s.match_id != "rangeSelector" { ex.warn(format!("{label}: {} is not supported by Lottie", s.name)); } else { - for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] { - if let Some(p) = s.get(m) { - sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name)); - } - } + let mut prop = |m: &str| s.get(m).map(|p| export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name))); + (sel.start, sel.end, sel.offset) = (prop("start"), prop("end"), prop("offset")); if let Some(adv) = s.sub("advanced") { - let e = |m: &str| adv.get(m).map(|p| p.value.as_enum()).unwrap_or(0) + 1; - sel["r"] = json!(e("units")); - sel["b"] = json!(e("basedOn")); - sel["m"] = json!({"a": 0, "k": e("mode")}); - sel["sh"] = json!(e("shape")); - sel["rn"] = json!(adv.get("randomize").is_some_and(|p| p.value.as_bool()) as u8); - for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] { - if let Some(p) = adv.get(m) { - sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name)); - } - } + let e = |m: &str| i64::from(adv.get(m).map(|p| p.value.as_enum()).unwrap_or(0)) + 1; + sel.units = Some(e("units")); + sel.based_on = Some(e("basedOn")); + sel.extra.insert("m".into(), json!({"a": 0, "k": e("mode")})); + sel.shape = Some(e("shape")); + sel.randomize = Some(i64::from(adv.get("randomize").is_some_and(|p| p.value.as_bool()))); + let mut prop = |m: &str| adv.get(m).map(|p| export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name))); + (sel.amount, sel.smoothness, sel.ease_high, sel.ease_low) = (prop("amount"), prop("smoothness"), prop("easeHigh"), prop("easeLow")); } } } - json!({"nm": a.name, "s": sel, "a": Json::Object(props)}) + TextRange { name: Some(a.name.clone()), selector: Some(sel), style: Some(props), ..Default::default() } } /// Import Lottie text data into a text layer's `text` group. -pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGroup, fonts: &[(String, String, String)]) { - if let Some(list) = t.get("d").and_then(|d| d.get("k")).and_then(Json::as_array) +pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &TextData, tg: &mut PropGroup, fonts: &[(String, String, String)]) { + if let Some(list) = t.documents.as_ref().and_then(|d| d.keys.as_deref()) && let Some(st) = tg.get_mut("sourceText") { - let docs: Vec<(f64, TextDoc)> = - list.iter().filter_map(|k| Some((k.get("t").and_then(Json::as_f64).unwrap_or(0.0), doc_from(k.get("s")?, fonts)))).collect(); + let docs: Vec<(f64, TextDoc)> = list.iter().filter_map(|k| Some((k.time.unwrap_or(0.0), doc_from(k.document.as_ref()?, fonts)))).collect(); if let Some((_, first)) = docs.first() { st.value = Value::Text(Box::new(first.clone())); } if docs.len() > 1 { - st.keys = docs.iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(*f), Value::Text(Box::new(d.clone()))).hold()).collect(); + // Moved into the keys, not cloned: the documents are not kept twice. + st.keys = docs.into_iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(f), Value::Text(Box::new(d))).hold()).collect(); } } - if let Some(m) = t.get("m") + if let Some(m) = &t.alignment && let Some(more) = tg.sub_mut("moreOptions") { - if let (Some(g), Some(p)) = (m.get("g").and_then(Json::as_u64), more.get_mut("anchorGrouping")) { + let grouping = m.grouping.or_else(|| m.extra.get("g").and_then(Json::as_i64)).and_then(|g| u64::try_from(g).ok()); + if let (Some(g), Some(p)) = (grouping, more.get_mut("anchorGrouping")) { p.value = Value::Enum((g.max(1) - 1).min(3) as u32); } - if let (Some(a), Some(p)) = (m.get("a"), more.get_mut("groupingAlignment")) { - import_prop(im, a, p, &anim::to_vec2); + if let (Some(a), Some(p)) = (&m.anchor, more.get_mut("groupingAlignment")) { + import_prop(im, &a.0, p, &anim::to_vec2); } } - let Some(list) = t.get("a").and_then(Json::as_array) else { return }; + let Some(list) = &t.ranges else { return }; let mut animators = vec![]; for (i, a) in list.iter().enumerate() { - let name = a.get("nm").and_then(Json::as_str).map(str::to_string).unwrap_or(format!("Animator {}", i + 1)); - let props_j = a.get("a").cloned().unwrap_or(json!({})); + let name = a.name.clone().unwrap_or(format!("Animator {}", i + 1)); + let style = a.style.clone().unwrap_or_default(); let mut kinds: Vec<&str> = vec![]; for (key, kind, _) in ANIM_PROPS { - if props_j.get(*key).is_some() && !kinds.contains(kind) { + if style_get(&style, key).is_some() && !kinds.contains(kind) { kinds.push(kind); } } @@ -338,7 +410,7 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro let mut g = build::text_animator(ids, &name, props); if let Some(pg) = g.sub_mut("properties") { for (key, _, m) in ANIM_PROPS { - let (Some(j), Some(p)) = (props_j.get(*key), pg.get_mut(m)) else { continue }; + let (Some(j), Some(p)) = (style_get(&style, key), pg.get_mut(m)) else { continue }; let conv: Box Option> = match &p.value { Value::Vec3(d) => { let z = d[2]; @@ -348,34 +420,38 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro Value::Color(_) => Box::new(anim::to_color), _ => Box::new(anim::to_scalar), }; - import_prop(im, j, p, &*conv); + import_prop(im, &j, p, &*conv); } } - if let Some(s) = a.get("s") + if let Some(s) = &a.selector && let Some(sel) = g.sub_mut("selectors").and_then(|s| s.groups_mut_first()) { - for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] { - if let (Some(j), Some(p)) = (s.get(k), sel.get_mut(m)) { - import_prop(im, j, p, &anim::to_scalar); + for (m, j) in [("start", &s.start), ("end", &s.end), ("offset", &s.offset)] { + if let (Some(j), Some(p)) = (j, sel.get_mut(m)) { + import_prop(im, &j.0, p, &anim::to_scalar); } } if let Some(adv) = sel.sub_mut("advanced") { - let mut en = |m: &str, key: &str, max: u32| { - let v = s.get(key).and_then(|v| if v.is_object() { v.get("k") } else { Some(v) }).and_then(anim::nums).and_then(|n| n.first().copied()); + let mut en = |m: &str, field: Option, key: &str, max: u32| { + let v = field.map(|f| f as f64).or_else(|| { + let v = s.extra.get(key)?; + let v = if v.is_object() { v.get("k")? } else { v }; + anim::nums(v).and_then(|n| n.first().copied()) + }); if let (Some(v), Some(p)) = (v, adv.get_mut(m)) { p.value = Value::Enum((((v.round() as i64).saturating_sub(1)).max(0) as u32).min(max)); } }; - en("units", "r", 1); - en("basedOn", "b", 3); - en("mode", "m", 5); - en("shape", "sh", 5); - if let (Some(rn), Some(p)) = (s.get("rn").and_then(Json::as_u64), adv.get_mut("randomize")) { + en("units", s.units, "r", 1); + en("basedOn", s.based_on, "b", 3); + en("mode", None, "m", 5); + en("shape", s.shape, "sh", 5); + if let (Some(rn), Some(p)) = (s.randomize.and_then(|r| u64::try_from(r).ok()), adv.get_mut("randomize")) { p.value = Value::Bool(rn != 0); } - for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] { - if let (Some(j), Some(p)) = (s.get(k), adv.get_mut(m)) { - import_prop(im, j, p, &anim::to_scalar); + for (m, j) in [("amount", &s.amount), ("smoothness", &s.smoothness), ("easeHigh", &s.ease_high), ("easeLow", &s.ease_low)] { + if let (Some(j), Some(p)) = (j, adv.get_mut(m)) { + import_prop(im, &j.0, p, &anim::to_scalar); } } } @@ -399,7 +475,7 @@ impl FirstGroup for PropGroup { /// Text converted to glyph shapes: one group with every glyph outline (at layer time 0's /// Source Text), a fill and a stroke, as Lottie shape items. -pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec { +pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec { let Some(tg) = layer.props.sub("text") else { return vec![] }; let Some(st) = tg.get("sourceText") else { return vec![] }; let Value::Text(doc) = st.value_at(Tick::ZERO) else { return vec![] }; @@ -411,14 +487,29 @@ pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec { for g in &lay.glyphs { let p = kurbo::Affine::translate((g.origin.x, g.origin.y)) * g.path.clone(); for sp in effectcraft_path::from_kurbo(&p) { - items.push(json!({"ty": "sh", "nm": g.ch.to_string(), "d": 1, "ks": {"a": 0, "k": shapes::shape_json(&sp)}})); + let path = Path { direction: Some(1), path: Some(fixed(shapes::shape_json(&sp))), closed: None }; + items.push(Shape { name: Some(g.ch.to_string()), kind: ShapeKind::Path(path), ..Default::default() }); } } - let col = |c: [f32; 4]| json!({"a": 0, "k": [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]}); - let stroke = (doc.apply_stroke && doc.stroke_width > 0.0).then( - || json!({"ty": "st", "nm": "Stroke", "c": col(doc.stroke), "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": doc.stroke_width}, "lc": 2, "lj": 2, "ml": 4}), - ); - let fill = doc.apply_fill.then(|| json!({"ty": "fl", "nm": "Fill", "c": col(doc.fill), "o": {"a": 0, "k": 100}, "r": 1})); + let col = |c: [f32; 4]| fixed(json!([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])); + let stroke = (doc.apply_stroke && doc.stroke_width > 0.0).then(|| Shape { + name: Some("Stroke".into()), + kind: ShapeKind::Stroke(craft_lottie::model::Stroke { + color: Some(col(doc.stroke)), + opacity: Some(fixed(json!(100))), + width: Some(fixed(json!(doc.stroke_width))), + cap: Some(2), + join: Some(2), + miter_limit: Some(4.0), + ..Default::default() + }), + ..Default::default() + }); + let fill = doc.apply_fill.then(|| Shape { + name: Some("Fill".into()), + kind: ShapeKind::Fill(craft_lottie::model::Fill { color: Some(col(doc.fill)), opacity: Some(fixed(json!(100))), rule: Some(1) }), + ..Default::default() + }); // Items above draw on top: stroke over fill puts the stroke first. if doc.stroke_over_fill { items.extend(stroke); @@ -428,6 +519,15 @@ pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec { items.extend(stroke); } let n = items.len() + 1; - items.push(json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}})); - vec![json!({"ty": "gr", "nm": layer.name, "np": n, "it": items})] + let tr = craft_lottie::model::Transform { + anchor: Some(fixed(json!([0, 0]))), + position: Some(fixed(json!([0, 0]))), + scale: Some(fixed(json!([100, 100]))), + rotation: Some(fixed(json!(0))), + opacity: Some(fixed(json!(100))), + ..Default::default() + }; + items.push(Shape { kind: ShapeKind::Transform(Box::new(tr)), ..Default::default() }); + let group = craft_lottie::model::Group { count: Some(n as f64), items: Some(items) }; + vec![Shape { name: Some(layer.name.clone()), kind: ShapeKind::Group(group), ..Default::default() }] } diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index a4070297b..f744c684c 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -22,17 +22,26 @@ effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } -filmcraft-aac = { workspace = true } -filmcraft-isobmff = { workspace = true } -filmcraft-matroska = { workspace = true } +craft-dpx = { workspace = true } +craft-exr = { workspace = true } +craft-gif = { workspace = true } +craft-webp = { workspace = true } +craft-audio = { workspace = true } +craft-media = { workspace = true } filmcraft-mpegts = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } exr = { workspace = true } -gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } rayon = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } + +[dev-dependencies] +# Writes the animated GIFs of the tests. +gif = { workspace = true } +tempfile = { workspace = true } +# (tests render comps that read layers through effects; already a dependency of effectcraft-render) +effectcraft-effects = { workspace = true } diff --git a/crates/media/src/aac.rs b/crates/media/src/aac.rs index 625c37b43..a1793b7af 100644 --- a/crates/media/src/aac.rs +++ b/crates/media/src/aac.rs @@ -6,7 +6,7 @@ use std::io; -use filmcraft_aac::AudioSpecificConfig; +use craft_audio::aac::AudioSpecificConfig; use filmcraft_media::ByteReader; /// How far into an MPEG TS / PS file the audio streams are looked for: the stream is indexed as @@ -34,15 +34,16 @@ pub(crate) fn config(header: Option<&[u8]>, file: Option<&dyn ByteReader>) -> Op /// are in its fragments, not the `moov`). fn mp4(header: &[u8], file: Option<&dyn ByteReader>) -> Option> { let mp4 = match file { - Some(f) => filmcraft_isobmff::open(Head(f, f.len())), - None => filmcraft_isobmff::open(header), + Some(f) => craft_media::isobmff::open(Head(f, f.len())), + None => craft_media::isobmff::open(header), }; let mp4 = mp4.ok()?; - let playable = - |t: &&filmcraft_isobmff::Track| t.kind == filmcraft_isobmff::TrackKind::Audio && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0; + let playable = |t: &&craft_media::isobmff::Track| { + t.kind == craft_media::isobmff::TrackKind::Audio && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0 + }; let track = mp4.tracks.iter().find(playable)?; match &track.entries.first()?.codec { - filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), + craft_media::isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), _ => None, } } @@ -50,9 +51,9 @@ fn mp4(header: &[u8], file: Option<&dyn ByteReader>) -> Option> { /// The track FilmCraft's Matroska source plays: the first audio track with frames, found by /// indexing the clusters as that source does. fn matroska(file: &dyn ByteReader) -> Option> { - let mkv = filmcraft_matroska::open(Head(file, file.len())).ok()?; - match &mkv.tracks.iter().find(|t| t.kind == filmcraft_matroska::TrackKind::Audio && !t.samples.is_empty())?.codec { - filmcraft_matroska::Codec::Aac { asc } => Some(asc.clone()), + let mkv = craft_media::matroska::open(Head(file, file.len())).ok()?; + match &mkv.tracks.iter().find(|t| t.kind == craft_media::matroska::TrackKind::Audio && !t.samples.is_empty())?.codec { + craft_media::matroska::Codec::Aac { asc } => Some(asc.clone()), _ => None, } } @@ -88,16 +89,7 @@ impl Head<'_> { } } -impl filmcraft_isobmff::ByteSource for Head<'_> { - fn len(&self) -> u64 { - self.1 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.read(offset, buf) - } -} - -impl filmcraft_matroska::ByteSource for Head<'_> { +impl craft_media::ByteSource for Head<'_> { fn len(&self) -> u64 { self.1 } diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index a2b24b3ae..86fb088f9 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -7,8 +7,9 @@ //! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation. use effectcraft_project::{AlphaMode, Footage}; -use effectcraft_raster::{Image, Px}; +use effectcraft_raster::{Image, Px, quantize_px_u8}; use effectcraft_render::FloatCurve; +use effectcraft_render::disk_cache::Frame8; use filmcraft_color::{Matrix, Range}; use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use rayon::prelude::*; @@ -80,14 +81,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - return img; } match &f.data { - PixelData::Rgba8(d) => { - img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let src = &d[y * w * 4..(y + 1) * w * 4]; - for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { - *o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]); - } - }); - } + PixelData::Rgba8(d) => convert_rgba8(&mut img.data, d, w, op, |p| p), PixelData::RgbaF32(d) => { // linear premultiplied → encoded premultiplied img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { @@ -104,7 +98,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - let yuv = Yuv::new(f.color.matrix, f.color.range, 8); let amax = 255.0; convert_yuv( - &mut img, + &mut img.data, YuvSource { size: (w, h), chroma: *chroma, @@ -114,13 +108,14 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - }, &yuv, op, + |p| p, ); } PixelData::Yuv16 { planes, chroma, bits, alpha } => { let yuv = Yuv::new(f.color.matrix, f.color.range, *bits); let amax = ((1u32 << *bits) - 1) as f32; convert_yuv( - &mut img, + &mut img.data, YuvSource { size: (w, h), chroma: *chroma, @@ -130,12 +125,52 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - }, &yuv, op, + |p| p, ); } } img } +/// An 8-bit frame as [`frame_to_image_in`] converts it and an 8 bpc render then quantises it +/// ([`effectcraft_raster::quantize_px`]), kept as the 8-bit levels: expanded with +/// [`effectcraft_raster::expand_u8`] it is that quantised image to the bit, in a quarter of +/// the memory. `None` for frames with more than 8 bits (10/12-bit Y'CbCr, float). +pub(crate) fn frame_to_rgba8(f: &VideoFrame, op: AlphaOp) -> Option { + let (w, h) = (f.width as usize, f.height as usize); + let mut px = vec![[0u8; 4]; w * h]; + if w > 0 && h > 0 { + match &f.data { + PixelData::Rgba8(d) => convert_rgba8(&mut px, d, w, op, quantize_px_u8), + PixelData::Yuv8 { planes, chroma, alpha } => convert_yuv( + &mut px, + YuvSource { + size: (w, h), + chroma: *chroma, + planes: [&planes[0][..], &planes[1][..], &planes[2][..]], + alpha: alpha.as_deref().map(|a| &a[..]), + amax: 255.0, + }, + &Yuv::new(f.color.matrix, f.color.range, 8), + op, + quantize_px_u8, + ), + PixelData::RgbaF32(_) | PixelData::Yuv16 { .. } => return None, + } + } + Some(Frame8 { width: f.width, height: f.height, rgba: px.into_flattened() }) +} + +/// Straight or premultiplied 8-bit RGBA into `out` (one `O` per pixel, made by `store`). +fn convert_rgba8(out: &mut [O], d: &[u8], w: usize, op: AlphaOp, store: impl Fn(Px) -> O + Sync) { + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let Some(src) = d.get(y * w * 4..(y + 1) * w * 4) else { return }; + for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { + *o = store(op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize])); + } + }); +} + /// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]: /// clockwise quarter turns; anything else leaves it as decoded). pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image { @@ -311,13 +346,14 @@ struct YuvSource<'a, T> { amax: f32, } -fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp) { +/// Y'CbCr into `out` (one `O` per pixel, made by `store` from the converted pixel). +fn convert_yuv(out: &mut [O], source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp, store: impl Fn(Px) -> O + Sync) { let YuvSource { size: (w, h), chroma, planes, alpha, amax } = source; let (sx, sy) = chroma.shifts(); let cw = w.div_ceil(1 << sx); let ch = h.div_ceil(1 << sy); let inv_amax = 1.0 / amax; - img.data.par_chunks_mut(w).enumerate().for_each_init( + out.par_chunks_mut(w).enumerate().for_each_init( || (vec![0f32; cw], vec![0f32; cw], vec![0f32; w], vec![0f32; w]), |(uc, vc, uf, vf), (y, row)| { // vertical chroma interpolation into normalised rows (Cb, Cr in −0.5..0.5) @@ -357,13 +393,13 @@ fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op None => { for (x, o) in row.iter_mut().enumerate() { let c = rgb(x); - *o = [c[0], c[1], c[2], 1.0]; + *o = store([c[0], c[1], c[2], 1.0]); } } Some(al) => { let arow = &al[y * w..y * w + w]; for (x, o) in row.iter_mut().enumerate() { - *o = op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0)); + *o = store(op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0))); } } } @@ -552,6 +588,43 @@ mod tests { assert!((p[0] - 0.5).abs() < 1e-3 && (p[1] - 1.0).abs() < 1e-5 && p[2] > 1.5, "{p:?}"); } + /// The 8-bit levels of a frame expand to the float frame quantised to 8 bpc, bit for bit: + /// 4:2:0 and 4:4:4 Y'CbCr with and without alpha, and RGBA, under every alpha interpretation. + #[test] + fn rgba8_levels_are_the_quantised_float_frame() { + let (w, h) = (37usize, 18usize); + let noise = |n: usize, seed: usize| Arc::new((0..n).map(|i| ((i * 7919 + seed * 104_729) % 256) as u8).collect::>()); + let frame = |data| VideoFrame { + width: w as u32, + height: h as u32, + data, + color: filmcraft_color::ColorInfo::REC709, + par: (1, 1), + pts: filmcraft_time::Tick::ZERO, + }; + let yuv = |chroma: Chroma, alpha: bool| { + let n = w.div_ceil(1 << chroma.shifts().0) * h.div_ceil(1 << chroma.shifts().1); + frame(PixelData::Yuv8 { planes: [noise(w * h, 1), noise(n, 2), noise(n, 3)], chroma, alpha: alpha.then(|| noise(w * h, 4)) }) + }; + let frames = [yuv(Chroma::C420, false), yuv(Chroma::C420, true), yuv(Chroma::C444, true), frame(PixelData::Rgba8(noise(w * h * 4, 5)))]; + for f in &frames { + for mode in [AlphaMode::Straight, AlphaMode::Premultiplied, AlphaMode::Ignore] { + let op = AlphaOp::new(mode, [0.2, 0.5, 0.9]); + let q8 = frame_to_rgba8(f, op).expect("8-bit"); + let mut want = frame_to_image(f, op); + for p in &mut want.data { + effectcraft_raster::quantize_px(p, 255.0, 1.0 / 255.0); + } + assert_eq!((q8.width, q8.height), (want.width, want.height)); + for (got, want) in q8.rgba.as_chunks::<4>().0.iter().zip(&want.data) { + assert_eq!(effectcraft_raster::expand_u8(*got).map(f32::to_bits), want.map(f32::to_bits)); + } + } + } + let wide = frame(PixelData::RgbaF32(Arc::new(vec![0.5; w * h * 4]))); + assert!(frame_to_rgba8(&wide, AlphaOp::new(AlphaMode::Straight, [0.0; 3])).is_none()); + } + /// `cargo test -p effectcraft-media --lib bench -- --ignored --nocapture` #[test] #[ignore] diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 09bb1e1f6..5e086bdaf 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -19,7 +19,7 @@ use std::f32::consts::FRAC_1_SQRT_2; use std::sync::Arc; -use filmcraft_aac::{AudioSpecificConfig, ProgramConfig}; +use craft_audio::aac::{AudioSpecificConfig, ProgramConfig}; /// A source channel's contribution to the left and right outputs, before normalisation. pub type Pair = [f32; 2]; @@ -185,7 +185,7 @@ mod tests { } } - /// AudioSpecificConfigs as ffmpeg 8.1 and FilmCraft's encoder write them (48 kHz AAC-LC). + /// AudioSpecificConfigs as ffmpeg 8.1 and craft-audio's encoder write them (48 kHz AAC-LC). const PCE_FFMPEG_61: &str = "118004c848002000c4400d4c61766336322e32382e31303256e500"; const PCE_FILMCRAFT_61: &str = "118004c845000108840000"; diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs index a5b2abdaf..f08f1344d 100644 --- a/crates/media/src/dpx.rs +++ b/crates/media/src/dpx.rs @@ -1,17 +1,11 @@ -//! DPX (SMPTE 268M) and Kodak Cineon stills, the film-scan interchange formats, read from the -//! public specifications. +//! DPX (SMPTE 268M) and Kodak Cineon stills, the film-scan interchange formats, read with +//! `craft-dpx`; this module maps a frame onto footage and 16-bit pixels. //! -//! Supported: luma, RGB, RGBA and ABGR (DPX) or one / three channels (Cineon), 8-, 10-, 12- and -//! 16-bit unsigned samples, 10-bit filled into 32-bit words with the pad bits low (DPX method A, -//! Cineon "32-bit left justified") or high (method B, "right justified"), 12-bit in 16-bit words, -//! big- and little-endian files, the four unrotated image orientations and the pixel aspect ratio. //! Code values are shown as stored (a log scan looks flat, as in After Effects without a Cineon -//! conversion); the codec names the transfer (`log`, `linear`, …). Anything else (run-length -//! encoding, signed or float samples, Y'CbCr, packed 10/12-bit, transposed images) is refused -//! with an error naming it. Every field is read with bounds checks. +//! conversion); the codec names the transfer (`log`, `linear`, …). Y'CbCr elements and transposed +//! images are refused with an error naming them; so is everything `craft-dpx` does not read. use effectcraft_project::{AlphaMode, Footage}; -use rayon::prelude::*; use crate::{MediaError, Result}; @@ -21,391 +15,110 @@ pub(crate) const EXTENSIONS: &[&str] = &["dpx", "cin"]; /// Largest image accepted (8192 × 8192): bigger headers are refused before allocating. const MAX_PIXELS: u64 = 1 << 26; -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Kind { - Dpx, - Cineon, +fn limits() -> craft_dpx::Limits { + let mut l = craft_dpx::Limits::default(); + l.max_pixels = MAX_PIXELS; + l } -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Layout { - Luma, - Rgb, - Rgba, - Abgr, -} - -impl Layout { - fn components(self) -> usize { - match self { - Layout::Luma => 1, - Layout::Rgb => 3, - Layout::Rgba | Layout::Abgr => 4, - } - } - fn name(self) -> &'static str { - match self { - Layout::Luma => "luma", - Layout::Rgb => "RGB", - Layout::Rgba => "RGBA", - Layout::Abgr => "ABGR", - } +fn error(path: &str, e: craft_dpx::Error) -> MediaError { + match e { + craft_dpx::Error::Unsupported(_) => MediaError::Unsupported(format!("{path}: {e}")), + _ => MediaError::Decode(format!("{path}: {e}")), } } -/// How samples are stored in a line. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Packing { - /// One byte per sample. - Bytes8, - /// One 16-bit word per sample. - Words16, - /// Three 10-bit samples per 32-bit word, first in the high bits, two pad bits low. - Filled10A, - /// Three 10-bit samples per 32-bit word, two pad bits high. - Filled10B, - /// One 12-bit sample per 16-bit word, four pad bits low. - Filled12A, - /// One 12-bit sample per 16-bit word, four pad bits high. - Filled12B, +/// The header of a DPX or Cineon file this module shows (`None` for other formats). +fn info(path: &str, bytes: &[u8]) -> Result> { + if craft_dpx::sniff(bytes).is_none() { + return Ok(None); + } + let info = craft_dpx::probe(bytes, &limits()).map_err(|e| error(path, e))?; + if info.layout.is_ycbcr() { + return Err(MediaError::Unsupported(format!("{path}: DPX with Y'CbCr samples is not supported"))); + } + if info.flips().is_none() { + return Err(MediaError::Unsupported(format!("{path}: DPX orientation {} (transposed image) is not supported", info.orientation))); + } + Ok(Some(info)) } -impl Packing { - fn max(self) -> u32 { - match self { - Packing::Bytes8 => 0xff, - Packing::Words16 => 0xffff, - Packing::Filled10A | Packing::Filled10B => 0x3ff, - Packing::Filled12A | Packing::Filled12B => 0xfff, - } - } - fn bits(self) -> u32 { - match self { - Packing::Bytes8 => 8, - Packing::Words16 => 16, - Packing::Filled10A | Packing::Filled10B => 10, - Packing::Filled12A | Packing::Filled12B => 12, - } - } - /// Bytes holding `n` samples, padded to a 32-bit word (lines start on word boundaries). - fn line_bytes(self, n: u64) -> u64 { - match self { - Packing::Bytes8 => n.div_ceil(4) * 4, - Packing::Words16 | Packing::Filled12A | Packing::Filled12B => (n * 2).div_ceil(4) * 4, - Packing::Filled10A | Packing::Filled10B => n.div_ceil(3) * 4, - } +fn layout_name(info: &craft_dpx::Info) -> &'static str { + match info.layout { + craft_dpx::Layout::Luma => "luma", + craft_dpx::Layout::Rgb => "RGB", + _ if info.descriptor == 52 => "ABGR", + _ => "RGBA", } } -/// What a header says about the picture. -#[derive(Clone, Debug)] -struct Header { - kind: Kind, - big_endian: bool, - width: u32, - height: u32, - layout: Layout, - packing: Packing, - /// Byte offset of the first line. - offset: u64, - /// Bytes from one line's start to the next. - stride: u64, - flip_x: bool, - flip_y: bool, - pixel_aspect: f64, - /// Transfer characteristic for the codec name (`log`, `linear`, …), empty when unstated. - transfer: &'static str, -} - -/// Bounds-checked reads in the file's byte order. -struct Reader<'a> { - b: &'a [u8], - big: bool, -} - -impl Reader<'_> { - fn u8(&self, o: usize) -> Option { - self.b.get(o).copied() +/// The transfer characteristic for the codec name, empty when unstated. +fn transfer(info: &craft_dpx::Info) -> &'static str { + if info.kind == craft_dpx::Kind::Cineon { + return "log"; } - fn u16(&self, o: usize) -> Option { - let s: [u8; 2] = self.b.get(o..o.checked_add(2)?)?.try_into().ok()?; - Some(if self.big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) }) - } - fn u32(&self, o: usize) -> Option { - let s: [u8; 4] = self.b.get(o..o.checked_add(4)?)?.try_into().ok()?; - Some(if self.big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) }) - } -} - -const UNDEFINED32: u32 = u32::MAX; - -/// DPX or Cineon, and the byte order, from the magic number. -fn sniff(bytes: &[u8]) -> Option<(Kind, bool)> { - match bytes.get(..4)? { - b"SDPX" => Some((Kind::Dpx, true)), - b"XPDS" => Some((Kind::Dpx, false)), - [0x80, 0x2a, 0x5f, 0xd7] => Some((Kind::Cineon, true)), - [0xd7, 0x5f, 0x2a, 0x80] => Some((Kind::Cineon, false)), - _ => None, - } -} - -fn unsupported(path: &str, what: impl std::fmt::Display) -> MediaError { - MediaError::Unsupported(format!("{path}: {what}")) -} - -fn truncated(path: &str, kind: Kind) -> MediaError { - let name = if kind == Kind::Dpx { "DPX" } else { "Cineon" }; - MediaError::Decode(format!("{path}: truncated {name} header")) -} - -fn parse(path: &str, bytes: &[u8]) -> Result> { - let Some((kind, big_endian)) = sniff(bytes) else { return Ok(None) }; - let r = Reader { b: bytes, big: big_endian }; - let h = match kind { - Kind::Dpx => parse_dpx(path, &r)?, - Kind::Cineon => parse_cineon(path, &r)?, - }; - let pixels = u64::from(h.width) * u64::from(h.height); - if h.width == 0 || h.height == 0 || pixels > MAX_PIXELS { - return Err(MediaError::Decode(format!("{path}: image size {} × {} out of range", h.width, h.height))); - } - let need = h.offset.saturating_add(h.stride.saturating_mul(u64::from(h.height))); - if (bytes.len() as u64) < need { - return Err(MediaError::Decode(format!("{path}: truncated image data ({} of {need} bytes)", bytes.len()))); - } - Ok(Some(h)) -} - -fn parse_dpx(path: &str, r: &Reader) -> Result
{ - let t = || truncated(path, Kind::Dpx); - let image_offset = r.u32(4).ok_or_else(t)?; - let orientation = r.u16(768).ok_or_else(t)?; - let elements = r.u16(770).ok_or_else(t)?; - let width = r.u32(772).ok_or_else(t)?; - let height = r.u32(776).ok_or_else(t)?; - // The first image element (the picture; further elements are alternative views). - let sign = r.u32(780).ok_or_else(t)?; - let descriptor = r.u8(800).ok_or_else(t)?; - let transfer = r.u8(801).ok_or_else(t)?; - let bits = r.u8(803).ok_or_else(t)?; - let packing = r.u16(804).ok_or_else(t)?; - let encoding = r.u16(806).ok_or_else(t)?; - let data_offset = r.u32(808).ok_or_else(t)?; - let eol_padding = r.u32(812).ok_or_else(t)?; - if elements == 0 || elements == u16::MAX { - return Err(unsupported(path, "DPX file without an image element")); - } - if sign == 1 { - return Err(unsupported(path, "DPX with signed samples is not supported")); - } - if encoding == 1 { - return Err(unsupported(path, "run-length encoded DPX is not supported")); - } - let layout = match descriptor { - 6 => Layout::Luma, - 50 => Layout::Rgb, - 51 => Layout::Rgba, - 52 => Layout::Abgr, - 100..=103 => return Err(unsupported(path, "DPX with Y'CbCr samples is not supported")), - d => return Err(unsupported(path, format!("DPX image element descriptor {d} is not supported (luma, RGB, RGBA and ABGR are)"))), - }; - let packing = match (bits, packing) { - (8, _) => Packing::Bytes8, - (16, _) => Packing::Words16, - (10, 1) => Packing::Filled10A, - (10, 2) => Packing::Filled10B, - (12, 1) => Packing::Filled12A, - (12, 2) => Packing::Filled12B, - (10 | 12, _) => return Err(unsupported(path, format!("DPX with packed {bits}-bit samples (packing {packing}) is not supported"))), - (b, _) => return Err(unsupported(path, format!("DPX with {b}-bit samples is not supported (8, 10, 12 and 16 are)"))), - }; - let (flip_x, flip_y) = match orientation { - 0 | u16::MAX => (false, false), - 1 => (true, false), - 2 => (false, true), - 3 => (true, true), - o => return Err(unsupported(path, format!("DPX orientation {o} (transposed image) is not supported"))), - }; - let offset = if data_offset == 0 || data_offset == UNDEFINED32 { image_offset } else { data_offset }; - let eol = if eol_padding == UNDEFINED32 { 0 } else { eol_padding }; - let samples = u64::from(width) * layout.components() as u64; - // Pixel aspect ratio (orientation header), when stated. - let pixel_aspect = match (r.u32(1628), r.u32(1632)) { - (Some(h), Some(v)) if h != 0 && v != 0 && h != UNDEFINED32 && v != UNDEFINED32 => { - let a = f64::from(h) / f64::from(v); - if (0.1..=10.0).contains(&a) { a } else { 1.0 } - } - _ => 1.0, - }; - let transfer = match transfer { - 1 => "log (printing density)", - 2 => "linear", - 3 => "log", - 5 | 6 => "Rec. 709", - 13 => "ADX", + match info.transfer { + Some(1) => "log (printing density)", + Some(2) => "linear", + Some(3) => "log", + Some(5 | 6) => "Rec. 709", + Some(13) => "ADX", _ => "", - }; - Ok(Header { - kind: Kind::Dpx, - big_endian: r.big, - width, - height, - layout, - packing, - offset: u64::from(offset), - stride: packing.line_bytes(samples) + u64::from(eol), - flip_x, - flip_y, - pixel_aspect, - transfer, - }) -} - -fn parse_cineon(path: &str, r: &Reader) -> Result
{ - let t = || truncated(path, Kind::Cineon); - let offset = r.u32(4).ok_or_else(t)?; - let orientation = r.u8(192).ok_or_else(t)?; - let channels = r.u8(193).ok_or_else(t)?; - // Channel 0's description; every channel must match it. - let bits = r.u8(198).ok_or_else(t)?; - let width = r.u32(200).ok_or_else(t)?; - let height = r.u32(204).ok_or_else(t)?; - let interleave = r.u8(680).ok_or_else(t)?; - let packing = r.u8(681).ok_or_else(t)?; - let signed = r.u8(682).ok_or_else(t)?; - let eol_padding = r.u32(684).ok_or_else(t)?; - let layout = match channels { - 1 => Layout::Luma, - 3 => Layout::Rgb, - c => return Err(unsupported(path, format!("Cineon with {c} channels is not supported (1 and 3 are)"))), - }; - for c in 1..usize::from(channels) { - let base = 196 + 28 * c; - if r.u8(base + 2) != Some(bits) || r.u32(base + 4) != Some(width) || r.u32(base + 8) != Some(height) { - return Err(unsupported(path, "Cineon channels of different sizes or depths are not supported")); - } } - if interleave != 0 { - return Err(unsupported(path, "Cineon with line or channel interleave is not supported")); - } - if signed == 1 { - return Err(unsupported(path, "Cineon with signed samples is not supported")); - } - let packing = match (bits, packing) { - (8, _) => Packing::Bytes8, - (16, _) => Packing::Words16, - (10, 5) => Packing::Filled10A, - (10, 6) => Packing::Filled10B, - (10, p) => return Err(unsupported(path, format!("Cineon with 10-bit packing {p} is not supported (32-bit words are)"))), - (b, _) => return Err(unsupported(path, format!("Cineon with {b}-bit samples is not supported (8, 10 and 16 are)"))), - }; - let (flip_x, flip_y) = match orientation { - 0 | u8::MAX => (false, false), - 1 => (false, true), - 2 => (true, false), - 3 => (true, true), - o => return Err(unsupported(path, format!("Cineon orientation {o} (transposed image) is not supported"))), - }; - let eol = if eol_padding == UNDEFINED32 { 0 } else { eol_padding }; - let samples = u64::from(width) * layout.components() as u64; - Ok(Header { - kind: Kind::Cineon, - big_endian: r.big, - width, - height, - layout, - packing, - offset: u64::from(offset), - stride: packing.line_bytes(samples) + u64::from(eol), - flip_x, - flip_y, - pixel_aspect: 1.0, - transfer: "log", - }) } /// Footage for a DPX or Cineon file (`None` for other formats). pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { - let Some(h) = parse(path, bytes)? else { return Ok(None) }; - let alpha = matches!(h.layout, Layout::Rgba | Layout::Abgr); + let Some(h) = info(path, bytes)? else { return Ok(None) }; + let alpha = h.layout.has_alpha(); let mut f = crate::probe::still_footage(path, h.width, h.height, image::ImageFormat::Png, alpha); f.alpha = if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }; - f.pixel_aspect = h.pixel_aspect; - let name = if h.kind == Kind::Dpx { "DPX" } else { "Cineon" }; - f.codec = format!("{name} {}-bit {}", h.packing.bits(), h.layout.name()); - if !h.transfer.is_empty() { - f.codec = format!("{}, {}", f.codec, h.transfer); + f.pixel_aspect = + h.pixel_aspect.map(|(x, y)| f64::from(x) / f64::from(y)).filter(|a| (0.1..=10.0).contains(a) && h.kind == craft_dpx::Kind::Dpx).unwrap_or(1.0); + let name = if h.kind == craft_dpx::Kind::Dpx { "DPX" } else { "Cineon" }; + f.codec = format!("{name} {}-bit {}", h.bits, layout_name(&h)); + let t = transfer(&h); + if !t.is_empty() { + f.codec = format!("{}, {t}", f.codec); } Ok(Some(f)) } -/// The picture of a DPX or Cineon file as 16-bit samples (`None` for other formats). +/// The picture of a DPX or Cineon file as 16-bit samples, upright (`None` for other formats). pub(crate) fn decode(path: &str, bytes: &[u8]) -> Result> { - let Some(h) = parse(path, bytes)? else { return Ok(None) }; - let (w, ht) = (h.width as usize, h.height as usize); - // Output components: luma, RGB or RGBA (ABGR reordered). - let nc = h.layout.components(); - let row_len = w * nc; - let mut out = vec![0u16; row_len * ht]; - let max = h.packing.max(); - out.par_chunks_mut(row_len).enumerate().for_each(|(y, row)| { - let sy = if h.flip_y { ht - 1 - y } else { y }; - let start = h.offset.saturating_add(h.stride.saturating_mul(sy as u64)); - let line = usize::try_from(start).ok().and_then(|s| bytes.get(s..)).unwrap_or(&[]); - for x in 0..w { - let sx = if h.flip_x { w - 1 - x } else { x }; - for c in 0..nc { - // ABGR stores alpha first and colour reversed. - let sc = if h.layout == Layout::Abgr { 3 - c } else { c }; - let v = sample(line, h.packing, h.big_endian, sx * nc + sc); - if let Some(o) = row.get_mut(x * nc + c) { - *o = ((u64::from(v.min(max)) * 0xffff + u64::from(max) / 2) / u64::from(max)) as u16; + let Some(h) = info(path, bytes)? else { return Ok(None) }; + let (flip_x, flip_y) = h.flips().unwrap_or_default(); + let frame = craft_dpx::decode(bytes, &limits()).map_err(|e| error(path, e))?; + let (w, ht, nc) = (h.width as usize, h.height as usize, h.layout.components()); + let row_len = w.checked_mul(nc).ok_or_else(|| MediaError::Decode(format!("{path}: image dimensions overflow")))?; + let mut out = frame.codes; + if flip_y { + for y in 0..ht / 2 { + for x in 0..row_len { + out.swap(y * row_len + x, (ht - 1 - y) * row_len + x); + } + } + } + if flip_x { + for row in out.chunks_exact_mut(row_len) { + for x in 0..w / 2 { + let (a, b) = (x * nc, (w - 1 - x) * nc); + for c in 0..nc { + row.swap(a + c, b + c); } } } - }); + } + out.iter_mut().for_each(|c| *c = craft_dpx::widen(*c, h.bits)); let (w32, h32) = (h.width, h.height); let img = match h.layout { - Layout::Luma => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageLuma16), - Layout::Rgb => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgb16), - Layout::Rgba | Layout::Abgr => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgba16), + craft_dpx::Layout::Luma => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageLuma16), + craft_dpx::Layout::Rgb => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgb16), + _ => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgba16), }; img.map(Some).ok_or_else(|| MediaError::Decode(format!("{path}: image buffer size mismatch"))) } -/// Sample `i` of a line (0 past its end; the header check makes that unreachable for valid -/// headers). -#[inline] -fn sample(line: &[u8], packing: Packing, big: bool, i: usize) -> u32 { - let word16 = |o: usize| -> u32 { - match line.get(o..o + 2) { - Some(&[a, b]) => u32::from(if big { u16::from_be_bytes([a, b]) } else { u16::from_le_bytes([a, b]) }), - _ => 0, - } - }; - let word32 = |o: usize| -> u32 { - match line.get(o..o + 4) { - Some(&[a, b, c, d]) => { - if big { - u32::from_be_bytes([a, b, c, d]) - } else { - u32::from_le_bytes([a, b, c, d]) - } - } - _ => 0, - } - }; - match packing { - Packing::Bytes8 => line.get(i).copied().map_or(0, u32::from), - Packing::Words16 => word16(i * 2), - Packing::Filled12A => word16(i * 2) >> 4, - Packing::Filled12B => word16(i * 2) & 0xfff, - Packing::Filled10A => (word32(i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3ff, - Packing::Filled10B => (word32(i / 3 * 4) >> (20 - 10 * (i % 3) as u32)) & 0x3ff, - } -} - #[cfg(test)] mod tests { use super::*; @@ -452,7 +165,7 @@ mod tests { #[test] fn hostile_headers_never_panic() { - assert!(parse("x", b"not a dpx").expect("other").is_none()); + assert!(info("x", b"not a dpx").expect("other").is_none()); let good = { let mut a = dpx_header(4, 4, 50, 10, 1, 0); a.extend(std::iter::repeat_n(0x5a, 64)); @@ -462,14 +175,18 @@ mod tests { for n in [4, 8, 600, 790, 805, 2047, 2048, 2100] { assert!(decode("t.dpx", good.get(..n).expect("cut")).is_err(), "{n}"); } - // huge, zero, unsupported + // huge, zero, Y'CbCr, unsupported depth, transposed for (w, h, d, bits, p, o) in - [(u32::MAX, u32::MAX, 50, 10, 1, 0), (0, 4, 50, 10, 1, 0), (4, 4, 102, 10, 1, 0), (4, 4, 50, 10, 0, 0), (4, 4, 50, 32, 0, 0), (4, 4, 50, 10, 1, 5)] + [(u32::MAX, u32::MAX, 50, 10, 1, 0), (0, 4, 50, 10, 1, 0), (4, 4, 102, 10, 1, 0), (4, 4, 50, 32, 0, 0), (4, 4, 50, 10, 1, 5)] { let mut b = dpx_header(w, h, d, bits, p, o); b.extend(std::iter::repeat_n(0, 64)); - assert!(probe("h.dpx", &b).is_err() && decode("h.dpx", &b).is_err()); + assert!(probe("h.dpx", &b).is_err() && decode("h.dpx", &b).is_err(), "{w}x{h} {d} {bits} {p} {o}"); } + // 8192 × 8193 is past this module's own cap, which is below craft-dpx's default. + let mut big = dpx_header(8192, 8193, 6, 8, 0, 0); + big.extend(std::iter::repeat_n(0, 64)); + assert!(probe("big.dpx", &big).is_err()); // run-length encoding and offsets past the end are refused let mut rle = good.clone(); rle[806..808].copy_from_slice(&1u16.to_be_bytes()); diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index c3e7cfc3d..96b0173cd 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; use effectcraft_raster::{AuxChannels, Image}; +use effectcraft_render::reads::{ReadFile, ReadFiles}; use exr::block::UncompressedBlock; use exr::block::chunk::TileCoordinates; use exr::image::read::image::ReadImage; @@ -30,35 +31,15 @@ use crate::convert::{AlphaOp, float_to_image}; /// stop at the same size). const MAX_DISPLAY_PIXELS: usize = 1 << 28; -fn is_depth(name: &str) -> bool { - let last = name.rsplit('.').next().unwrap_or(name); - last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth") -} - -/// Full plane name of a channel in a part: `part.channel`, except that Blender 5 writes one part -/// per pass whose channels already carry the full name (part `ViewLayer.Depth` holds -/// `ViewLayer.Depth.Z`), which is kept as is instead of doubling the prefix. -fn channel_name(part: Option<&str>, channel: &str) -> String { - match part { - Some(p) if channel.strip_prefix(p).is_some_and(|rest| rest.starts_with('.')) => channel.to_string(), - Some(p) => format!("{p}.{channel}"), - None => channel.to_string(), - } -} +// Channel naming and the Cryptomatte manifest are `craft-exr`'s. +use craft_exr::channels::{is_depth, part_channel_name as channel_name}; +pub use craft_exr::cryptomatte::parse_manifest; /// The part name channels are prefixed with (`None`: unnamed). fn part_name(header: &Header) -> Option { header.own_attributes.layer_name.as_ref().map(|t| t.to_string()).filter(|s| !s.is_empty()) } -/// Parse a Cryptomatte manifest (`{"name": "hexhash", …}`). -pub fn parse_manifest(json: &str) -> Vec<(String, u32)> { - let Ok(serde_json::Value::Object(m)) = serde_json::from_str::(json) else { return vec![] }; - let mut v: Vec<(String, u32)> = m.iter().filter_map(|(k, h)| Some((k.clone(), u32::from_str_radix(h.as_str()?.trim(), 16).ok()?))).collect(); - v.sort(); - v -} - /// Why an OpenEXR file's pixels can't be decoded when its headers name a compression this build /// can't decompress (HTJ2K, OpenEXR 3.4's High-Throughput JPEG 2000): the decoders only said /// "no non-deep rgb channels" or showed a transparent frame (#482). `None` for other files. @@ -477,8 +458,8 @@ fn layered_picture_of(aux: &AuxChannels, op: AlphaOp) -> Option { /// decoded anyway), else of the picture's channels alone (also when the cache has no channels /// for the file: a read that failed, or a broken part the picture doesn't use). `Err(Some(why))` /// when the file is too large to decode, `Err(None)` when it isn't a readable multi-layer EXR. -pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared: bool) -> Result> { - if shared && let Some(d) = cache().get(path, Use::Picture, || Some(bytes.clone())) { +pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp, shared: bool) -> Result> { + if shared && let Some(d) = cache().get(path, Use::Picture, || read_as(file, bytes.clone())) { return layered_picture_of(&d.aux, op).ok_or(None); } let (d, rgb, a) = picture_only(&bytes).map_err(|r| match r { @@ -499,8 +480,8 @@ pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared: /// decoder shows for a file it reads, [`layered_image`]'s rule for one it refuses. `None` when /// the planes can't stand in for the `image` crate's picture (decode with it), or the file is /// not a readable EXR. -pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp) -> Option { - let d = cache().get(path, Use::Picture, || Some(bytes))?; +pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp) -> Option { + let d = cache().get(path, Use::Picture, || read_as(file, bytes))?; let aux = &d.aux; match d.rgb { Rgb::None => layered_picture_of(aux, op), @@ -536,8 +517,16 @@ const CACHE_FILES: usize = 64; /// rendered at once. The oldest stops being kept when another comes. const HELD_FILES: usize = 4; -/// A file's decode: done once, by the first caller; the others wait for it. -type Slot = Arc>>>; +/// A file's decode: done once, by the first caller; the others wait for it. With the files read +/// for it (see [`crate::reads`]), noted again on every hit. +type Slot = Arc>, ReadFiles)>>; + +/// Bytes the caller already read from `file`, given to the cache as read from it (so the entry +/// keeps the file's identity). +fn read_as(file: &ReadFile, bytes: Arc<[u8]>) -> Option> { + crate::reads::note(file.clone()); + Some(bytes) +} struct Entry { path: String, @@ -554,8 +543,8 @@ impl Entry { fn in_use(&self) -> bool { match self.slot.get() { None => true, - Some(None) => false, - Some(Some(d)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1, + Some((None, _)) => false, + Some((Some(d), _)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1, } } } @@ -648,30 +637,18 @@ impl ChannelCache { // is safe because the decode never waits on rayon's global pool (the exr crate // decompresses on a pool of its own and this thread only receives the blocks), so the // decoding thread can't be waiting for this one. - let v = slot - .get_or_init(|| { - decoded_here = true; - self.decodes.fetch_add(1, Ordering::Relaxed); - #[cfg(test)] - count_decode(true); - let _abandon = Abandon { cache: self, slot: &slot }; - let bytes = read()?; - // The planes it will allocate count against the budget while it decodes. - if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes[..]), false) - && let Some(need) = planes_bytes(&meta, &|_| true) - { - self.account(&slot, need.min(MAX_DECODE_BYTES)); - } - match decode(&bytes, &|_| true) { - Ok(d) => Some(Arc::new(d)), - Err(Refused::TooLarge(why)) => { - log::warn!("media: {path}: {why}"); - None - } - Err(Refused::Unreadable) => None, - } - }) - .clone(); + let (v, files) = slot.get_or_init(|| { + decoded_here = true; + self.decodes.fetch_add(1, Ordering::Relaxed); + #[cfg(test)] + count_decode(true); + let _abandon = Abandon { cache: self, slot: &slot }; + let (bytes, files) = crate::reads::captured(read); + (bytes.and_then(|bytes| self.decode_file(path, &slot, &bytes)), files) + }); + // The file(s) the decode was made of, read now or for an earlier caller. + crate::reads::note_all(files); + let v = v.clone(); if decoded_here { self.account(&slot, v.as_ref().map_or(0, |d| d.bytes())); } @@ -692,6 +669,24 @@ impl ChannelCache { v } + /// Decode the file at `path` whose bytes are `bytes` for `slot` (see [`ChannelCache::get`]). + fn decode_file(&self, path: &str, slot: &Slot, bytes: &[u8]) -> Option> { + // The planes it will allocate count against the budget while it decodes. + if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false) + && let Some(need) = planes_bytes(&meta, &|_| true) + { + self.account(slot, need.min(MAX_DECODE_BYTES)); + } + match decode(bytes, &|_| true) { + Ok(d) => Some(Arc::new(d)), + Err(Refused::TooLarge(why)) => { + log::warn!("media: {path}: {why}"); + None + } + Err(Refused::Unreadable) => None, + } + } + /// Drop least recently used files not in use until the rest fit the budget, keeping `newest`. fn evict(entries: &mut Vec, budget: usize, newest: &Slot) { while entries.iter().map(|e| e.bytes).sum::() > budget || entries.len() > CACHE_FILES { @@ -996,7 +991,7 @@ mod tests { let path = "/474-shared-fallback.exr"; assert!(cache().get(path, Use::Channels, || None).is_none(), "a read that failed"); let op = AlphaOp::of(&Footage::default(), FloatCurve::Linear); - let img = layered_picture(path, bytes, op, true).unwrap(); + let img = layered_picture(path, bytes, &ReadFile::new(path, effectcraft_render::reads::FileStamp::Virtual), op, true).unwrap(); assert_eq!(img.get(1, 1), [0.5, 0.5, 0.5, 1.0]); } @@ -1174,7 +1169,8 @@ mod tests { for curve in [FloatCurve::Srgb, FloatCurve::Linear, FloatCurve::Gamma24] { let op = AlphaOp::of(&Footage { alpha, premul_color: [0.2, 0.4, 0.6], ..Default::default() }, curve); let want = crate::convert::dynamic_to_image(&img, op); - let got = cached_picture(&path, bytes.as_slice().into(), op).unwrap(); + let got = cached_picture(&path, bytes.as_slice().into(), &ReadFile::new(path.as_str(), effectcraft_render::reads::FileStamp::Virtual), op) + .unwrap(); assert_eq!((got.width, got.height), (want.width, want.height)); assert_eq!(bits(&got), bits(&want), "file {k} {alpha:?} {curve:?}"); } diff --git a/crates/media/src/gif_anim.rs b/crates/media/src/gif_anim.rs index 09c0cda34..8453b50ac 100644 --- a/crates/media/src/gif_anim.rs +++ b/crates/media/src/gif_anim.rs @@ -43,18 +43,11 @@ pub(crate) fn gcd(a: u32, b: u32) -> u32 { } /// Describe an animated GIF as a movie. `None` for a GIF with fewer than two frames (a still) or -/// one whose headers can't be read. +/// one whose headers can't be read. A damaged tail keeps the frames before it. pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option { - let mut dec = gif::DecodeOptions::new().read_info(Cursor::new(bytes)).ok()?; - let (width, height) = (u32::from(dec.width()), u32::from(dec.height())); - let mut delays: Vec = Vec::new(); - while delays.len() < MAX_FRAMES { - match dec.next_frame_info() { - Ok(Some(f)) => delays.push(delay_cs(u32::from(f.delay))), - // The end, or a damaged tail: keep the frames before it. - _ => break, - } - } + let info = craft_gif::scan(bytes, MAX_FRAMES)?; + let (width, height) = (u32::from(info.width), u32::from(info.height)); + let delays: Vec = info.frames.iter().map(|f| delay_cs(u32::from(f.delay_cs))).collect(); if delays.len() < 2 || width == 0 || height == 0 { return None; } @@ -93,7 +86,18 @@ impl Anim { if crate::webp_anim::is_webp(bytes) { return crate::webp_anim::load(bytes); } - let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; + let mut dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; + // A frame may be far larger than the canvas, and the decoder allocates its buffer before + // decoding it: no single buffer may pass what all the frames may hold. + let mut limits = image::Limits::no_limits(); + limits.max_alloc = Some(MAX_BYTES as u64); + image::ImageDecoder::set_limits(&mut dec, limits).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; + // The decoder allocates the full logical canvas for the first frame, however small the + // frames themselves are: check it before decoding any. + let (w, h) = image::ImageDecoder::dimensions(&dec); + if u64::from(w) * u64::from(h) * 4 > MAX_BYTES as u64 { + return Err(MediaError::Unsupported(format!("GIF canvas of {w}×{h} is too large to hold in memory"))); + } let (mut frames, mut starts) = (Vec::new(), Vec::new()); let (mut at, mut size) = (0i64, 0usize); for frame in dec.into_frames() { @@ -179,6 +183,17 @@ mod tests { assert_eq!(colour_at(&pool, &f, 5.0), [0.0, 0.0, 1.0]); } + #[test] + fn a_frame_far_larger_than_the_canvas_is_refused_before_it_is_allocated() { + // A 1×1 canvas and a 20000×20000 frame (1.6 GB of RGBA); the data itself is not there. + let mut bytes = b"GIF89a".to_vec(); + // (a two-entry global colour table, so the frame gets as far as its buffer) + bytes.extend([1, 0, 1, 0, 0x80, 0, 0, 0, 0, 0, 255, 255, 255]); + bytes.extend([0x2c, 0, 0, 0, 0, 0x20, 0x4e, 0x20, 0x4e, 0, 2, 0]); + let e = crate::gif_anim::Anim::load(&bytes).err().expect("refused").to_string(); + assert!(e.to_lowercase().contains("memory"), "{e}"); + } + #[test] fn a_zero_delay_is_ten_hundredths_like_in_browsers() { let f = probe_bytes("a.gif", gif(&[(RED, 0), (GREEN, 1)]).into()).unwrap(); @@ -212,4 +227,18 @@ mod tests { assert!(pool.frame_at(&f, Tick::ZERO).is_err()); let _ = probe_bytes("a.gif", bytes.into()); } + + #[test] + fn a_huge_canvas_is_refused_before_decoding() { + // A 65535×65535 logical screen (16 GiB of RGBA) holding one 1×1 frame. + let mut out = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut out, u16::MAX, u16::MAX, &[]).unwrap(); + for _ in 0..2 { + enc.write_frame(&gif::Frame::from_rgb(1, 1, &RED)).unwrap(); + } + } + let e = super::Anim::load(&out).err().unwrap(); + assert!(e.to_string().contains("too large"), "{e}"); + } } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index b259c8b7f..adc23cf31 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -14,19 +14,20 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool { } /// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page -/// `page` of a PDF, restricted to the footage's layer when it names one. +/// `page` of a PDF framed as `framing` says, restricted to the footage's layer when it names one. pub fn vector_doc( path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, + framing: effectcraft_pdf::Framing, ) -> Option> { if is_svg(path, bytes) { return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); } effectcraft_pdf::sniff(bytes)?; Some( - effectcraft_pdf::parse_page(bytes, page as usize) + effectcraft_pdf::parse_page_framed(bytes, page as usize, framing) .map(|d| match layer { Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, @@ -111,14 +112,14 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } if is_svg(path, bytes) || is_pdf_like(path, bytes) { - let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; - let (w, h) = doc.pixel_size(); + let doc = footage_doc(path, bytes, footage).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; + let ((w, h), to_image) = fit(&doc, footage, 1.0); // A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136): // past what Continuously Rasterize can draw, the footage reports an error instead. if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) { return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})"))); } - let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let img = effectcraft_svg::try_rasterize_with(&doc, w, h, to_image).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; // The rasteriser produces premultiplied pixels already. let _ = op; return Ok(Some(img)); @@ -129,12 +130,34 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - /// The largest side vector footage is drawn at, decoded or continuously rasterised. const MAX_VECTOR_SIDE: u32 = 16384; -/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). -pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option { - let doc = vector_doc(path, bytes, layer, page)?.ok()?; - let (w, h) = doc.pixel_size(); - let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE)); - Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) +/// The vector footage `footage` (of the file `path` with these `bytes`) rasterised at `scale` × +/// its size as [`effectcraft_render::vector_fit`] draws it (Continuously Rasterize). +/// +/// `None` when the file cannot be read as a vector document. `Some(Err(_))` when a render budget +/// refused part of the drawing (for example a layer scaled past the surface limit), so the caller +/// can report it instead of showing a partial or empty image as if it were complete. +pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option> { + let doc = footage_doc(path, bytes, footage)?.ok()?; + let ((sw, sh), to_image) = fit(&doc, footage, scale); + let drawn = effectcraft_svg::try_rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image); + Some(drawn.map_err(|e| MediaError::Decode(format!("{path}: {e}")))) +} + +/// The page framing of vector footage: as After Effects places it for footage from an After +/// Effects project ([`Footage::placed_page`]), as a PDF viewer shows it otherwise. +fn framing(footage: &Footage) -> effectcraft_pdf::Framing { + if footage.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer } +} + +/// [`vector_doc`] of `footage`'s page, layer and framing. +fn footage_doc(path: &str, bytes: &[u8], footage: &Footage) -> Option> { + vector_doc(path, bytes, footage.layer.as_ref(), footage.page, framing(footage)) +} + +/// The image size and document → image mapping of `doc` drawn for `footage` at `scale`. +fn fit(doc: &effectcraft_svg::Doc, footage: &Footage, scale: f64) -> ((u32, u32), effectcraft_svg::Affine) { + let (size, [sx, sy]) = effectcraft_render::vector_fit(footage, [doc.width, doc.height], doc.pixel_size(), scale); + (size, effectcraft_svg::Affine::scale_non_uniform(sx, sy)) } #[cfg(test)] @@ -154,4 +177,77 @@ mod tests { let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap(); assert_eq!((img.width, img.height), (40, 20)); } + + /// Continuously Rasterize reports a layer scaled past the render budget as an error instead of + /// returning a partial or empty image, and still draws the same layer at an ordinary scale. + #[test] + fn rasterizing_vector_footage_past_the_budget_is_reported() { + let svg = br#""#; + let footage = probe("scaled.svg", svg).unwrap().unwrap(); + let ok = rasterize_vector("scaled.svg", svg, &footage, 1.0).unwrap().unwrap(); + assert_eq!((ok.width, ok.height), (100, 100)); + let e = rasterize_vector("scaled.svg", svg, &footage, 1000.0).unwrap().err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("scaled.svg"), "{e}"); + } + + /// A 200 × 100 pt page: red left half, blue right half. + fn halves_pdf() -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 100 100 re f 0 0 1 rg 100 0 100 100 re f".to_vec())), + ], + 1, + ) + } + + /// An opaque red pixel. + fn is_red(p: [f32; 4]) -> bool { + p[3] > 0.99 && p[0] > 0.9 && p[2] < 0.1 + } + + /// An opaque blue pixel. + fn is_blue(p: [f32; 4]) -> bool { + p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1 + } + + /// #3099: an After Effects project keeps the size After Effects measured for a placed .ai or + /// PDF, which can differ from this reader's page size. A placed page fills that size (the + /// layer's anchor and masks are measured against it), decoded and continuously rasterised; + /// other footage is drawn at the page's own size as before, whatever size it says. + #[test] + fn placed_vector_footage_fills_the_footage_size() { + let pdf = halves_pdf(); + let mut footage = probe("art.ai", &pdf).unwrap().unwrap(); + assert_eq!((footage.width, footage.height), (200, 100)); + assert!(!footage.placed_page); + let op = AlphaOp::new(footage.alpha, footage.premul_color); + let own = decode("art.ai", &pdf, &footage, op).unwrap().unwrap(); + assert_eq!((own.width, own.height), (200, 100)); + assert!(is_red(own.get(50, 50)) && is_blue(own.get(150, 50))); + + (footage.width, footage.height) = (100, 80); + let unplaced = decode("art.ai", &pdf, &footage, op).unwrap().unwrap(); + assert!(unplaced == own, "not placed: drawn as before"); + assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).and_then(|r| r.ok()).map(|i| (i.width, i.height)), Some((100, 50))); + + footage.placed_page = true; + let img = decode("art.ai", &pdf, &footage, op).unwrap().unwrap(); + assert_eq!((img.width, img.height), (100, 80)); + for (x, y) in [(25, 3), (25, 76)] { + assert!(is_red(img.get(x, y)), "red at ({x}, {y}): {:?}", img.get(x, y)); + } + for (x, y) in [(75, 3), (75, 76)] { + assert!(is_blue(img.get(x, y)), "blue at ({x}, {y}): {:?}", img.get(x, y)); + } + + let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap().unwrap(); + assert_eq!((sharp.width, sharp.height), (200, 160)); + assert!(is_red(sharp.get(50, 155)) && is_blue(sharp.get(150, 155))); + // Placed at the page's own size: 1:1. + (footage.width, footage.height) = (200, 100); + assert!(decode("art.ai", &pdf, &footage, op).unwrap().unwrap() == own, "placed at its own size: 1:1"); + } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 31478f111..454c3705f 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ mod layered; pub use layered::vector_doc; mod pool; mod probe; +mod reads; mod stream; mod wav_markers; mod webp_anim; @@ -32,7 +33,7 @@ mod webp_anim; /// Video decoder counters (time in decoders, samples decoded, seeks, cache hits), for benchmarks. pub use filmcraft_codecs::{GopStats, gop_stats}; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; -pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model}; +pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model, probe_noting}; /// Errors from probing or decoding footage. #[derive(Debug, thiserror::Error)] @@ -85,7 +86,15 @@ pub fn is_importable(path: &std::path::Path) -> bool { /// A whole file's bytes, refusing anything that isn't a regular file: a project can name /// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134). pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> { + read_regular_file_stamped(path).map(|(bytes, _)| bytes) +} + +/// [`read_regular_file`] with the file's identity, from the metadata of the open file (no +/// second look at the path). +pub(crate) fn read_regular_file_stamped(path: &std::path::Path) -> std::io::Result<(Vec, effectcraft_render::reads::FileStamp)> { use std::io::Read; + #[cfg(test)] + count_open(path); let mut file = std::fs::File::open(path)?; let meta = file.metadata()?; if !meta.is_file() { @@ -95,7 +104,22 @@ pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> = std::sync::Mutex::new(Vec::new()); + +/// Count an open of a file (tests: which files a render touched on disk). +#[cfg(test)] +pub(crate) fn count_open(path: &std::path::Path) { + OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_path_buf()); +} + +/// How many times files under `dir` were opened so far (tests). +#[cfg(test)] +pub(crate) fn opens_under(dir: &std::path::Path) -> usize { + OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter().filter(|p| p.starts_with(dir)).count() } pub(crate) fn ext_of(path: &std::path::Path) -> String { diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9e10b4eb3..f5d109797 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,14 +6,17 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::{Image, Px}; +use effectcraft_render::disk_cache::Frame8; +use effectcraft_render::reads::{FileStamp, ReadFile, ReadFiles, ReadLog}; use effectcraft_render::{FloatCurve, FootageSource}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; use image::metadata::Orientation; -use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; +use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, frame_to_rgba8, turned}; use crate::downmix::Order; use crate::gif_anim::{self, Anim}; +use crate::reads; use crate::{MediaError, Result}; /// Default frame-cache budget: 1 GiB of decoded frames. @@ -40,19 +43,41 @@ fn colour_bars(w: u32, h: u32) -> Image { struct Key { path: Arc, frame: i64, - /// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB, - /// as stored, a 2.4 power curve): they change the decoded pixels. + /// The alpha interpretation (bits 0–1), how float values are encoded (bits 2–3: sRGB, as + /// stored, a 2.4 power curve) and a placed PDF / Illustrator page's framing (bit 4, + /// [`Footage::placed_page`]): they change the decoded pixels. interp: u8, 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. + /// resampled (see `Inner::still_at_size`); also at its own size for a placed PDF / + /// Illustrator page, which is drawn to fill it ([`Footage::placed_page`]); (0, 0) otherwise. nominal: (u32, u32), + /// Kept as 8-bit levels ([`FootageSource::frame8`]), not float. + q8: bool, +} + +/// A cached frame's pixels: float, or the 8-bit levels an 8 bpc render quantises it to. +#[derive(Clone)] +enum Pixels { + F32(Arc), + Q8(Arc), +} + +impl Pixels { + fn bytes(&self) -> usize { + match self { + Pixels::F32(img) => img.data.len() * std::mem::size_of::(), + Pixels::Q8(f) => f.rgba.len(), + } + } } struct Entry { - img: Arc, + img: Pixels, + /// The files the frame was decoded from (see [`crate::reads`]). + files: Arc, stamp: u64, bytes: usize, } @@ -106,18 +131,20 @@ struct Lru { } impl Lru { - fn get(&mut self, k: &Key) -> Option> { + /// The cached frame and its read files for `k`, marking the entry as most recently used. + fn get(&mut self, k: &Key) -> Option<(Pixels, Arc)> { let e = self.map.get_mut(k)?; self.order.remove(&e.stamp); self.clock += 1; e.stamp = self.clock; self.order.insert(e.stamp, k.clone()); - Some(e.img.clone()) + Some((e.img.clone(), e.files.clone())) } - fn insert(&mut self, k: Key, img: Arc, budget: usize) { - let bytes = img.data.len() * std::mem::size_of::() + 64; + /// Stores a frame with its read files under `k` and evicts the least recently used entries until the cache fits `budget` bytes. + fn insert(&mut self, k: Key, img: Pixels, files: Arc, budget: usize) { + let bytes = img.bytes() + 64; self.clock += 1; - if let Some(old) = self.map.insert(k.clone(), Entry { img, stamp: self.clock, bytes }) { + if let Some(old) = self.map.insert(k.clone(), Entry { img, files, stamp: self.clock, bytes }) { self.order.remove(&old.stamp); self.bytes -= old.bytes; } @@ -137,8 +164,10 @@ impl Lru { let Some((_, k)) = self.order.pop_first() else { break }; if let Some(e) = self.map.remove(&k) { self.bytes -= e.bytes; + // ponytail: float buffers only; add 8-bit spares if their allocation shows up in a profile if self.spare.len() < MAX_SPARE - && let Ok(img) = Arc::try_unwrap(e.img) + && let Pixels::F32(img) = e.img + && let Ok(img) = Arc::try_unwrap(img) { self.spare.push(img.data); } @@ -177,11 +206,14 @@ struct CacheState { inflight: HashSet, /// Last frame index requested per movie (sequential-playback detection). last: HashMap, i64>, + /// Movies whose frames can't be kept as 8-bit levels (more bits, or shown turned). + wide: HashSet>, } /// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait -/// for it (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +/// for it (`None`: failed to open; not retried until `forget`), with the file's identity as +/// opened. +type SourceSlot = Arc, ReadFile)>>; /// An opened movie/audio source, the turn its picture is shown with and the order its decoded /// audio channels arrive in. @@ -211,7 +243,7 @@ fn channel_order(src: &SharedSource, header: Option<&[u8]>, file: Option<&dyn fi } /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). -type GifSlot = Arc, String>>>; +type GifSlot = Arc, String>, ReadFile)>>; struct Inner { /// Opened movie/audio sources by path. @@ -220,8 +252,9 @@ struct Inner { opened: AtomicU64, /// In-memory files registered with `add_bytes` (web builds, tests). files: Mutex>>, - /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). - models: Mutex>>>, + /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`), with the + /// files read for them (the model's and the resources it names). + models: Mutex>, Arc)>>, /// Decoded animated GIFs and WebPs by path, loaded by the first caller while the others wait (an /// `Err` is not retried until `forget`). gifs: Mutex, GifSlot>>, @@ -397,6 +430,7 @@ impl MediaPool { c.lru.remove(&k); } c.last.remove(path); + c.wide.remove(path); drop(c); crate::exr_channels::forget(path); } @@ -412,6 +446,21 @@ impl MediaPool { Inner::frame_at(&self.inner, footage, t, true, None, FloatCurve::Srgb) } + /// [`FootageSource::frame8`]: `None` (the renderer then asks for the float frame) for + /// anything but an 8-bit movie frame shown as decoded, and on errors. + fn frame8_of(&self, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + if footage.missing || footage.kind != FootageKind::Video || gif_anim::is_animated(footage) { + return None; + } + if lock(&self.inner.cache).wide.contains(footage.path.as_str()) { + return None; + } + match Inner::frame_px(&self.inner, footage, t, true, None, float, true) { + Ok(Pixels::Q8(f)) => Some(f), + _ => None, + } + } + /// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors. fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { if footage.missing { @@ -439,11 +488,13 @@ impl MediaPool { } /// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and - /// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). + /// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). Notes + /// `path` as read with that identity (see [`crate::reads`]): its samples come from it. pub fn conformed_path(&self, path: &str, rate: u32) -> Option { use std::hash::{Hash, Hasher}; let folder = lock(&self.inner.conform).clone()?; let meta = std::fs::metadata(path).ok()?; + reads::note(ReadFile::new(path, FileStamp::of_metadata(&meta))); let mut h = std::collections::hash_map::DefaultHasher::new(); (CONFORM_VERSION, path, meta.len(), meta.modified().ok()).hash(&mut h); Some(folder.join(format!("{:016x}_{rate}.ecaf", h.finish()))) @@ -612,16 +663,20 @@ impl Inner { /// A 3D model file and its sibling resources (buffers, textures, MTL files), parsed once. fn model(&self, path: &str) -> Option> { - if let Some(m) = lock(&self.models).get(path) { - return m.clone(); + let found = lock(&self.models).get(path).cloned(); + if let Some((m, files)) = found { + reads::note_all(&files); + return m; } - 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_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()) + let (loaded, files) = reads::captured(|| { + 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_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()) + }) }); let m = match loaded { Ok(m) => Some(Arc::new(m)), @@ -630,14 +685,29 @@ impl Inner { None } }; - lock(&self.models).entry(path.to_string()).or_insert(m).clone() + let (m, files) = lock(&self.models).entry(path.to_string()).or_insert((m, Arc::new(files))).clone(); + reads::note_all(&files); + m } + /// The bytes of the file at `path`, noted as read (see [`crate::reads`]). fn read(&self, path: &str) -> Result> { - if let Some(b) = lock(&self.files).get(path) { - return Ok(b.clone()); + let (bytes, file) = self.read_file(path); + reads::note(file); + bytes + } + + /// The bytes of the file at `path` and its identity as read ([`FileStamp::Missing`] when it + /// can't be read), not noted. + fn read_file(&self, path: &str) -> (Result>, ReadFile) { + let in_memory = lock(&self.files).get(path).cloned(); + if let Some(b) = in_memory { + return (Ok(b), ReadFile::new(path, FileStamp::Virtual)); + } + match crate::read_regular_file_stamped(std::path::Path::new(path)) { + Ok((bytes, stamp)) => (Ok(bytes.into()), ReadFile::new(path, stamp)), + Err(e) => (Err(MediaError::Io(format!("{path}: {e}"))), ReadFile::new(path, FileStamp::Missing)), } - crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } /// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside @@ -645,35 +715,42 @@ impl Inner { /// files are decoded whole); callers asking for the same file meanwhile wait for that instead /// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads /// each decoded a long FLAC in full at once (#408). + /// Each call notes the file as read (see [`crate::reads`]), with its identity as opened. fn opened(&self, path: &Arc) -> Option { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); - slot.get_or_init(|| { - self.opened.fetch_add(1, Ordering::Relaxed); - // Files on disk are read in place (the index, then samples on demand) rather than - // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. - let in_memory = lock(&self.files).contains_key(&**path); - let file = std::path::Path::new(&**path); - let in_place = if in_memory { None } else { crate::stream::open_source(file) }; - let in_place = in_place.map(|src| { - let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); - src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())) + let (opened, file) = slot.get_or_init(|| self.open(path)); + reads::note(file.clone()); + opened.clone() + } + + /// Open the movie/audio file at `path` (see [`Inner::opened`]), with its identity as opened. + fn open(&self, path: &Arc) -> (Option, ReadFile) { + self.opened.fetch_add(1, Ordering::Relaxed); + // Files on disk are read in place (the index, then samples on demand) rather than + // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. + let in_memory = lock(&self.files).contains_key(&**path); + let file = std::path::Path::new(&**path); + let in_place = if in_memory { None } else { crate::stream::open_source(file) }; + let in_place = in_place.map(|(src, stamp)| { + let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); + (src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())), ReadFile::new(path.clone(), stamp)) + }); + let (opened, read) = in_place.unwrap_or_else(|| { + let (bytes, read) = self.read_file(path); + let opened = bytes.and_then(|bytes| { + let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; + Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) }); - let opened = in_place.unwrap_or_else(|| { - self.read(path).and_then(|bytes| { - let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; - Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) - }) - }); - match opened { - Ok(s) => Some(s), - Err(e) => { - self.note_problem(path, "", &e); - None - } + (opened, read) + }); + match opened { + Ok(s) => (Some(s), read), + Err(e) => { + self.note_problem(path, "", &e); + (None, read) } - }) - .clone() + } } /// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known) @@ -694,13 +771,15 @@ impl Inner { /// map's lock; callers asking for the same file meanwhile wait for that. fn gif(&self, path: &Arc) -> Result> { let slot = lock(&self.gifs).entry(path.clone()).or_default().clone(); - let loaded = slot.get_or_init(|| { - let loaded = self.read(path).and_then(|bytes| Anim::load(&bytes)); - loaded.map(Arc::new).map_err(|e| { + let (loaded, file) = slot.get_or_init(|| { + let (bytes, file) = self.read_file(path); + let loaded = bytes.and_then(|bytes| Anim::load(&bytes)).map(Arc::new).map_err(|e| { log::warn!("media: cannot decode {path}: {e}"); e.to_string() - }) + }); + (loaded, file) }); + reads::note(file.clone()); loaded.clone().map_err(|e| MediaError::Decode(format!("{path}: {e}"))) } @@ -718,6 +797,8 @@ impl Inner { ((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64 } + /// Where the frame of `footage` at `t` comes from (file, frame or media time) and its cache + /// key, at the footage's own size. fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc { let alpha = match footage.alpha { AlphaMode::Straight => 0, @@ -730,9 +811,10 @@ impl Inner { FloatCurve::Srgb => 0, FloatCurve::Linear => 1, FloatCurve::Gamma24 => 2, - } << 2); + } << 2) + | (u8::from(footage.placed_page) << 4); 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), nominal: (0, 0) }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0), q8: false }; 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); @@ -768,10 +850,22 @@ impl Inner { /// 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> { + match Self::frame_px(self, footage, t, playback, size, float, false)? { + Pixels::F32(img) => Ok(img), + // (a float request's key never holds 8-bit levels) + Pixels::Q8(_) => Err(MediaError::Decode(format!("{}: 8-bit frame for a float request", footage.path))), + } + } + + /// [`Self::frame_at`], kept as 8-bit levels when `q8` is set and the frame has 8 bits (a + /// float frame under the float key otherwise, and the movie is remembered as `wide`). + #[allow(clippy::too_many_arguments)] + fn frame_px(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve, q8: bool) -> Result { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } let mut loc = Self::locate(footage, t, float); + loc.key.q8 = q8; let movie = loc.media_t.is_some(); // 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)) { @@ -780,22 +874,23 @@ impl Inner { loc.key.nominal = (footage.width, footage.height); } } + // A placed PDF / Illustrator page is drawn to fill the footage's size (`layered::decode`, + // #3099): two items of one file stored at different sizes have different pixels. + if footage.placed_page && !movie { + loc.key.nominal = (footage.width, footage.height); + } let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead if playback && movie { - let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); - if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { - let next_t = t + footage.frame_rate.frame_duration(); - let mut next = Self::locate(footage, next_t, float); - next.key.size = loc.key.size; - if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { - self.prefetch(footage, next_t, size, float); - } - } + self.prefetch_next(&mut c, &loc, footage, t, size, float); } + // (Each exit notes the files the frame came from, see `crate::reads`; a cached frame + // notes those it was decoded from, without touching them.) loop { - if let Some(img) = c.lru.get(&loc.key) { + if let Some((img, files)) = c.lru.get(&loc.key) { self.hits.fetch_add(1, Ordering::Relaxed); + drop(c); + reads::note_all(&files); return Ok(img); } if !c.inflight.contains(&loc.key) { @@ -808,7 +903,9 @@ impl Inner { if rayon::current_thread_index().is_some() { self.misses.fetch_add(1, Ordering::Relaxed); drop(c); - return Ok(Arc::new(self.decode(&loc, footage)?)); + let (img, files) = reads::captured(|| self.decode(&loc, footage)); + reads::note_all(&files); + return img; } c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner); } @@ -816,23 +913,45 @@ impl Inner { c.inflight.insert(loc.key.clone()); drop(c); let _guard = Inflight { inner: self, key: loc.key.clone() }; - let img = Arc::new(self.decode(&loc, footage)?); - lock(&self.cache).lru.insert(loc.key, img.clone(), self.budget()); + let (img, files) = reads::captured(|| self.decode(&loc, footage)); + reads::note_all(&files); + let img = img?; + let mut c = lock(&self.cache); + if q8 && let Pixels::F32(_) = img { + // not 8-bit: kept as the float frame it is, which the renderer asks for next + c.wide.insert(loc.key.path.clone()); + loc.key.q8 = false; + } + c.lru.insert(loc.key, img.clone(), Arc::new(files), self.budget()); Ok(img) } + /// Sequential playback of a movie (`loc`, at `t`): read the frame after it ahead. + fn prefetch_next(self: &Arc, c: &mut CacheState, loc: &Loc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { + let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); + if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { + let next_t = t + footage.frame_rate.frame_duration(); + let mut next = Self::locate(footage, next_t, float); + next.key.size = loc.key.size; + next.key.q8 = loc.key.q8; + if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { + self.prefetch(footage, next_t, size, float, loc.key.q8); + } + } + } + #[cfg(not(target_arch = "wasm32"))] - fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { + fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve, q8: bool) { let (me, f) = (self.clone(), footage.clone()); self.prefetched.fetch_add(1, Ordering::Relaxed); // A plain thread, not a rayon job: the decode below parallelises with rayon itself. std::thread::spawn(move || { - let _ = me.frame_at(&f, t, false, size, float); + let _ = me.frame_px(&f, t, false, size, float, q8); }); } #[cfg(target_arch = "wasm32")] - fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} + fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve, _q8: bool) {} /// 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 @@ -849,20 +968,24 @@ impl Inner { effectcraft_raster::resample(&img, w, h) } - fn decode(&self, loc: &Loc, footage: &Footage) -> Result { + 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)), + None => self.decode_still(loc, footage).map(|img| Pixels::F32(Arc::new(Self::still_at_size(img, loc, footage)))), Some(_) => self.decode_media(loc, footage), } } + /// Decodes the still image at `loc` and applies the footage alpha and float handling. + /// A placeholder path decodes to colour bars. 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)?; + let (bytes, file) = self.read_file(path); + reads::note(file.clone()); + let bytes = bytes?; if let Some(img) = crate::dpx::decode(path, &bytes)? { return Ok(dynamic_to_image(&img, op)); } @@ -880,7 +1003,7 @@ impl Inner { let shared = lock(&self.aux_footage).contains(&footage.path); if shared && path.to_ascii_lowercase().ends_with(".exr") - && let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), op) + && let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), &file, op) { return Ok(img); } @@ -888,21 +1011,24 @@ impl Inner { crate::exr_channels::count_decode(false); match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) { Ok(img) => Ok(dynamic_to_image(&img, op)), + // Over the size limit: the layered fallback would allocate its planes anyway. + Err(e @ image::ImageError::Limits(_)) => Err(MediaError::Decode(format!("{path}: {e}"))), // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::layered_picture(path, bytes, op, shared) + Err(e) => crate::exr_channels::layered_picture(path, bytes, &file, op, shared) .map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))), } } - fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { + fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; + let f32 = |img| Pixels::F32(Arc::new(img)); match loc.media_t { - None => self.decode_still(loc, footage), + None => self.decode_still(loc, footage).map(f32), 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")))?; - Ok(dynamic_to_image(frame, op)) + Ok(f32(dynamic_to_image(frame, op))) } Some(mt) => { let Opened { src, turn, .. } = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; @@ -920,10 +1046,16 @@ impl Inner { && (vf.width, vf.height) == side((footage.width, footage.height)) && let Some(img) = frame_to_image_scaled(&vf, w, h) { - return Ok(turned(img, turn)); + return Ok(f32(turned(img, turn))); + } + if loc.key.q8 + && turn == Orientation::NoTransforms + && let Some(f) = frame_to_rgba8(&vf, op) + { + return Ok(Pixels::Q8(Arc::new(f))); } let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize); - Ok(turned(frame_to_image_in(&vf, op, buf), turn)) + Ok(f32(turned(frame_to_image_in(&vf, op, buf), turn))) } } } @@ -957,6 +1089,13 @@ impl FootageSource for MediaPool { fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { filmcraft_media::cancel::with_cancel(cancel, f) } + /// Every read of a file notes it (see [`crate::reads`]): a frame's image file or sequence + /// frame, a movie, a model and the files it names, an EXR's channels, a movie's audio. A + /// frame (or other entry) served from memory notes the files it was made of, as they were + /// when read, without reading them again. A missing sequence frame (colour bars) reads none. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + reads::recording(reads, f) + } fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { self.frame_sized(item, footage, t, None, FloatCurve::Srgb) } @@ -966,9 +1105,12 @@ impl FootageSource for MediaPool { fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.frame_sized(item, footage, t, size, float) } + fn frame8(&self, _item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.frame8_of(footage, t, float) + } fn problem(&self, footage: &Footage) -> Option { - if footage.missing || footage.kind != FootageKind::Video { + if footage.missing || (footage.kind != FootageKind::Video && !effectcraft_render::is_vector_footage(footage)) { return None; } lock(&self.inner.problems).get(footage.path.as_str()).cloned() @@ -979,7 +1121,19 @@ impl FootageSource for MediaPool { return None; } let bytes = self.inner.read(&footage.path).ok()?; - crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new) + let path: Arc = footage.path.as_str().into(); + match crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale)? { + Ok(img) => { + lock(&self.inner.problems).remove(&path); + Some(Arc::new(img)) + } + // Refused by a render budget (a layer scaled too far): no frame, with the reason + // reported through `problem` rather than showing a partial image as complete. + Err(e) => { + self.inner.note_problem(&path, "", &e); + None + } + } } fn set_aux_footage(&self, paths: &[String]) { @@ -1203,23 +1357,35 @@ mod tests { #[test] 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), nominal: (0, 0) }; + let img = || Pixels::F32(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), nominal: (0, 0), q8: false }; let budget = 3 * (4096 + 64); for i in 0..10 { - c.insert(k(i), img(), budget); + c.insert(k(i), img(), Arc::default(), budget); } assert_eq!(c.map.len(), 3); assert!(c.get(&k(9)).is_some() && c.get(&k(6)).is_none()); // touching 7 makes 8 the oldest c.get(&k(7)); - c.insert(k(10), img(), budget); + c.insert(k(10), img(), Arc::default(), budget); assert!(c.get(&k(7)).is_some() && c.get(&k(8)).is_none()); assert_eq!(c.spare.len(), MAX_SPARE); assert_eq!(c.take_spare(256).len(), 256); assert!(c.take_spare(17).is_empty()); } + /// A frame kept as 8-bit levels is its own entry beside the float one, counted at 4 bytes + /// a pixel. + #[test] + fn lru_keeps_8_bit_frames_apart_at_a_quarter_of_the_bytes() { + let mut c = Lru::default(); + let k = |q8| Key { path: "a".into(), frame: 0, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0), q8 }; + c.insert(k(false), Pixels::F32(Arc::new(Image::new(16, 16))), Arc::default(), usize::MAX); + c.insert(k(true), Pixels::Q8(Arc::new(Frame8 { width: 16, height: 16, rgba: vec![0; 16 * 16 * 4] })), Arc::default(), usize::MAX); + assert!(matches!(c.get(&k(false)), Some((Pixels::F32(_), _))) && matches!(c.get(&k(true)), Some((Pixels::Q8(_), _)))); + assert_eq!(c.bytes, (16 * 16 * 16 + 64) + (16 * 16 * 4 + 64)); + } + /// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import /// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame. #[test] diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 1fc945aa1..194eebc47 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -9,6 +9,15 @@ use image::metadata::Orientation; use crate::{MediaError, Result, ext_of, rate_from_fc}; +/// Most pixels a still may decode to: 4 GiB as the renderer's float RGBA (2^28, 16384 × 16384; +/// 150-megapixel medium-format frames, stitched panoramas and large scans decode). A browser tab's +/// wasm32 heap can't hold one allocation that large (and the decoded input beside it): 512 MiB +/// there, and on 32-bit targets, where one allocation may not pass `isize::MAX` (2 GiB). +#[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))] +pub(crate) const MAX_STILL_PIXELS: u64 = 1 << 28; +#[cfg(any(target_arch = "wasm32", target_pointer_width = "32"))] +pub(crate) const MAX_STILL_PIXELS: u64 = 1 << 25; + /// Frame rate given to image sequences on import (After Effects' default preference). pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30; @@ -43,11 +52,25 @@ fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> { } } +thread_local! { + /// What the last model probed on this thread left out (see [`probe_noting`]). + static MODEL_WARNING: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// [`probe`], and what the import left out of the file (a 3D model's textures over the limits), +/// for the user. +pub fn probe_noting(path: impl AsRef) -> Result<(Footage, Option)> { + MODEL_WARNING.with(|w| w.borrow_mut().take()); + let f = probe(path)?; + Ok((f, MODEL_WARNING.with(|w| w.borrow_mut().take()))) +} + /// 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 { let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; let s = m.summary(); + MODEL_WARNING.with(|w| *w.borrow_mut() = (!m.warnings.is_empty()).then(|| format!("{path}: {}", m.warnings.join("; ")))); let dur = s.animations.iter().map(|a| a.1).fold(0.0f64, f64::max); let codec = match effectcraft_model::format_of(path) { Some("obj") => "OBJ", @@ -134,12 +157,12 @@ fn trim_to_edits(f: &mut Footage, shown: Option) { /// How long the video track's edit list shows its media (`elst` segments, movie timescale): /// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration). fn video_shown(bytes: &[u8]) -> Option { - let file = filmcraft_isobmff::open(bytes).ok()?; - let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + let file = craft_media::isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == craft_media::isobmff::TrackKind::Video && !t.samples.is_empty())?; edits_shown(&t.edits, file.timescale) } -fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option { +fn edits_shown(edits: &[craft_media::isobmff::Edit], timescale: u32) -> Option { if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) { return None; } @@ -291,6 +314,12 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im None => image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?, }; let mut dec = reader.into_decoder()?; + // `from_decoder` skips the reader's allocation limit, and some decoders (BMP) allocate + // whatever size their header claims: refuse a still the renderer could not hold as floats. + let (w, h) = dec.dimensions(); + if u64::from(w) * u64::from(h) > MAX_STILL_PIXELS { + return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::DimensionError))); + } let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms); let mut img = image::DynamicImage::from_decoder(dec)?; img.apply_orientation(orientation); @@ -303,8 +332,8 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im /// containers have none. FilmCraft turns the frames of a pure 0/90/180/270° header itself, so this /// is only the quarter turn nearest to a header it does not turn. pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { - let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; - let Some(track) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video) else { + let Ok(file) = craft_media::isobmff::open(bytes) else { return Orientation::NoTransforms }; + let Some(track) = file.tracks.iter().find(|t| t.kind == craft_media::isobmff::TrackKind::Video) else { return Orientation::NoTransforms; }; // A pure rotation is already applied by FilmCraft's decoder (frames and size come out @@ -329,8 +358,8 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { /// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median /// reads right. fn video_rate(bytes: &[u8]) -> Option { - let file = filmcraft_isobmff::open(bytes).ok()?; - let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + let file = craft_media::isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == craft_media::isobmff::TrackKind::Video && !t.samples.is_empty())?; averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration)) } @@ -364,11 +393,28 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option mod tests { use super::*; + #[test] + fn a_still_larger_than_the_renderer_holds_is_refused_before_decoding() { + // A 24-bit BMP header claiming 20000×20000 pixels (within the BMP decoder's own limit) + // over no pixel data. + let mut bmp = b"BM".to_vec(); + bmp.extend(54u32.to_le_bytes()); + bmp.extend([0u8; 4]); + bmp.extend(54u32.to_le_bytes()); + bmp.extend(40u32.to_le_bytes()); + bmp.extend(20_000i32.to_le_bytes()); + bmp.extend(20_000i32.to_le_bytes()); + bmp.extend(1u16.to_le_bytes()); + bmp.extend(24u16.to_le_bytes()); + bmp.extend([0u8; 24]); + assert!(matches!(load_oriented(&bmp, Some(image::ImageFormat::Bmp)), Err(image::ImageError::Limits(_)))); + } + /// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a /// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it. #[test] fn the_edit_list_trims_a_movie_to_the_frames_it_shows() { - let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 }; + let edit = |segment_duration, media_time| craft_media::isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 }; let shown = edits_shown(&[edit(3000, 1024)], 1000); assert_eq!(shown, Some(Tick::from_units(3, 1))); let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() }; @@ -455,4 +501,21 @@ mod tests { assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None); assert_eq!(video_rate(b"not a movie"), None); } + + /// A 150-megapixel still (a medium-format frame) is not refused for its size: only its (absent) + /// pixel data fails, not the dimension check. A still past the limit is. + #[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))] + #[test] + fn stills_up_to_the_limit_pass_the_size_check() { + let bmp = |w: u32, h: u32| { + let mut b = vec![b'B', b'M', 0, 0, 0, 0, 0, 0, 0, 0, 54, 0, 0, 0, 40, 0, 0, 0]; + b.extend_from_slice(&w.to_le_bytes()); + b.extend_from_slice(&h.to_le_bytes()); + b.extend_from_slice(&[1, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + b + }; + let limit = |e: image::ImageError| matches!(e, image::ImageError::Limits(_)); + assert!(!load_oriented(&bmp(14_000, 10_800), None).err().is_some_and(limit), "150 MP passes the check"); + assert!(load_oriented(&bmp(20_000, 20_000), None).err().is_some_and(limit), "400 MP is refused"); + } } diff --git a/crates/media/src/reads.rs b/crates/media/src/reads.rs new file mode 100644 index 000000000..43ea209b8 --- /dev/null +++ b/crates/media/src/reads.rs @@ -0,0 +1,120 @@ +//! Which files the [`MediaPool`](crate::MediaPool) reads for a render +//! ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)). +//! +//! Every read of a file notes it, with the identity it had when read, in the innermost scope open +//! on this thread. The pool's caches (decoded frames, opened movies, GIFs, models, EXR channels) +//! keep what their entries were made of and note it again on each hit, so a frame served from +//! memory reports its file without touching the disk. +//! +//! The scopes are per thread: a read noted while a render's scope is open on the thread belongs +//! to that render. A thread waiting inside a decode can run another job meanwhile (rayon); that +//! job's reads go to its own scope when it opens one, and otherwise to the scope below it, which +//! then lists a file more than it read (never fewer). + +use std::cell::RefCell; + +use effectcraft_render::reads::{ReadFile, ReadFiles, ReadLog}; + +/// Scopes nested deeper than this are not opened (a thread that keeps taking jobs while it waits +/// cannot pile them up without bound): their reads go to the scope below, and they come back +/// incomplete. +const MAX_DEPTH: usize = 64; + +thread_local! { + /// The open scopes on this thread, innermost last, each with the files noted in it. + static SCOPES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Note `file` in the innermost open scope on this thread (nothing when none is open). +pub(crate) fn note(file: ReadFile) { + let _ = SCOPES.try_with(|s| { + if let Ok(mut s) = s.try_borrow_mut() + && let Some(top) = s.last_mut() + { + top.add(file); + } + }); +} + +/// Note every file of `files` in the innermost open scope on this thread. +pub(crate) fn note_all(files: &ReadFiles) { + if files.is_empty() && files.is_complete() { + return; + } + let _ = SCOPES.try_with(|s| { + if let Ok(mut s) = s.try_borrow_mut() + && let Some(top) = s.last_mut() + { + top.extend(files); + } + }); +} + +/// Closes the scope it opened, also when what runs inside panics: its files go to `out`. +struct Scope<'a> { + out: Out<'a>, + opened: bool, +} + +enum Out<'a> { + /// A render's log ([`recording`]). + Log(&'a ReadLog), + /// The caller's ([`captured`]). + Files(&'a mut ReadFiles), +} + +impl Scope<'_> { + /// Starts collecting the files read on this thread into `out`. + /// At the nesting limit the scope stays closed and reports an incomplete list. + fn open(out: Out<'_>) -> Scope<'_> { + let opened = SCOPES + .try_with(|s| match s.try_borrow_mut() { + Ok(mut s) if s.len() < MAX_DEPTH => { + s.push(ReadFiles::default()); + true + } + _ => false, + }) + .unwrap_or(false); + Scope { out, opened } + } +} + +impl Drop for Scope<'_> { + /// Ends the scope and hands the collected files to its output, marked incomplete when the scope could not be opened. + fn drop(&mut self) { + let files = if self.opened { SCOPES.try_with(|s| s.try_borrow_mut().ok().and_then(|mut s| s.pop())).ok().flatten() } else { None }; + // A scope that could not open saw nothing: what it made can't name its files. + let files = files.unwrap_or_else(|| { + let mut f = ReadFiles::default(); + f.mark_incomplete(); + f + }); + match &mut self.out { + Out::Log(log) => log.note_all(&files), + Out::Files(out) => out.extend(&files), + } + } +} + +/// Run `f` with the files it reads on this thread noted in `log` +/// ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)). +pub(crate) fn recording(log: &ReadLog, f: &mut dyn FnMut()) { + let _scope = Scope::open(Out::Log(log)); + f(); +} + +/// Run `f` and return what it returns with the files it read on this thread (to keep with a +/// cache entry made of them). They are not noted in the enclosing scope: the caller notes them, +/// as it does on each later hit of the entry. +pub(crate) fn captured(f: impl FnOnce() -> T) -> (T, ReadFiles) { + let mut files = ReadFiles::default(); + let out = { + let _scope = Scope::open(Out::Files(&mut files)); + f() + }; + (out, files) +} + +#[cfg(test)] +mod tests; diff --git a/crates/media/src/reads/tests.rs b/crates/media/src/reads/tests.rs new file mode 100644 index 000000000..5dae8415a --- /dev/null +++ b/crates/media/src/reads/tests.rs @@ -0,0 +1,390 @@ +//! The files a render reads, as the media pool reports them (#3184): exactly the files the frame +//! was made of, also when its pixels come from memory. + +use std::path::{Path, PathBuf}; + +use effectcraft_color::Label; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Solid, Value}; +use effectcraft_render::reads::{FileStamp, ReadFiles, ReadLog}; +use effectcraft_render::{LayerCache, RenderOpts, Renderer}; +use effectcraft_time::FrameRate; + +use crate::MediaPool; + +const RATE: FrameRate = FrameRate::FPS_30; +const SIZE: (u32, u32) = (16, 8); + +/// A fresh folder for test `name` (on the build drive, not the system temp folder). +fn folder(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../target/test-reads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A PNG at `path`, a flat grey of `level`. +fn png(path: &Path, level: u8) -> String { + image::RgbaImage::from_pixel(SIZE.0, SIZE.1, image::Rgba([level, level, level, 255])).save(path).unwrap(); + path.to_string_lossy().to_string() +} + +/// An image sequence of `n` PNG frames in `dir`. +fn sequence(dir: &Path, n: usize) -> Footage { + let files: Vec = (0..n).map(|i| png(&dir.join(format!("seq_{i:04}.png")), 20 + i as u8 * 10)).collect(); + let mut f = Footage { kind: FootageKind::Sequence, sequence: files, frame_rate: RATE, ..crate::probe(dir.join("seq_0000.png")).unwrap() }; + f.sync_sequence_duration(); + f +} + +/// A still footage item of `path`. +fn still(path: &Path, level: u8) -> Footage { + png(path, level); + crate::probe(path).unwrap() +} + +fn comp() -> Comp { + Comp::new(SIZE.0, SIZE.1, RATE, RATE.tick_of(10)) +} + +fn footage_layer(p: &mut Project, comp: &Comp, name: &str, f: Footage) -> Layer { + let item = p.add_item(name, Label::Aqua, None, ItemKind::Footage(f)); + build::layer(p, comp, name, LayerSource::Footage { item }, SIZE, Some(comp.duration)) +} + +fn add_comp(p: &mut Project, name: &str, mut comp: Comp, layers: Vec) -> ItemId { + comp.layers = layers.into(); + p.add_item(name, Label::Sandstone, None, ItemKind::Comp(comp.into())) +} + +/// Render frame `frame` of `comp` with `pool` and `cache`: the picture's top-left pixel and the +/// files the render reports. +fn render(p: &Project, pool: &MediaPool, cache: &LayerCache, comp: ItemId, frame: i64) -> ([f32; 4], ReadFiles) { + let log = ReadLog::new(); + let mut r = Renderer::new(p, pool, RenderOpts::default()); + r.cache = Some(cache); + r.reads = Some(&log); + let img = r.comp_frame(comp, RATE.tick_of(frame)); + (img.get(0, 0), log.into_files()) +} + +fn paths(files: &ReadFiles) -> Vec { + files.files().iter().map(|f| f.path.to_string()).collect() +} + +/// Every listed file has the identity it has on disk now. +fn assert_stamps_match_disk(files: &ReadFiles) { + for f in files.files() { + assert_eq!(Some(f.stamp), FileStamp::native(&f.path), "{}", f.path); + } +} + +/// (a) One frame of a comp with an image-sequence layer reads that frame's file and nothing else +/// of the sequence; a large sequence in the project that no layer uses adds nothing, not even a +/// look at its files. +#[test] +fn a_sequence_frame_reports_only_its_own_file() { + let dir = folder("sequence"); + let unused_dir = dir.join("unused"); + let mut p = Project::default(); + let c = comp(); + let seq = sequence(&dir, 5); + let (frame_2, frame_4) = (seq.sequence[2].clone(), seq.sequence[4].clone()); + let layer = footage_layer(&mut p, &c, "Seq", seq); + // 5000 frames nobody shows (they don't even exist). + let unused = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| unused_dir.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: SIZE.0, + height: SIZE.1, + has_video: true, + ..Default::default() + }; + p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(unused)); + let cid = add_comp(&mut p, "C", c, vec![layer]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let opened = crate::opens_under(&dir); + let (px, files) = render(&p, &pool, &cache, cid, 2); + assert!((px[0] - 40.0 / 255.0).abs() < 0.01, "frame 2 shows: {px:?}"); + assert_eq!(paths(&files), [frame_2]); + assert!(files.is_complete()); + assert_stamps_match_disk(&files); + assert_eq!(crate::opens_under(&dir) - opened, 1, "one file opened: the frame's"); + assert_eq!(crate::opens_under(&unused_dir), 0, "the unused sequence was not looked at"); + // Another frame reads its own file, also through the viewer's wrapper of the pool. + let viewer = effectcraft_render::CancellableFootage { inner: &pool, cancel: Default::default() }; + let log = ReadLog::new(); + let mut r = Renderer::new(&p, &viewer, RenderOpts::default()); + r.cache = Some(&cache); + r.reads = Some(&log); + r.comp_frame(cid, RATE.tick_of(4)); + let files = log.into_files(); + assert_eq!(paths(&files), [frame_4]); + assert!(files.is_complete()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// (b) A precomp reports the files of what it draws, and an effect that reads another layer +/// reports that layer's files (the layer itself is hidden: its pixels come only through the +/// effect). +#[test] +fn precomps_and_layers_read_by_effects_report_their_files() { + let dir = folder("nested"); + let mut p = Project::default(); + let c = comp(); + let seq = sequence(&dir, 4); + let nested_frame = seq.sequence[3].clone(); + let inner_layer = footage_layer(&mut p, &c, "Seq", seq); + let inner = add_comp(&mut p, "Inner", c.clone(), vec![inner_layer]); + let pre = build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner }, SIZE, Some(c.duration)); + let matte_path = dir.join("matte.png"); + let mut matte = footage_layer(&mut p, &c, "Matte", still(&matte_path, 255)); + matte.switches.video = false; + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: SIZE.0, height: SIZE.1, pixel_aspect: 1.0 })); + let mut solid = build::layer(&mut p, &c, "S", LayerSource::Solid { item: sid }, SIZE, Some(c.duration)); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut next = p.next_id; + let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [SIZE.0 as f64, SIZE.1 as f64]); + p.next_id = next; + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(matte.id.0)); + solid.props.sub_mut("effects").unwrap().children.push(fx.into()); + let cid = add_comp(&mut p, "Outer", c, vec![solid, matte, pre]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let (_, files) = render(&p, &pool, &cache, cid, 3); + let mut want = vec![matte_path.to_string_lossy().to_string(), nested_frame]; + want.sort(); + assert_eq!(paths(&files), want); + assert!(files.is_complete()); + assert_stamps_match_disk(&files); + let _ = std::fs::remove_dir_all(&dir); +} + +/// (c) A frame rendered again comes from memory (the sequence frame from the media pool, the +/// still layer from the layer cache) and reports the same files, with the identities they had +/// when read, without opening anything: here the files are even gone by then. +#[test] +fn a_frame_served_from_memory_reports_its_files_without_touching_them() { + let dir = folder("memory"); + let mut p = Project::default(); + let c = comp(); + // The sequence at half opacity over the still: both show. + let mut seq = footage_layer(&mut p, &c, "Seq", sequence(&dir, 3)); + seq.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + let plate = footage_layer(&mut p, &c, "Plate", still(&dir.join("plate.png"), 128)); + let cid = add_comp(&mut p, "C", c, vec![seq, plate]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let (px, first) = render(&p, &pool, &cache, cid, 1); + assert_eq!(first.len(), 2, "{:?}", paths(&first)); + assert_stamps_match_disk(&first); + std::fs::remove_dir_all(&dir).unwrap(); + let opened = crate::opens_under(&dir); + let hits = cache.stats().hits; + let (again_px, again) = render(&p, &pool, &cache, cid, 1); + assert_eq!(again_px, px, "the same picture"); + assert_eq!(again, first, "the same files, as they were when read"); + assert!(cache.stats().hits > hits, "the still layer came from the layer cache"); + // The media pool alone (a new layer cache) reports them too. + let (_, from_pool) = render(&p, &pool, &LayerCache::default(), cid, 1); + assert_eq!(from_pool, first); + assert_eq!(crate::opens_under(&dir), opened, "nothing was opened on disk"); +} + +// ---------------------------------------------------------------- layer entries on disk (#3184) + +use std::sync::Arc; + +use effectcraft_render::disk_cache::{DiskCache, Kind}; + +/// A "session": a new media pool and layer cache over the disk cache in `cache` (opened anew, +/// as after a restart), writing every layer buffer however quick to render. +fn session(cache: &Path) -> (MediaPool, LayerCache, Arc) { + let dc = DiskCache::open(cache, 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + let lc = LayerCache::default(); + lc.set_disk(Some(dc.clone())); + (MediaPool::new(), lc, dc) +} + +/// Give `path` a later modification time, so a rewrite within the clock's resolution counts. +fn touch_later(path: &Path) { + let t = std::fs::metadata(path).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(path).unwrap().set_modified(t).unwrap(); +} + +/// Two still plates (the upper one at half opacity, so both show), and a 5000-frame sequence +/// in the project that no layer uses (its files don't even exist). +fn plates(dir: &Path) -> (Project, ItemId, PathBuf) { + let mut p = Project::default(); + let c = comp(); + let a = dir.join("a.png"); + let mut top = footage_layer(&mut p, &c, "A", still(&a, 60)); + top.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + let bottom = footage_layer(&mut p, &c, "B", still(&dir.join("b.png"), 200)); + let unused = dir.join("unused"); + let seq = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| unused.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: SIZE.0, + height: SIZE.1, + has_video: true, + ..Default::default() + }; + p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(seq)); + let cid = add_comp(&mut p, "C", c, vec![top, bottom]); + (p, cid, a) +} + +/// Correct and no scan: after a restart, only the layer buffer whose source file changed is +/// rendered again (only that file is opened); the other comes from disk with its file list, so +/// the frame's list is complete again (its frame entry can be stored). The disk cache checks +/// exactly the files its entries list, never the unused sequence. The re-rendered buffer +/// replaces its entry: the next restart opens nothing. +#[test] +fn a_changed_file_renders_only_the_layer_buffers_that_read_it_again() { + let dir = folder("layer-disk"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let (p, cid, a) = plates(&src); + let (pool, lc, dc) = session(&cache); + let (px, files) = render(&p, &pool, &lc, cid, 0); + assert!(files.is_complete() && files.len() == 2, "{:?}", paths(&files)); + dc.flush(); + let entries = dc.keys(Kind::Layer).len(); + assert!(entries >= 2, "both plates' buffers are on disk: {entries}"); + drop((pool, lc, dc)); + + // Restart without a change: everything from disk, nothing opened, a complete list. + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (again, files_again) = render(&p, &pool, &lc, cid, 0); + assert_eq!(again, px); + assert_eq!(files_again, files, "the files of the entries, as they were read"); + assert!(files_again.is_complete(), "a frame made of buffers from disk can be stored"); + assert_eq!(crate::opens_under(&src), opened, "nothing opened"); + let checks = dc.file_checks(); + assert!(checks >= 2 && checks as usize <= entries, "one check per entry used: {checks} of {entries}"); + drop((pool, lc, dc)); + + // a.png gets new pixels: only the top plate renders again. + png(&a, 120); + touch_later(&a); + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (changed, files_changed) = render(&p, &pool, &lc, cid, 0); + assert_ne!(changed, px, "the new pixels show"); + assert_eq!(crate::opens_under(&src) - opened, 1, "only the changed file is read"); + assert!(files_changed.is_complete() && files_changed.len() == 2); + assert_stamps_match_disk(&files_changed); + assert!(dc.file_checks() as usize <= entries, "only listed files are checked"); + assert_eq!(crate::opens_under(&src.join("unused")), 0); + dc.flush(); + drop((pool, lc, dc)); + + // Its entry was replaced: the next restart opens nothing and shows the new pixels. + let (pool, lc, _dc) = session(&cache); + let opened = crate::opens_under(&src); + let (last, _) = render(&p, &pool, &lc, cid, 0); + assert_eq!(last, changed); + assert_eq!(crate::opens_under(&src), opened, "both from disk"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Two projects whose footage items have the same ids but name different files never share a +/// layer entry: the layer key names the item's path and settings. +#[test] +fn projects_with_the_same_item_ids_never_share_a_layer_entry() { + let dir = folder("layer-disk-projects"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let mut p = Project::default(); + let c = comp(); + let layer = footage_layer(&mut p, &c, "Plate", still(&src.join("one.png"), 40)); + let LayerSource::Footage { item } = layer.source else { panic!("a footage layer") }; + let cid = add_comp(&mut p, "C", c, vec![layer]); + let mut other = p.clone(); + other.item_mut(item).unwrap().kind = ItemKind::Footage(still(&src.join("two.png"), 220)); + let (pool, lc, dc) = session(&cache); + let (one, _) = render(&p, &pool, &lc, cid, 0); + dc.flush(); + drop((pool, lc, dc)); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + let (two, files) = render(&other, &pool, &lc, cid, 0); + assert!((two[0] - 220.0 / 255.0).abs() < 0.01, "the other project's own file shows: {two:?}"); + assert_ne!(two, one); + assert_eq!(dc.stats().hits, hits, "no entry of the first project is used"); + assert_eq!(paths(&files), [src.join("two.png").to_string_lossy().to_string()]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Safe: a damaged layer entry, a source file that is missing, one replaced by something +/// unreadable (a folder), and one back with other pixels never serve a stale buffer after a +/// restart; the damaged entry is rendered again, replaced and then used. +#[test] +fn missing_unreadable_files_and_damaged_layer_entries_never_serve_stale_buffers() { + let dir = folder("layer-disk-safe"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let mut p = Project::default(); + let c = comp(); + let path = src.join("plate.png"); + let layer = footage_layer(&mut p, &c, "Plate", still(&path, 90)); + let cid = add_comp(&mut p, "C", c, vec![layer]); + let (pool, lc, dc) = session(&cache); + let (shown, _) = render(&p, &pool, &lc, cid, 0); + assert!(shown[3] > 0.99, "the plate shows: {shown:?}"); + dc.flush(); + drop((pool, lc, dc)); + + // A damaged entry: a miss, deleted, rendered again and stored anew. + let layers = cache.join("v1").join(Kind::Layer.dir()); + for shard in std::fs::read_dir(&layers).unwrap().flatten() { + for f in std::fs::read_dir(shard.path()).unwrap().flatten() { + let mut d = std::fs::read(f.path()).unwrap(); + let n = d.len() / 2; + d[n] ^= 0x5a; + std::fs::write(f.path(), d).unwrap(); + } + } + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (again, files) = render(&p, &pool, &lc, cid, 0); + assert_eq!(again, shown); + assert_eq!(crate::opens_under(&src) - opened, 1, "read again, not served from the damaged entry"); + assert!(files.is_complete()); + dc.flush(); + drop((pool, lc, dc)); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + assert_eq!(render(&p, &pool, &lc, cid, 0).0, shown); + assert!(dc.stats().hits > hits, "the rewritten entry is used"); + drop((pool, lc, dc)); + + // The file is gone: not the old plate. + std::fs::remove_file(&path).unwrap(); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + let (gone, files) = render(&p, &pool, &lc, cid, 0); + assert!(gone[3] < 0.01, "a missing file shows nothing, never the cached plate: {gone:?}"); + assert_eq!(dc.stats().hits, hits); + assert!(files.files().iter().all(|f| f.stamp == FileStamp::Missing), "{files:?}"); + dc.flush(); + drop((pool, lc, dc)); + + // Something unreadable at its path (a folder): still not the old plate. + std::fs::create_dir(&path).unwrap(); + let (pool, lc, _dc) = session(&cache); + let (unreadable, _) = render(&p, &pool, &lc, cid, 0); + assert!(unreadable[3] < 0.01, "{unreadable:?}"); + std::fs::remove_dir(&path).unwrap(); + + // Back with other pixels: those show, not the entry made of the old file. + png(&path, 240); + touch_later(&path); + let (pool, lc, _dc) = session(&cache); + let (back, _) = render(&p, &pool, &lc, cid, 0); + assert!((back[0] - 240.0 / 255.0).abs() < 0.01, "{back:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 80db21fc4..0155b4acd 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -9,6 +9,7 @@ use std::path::Path; +use effectcraft_render::reads::FileStamp; use filmcraft_media::SharedSource; use crate::Result; @@ -19,17 +20,22 @@ use crate::Result; pub(crate) struct FileReader { file: std::fs::File, len: u64, + /// The file's identity when it was opened. + stamp: FileStamp, } #[cfg(any(unix, windows))] impl FileReader { + /// Opens `path` for reading and records its length and modification time. pub(crate) fn open(path: &Path) -> std::io::Result { + #[cfg(test)] + crate::count_open(path); let file = std::fs::File::open(path)?; let m = file.metadata()?; if !m.is_file() { return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); } - Ok(Self { file, len: m.len() }) + Ok(Self { file, len: m.len(), stamp: FileStamp::of_metadata(&m) }) } } @@ -72,16 +78,19 @@ fn name_of(path: &Path) -> String { /// Open a movie/audio file for the media pool, reading only what its container needs; files the /// reader openers don't handle are read whole and opened from memory, exactly as before. `None` /// when the file can't be opened in place (the caller reads it whole, which reports the error). +/// With the file's identity as opened. #[cfg(any(unix, windows))] -pub(crate) fn open_source(path: &Path) -> Option> { +pub(crate) fn open_source(path: &Path) -> Option<(Result, FileStamp)> { let reader = FileReader::open(path).ok()?; + let stamp = reader.stamp; let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader); let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()); - Some(opened.map_err(Into::into)) + Some((opened.map_err(Into::into), stamp)) } +/// Without a native file system there is no in-place source: always `None`. #[cfg(not(any(unix, windows)))] -pub(crate) fn open_source(_path: &Path) -> Option> { +pub(crate) fn open_source(_path: &Path) -> Option<(Result, FileStamp)> { None } diff --git a/crates/media/src/webp_anim.rs b/crates/media/src/webp_anim.rs index 9420be1ed..147fe8264 100644 --- a/crates/media/src/webp_anim.rs +++ b/crates/media/src/webp_anim.rs @@ -23,7 +23,7 @@ pub(crate) const CODEC: &str = "WebP"; /// Whether `bytes` start like a WebP file. pub(crate) fn is_webp(bytes: &[u8]) -> bool { - bytes.get(0..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") + craft_webp::is_webp(bytes) } /// What the chunk headers of an animated WebP say. @@ -35,46 +35,11 @@ struct Header { delays: Vec, } -fn u24(b: &[u8]) -> Option { - match b { - [a, b, c, ..] => Some(u32::from(*a) | u32::from(*b) << 8 | u32::from(*c) << 16), - _ => None, - } -} - /// Read the `VP8X` header and the `ANMF` frame headers. `None` when the file isn't an animated /// WebP. A truncated tail keeps the frames before it. fn header(bytes: &[u8]) -> Option
{ - if !is_webp(bytes) { - return None; - } - let riff = bytes.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?; - let end = usize::try_from(riff).ok()?.saturating_add(8).min(bytes.len()); - let (mut at, mut canvas, mut delays) = (12usize, None, Vec::new()); - while delays.len() < MAX_FRAMES { - let Some(head) = bytes.get(at..at.saturating_add(8)).filter(|_| at.saturating_add(8) <= end) else { break }; - let size = head.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?; - let size = usize::try_from(size).ok()?; - let body_end = at.saturating_add(8).saturating_add(size); - let Some(body) = bytes.get(at.saturating_add(8)..body_end).filter(|_| body_end <= end) else { break }; - match head.get(0..4) { - Some(b"VP8X") => { - let flags = *body.first()?; - if flags & 0x02 == 0 { - // No animation flag: a still. - return None; - } - let w = u24(body.get(4..)?)?.saturating_add(1); - let h = u24(body.get(7..)?)?.saturating_add(1); - canvas = Some((w, h, flags & 0x10 != 0)); - } - Some(b"ANMF") if body.len() >= 16 => delays.push(u24(body.get(12..)?)?), - _ => {} - } - at = body_end.saturating_add(size & 1); - } - let (width, height, alpha) = canvas?; - Some(Header { width, height, alpha, delays }) + let info = craft_webp::scan(bytes, MAX_FRAMES).filter(|i| i.animated)?; + Some(Header { width: info.width, height: info.height, alpha: info.alpha, delays: info.frames.iter().map(|f| f.duration_ms).collect() }) } /// The delay of a frame in milliseconds. Browsers show delays of 10 ms or less (that encoders @@ -157,6 +122,11 @@ pub(crate) fn load(bytes: &[u8]) -> Result { return Err(MediaError::Unsupported(format!("animated WebP of {} frames of {}×{} is too large to hold in memory", h.delays.len(), h.width, h.height))); } let dec = WebPDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("WebP: {e}")))?; + // The budget above used the canvas this file's header scan found; the decoder must agree, or + // a second VP8X header could make it allocate a far larger canvas. + if image::ImageDecoder::dimensions(&dec) != (h.width, h.height) { + return Err(MediaError::Decode("WebP: conflicting canvas sizes".into())); + } let (mut frames, mut delays, mut size) = (Vec::new(), Vec::new(), 0usize); for frame in dec.into_frames() { let frame = match frame { @@ -326,4 +296,20 @@ mod tests { let e = super::load(&bytes).err().unwrap(); assert!(e.to_string().contains("too large"), "{e}"); } + + #[test] + fn conflicting_canvas_headers_are_refused() { + // The first VP8X claims 16384×16384; a second one, which the header scan keeps, 1×1. + let mut bytes = webp(1, 1, &[(RED, 50); 2]); + bytes[24..27].copy_from_slice(&u24(16383)); + bytes[27..30].copy_from_slice(&u24(16383)); + let mut small = vec![0x12, 0, 0, 0]; + small.extend_from_slice(&u24(0)); + small.extend_from_slice(&u24(0)); + let second = chunk(b"VP8X", &small); + bytes.splice(30..30, second.iter().copied()); + let riff = u32::try_from(bytes.len() - 8).unwrap(); + bytes[4..8].copy_from_slice(&riff.to_le_bytes()); + assert!(super::load(&bytes).is_err()); + } } diff --git a/crates/media/tests/it/aac_layout.rs b/crates/media/tests/it/aac_layout.rs index fb3e078a6..896e4f0a7 100644 --- a/crates/media/tests/it/aac_layout.rs +++ b/crates/media/tests/it/aac_layout.rs @@ -1,5 +1,5 @@ //! Multichannel AAC reaches stereo by where each channel plays, whatever order its channel -//! configuration decodes in (#1701). Fixtures are tones encoded with FilmCraft's AAC encoder (no +//! configuration decodes in (#1701). Fixtures are tones encoded with craft-audio's AAC encoder (no //! ffmpeg needed, so nothing is skipped), with the configuration rewritten where the coded //! elements fit another: configurations 11 and 12 code the same elements as the encoder's 7- and //! 8-channel output, and a program config element can name any of them. @@ -9,9 +9,9 @@ use std::io::Cursor; use std::path::PathBuf; use std::sync::Arc; +use craft_audio::aac::{AudioSpecificConfig, Encoder, EncoderConfig, ProgramConfig}; use effectcraft_media::{MediaPool, probe, probe_bytes}; use effectcraft_time::Tick; -use filmcraft_aac::{AudioSpecificConfig, Encoder, EncoderConfig, ProgramConfig}; const RATE: u32 = 48_000; /// Tone frequencies by channel; an LFE gets [`LFE_TONE`], which its element can carry. @@ -61,7 +61,9 @@ fn lc(config: u8) -> Option { fn pce(front: Vec<(bool, u8)>, side: Vec<(bool, u8)>, back: Vec<(bool, u8)>, lfe: Vec, comment: &[u8]) -> Option { let pce = ProgramConfig { object_type: 1, sf_index: 3, front, side, back, lfe, comment: comment.to_vec(), ..Default::default() }; - Some(AudioSpecificConfig { pce: Some(pce), ..AudioSpecificConfig::lc(3, 0) }) + let mut asc = AudioSpecificConfig::lc(3, 0); + asc.pce = Some(pce); + Some(asc) } use Ch::*; @@ -115,7 +117,7 @@ fn mp4(asc: Vec, channels: u32, units: &[Vec]) -> Vec { /// As [`mp4`], after an audio track with the configuration and channel count `empty` and no /// samples, if any. fn mp4_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { - use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + use craft_media::isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer"); if let Some((asc, channels)) = empty { w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("empty track"); @@ -134,7 +136,7 @@ fn mkv(asc: Vec, channels: u32, units: &[Vec]) -> Vec { /// As [`mkv`], after an audio track with the configuration and channel count `empty` and no /// frames, if any. fn mkv_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { - use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; + use craft_media::matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; let track = |asc: Vec, channels: u32| { let mut t = TrackSpec::new(TrackKind::Audio, "A_AAC"); t.codec_private = asc; @@ -210,7 +212,7 @@ fn lfe_is_coded(case: &Case, bytes: Vec) { fn every_aac_layout_mixes_by_speaker_in_mp4() { for case in cases() { let (asc, units) = encode(case.content, false); - let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let asc = case.asc.as_ref().map_or(asc, |a| a.to_bytes().expect("ASC")); let bytes = mp4(asc, case.content.len() as u32, &units); lfe_is_coded(&case, bytes.clone()); let p = write(&format!("{}.mp4", case.name), &bytes); @@ -227,7 +229,7 @@ fn every_aac_layout_mixes_by_speaker_in_mp4() { fn matroska_aac_layouts_mix_by_speaker() { for case in cases().into_iter().filter(|c| matches!(c.name, "config12" | "pce61-centre-first" | "pce61-ffmpeg")) { let (asc, units) = encode(case.content, false); - let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let asc = case.asc.as_ref().map_or(asc, |a| a.to_bytes().expect("ASC")); let p = write(&format!("{}.mkv", case.name), &mkv(asc, case.content.len() as u32, &units)); check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); } @@ -251,9 +253,9 @@ fn an_empty_audio_track_lends_its_configuration_to_no_other() { for (name, empty) in [("config12", 7), ("pce61-centre-first", 11)] { let case = cases().into_iter().find(|c| c.name == name).expect("case"); let n = case.content.len() as u32; - let empty = Some((AudioSpecificConfig::lc(3, empty).to_bytes(), n)); + let empty = Some((AudioSpecificConfig::lc(3, empty).to_bytes().expect("ASC"), n)); let (asc, units) = encode(case.content, false); - let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let asc = case.asc.as_ref().map_or(asc, |a| a.to_bytes().expect("ASC")); let p = write(&format!("{name}-after-empty.mp4"), &mp4_after(empty.clone(), asc.clone(), n, &units)); check(&format!("{name} MP4"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); let p = write(&format!("{name}-after-empty.mkv"), &mkv_after(empty, asc, n, &units)); diff --git a/crates/media/tests/it/downmix.rs b/crates/media/tests/it/downmix.rs index 05205ab6a..f974c6cb0 100644 --- a/crates/media/tests/it/downmix.rs +++ b/crates/media/tests/it/downmix.rs @@ -174,7 +174,7 @@ fn each_channel_reaches_stereo_at_its_bs775_gain() { fn whole_pce_entry(p: &Path, channels: usize) -> PathBuf { let mut bytes = std::fs::read(p).expect("read"); let Some((asc, 2)) = aac_entry(&bytes) else { return p.to_path_buf() }; - if channels <= 2 || filmcraft_aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config).ok() != Some(0) { + if channels <= 2 || craft_audio::aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config).ok() != Some(0) { return p.to_path_buf(); } // the `mp4a` entry's channelcount follows its 6 reserved bytes, data reference index and @@ -189,9 +189,9 @@ fn whole_pce_entry(p: &Path, channels: usize) -> PathBuf { /// The AudioSpecificConfig and sample entry channel count of an MP4's first AAC track. fn aac_entry(bytes: &[u8]) -> Option<(Vec, u32)> { - let file = filmcraft_isobmff::open(bytes).ok()?; + let file = craft_media::isobmff::open(bytes).ok()?; file.tracks.iter().flat_map(|t| &t.entries).find_map(|e| match &e.codec { - filmcraft_isobmff::CodecConfig::Aac(a) => Some((a.asc.clone(), e.audio.as_ref()?.channels)), + craft_media::isobmff::CodecConfig::Aac(a) => Some((a.asc.clone(), e.audio.as_ref()?.channels)), _ => None, }) } diff --git a/crates/media/tests/it/file_timecode.rs b/crates/media/tests/it/file_timecode.rs index e5cbfa92d..dae3335ba 100644 --- a/crates/media/tests/it/file_timecode.rs +++ b/crates/media/tests/it/file_timecode.rs @@ -2,8 +2,8 @@ use std::io::Cursor; +use craft_media::isobmff::{Brand, Mp4Writer, SampleEntry, TimecodeConfig, TrackConfig, WriteSample, WriterOptions}; use effectcraft_project::FootageKind; -use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TimecodeConfig, TrackConfig, WriteSample, WriterOptions}; /// A two-frame 24 fps Motion JPEG MOV, with a timecode track from `start_frame` when given. fn mov(start_frame: Option) -> Vec { diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index 7f0de6ae4..ed152541c 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -747,8 +747,8 @@ fn sequence_gaps_hold_the_previous_frame() { /// 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 guard = tempfile::tempdir().expect("temp dir"); + let dir = guard.path().to_path_buf(); 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 }]) diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index ebe7da7e8..1dfa79430 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-model" -description = "3D models: glTF 2.0 (.gltf/.glb), Wavefront OBJ/MTL, STL and FBX 7.x (binary and ASCII) import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" +description = "3D models: glTF 2.0 (.gltf/.glb), Wavefront OBJ/MTL, STL and FBX 6.x/7.x (binary and ASCII) import through craft-mesh, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" version.workspace = true edition.workspace = true license.workspace = true @@ -10,10 +10,9 @@ rust-version.workspace = true workspace = true [dependencies] +craft-mesh = { workspace = true } effectcraft-geom = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } -miniz_oxide = "0.9" diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs index 114606b95..71d550367 100644 --- a/crates/model/src/extrude.rs +++ b/crates/model/src/extrude.rs @@ -96,10 +96,14 @@ fn point_in(p: P, c: &[P]) -> bool { inside } +/// Most contours grouped into outlines; containment is tested pairwise, so the smallest are dropped. +pub const MAX_CONTOURS: usize = 10_000; + /// Group closed contours into outlines by nesting depth (even depth = outer, odd = hole of the -/// innermost containing outer contour). Degenerate contours are dropped. +/// innermost containing outer contour). Degenerate contours are dropped, as are the smallest +/// past [`MAX_CONTOURS`] (a kept hole always keeps its larger container). pub fn group_contours(contours: Vec>) -> Vec { - let cs: Vec> = contours + let mut cs: Vec> = contours .into_iter() .map(|mut c| { c.dedup(); @@ -110,26 +114,60 @@ pub fn group_contours(contours: Vec>) -> Vec { }) .filter(|c| c.len() >= 3 && signed_area(c).abs() > 1e-9) .collect(); + if cs.len() > MAX_CONTOURS { + log::warn!("model: {} contours, grouping the {MAX_CONTOURS} largest", cs.len()); + // Keep the largest, in their original order. + let area: Vec = cs.iter().map(|c| signed_area(c).abs()).collect(); + let mut keep: Vec = (0..cs.len()).collect(); + keep.sort_by(|&a, &b| area[b].total_cmp(&area[a])); + keep.truncate(MAX_CONTOURS); + keep.sort_unstable(); + cs = keep.into_iter().map(|i| std::mem::take(&mut cs[i])).collect(); + } let n = cs.len(); let area: Vec = cs.iter().map(|c| signed_area(c).abs()).collect(); - // Containers of each contour (by a vertex inside; nested contours do not cross). - let parents: Vec> = (0..n) + let bounds: Vec<[P; 2]> = cs + .iter() + .map(|c| c.iter().fold([[f64::MAX; 2], [f64::MIN; 2]], |[lo, hi], p| [[lo[0].min(p[0]), lo[1].min(p[1])], [hi[0].max(p[0]), hi[1].max(p[1])]])) + .collect(); + // The containers of each contour: the larger contours holding a vertex of it (nested contours + // do not cross); the bounding box must hold the vertex first. Depth is their count, so + // overlapping (non-nested) paths keep every container they sit inside. + let containers = |i: usize, only: &dyn Fn(usize) -> bool| -> Vec { + let p = cs[i][0]; + (0..n) + .filter(|&j| j != i && area[j] > area[i] && only(j)) + .filter(|&j| (bounds[j][0][0]..=bounds[j][1][0]).contains(&p[0]) && (bounds[j][0][1]..=bounds[j][1][1]).contains(&p[1])) + .filter(|&j| point_in(p, &cs[j])) + .collect() + }; + let depth: Vec = (0..n).map(|i| containers(i, &|_| true).len()).collect(); + // A hole belongs to the innermost containing contour that is itself an outline: with + // overlapping paths the smallest container can be a hole, which has no outline to take it. + let parent: Vec> = (0..n) .map(|i| { - let probe = cs[i][0]; - (0..n).filter(|&j| j != i && area[j] > area[i] && point_in(probe, &cs[j])).collect() + if depth[i].is_multiple_of(2) { + None + } else { + containers(i, &|j| depth[j].is_multiple_of(2)).into_iter().min_by(|&a, &b| area[a].total_cmp(&area[b])) + } }) .collect(); let mut outlines: Vec = vec![]; let mut slot = vec![usize::MAX; n]; let mut order: Vec = (0..n).collect(); - order.sort_by_key(|&i| parents[i].len()); - for i in order { - let depth = parents[i].len(); - if depth.is_multiple_of(2) { + order.sort_by_key(|&i| depth[i]); + // Every outline gets its slot before any hole is attached: with overlapping paths a hole's + // parent can be deeper than the hole itself. + for &i in &order { + if depth[i].is_multiple_of(2) { slot[i] = outlines.len(); outlines.push(Outline { outer: cs[i].clone(), holes: vec![] }); - } else if let Some(&p) = parents[i].iter().filter(|&&j| parents[j].len() == depth - 1).min_by(|&&a, &&b| area[a].total_cmp(&area[b])) - && slot[p] != usize::MAX + } + } + for &i in &order { + if !depth[i].is_multiple_of(2) + && let Some(p) = parent[i] { outlines[slot[p]].holes.push(cs[i].clone()); } diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs index bca387ff2..fead03b21 100644 --- a/crates/model/src/fbx.rs +++ b/crates/model/src/fbx.rs @@ -1,953 +1,34 @@ -//! FBX 7.x scenes, binary and ASCII. -//! -//! Written from public descriptions of the format (the binary node-record layout, the ASCII -//! syntax, and the scene objects of the Autodesk FBX SDK documentation): no importer source was -//! read. -//! -//! - **Binary**: the `Kaydara FBX Binary` header and version, node records with 32-bit (before -//! 7.5) or 64-bit offsets, every property type (`Y C I F D L S R` and the `f d l i b` arrays, -//! zlib-compressed or not). Object names `Name\0\x01Class` become `Class::Name` as in ASCII. -//! - **ASCII**: `Key: value, value { children }` with `;` comments and `*N { a: … }` arrays. -//! - **Scene**: `Geometry` meshes (`Vertices`, `PolygonVertexIndex` polygons fan-triangulated, -//! the first `LayerElementNormal`, `LayerElementUV` and `LayerElementMaterial` with every -//! mapping and reference mode), `Model` nodes with the SDK transform (translation, rotation -//! offset and pivot, pre/post rotation, Euler rotation in its order, scaling offset and pivot, -//! scaling) and geometric transforms, `Material` (Lambert/Phong mapped onto -//! metallic-roughness), `Texture` and `Video` (embedded or relative image files), joined by -//! `Connections`. `GlobalSettings` axes turn the scene to +Y up. -//! -//! Not imported: FBX 6.x and older, animation stacks, skin deformers, blend shapes, cameras and -//! lights, NURBS and patches. Units stay the file's own (like OBJ). +//! FBX 6.x and 7.x scenes, binary and ASCII, read with `craft-mesh` (`craft_mesh::fbx`): +//! `Geometry` meshes, `Model` nodes with the SDK transform and geometric transforms, `Material` +//! (Lambert/Phong mapped onto metallic-roughness), embedded or relative `Texture` images, +//! joined by `Connections`. A root node turns the file's `GlobalSettings` axes to +Y up; units +//! stay the file's own (like OBJ). Meshes without normals get smooth ones. -use std::collections::HashMap; -use std::sync::Arc; +use crate::scene::{MissingNormals, error, limits, mat4, to_model}; +use crate::{Model, ModelError, Node, Result}; -use crate::obj::srgb_to_linear; -use crate::{AlphaMode, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, TexRef, Texture, perr, vec3}; +pub use craft_mesh::fbx::BINARY_MAGIC; -/// The binary file's magic (`Kaydara FBX Binary`, two spaces, NUL, 0x1A, 0x00). -pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; - -/// Deepest node nesting accepted (real files nest well under 10 levels). -const MAX_DEPTH: usize = 64; -/// Largest decoded array accepted (bytes). -const MAX_ARRAY_BYTES: usize = 1 << 30; -/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the -/// wire type) plus the meshes built from them (#1234). -const MAX_IMPORT_BYTES: usize = 1 << 31; - -/// What is left of an import's memory budget. -struct Budget(std::cell::Cell); - -impl Budget { - fn new(bytes: usize) -> Budget { - Budget(std::cell::Cell::new(bytes)) - } - /// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before - /// anything that large is allocated. - fn take(&self, bytes: Option, what: &str) -> Result<()> { - let left = self.0.get(); - match bytes.filter(|&b| b <= left) { - Some(b) => { - self.0.set(left - b); - Ok(()) - } - None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))), - } - } -} - -/// Bytes a mesh keeps. -fn mesh_bytes(m: &Mesh) -> Option { - m.primitives.iter().try_fold(0usize, |acc, p| { - let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?; - let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?; - acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?) - }) -} - -/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as -/// one `Prop` each and are parsed before they can be counted (the text itself bounds them). -fn value_bytes(nodes: &[FbxNode]) -> Option { - nodes.iter().try_fold(0usize, |acc, n| { - let own = n.props.iter().try_fold(0usize, |a, p| { - let extra = match p { - Prop::Ints(v) => v.len().checked_mul(8)?, - Prop::Floats(v) => v.len().checked_mul(8)?, - Prop::Str(v) => v.len(), - Prop::Bytes(v) => v.len(), - Prop::Int(_) | Prop::Float(_) => 0, - }; - a.checked_add(std::mem::size_of::())?.checked_add(extra) - })?; - acc.checked_add(own)?.checked_add(value_bytes(&n.children)?) - }) -} - -/// One property value of an FBX node. -#[derive(Clone, Debug, PartialEq)] -pub enum Prop { - Int(i64), - Float(f64), - Str(String), - Bytes(Vec), - Ints(Vec), - Floats(Vec), -} - -impl Prop { - fn as_f64(&self) -> Option { - match self { - Prop::Int(i) => Some(*i as f64), - Prop::Float(f) => Some(*f), - _ => None, - } - } - fn as_i64(&self) -> Option { - match self { - Prop::Int(i) => Some(*i), - Prop::Float(f) if f.is_finite() => Some(*f as i64), - _ => None, - } - } - fn as_str(&self) -> Option<&str> { - match self { - Prop::Str(s) => Some(s), - _ => None, - } - } -} - -/// A node of the FBX document tree (the same for binary and ASCII files). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct FbxNode { - pub name: String, - pub props: Vec, - pub children: Vec, -} - -impl FbxNode { - pub fn child(&self, name: &str) -> Option<&FbxNode> { - self.children.iter().find(|c| c.name == name) - } - pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { - self.children.iter().filter(move |c| c.name == name) - } - fn prop(&self, i: usize) -> Option<&Prop> { - self.props.get(i) - } - fn str_at(&self, i: usize) -> Option<&str> { - self.prop(i).and_then(Prop::as_str) - } - /// The node's array as floats: a binary array property, an ASCII `a:` child, or the plain - /// value list. - pub fn floats(&self) -> Vec { - if let Some(a) = self.child("a") { - return a.floats(); - } - let mut out = vec![]; - for p in &self.props { - match p { - Prop::Floats(v) => out.extend_from_slice(v), - Prop::Ints(v) => out.extend(v.iter().map(|&i| i as f64)), - Prop::Int(i) => out.push(*i as f64), - Prop::Float(f) => out.push(*f), - _ => {} - } - } - out - } - /// The node's array as integers (see [`FbxNode::floats`]). - pub fn ints(&self) -> Vec { - if let Some(a) = self.child("a") { - return a.ints(); - } - let mut out = vec![]; - for p in &self.props { - match p { - Prop::Ints(v) => out.extend_from_slice(v), - Prop::Floats(v) => out.extend(v.iter().filter(|f| f.is_finite()).map(|&f| f as i64)), - p => out.extend(p.as_i64()), - } - } - out - } -} - -/// Read an FBX file (binary or ASCII) into its top-level nodes and the file version -/// (e.g. 7400). -pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { - read_within(bytes, &Budget::new(MAX_IMPORT_BYTES)) -} - -fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { - if bytes.starts_with(BINARY_MAGIC) { - read_binary(bytes, budget) - } else { - let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?; - budget.take(value_bytes(&nodes), "its values")?; - Ok((version, nodes)) - } -} - -// --------------------------------------------------------------------------------------------- -// Binary - -struct Bin<'a> { - d: &'a [u8], - /// 64-bit record offsets (version 7500 and later). - wide: bool, - budget: &'a Budget, -} - -impl Bin<'_> { - fn take(&self, pos: &mut usize, n: usize) -> Result<&[u8]> { - let end = pos.checked_add(n).ok_or_else(|| perr("FBX: offset overflow"))?; - let s = self.d.get(*pos..end).ok_or_else(|| perr(format!("FBX: truncated at byte {pos}")))?; - *pos = end; - Ok(s) - } - fn arr(&self, pos: &mut usize) -> Result<[u8; N]> { - let s = self.take(pos, N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - fn u32(&self, pos: &mut usize) -> Result { - Ok(u32::from_le_bytes(self.arr(pos)?)) - } - fn offset(&self, pos: &mut usize) -> Result { - if self.wide { Ok(u64::from_le_bytes(self.arr(pos)?)) } else { Ok(self.u32(pos)? as u64) } - } - fn to_usize(v: u64) -> Result { - usize::try_from(v).map_err(|_| perr("FBX: offset out of range")) - } - - /// Records until `end` or a null record. - fn list(&self, pos: &mut usize, end: usize, depth: usize) -> Result> { - if depth > MAX_DEPTH { - return Err(perr("FBX: nodes nested too deeply")); - } - let mut out = vec![]; - while *pos < end { - match self.record(pos, depth)? { - Some(n) => out.push(n), - None => break, - } - } - Ok(out) - } - - /// One node record; `None` for the null record that closes a list. - fn record(&self, pos: &mut usize, depth: usize) -> Result> { - let start = *pos; - let end = Self::to_usize(self.offset(pos)?)?; - let nprops = self.offset(pos)?; - let plen = Self::to_usize(self.offset(pos)?)?; - let nlen = self.arr::<1>(pos)?[0] as usize; - if end == 0 && nprops == 0 && plen == 0 && nlen == 0 { - return Ok(None); - } - if end <= start || end > self.d.len() { - return Err(perr(format!("FBX: bad record end {end} at byte {start}"))); - } - let name = String::from_utf8_lossy(self.take(pos, nlen)?).into_owned(); - let pstart = *pos; - let pend = pstart.checked_add(plen).filter(|&e| e <= end).ok_or_else(|| perr(format!("FBX: `{name}`: property list past the record")))?; - // Every property is at least one byte, so a count above the list length is corrupt. - if nprops > plen as u64 { - return Err(perr(format!("FBX: `{name}`: {nprops} properties in {plen} bytes"))); - } - let mut props = Vec::with_capacity(nprops as usize); - for _ in 0..nprops { - props.push(self.prop(pos, pend)?); - } - *pos = pend; - let children = if *pos < end { self.list(pos, end, depth + 1)? } else { vec![] }; - *pos = end; - Ok(Some(FbxNode { name, props, children })) - } - - fn prop(&self, pos: &mut usize, pend: usize) -> Result { - let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget }; - let t = sub.arr::<1>(pos)?[0]; - Ok(match t { - b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), - b'C' => Prop::Int((sub.arr::<1>(pos)?[0] != 0) as i64), - b'I' => Prop::Int(i32::from_le_bytes(sub.arr(pos)?) as i64), - b'L' => Prop::Int(i64::from_le_bytes(sub.arr(pos)?)), - b'F' => Prop::Float(f32::from_le_bytes(sub.arr(pos)?) as f64), - b'D' => Prop::Float(f64::from_le_bytes(sub.arr(pos)?)), - b'S' => { - let n = sub.u32(pos)? as usize; - Prop::Str(object_name(sub.take(pos, n)?)) - } - b'R' => { - let n = sub.u32(pos)? as usize; - Prop::Bytes(sub.take(pos, n)?.to_vec()) - } - b'f' | b'd' | b'l' | b'i' | b'b' => { - let len = sub.u32(pos)? as usize; - let enc = sub.u32(pos)?; - let clen = sub.u32(pos)? as usize; - let size = match t { - b'd' | b'l' => 8, - b'f' | b'i' => 4, - _ => 1, - }; - let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?; - // Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget - // is charged for what is kept, not for the wire bytes. - sub.budget.take(len.checked_mul(8), "an array")?; - let data = sub.take(pos, clen)?; - let raw = match enc { - 0 => data.to_vec(), - 1 => miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, want).map_err(|e| perr(format!("FBX: array zlib: {e:?}")))?, - e => return Err(perr(format!("FBX: unknown array encoding {e}"))), - }; - let raw = raw.get(..want).ok_or_else(|| perr("FBX: array shorter than its length"))?; - match t { - b'f' => Prop::Floats(raw.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c) as f64).collect()), - b'd' => Prop::Floats(raw.as_chunks::<8>().0.iter().map(|c| f64::from_le_bytes(*c)).collect()), - b'l' => Prop::Ints(raw.as_chunks::<8>().0.iter().map(|c| i64::from_le_bytes(*c)).collect()), - b'i' => Prop::Ints(raw.as_chunks::<4>().0.iter().map(|c| i32::from_le_bytes(*c) as i64).collect()), - _ => Prop::Ints(raw.iter().map(|&b| (b != 0) as i64).collect()), - } - } - t => return Err(perr(format!("FBX: unknown property type 0x{t:02x}"))), - }) - } -} - -/// Binary object names are `Name\0\x01Class`; ASCII writes `Class::Name`. -fn object_name(b: &[u8]) -> String { - let s = String::from_utf8_lossy(b); - match s.split_once("\0\x01") { - Some((name, class)) => format!("{class}::{name}"), - None => s.into_owned(), - } -} - -fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { - let mut pos = BINARY_MAGIC.len(); - let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?; - let bin = Bin { d: bytes, wide: version >= 7500, budget }; - let mut nodes = vec![]; - while pos < bytes.len() { - match bin.record(&mut pos, 0) { - Ok(Some(n)) => nodes.push(n), - Ok(None) => break, - // A damaged tail (footer, padding) after complete sections is ignored. - Err(_) if nodes.iter().any(|n| n.name == "Objects") => break, - Err(e) => return Err(e), - } - } - Ok((version, nodes)) -} - -// --------------------------------------------------------------------------------------------- -// ASCII - -#[derive(Clone, Debug, PartialEq)] -enum Tok { - Key(String), - Val(Prop), - /// `*N` array length (the values follow in an `a:` child). - Count, - Comma, - Open, - Close, -} - -fn tokenize(text: &str) -> Vec { - let mut out = vec![]; - let mut it = text.char_indices().peekable(); - while let Some(&(i, c)) = it.peek() { - match c { - c if c.is_whitespace() => { - it.next(); - } - ';' => { - for (_, c) in it.by_ref() { - if c == '\n' { - break; - } - } - } - ',' => { - it.next(); - out.push(Tok::Comma); - } - '{' => { - it.next(); - out.push(Tok::Open); - } - '}' => { - it.next(); - out.push(Tok::Close); - } - '"' => { - it.next(); - let start = i + 1; - let mut end = text.len(); - for (j, c) in it.by_ref() { - if c == '"' { - end = j; - break; - } - } - out.push(Tok::Val(Prop::Str(text.get(start..end).unwrap_or("").replace(""", "\"")))); - } - _ => { - let start = i; - let mut end = text.len(); - while let Some(&(j, c)) = it.peek() { - if c.is_whitespace() || matches!(c, ',' | '{' | '}' | '"' | ';' | ':') { - end = j; - break; - } - it.next(); - } - let word = text.get(start..end).unwrap_or(""); - if it.peek().is_some_and(|&(_, c)| c == ':') { - it.next(); - out.push(Tok::Key(word.to_string())); - } else if word.starts_with('*') { - out.push(Tok::Count); - } else if let Ok(i) = word.parse::() { - out.push(Tok::Val(Prop::Int(i))); - } else if let Ok(f) = word.parse::() { - out.push(Tok::Val(Prop::Float(f))); - } else if !word.is_empty() { - out.push(Tok::Val(Prop::Str(word.to_string()))); - } else { - // A stray `:` with no key. - it.next(); - } - } - } - } - out -} - -fn ascii_list(toks: &[Tok], i: &mut usize, depth: usize) -> Result> { - if depth > MAX_DEPTH { - return Err(perr("FBX: nodes nested too deeply")); - } - let mut out = vec![]; - while let Some(t) = toks.get(*i) { - *i += 1; - match t { - Tok::Close if depth > 0 => return Ok(out), - Tok::Key(name) => { - let mut node = FbxNode { name: name.clone(), ..FbxNode::default() }; - loop { - match toks.get(*i) { - Some(Tok::Val(v)) => node.props.push(v.clone()), - Some(Tok::Count | Tok::Comma) => {} - _ => break, - } - *i += 1; - } - if toks.get(*i) == Some(&Tok::Open) { - *i += 1; - node.children = ascii_list(toks, i, depth + 1)?; - } - out.push(node); - } - _ => {} - } - } - Ok(out) -} - -fn read_ascii(text: &str) -> Result<(u32, Vec)> { - let toks = tokenize(text); - let mut i = 0; - let nodes = ascii_list(&toks, &mut i, 0)?; - let version = nodes - .iter() - .find(|n| n.name == "FBXHeaderExtension") - .and_then(|h| h.child("FBXVersion")) - .and_then(|v| v.prop(0)) - .and_then(Prop::as_i64) - .and_then(|v| u32::try_from(v).ok()) - .unwrap_or(0); - Ok((version, nodes)) -} - -// --------------------------------------------------------------------------------------------- -// Scene - -/// `Properties70` entries by name (`P: name, type, label, flags, values…`). -struct Props70<'a>(HashMap<&'a str, &'a FbxNode>); - -impl<'a> Props70<'a> { - fn of(node: &'a FbxNode) -> Props70<'a> { - let mut m = HashMap::new(); - if let Some(p) = node.child("Properties70") { - for e in p.children_named("P") { - if let Some(name) = e.str_at(0) { - m.entry(name).or_insert(e); - } - } - } - Props70(m) - } - fn values(&self, name: &str) -> Vec { - self.0.get(name).map(|e| e.props.iter().skip(4).filter_map(Prop::as_f64).collect()).unwrap_or_default() - } - fn f(&self, name: &str) -> Option { - self.values(name).first().copied().filter(|v| v.is_finite()) - } - fn v3(&self, name: &str, default: [f64; 3]) -> [f64; 3] { - match self.values(name).as_slice() { - [x, y, z, ..] if x.is_finite() && y.is_finite() && z.is_finite() => [*x, *y, *z], - _ => default, - } - } -} - -/// An Euler rotation (degrees) in FBX rotation order (`eEulerXYZ` = 0 rotates about X first). -fn euler(d: [f64; 3], order: i64) -> Mat4 { - let (x, y, z) = (Mat4::rotate_x(d[0]), Mat4::rotate_y(d[1]), Mat4::rotate_z(d[2])); - match order { - 1 => y * z * x, - 2 => x * z * y, - 3 => z * x * y, - 4 => y * x * z, - 5 => x * y * z, - _ => z * y * x, - } -} - -fn translate(t: [f64; 3]) -> Mat4 { - Mat4::translate(vec3(t[0], t[1], t[2])) -} - -/// A Model's local transform: `T · Roff · Rp · Rpre · R · Rpost⁻¹ · Rp⁻¹ · Soff · Sp · S · Sp⁻¹`. -fn model_local(p: &Props70) -> Mat4 { - let order = p.f("RotationOrder").map_or(0, |o| o as i64); - let active = p.f("RotationActive").is_some_and(|a| a != 0.0); - let (pre, post) = - if active { (euler(p.v3("PreRotation", [0.0; 3]), 0), euler(p.v3("PostRotation", [0.0; 3]), 0).transpose()) } else { (Mat4::IDENTITY, Mat4::IDENTITY) }; - let rp = p.v3("RotationPivot", [0.0; 3]); - let sp = p.v3("ScalingPivot", [0.0; 3]); - let s = p.v3("Lcl Scaling", [1.0; 3]); - translate(p.v3("Lcl Translation", [0.0; 3])) - * translate(p.v3("RotationOffset", [0.0; 3])) - * translate(rp) - * pre - * euler(p.v3("Lcl Rotation", [0.0; 3]), order) - * post - * translate([-rp[0], -rp[1], -rp[2]]) - * translate(p.v3("ScalingOffset", [0.0; 3])) - * translate(sp) - * Mat4::scale(vec3(s[0], s[1], s[2])) - * translate([-sp[0], -sp[1], -sp[2]]) -} - -/// The geometric transform of a Model (applies to its geometry, not to its children). -fn geometric(p: &Props70) -> Option { - let t = p.v3("GeometricTranslation", [0.0; 3]); - let r = p.v3("GeometricRotation", [0.0; 3]); - let s = p.v3("GeometricScaling", [1.0; 3]); - if t == [0.0; 3] && r == [0.0; 3] && s == [1.0; 3] { - return None; - } - Some(translate(t) * euler(r, 0) * Mat4::scale(vec3(s[0], s[1], s[2]))) -} - -/// The file's axis system → +Y up, +Z front, +X right. -fn axis_matrix(settings: Option<&FbxNode>) -> Mat4 { - let p = settings.map(Props70::of); - let get = |name: &str, d: f64| p.as_ref().and_then(|p| p.f(name)).unwrap_or(d); - let axis = |a: f64, s: f64| -> Option<[f64; 4]> { - let a = a as i64; - let sign = if s < 0.0 { -1.0 } else { 1.0 }; - let mut v = [0.0; 4]; - *v.get_mut(usize::try_from(a).ok().filter(|&a| a < 3)?)? = sign; - Some(v) - }; - let (Some(right), Some(up), Some(front)) = ( - axis(get("CoordAxis", 0.0), get("CoordAxisSign", 1.0)), - axis(get("UpAxis", 1.0), get("UpAxisSign", 1.0)), - axis(get("FrontAxis", 2.0), get("FrontAxisSign", 1.0)), - ) else { - return Mat4::IDENTITY; - }; - let m = Mat4([right, up, front, [0.0, 0.0, 0.0, 1.0]]); - // Two axes on the same file axis: not a rotation. - let used = |k: usize| right[k] != 0.0 || up[k] != 0.0 || front[k] != 0.0; - if (0..3).all(used) { m } else { Mat4::IDENTITY } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Mapping { - PolygonVertex, - Vertex, - Polygon, - AllSame, -} - -/// A geometry layer element (normals, UVs, material indices). -struct LayerData { - mapping: Mapping, - data: Vec, - index: Option>, - width: usize, -} - -impl LayerData { - fn of(geom: &FbxNode, element: &str, data: &str, index: &str, width: usize) -> Option { - let e = geom.child(element)?; - let mapping = match e.child("MappingInformationType").and_then(|m| m.str_at(0)).unwrap_or("ByPolygonVertex") { - "ByPolygonVertex" => Mapping::PolygonVertex, - "ByVertex" | "ByVertice" | "ByControlPoint" => Mapping::Vertex, - "ByPolygon" => Mapping::Polygon, - "AllSame" => Mapping::AllSame, - _ => return None, - }; - let reference = e.child("ReferenceInformationType").and_then(|m| m.str_at(0)).unwrap_or("Direct"); - let index = (reference != "Direct").then(|| e.child(index).map(FbxNode::ints)).flatten(); - Some(LayerData { mapping, data: e.child(data)?.floats(), index, width }) - } - fn get(&self, pv: usize, v: usize, poly: usize) -> Option<&[f64]> { - let i = match self.mapping { - Mapping::PolygonVertex => pv, - Mapping::Vertex => v, - Mapping::Polygon => poly, - Mapping::AllSame => 0, - }; - let j = match &self.index { - Some(ix) => usize::try_from(*ix.get(i)?).ok()?, - None => i, - }; - let a = j.checked_mul(self.width)?; - self.data.get(a..a.checked_add(self.width)?) - } -} - -fn f32s(s: &[f64]) -> [f32; N] { - let mut a = [0.0f32; N]; - for (o, v) in a.iter_mut().zip(s) { - *o = if v.is_finite() { *v as f32 } else { 0.0 }; - } - a -} - -/// A Mesh geometry with its polygons triangulated; `materials[k]` is the model-level material -/// index of the geometry's material slot `k`. -fn build_mesh(name: &str, geom: &FbxNode, materials: &[usize], with_normal_maps: &dyn Fn(usize) -> bool) -> Option { - let positions: Vec<[f32; 3]> = geom.child("Vertices")?.floats().as_chunks::<3>().0.iter().map(|c| f32s::<3>(c)).collect(); - let pvi = geom.child("PolygonVertexIndex")?.ints(); - let normals = LayerData::of(geom, "LayerElementNormal", "Normals", "NormalsIndex", 3); - let uvs = LayerData::of(geom, "LayerElementUV", "UV", "UVIndex", 2); - let mats = geom.child("LayerElementMaterial").and_then(|e| { - let mapping = e.child("MappingInformationType").and_then(|m| m.str_at(0)).unwrap_or("AllSame"); - let ids = e.child("Materials")?.ints(); - Some((mapping == "ByPolygon", ids)) - }); - - type Key = (u32, [u32; 3], [u32; 2]); - let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; - let mut corners: Vec<(usize, usize)> = vec![]; - let mut poly = 0usize; - for (pv, &raw) in pvi.iter().enumerate() { - // The last corner of a polygon is stored as `-index - 1`. - let (v, last) = if raw < 0 { (!raw, true) } else { (raw, false) }; - corners.push((pv, usize::try_from(v).unwrap_or(usize::MAX))); - if !last { - continue; - } - let poly_corners = std::mem::take(&mut corners); - let p_index = poly; - poly += 1; - if poly_corners.len() < 3 || poly_corners.iter().any(|&(_, v)| v >= positions.len()) { - continue; - } - let slot = mats.as_ref().and_then(|(by_poly, ids)| { - let k = if *by_poly { *ids.get(p_index)? } else { *ids.first()? }; - materials.get(usize::try_from(k).ok()?).copied() - }); - let slot = slot.or_else(|| materials.first().copied()); - let at = match prims.iter().position(|p| p.0 == slot) { - Some(i) => i, - None => { - prims.push((slot, Primitive { material: slot, ..Default::default() }, HashMap::new(), vec![])); - prims.len() - 1 - } - }; - let Some((_, prim, map, need)) = prims.get_mut(at) else { continue }; - let mut ids = Vec::with_capacity(poly_corners.len()); - for &(pv, v) in &poly_corners { - let n = normals.as_ref().and_then(|l| l.get(pv, v, p_index)).map(f32s::<3>); - let t = uvs.as_ref().and_then(|l| l.get(pv, v, p_index)).map(f32s::<2>); - let key: Key = (v as u32, n.unwrap_or([f32::NAN; 3]).map(f32::to_bits), t.unwrap_or([f32::NAN; 2]).map(f32::to_bits)); - let id = *map.entry(key).or_insert_with(|| { - prim.positions.push(positions.get(v).copied().unwrap_or_default()); - prim.normals.push(n.unwrap_or_default()); - prim.uvs.push(t.unwrap_or_default()); - need.push(n.is_none()); - (prim.positions.len() - 1) as u32 - }); - ids.push(id); - } - for i in 2..ids.len() { - prim.indices.extend([ids[0], ids[i - 1], ids[i]]); - } - } - let mut mesh = Mesh { name: name.to_string(), primitives: vec![] }; - for (_, mut p, _, need) in prims { - if p.indices.is_empty() { - continue; - } - if need.iter().any(|&b| b) { - let given = p.normals.clone(); - p.compute_normals(); - for ((n, g), need) in p.normals.iter_mut().zip(given).zip(&need) { - if !need { - *n = g; - } - } - } - if uvs.is_none() { - p.uvs.clear(); - } else { - // FBX's v axis points up the image; textures are stored top row first. - for uv in p.uvs.iter_mut() { - uv[1] = 1.0 - uv[1]; - } - } - if p.material.is_some_and(with_normal_maps) { - p.compute_tangents(); - } - mesh.primitives.push(p); - } - (!mesh.primitives.is_empty()).then_some(mesh) -} - -/// Lambert/Phong surface properties onto metallic-roughness. -fn material(name: &str, node: &FbxNode) -> Material { - let p = Props70::of(node); - let phong = node.child("ShadingModel").and_then(|s| s.str_at(0)).is_some_and(|s| s.eq_ignore_ascii_case("phong")); - let factor = p.f("DiffuseFactor").unwrap_or(1.0); - let d = p.v3("DiffuseColor", p.v3("Diffuse", [0.8; 3])); - let alpha = match p.f("Opacity") { - Some(o) => o, - None => { - let tc = p.v3("TransparentColor", [0.0; 3]); - 1.0 - (tc[0] + tc[1] + tc[2]) / 3.0 * p.f("TransparencyFactor").unwrap_or(0.0) - } - } - .clamp(0.0, 1.0); - let lin = |c: f64| srgb_to_linear((c * factor).clamp(0.0, 1.0) as f32); - let e = p.v3("EmissiveColor", [0.0; 3]); - let ef = p.f("EmissiveFactor").unwrap_or(1.0); - let roughness = if phong { - let ns = p.f("ShininessExponent").or_else(|| p.f("Shininess")).unwrap_or(20.0).max(0.0); - (2.0 / (ns + 2.0)).sqrt() as f32 - } else { - 1.0 - }; - Material { - name: name.to_string(), - base_color: [lin(d[0]), lin(d[1]), lin(d[2]), alpha as f32], - metallic: 0.0, - roughness, - emissive: e.map(|c| srgb_to_linear((c * ef).clamp(0.0, 1.0) as f32)), - alpha_mode: if alpha < 0.999 { AlphaMode::Blend } else { AlphaMode::Opaque }, - ..Material::default() - } -} - -/// Decode standard base64 (ASCII FBX embeds `Video` content this way); `None` on bad input. -fn base64(s: &str) -> Option> { - let mut out = Vec::with_capacity(s.len() / 4 * 3); - let (mut acc, mut bits) = (0u32, 0u32); - for c in s.bytes() { - let v = match c { - b'A'..=b'Z' => c - b'A', - b'a'..=b'z' => c - b'a' + 26, - b'0'..=b'9' => c - b'0' + 52, - b'+' => 62, - b'/' => 63, - b'=' => break, - c if c.is_ascii_whitespace() => continue, - _ => return None, - }; - acc = (acc << 6) | v as u32; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((acc >> bits) as u8); - acc &= (1 << bits) - 1; - } - } - Some(out) -} - -fn short_name(full: &str) -> &str { - full.split_once("::").map_or(full, |(_, n)| n) -} - -/// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. +/// Parse an FBX file; `resolve(path)` reads texture files the file names but does not embed. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - parse_within(bytes, resolve, MAX_IMPORT_BYTES) -} - -/// [`parse`] keeping at most `limit` bytes of arrays and meshes. -fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: usize) -> Result { - let budget = Budget::new(limit); - let (version, doc) = read_within(bytes, &budget)?; - if version != 0 && version < 7000 { - return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); + let limits = limits(); + let doc = craft_mesh::fbx::read_document(bytes, &limits).map_err(error)?; + let mut textures = |path: &str| resolve(path); + let scene = doc.scene(&limits, &mut textures).map_err(error)?; + if scene.meshes.is_empty() { + return Err(ModelError::Parse("FBX file without meshes".into())); } - let top = |name: &str| doc.iter().find(|n| n.name == name); - let objects = top("Objects").ok_or_else(|| perr("FBX: no Objects section"))?; - let by_id: HashMap = objects.children.iter().filter_map(|o| Some((o.prop(0)?.as_i64()?, o))).collect(); - let kind = |id: i64| by_id.get(&id).map(|n| n.name.as_str()); - - // Connections: (child, parent, property) in file order. - let mut links: Vec<(i64, i64, Option<&str>)> = vec![]; - if let Some(c) = top("Connections") { - for e in c.children_named("C") { - let (Some(a), Some(b)) = (e.prop(1).and_then(Prop::as_i64), e.prop(2).and_then(Prop::as_i64)) else { continue }; - links.push((a, b, e.str_at(3))); - } + let mut m = to_model(scene, MissingNormals::Smooth, &|_| None)?; + // The axes node goes first: every node index moves up by one. + for n in &mut m.nodes { + n.children.iter_mut().for_each(|c| *c += 1); } - - let model_ids: Vec = objects.children.iter().filter(|o| o.name == "Model").filter_map(|o| o.prop(0)?.as_i64()).collect(); - let mut node_of: HashMap = HashMap::new(); - let mut nodes = vec![Node { name: "FBX axes".into(), matrix: Some(axis_matrix(top("GlobalSettings"))), ..Node::default() }]; - for &id in &model_ids { - if node_of.contains_key(&id) { - continue; - } - let Some(o) = by_id.get(&id) else { continue }; - let name = short_name(o.str_at(1).unwrap_or("")).to_string(); - node_of.insert(id, nodes.len()); - nodes.push(Node { name, matrix: Some(model_local(&Props70::of(o))), ..Node::default() }); - } - - // Hierarchy: the first Model→Model link is the parent; anything else hangs off the root. - let mut parent: HashMap = HashMap::new(); - for &(c, p, _) in &links { - if let (Some(&ci), Some(&pi)) = (node_of.get(&c), node_of.get(&p)) - && ci != pi - { - parent.entry(ci).or_insert(pi); - } - } - for i in 1..nodes.len() { - let p = parent.get(&i).copied().unwrap_or(0); - if let Some(n) = nodes.get_mut(p) { - n.children.push(i); - } - } - - // Materials (in first-use order) and their textures. - let mut materials: Vec = vec![]; - let mut textures: Vec> = vec![]; - let mut material_of: HashMap = HashMap::new(); - let mut texture_of: HashMap> = HashMap::new(); - let mut load_texture = |tid: i64, textures: &mut Vec>| -> Option { - if let Some(t) = texture_of.get(&tid) { - return *t; - } - let t = (|| { - let tex = by_id.get(&tid)?; - let embedded = links.iter().filter(|l| l.1 == tid && kind(l.0) == Some("Video")).find_map(|l| { - let content = by_id.get(&l.0)?.child("Content")?; - match content.props.first()? { - Prop::Bytes(b) if !b.is_empty() => Some(b.clone()), - Prop::Str(_) => { - let s: String = content.props.iter().filter_map(Prop::as_str).collect(); - base64(&s).filter(|b| !b.is_empty()) - } - _ => None, - } - }); - let bytes = embedded.or_else(|| { - ["RelativeFilename", "FileName"].iter().find_map(|k| { - let f = tex.child(k)?.str_at(0)?.replace('\\', "/"); - resolve(&f).or_else(|| resolve(f.rsplit('/').next()?)) - }) - })?; - let t = Texture::decode(&bytes).ok()?; - textures.push(Arc::new(t)); - Some(textures.len() - 1) - })(); - texture_of.insert(tid, t); - t - }; - let mut material_index = |mid: i64, materials: &mut Vec, textures: &mut Vec>| -> Option { - if let Some(&m) = material_of.get(&mid) { - return Some(m); - } - let node = by_id.get(&mid)?; - let mut m = material(short_name(node.str_at(1).unwrap_or("")), node); - for &(tid, _, prop) in links.iter().filter(|l| l.1 == mid && kind(l.0) == Some("Texture")) { - let slot = match prop { - Some("DiffuseColor") => &mut m.base_color_tex, - Some("NormalMap") => &mut m.normal_tex, - Some("EmissiveColor") => &mut m.emissive_tex, - _ => continue, - }; - if slot.is_none() { - *slot = load_texture(tid, textures).map(|texture| TexRef { texture, uv_set: 0 }); - } - } - if m.emissive_tex.is_some() && m.emissive == [0.0; 3] { - m.emissive = [1.0; 3]; - } - materials.push(m); - material_of.insert(mid, materials.len() - 1); - Some(materials.len() - 1) - }; - - // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the - // same geometry with the same materials share one mesh rather than each building a copy. - let mut meshes: Vec = vec![]; - let mut mesh_of: HashMap<(i64, Vec), usize> = HashMap::new(); - for &id in &model_ids { - let Some(&ni) = node_of.get(&id) else { continue }; - let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; - let Some(geom) = by_id.get(&gid) else { continue }; - let mats: Vec = - links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); - let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); - let mesh_index = match mesh_of.get(&(gid, mats.clone())) { - Some(&i) => i, - None => { - let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); - let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; - budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?; - meshes.push(mesh); - mesh_of.insert((gid, mats), meshes.len() - 1); - meshes.len() - 1 - } - }; - let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); - match geo { - Some(matrix) => { - nodes.push(Node { name: format!("{name} geometry"), matrix: Some(matrix), mesh: Some(mesh_index), ..Node::default() }); - let gi = nodes.len() - 1; - if let Some(n) = nodes.get_mut(ni) { - n.children.push(gi); - } - } - None => { - if let Some(n) = nodes.get_mut(ni) { - n.mesh = Some(mesh_index); - } - } - } - } - if meshes.is_empty() { - return Err(perr("FBX file without meshes")); - } - Ok(Model { meshes, nodes, roots: vec![0], materials, textures, ..Model::default() }) + m.skins.iter_mut().flat_map(|s| s.joints.iter_mut()).for_each(|j| *j += 1); + m.animations.iter_mut().flat_map(|a| a.channels.iter_mut()).for_each(|c| c.node += 1); + let roots = m.roots.iter().map(|r| r + 1).collect(); + m.nodes.insert(0, Node { name: "FBX axes".into(), matrix: Some(mat4(&doc.axes_to_y_up())), children: roots, ..Node::default() }); + m.roots = vec![0]; + Ok(m) } #[cfg(test)] diff --git a/crates/model/src/fbx/tests.rs b/crates/model/src/fbx/tests.rs index 1ba1978ba..399b16e2d 100644 --- a/crates/model/src/fbx/tests.rs +++ b/crates/model/src/fbx/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::load; +use crate::{AlphaMode, load, vec3}; /// A Z-up scene: a Null parent with a mesh child (rotated, scaled, with a geometric offset) /// holding a quad and a triangle with a material each. @@ -131,116 +131,12 @@ fn check_scene(m: &Model) { assert!(close(lo, [8.0, 2.0, -2.0]) && close(hi, [10.0, 4.0, 0.0]), "{lo:?} {hi:?}"); } -/// A binary FBX of the same node tree (`a:` children become typed arrays). -fn write_binary(nodes: &[FbxNode], version: u32, compress: bool) -> Vec { - fn prop(out: &mut Vec, p: &Prop, compress: bool) { - let array = |out: &mut Vec, t: u8, len: usize, raw: Vec| { - out.push(t); - out.extend((len as u32).to_le_bytes()); - let data = if compress { miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6) } else { raw }; - out.extend((compress as u32).to_le_bytes()); - out.extend((data.len() as u32).to_le_bytes()); - out.extend(data); - }; - match p { - Prop::Int(i) => match i32::try_from(*i) { - Ok(v) => { - out.push(b'I'); - out.extend(v.to_le_bytes()); - } - Err(_) => { - out.push(b'L'); - out.extend(i.to_le_bytes()); - } - }, - Prop::Float(f) => { - out.push(b'D'); - out.extend(f.to_le_bytes()); - } - Prop::Str(s) => { - let s = match s.split_once("::") { - Some((class, name)) => format!("{name}\0\x01{class}"), - None => s.clone(), - }; - out.push(b'S'); - out.extend((s.len() as u32).to_le_bytes()); - out.extend(s.as_bytes()); - } - Prop::Bytes(b) => { - out.push(b'R'); - out.extend((b.len() as u32).to_le_bytes()); - out.extend(b); - } - Prop::Ints(v) => array(out, b'i', v.len(), v.iter().flat_map(|&i| (i as i32).to_le_bytes()).collect()), - Prop::Floats(v) => array(out, b'd', v.len(), v.iter().flat_map(|f| f.to_le_bytes()).collect()), - } - } - fn record(out: &mut Vec, n: &FbxNode, wide: bool, compress: bool) { - let w = if wide { 8 } else { 4 }; - let start = out.len(); - out.extend(vec![0u8; 3 * w]); - out.push(n.name.len() as u8); - out.extend(n.name.as_bytes()); - let props = match n.child("a") { - Some(a) if a.props.iter().all(|p| matches!(p, Prop::Int(_))) => vec![Prop::Ints(a.ints())], - Some(a) => vec![Prop::Floats(a.floats())], - None => n.props.clone(), - }; - let pstart = out.len(); - for p in &props { - prop(out, p, compress); - } - let plen = out.len() - pstart; - let kids: Vec<&FbxNode> = n.children.iter().filter(|c| c.name != "a").collect(); - if !kids.is_empty() { - for k in kids { - record(out, k, wide, compress); - } - out.extend(vec![0u8; 3 * w + 1]); - } - let end = out.len(); - for (k, v) in [end, props.len(), plen].into_iter().enumerate() { - let at = start + k * w; - if wide { - out[at..at + 8].copy_from_slice(&(v as u64).to_le_bytes()); - } else { - out[at..at + 4].copy_from_slice(&(v as u32).to_le_bytes()); - } - } - } - let wide = version >= 7500; - let mut out = BINARY_MAGIC.to_vec(); - out.extend(version.to_le_bytes()); - for n in nodes { - record(&mut out, n, wide, compress); - } - out.extend(vec![0u8; if wide { 25 } else { 13 }]); - // Footer bytes after the closing null record. - out.extend([0xfa; 16]); - out -} - #[test] fn ascii_fbx_scene() { let m = load("scene.fbx", ASCII.as_bytes(), &|_| None).unwrap(); check_scene(&m); } -#[test] -fn binary_fbx_matches_ascii() { - let (_, doc) = read(ASCII.as_bytes()).unwrap(); - let ascii = parse(ASCII.as_bytes(), &|_| None).unwrap(); - for (version, compress) in [(7400, false), (7400, true), (7500, true)] { - let bin = write_binary(&doc, version, compress); - let (v, nodes) = read(&bin).unwrap(); - assert_eq!(v, version); - assert_eq!(nodes.iter().map(|n| n.name.as_str()).collect::>(), ["FBXHeaderExtension", "GlobalSettings", "Objects", "Connections"]); - // Found by its magic even without the extension. - let m = load("scene.bin", &bin, &|_| None).unwrap(); - assert_eq!(m, ascii, "version {version}, compressed {compress}"); - } -} - #[test] fn embedded_and_relative_textures() { let png = { @@ -291,91 +187,6 @@ fn b64(b: &[u8]) -> String { s } -#[test] -fn hostile_fbx_files_fail_cleanly() { - let (_, doc) = read(ASCII.as_bytes()).unwrap(); - let bin = write_binary(&doc, 7500, true); - // Every truncation and every single-byte corruption returns (no panic, no runaway). - for n in 0..bin.len() { - let _ = parse(&bin[..n], &|_| None); - } - for i in 0..bin.len() { - let mut b = bin.clone(); - b[i] ^= 0xff; - let _ = parse(&b, &|_| None); - } - // An array claiming 4 G elements. - let mut huge = BINARY_MAGIC.to_vec(); - huge.extend(7400u32.to_le_bytes()); - let body: Vec = b"d".iter().copied().chain(u32::MAX.to_le_bytes()).chain(0u32.to_le_bytes()).chain(8u32.to_le_bytes()).chain([0u8; 8]).collect(); - let start = huge.len(); - let end = start + 13 + 1 + body.len(); - huge.extend((end as u32).to_le_bytes()); - huge.extend(1u32.to_le_bytes()); - huge.extend((body.len() as u32).to_le_bytes()); - huge.push(1); - huge.push(b'V'); - huge.extend(body); - assert!(read(&huge).is_err()); - // Deep nesting, both encodings. - assert!(read("A: {".repeat(100_000).as_bytes()).is_err()); - // Not an FBX scene at all, an old FBX version, and a scene without meshes. - assert!(parse(b"\x00\x01garbage", &|_| None).is_err()); - let v6 = "FBXHeaderExtension: {\n FBXVersion: 6100\n}\nObjects: {\n}\n"; - assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); - assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); -} - -/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type -/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each. -fn binary_arrays(arrays: &[(u8, usize)]) -> Vec { - let mut props = vec![]; - for &(t, len) in arrays { - let size = match t { - b'd' | b'l' => 8, - b'f' | b'i' => 4, - _ => 1, - }; - props.push(t); - props.extend((len as u32).to_le_bytes()); - props.extend(0u32.to_le_bytes()); - props.extend(((len * size) as u32).to_le_bytes()); - props.extend(std::iter::repeat_n(1u8, len * size)); - } - let mut out = BINARY_MAGIC.to_vec(); - out.extend(7400u32.to_le_bytes()); - let end = out.len() + 13 + 1 + props.len(); - out.extend((end as u32).to_le_bytes()); - out.extend((arrays.len() as u32).to_le_bytes()); - out.extend((props.len() as u32).to_le_bytes()); - out.push(1); - out.push(b'A'); - out.extend(props); - out -} - -#[test] -fn fbx_arrays_are_charged_for_the_memory_they_keep() { - // A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`: - // the budget counts the eight (#1234). - let n = 10_000; - let bin = binary_arrays(&[(b'b', n)]); - assert!(read_within(&bin, &Budget::new(n * 8)).is_ok()); - let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err(); - assert!(e.to_string().contains("memory limit"), "{e}"); - // 32-bit floats widen to f64 the same way. - assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err()); - // Several arrays each within the budget but not together. - let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]); - assert!(read_within(&bin, &Budget::new(n * 24)).is_ok()); - assert!(read_within(&bin, &Budget::new(n * 20)).is_err()); - // ASCII values count too. - let (_, doc) = read(ASCII.as_bytes()).unwrap(); - let values = value_bytes(&doc).unwrap(); - assert!(values > 45 * std::mem::size_of::(), "{values}"); - assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit")); -} - /// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials, /// `Red only` with just the first. fn instanced_scene() -> String { @@ -401,11 +212,94 @@ fn fbx_models_instancing_one_geometry_share_its_mesh() { assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh"); assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh"); assert_eq!(m.meshes.len(), 2); - // A budget with room for one copy of the geometry's mesh, but not three, still imports. - let one = parse(ASCII.as_bytes(), &|_| None).unwrap(); - let mesh = mesh_bytes(&one.meshes[0]).unwrap(); - let (_, doc) = read(instanced_scene().as_bytes()).unwrap(); - let values = value_bytes(&doc).unwrap(); - assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok()); - assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err()); +} + +#[test] +fn texture_aliases_of_one_image_share_its_pixels() { + let png = { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let objects = r#" + Texture: 600, "Texture::Base", "" { + RelativeFilename: "maps\base.png" + } + Texture: 700, "Texture::Base again", "" { + RelativeFilename: "maps\base.png" + } +} +Connections: {"#; + let doc = ASCII + .replace("}\nConnections: {", objects) + .replace("C: \"OO\",500,300\n", "C: \"OO\",500,300\n\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"DiffuseColor\"\n"); + let resolve = |uri: &str| (uri == "maps/base.png").then(|| png.clone()); + let m = parse(doc.as_bytes(), &resolve).unwrap(); + assert_eq!(m.textures.len(), 1); + assert!(m.materials.iter().all(|x| x.base_color_tex.map(|t| t.texture) == Some(0))); + assert!(m.warnings.is_empty()); +} + +/// A PNG that is only a header claiming `w`×`h` pixels (an empty data chunk follows). +fn png_header_only(w: u32, h: u32) -> Vec { + fn chunk(png: &mut Vec, ty: &[u8], data: &[u8]) { + let body = [ty, data].concat(); + let mut crc = 0xFFFF_FFFFu32; + for &b in &body { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + png.extend((data.len() as u32).to_be_bytes()); + png.extend(&body); + png.extend((!crc).to_be_bytes()); + } + let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + chunk(&mut png, b"IHDR", &[w.to_be_bytes(), h.to_be_bytes(), [8, 6, 0, 0]].concat().iter().copied().chain([0]).collect::>()); + chunk(&mut png, b"IDAT", &[]); + png +} + +#[test] +fn a_texture_past_the_limits_is_left_out_with_a_warning_not_a_failed_import() { + let objects = r#" + Texture: 600, "Texture::Base", "" { + RelativeFilename: "maps\base.png" + } +} +Connections: {"#; + let doc = ASCII.replace("}\nConnections: {", objects).replace("C: \"OO\",500,300\n", "C: \"OO\",500,300\n\tC: \"OP\",600,400, \"DiffuseColor\"\n"); + // 30000×30000 pixels would take ~14 GiB; the header alone is refused, before anything is allocated. + let huge = png_header_only(30000, 30000); + let resolve = |_: &str| Some(huge.clone()); + let m = parse(doc.as_bytes(), &resolve).unwrap(); + assert!(m.textures.is_empty() && m.materials.iter().all(|x| x.base_color_tex.is_none())); + assert!(m.warnings.iter().any(|w| w.contains("30000")), "{:?}", m.warnings); +} + +#[test] +fn texture_files_resolving_to_one_image_share_it() { + let png = { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let objects = r#" + Texture: 600, "Texture::Base", "" { + RelativeFilename: "maps\base.png" + } + Texture: 700, "Texture::Other spelling", "" { + RelativeFilename: "missing\base.png" + FileName: "maps\base.png" + } +} +Connections: {"#; + let doc = ASCII + .replace("}\nConnections: {", objects) + .replace("C: \"OO\",500,300\n", "C: \"OO\",500,300\n\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"DiffuseColor\"\n"); + let resolve = |uri: &str| (uri == "maps/base.png").then(|| png.clone()); + assert_eq!(parse(doc.as_bytes(), &resolve).unwrap().textures.len(), 1); } diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index c0b736359..68bc86764 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -1,687 +1,26 @@ -//! glTF 2.0 import, written from the Khronos glTF 2.0 specification. -//! -//! Supports `.gltf` (JSON with external `.bin`/image files or `data:` URIs) and `.glb` (binary -//! container: a JSON chunk and a BIN chunk). Imports the default scene's node hierarchy (TRS or -//! matrix), triangle primitives (`TRIANGLES`, `TRIANGLE_STRIP`, `TRIANGLE_FAN`) with POSITION, -//! NORMAL, TANGENT, TEXCOORD_0, JOINTS_0 and WEIGHTS_0, PBR metallic-roughness materials -//! (base colour, metallic-roughness, normal, occlusion and emissive textures; alpha modes; -//! double-sided; `KHR_materials_emissive_strength`, `KHR_materials_unlit`), embedded or -//! external PNG/JPEG images with sampler wrap modes, skins and animations (step, linear and -//! cubic-spline translation/rotation/scale), perspective and orthographic cameras, and -//! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets -//! are ignored. +//! glTF 2.0 import (`.gltf` with external or embedded buffers, `.glb`), read with `craft-mesh` +//! (`craft_mesh::gltf`): meshes, PBR metallic-roughness materials, the node hierarchy, skins, +//! animations, cameras and `KHR_lights_punctual` lights. External buffers and images are read +//! through the caller's resolver by their percent-decoded URI. Primitives without normals get +//! flat normals (glTF 2.0 §3.7.2.1); normal-mapped ones get tangents from their UVs. -use std::{cell::Cell, sync::Arc}; +use crate::scene::{MissingNormals, error, limits, to_model}; +use crate::{Model, Result}; -use serde_json::Value; +pub use craft_mesh::base64_decode; +pub use craft_mesh::gltf::{split_glb, uri_decode}; -use crate::{ - AlphaMode, Animation, Channel, ChannelPath, Interp, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, TexRef, Texture, Wrap, perr, -}; - -const GLB_MAGIC: u32 = 0x4654_6C67; -const CHUNK_JSON: u32 = 0x4E4F_534A; -const CHUNK_BIN: u32 = 0x004E_4942; - -/// Split a GLB container into its JSON text and BIN chunk. -pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> { - let u32_at = |o: usize| -> Option { b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) }; - if u32_at(0) != Some(GLB_MAGIC) { - return Err(perr("not a GLB file")); - } - if u32_at(4) != Some(2) { - return Err(ModelError::Unsupported(format!("GLB version {}", u32_at(4).unwrap_or(0)))); - } - let total = (u32_at(8).unwrap_or(0) as usize).min(b.len()); - let mut o = 12; - let mut json = None; - let mut bin = None; - while o + 8 <= total { - let len = u32_at(o).unwrap_or(0) as usize; - let ty = u32_at(o + 4).unwrap_or(0); - let past_end = || perr("GLB chunk runs past the end of the file"); - let end = (o + 8).checked_add(len).ok_or_else(past_end)?; - let data = b.get(o + 8..end).ok_or_else(past_end)?; - match ty { - CHUNK_JSON if json.is_none() => json = Some(data), - CHUNK_BIN if bin.is_none() => bin = Some(data), - _ => {} - } - o = end; - } - Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin)) -} - -/// Decode standard base64 (with or without padding; whitespace ignored). -pub fn base64_decode(s: &str) -> Option> { - let mut out = Vec::with_capacity(s.len() * 3 / 4); - let mut acc = 0u32; - let mut bits = 0; - for c in s.bytes() { - let v = match c { - b'A'..=b'Z' => c - b'A', - b'a'..=b'z' => c - b'a' + 26, - b'0'..=b'9' => c - b'0' + 52, - b'+' | b'-' => 62, - b'/' | b'_' => 63, - b'=' => break, - b' ' | b'\n' | b'\r' | b'\t' => continue, - _ => return None, - } as u32; - acc = (acc << 6) | v; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((acc >> bits) as u8); - acc &= (1 << bits) - 1; - } - } - Some(out) -} - -/// Percent-decode a relative URI. -pub fn uri_decode(s: &str) -> String { - let b = s.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && i + 2 < b.len() - && b[i + 1].is_ascii_hexdigit() - && b[i + 2].is_ascii_hexdigit() - && let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) - { - out.push(v); - i += 3; - continue; - } - out.push(b[i]); - i += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - -/// Bytes of a `data:` URI or an external resource. -fn load_uri(uri: &str, resolve: &dyn Fn(&str) -> Option>) -> Result> { - if let Some(rest) = uri.strip_prefix("data:") { - let (meta, data) = rest.split_once(',').ok_or_else(|| perr("malformed data URI"))?; - return if meta.ends_with(";base64") { base64_decode(data).ok_or_else(|| perr("bad base64 in data URI")) } else { Ok(uri_decode(data).into_bytes()) }; - } - let name = uri_decode(uri); - resolve(&name).ok_or(ModelError::Missing(name)) -} - -struct Doc<'a> { - j: &'a Value, - buffers: Vec>, - /// Zero components that buffer-less accessors may still allocate, across the whole file: - /// nothing backs them, so many references to one capped accessor must not multiply (#2139). - zero_left: Cell, - /// Components every accessor read may still allocate, across the whole file: each primitive - /// that references an accessor keeps its own copy, so references must not multiply memory. - read_left: Cell, - /// The budget `read_left` started from, for the error message. - read_cap: usize, -} - -fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { - v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) -} -/// A non-negative integer field; one too large for `usize` counts as absent (#1235). -fn idx(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()) -} -/// Node indices in an array field (out-of-range ones are dropped). -fn indices(v: &Value, k: &str) -> Vec { - arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect() -} - -/// The most components accessors without a buffer view (all zeros) may have, together: nothing -/// in the file backs their counts, so they are capped instead of checked against a buffer -/// (#1235, #2139). -const MAX_ZERO_COMPONENTS: usize = 1 << 24; - -/// The most components all accessor reads in one file may allocate together (about 1 GiB as -/// `f32`/`u32`). Each primitive keeps its own copy of what it reads, so an accessor referenced -/// N times costs N times; this bounds that by a fixed amount rather than by buffer size, because -/// real assets legitimately share one vertex buffer across many primitives. A 10M-triangle scan -/// (5M vertices with position, normal, UV and tangent, 30M indices) reads about 90M. -const MAX_READ_COMPONENTS: usize = 1 << 28; - -/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride` -/// apart from `base`, all checked to be inside `buf`. -struct Span<'b> { - buf: &'b [u8], - base: usize, - stride: usize, -} -fn num(v: &Value, k: &str, d: f64) -> f64 { - v.get(k).and_then(Value::as_f64).unwrap_or(d) -} -fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { - let mut out = d; - if let Some(a) = v.get(k).and_then(Value::as_array) { - for (i, x) in a.iter().take(N).enumerate() { - out[i] = x.as_f64().unwrap_or(d[i]); - } - } - out -} - -/// Bytes of component `c` of element `i`, with checked offsets (the span was validated, but -/// nothing derived from the file is indexed unchecked). -fn component(buf: &[u8], base: usize, stride: usize, i: usize, c: usize, csize: usize) -> Result<&[u8]> { - let o = i.checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(c.checked_mul(csize)?)); - o.and_then(|o| buf.get(o..o.checked_add(csize)?)).ok_or_else(|| perr("accessor reads past its buffer")) -} - -impl Doc<'_> { - /// Charge `total` components to the file's read budget before allocating them. - fn charge(&self, total: usize) -> Result<()> { - let left = self.read_left.get(); - let Some(rest) = left.checked_sub(total) else { - let cap = self.read_cap; - return Err(perr(format!("accessor reads need more than {cap} components in total ({total} requested, {left} left)"))); - }; - self.read_left.set(rest); - Ok(()) - } - - /// Charge `count` items of `per` components each. - fn charge_each(&self, count: usize, per: usize) -> Result<()> { - self.charge(count.checked_mul(per).ok_or_else(|| perr("primitive is too large"))?) - } - - /// The accessor's element count and total components (`count * n`), checked; and its span in - /// the buffer, validated before anything is allocated from the count. `None` for an - /// accessor without a buffer view, whose components are all zero. - fn layout(&self, acc: &Value, n: usize, csize: usize) -> Result<(usize, usize, Option>)> { - let count = match acc.get("count") { - None => 0, - Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| perr("accessor count is not a valid size"))?, - }; - let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?; - let Some(bv) = idx(acc, "bufferView") else { - let left = self.zero_left.get(); - let Some(rest) = left.checked_sub(total) else { - return Err(perr(format!("accessors without a bufferView have more than {MAX_ZERO_COMPONENTS} components ({total} requested, {left} left)"))); - }; - self.zero_left.set(rest); - self.charge(total)?; - return Ok((count, total, None)); - }; - let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; - let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; - let past = || perr("accessor reads past its buffer"); - let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?; - let elem = csize.checked_mul(n).ok_or_else(past)?; - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); - if stride < elem { - return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements"))); - } - if count > 0 { - let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(elem)).ok_or_else(past)?; - if end > buf.len() { - return Err(past()); - } - } - self.charge(total)?; - Ok((count, total, Some(Span { buf, base, stride }))) - } - - /// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1). - fn floats(&self, a: usize) -> Result<(Vec, usize)> { - let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?; - let n = match acc.get("type").and_then(Value::as_str).unwrap_or("") { - "SCALAR" => 1, - "VEC2" => 2, - "VEC3" => 3, - "VEC4" => 4, - "MAT2" => 4, - "MAT3" => 9, - "MAT4" => 16, - t => return Err(perr(format!("accessor type `{t}`"))), - }; - let ct = idx(acc, "componentType").unwrap_or(5126); - let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false); - let csize = match ct { - 5120 | 5121 => 1, - 5122 | 5123 => 2, - 5125 | 5126 => 4, - _ => return Err(perr(format!("componentType {ct}"))), - }; - let (count, total, span) = self.layout(acc, n, csize)?; - let mut out = vec![0.0f32; total]; - let Some(Span { buf, base, stride }) = span else { - // No buffer view: all zeros (sparse substitution is not supported). - return Ok((out, n)); - }; - // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). - for i in 0..count { - for c in 0..n { - let s = component(buf, base, stride, i, c, csize)?; - out[i * n + c] = match ct { - 5126 => f32::from_le_bytes([s[0], s[1], s[2], s[3]]), - 5125 => u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32, - 5123 => { - let v = u16::from_le_bytes([s[0], s[1]]) as f32; - if norm { v / 65535.0 } else { v } - } - 5122 => { - let v = i16::from_le_bytes([s[0], s[1]]) as f32; - if norm { (v / 32767.0).max(-1.0) } else { v } - } - 5121 => { - let v = s[0] as f32; - if norm { v / 255.0 } else { v } - } - _ => { - let v = s[0] as i8 as f32; - if norm { (v / 127.0).max(-1.0) } else { v } - } - }; - } - } - Ok((out, n)) - } - - /// Integer accessor (indices, joints) read exactly. - fn ints(&self, a: usize) -> Result> { - let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?; - let ct = idx(acc, "componentType").unwrap_or(5125); - if ct == 5126 { - return Ok(self.floats(a)?.0.into_iter().map(|v| v as u32).collect()); - } - let n = match acc.get("type").and_then(Value::as_str).unwrap_or("SCALAR") { - "VEC4" => 4, - "VEC3" => 3, - "VEC2" => 2, - _ => 1, - }; - let csize = match ct { - 5120 | 5121 => 1, - 5122 | 5123 => 2, - _ => 4, - }; - let (count, total, span) = self.layout(acc, n, csize)?; - let Some(Span { buf, base, stride }) = span else { return Ok(vec![0; total]) }; - let mut out = Vec::with_capacity(total); - for i in 0..count { - for c in 0..n { - let s = component(buf, base, stride, i, c, csize)?; - out.push(match csize { - 1 => s[0] as u32, - 2 => u16::from_le_bytes([s[0], s[1]]) as u32, - _ => u32::from_le_bytes([s[0], s[1], s[2], s[3]]), - }); - } - } - Ok(out) - } -} - -/// Parse a `.gltf` (JSON) or `.glb` file. +/// Parse a `.gltf` (JSON) or `.glb` file; `resolve(uri)` reads external buffers and images. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - parse_with_budget(bytes, resolve, MAX_READ_COMPONENTS) -} - -/// [`parse`] with a read budget of `read_budget` accessor components for the whole file. -pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, read_budget: usize) -> Result { - let (json, bin) = if bytes.starts_with(b"glTF") { split_glb(bytes)? } else { (bytes, None) }; - let j: Value = serde_json::from_slice(json).map_err(|e| perr(format!("glTF JSON: {e}")))?; - let ver = j.get("asset").and_then(|a| a.get("version")).and_then(Value::as_str).unwrap_or(""); - if !ver.starts_with('2') { - return Err(ModelError::Unsupported(format!("glTF version `{ver}` (2.0 required)"))); - } - for ext in arr(&j, "extensionsRequired") { - let e = ext.as_str().unwrap_or(""); - if !matches!(e, "KHR_materials_unlit" | "KHR_materials_emissive_strength") { - return Err(ModelError::Unsupported(format!("required extension {e}"))); - } - } - let mut buffers = vec![]; - for (i, b) in arr(&j, "buffers").iter().enumerate() { - let data = match b.get("uri").and_then(Value::as_str) { - Some(u) => load_uri(u, resolve)?, - None if i == 0 => bin.map(<[u8]>::to_vec).ok_or_else(|| perr("buffer 0 has no uri and the file has no BIN chunk"))?, - None => vec![], - }; - buffers.push(data); - } - let d = Doc { j: &j, buffers, zero_left: Cell::new(MAX_ZERO_COMPONENTS), read_left: Cell::new(read_budget), read_cap: read_budget }; - - // Images → textures (decoded once per image; samplers set the wrap modes). - let mut images: Vec>> = vec![]; - for im in arr(&j, "images") { - let data = if let Some(u) = im.get("uri").and_then(Value::as_str) { - load_uri(u, resolve).ok() - } else if let Some(bv) = idx(im, "bufferView") { - arr(&j, "bufferViews").get(bv).and_then(|v| { - let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; - let o = idx(v, "byteOffset").unwrap_or(0); - b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) - }) - } else { - None - }; - images.push(data.and_then(|b| Texture::decode(&b).ok()).map(Arc::new)); - } - let wrap = |v: u64| match v { - 33071 => Wrap::Clamp, - 33648 => Wrap::Mirror, - _ => Wrap::Repeat, - }; - let mut textures: Vec> = vec![]; - let mut tex_map: Vec> = vec![]; - for t in arr(&j, "textures") { - let Some(img) = idx(t, "source").and_then(|s| images.get(s).cloned().flatten()) else { - tex_map.push(None); - continue; - }; - let samp = idx(t, "sampler").and_then(|s| arr(&j, "samplers").get(s)); - let ws = samp.and_then(|s| s.get("wrapS")).and_then(Value::as_u64).unwrap_or(10497); - let wt = samp.and_then(|s| s.get("wrapT")).and_then(Value::as_u64).unwrap_or(10497); - let w = [wrap(ws), wrap(wt)]; - let tex = if img.wrap == w { img } else { Arc::new(Texture { wrap: w, ..(*img).clone() }) }; - tex_map.push(Some(textures.len())); - textures.push(tex); - } - let tref = |v: Option<&Value>| -> Option { - let v = v?; - let t = tex_map.get(idx(v, "index")?).copied().flatten()?; - Some(TexRef { texture: t, uv_set: idx(v, "texCoord").unwrap_or(0) as u32 }) - }; - - let mut materials = vec![]; - for m in arr(&j, "materials") { - let pbr = m.get("pbrMetallicRoughness").cloned().unwrap_or(Value::Null); - let bc = nums(&pbr, "baseColorFactor", [1.0; 4]); - let ext = m.get("extensions"); - let strength = ext.and_then(|e| e.get("KHR_materials_emissive_strength")).map_or(1.0, |e| num(e, "emissiveStrength", 1.0)); - let em = nums(m, "emissiveFactor", [0.0; 3]); - materials.push(Material { - name: m.get("name").and_then(Value::as_str).unwrap_or("").to_string(), - base_color: bc.map(|v| v as f32), - base_color_tex: tref(pbr.get("baseColorTexture")), - metallic: num(&pbr, "metallicFactor", 1.0) as f32, - roughness: num(&pbr, "roughnessFactor", 1.0) as f32, - metallic_roughness_tex: tref(pbr.get("metallicRoughnessTexture")), - normal_tex: tref(m.get("normalTexture")), - normal_scale: m.get("normalTexture").map_or(1.0, |n| num(n, "scale", 1.0)) as f32, - occlusion_tex: tref(m.get("occlusionTexture")), - occlusion_strength: m.get("occlusionTexture").map_or(1.0, |n| num(n, "strength", 1.0)) as f32, - emissive: [(em[0] * strength) as f32, (em[1] * strength) as f32, (em[2] * strength) as f32], - emissive_tex: tref(m.get("emissiveTexture")), - alpha_mode: match m.get("alphaMode").and_then(Value::as_str) { - Some("MASK") => AlphaMode::Mask, - Some("BLEND") => AlphaMode::Blend, - _ => AlphaMode::Opaque, - }, - alpha_cutoff: num(m, "alphaCutoff", 0.5) as f32, - double_sided: m.get("doubleSided").and_then(Value::as_bool).unwrap_or(false), - unlit: ext.and_then(|e| e.get("KHR_materials_unlit")).is_some(), - }); - } - - let mut meshes = vec![]; - for m in arr(&j, "meshes") { - let mut mesh = Mesh { name: m.get("name").and_then(Value::as_str).unwrap_or("").to_string(), primitives: vec![] }; - for p in arr(m, "primitives") { - if let Some(prim) = primitive(&d, p, &materials)? { - mesh.primitives.push(prim); - } - } - meshes.push(mesh); - } - - let mut nodes = vec![]; - for n in arr(&j, "nodes") { - let matrix = n.get("matrix").and_then(Value::as_array).filter(|a| a.len() == 16).map(|a| { - // Column-major in the file. - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); - let mut m = [[0.0; 4]; 4]; - for c in 0..4 { - for r in 0..4 { - m[r][c] = v[c * 4 + r]; - } - } - Mat4(m) - }); - nodes.push(Node { - name: n.get("name").and_then(Value::as_str).unwrap_or("").to_string(), - translation: nums(n, "translation", [0.0; 3]), - rotation: nums(n, "rotation", [0.0, 0.0, 0.0, 1.0]), - scale: nums(n, "scale", [1.0; 3]), - matrix, - mesh: idx(n, "mesh").filter(|&m| m < meshes.len()), - skin: idx(n, "skin"), - camera: idx(n, "camera"), - light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")), - children: indices(n, "children"), - }); - } - - let scene = idx(&j, "scene").unwrap_or(0); - let roots: Vec = match arr(&j, "scenes").get(scene) { - Some(s) => indices(s, "nodes"), - None => { - // No scene: every node that is nobody's child. - let mut child = vec![false; nodes.len()]; - for n in &nodes { - for &c in &n.children { - if c < child.len() { - child[c] = true; - } - } - } - (0..nodes.len()).filter(|&i| !child[i]).collect() - } - }; - - let mut skins = vec![]; - for s in arr(&j, "skins") { - let joints: Vec = indices(s, "joints"); - let inverse_bind = match idx(s, "inverseBindMatrices") { - Some(a) => { - let (f, _) = d.floats(a)?; - f.as_chunks::<16>() - .0 - .iter() - .map(|v| { - let mut m = [[0.0; 4]; 4]; - for c in 0..4 { - for r in 0..4 { - m[r][c] = v[c * 4 + r] as f64; - } - } - Mat4(m) - }) - .collect() - } - None => vec![Mat4::IDENTITY; joints.len()], - }; - skins.push(Skin { joints, inverse_bind }); - } - - let mut animations = vec![]; - for a in arr(&j, "animations") { - let samplers = arr(a, "samplers"); - let mut anim = Animation { name: a.get("name").and_then(Value::as_str).unwrap_or("").to_string(), channels: vec![] }; - for c in arr(a, "channels") { - let Some(t) = c.get("target") else { continue }; - let Some(node) = idx(t, "node") else { continue }; - let path = match t.get("path").and_then(Value::as_str) { - Some("translation") => ChannelPath::Translation, - Some("rotation") => ChannelPath::Rotation, - Some("scale") => ChannelPath::Scale, - _ => continue, - }; - let Some(s) = idx(c, "sampler").and_then(|s| samplers.get(s)) else { continue }; - let interp = match s.get("interpolation").and_then(Value::as_str) { - Some("STEP") => Interp::Step, - Some("CUBICSPLINE") => Interp::CubicSpline, - _ => Interp::Linear, - }; - let (Some(input), Some(output)) = (idx(s, "input"), idx(s, "output")) else { continue }; - let (times, _) = d.floats(input)?; - let (values, _) = d.floats(output)?; - anim.channels.push(Channel { node, path, interp, times, values }); - } - animations.push(anim); - } - - let cameras = arr(&j, "cameras").iter().enumerate().map(|(i, c)| camera(c, i)).collect(); - let lights = j - .get("extensions") - .and_then(|e| e.get("KHR_lights_punctual")) - .map(|e| arr(e, "lights").iter().enumerate().map(|(i, l)| light(l, i)).collect()) - .unwrap_or_default(); - Ok(Model { meshes, nodes, roots, materials, textures, skins, animations, cameras, lights }) -} - -fn name_or(v: &Value, d: String) -> String { - v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string).unwrap_or(d) -} - -/// A glTF camera (glTF 2.0 §5.12): `perspective` {yfov, aspectRatio?, znear} or -/// `orthographic` {xmag, ymag}. -fn camera(c: &Value, i: usize) -> crate::ModelCamera { - let projection = match (c.get("type").and_then(Value::as_str), c.get("orthographic")) { - (Some("orthographic"), Some(o)) => crate::CameraProjection::Orthographic { xmag: num(o, "xmag", 1.0), ymag: num(o, "ymag", 1.0) }, - _ => { - let p = c.get("perspective").cloned().unwrap_or(Value::Null); - crate::CameraProjection::Perspective { - yfov: num(&p, "yfov", 0.8).clamp(1e-3, 3.1), - aspect: p.get("aspectRatio").and_then(Value::as_f64), - znear: num(&p, "znear", 0.01), - } - } - }; - crate::ModelCamera { name: name_or(c, format!("Camera {}", i + 1)), projection } -} - -/// A `KHR_lights_punctual` light: type, color, intensity, range, spot cone angles. -fn light(l: &Value, i: usize) -> crate::ModelLight { - let kind = match l.get("type").and_then(Value::as_str) { - Some("directional") => crate::ModelLightKind::Directional, - Some("spot") => { - let s = l.get("spot").cloned().unwrap_or(Value::Null); - let outer = num(&s, "outerConeAngle", std::f64::consts::FRAC_PI_4).clamp(1e-3, std::f64::consts::FRAC_PI_2); - crate::ModelLightKind::Spot { inner: num(&s, "innerConeAngle", 0.0).clamp(0.0, outer), outer } - } - _ => crate::ModelLightKind::Point, - }; - crate::ModelLight { - name: name_or(l, format!("Light {}", i + 1)), - kind, - color: nums(l, "color", [1.0; 3]), - intensity: num(l, "intensity", 1.0).max(0.0), - range: l.get("range").and_then(Value::as_f64), - } -} - -fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result> { - let mode = idx(p, "mode").unwrap_or(4); - if !matches!(mode, 4..=6) { - return Ok(None); - } - let attrs = p.get("attributes").ok_or_else(|| perr("primitive without attributes"))?; - let Some(pa) = idx(attrs, "POSITION") else { return Ok(None) }; - let (pos, _) = d.floats(pa)?; - let positions: Vec<[f32; 3]> = pos.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(); - let n = positions.len(); - let vec3s = |a: Option| -> Result> { - Ok(match a { - Some(a) => d.floats(a)?.0.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(), - None => vec![], - }) - }; - let mut normals = vec3s(idx(attrs, "NORMAL"))?; - let uvs: Vec<[f32; 2]> = match idx(attrs, "TEXCOORD_0") { - Some(a) => d.floats(a)?.0.as_chunks::<2>().0.iter().map(|c| [c[0], c[1]]).collect(), - None => vec![], - }; - let tangents: Vec<[f32; 4]> = match idx(attrs, "TANGENT") { - Some(a) => d.floats(a)?.0.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect(), - None => vec![], - }; - let joints: Vec<[u16; 4]> = match idx(attrs, "JOINTS_0") { - Some(a) => d.ints(a)?.as_chunks::<4>().0.iter().map(|c| [c[0] as u16, c[1] as u16, c[2] as u16, c[3] as u16]).collect(), - None => vec![], - }; - let weights: Vec<[f32; 4]> = match idx(attrs, "WEIGHTS_0") { - Some(a) => d.floats(a)?.0.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect(), - None => vec![], - }; - let raw: Vec = match idx(p, "indices") { - Some(a) => d.ints(a)?, - None => { - // Generated indices are allocated like a read, once per referencing primitive. - d.charge(n)?; - (0..n as u32).collect() - } - }; - if raw.iter().any(|&i| i as usize >= n) { - return Err(perr("index out of range")); - } - let mut indices = Vec::with_capacity(raw.len()); - match mode { - 5 => { - for i in 2..raw.len() { - let (a, b, c) = (raw[i - 2], raw[i - 1], raw[i]); - if i % 2 == 0 { indices.extend([a, b, c]) } else { indices.extend([b, a, c]) } - } - } - 6 => { - for i in 2..raw.len() { - indices.extend([raw[0], raw[i - 1], raw[i]]); - } - } - _ => indices.extend(raw.as_chunks::<3>().0.iter().flatten()), - } - // The triangle list is kept besides the raw indices (a strip or fan of k indices becomes - // 3(k-2)), so it is charged like a read. - d.charge(indices.len())?; - if normals.len() != n { - normals.clear(); - } - let material = idx(p, "material").filter(|&m| m < materials.len()); - let skinned = joints.len() == n && weights.len() == n; - if normals.is_empty() { - // Flat normals unshare every vertex: position and normal, plus UVs and joints/weights. - let per = 6 + if uvs.len() == n { 2 } else { 0 } + if skinned { 8 } else { 0 }; - d.charge_each(indices.len(), per)?; - } - let mut prim = Primitive { - positions, - normals, - uvs: if uvs.len() == n { uvs } else { vec![] }, - tangents: if tangents.len() == n { tangents } else { vec![] }, - joints: if skinned { joints } else { vec![] }, - weights: if skinned { weights } else { vec![] }, - indices, - material, - }; - if prim.normals.is_empty() { - prim.flat_normals(); - prim.tangents.clear(); - } - let needs_tangents = material.is_some_and(|m| materials[m].normal_tex.is_some()); - if needs_tangents && prim.tangents.is_empty() { - d.charge_each(prim.positions.len(), 4)?; - prim.compute_tangents(); - } - Ok(Some(prim)) -} - -#[cfg(test)] -mod tests { - use super::uri_decode; - - #[test] - fn uri_decode_rejects_signed_hex_pair() { - assert_eq!(uri_decode("a%+1b"), "a%+1b"); - assert_eq!(uri_decode("a%-1b"), "a%-1b"); - assert_eq!(uri_decode("a%41b"), "aAb"); - } + let mut buffers = |uri: &str| resolve(uri); + let scene = craft_mesh::gltf::read(bytes, &limits(), &mut buffers).map_err(error)?; + to_model(scene, MissingNormals::Flat, &|im| { + let uri = im.uri.as_deref()?; + match uri.strip_prefix("data:") { + Some(_) => None, + // craft-mesh decodes external image URIs while constructing the scene. Decode only + // once here; a second pass changes literal percent escapes in valid filenames. + None => resolve(uri), + } + }) } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 19a0d37f3..0f25570eb 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -1,12 +1,14 @@ //! 3D models for the Advanced 3D renderer. //! -//! - [`gltf`]: glTF 2.0 import (`.gltf` with external or embedded buffers, `.glb`), written from -//! the Khronos glTF 2.0 specification: meshes, PBR metallic-roughness materials (base colour, -//! metallic-roughness, normal, occlusion and emissive maps), the node hierarchy, skins and -//! animations. +//! The file formats are read with `craft-mesh` (storytold/craft-libs); this crate maps its +//! scene onto [`Model`], decodes the textures and adds the normals and tangents a file leaves out. +//! +//! - [`gltf`]: glTF 2.0 import (`.gltf` with external or embedded buffers, `.glb`): meshes, PBR +//! metallic-roughness materials (base colour, metallic-roughness, normal, occlusion and +//! emissive maps), the node hierarchy, skins, animations, cameras and lights. //! - [`obj`]: Wavefront OBJ geometry with MTL materials (mapped onto the metallic-roughness model). //! - [`stl`]: STL meshes (binary and ASCII, with the binary per-facet and part colours). -//! - [`fbx`]: FBX 7.x scenes, binary and ASCII: meshes, the Model hierarchy, Lambert/Phong +//! - [`fbx`]: FBX 6.x and 7.x scenes, binary and ASCII: meshes, the Model hierarchy, Lambert/Phong //! materials and their textures. //! - [`prim`]: parametric primitives (cube, sphere, plane, torus, cone, cylinder). //! - [`extrude`]: extruded, bevelled meshes from 2D outlines (text and shape layers), with @@ -22,6 +24,7 @@ pub mod fbx; pub mod gltf; pub mod obj; pub mod prim; +mod scene; pub mod stl; pub mod triangulate; @@ -46,7 +49,7 @@ pub(crate) fn perr(s: impl Into) -> ModelError { } /// Texture coordinate wrapping (glTF sampler wrap modes). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Wrap { #[default] Repeat, @@ -64,14 +67,40 @@ pub struct Texture { pub wrap: [Wrap; 2], } +/// Most pixels one texture may hold (8192 × 8192: 1 GiB as float RGBA). +pub const MAX_TEXTURE_PIXELS: u64 = 1 << 26; +/// Most pixels the textures of one import may hold together (8 GiB as float RGBA: thirty-two 4K +/// maps). A texture past it is left out and the import says so (`Model::warnings`). +pub const MAX_IMPORT_TEXTURE_PIXELS: u64 = 1 << 29; + impl Texture { pub fn solid(c: [f32; 4]) -> Texture { Texture { width: 1, height: 1, data: vec![c], wrap: [Wrap::Repeat; 2] } } + /// The pixel size an image claims, read from its header without decoding it. + pub fn dimensions(bytes: &[u8]) -> Result<(u32, u32)> { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| perr(format!("image: {e}")))?; + reader.into_dimensions().map_err(|e| perr(format!("image: {e}"))) + } /// Decode a PNG or JPEG. pub fn decode(bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| perr(format!("image: {e}")))?.to_rgba32f(); + Self::decode_within(bytes, &mut MAX_IMPORT_TEXTURE_PIXELS.clone()) + } + /// [`decode`](Self::decode), of at most [`MAX_TEXTURE_PIXELS`], taking the pixels from `pixels_left`, an import's remaining + /// texture budget, before the image is decoded. + pub fn decode_within(bytes: &[u8], pixels_left: &mut u64) -> Result { + let err = |e: image::ImageError| perr(format!("image: {e}")); + let (w, h) = Self::dimensions(bytes)?; + let pixels = w as u64 * h as u64; + if pixels > MAX_TEXTURE_PIXELS { + return Err(ModelError::Unsupported(format!("a {w}×{h} texture (at most {MAX_TEXTURE_PIXELS} pixels)"))); + } + if pixels > *pixels_left { + return Err(ModelError::Unsupported(format!("a {w}×{h} texture: the model's textures would hold more than {MAX_IMPORT_TEXTURE_PIXELS} pixels"))); + } + let img = image::load_from_memory(bytes).map_err(err)?.to_rgba32f(); let (w, h) = (img.width(), img.height()); + *pixels_left -= pixels; let data = img.pixels().map(|p| p.0).collect(); Ok(Texture { width: w, height: h, data, wrap: [Wrap::Repeat; 2] }) } @@ -201,6 +230,9 @@ impl Primitive { if !self.uvs.is_empty() { p.uvs.push(self.uvs[i]); } + if !self.tangents.is_empty() { + p.tangents.push(self.tangents[i]); + } if !self.joints.is_empty() { p.joints.push(self.joints[i]); p.weights.push(self.weights[i]); @@ -512,6 +544,8 @@ pub struct Model { pub cameras: Vec, /// Punctual lights embedded in the file (glTF `KHR_lights_punctual`). pub lights: Vec, + /// Parts of the file the import left out (textures over the limits), for the user. + pub warnings: Vec, } /// A camera embedded in a model file. @@ -570,8 +604,9 @@ pub struct Instance<'m> { pub mesh: &'m Mesh, /// Node → model space. pub world: Mat4, - /// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`). - pub joints: Option>, + /// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`), shared by + /// the instances of one skin. + pub joints: Option>>, } impl Model { @@ -622,6 +657,8 @@ impl Model { let mut seen = vec![false; self.nodes.len()]; let mut stack: Vec = self.roots.clone(); let mut out = vec![]; + // The pose is one for the whole scene, so every instance of a skin shares its matrices. + let mut joints_of: std::collections::HashMap>> = std::collections::HashMap::new(); while let Some(i) = stack.pop() { if i >= self.nodes.len() || seen[i] { continue; @@ -629,12 +666,19 @@ impl Model { seen[i] = true; let n = &self.nodes[i]; if let Some(m) = n.mesh.and_then(|m| self.meshes.get(m)) { - let joints = n.skin.and_then(|s| self.skins.get(s)).map(|s| { - s.joints - .iter() - .enumerate() - .map(|(k, &j)| world.get(j).copied().unwrap_or(Mat4::IDENTITY) * s.inverse_bind.get(k).copied().unwrap_or(Mat4::IDENTITY)) - .collect() + let joints = n.skin.and_then(|si| self.skins.get(si).map(|s| (si, s))).map(|(si, s)| { + joints_of + .entry(si) + .or_insert_with(|| { + Arc::new( + s.joints + .iter() + .enumerate() + .map(|(k, &j)| world.get(j).copied().unwrap_or(Mat4::IDENTITY) * s.inverse_bind.get(k).copied().unwrap_or(Mat4::IDENTITY)) + .collect(), + ) + }) + .clone() }); // Skinned vertices are placed by their joints alone (glTF: the node transform of // a skinned mesh is ignored). diff --git a/crates/model/src/obj.rs b/crates/model/src/obj.rs index 22d9877a2..c305cf23f 100644 --- a/crates/model/src/obj.rs +++ b/crates/model/src/obj.rs @@ -1,33 +1,21 @@ -//! Wavefront OBJ geometry and MTL materials. +//! Wavefront OBJ geometry and MTL materials, read with `craft-mesh` (`craft_mesh::obj`). //! -//! OBJ: `v` (optional vertex colours ignored), `vt`, `vn`, `f` with `v`, `v/vt`, `v//vn`, -//! `v/vt/vn` references (negative = relative), polygons fan-triangulated, `o`/`g` groups, `s` -//! smoothing (normals computed per smoothing state when a face has none), `usemtl`, `mtllib`. -//! -//! MTL materials map onto metallic-roughness: `Kd` → base colour, `d`/`Tr` → alpha, `Ke` → -//! emissive, `Pr`/`Pm` (the PBR extension) → roughness/metallic, otherwise roughness from the -//! Phong exponent `Ns` (`sqrt(2 / (Ns + 2))`) and metallic 0; `map_Kd`, `map_Ke`, -//! `map_Bump`/`bump`/`norm` load base colour, emissive and normal textures. +//! The material library and the textures it names are read through the caller's resolver: a +//! texture is looked up by the name as written (a name containing spaces is one name), then by +//! its file name (an absolute path from another machine, #1274), then by the final token (the +//! options convention, `-bm 1 file.png`). Faces without normals get smooth normals within a +//! smoothing group and flat ones where smoothing is off. -use std::collections::HashMap; -use std::sync::Arc; +use crate::scene::{MissingNormals, error, limits, to_model}; +use crate::{Model, Result}; -use crate::{AlphaMode, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; +pub use craft_mesh::srgb_to_linear; -/// The last component of a referenced path, treating `\` as a separator too: a path from a -/// machine of another kind (`C:\textures\checker.png`) still finds `checker.png` next to the -/// model (#1274). `None` when the name carries no path at all. -fn sibling_name(name: &str) -> Option<&str> { - let file = name.rsplit(['/', '\\']).next()?; - (!file.is_empty() && file.len() < name.len()).then_some(file) -} - -/// A material reference resolved against the model's folder: the name as written (a name -/// containing spaces is one name), then its file name (an absolute path from another machine, -/// #1274), then the final token (the options convention, `-bm 1 file.png`). +/// A material reference resolved against the model's folder: the name as written, then its file +/// name, then the final token. fn resolve_reference(resolve: &dyn Fn(&str) -> Option>, name: &str) -> Option> { let mut tried: Vec<&str> = vec![]; - for candidate in [name, sibling_name(name).unwrap_or(name), name.split_whitespace().last().unwrap_or(name)] { + for candidate in [name, craft_mesh::obj::sibling_name(name).unwrap_or(name), name.split_whitespace().last().unwrap_or(name)] { if tried.contains(&candidate) { continue; } @@ -39,263 +27,11 @@ fn resolve_reference(resolve: &dyn Fn(&str) -> Option>, name: &str) -> O None } -/// Parse MTL text into named materials (textures appended to `textures`). -pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>) -> Vec { - let mut out: Vec = vec![]; - let mut loaded: HashMap> = HashMap::new(); - let mut tex = |name: &str, textures: &mut Vec>| -> Option { - let key = name.trim().to_string(); - let t = *loaded.entry(key.clone()).or_insert_with(|| { - let Some(b) = resolve_reference(resolve, &key) else { - log::warn!("model: texture `{key}` was not found next to the model"); - return None; - }; - match Texture::decode(&b) { - Ok(t) => { - textures.push(Arc::new(t)); - Some(textures.len() - 1) - } - Err(e) => { - log::warn!("model: cannot decode texture `{key}`: {e}"); - None - } - } - }); - t.map(|texture| TexRef { texture, uv_set: 0 }) - }; - let mut ns_seen = false; - let mut pr_seen = false; - for line in text.lines() { - let line = line.trim(); - let (key, rest) = line.split_once(char::is_whitespace).map(|(k, r)| (k, r.trim())).unwrap_or((line, "")); - let f3 = || -> [f32; 3] { - let v: Vec = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect(); - match v.len() { - 0 => [0.0; 3], - 1 | 2 => [v[0]; 3], - _ => [v[0], v[1], v[2]], - } - }; - let f1 = || rest.split_whitespace().next().and_then(|x| x.parse::().ok()); - if key == "newmtl" { - if let Some(m) = out.last_mut() { - finish(m, ns_seen, pr_seen); - } - ns_seen = false; - pr_seen = false; - out.push(Material { name: rest.to_string(), metallic: 0.0, roughness: 0.5, ..Material::default() }); - continue; - } - let Some(m) = out.last_mut() else { continue }; - match key { - "Kd" => { - let c = f3(); - m.base_color = [srgb_to_linear(c[0]), srgb_to_linear(c[1]), srgb_to_linear(c[2]), m.base_color[3]]; - } - "Ke" => m.emissive = f3().map(srgb_to_linear), - "d" => m.base_color[3] = f1().unwrap_or(1.0), - "Tr" => m.base_color[3] = 1.0 - f1().unwrap_or(0.0), - "Ns" => { - let ns = f1().unwrap_or(0.0).max(0.0); - if !pr_seen { - m.roughness = (2.0 / (ns + 2.0)).sqrt(); - } - ns_seen = true; - } - "Pr" => { - m.roughness = f1().unwrap_or(0.5).clamp(0.0, 1.0); - pr_seen = true; - } - "Pm" => m.metallic = f1().unwrap_or(0.0).clamp(0.0, 1.0), - "map_Kd" => m.base_color_tex = tex(rest, textures), - "map_Ke" => { - m.emissive_tex = tex(rest, textures); - if m.emissive == [0.0; 3] { - m.emissive = [1.0; 3]; - } - } - "map_Bump" | "map_bump" | "bump" | "norm" => m.normal_tex = tex(rest, textures), - _ => {} - } - } - if let Some(m) = out.last_mut() { - finish(m, ns_seen, pr_seen); - } - out -} - -fn finish(m: &mut Material, _ns: bool, _pr: bool) { - if m.base_color[3] < 0.999 { - m.alpha_mode = AlphaMode::Blend; - } -} - -/// sRGB-encoded component → linear (MTL colours are display colours). -pub fn srgb_to_linear(c: f32) -> f32 { - if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } -} - -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -struct Key { - v: u32, - t: u32, - n: u32, - /// Smoothing state (faces without normals get computed normals per state). - s: u32, -} - -/// Parse OBJ text (and its MTL libraries through `resolve`). +/// Parse OBJ text (and its MTL libraries and textures through `resolve`). pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - let text = String::from_utf8_lossy(bytes); - let mut v: Vec<[f32; 3]> = vec![]; - let mut vt: Vec<[f32; 2]> = vec![]; - let mut vn: Vec<[f32; 3]> = vec![]; - let mut materials: Vec = vec![]; - let mut textures: Vec> = vec![]; - // One primitive per material, in order of first use. - let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; - let mut current: Option = None; - let mut smooth = 0u32; - let mut flat_id = 1u32 << 31; - let mut faces = 0usize; - // Join `\` line continuations. - let joined = text.replace("\\\r\n", " ").replace("\\\n", " "); - for (ln, line) in joined.lines().enumerate() { - let line = line.split('#').next().unwrap_or("").trim(); - if line.is_empty() { - continue; - } - let mut it = line.split_whitespace(); - let key = it.next().unwrap_or(""); - let rest: Vec<&str> = it.collect(); - let fl = |i: usize| rest.get(i).and_then(|x| x.parse::().ok()); - match key { - "v" => v.push([fl(0).unwrap_or(0.0), fl(1).unwrap_or(0.0), fl(2).unwrap_or(0.0)]), - "vt" => vt.push([fl(0).unwrap_or(0.0), fl(1).unwrap_or(0.0)]), - "vn" => vn.push([fl(0).unwrap_or(0.0), fl(1).unwrap_or(0.0), fl(2).unwrap_or(1.0)]), - "s" => { - smooth = match rest.first().copied() { - None | Some("off") | Some("0") => 0, - Some(x) => x.parse().unwrap_or(1).max(1), - } - } - "mtllib" => { - let name = line["mtllib".len()..].trim(); - let mut found = false; - for file in std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)) { - if let Some(b) = resolve(file) { - let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures); - materials.append(&mut ms); - found = true; - break; - } - } - if !found && !name.is_empty() { - log::warn!("model: material library `{name}` was not found next to the model"); - } - } - "usemtl" => { - let name = line["usemtl".len()..].trim(); - current = materials.iter().position(|m| m.name == name); - if current.is_none() { - // Unknown material: a named grey default. - materials.push(Material { name: name.to_string(), metallic: 0.0, roughness: 0.5, base_color: [0.8, 0.8, 0.8, 1.0], ..Material::default() }); - current = Some(materials.len() - 1); - } - } - "f" => { - if rest.len() < 3 { - continue; - } - faces += 1; - let resolve_i = |s: &str, len: usize| -> Option { - if s.is_empty() { - return None; - } - let i: i64 = s.parse().ok()?; - let k = if i < 0 { len as i64 + i } else { i - 1 }; - (k >= 0 && (k as usize) < len).then_some(k as u32) - }; - let mut refs = vec![]; - for r in &rest { - let mut parts = r.split('/'); - let vi = resolve_i(parts.next().unwrap_or(""), v.len()).ok_or_else(|| perr(format!("line {}: bad vertex index `{r}`", ln + 1)))?; - let ti = parts.next().and_then(|s| resolve_i(s, vt.len())); - let ni = parts.next().and_then(|s| resolve_i(s, vn.len())); - refs.push((vi, ti, ni)); - } - let has_n = refs.iter().all(|r| r.2.is_some()); - // Faces without normals: shared within a smoothing group, unique otherwise. - let s = if has_n { - 0 - } else if smooth == 0 { - flat_id += 1; - flat_id - } else { - smooth - }; - let slot = match prims.iter().position(|p| p.0 == current) { - Some(i) => i, - None => { - prims.push((current, Primitive { material: current, ..Default::default() }, HashMap::new(), vec![])); - prims.len() - 1 - } - }; - let (_, prim, map, need) = &mut prims[slot]; - let mut ids = vec![]; - for (vi, ti, ni) in refs { - let k = Key { v: vi, t: ti.unwrap_or(u32::MAX), n: ni.unwrap_or(u32::MAX), s }; - let id = *map.entry(k).or_insert_with(|| { - prim.positions.push(v[vi as usize]); - prim.uvs.push(ti.map_or([0.0, 0.0], |t| vt[t as usize])); - prim.normals.push(ni.map_or([0.0; 3], |n| vn[n as usize])); - need.push(ni.is_none()); - (prim.positions.len() - 1) as u32 - }); - ids.push(id); - } - for i in 2..ids.len() { - prim.indices.extend([ids[0], ids[i - 1], ids[i]]); - } - } - _ => {} - } - } - if faces == 0 { - return Err(perr("OBJ file without faces")); - } - let any_uv = !vt.is_empty(); - let mut mesh = Mesh { name: String::new(), primitives: vec![] }; - for (_, mut p, _, need) in prims { - if need.iter().any(|&b| b) { - // Fill missing normals from the triangles (area weighted), keep given ones. - let given = p.normals.clone(); - p.compute_normals(); - for (i, n) in p.normals.iter_mut().enumerate() { - if !need[i] { - *n = given[i]; - } - } - } - if !any_uv { - p.uvs.clear(); - } else { - // OBJ's v axis points up the image; textures are stored top row first. - for uv in p.uvs.iter_mut() { - uv[1] = 1.0 - uv[1]; - } - } - if p.material.is_some_and(|m| materials[m].normal_tex.is_some()) { - p.compute_tangents(); - } - mesh.primitives.push(p); - } - Ok(Model::single(mesh, materials).with_textures(textures)) -} - -impl Model { - fn with_textures(mut self, t: Vec>) -> Model { - self.textures = t; - self - } + // `craft-mesh` splits `mtllib` names and tries each complete library path itself. Applying + // texture-option fallback here first can mistake the last library for the whole directive. + let mut library = |name: &str| resolve(name); + let scene = craft_mesh::obj::read(bytes, &limits(), &mut library).map_err(error)?; + to_model(scene, MissingNormals::BySmoothing, &|im| resolve_reference(resolve, im.uri.as_deref()?.trim())) } diff --git a/crates/model/src/scene.rs b/crates/model/src/scene.rs new file mode 100644 index 000000000..07fee0397 --- /dev/null +++ b/crates/model/src/scene.rs @@ -0,0 +1,459 @@ +//! A `craft-mesh` scene (glTF, OBJ, STL or FBX, read there) as a [`Model`]: one primitive per +//! part with one index per vertex, decoded textures, and the normals and tangents the renderer +//! needs where the file has none. + +use std::collections::HashMap; +use std::sync::Arc; + +use craft_mesh as cm; + +use crate::{ + AlphaMode, Animation, CameraProjection, Channel, ChannelPath, Interp, MAX_IMPORT_TEXTURE_PIXELS, Mat4, Material, Mesh, Model, ModelCamera, ModelError, + ModelLight, ModelLightKind, Node, Primitive, Result, Skin, TexRef, Texture, Wrap, +}; + +/// The bounds every reader keeps: `craft-mesh`'s defaults, with up to 2 GiB of buffers, +/// arrays and embedded images in one import. +pub(crate) fn limits() -> cm::Limits { + cm::Limits { max_data_bytes: 2 << 30, ..cm::Limits::default() } +} + +pub(crate) fn error(e: cm::Error) -> ModelError { + match e { + cm::Error::Missing(name) => ModelError::Missing(name), + cm::Error::Unsupported(m) => ModelError::Unsupported(m), + cm::Error::Malformed(m) | cm::Error::TooLarge(m) => ModelError::Parse(m), + } +} + +/// What a primitive without normals gets. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum MissingNormals { + /// Every triangle its own vertices and face normal (glTF 2.0 §3.7.2.1). + Flat, + /// Area-weighted vertex normals (FBX). + Smooth, + /// Smooth within an OBJ smoothing group, flat where smoothing is off. + BySmoothing, +} + +/// A column-major `craft-mesh` matrix as a [`Mat4`]. +pub(crate) fn mat4(m: &cm::Mat4) -> Mat4 { + let mut out = [[0.0; 4]; 4]; + for (c, col) in m.as_chunks::<4>().0.iter().enumerate() { + for (r, v) in col.iter().enumerate() { + out[r][c] = *v; + } + } + Mat4(out) +} + +fn wrap(w: cm::Wrap) -> Wrap { + match w { + cm::Wrap::Repeat => Wrap::Repeat, + cm::Wrap::Clamp => Wrap::Clamp, + cm::Wrap::Mirror => Wrap::Mirror, + } +} + +/// The scene as a model. `image` gives the bytes of an image the scene names but does not hold +/// (OBJ, which only names its textures); an image that cannot be read or decoded leaves its +/// material untextured, with a log line. +pub(crate) fn to_model(mut scene: cm::Scene, missing: MissingNormals, image: &dyn Fn(&cm::Image) -> Option>) -> Result { + let mut warnings = std::mem::take(&mut scene.warnings); + for w in &warnings { + log::warn!("model: {w}"); + } + // Images decode once; each (image, wrap) a material uses is one texture. + let mut pixels_left = MAX_IMPORT_TEXTURE_PIXELS; + let mut decoded_by_name: HashMap>> = HashMap::new(); + let decoded: Vec>> = scene + .images + .iter() + .enumerate() + .map(|(index, im)| { + let key = image_key(im, index); + if let Some(decoded) = decoded_by_name.get(&key) { + return decoded.clone(); + } + let external; + let bytes = if let Some(data) = im.data.as_deref() { + Some(data) + } else { + external = image(im); + external.as_deref() + }; + let decoded = match bytes.map(|b| Texture::decode_within(b, &mut pixels_left)) { + Some(Ok(t)) => Some(Arc::new(t)), + Some(Err(e)) => { + let warning = format!("texture `{}` was left out: {e}", im.uri.as_deref().unwrap_or(&im.name)); + log::warn!("model: {warning}"); + warnings.push(warning); + None + } + None => { + let warning = format!("texture `{}` was not found next to the model", im.uri.as_deref().unwrap_or(&im.name)); + log::warn!("model: {warning}"); + warnings.push(warning); + None + } + }; + decoded_by_name.insert(key, decoded.clone()); + decoded + }) + .collect(); + let mut textures: Vec> = vec![]; + let mut texture_of: HashMap<(String, [Wrap; 2]), usize> = HashMap::new(); + for texture in &scene.texture_variants { + ensure_texture(&scene, &decoded, *texture, &mut textures, &mut texture_of, &mut pixels_left, &mut warnings); + } + let mut tref = |t: Option| -> Option { + let t = t?; + let texture = ensure_texture(&scene, &decoded, t, &mut textures, &mut texture_of, &mut pixels_left, &mut warnings)?; + Some(TexRef { texture, uv_set: t.uv_set }) + }; + let materials: Vec = scene + .materials + .iter() + .map(|m| Material { + name: m.name.clone(), + base_color: m.base_color, + base_color_tex: tref(m.base_color_texture), + metallic: m.metallic, + roughness: m.roughness, + metallic_roughness_tex: tref(m.metallic_roughness_texture), + normal_tex: tref(m.normal_texture), + normal_scale: m.normal_scale, + occlusion_tex: tref(m.occlusion_texture), + occlusion_strength: m.occlusion_strength, + emissive: m.emissive, + emissive_tex: tref(m.emissive_texture), + alpha_mode: match m.alpha_mode { + cm::AlphaMode::Opaque => AlphaMode::Opaque, + cm::AlphaMode::Mask(_) => AlphaMode::Mask, + cm::AlphaMode::Blend => AlphaMode::Blend, + }, + alpha_cutoff: if let cm::AlphaMode::Mask(c) = m.alpha_mode { c } else { 0.5 }, + double_sided: m.double_sided, + unlit: m.unlit, + }) + .collect(); + let mut adapter_bytes = 0u64; + let meshes = scene.meshes.iter().map(|m| mesh(m, &materials, missing, &mut adapter_bytes)).collect::>>()?; + let nodes = scene + .nodes + .iter() + .map(|n| { + let (translation, rotation, scale, matrix) = match n.trs { + Some(t) => (t.translation, t.rotation, t.scale, None), + None => ([0.0; 3], [0.0, 0.0, 0.0, 1.0], [1.0; 3], Some(mat4(&n.transform))), + }; + Node { + name: n.name.clone(), + translation, + rotation, + scale, + matrix, + mesh: n.mesh.filter(|&m| m < scene.meshes.len()), + skin: n.skin, + camera: n.camera, + light: n.light, + children: n.children.clone(), + } + }) + .collect(); + let skins = scene + .skins + .iter() + .map(|s| Skin { + joints: s.joints.clone(), + inverse_bind: if s.inverse_bind.is_empty() { vec![Mat4::IDENTITY; s.joints.len()] } else { s.inverse_bind.iter().map(mat4).collect() }, + }) + .collect(); + let animations = scene + .animations + .iter() + .map(|a| Animation { + name: a.name.clone(), + channels: a + .channels + .iter() + .map(|c| Channel { + node: c.node, + path: match c.path { + cm::ChannelPath::Translation => ChannelPath::Translation, + cm::ChannelPath::Rotation => ChannelPath::Rotation, + cm::ChannelPath::Scale => ChannelPath::Scale, + }, + interp: match c.interpolation { + cm::Interpolation::Step => Interp::Step, + cm::Interpolation::Linear => Interp::Linear, + cm::Interpolation::CubicSpline => Interp::CubicSpline, + }, + times: c.times.clone(), + values: c.values.clone(), + }) + .collect(), + }) + .collect(); + let name_or = |n: &str, d: String| if n.is_empty() { d } else { n.to_string() }; + let cameras = scene + .cameras + .iter() + .enumerate() + .map(|(i, c)| ModelCamera { + name: name_or(&c.name, format!("Camera {}", i + 1)), + projection: match c.projection { + cm::Projection::Perspective { yfov, aspect, znear, .. } => CameraProjection::Perspective { yfov: yfov.clamp(1e-3, 3.1), aspect, znear }, + cm::Projection::Orthographic { xmag, ymag, .. } => CameraProjection::Orthographic { xmag, ymag }, + }, + }) + .collect(); + let lights = scene + .lights + .iter() + .enumerate() + .map(|(i, l)| ModelLight { + name: name_or(&l.name, format!("Light {}", i + 1)), + kind: match l.kind { + cm::LightKind::Directional => ModelLightKind::Directional, + cm::LightKind::Point => ModelLightKind::Point, + cm::LightKind::Spot { inner, outer } => { + let outer = outer.clamp(1e-3, std::f64::consts::FRAC_PI_2); + ModelLightKind::Spot { inner: inner.clamp(0.0, outer), outer } + } + }, + color: l.color, + intensity: l.intensity.max(0.0), + range: l.range, + }) + .collect(); + Ok(Model { meshes, nodes, roots: scene.roots, materials, textures, skins, animations, cameras, lights, warnings }) +} + +fn image_key(image: &cm::Image, index: usize) -> String { + image.uri.as_deref().or_else(|| (!image.name.is_empty()).then_some(image.name.as_str())).map(str::to_string).unwrap_or_else(|| format!("image:{index}")) +} + +fn ensure_texture( + scene: &cm::Scene, + decoded: &[Option>], + reference: cm::TextureRef, + textures: &mut Vec>, + texture_of: &mut HashMap<(String, [Wrap; 2]), usize>, + pixels_left: &mut u64, + warnings: &mut Vec, +) -> Option { + let image = decoded.get(reference.image)?.as_ref()?; + let wrap_mode = reference.wrap.map(wrap); + let key = (image_key(scene.images.get(reference.image)?, reference.image), wrap_mode); + if let Some(&index) = texture_of.get(&key) { + return Some(index); + } + if image.wrap != wrap_mode { + let cost = (image.width as u64).checked_mul(image.height as u64); + let Some(cost) = cost.filter(|&cost| cost <= *pixels_left) else { + let source = scene.images.get(reference.image)?; + let warning = format!("texture `{}` wrap variant exceeds the model texture pixel limit", source.uri.as_deref().unwrap_or(&source.name)); + log::warn!("model: {warning}"); + warnings.push(warning); + return None; + }; + *pixels_left -= cost; + textures.push(Arc::new(Texture { wrap: wrap_mode, ..(**image).clone() })); + } else { + textures.push(image.clone()); + } + let index = textures.len() - 1; + texture_of.insert(key, index); + Some(index) +} + +/// One primitive per part, each with one vertex per distinct corner of its triangles. Normals +/// and UVs are kept for a part when every corner of it has them; a triangle naming a vertex +/// that is not there is left out. +fn mesh(m: &cm::Mesh, materials: &[Material], missing: MissingNormals, adapter_bytes: &mut u64) -> Result { + let skinned = !m.joints.is_empty() && m.joints.len() == m.positions.len() && m.weights.len() == m.positions.len(); + let uv_set = m.uv_sets.first(); + // Include temporary hash keys, smoothing maps and the peak duplicate produced by + // flat-normal expansion. + // Account against the whole scene before building any primitive arrays. + let projected = m + .parts + .iter() + .try_fold(0u64, |sum, part| { + let has_missing = part.corners.iter().any(|c| (c.normal as usize) >= m.normals.len()); + let flat = missing == MissingNormals::Flat || (missing == MissingNormals::BySmoothing && part.smoothing == 0); + let vertices = if flat && has_missing { (part.triangle_count() as u64).checked_mul(3)? } else { part.corners.len() as u64 }; + let bytes_per_vertex = if missing == MissingNormals::Flat { 64 } else { 128 }; + sum.checked_add(vertices.checked_mul(bytes_per_vertex)?) + }) + .ok_or_else(|| ModelError::Parse("expanded mesh size overflow".into()))?; + *adapter_bytes = adapter_bytes.checked_add(projected).ok_or_else(|| ModelError::Parse("expanded mesh size overflow".into()))?; + if *adapter_bytes > (2u64 << 30) { + return Err(ModelError::Parse("expanded mesh data exceeds the 2 GiB import limit".into())); + } + let smooth = smooth_normals(m, missing); + let mut primitives = vec![]; + for part in &m.parts { + let normals = part.corners.iter().any(|c| (c.normal as usize) < m.normals.len()); + let tangents = part.has_tangents && m.tangents.len() == m.positions.len(); + let uvs = uv_set.filter(|set| part.corners.iter().any(|c| (c.uv as usize) < set.len())); + let flat_missing = missing == MissingNormals::BySmoothing && part.smoothing == 0; + let mut authored_normals = vec![]; + let mut ids: HashMap<(cm::Corner, Option), u32> = HashMap::new(); + let mut p = Primitive { material: part.material.filter(|&i| i < materials.len()), ..Primitive::default() }; + for (face, t) in part.triangles().enumerate() { + if t.iter().any(|c| c.position as usize >= m.positions.len()) { + continue; + } + let flat_face_normal = (normals && flat_missing).then(|| face_normal(m, t)); + for c in t { + let has_normal = (c.normal as usize) < m.normals.len(); + let has_uv = uvs.is_some_and(|set| (c.uv as usize) < set.len()); + let key = ( + cm::Corner { position: c.position, normal: if has_normal { c.normal } else { cm::NONE }, uv: if has_uv { c.uv } else { cm::NONE } }, + (!has_normal && flat_face_normal.is_some()).then_some(face), + ); + let next = p.positions.len() as u32; + let id = *ids.entry(key).or_insert_with(|| { + let at = c.position as usize; + p.positions.push(m.positions.get(at).copied().unwrap_or_default().map(|x| x as f32)); + if normals { + let authored = m.normals.get(c.normal as usize).copied().map(|n| n.map(|x| x as f32)); + let fallback = flat_face_normal.or_else(|| smooth.get(&(c.position, part.material, smooth_group(part, missing))).copied()); + authored_normals.push(authored.or(fallback)); + p.normals.push(authored.or(fallback).unwrap_or([0.0, 0.0, 1.0])); + } else if let Some(n) = smooth.get(&(c.position, part.material, smooth_group(part, missing))) { + p.normals.push(*n); + } + if tangents { + p.tangents.push(m.tangents.get(at).copied().unwrap_or_default().map(|x| x as f32)); + } + if let Some(set) = uvs { + p.uvs.push(set.get(c.uv as usize).copied().unwrap_or_default()); + } + if skinned { + p.joints.push(m.joints.get(at).copied().unwrap_or_default()); + p.weights.push(m.weights.get(at).copied().unwrap_or_default()); + } + next + }); + p.indices.push(id); + } + } + if p.indices.is_empty() { + continue; + } + if normals && authored_normals.iter().any(Option::is_none) { + // Keep authored corner normals, and fill only absent ones from the surrounding + // triangles. The generated normals are smooth in a mixed-authorship part. + if authored_normals.iter().any(Option::is_none) { + p.compute_normals(); + for (n, authored) in p.normals.iter_mut().zip(authored_normals) { + if let Some(authored) = authored { + *n = authored; + } + } + } + } else if p.normals.is_empty() { + match missing { + MissingNormals::Flat => p.flat_normals(), + MissingNormals::BySmoothing if part.smoothing == 0 => p.flat_normals(), + MissingNormals::Smooth | MissingNormals::BySmoothing => p.compute_normals(), + } + } + if p.tangents.is_empty() && p.material.is_some_and(|i| materials.get(i).is_some_and(|mat| mat.normal_tex.is_some())) { + p.compute_tangents(); + } + primitives.push(p); + } + Ok(Mesh { name: m.name.clone(), primitives }) +} + +fn smooth_group(part: &cm::Part, missing: MissingNormals) -> u32 { + match missing { + MissingNormals::Smooth => 1, + MissingNormals::BySmoothing => part.smoothing, + MissingNormals::Flat => 0, + } +} + +fn face_normal(m: &cm::Mesh, tri: [cm::Corner; 3]) -> [f32; 3] { + let [a, b, c] = tri.map(|corner| m.positions.get(corner.position as usize).copied().unwrap_or_default()); + let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + if len > 0.0 { n.map(|x| (x / len) as f32) } else { [0.0, 0.0, 1.0] } +} + +/// Area weighted generated normals across parts with the same material and smoothing state. +fn smooth_normals(m: &cm::Mesh, missing: MissingNormals) -> HashMap<(u32, Option, u32), [f32; 3]> { + let mut sums: HashMap<(u32, Option, u32), [f64; 3]> = HashMap::new(); + if missing == MissingNormals::Flat { + return HashMap::new(); + } + for part in &m.parts { + let group = smooth_group(part, missing); + if group == 0 { + continue; + } + for tri in part.triangles() { + let Some(a) = m.positions.get(tri[0].position as usize) else { continue }; + let Some(b) = m.positions.get(tri[1].position as usize) else { continue }; + let Some(c) = m.positions.get(tri[2].position as usize) else { continue }; + let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let face = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]; + for corner in tri { + if (corner.normal as usize) < m.normals.len() { + continue; + } + let n = sums.entry((corner.position, part.material, group)).or_default(); + for i in 0..3 { + n[i] += face[i]; + } + } + } + } + sums.into_iter() + .map(|(key, n)| { + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + let n = if len > 0.0 { n.map(|x| (x / len) as f32) } else { [0.0, 0.0, 1.0] }; + (key, n) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_variants_share_and_charge_the_import_pixel_budget() { + let scene = cm::Scene { images: vec![cm::Image { name: "texture.png".into(), ..cm::Image::default() }], ..cm::Scene::default() }; + let texture = Arc::new(Texture { width: 2, height: 2, data: vec![[1.0; 4]; 4], wrap: [Wrap::Repeat; 2] }); + let decoded = vec![Some(texture)]; + let mut textures = vec![]; + let mut texture_of = HashMap::new(); + // The decoded image has already taken four pixels from this budget. + let mut pixels_left = 4; + let mut warnings = vec![]; + let clamped = cm::TextureRef { image: 0, uv_set: 0, wrap: [cm::Wrap::Clamp; 2] }; + + let first = ensure_texture(&scene, &decoded, clamped, &mut textures, &mut texture_of, &mut pixels_left, &mut warnings).unwrap(); + let shared = ensure_texture(&scene, &decoded, clamped, &mut textures, &mut texture_of, &mut pixels_left, &mut warnings).unwrap(); + assert_eq!(first, shared); + assert_eq!(pixels_left, 0); + + let repeated = cm::TextureRef { wrap: [cm::Wrap::Repeat; 2], ..clamped }; + assert!(ensure_texture(&scene, &decoded, repeated, &mut textures, &mut texture_of, &mut pixels_left, &mut warnings).is_some()); + assert_eq!(pixels_left, 0); + + let mirrored = cm::TextureRef { wrap: [cm::Wrap::Mirror; 2], ..clamped }; + assert!(ensure_texture(&scene, &decoded, mirrored, &mut textures, &mut texture_of, &mut pixels_left, &mut warnings).is_none()); + assert_eq!(textures.len(), 2); + assert_eq!(warnings.len(), 1); + } +} diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 34e9a7f06..4fb8ee084 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -1,201 +1,23 @@ -//! STL (stereolithography) meshes, binary and ASCII. +//! STL (stereolithography) meshes, binary and ASCII, read with `craft-mesh` +//! (`craft_mesh::stl`): every facet keeps its own three vertices and its face normal, and binary +//! colour extensions become one plain material per colour. //! -//! Written from the public description of the format (3D Systems' StereoLithography Interface -//! Specification, 1989, and its widely documented colour extensions): -//! -//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a -//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last -//! facet are ignored. -//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… / -//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other. -//! Loops with more than three vertices are fan-triangulated. -//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics), -//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour -//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15 -//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a -//! plain dielectric material; uncoloured files get none (the renderer's default material). -//! -//! STL has no units, no UVs and no shared vertices: every facet keeps its own three vertices -//! with the face normal from its counter-clockwise winding (the stored normal is only used when -//! the triangle is degenerate), which gives the faceted look of an STL part. STL files are -//! Z-up (3D printing and CAD); they are turned to the +Y-up convention of [`crate::Model`] -//! (`(x, y, z)` → `(x, z, −y)`, a proper rotation, so winding is kept). Facets with non-finite -//! coordinates are skipped. +//! STL files are Z-up (3D printing and CAD); they are turned to the +Y-up convention of +//! [`crate::Model`] (`(x, y, z)` → `(x, z, −y)`, a proper rotation, so winding is kept). -use std::collections::HashMap; - -use crate::{Material, Mesh, Model, Primitive, Result, cross, normalize, obj::srgb_to_linear, perr, sub}; - -/// Header (80) plus facet count (4). -const HEADER: usize = 84; -/// Bytes per binary facet. -const FACET: usize = 50; - -/// Parse an STL file (binary or ASCII, detected from the contents). -pub fn parse(bytes: &[u8]) -> Result { - let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes); - let needed = count.and_then(|n| (n as usize).checked_mul(FACET)).and_then(|n| n.checked_add(HEADER)); - // A binary file's size is exact; many binary headers still begin with `solid`, so the size - // decides first, then the ASCII keyword. - if needed.is_some_and(|n| n == bytes.len()) { - return binary(bytes); - } - if looks_ascii(bytes) { - return ascii(bytes); - } - if needed.is_some_and(|n| n <= bytes.len()) { - return binary(bytes); - } - Err(perr("not an STL file (truncated binary facets or no `solid` keyword)")) -} - -fn looks_ascii(bytes: &[u8]) -> bool { - let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]); - let text = String::from_utf8_lossy(head); - let t = text.trim_start(); - t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid")) -} +use crate::scene::{MissingNormals, error, limits, to_model}; +use crate::{Model, Result}; /// The Z-up → Y-up turn. -fn y_up(v: [f32; 3]) -> [f32; 3] { +fn y_up(v: [f64; 3]) -> [f64; 3] { [v[0], v[2], -v[1]] } -/// Facets grouped into primitives by colour, in order of first use. -#[derive(Default)] -struct Builder { - /// (colour key, primitive); `None` = uncoloured. - prims: Vec<(Option<[u8; 3]>, Primitive)>, - /// Colour → index into `prims` (up to 32 768 colours, so not a linear search). - slots: HashMap, usize>, - facets: usize, -} - -impl Builder { - /// Add one triangle (file coordinates); `stored` is the file's facet normal. - fn tri(&mut self, color: Option<[u8; 3]>, stored: [f32; 3], v: [[f32; 3]; 3]) { - if v.iter().flatten().any(|c| !c.is_finite()) { - return; - } - let v = v.map(y_up); - let n = normalize(cross(sub(v[1], v[0]), sub(v[2], v[0]))) - .or_else(|| stored.iter().all(|c| c.is_finite()).then(|| normalize(y_up(stored))).flatten()) - .unwrap_or([0.0, 1.0, 0.0]); - let slot = *self.slots.entry(color).or_insert_with(|| { - self.prims.push((color, Primitive::default())); - self.prims.len() - 1 - }); - let Some((_, p)) = self.prims.get_mut(slot) else { return }; - for c in v { - p.indices.push(p.positions.len() as u32); - p.positions.push(c); - p.normals.push(n); - } - self.facets += 1; - } - - fn finish(self, name: String) -> Result { - if self.facets == 0 { - return Err(perr("STL file without facets")); - } - let mut materials = vec![]; - let mut mesh = Mesh { name, primitives: vec![] }; - for (color, mut p) in self.prims { - if let Some(c) = color { - let lin = c.map(|x| srgb_to_linear(x as f32 / 255.0)); - let name = format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]); - materials.push(Material { name, ..Material::plain([lin[0], lin[1], lin[2], 1.0], 0.0, 0.5) }); - p.material = Some(materials.len() - 1); - } - mesh.primitives.push(p); - } - // Uncoloured facets next to coloured ones: a neutral grey of their own. - if !materials.is_empty() { - for p in mesh.primitives.iter_mut().filter(|p| p.material.is_none()) { - materials.push(Material { name: "STL".into(), ..Material::plain([0.6, 0.6, 0.6, 1.0], 0.0, 0.5) }); - p.material = Some(materials.len() - 1); - } - } - Ok(Model::single(mesh, materials)) +/// Parse an STL file (binary or ASCII, detected from the contents). +pub fn parse(bytes: &[u8]) -> Result { + let mut scene = craft_mesh::stl::read(bytes, &limits()).map_err(error)?; + for m in &mut scene.meshes { + m.positions.iter_mut().chain(m.normals.iter_mut()).for_each(|v| *v = y_up(*v)); } -} - -fn f32_at(b: &[u8], at: usize) -> f32 { - b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes) -} - -/// A 5-bit channel expanded to 8 bits. -fn five(x: u16) -> u8 { - let x = (x & 31) as u8; - (x << 3) | (x >> 2) -} - -/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored). -fn header_color(header: &[u8]) -> Option<[u8; 3]> { - let at = header.windows(6).position(|w| w == b"COLOR=")?; - let c = header.get(at + 6..at + 10)?; - Some([*c.first()?, *c.get(1)?, *c.get(2)?]) -} - -fn binary(bytes: &[u8]) -> Result { - let header = bytes.get(..80).ok_or_else(|| perr("STL header truncated"))?; - let magics = header_color(header); - let body = bytes.get(HEADER..).unwrap_or(&[]); - let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map_or(0, u32::from_le_bytes) as usize; - let mut b = Builder::default(); - for f in body.as_chunks::().0.iter().take(count) { - let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)]; - let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]); - let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes); - let color = match magics { - Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }), - None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]), - }; - b.tri(color, n, v); - } - let text = String::from_utf8_lossy(header); - let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); - b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string()) -} - -/// Three numbers from the rest of an ASCII line. -fn three<'a>(it: &mut impl Iterator) -> Option<[f32; 3]> { - Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?]) -} - -fn ascii(bytes: &[u8]) -> Result { - let text = String::from_utf8_lossy(bytes); - let mut b = Builder::default(); - let mut name: Option = None; - let mut normal = [0.0f32; 3]; - let mut loop_v: Vec<[f32; 3]> = vec![]; - for (ln, line) in text.lines().enumerate() { - let line = line.trim(); - let mut it = line.split_whitespace(); - let Some(key) = it.next() else { continue }; - let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); - match key.to_ascii_lowercase().as_str() { - "solid" if name.is_none() => { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } - "facet" => { - // `facet normal nx ny nz` - normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]); - loop_v.clear(); - } - "vertex" => loop_v.push(three(&mut it).ok_or_else(bad)?), - "endloop" | "endfacet" => { - if let Some((&first, rest)) = loop_v.split_first() { - for w in rest.windows(2) { - if let [a, c] = w { - b.tri(None, normal, [first, *a, *c]); - } - } - } - loop_v.clear(); - } - _ => {} - } - } - b.finish(name.unwrap_or_default()) + to_model(scene, MissingNormals::Flat, &|_| None) } diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 5ee6a6950..8c6a347fb 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -119,6 +119,22 @@ fn obj_without_normals_gets_them() { assert!(obj::parse(b"v 0 0 0\n", &|_| None).is_err()); } +#[test] +fn obj_mixed_authored_normals_keep_flat_missing_corners() { + let src = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 0 1\nvn 0 1 0\ns off\nf 1//1 2 3\nf 1 3 4\n"; + let m = obj::parse(src, &|_| None).unwrap(); + let corners: Vec<_> = m.meshes[0] + .primitives + .iter() + .flat_map(|p| p.positions.iter().zip(&p.normals)) + .filter(|(position, _)| **position == [0.0, 1.0, 0.0]) + .map(|(_, normal)| *normal) + .collect(); + assert!(corners.contains(&[0.0, 0.0, 1.0]), "{corners:?}"); + assert!(corners.contains(&[1.0, 0.0, 0.0]), "{corners:?}"); + assert!(m.meshes[0].primitives.iter().any(|p| p.normals.contains(&[0.0, 1.0, 0.0]))); +} + #[test] fn obj_textures_from_another_machine_find_the_file_next_to_the_model() { // No committed media: the material file and its textures are written by the test itself. @@ -432,6 +448,58 @@ fn contours_group_into_outlines_by_nesting() { assert_eq!(g.iter().map(|o| o.holes.len()).sum::(), 2); } +#[test] +fn overlapping_contours_keep_every_container_in_their_depth() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + // C's first vertex lies inside both A and B, which overlap without nesting: C is two deep, + // an outer contour, not a hole cut out of B. + let g = group_contours(vec![rect(0.0, 0.0, 10.0, 10.0), rect(5.0, -2.0, 13.0, 8.0), rect(9.0, 1.0, 12.0, 2.0)]); + assert_eq!(g.len(), 3); + assert_eq!(g.iter().map(|o| o.holes.len()).sum::(), 0); +} + +#[test] +fn a_contour_inside_a_hole_takes_the_innermost_outline_not_the_hole() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + // A and B overlap; C lies in A only (a hole of A); D lies in A, B and C (three deep, a hole). + // The smallest container of D is the hole C, which has no outline of its own: D goes to B. + let g = group_contours(vec![rect(0.0, 0.0, 10.0, 10.0), rect(5.0, -2.0, 13.0, 8.0), rect(1.0, 1.0, 9.0, 9.0), rect(6.0, 2.0, 7.0, 3.0)]); + assert_eq!(g.len(), 2); + assert_eq!(g.iter().map(|o| o.holes.len()).collect::>(), [1, 1], "C is a hole of A, D a hole of B (not dropped)"); +} + +#[test] +fn a_hole_whose_outline_is_deeper_than_it_is_still_attached() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + // D is three deep and its innermost outline A four deep: A's slot must exist before D is attached. + let a = rect(0.0, 0.0, 10.0, 10.0); + let g = group_contours(vec![ + a.clone(), + rect(-1.0, -1.0, 20.0, 20.0), + rect(-2.0, -2.0, 25.0, 25.0), + rect(5.0, 5.0, 6.0, 6.0), + rect(-100.0, -100.0, 3.0, 3.0), + rect(-0.5, -1000.0, 0.5, 1000.0), + ]); + let o = g.iter().find(|o| o.outer == a).expect("A is an outline"); + assert_eq!(o.holes.len(), 1, "D is a hole of A"); +} + +#[test] +fn gltf_images_naming_one_file_share_their_wrap_variants() { + let png = { + let img = image::RgbaImage::from_fn(2, 2, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + // Two image entries with one URI share a decode; two textures clamp it, so they share one copy. + let doc = br#"{"asset":{"version":"2.0"},"images":[{"uri":"t.png"},{"uri":"t.png"}], + "samplers":[{"wrapS":33071,"wrapT":33071}],"textures":[{"source":0,"sampler":0},{"source":1,"sampler":0}]}"#; + let m = gltf::parse(doc, &|u| (u == "t.png").then(|| png.clone())).unwrap(); + assert_eq!(m.textures.len(), 1, "one clamped copy for both textures"); +} + #[test] fn gltf_cameras_and_punctual_lights() { let doc = r#"{ @@ -597,9 +665,9 @@ fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() { let view = r#"{"buffer": 0, "byteLength": 36}"#; let cases = [ // A count far beyond the 36-byte buffer. - (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"), + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "limit allows"), // count * components overflows. - (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"), + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "limit allows"), // Offsets that overflow when added. ( r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#, @@ -607,11 +675,11 @@ fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() { "past its buffer", ), // A stride that overflows with the count. - (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"), + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "limit allows"), // A stride smaller than an element would re-read the same bytes for a huge count. (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"), // No buffer view: nothing backs the count, so it is capped. - (r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"), + (r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "limit allows"), ]; let t0 = std::time::Instant::now(); for (acc, v, want) in cases { @@ -669,65 +737,112 @@ fn gltf_issue_2139_huge_mat4_count_is_an_error() { } #[test] -fn gltf_buffer_less_accessors_share_one_zero_budget() { - // Each buffer-less accessor is capped, but nothing backs any of them, so referencing a capped - // one from many primitives must not multiply the allocation (#2139). +fn gltf_reused_buffer_less_accessor_is_read_once() { + // The shared reader parses a repeated accessor only once, so referencing it from several + // primitives does not multiply its one bounded allocation. let acc = r#"{"componentType": 5126, "type": "VEC3", "count": 5000000}"#; let prims = [r#"{"attributes": {"POSITION": 0}}"#; 4].join(", "); let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]")); assert_eq!(doc.matches(r#""POSITION": 0"#).count(), 4); - let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("imported")); - assert!(e.to_string().contains("without a bufferView"), "{e}"); - // One such accessor read once still imports. - let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3); - assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_ok()); -} - -/// `refs` primitives sharing one buffer-backed `verts`-vertex POSITION accessor, without indices. -fn gltf_shared_positions(verts: usize, refs: usize) -> String { - let acc = format!(r#"{{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": {verts}}}"#); - let prims = vec![r#"{"attributes": {"POSITION": 0}}"#; refs].join(", "); - gltf_doc(&acc, r#"{"buffer": 0}"#, verts * 12).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]")) + let result = crate::gltf::parse(doc.as_bytes(), &|_| None); + assert!(result.is_ok(), "{result:?}"); } #[test] -fn gltf_buffer_backed_reads_share_one_read_budget() { - // A buffer-backed accessor is checked against its buffer, but each primitive that references - // it reads and keeps its own copy, so a small file listing many references must not ask for - // memory set by the reference count (#2139 review). Everything a primitive keeps is charged - // to one per-file budget: 1,000 vertices without indices or normals cost 3,000 position - // components, 1,000 generated indices, a 999-index triangle list and 999 flat-shaded - // vertices of 6 components, 10,993 in all. - let one = gltf_shared_positions(1000, 1); - let m = crate::gltf::parse_with_budget(one.as_bytes(), &|_| None, 20_000).unwrap(); - assert_eq!(m.meshes[0].primitives.len(), 1); - let three = gltf_shared_positions(1000, 3); - assert_eq!(three.matches(r#""POSITION": 0"#).count(), 3); - let e = crate::gltf::parse_with_budget(three.as_bytes(), &|_| None, 20_000).err().unwrap_or_else(|| panic!("imported")); - assert!(e.to_string().contains("more than 20000 components"), "{e}"); - // The real cap leaves room for legitimate sharing: many primitives over one vertex buffer. - let m = crate::gltf::parse(gltf_shared_positions(1000, 50).as_bytes(), &|_| None).unwrap(); - assert_eq!(m.meshes[0].primitives.len(), 50); +fn texture_decoding_is_charged_to_the_import_budget() { + let png = { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let mut left = 4; + assert_eq!(Texture::decode_within(&png, &mut left).unwrap().data.len(), 4); + assert_eq!(left, 0); + assert!(matches!(Texture::decode_within(&png, &mut left), Err(ModelError::Unsupported(_)))); } #[test] -fn gltf_missing_indices_are_charged_to_the_read_budget() { - // Without `indices` every vertex gets a generated index, allocated like a read: after the - // 3,000 position components, 999 left is one short for the 1,000 indices. - let doc = gltf_shared_positions(1000, 1); - let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 3999).err().unwrap_or_else(|| panic!("imported")); - assert!(e.to_string().contains("(1000 requested, 999 left)"), "{e}"); +fn obj_repeated_material_libraries_and_textures_load_once() { + let png = { + let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let mtl = b"newmtl A\nmap_Kd t.png\nnewmtl B\nmap_Kd t.png\n".to_vec(); + let src = b"mtllib m.mtl\nmtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl A\nf 1 2 3\nusemtl B\nf 1 2 3\nusemtl A\nf 1 2 3\n"; + let m = obj::parse(src, &|name| match name { + "m.mtl" => Some(mtl.clone()), + "t.png" => Some(png.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(m.materials.iter().map(|m| m.name.as_str()).collect::>(), ["A", "B"]); + assert_eq!(m.textures.len(), 1); + assert_eq!(m.meshes[0].primitives.iter().map(|p| (p.material, p.triangle_count())).collect::>(), [(Some(0), 2), (Some(1), 1)]); } #[test] -fn gltf_strip_expansion_is_charged_to_the_read_budget() { - // A strip of k indices becomes 3(k-2) triangle indices and as many flat-shaded vertices, so - // the output is charged, not only the 1,009 components read. - let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0}"#, 1036) - .replace(r#""count": 3}]"#, r#""count": 3}, {"bufferView": 0, "componentType": 5121, "type": "SCALAR", "count": 1000}]"#) - .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1, "mode": 5"#); - let m = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 30_000).unwrap(); - assert_eq!(m.meshes[0].primitives[0].indices.len(), 2994); - let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 5000).err().unwrap_or_else(|| panic!("imported")); - assert!(e.to_string().contains("more than 5000 components"), "{e}"); +fn gltf_textures_naming_one_image_share_it() { + let doc = String::from_utf8(fixture("quad.gltf")).unwrap(); + // Three textures of one image: the fixture's sampler, the same sampler again, no sampler. + let doc = doc.replace( + "\"textures\": [\n {\n \"source\": 0,\n \"sampler\": 0\n }\n ]", + "\"textures\": [{\"source\": 0, \"sampler\": 0}, {\"source\": 0, \"sampler\": 0}, {\"source\": 0}]", + ); + let m = gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.textures.len(), 2); + assert_eq!(m.textures[0].wrap, [Wrap::Clamp, Wrap::Repeat]); + assert_eq!(m.textures[1].wrap, [Wrap::Repeat, Wrap::Repeat]); +} + +#[test] +fn instances_of_one_skin_share_its_joint_matrices() { + let p = mesh(PrimitiveKind::Plane, &PrimParams { size: [2.0, 2.0, 0.0], ..Default::default() }); + let mut m = Model::single(Mesh { name: "s".into(), primitives: vec![p] }, vec![Material::default()]); + m.nodes[0].skin = Some(0); + m.nodes.push(Node { mesh: Some(0), skin: Some(0), ..Node::default() }); + m.roots = vec![0, 1]; + m.skins.push(Skin { joints: vec![0], inverse_bind: vec![Mat4::IDENTITY] }); + let inst = m.instances(None, 0.0); + assert_eq!(inst.len(), 2); + assert!(Arc::ptr_eq(inst[0].joints.as_ref().unwrap(), inst[1].joints.as_ref().unwrap())); +} + +#[test] +fn contour_grouping_is_bounded() { + let sq = |x: f64, y: f64, s: f64| vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]; + let n = extrude::MAX_CONTOURS + 1; + let cs: Vec> = (0..n).map(|i| sq(i as f64 * 2.0, 0.0, 1.0)).collect(); + let g = group_contours(cs); + assert_eq!(g.len(), extrude::MAX_CONTOURS); + assert!(g.iter().all(|o| o.holes.is_empty())); +} + +/// A texture the import leaves out (too large, or not an image) is named in `Model::warnings`, +/// which the import shows the user; the model still loads. +#[test] +fn left_out_textures_are_reported_as_import_warnings() { + // A 24-bit BMP header claiming 9000×9000 (81 MP, over one texture's limit) over no pixel data. + let mut big = b"BM".to_vec(); + big.extend_from_slice(&[0; 8]); + big.extend_from_slice(&54u32.to_le_bytes()); + big.extend_from_slice(&40u32.to_le_bytes()); + big.extend_from_slice(&9000u32.to_le_bytes()); + big.extend_from_slice(&9000u32.to_le_bytes()); + big.extend_from_slice(&[1, 0, 24, 0]); + big.extend_from_slice(&[0; 24]); + let mtl = b"newmtl A\nmap_Kd big.bmp\nnewmtl B\nmap_Kd junk.png\nnewmtl C\nmap_Kd gone.png\n".to_vec(); + let src = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl A\nf 1 2 3\n"; + let m = obj::parse(src, &|name| match name { + "m.mtl" => Some(mtl.clone()), + "big.bmp" => Some(big.clone()), + "junk.png" => Some(b"not an image".to_vec()), + _ => None, + }) + .unwrap(); + assert!(m.textures.is_empty()); + let all = m.warnings.join("|"); + assert!(all.contains("big.bmp") && all.contains("junk.png") && all.contains("gone.png"), "{all}"); } diff --git a/crates/opusenc/Cargo.toml b/crates/opusenc/Cargo.toml index 489c648ea..db7f2b7f8 100644 --- a/crates/opusenc/Cargo.toml +++ b/crates/opusenc/Cargo.toml @@ -12,4 +12,4 @@ workspace = true [dependencies] [dev-dependencies] -filmcraft-opus = { workspace = true } +craft-audio = { workspace = true } diff --git a/crates/opusenc/README.md b/crates/opusenc/README.md index 1ebf6fedb..8a822ceac 100644 --- a/crates/opusenc/README.md +++ b/crates/opusenc/README.md @@ -85,7 +85,7 @@ recursive band splitting, greedy PVQ search. Not implemented: transient frames, pitch post-filter, intensity stereo, dynamic allocation, VBR. All-zero CELT frames become 3-byte silence packets. -## Measured (tests in `tests/`, FilmCraft decoder) +## Measured (tests in `tests/`, craft-audio's Opus decoder) Synthetic speech (glottal pulses 100–200 Hz through three moving formants, syllable envelopes, pauses) and music (three-note harmonic chords); waveform SNR against the input band-limited to @@ -129,8 +129,8 @@ stage-1/2 codebooks and prediction weights, gain, pitch, contour, LTP and pulse weights) and the decoder-side normative arithmetic the encoder must reproduce bit-exactly (CELT 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/it/opus_roundtrip.rs` (ffmpeg is a second, +first-party, spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0). That decoder, now +`craft_audio::opus` in craft-libs, is also the 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/it/common/mod.rs b/crates/opusenc/tests/it/common/mod.rs index 9b420f31b..8f00b88e9 100644 --- a/crates/opusenc/tests/it/common/mod.rs +++ b/crates/opusenc/tests/it/common/mod.rs @@ -124,9 +124,9 @@ pub fn encode(mut enc: OpusEncoder, planar: &[Vec]) -> (OpusEncoder, Vec]) -> Vec> { - let mut dec = filmcraft_opus::Decoder::new(head).expect("OpusHead"); + let mut dec = craft_audio::opus::Decoder::new(head).expect("OpusHead"); dec.set_trim_pre_skip(true); let mut out: Vec> = vec![Vec::new(); dec.channels()]; for p in packets { diff --git a/crates/path/src/interp.rs b/crates/path/src/interp.rs index 4087b26b5..89e7edc51 100644 --- a/crates/path/src/interp.rs +++ b/crates/path/src/interp.rs @@ -311,6 +311,9 @@ fn frame(v: &[P]) -> (P, f64) { (c, r) } +/// Vertex counts above this skip the shape contexts (quadratic to build) and search the starting +/// vertex coarsely; no mask comes near it (the Add Vertices option itself stops at 2000). +const MAX_CONTEXT_VERTICES: usize = 4096; const RBINS: usize = 5; const ABINS: usize = 12; @@ -412,10 +415,18 @@ pub fn correspond(a: &ShapePath, b: &ShapePath, o: &InterpOpts) -> (ShapePath, S } if !o.first_vertices_match { let n = ra.len(); - let use_sc = o.quality > 0.0; + let large = n > MAX_CONTEXT_VERTICES; + let use_sc = o.quality > 0.0 && !large; let (sa, sb) = if use_sc { (Some(shape_contexts(&ra.vertices)), Some(shape_contexts(&rb.vertices))) } else { (None, None) }; // Quality limits the offsets examined exhaustively (coarse step), then refines. - let step = if o.quality >= 50.0 { 1 } else { ((n as f64 / 24.0).ceil() as usize).max(1) }; + // (A very large shape tries about 3 √n offsets, not n, each costing a pass over it.) + let step = if large { + (n as f64).sqrt().ceil() as usize + } else if o.quality >= 50.0 { + 1 + } else { + ((n as f64 / 24.0).ceil() as usize).max(1) + }; let mut best = (f64::MAX, 0usize); let mut k = 0; while k < n { @@ -592,4 +603,22 @@ mod tests { let l2 = (lin.vertices[1][0] - lin.vertices[0][0]).hypot(lin.vertices[1][1] - lin.vertices[0][1]); assert!(l2 < 80.0); } + + /// A shape with tens of thousands of vertices is matched without the quadratic shape contexts + /// and exhaustive offset search. + #[test] + fn very_large_shapes_are_matched_in_bounded_time() { + let n = 30_000; + let circle = |rot: f64| -> ShapePath { + let pts: Vec

= (0..n) + .map(|i| [100.0 * (rot + i as f64 * std::f64::consts::TAU / n as f64).cos(), 100.0 * (rot + i as f64 * std::f64::consts::TAU / n as f64).sin()]) + .collect(); + ShapePath::polygon(&pts, true) + }; + let o = InterpOpts { one_to_one: true, quality: 100.0, ..Default::default() }; + let t = std::time::Instant::now(); + let (a, b) = correspond(&circle(0.0), &circle(1.0), &o); + assert_eq!((a.len(), b.len()), (n, n)); + assert!(t.elapsed().as_secs() < 30, "{:?}", t.elapsed()); + } } diff --git a/crates/path/src/trace.rs b/crates/path/src/trace.rs index 01508bcaf..7b15b55c2 100644 --- a/crates/path/src/trace.rs +++ b/crates/path/src/trace.rs @@ -55,37 +55,85 @@ pub fn trace(field: &[f32], w: usize, h: usize, o: &TraceOpts) -> Vec { return vec![]; } let loops = contours(field, w, h, o.threshold); - let mut polys: Vec<(Vec<[f64; 2]>, Vec<[f64; 2]>, f64)> = loops + // (raw loop, kept raw indices, simplified loop, area) + let mut polys: Vec<(Vec<[f64; 2]>, Vec, Vec<[f64; 2]>, f64)> = loops .into_iter() .filter_map(|raw| { let a = signed_area(&raw); if a.abs() < o.min_area.max(0.0) || raw.len() < 3 { return None; } - let simp = simplify_closed(&raw, o.tolerance.max(0.01)); - if simp.len() < 3 { + let idx = simplify_idx(&raw, o.tolerance.max(0.01)); + if idx.len() < 3 { return None; } - Some((raw, simp, a)) - }) - .collect(); - // Depth: how many other loops contain a point of this loop. - let depths: Vec = (0..polys.len()) - .map(|i| { - let p = polys[i].1[0]; - (0..polys.len()).filter(|&j| j != i && polys[j].2.abs() > polys[i].2.abs() && point_in_poly(p, &polys[j].1)).count() + let simp = idx.iter().map(|&i| raw[i]).collect(); + Some((raw, idx, simp, a)) }) .collect(); + let depths = nesting_depths(&polys.iter().map(|p| (p.2.as_slice(), p.3.abs())).collect::>(), w, h); let mut out: Vec = polys .drain(..) .zip(depths) - .map(|((raw, simp, a), depth)| Traced { path: fit_loop(&raw, &simp, o.corner_roundness.clamp(0.0, 1.0)), depth, area: a }) + .map(|((raw, idx, simp, a), depth)| Traced { path: fit_loop(&raw, &idx, &simp, o.corner_roundness.clamp(0.0, 1.0)), depth, area: a }) .collect(); // Outer outlines first, then holes, islands…; larger first within a depth. out.sort_by(|a, b| a.depth.cmp(&b.depth).then(b.area.abs().total_cmp(&a.area.abs()))); out } +/// Grid cell (pixels) of the index [`nesting_depths`] finds candidate containers with. +const DEPTH_CELL: f64 = 32.0; + +/// Most grid cells one loop's box is entered into; a box covering more is kept in a short list +/// every point is tested against, so nested full-image contours can't fill the grid. +const MAX_BOX_CELLS: usize = 64; + +/// Per loop (simplified points, |area|): how many other, larger loops contain its first point. +/// Only loops whose bounding box holds that point can contain it, so each loop's box is entered +/// into a coarse grid over the `w × h` field and a point is tested against the loops of its +/// cell alone: many separate regions no longer test every pair. +fn nesting_depths(polys: &[(&[[f64; 2]], f64)], w: usize, h: usize) -> Vec { + // Loops lie within the field padded by half a pixel each side. + let (gw, gh) = (((w as f64 + 1.0) / DEPTH_CELL).ceil() as usize + 1, ((h as f64 + 1.0) / DEPTH_CELL).ceil() as usize + 1); + let cell = |v: f64, n: usize| (((v + 0.5) / DEPTH_CELL).floor().max(0.0) as usize).min(n - 1); + let boxes: Vec<[f64; 4]> = polys + .iter() + .map(|(p, _)| { + p.iter().fold([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY], |b, q| { + [b[0].min(q[0]), b[1].min(q[1]), b[2].max(q[0]), b[3].max(q[1])] + }) + }) + .collect(); + let mut grid: Vec> = vec![Vec::new(); gw * gh]; + let mut wide: Vec = Vec::new(); + for (j, b) in boxes.iter().enumerate() { + let (x0, x1, y0, y1) = (cell(b[0], gw), cell(b[2], gw), cell(b[1], gh), cell(b[3], gh)); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > MAX_BOX_CELLS { + wide.push(j); + continue; + } + for cy in y0..=y1 { + for cx in x0..=x1 { + grid[cy * gw + cx].push(j); + } + } + } + (0..polys.len()) + .map(|i| { + let p = polys[i].0[0]; + grid[cell(p[1], gh) * gw + cell(p[0], gw)] + .iter() + .chain(&wide) + .filter(|&&j| { + let b = boxes[j]; + j != i && polys[j].1 > polys[i].1 && p[0] >= b[0] && p[0] <= b[2] && p[1] >= b[1] && p[1] <= b[3] && point_in_poly(p, polys[j].0) + }) + .count() + }) + .collect() +} + /// Marching squares: closed polylines (pixel coordinates). fn contours(field: &[f32], w: usize, h: usize, thr: f32) -> Vec> { // Padded grid: sample (i, j) of the padded grid is pixel (i - 1, j - 1), outside = -inf. @@ -233,23 +281,37 @@ fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { ((p[0] - a[0] - t * dx).powi(2) + (p[1] - a[1] - t * dy).powi(2)).sqrt() } -/// Douglas–Peucker on an open polyline: kept indices. -fn dp(p: &[[f64; 2]], lo: usize, hi: usize, tol: f64, keep: &mut Vec) { - if hi <= lo + 1 { - return; - } - let (mut best, mut bi) = (0.0, lo); - for (k, q) in p.iter().enumerate().take(hi).skip(lo + 1) { - let d = seg_dist(*q, p[lo], p[hi]); - if d > best { - best = d; - bi = k; +/// Distance evaluations Douglas–Peucker may spend per point of a loop. Balanced splits need +/// about log2(n); splits that keep landing next to an end would need up to n. +const DP_WORK_PER_POINT: usize = 64; + +/// Douglas–Peucker on an open polyline: kept indices (unordered). Spans wait on an explicit +/// stack (no recursion depth to overflow), and once `budget` distance evaluations are spent the +/// spans still waiting keep all their points: the outline stays exact, just less simplified. +fn dp(p: &[[f64; 2]], lo: usize, hi: usize, tol: f64, budget: &mut usize, keep: &mut Vec) { + let mut spans = vec![(lo, hi)]; + while let Some((lo, hi)) = spans.pop() { + if hi <= lo + 1 { + continue; + } + if *budget < hi - lo - 1 { + keep.extend(lo + 1..hi); + continue; + } + *budget -= hi - lo - 1; + let (mut best, mut bi) = (0.0, lo); + for (k, q) in p.iter().enumerate().take(hi).skip(lo + 1) { + let d = seg_dist(*q, p[lo], p[hi]); + if d > best { + best = d; + bi = k; + } + } + if best > tol { + keep.push(bi); + spans.push((lo, bi)); + spans.push((bi, hi)); } - } - if best > tol { - dp(p, lo, bi, tol, keep); - keep.push(bi); - dp(p, bi, hi, tol, keep); } } @@ -265,27 +327,23 @@ fn simplify_idx(p: &[[f64; 2]], tol: f64) -> Vec { let mut ext: Vec<[f64; 2]> = p.to_vec(); ext.push(p[0]); let mut keep = vec![0]; - dp(&ext, 0, far, tol, &mut keep); + let mut budget = n.saturating_mul(DP_WORK_PER_POINT); + dp(&ext, 0, far, tol, &mut budget, &mut keep); keep.push(far); - dp(&ext, far, n, tol, &mut keep); + dp(&ext, far, n, tol, &mut budget, &mut keep); keep.sort_unstable(); keep.dedup(); keep.retain(|&i| i < n); keep } -fn simplify_closed(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> { - simplify_idx(p, tol).into_iter().map(|i| p[i]).collect() -} - -/// Fit cubics through the simplified vertices of a loop (`raw` holds the full contour). -fn fit_loop(raw: &[[f64; 2]], simp: &[[f64; 2]], round: f64) -> ShapePath { +/// Fit cubics through the simplified vertices of a loop (`raw` holds the full contour, `idx` +/// the raw index of each simplified vertex, as [`simplify_idx`] kept them). +fn fit_loop(raw: &[[f64; 2]], idx: &[usize], simp: &[[f64; 2]], round: f64) -> ShapePath { let n = simp.len(); if round <= 1e-6 { return ShapePath::polygon(simp, true); } - // Raw index of each simplified vertex (they are raw points). - let idx: Vec = simp.iter().map(|s| raw.iter().position(|r| r == s).unwrap_or(0)).collect(); let pt = |a: [f64; 2]| Point::new(a[0], a[1]); let v = |a: [f64; 2], b: [f64; 2]| Vec2::new(b[0] - a[0], b[1] - a[1]); // Per vertex: (direction leaving, direction arriving), blended between chord and smooth. @@ -349,6 +407,86 @@ mod tests { fill_coverage(&paths, &Mat3::IDENTITY, w as u32, h as u32, FillRule::EvenOdd).data } + #[test] + fn nesting_depths_match_every_pair() { + // Rings within rings beside many separate dots: the grid lookup gives the same depths + // as testing every pair. + let (w, h) = (200, 120); + let mut field = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f64 + 0.5 - 60.0, y as f64 + 0.5 - 60.0); + let r = (fx * fx + fy * fy).sqrt(); + let ring = r < 56.0 && ((r / 8.0) as usize).is_multiple_of(2); + let dot = x >= 130 && x % 6 < 3 && y % 6 < 3; + field[y * w + x] = if ring || dot { 1.0 } else { 0.0 }; + } + } + let opts = TraceOpts { min_area: 1.0, ..Default::default() }; + let t = trace(&field, w, h, &opts); + assert!(t.len() > 200, "{}", t.len()); + let polys: Vec<(Vec<[f64; 2]>, f64)> = t.iter().map(|t| (t.path.vertices.clone(), t.area.abs())).collect(); + let refs: Vec<(&[[f64; 2]], f64)> = polys.iter().map(|(p, a)| (p.as_slice(), *a)).collect(); + let fast = nesting_depths(&refs, w, h); + let pairs: Vec = + (0..refs.len()).map(|i| (0..refs.len()).filter(|&j| j != i && refs[j].1 > refs[i].1 && point_in_poly(refs[i].0[0], refs[j].0)).count()).collect(); + assert_eq!(fast, pairs); + assert_eq!(fast, t.iter().map(|t| t.depth).collect::>()); + assert!(fast.iter().max() >= Some(&5)); + } + + #[test] + fn nesting_depths_count_wide_boxes_kept_out_of_the_grid() { + // Concentric squares whose boxes each cover far more than MAX_BOX_CELLS cells. + let (w, h) = (4000, 4000); + let squares: Vec> = (0..40) + .map(|k| { + let (a, b) = (10.0 + 40.0 * k as f64, 3990.0 - 40.0 * k as f64); + vec![[a, a], [b, a], [b, b], [a, b]] + }) + .collect(); + let refs: Vec<(&[[f64; 2]], f64)> = squares.iter().map(|p| (p.as_slice(), (p[1][0] - p[0][0]).powi(2))).collect(); + assert_eq!(nesting_depths(&refs, w, h), (0..40).collect::>()); + } + + #[test] + fn simplification_work_is_bounded_and_keeps_the_outline() { + // Out of budget, the spans still waiting keep every point: nothing is lost, the outline + // is just less simplified. + let p: Vec<[f64; 2]> = (0..12).map(|i| [i as f64, if i % 2 == 0 { 0.0 } else { 5.0 }]).collect(); + let (mut budget, mut keep) = (0, vec![]); + dp(&p, 0, 11, 0.01, &mut budget, &mut keep); + keep.sort_unstable(); + assert_eq!(keep, (1..11).collect::>()); + // With a budget, the result matches the plain recursion's on an ordinary loop. + let (mut budget, mut keep) = (usize::MAX, vec![]); + dp(&p, 0, 11, 0.01, &mut budget, &mut keep); + keep.sort_unstable(); + assert_eq!(keep, (1..11).collect::>()); + // A long, finely zig-zagging loop: simplified within a budget linear in its length. + let n = 200_000; + let ring: Vec<[f64; 2]> = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + let r = 1000.0 + if i % 2 == 0 { 0.0 } else { 3.0 }; + [r * a.cos(), r * a.sin()] + }) + .collect(); + let idx = simplify_idx(&ring, 0.5); + assert!(idx.windows(2).all(|w| w[0] < w[1]) && idx.len() >= 3 && *idx.last().unwrap() < n); + } + + #[test] + fn fit_uses_the_kept_indices() { + // A contour that passes the same point twice: the fit follows the simplifier's index, + // not the first raw point with those coordinates. + let raw = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 0.0], [-10.0, 10.0], [-10.0, 0.0]]; + let idx = vec![0, 2, 3, 4]; + let simp: Vec<[f64; 2]> = idx.iter().map(|&i| raw[i]).collect(); + let path = fit_loop(&raw, &idx, &simp, 0.5); + assert_eq!(path.vertices, simp); + } + #[test] fn traces_a_disc_and_a_square_with_a_hole() { let (w, h) = (120, 80); diff --git a/crates/path/src/varstroke.rs b/crates/path/src/varstroke.rs index 61e6a1ab7..da00b02d0 100644 --- a/crates/path/src/varstroke.rs +++ b/crates/path/src/varstroke.rs @@ -220,6 +220,56 @@ fn disc(out: &mut BezPath, c: Point, r: f64) { } } +/// `path` with each curve whose extent is over 10⁷ × `tol` replaced by lines flattened at 10⁻⁷ of +/// that extent. `None` when a coordinate is not finite. +fn coarsen_huge(path: &BezPath, tol: f64) -> Option { + use kurbo::Shape as _; + let mut out = BezPath::new(); + let (mut cur, mut start) = (Point::ZERO, Point::ZERO); + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + (cur, start) = (p, p); + out.push(*el); + continue; + } + PathEl::LineTo(p) => { + cur = p; + out.push(*el); + continue; + } + PathEl::ClosePath => { + cur = start; + out.push(*el); + continue; + } + PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => { + let mut one = BezPath::new(); + one.move_to(cur); + one.push(*el); + (one, p) + } + }; + let (one, end) = seg; + let bb = one.bounding_box(); + let extent = bb.width().max(bb.height()); + if !extent.is_finite() { + return None; + } + if extent * 1e-7 > tol { + kurbo::flatten(one.elements().iter().copied(), extent * 1e-7, |e| { + if let PathEl::LineTo(p) = e { + out.line_to(p); + } + }); + } else { + out.push(*el); + } + cur = end; + } + Some(out) +} + /// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale). pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option { if style.width <= 0.0 { @@ -230,7 +280,11 @@ pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option let half = style.width / 2.0; for path in paths { // Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path. - let rough = flatten(path, 0.1 / rs, f64::INFINITY); + // Curves with huge extents are first cut into lines at a tolerance of 10⁻⁷ of their own + // extent, so none subdivides into billions of points before the step below applies; + // every other segment (small curves in a large path included) keeps the ½-pixel tolerance. + let Some(path) = coarsen_huge(path, 0.1 / rs) else { continue }; + let rough = flatten(&path, 0.1 / rs, f64::INFINITY); for poly0 in rough { let len0 = poly0.len(); if len0 <= 0.0 { @@ -396,4 +450,37 @@ mod tests { let area: f32 = m.data.iter().sum(); assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}"); } + + /// A curve with enormous coordinates is approximated within a bounded number of points. + #[test] + fn huge_coordinates_do_not_explode_the_first_approximation() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((1e13, 0.0), (-1e13, 1e13), (1e13, 1e13)); + let st = StrokeStyle { + width: 4.0, + taper: Some(Taper { percent: true, start_len: 50.0, end_len: 50.0, start_width: 0.0, end_width: 0.0, start_ease: 0.0, end_ease: 0.0 }), + ..Default::default() + }; + let t = std::time::Instant::now(); + let o = outline(&[p], &st, 1.0).unwrap(); + assert!(o.elements().len() < 200_000, "{}", o.elements().len()); + assert!(t.elapsed().as_secs() < 20); + } + + /// Small curves keep their shape in a path that also has a huge segment. + #[test] + fn small_curves_survive_a_huge_segment() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((100.0, 0.0), (0.0, 100.0), (0.0, 0.0)); + p.line_to((1e10, 0.0)); + let c = coarsen_huge(&p, 0.1).unwrap(); + assert_eq!(c.elements().len(), p.elements().len(), "nothing here is huge enough to cut"); + let mut q = BezPath::new(); + q.move_to((0.0, 0.0)); + q.curve_to((1e13, 0.0), (-1e13, 1e13), (1e13, 1e13)); + let c = coarsen_huge(&q, 0.1).unwrap(); + assert!(c.elements().iter().skip(1).all(|e| matches!(e, PathEl::LineTo(_))) && c.elements().len() < 10_000); + } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index bf6148569..68aabfbd7 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-eps = { workspace = true } craft-pdf.workspace = true effectcraft-svg = { workspace = true } kurbo = { workspace = true } diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 20f655611..53689ba9a 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -29,7 +29,13 @@ ffmpeg elsewhere) to check the code tables against an independent implementation the app's existing object model. References retain object and generation numbers. A bounded compatibility repair recovers referenced objects from object streams when damaged xrefs omit them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages). - The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`. + The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`. A page + is its CropBox (else MediaBox), turned by `/Rotate` (`Framing::Viewer`, the default). Footage + from an After Effects project is framed as After Effects places a PDF or `.ai` + (`Framing::Placed`, `parse_page_framed`): the page's own ArtBox clipped to the CropBox (else + MediaBox) when it has one, else the CropBox, else the MediaBox, with `/Rotate` ignored + (measured in After Effects 2026 with own files, #3099). Not measured: a CropBox without an + ArtBox, files saved by Illustrator itself, `.pdf` names. Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85, RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the reader refuses a document. @@ -138,7 +144,7 @@ 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 -`92d392cff132eaf53e13afff77f38b5c11b73aa0`, with `default-features = false` and only the explicit `read` +`091863e4a615f38743bfd5d6dc62a2e8f37cd5d5`, 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. diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 0b1586f89..0f295a045 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -7,6 +7,9 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape /// PostScript program, or the PDF content stream. pub(crate) const MAX_PATH: usize = 1 << 20; +/// The deepest the open groups nest (the render tree and its consumers recurse through them). +const MAX_DEPTH: usize = 256; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, @@ -20,6 +23,9 @@ pub(crate) struct Builder { pub top_layers: Vec, pub skipped: Vec, pub shapes: usize, + /// Groups the finished nodes will sit under once placed (an enclosing builder's open groups + /// and the pattern or mask groups around them), counted against [`MAX_DEPTH`]. + base: usize, } pub(crate) fn group(name: &str) -> Group { @@ -28,13 +34,25 @@ pub(crate) fn group(name: &str) -> Group { impl Builder { pub fn new() -> Builder { - Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 } + Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0, base: 0 } + } + + /// A builder for content that will be placed inside this one's open groups and `wrap` more + /// groups (a pattern's `Pattern` and `Tile`, a soft mask's two), so its nesting limit counts + /// them. + pub fn nested(&self, wrap: usize) -> Builder { + Builder { base: self.base.saturating_add(self.stack.len()).saturating_add(wrap), ..Builder::new() } } pub fn depth(&self) -> usize { self.stack.len() } + /// `wrap` more groups fit under the open ones within [`MAX_DEPTH`]. + pub fn has_room(&self, wrap: usize) -> bool { + self.base.saturating_add(self.stack.len()).saturating_add(wrap) <= MAX_DEPTH + } + pub fn skip(&mut self, what: &str) { if !self.skipped.iter().any(|s| s == what) { self.skipped.push(what.to_string()); @@ -62,23 +80,33 @@ impl Builder { } } + /// Open `g` as the group drawn into from now on; past [`MAX_DEPTH`] the group is dropped + /// (what would have been drawn inside it goes to the open group) and noted as skipped. + fn push_group(&mut self, g: Group, kind: Kind) { + if self.base.saturating_add(self.stack.len()) >= MAX_DEPTH { + self.skip("group nesting limit"); + return; + } + self.stack.push((g, kind)); + } + /// Children drawn from now on are clipped by `path` (page space) until the group closes. pub fn clip(&mut self, path: BezPath, rule: FillRule) { let mut g = group("Clip Group"); g.clip.push((path, rule)); // Clipping is not a transparency group: blend modes inside reach the backdrop. g.isolated = false; - self.stack.push((g, Kind::Clip)); + self.push_group(g, Kind::Clip); } /// Open an optional-content layer (or a named group). pub fn open(&mut self, name: &str, layer: bool) { - self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + self.push_group(group(name), if layer { Kind::Layer } else { Kind::Plain }); } /// Open a group (blend mode, soft mask) closed with the graphics state. pub fn open_group(&mut self, g: Group) { - self.stack.push((g, Kind::Plain)); + self.push_group(g, Kind::Plain); } /// Add a finished node (an image, pattern tiles). diff --git a/crates/pdf/src/ccitt.rs b/crates/pdf/src/ccitt.rs index fa013161f..ca6a40d21 100644 --- a/crates/pdf/src/ccitt.rs +++ b/crates/pdf/src/ccitt.rs @@ -401,8 +401,10 @@ fn mode(r: &mut Reader) -> Option { /// (index parity: even indices start black runs), and the one after it. fn b1b2(refl: &[usize], a0: i64, white: bool, columns: usize) -> (usize, usize) { let want = if white { 0 } else { 1 }; - let mut i = 0; - while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) { + // The changing elements are in order, so the first past `a0` is found by bisection (a scan + // from the start made every row quadratic in its transitions). + let mut i = refl.partition_point(|&c| c as i64 <= a0); + if i % 2 != want { i += 1; } let b1 = refl.get(i).copied().unwrap_or(columns); @@ -804,4 +806,24 @@ pub(crate) mod tests { let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }, 1 << 20); } } + + /// `b1b2` by bisection agrees with the scan it replaced, for every position and colour + /// over a dense reference row. + #[test] + fn b1b2_by_bisection_matches_the_scan() { + let scan = |refl: &[usize], a0: i64, white: bool, columns: usize| { + let want = if white { 0 } else { 1 }; + let mut i = 0; + while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) { + i += 1; + } + (refl.get(i).copied().unwrap_or(columns), refl.get(i + 1).copied().unwrap_or(columns)) + }; + let refl: Vec = (0..64).map(|i| i * 3 / 2).collect(); + for a0 in -1..100 { + for white in [true, false] { + assert_eq!(b1b2(&refl, a0, white, 100), scan(&refl, a0, white, 100), "a0 {a0} white {white}"); + } + } + } } diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 61bb7f9eb..cb7214cc2 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -538,16 +538,31 @@ impl Cff { /// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance. pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> { - self.outline_depth(gid, 0) + self.outline_depth(gid, 0, &mut { MAX_GLYPH_OPS }) } - fn outline_depth(&self, gid: u16, depth: usize) -> Option<(BezPath, f64)> { + /// `ops` is the glyph's instruction budget, shared by the base glyph and its seac components. + fn outline_depth(&self, gid: u16, depth: usize, ops: &mut usize) -> Option<(BezPath, f64)> { let r = self.charstrings.get(gid as usize)?.clone(); let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize; let fd = fd.min(self.lsubrs.len() - 1); - let mut st = - T2 { cff: self, fd, path: BezPath::new(), x: 0.0, y: 0.0, stack: vec![], stems: 0, width: None, open: false, done: false, depth: 0, seac: None }; + let mut st = T2 { + cff: self, + fd, + path: BezPath::new(), + x: 0.0, + y: 0.0, + stack: vec![], + stems: 0, + width: None, + open: false, + done: false, + depth: 0, + ops: *ops, + seac: None, + }; st.run(&self.data[r]); + *ops = st.ops; if st.open { st.path.close_path(); } @@ -563,10 +578,10 @@ impl Cff { && depth < 2 { let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n)); - if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1)) { + if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1, ops)) { path.extend(bp); } - if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1)) { + if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1, ops)) { path.extend(kurbo::Affine::translate((adx, ady)) * ap); } } @@ -646,9 +661,16 @@ struct T2<'a> { open: bool, done: bool, depth: usize, + /// Instructions this glyph may still run, across its subroutine calls. + ops: usize, seac: Option<(f64, f64, u8, u8)>, } +/// The most charstring instructions one glyph runs: subroutines that branch (two calls each, +/// ten levels deep) would otherwise multiply a small font into unbounded work and geometry. +/// A real glyph runs a few hundred. +pub(crate) const MAX_GLYPH_OPS: usize = 100_000; + impl T2<'_> { fn move_to(&mut self, dx: f64, dy: f64) { if self.open { @@ -698,6 +720,11 @@ impl T2<'_> { } let mut i = 0; while i < cs.len() && !self.done { + let Some(left) = self.ops.checked_sub(1) else { + self.done = true; + return; + }; + self.ops = left; let b0 = cs[i]; i += 1; match b0 { @@ -958,6 +985,12 @@ impl T2<'_> { /// named by custom strings. #[cfg(test)] pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { + write_test_cff_with_gsubrs(glyphs, &[]) +} + +/// [`write_test_cff`] with global subroutines. +#[cfg(test)] +pub(crate) fn write_test_cff_with_gsubrs(glyphs: &[(&str, Vec)], gsubrs: &[Vec]) -> Vec { fn idx(items: &[Vec]) -> Vec { let mut out = (items.len() as u16).to_be_bytes().to_vec(); if items.is_empty() { @@ -983,7 +1016,7 @@ pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { let names = idx(&[b"Test".to_vec()]); let strings: Vec> = glyphs.iter().skip(1).map(|(n, _)| n.as_bytes().to_vec()).collect(); let strings_idx = idx(&strings); - let gsubrs = idx(&[]); + let gsubrs = idx(gsubrs); let cs = idx(&glyphs.iter().map(|(_, c)| c.clone()).collect::>()); // charset format 0: SIDs of glyphs 1.. (custom strings from 391). let mut charset = vec![0u8]; @@ -1090,6 +1123,43 @@ mod tests { assert!(p.elements().is_empty()); } + /// A global subroutine that calls itself ten times: 10^10 calls within the depth limit of + /// ten. The glyph stops at its instruction budget instead. + #[test] + fn branching_subroutines_stop_at_the_instruction_budget() { + // `-107 callgsubr` (bias 107) is subroutine 0. + let call = [32u8, 29]; + let mut subr = call.repeat(10); + subr.push(11); + let mut cs = call.to_vec(); + cs.push(14); + let font = write_test_cff_with_gsubrs(&[(".notdef", vec![14]), ("A", cs)], &[subr]); + let cff = Cff::parse(&font).unwrap(); + let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap(); + assert!(p.elements().is_empty()); + } + + /// The instruction budget is one per glyph: a seac whose base glyph spends all of it leaves + /// nothing for the accent, instead of each component getting a fresh budget. + #[test] + fn seac_components_share_the_instruction_budget() { + let call = [32u8, 29]; + let mut subr = call.repeat(10); + subr.push(11); + // A square, then the branching subroutine that exhausts the budget. + let mut a = vec![139 + 100, 139 + 100, 139 - 100, 6]; + a.extend(call); + a.push(14); + // `adx ady bchar achar endchar` with both components 'A' (standard code 65). + let seac = vec![139 + 10, 139 + 20, 139 + 65, 139 + 65, 14]; + let font = write_test_cff_with_gsubrs(&[(".notdef", vec![14]), ("A", a), ("Aacute", seac)], &[subr]); + let cff = Cff::parse(&font).unwrap(); + let (alone, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap(); + let (both, _) = cff.outline(cff.gid_by_name("Aacute").unwrap()).unwrap(); + assert!(!alone.elements().is_empty()); + assert_eq!(both.elements().len(), alone.elements().len(), "the accent gets no budget after the base spent it"); + } + /// A CFF whose top DICT points its Private DICT at offsets near `usize::MAX`: `size` and `off` /// come from the file, and reading the Private DICT must not overflow `off + size`. #[test] diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index 6943476bc..5a7e9af86 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -215,19 +215,44 @@ pub enum PsOp { Proc(Vec), } -fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { +/// Parse a procedure. `ops` counts the tokens read (those skipped as unsupported included); past +/// [`MAX_CALC_OPS`] the rest is not read, so an oversized program is never fully scanned or built +/// (the caller rejects it when `ops` ends past the cap). +fn ps_parse(lx: &mut crate::object::Lexer, depth: usize, ops: &mut usize) -> Vec { let mut out = vec![]; - while let Some(o) = lx.next() { - match o { - Obj::Num(n) => out.push(PsOp::Num(n)), - Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })), - Obj::Op(op) if op == "{" && depth < 32 => out.push(PsOp::Proc(ps_parse(lx, depth + 1))), - // Nested too deep: skip the procedure's opening brace (never push it as an operator). - Obj::Op(op) if op == "{" => {} - Obj::Op(op) if op == "}" => return out, - Obj::Op(op) => out.push(PsOp::Op(op)), - _ => {} + loop { + // A calculator holds no arrays or dictionaries: one is skipped as a single token without + // being built (the lexer would allocate every element before this counted it). + if lx.at_compound() { + if *ops > MAX_CALC_OPS { + break; + } + lx.skip_compound(); + *ops += 1; + continue; } + let Some(o) = lx.next() else { break }; + if *ops > MAX_CALC_OPS { + break; + } + let op = match o { + Obj::Num(n) => PsOp::Num(n), + Obj::Bool(b) => PsOp::Num(if b { 1.0 } else { 0.0 }), + Obj::Op(op) if op == "{" && depth < 32 => PsOp::Proc(ps_parse(lx, depth + 1, ops)), + // Nested too deep: skip the procedure's opening brace (never push it as an operator). + Obj::Op(op) if op == "{" => { + *ops += 1; + continue; + } + Obj::Op(op) if op == "}" => return out, + Obj::Op(op) => PsOp::Op(op), + _ => { + *ops += 1; + continue; + } + }; + *ops += 1; + out.push(op); } out } @@ -243,6 +268,15 @@ fn ps_push(st: &mut Vec, v: f64) { } } +/// The most operators and operands a calculator program may hold (nested procedures included). +/// It has no loops, so this bounds what one evaluation runs; real programs hold a few dozen. +const MAX_CALC_OPS: usize = 4096; + +/// The operators and operands in `code`, nested procedures included. +fn ps_len(code: &[PsOp]) -> usize { + code.iter().map(|op| if let PsOp::Proc(p) = op { 1 + ps_len(p) } else { 1 }).sum() +} + fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { if depth > 32 { return; @@ -431,6 +465,12 @@ impl Budget { Budget { nodes: MAX_NODES, samples: MAX_SAMPLES, overrun: false } } + /// Charge `n` retained values (coefficients, bounds, program operators) as nodes, eight to a + /// node, so a cached copy of a function is charged for what it holds, not just its node. + fn take_values(&mut self, n: usize) -> bool { + self.take_nodes(n.div_ceil(8)) + } + /// Charge `n` nodes; `false` (and the read is abandoned) once the cap is passed. fn take_nodes(&mut self, n: usize) -> bool { match self.nodes.checked_sub(n) { @@ -484,7 +524,8 @@ impl Func { /// [`Func::read`] nested `depth` levels deep, charging `budget`: unsupported past [`MAX_DEPTH`] /// or once the budget is spent. fn read_at(file: &File, o: &Obj, depth: usize, budget: &mut Budget) -> Func { - if depth >= MAX_DEPTH || !budget.take_nodes(1) { + // Charged first: the fallback past the depth limit is a node a copy keeps too. + if !budget.take_nodes(1) || depth >= MAX_DEPTH { return Func::Unsupported; } let o = file.resolve(o); @@ -499,12 +540,14 @@ impl Func { let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default(); let domain = [dom.first().copied().unwrap_or(0.0), dom.get(1).copied().unwrap_or(1.0)]; match file.get_num(d, "FunctionType").unwrap_or(-1.0) as i32 { - 2 => Func::Exp { - domain, - c0: file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]), - c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]), - n: file.get_num(d, "N").unwrap_or(1.0), - }, + 2 => { + let c0 = file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]); + let c1 = file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]); + if !budget.take_values(c0.len() + c1.len()) { + return Func::Unsupported; + } + Func::Exp { domain, c0, c1, n: file.get_num(d, "N").unwrap_or(1.0) } + } 3 => { // A stitching function's pieces: no element cap (a repeating gradient lists one // piece once per repeat); the budget bounds the total work. @@ -513,12 +556,12 @@ impl Func { None => Some(vec![]), }; let Some(funcs) = funcs else { return Func::Unsupported }; - Func::Stitch { - domain, - funcs, - bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(), - encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(), + let bounds = file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(); + let encode = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(); + if !budget.take_values(bounds.len() + encode.len()) { + return Func::Unsupported; } + Func::Stitch { domain, funcs, bounds, encode } } 0 => { let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; @@ -537,6 +580,10 @@ impl Func { if !budget.take_samples(count) { return Func::Unsupported; } + // The stream must hold the table: missing bytes are not made up as zeros. + if count.checked_mul(bps).is_none_or(|bits| bits.div_ceil(8) > data.len()) { + return Func::Unsupported; + } let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(); let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)]; let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone()); @@ -567,10 +614,15 @@ impl Func { let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; let mut lx = crate::object::Lexer::new(&data, 0); // The program is one procedure: `{ … }`. - let code = match ps_parse(&mut lx, 0).into_iter().next() { + let mut scanned = 0; + let code = match ps_parse(&mut lx, 0, &mut scanned).into_iter().next() { Some(PsOp::Proc(p)) => p, _ => return Func::Unsupported, }; + let ops = ps_len(&code); + if scanned > MAX_CALC_OPS || ops > MAX_CALC_OPS || !budget.take_values(ops) { + return Func::Unsupported; + } Func::Calc { domain: dom, range: file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(), code } } _ => Func::Unsupported, @@ -756,12 +808,56 @@ mod tests { assert_eq!(sample_count(1 << 22, usize::MAX / (1 << 22) + 1), None); } + #[test] + fn oversized_calculator_program_is_cut_short() { + let src = format!("{{ {}}}", "1 ".repeat(MAX_CALC_OPS + 10)); + let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; + assert_eq!(ps_len(&code), MAX_CALC_OPS + 1); + } + + #[test] + fn arrays_and_dictionaries_in_a_calculator_are_skipped_whole() { + // Strings with brackets, nested arrays and a dictionary are one skipped token each; the + // operators around them still parse. + let src = b"{ 1 [ (a]b) [1 2] << /K [3 4] /S (>>) >> 5 ] 2 add << /A 1 >> mul }"; + let mut lx = crate::object::Lexer::new(src, 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; + assert_eq!(code, vec![PsOp::Num(1.0), PsOp::Num(2.0), PsOp::Op("add".into()), PsOp::Op("mul".into())]); + // A huge array is skipped once the cap is passed, not read to its end. + let src = format!("{{ [ {}] 1 }}", "0 ".repeat(MAX_CALC_OPS * 100)); + let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); + let mut scanned = 0; + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut scanned).into_iter().next() else { panic!() }; + assert_eq!(code, vec![PsOp::Num(1.0)]); + // A large string inside a skipped array is passed over, not decoded: escapes, nested + // parentheses, a hexadecimal string and names all end where the lexer would end them. + let big = format!("{{ [ ({}) <{}> /{} (a\\)b(c)) 7 ] 1 }}", "x".repeat(1 << 20), "ab".repeat(1 << 19), "N".repeat(1 << 10)); + let mut lx = crate::object::Lexer::new(big.as_bytes(), 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; + assert_eq!(code, vec![PsOp::Num(1.0)]); + // An unclosed one ends at the end of the data. + let mut lx = crate::object::Lexer::new(b"[ 1 2 ", 0); + lx.skip_compound(); + assert_eq!(lx.pos, 6); + } + + #[test] + fn skipped_calculator_tokens_count_toward_the_cap() { + // `null` is not a calculator token: skipped, but still scanned. + let src = format!("{{ {}}}", "null ".repeat(MAX_CALC_OPS + 10)); + let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); + let mut scanned = 0; + ps_parse(&mut lx, 0, &mut scanned); + assert!(scanned > MAX_CALC_OPS && scanned < MAX_CALC_OPS + 10, "parsing stops once the cap is passed ({scanned})"); + } + #[test] fn calculator_functions() { // Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 } let code = b"{ 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } { 0.25 mul } ifelse 0 }"; let mut lx = crate::object::Lexer::new(code, 0); - let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() }; + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; let f = Func::Calc { domain: vec![0.0, 1.0, 0.0, 1.0], range: vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0], code }; assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]); assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]); @@ -771,7 +867,7 @@ mod tests { fn run(src: &str, mut st: Vec) -> Vec { let src = format!("{{ {src} }}"); let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); - let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() }; + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0, &mut 0).into_iter().next() else { panic!() }; ps_run(&code, &mut st, 0); st } diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index 64b0a1c3a..8bd91831e 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -211,6 +211,12 @@ fn utf16(b: &[u8]) -> String { String::from_utf16_lossy(&u) } +/// The most code → text mappings a CMap keeps (more than Unicode has code points), and the +/// longest destination string a `bfrange` is expanded with: a range of 65,536 codes would +/// otherwise multiply a long destination into gigabytes of text. +const MAX_UNICODE_MAPPINGS: usize = 1 << 20; +const MAX_RANGE_DST: usize = 256; + fn parse_cmap(data: &[u8]) -> CMap { let mut m = CMap::default(); let mut lx = Lexer::new(data, 0); @@ -239,6 +245,9 @@ fn parse_cmap(data: &[u8]) -> CMap { } "bfchar" => { for p in a.as_chunks::<2>().0 { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } if let (Obj::Str(src), Obj::Str(dst)) = (&p[0], &p[1]) { m.unicode.insert(be(src), utf16(dst)); } @@ -252,9 +261,12 @@ fn parse_cmap(data: &[u8]) -> CMap { continue; } match &p[2] { - Obj::Str(dst) => { + Obj::Str(dst) if dst.len() <= MAX_RANGE_DST => { let mut d = dst.clone(); for c in lo..=hi { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } m.unicode.insert(c, utf16(&d)); if let Some(last) = d.last_mut() { *last = last.wrapping_add(1); @@ -263,6 +275,9 @@ fn parse_cmap(data: &[u8]) -> CMap { } Obj::Array(list) => { for (k, s) in list.iter().enumerate() { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } if let (Obj::Str(s), Some(c)) = (s, lo.checked_add(k as u32)) { m.unicode.insert(c, utf16(s)); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 567dca9c0..ff914e759 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -92,8 +92,25 @@ pub fn parse(bytes: &[u8]) -> Result { /// Parse page `index` of a PDF (EPS files have one page). pub fn parse_page(bytes: &[u8], index: usize) -> Result { + parse_page_framed(bytes, index, Framing::Viewer) +} + +/// Which region of a PDF page becomes the document, and whether `/Rotate` turns it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Framing { + /// As a PDF viewer shows the page: its CropBox (else MediaBox), turned by `/Rotate`. + #[default] + Viewer, + /// As After Effects places a PDF or `.ai` as footage: the page's ArtBox clipped to the + /// CropBox (else MediaBox) when it has one, else the CropBox, else the MediaBox, with + /// `/Rotate` ignored (measured in After Effects 2026 with own files, #3099). + Placed, +} + +/// [`parse_page`] with the page framed as `framing` says (EPS files are their bounding box). +pub fn parse_page_framed(bytes: &[u8], index: usize, framing: Framing) -> Result { match sniff(bytes) { - Some(Format::Pdf) => parse_pdf(bytes, index), + Some(Format::Pdf) => parse_pdf(bytes, index, framing), Some(Format::Eps) => parse_eps(bytes), None => Err(Error::NotVector), } @@ -142,17 +159,22 @@ fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, Doc { width, height, root, skipped } } -fn parse_pdf(bytes: &[u8], index: usize) -> Result { +/// Page `index` of a PDF framed as `framing` says, as a document in points (y down). +fn parse_pdf(bytes: &[u8], index: usize, framing: Framing) -> Result { let file = object::File::parse(bytes)?; let pages = page::pages(&file); if pages.is_empty() { return Err(Error::NoPages); } let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?; - let [x0, y0, x1, y1] = pg.bbox; + let (frame, rotate) = match framing { + Framing::Viewer => (pg.bbox, pg.rotate), + Framing::Placed => (pg.art.unwrap_or(pg.bbox), 0), + }; + let [x0, y0, x1, y1] = frame; let (w, h) = (x1 - x0, y1 - y0); // Default user space (y up) → document pixels (y down), with the page's /Rotate. - let (to_doc, dw, dh) = match pg.rotate { + let (to_doc, dw, dh) = match rotate { 90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w), 180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h), 270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w), diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index 79a91dde8..df475a4bb 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -82,6 +82,91 @@ impl<'a> Lexer<'a> { } } + /// Whether the next token opens an array or a dictionary (skipping white space first). + pub fn at_compound(&mut self) -> bool { + self.skip_ws(); + match self.data.get(self.pos) { + Some(b'[') => true, + Some(b'<') => self.data.get(self.pos + 1) == Some(&b'<'), + _ => false, + } + } + + /// Skip the array or dictionary at the current position (see [`Lexer::at_compound`]) as + /// [`Lexer::next`] would read it, without building it: for readers that have no use for one + /// and must not allocate a vector per element of a hostile stream. + pub fn skip_compound(&mut self) { + let mut open = 0usize; + loop { + self.skip_ws(); + match self.data.get(self.pos) { + None => return, + Some(b'[') => { + open += 1; + self.pos += 1; + } + Some(b'<') if self.data.get(self.pos + 1) == Some(&b'<') => { + open += 1; + self.pos += 2; + } + Some(b']') | Some(b'>') if self.data[self.pos] == b']' || self.data.get(self.pos + 1) == Some(&b'>') => { + open = open.saturating_sub(1); + self.pos += if self.data[self.pos] == b']' { 1 } else { 2 }; + if open == 0 { + return; + } + } + // Anything else is one token (a string, name, number or word), passed over + // without building it. + Some(_) => self.skip_scalar(), + } + } + } + + /// Pass over one token that is not an array or dictionary bracket, as [`Lexer::next`] reads + /// it but without building its value. + fn skip_scalar(&mut self) { + let Some(&c) = self.data.get(self.pos) else { return }; + match c { + b'(' => { + // A literal string: balanced parentheses, a backslash escapes the next byte. + let mut depth = 0usize; + while let Some(&d) = self.data.get(self.pos) { + self.pos += 1; + match d { + b'\\' => self.pos += 1, + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + self.pos = self.pos.min(self.data.len()); + } + b'<' => { + // A hexadecimal string. + while self.data.get(self.pos).is_some_and(|d| *d != b'>') { + self.pos += 1; + } + self.pos = (self.pos + 1).min(self.data.len()); + } + b'/' => { + self.pos += 1; + self.word(); + } + b']' | b'{' | b'}' | b')' | b'>' => self.pos += 1, + _ => { + if self.word().is_empty() { + self.pos += 1; + } + } + } + } + fn word(&mut self) -> &'a [u8] { let s = self.pos; while self.pos < self.data.len() && !is_white(self.data[self.pos]) && !is_delim(self.data[self.pos]) { diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index b7eb70fe0..ba67b39aa 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -23,6 +23,9 @@ pub struct Page { pub resources: Dict, /// CropBox (else MediaBox) `[x0, y0, x1, y1]`. pub bbox: [f64; 4], + /// The page's own ArtBox clipped to [`Page::bbox`] (ISO 32000-1 §14.11.2), when it has one + /// that keeps an area there; not inherited (table 30). + pub art: Option<[f64; 4]>, pub rotate: i32, } @@ -31,13 +34,15 @@ pub fn pages(file: &File) -> Vec { let mut out = vec![]; let Some(cat) = file.catalog() else { return out }; let Some(root) = file.get_dict(cat, "Pages") else { return out }; + /// Collects the pages under `node`, passing on the attributes pages inherit (resources, + /// MediaBox, CropBox, /Rotate); bounded in depth and page count. fn walk(file: &File, node: &Dict, inherited: (Option, Option>, Option>, i32), out: &mut Vec, depth: usize) { if depth > 32 || out.len() > 10_000 { return; } let res = file.get_dict(node, "Resources").cloned().or(inherited.0); - let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1); - let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2); + let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.1); + let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.2); let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3); match file.get(node, "Kids").and_then(Obj::array) { Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => { @@ -49,8 +54,16 @@ pub fn pages(file: &File) -> Vec { } _ => { let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]); - let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])]; - out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) }); + let bbox = normalized(&b).unwrap_or([0.0, 0.0, 612.0, 792.0]); + // The ArtBox is the page's own (not inherited), clipped to the crop box. + let art = file + .get(node, "ArtBox") + .map(|m| file.nums(m)) + .filter(|v| is_area(v)) + .and_then(|v| normalized(&v)) + .map(|a| [a[0].max(bbox[0]), a[1].max(bbox[1]), a[2].min(bbox[2]), a[3].min(bbox[3])]) + .filter(|a| a[2] > a[0] && a[3] > a[1]); + out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, art, rotate: rot.rem_euclid(360) }); } } } @@ -58,6 +71,26 @@ pub fn pages(file: &File) -> Vec { out } +/// A box `[x0, y0, x1, y1]` (corners in any order) with finite corners and a finite span that +/// encloses an area. +fn is_area(v: &[f64]) -> bool { + match v { + [x0, y0, x1, y1] => { + let (w, h) = (x1 - x0, y1 - y0); + v.iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite() && w.abs() > 0.0 && h.abs() > 0.0 + } + _ => false, + } +} + +/// A box with its corners ordered (`x0 < x1`, `y0 < y1`), `None` unless [`is_area`]. +fn normalized(v: &[f64]) -> Option<[f64; 4]> { + match v { + [x0, y0, x1, y1] if is_area(v) => Some([x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)]), + _ => None, + } +} + /// The page's content streams, decoded and joined. pub fn page_content(file: &File, page: &Page) -> Vec { let mut out = vec![]; @@ -636,7 +669,12 @@ impl<'a> Interp<'a> { let bc = f.get(&sm, "BC").map(|b| f.nums(b)).unwrap_or_default(); let gcs = f.get_dict(&gd, "Group").and_then(|gr| f.get(gr, "CS")).map(|c| color_space(f, c, Some(res))).unwrap_or(Cs::Rgb); let backdrop = if bc.is_empty() { [0.0; 3] } else { gcs.to_rgb(&bc) }; - let saved_b = std::mem::replace(&mut self.b, Builder::new()); + // The mask it replaces closes first: its groups don't enclose the new one. + if let Some(d) = self.gs.mask_depth { + self.close_to(d); + } + let inner = self.b.nested(2); + let saved_b = std::mem::replace(&mut self.b, inner); let ctm = self.gs.ctm; let saved_gs = std::mem::replace(&mut self.gs, GState { ctm, ..GState::default() }); let saved_stack = std::mem::take(&mut self.saved); @@ -648,9 +686,6 @@ impl<'a> Interp<'a> { for s in sk { self.b.skip(&s); } - if let Some(d) = self.gs.mask_depth { - self.close_to(d); - } let mut content = Group::new("Soft Mask"); content.children = nodes; let mut grp = Group::new("Soft Mask Group"); @@ -751,8 +786,15 @@ impl<'a> Interp<'a> { if depth > 8 { return; } + // The `Pattern` and `Tile` wrappers must fit under the open groups too. + if !self.b.has_room(2) { + self.b.skip("group nesting limit"); + return; + } // The cell, drawn once in pattern space. - let saved_b = std::mem::replace(&mut self.b, Builder::new()); + // The cell ends up under the open groups and this pattern's `Pattern` and `Tile`. + let inner = self.b.nested(2); + let saved_b = std::mem::replace(&mut self.b, inner); let saved_gs = std::mem::take(&mut self.gs); let saved_stack = std::mem::take(&mut self.saved); let saved_path = std::mem::take(&mut self.path); diff --git a/crates/pdf/src/ps/builtin.rs b/crates/pdf/src/ps/builtin.rs index dcafa9417..a19aeb6da 100644 --- a/crates/pdf/src/ps/builtin.rs +++ b/crates/pdf/src/ps/builtin.rs @@ -1,4 +1,5 @@ use super::eps::find_from; +use super::exec::MAX_STACK; use super::scanner::Src; use super::text::encoding_array; use super::value::{Flow, V, array, matrix_of, matrix_val, new_dict}; @@ -181,8 +182,13 @@ impl Interp { match self.pop() { V::Num(k) => { let k = (k.max(0.0) as usize).min(self.st.len()); - let tail: Vec = self.st[self.st.len() - k..].to_vec(); - self.st.extend(tail); + // A copy past the operand limit copies nothing, and the values copied + // count as operations (one for eight): repeated doubling spends the budget. + if self.st.len().saturating_add(k) <= MAX_STACK { + self.charge(k / 8); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); + } } V::Array(dst) => { let s = self.pop().items().unwrap_or_default(); @@ -507,12 +513,16 @@ impl Interp { fn try_collection_op(&mut self, n: &str) -> bool { match n { // Arrays and strings. + // The storage made counts as operations (a value, or eight bytes, each), so a loop + // of large constructors spends the budget rather than the memory. "array" => { let k = self.popn().clamp(0.0, 1e6) as usize; + self.charge(k); self.push(array(vec![V::Null; k])); } "string" => { let k = self.popn().clamp(0.0, 1e7) as usize; + self.charge(k / 8); self.push(V::Str(Rc::new(RefCell::new(vec![0; k])))); } "length" => { diff --git a/crates/pdf/src/ps/eps.rs b/crates/pdf/src/ps/eps.rs index 90d60cfbe..e89b1d276 100644 --- a/crates/pdf/src/ps/eps.rs +++ b/crates/pdf/src/ps/eps.rs @@ -1,38 +1,26 @@ use super::{HashMap, Rc, is_white}; /// The PostScript section of an EPS file (DOS EPS binary header handled) and its bounding box -/// (`%%HiResBoundingBox` preferred). +/// (`%%HiResBoundingBox` preferred), read with `craft-eps`: the header comments, and the trailer +/// for a box deferred with `(atend)`. A binary header that points outside the file is refused. pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { - let ps = if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && bytes.len() >= 12 { - let off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; - let len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; - bytes.get(off..off.checked_add(len)?.min(bytes.len()))? - } else { - bytes - }; - let find_box = |key: &[u8]| -> Option<[f64; 4]> { - let mut from = 0; - while let Some(p) = ps[from..].windows(key.len()).position(|w| w == key) { - let s = from + p + key.len(); - let e = ps[s..].iter().position(|c| matches!(c, b'\n' | b'\r')).map_or(ps.len(), |q| s + q); - let v: Vec = String::from_utf8_lossy(&ps[s..e]).split_whitespace().filter_map(|t| t.parse().ok()).collect(); - if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { - return Some([v[0], v[1], v[2], v[3]]); - } - from = s; - } - None - }; - let bbox = find_box(b"%%HiResBoundingBox:").or_else(|| find_box(b"%%BoundingBox:"))?; - Some((ps, bbox)) + let ps = craft_eps::sections(bytes).ok()?.postscript; + Some((ps, craft_eps::Comments::read(ps).page_box()?)) } pub(super) fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option { hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) } -fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option { - hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle) +/// Every position of `needle` in `hay`, in order (one pass over the file). +fn positions(hay: &[u8], needle: &[u8]) -> Vec { + let mut out = vec![]; + let mut from = 0; + while let Some(p) = find_from(hay, needle, from) { + out.push(p); + from = p + needle.len(); + } + out } /// The name after `key` (`/FontName /Name`). @@ -48,20 +36,26 @@ fn name_after(b: &[u8], key: &[u8]) -> Option { /// … cleartomark`, hexadecimal or binary) and CFF font sets (`… StartData `). pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> { let mut out = HashMap::new(); + // The headers are found once, in one pass each; every `eexec` then takes the last header + // before it that no earlier `eexec` has used, so no prefix of the file is searched again. + let mut heads = positions(ps, b"%!PS-AdobeFont"); + heads.extend(positions(ps, b"%!FontType1")); + heads.sort_unstable(); + let names = positions(ps, b"/FontName"); let mut from = 0; + let mut used = 0; while let Some(ee) = find_from(ps, b"eexec", from) { from = ee + 5; - let start = [b"%!PS-AdobeFont".as_slice(), b"%!FontType1"] - .iter() - .filter_map(|h| rfind_before(ps, h, ee)) - .max() - .or_else(|| rfind_before(ps, b"/FontName", ee).map(|p| p.saturating_sub(512))); + let last_before = |v: &[usize]| v[..v.partition_point(|&p| p < ee)].last().copied().filter(|&p| p >= used); + // The fallback look-back stops at the previous font's end, so it cannot pick up its header. + let start = last_before(&heads).or_else(|| last_before(&names).map(|p| p.saturating_sub(512).max(used))); let Some(start) = start else { continue }; let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11); if let Some(name) = name_after(&ps[start..ee], b"/FontName") { out.insert(name, (Rc::new(ps[start..end].to_vec()), false)); } from = end; + used = end; } let mut from = 0; while let Some(sd) = find_from(ps, b"StartData", from) { @@ -77,3 +71,30 @@ pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> } out } + +#[cfg(test)] +mod tests { + use super::embedded_fonts; + + #[test] + fn font_discovery_looks_back_once_per_header() { + // 20,000 `eexec` words before any font header: each used to search the whole prefix + // for a header again. The font after them is still found. + let mut ps = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + ps.extend_from_slice("eexec ".repeat(20_000).as_bytes()); + ps.extend_from_slice(b"\n%!PS-AdobeFont-1.0: Real\n/FontName /Real def\ncurrentfile eexec\n0000\ncleartomark\n"); + let fonts = embedded_fonts(&ps); + assert_eq!(fonts.len(), 1); + assert!(fonts.contains_key("Real")); + } + + #[test] + fn header_less_font_after_a_short_one_does_not_take_its_header() { + let a = "%!PS-AdobeFont-1.0: A\n/FontName /A def\ncurrentfile eexec\n0000\ncleartomark\n"; + let b = "/FontName /B def\ncurrentfile eexec\n0000\ncleartomark\n"; + let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n{a}{b}"); + let fonts = embedded_fonts(ps.as_bytes()); + let (data, _) = fonts.get("B").expect("second font found"); + assert!(!String::from_utf8_lossy(data).contains("AdobeFont"), "{}", String::from_utf8_lossy(data)); + } +} diff --git a/crates/pdf/src/ps/exec.rs b/crates/pdf/src/ps/exec.rs index 27697527c..233ff82b0 100644 --- a/crates/pdf/src/ps/exec.rs +++ b/crates/pdf/src/ps/exec.rs @@ -6,6 +6,10 @@ use super::{Affine, BezPath, Builder, Cap, HashMap, Interp, Join}; /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; +/// The most values the operand stack holds: a push past it is dropped, and `copy` copies +/// nothing rather than grow the stack past it. +pub(super) const MAX_STACK: usize = 100_000; + impl Interp { pub fn new(page: [f64; 4]) -> Interp { Interp { @@ -56,11 +60,17 @@ impl Interp { } pub(super) fn push(&mut self, v: V) { - if self.st.len() < 100_000 { + if self.st.len() < MAX_STACK { self.st.push(v); } } + /// Count `n` units of work (values copied, storage allocated) against the budget without + /// stopping the program here: once it is spent, the next operator stops it. + pub(super) fn charge(&mut self, n: usize) { + self.budget = self.budget.saturating_sub(n); + } + pub(super) fn lookup(&self, k: &str) -> Option { self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned()) } diff --git a/crates/pdf/src/ps/paths.rs b/crates/pdf/src/ps/paths.rs index b1fe07092..30b5a7087 100644 --- a/crates/pdf/src/ps/paths.rs +++ b/crates/pdf/src/ps/paths.rs @@ -22,7 +22,7 @@ impl Interp { } /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. - fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + pub(super) fn path_fits(&mut self, len: usize) -> Result<(), Flow> { if len <= MAX_PATH { return Ok(()); } diff --git a/crates/pdf/src/ps/text.rs b/crates/pdf/src/ps/text.rs index b138965bb..d4cee07f8 100644 --- a/crates/pdf/src/ps/text.rs +++ b/crates/pdf/src/ps/text.rs @@ -95,7 +95,14 @@ impl Interp { let ctm = self.gs.ctm; for (i, &code) in bytes.iter().enumerate() { let code = code as u32; + // Every input character costs at least one unit, so empty outlines are not free. + self.spend(1)?; if let Some(g) = font.glyph(code) { + // Each outline element counts against the path limit and the budget before it + // is copied, so a long string cannot expand into unbounded geometry. + let n = g.elements().len(); + self.path_fits(path.elements().len().saturating_add(n))?; + self.spend(n)?; path.extend(ctm * Affine::translate(p.to_vec2()) * m * (*g).clone()); } text.push_str(&font.unicode(code)); diff --git a/crates/pdf/src/shading.rs b/crates/pdf/src/shading.rs index df5e5f9c7..aa83adebf 100644 --- a/crates/pdf/src/shading.rs +++ b/crates/pdf/src/shading.rs @@ -18,6 +18,8 @@ const MAX_SIDE: f64 = 1024.0; const MAX_SCALE: f64 = 4.0; /// The most triangles one shading draws. const MAX_TRIANGLES: usize = 2_000_000; +/// How many times over the mesh raster may be covered by triangle bounding boxes in all. +const MAX_RASTER_PASSES: usize = 64; /// A mesh vertex: page-space position and its value (RGB, or `t` in `[0]` with a function). #[derive(Clone, Copy, Debug, PartialEq)] @@ -234,13 +236,17 @@ fn free_form(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine) -> Vec<[V; 3] /// Type 5: a lattice of `per_row` vertices per row, two triangles per cell. fn lattice(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine, per_row: usize) -> Vec<[V; 3]> { - if per_row < 2 { + if !(2..=MAX_TRIANGLES).contains(&per_row) { return vec![]; } let mut rows: Vec> = vec![]; let mut row = vec![]; while let (Some(p), Some(c)) = (l.point(r), l.comps(r)) { r.align(); + // Vertices kept, the unfinished row's included, stay within the triangle limit. + if (rows.len() * per_row).saturating_add(row.len()) >= MAX_TRIANGLES { + break; + } row.push(V { p: m * p, c: colors.value(&c) }); if row.len() == per_row { rows.push(std::mem::take(&mut row)); @@ -447,6 +453,9 @@ fn rasterize(tris: &[[V; 3]], colors: &Colors, page_box: [f64; 4]) -> Option Option Vec { + let content = "0.5 g 0 0 420 220 re f 0 0 1 rg 64 54 20 10 re f"; + pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, format!("<< /Type /Pages /Kids [3 0 R] /Count 1 {pages} >>"), None), + (3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 220] {page} /Contents 4 0 R >>"), None), + (4, "<< >>".to_string(), Some(content.as_bytes().to_vec())), + ], + 1, + ) +} + +/// The first page of `bytes` framed as After Effects places it. +fn placed(bytes: &[u8]) -> Doc { + parse_page_framed(bytes, 0, Framing::Placed).unwrap() +} + +/// #3099: After Effects 2026 places a .ai or PDF as footage by the page's ArtBox and shows only +/// that region (a page with MediaBox 420 × 220, CropBox 410 × 210, TrimBox 380 × 180 and ArtBox +/// [60 50 300.6 170.4] imports as 241 × 121). `Framing::Placed` frames it so; the viewer framing +/// (the default, used by File ▸ Import) stays the CropBox. +#[test] +fn placed_framing_is_the_art_box() { + let bytes = boxes_pdf("/CropBox [5 5 415 215]", "/TrimBox [20 20 400 200] /ArtBox [60 50 300.6 170.4]"); + let doc = placed(&bytes); + assert!((doc.width - 240.6).abs() < 1e-9 && (doc.height - 120.4).abs() < 1e-9, "{} × {}", doc.width, doc.height); + assert_eq!(doc.pixel_size(), (241, 121)); + // The art box's top-left corner is the document's origin: the marker (PDF x 64..84, y 54..64) + // is at x 4..24, y 106.4..116.4 from the top. + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let marker = img.get(10, 110); + assert!(marker[2] > 0.99 && marker[0] < 0.01, "{marker:?}"); + assert!(img.get(30, 110)[2] < 0.6, "{:?}", img.get(30, 110)); + let viewer = parse(&bytes).unwrap(); + assert_eq!((viewer.width, viewer.height), (410.0, 210.0), "the viewer framing is unchanged"); +} + +/// The ArtBox is clipped to the crop box (ISO 32000-1 §14.11.2); one that keeps no area there, +/// a degenerate or unreadable one, or one on the /Pages node (not inherited, table 30) leaves the +/// crop box (else the media box). +#[test] +fn placed_framing_clips_the_art_box_and_falls_back() { + let size = |pages: &str, page: &str| { + let d = placed(&boxes_pdf(pages, page)); + (d.width, d.height) + }; + let crop = "/CropBox [5 5 415 215]"; + assert_eq!(size(crop, "/ArtBox [-100 -100 1000 1000]"), (410.0, 210.0), "larger than the page"); + assert_eq!(size(crop, "/ArtBox [300 100 900 900]"), (115.0, 115.0), "partly outside"); + let (w, h) = size(crop, "/ArtBox [300.6 170.4 60 50]"); + assert!((w - 240.6).abs() < 1e-9 && (h - 120.4).abs() < 1e-9, "corners in any order: {w} × {h}"); + // Finite corners whose span is not. + let big = format!("1{}", "0".repeat(308)); + let unreadable = [format!("/ArtBox [-{big} 50 {big} 170]")]; + for page in ["/ArtBox [500 300 600 400]", "/ArtBox [60 50 60 170]", "/ArtBox [60 50 300]"].map(String::from).iter().chain(&unreadable) { + assert_eq!(size(crop, page), (410.0, 210.0), "{page}"); + } + assert_eq!(size("/ArtBox [60 50 300 170]", ""), (420.0, 220.0), "an ArtBox on /Pages is not inherited"); + // A crop box that is not a box leaves the media box. + assert_eq!(size("", &format!("/CropBox [-{big} 5 {big} 215]")), (420.0, 220.0)); +} + +/// After Effects ignores /Rotate on placed footage (a page [100 50 400 150] with /Rotate 90 +/// imports as 300 × 100, drawn unturned); the viewer framing turns it. +#[test] +fn placed_framing_ignores_rotate() { + let bytes = boxes_pdf("", "/Rotate 90"); + let doc = placed(&bytes); + assert_eq!((doc.width, doc.height), (420.0, 220.0)); + // Unturned: the marker at PDF (64..84, 54..64) is at x 64..84, y 156..166 from the top. + let img = effectcraft_svg::rasterize(&doc, 420, 220, 1.0); + assert!(img.get(70, 160)[2] > 0.99, "{:?}", img.get(70, 160)); + let viewer = parse(&bytes).unwrap(); + assert_eq!((viewer.width, viewer.height), (220.0, 420.0)); +} + const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\ /m {moveto} bind def /l {lineto} bind def\n\ /cm { 6 array astore concat } bind def\n\ @@ -668,6 +748,63 @@ fn tiling_patterns_coloured_and_uncoloured() { assert_eq!(at(115, 15)[3], 0.0); } +/// A pattern cell nests under the page's open clipping groups and the pattern's own groups, and +/// the render tree stays within the group depth limit that counts all of them. +#[test] +fn a_pattern_cell_counts_the_groups_it_is_nested_in() { + fn depth(g: &effectcraft_svg::Group) -> usize { + 1 + g.children.iter().filter_map(|c| if let effectcraft_svg::Node::Group(g) = c { Some(depth(g)) } else { None }).max().unwrap_or(0) + } + let clips = "0 0 200 100 re W n ".repeat(250); + let cell = format!("{} 1 0 0 rg 0 0 10 10 re f", "0 0 20 20 re W n ".repeat(250)).into_bytes(); + let bytes = page_pdf( + &format!("{clips} /Pattern cs /P1 scn 0 0 100 100 re f"), + "/Pattern << /P1 10 0 R >>", + vec![(10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell))], + ); + let doc = parse(&bytes).unwrap(); + assert!(depth(&doc.root) <= 260, "{}", depth(&doc.root)); + assert!(doc.skipped.iter().any(|s| s == "group nesting limit"), "{:?}", doc.skipped); +} + +/// A pattern whose `Pattern` and `Tile` wrappers would pass the depth limit is skipped, not built +/// past it. +#[test] +fn a_pattern_whose_wrappers_pass_the_depth_limit_is_skipped() { + let clips = "0 0 200 100 re W n ".repeat(255); + let bytes = page_pdf( + &format!("{clips} /Pattern cs /P1 scn 0 0 100 100 re f"), + "/Pattern << /P1 10 0 R >>", + vec![( + 10, + "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), + Some(b"0 0 10 10 re f".to_vec()), + )], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.iter().any(|s| s == "group nesting limit"), "{:?}", doc.skipped); +} + +/// A soft mask replacing another is nested under what is open once the old one has closed, so +/// groups inside the old mask don't count against the new mask's content. +#[test] +fn a_replaced_soft_mask_does_not_count_against_the_new_one() { + let form = |n: &str| { + ( + n.parse().unwrap(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".to_string(), + Some(format!("{} 1 g 0 0 100 100 re f", "0 0 200 100 re W n ".repeat(250)).into_bytes()), + ) + }; + let bytes = page_pdf( + &format!("/GS1 gs {} /GS2 gs 1 0 0 rg 0 0 200 100 re f", "0 0 200 100 re W n ".repeat(250)), + "/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> /GS2 << /SMask << /S /Luminosity /G 11 0 R >> >> >>", + vec![form("10"), form("11")], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.iter().all(|s| s != "group nesting limit"), "{:?}", doc.skipped); +} + /// Build isolated interpreter objects without presenting a function or colour space as a /// document catalog. Complete-document parsing is covered by the page and reader fixtures. fn object_file(objs: Objs) -> object::File { @@ -988,6 +1125,35 @@ fn a_fanning_out_reference_graph_is_refused_within_the_budget() { assert_eq!(read_function(objs), color::Func::Unsupported); } +#[test] +fn a_sampled_function_shorter_than_its_table_is_refused() { + // 4,096 8-bit samples declared, ten bytes given: the missing ones are not made up. + let f = "<< /FunctionType 0 /Domain [0 1] /Size [4096] /Range [0 1] /BitsPerSample 8 >>"; + assert_eq!(read_function(vec![(1, f.into(), Some(vec![7; 10]))]), color::Func::Unsupported); + assert_ne!(read_function(vec![(1, f.into(), Some(vec![7; 4096]))]), color::Func::Unsupported); +} + +#[test] +fn a_calculator_program_past_the_operator_limit_is_refused() { + let f = "<< /FunctionType 4 /Domain [0 1] /Range [0 1] >>"; + let long = format!("{{ {} }}", "1 pop ".repeat(3000)); + assert_eq!(read_function(vec![(1, f.into(), Some(long.into_bytes()))]), color::Func::Unsupported); + assert_ne!(read_function(vec![(1, f.into(), Some(b"{ 1 exch sub }".to_vec()))]), color::Func::Unsupported); +} + +#[test] +fn copies_of_a_function_are_charged_for_its_coefficients() { + // A stitching function listing one object 1,000 times, whose coefficient arrays hold 2,000 + // values: each copy is charged its values, so the budget refuses it instead of copying + // two million values. + let c = format!("[{}]", "0.5 ".repeat(1000)); + let objs: Objs = vec![ + (1, format!("<< /FunctionType 3 /Domain [0 1] /Functions [{}] >>", "2 0 R ".repeat(1000)), None), + (2, format!("<< /FunctionType 2 /Domain [0 1] /C0 {c} /C1 {c} /N 1 >>"), None), + ]; + assert_eq!(read_function(objs), color::Func::Unsupported); +} + #[test] fn colour_space_chain_of_depth_3() { // Indexed → Separation → ICCBased → DeviceRGB: entry 0 is tint 1 (blue), entry 1 tint 0 (white). diff --git a/crates/pdf/src/tests/import.rs b/crates/pdf/src/tests/import.rs index c7dcbee57..f8c6509c2 100644 --- a/crates/pdf/src/tests/import.rs +++ b/crates/pdf/src/tests/import.rs @@ -513,6 +513,9 @@ fn paths_stop_at_the_path_limit() { // The outlines of 100,000 H's, 13 elements each. "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ 0 0 moveto s false charpath", + // The same outlines shown: the text path is bounded like the current path. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s show", // 210,000 rectangles of five elements each. "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", ] { @@ -528,6 +531,46 @@ fn paths_stop_at_the_path_limit() { assert_eq!(doc.flatten().len(), 1); } +#[test] +fn copies_past_the_operand_limit_copy_nothing() { + // `count copy` doubles the operand stack: 40 doublings would be 2^40 values. A copy that would + // pass the operand limit (100,000) copies nothing, so the program runs on to its end. + let doc = eps_soon("0 0 10 10 rectfill 1 40 { count copy } repeat clear 20 20 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 2); +} + +#[test] +fn storage_made_in_a_loop_runs_out_of_the_budget() { + // Each value of an array and each eight bytes of a string count as an operation when they + // are made, so a loop keeping large arrays or strings spends the budget, not the memory. + for body in ["/a 1000 array def 0 1 999 { a exch 1000000 array put } for", "/a 1000 array def 0 1 999 { a exch 10000000 string put } for"] { + runs_out_of_budget(body); + } +} + +#[test] +fn groups_stop_nesting_at_the_depth_limit() { + // Every `rectclip` opens a clipping group inside the last: past 256 open groups the group is + // dropped (and noted), and what is drawn inside goes to the open one. + let doc = eps_soon("300 { 0 0 100 100 rectclip } repeat 0 0 10 10 rectfill"); + assert_eq!(doc.skipped, ["group nesting limit"]); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn bounding_box_comments_are_scanned_once() { + // A rejected `%%BoundingBox:` comment is not scanned again from each later marker on its + // line: 20,000 markers on one line would otherwise take quadratic work. + let mut src = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + // Keep the valid comment inside craft-eps's bounded header window. + src.extend_from_slice("%%BoundingBox: x ".repeat(3_000).as_bytes()); + src.extend_from_slice(b"\n%%BoundingBox: 0 0 50 50\n0 0 10 10 rectfill\n"); + let doc = parse(&src).unwrap(); + assert_eq!((doc.width, doc.height), (50.0, 50.0)); + assert_eq!(doc.flatten().len(), 1); +} + #[test] fn pdf_paths_stop_at_the_path_limit() { // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each) diff --git a/crates/pdf/src/tests/shading.rs b/crates/pdf/src/tests/shading.rs index 8e96193ab..4b5f9645d 100644 --- a/crates/pdf/src/tests/shading.rs +++ b/crates/pdf/src/tests/shading.rs @@ -262,3 +262,32 @@ fn corrupt_mesh_data_draws_what_parses() { let doc = parse(&bytes).unwrap(); assert!(doc.skipped.iter().any(|s| s.contains("layout")), "{:?}", doc.skipped); } + +#[test] +fn overlapping_mesh_triangles_stop_at_the_raster_budget() { + // 20,000 triangles each covering the whole page would test every sample 20,000 times; the + // raster budget stops after about 64 passes, and the shading is still drawn. + let mut m = Mesh::default(); + for _ in 0..20_000 { + m.flag(0).pt(0, 0).c(&[255, 0, 0]); + m.flag(0).pt(400, 0).c(&[255, 0, 0]); + m.flag(0).pt(0, 400).c(&[255, 0, 0]); + } + let sh = format!("<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"); + let bytes = page_pdf("/Sh sh", "/Shading << /Sh 10 0 R >>", vec![(10, sh, Some(m.0))]); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(parse(&bytes)).ok()); + let doc = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("the mesh finishes").unwrap(); + assert!(render(&doc).get(50, 50)[0] > 0.99); +} + +#[test] +fn a_lattice_row_longer_than_the_triangle_limit_draws_nothing() { + let mut m = Mesh::default(); + for i in 0..8 { + m.pt(i * 10, 0).c(&[255, 0, 0]); + } + let sh = format!("<< /ShadingType 5 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /VerticesPerRow 3000000 {RGB_DECODE} >>"); + let doc = parse(&page_pdf("/Sh sh", "/Shading << /Sh 10 0 R >>", vec![(10, sh, Some(m.0))])).unwrap(); + assert!(doc.flatten().is_empty()); +} diff --git a/crates/pdf/src/type1.rs b/crates/pdf/src/type1.rs index 4b3e05756..399905722 100644 --- a/crates/pdf/src/type1.rs +++ b/crates/pdf/src/type1.rs @@ -148,8 +148,9 @@ impl Type1 { }; if let Some(p) = find(&priv_, b"/Subrs", 0) { let mut q = p + 6; + // The section ends at `/CharStrings`, found once rather than for every entry. + let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); while let Some(d) = find(&priv_, b"dup", q) { - let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); if d > lim { break; } @@ -205,10 +206,11 @@ impl Type1 { /// A glyph's outline in glyph space and its advance width. pub fn outline(&self, name: &str) -> Option<(BezPath, f64)> { - self.outline_depth(name, 0) + self.outline_depth(name, 0, &mut { crate::cff::MAX_GLYPH_OPS }) } - fn outline_depth(&self, name: &str, depth: usize) -> Option<(BezPath, f64)> { + /// `ops` is the glyph's instruction budget, shared by the base glyph and its seac components. + fn outline_depth(&self, name: &str, depth: usize, ops: &mut usize) -> Option<(BezPath, f64)> { let cs = self.charstrings.get(name)?; let mut st = T1 { font: self, @@ -223,9 +225,11 @@ impl Type1 { flex: None, done: false, depth: 0, + ops: *ops, seac: None, }; st.run(cs); + *ops = st.ops; if st.open { st.path.close_path(); } @@ -234,10 +238,10 @@ impl Type1 { && depth < 2 { let name_of = |c: u8| crate::encoding::Base::Standard.name(c); - if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1)) { + if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1, ops)) { path.extend(bp); } - if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1)) { + if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1, ops)) { // The accent's origin: its own sidebearing is replaced by `asb`. path.extend(kurbo::Affine::translate((adx - asb + st.sbx, ady)) * ap); } @@ -270,6 +274,8 @@ struct T1<'a> { flex: Option>, done: bool, depth: usize, + /// Instructions this glyph may still run, across its subroutine calls. + ops: usize, seac: Option<(f64, f64, f64, u8, u8)>, } @@ -314,6 +320,12 @@ impl T1<'_> { } let mut i = 0; while i < cs.len() && !self.done { + // Subroutines that branch would multiply the work: the budget is shared. + let Some(left) = self.ops.checked_sub(1) else { + self.done = true; + return; + }; + self.ops = left; let b0 = cs[i]; i += 1; let s = |st: &Self, k: usize| st.stack.get(k).copied().unwrap_or(0.0); @@ -533,6 +545,25 @@ mod tests { assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + /// A subroutine that calls itself ten times: 10^10 calls within the depth limit of ten. The + /// glyph stops at its instruction budget instead. + #[test] + fn branching_subroutines_stop_at_the_instruction_budget() { + let mut out = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\ncurrentfile eexec\n".to_vec(); + // `0 callsubr` is `139 10`; `return` is 11, `endchar` 14 (lenIV -1: charstrings in clear). + let mut subr = [139u8, 10].repeat(10); + subr.push(11); + let mut priv_ = b"dup /Private 8 dict dup begin /lenIV -1 def\n/Subrs 1 array\n".to_vec(); + priv_.extend_from_slice(format!("dup 0 {} RD ", subr.len()).as_bytes()); + priv_.extend_from_slice(&subr); + priv_.extend_from_slice(b" NP\nend\n/CharStrings 1 dict dup begin\n/A 3 RD \x8b\x0a\x0e ND\nend\nend\nmark currentfile closefile\n"); + out.extend(encrypt(&priv_, EEXEC_R, 4)); + out.extend_from_slice(b"\n0000000000000000\ncleartomark\n"); + let t = Type1::parse(&out).unwrap(); + let (p, _) = t.outline("A").unwrap(); + assert!(p.elements().is_empty()); + } + /// A `/Subrs` entry whose `RD` length is `usize::MAX`: the bytes are not in the file, and /// `q + n` must not overflow. #[test] diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index 4081b61b6..d9b35a91a 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-plugin" -description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1) into the effect registry" +description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (WebAssembly ABI v1 and v2) into the effect registry" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 7c85e144b..da21e22a0 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,21 +1,25 @@ //! # effectcraft-plugin (L3) //! -//! Loads **WebAssembly effect plug-ins** (plug-in ABI v1, see `docs/plugins.md`; the native -//! plug-in API 2 additions such as layer parameters and host services are not part of it) into the effect -//! registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or `.wat`) module with -//! no imports that exports: +//! Loads **WebAssembly effect plug-ins** (WebAssembly ABI v1 and v2, see `docs/plugins.md`; the +//! native plug-in API 2 additions such as layer parameters and host services are not part of it) +//! into the effect registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or +//! `.wat`) module with no imports that exports: //! //! | export | signature | | //! |---|---|---| //! | `memory` | memory | linear memory shared with the host | -//! | `ec_api_version` | `() -> i32` | must return 1 | +//! | `ec_api_version` | `() -> i32` | 1 for ABI 1, 2 for ABI 2 (neighbouring frames) | //! | `ec_manifest_ptr`, `ec_manifest_len` | `() -> i32` | a UTF-8 JSON [`PluginManifest`] in memory | //! | `ec_alloc` | `(bytes: i32) -> i32` | a buffer of at least `bytes` bytes, 8-byte aligned; it may reuse the previous one | -//! | `ec_render` | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; 0 = ok | +//! | `ec_render` (ABI 1) | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; 0 = ok | +//! | `ec_render2` (ABI 2) | `ec_render`'s arguments, then `prev: i32, prev_time: f64, next: i32, next_time: f64, flags: i32` | the same, with the neighbouring frames; 0 = ok | //! //! For each render the host asks `ec_alloc` for one buffer holding the parameters (`nparams` //! little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s, -//! premultiplied RGBA, row-major), calls `ec_render`, and reads the pixels back. +//! premultiplied RGBA, row-major), calls the render export, and reads the pixels back. An ABI 2 +//! module gets two more pixel buffers after the current frame: the layer's previous and next +//! frames, resampled onto the current grid, and their layer times. The buffers are always +//! allocated (zeroed when a frame does not exist); `flags` bit 0/1 says which are real. //! //! Modules run in the [wasmi](https://github.com/wasmi-labs/wasmi) interpreter (pure Rust, //! MIT/Apache-2.0) with deterministic floats, no imports (no files, network or clock) and a @@ -26,7 +30,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use effectcraft_effects::EffectSpec; -pub use effectcraft_effects::plugin::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION, register_plugin}; +pub use effectcraft_effects::plugin::{ + EffectPlugin, MIN_WASM_API_VERSION, PLUGIN_API_VERSION, PluginFrame, PluginImage, PluginManifest, PluginParams, WASM_API_VERSION, register_plugin, +}; #[cfg(feature = "wasm")] mod wasm; diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index f68fde00a..a4fadb26b 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -163,11 +163,15 @@ fn wasm_plugins_are_sandboxed() { // No imports (files, clock, network…). let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#; assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import")); - // Garbage, wrong API version, bad manifests. + // Garbage, an ABI this build doesn't implement, bad manifests. A module that claims ABI 2 + // must export `ec_render2` (this one still has only `ec_render`). assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err()); let v2 = gain_module("org.test.v2", "") .replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)"); - assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2")); + assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("ec_render2")); + let v3 = gain_module("org.test.v3", "") + .replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 3)"); + assert!(load_err(load_wasm(v3.as_bytes(), "v3.wat")).contains("ABI 3")); let reserved = gain_module("ec.blur.mine", ""); assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved")); } @@ -194,14 +198,14 @@ fn abi_violations_fail_to_load_with_reasons() { assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_api_version")); let m = edit(&base, "(param $scale f64) (result i32)", "(param $scale f32) (result i32)"); assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_render")); - // API versions other than 1. - for v in ["0", "-1", "2147483647"] { + // WebAssembly ABIs other than 1 and 2. + for v in ["0", "-1", "3", "2147483647"] { let m = edit( &base, "(func (export \"ec_api_version\") (result i32) i32.const 1)", &format!("(func (export \"ec_api_version\") (result i32) i32.const {v})"), ); - assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("API {v}")), "{v}"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("ABI {v}")), "{v}"); } // Manifest out of bounds, negative or absurd lengths, not JSON. let ptr = "(func (export \"ec_manifest_ptr\") (result i32) i32.const 16)"; @@ -317,3 +321,168 @@ fn wasm_rendering_is_deterministic() { let twin = load_wasm(gain_module("org.test.det2", body).as_bytes(), "det2.wat").unwrap(); assert_eq!(bits(&render(twin, 0.37, true)), first); } + +/// An ABI 2 module whose `ec_render2` writes what it got from the host into each pixel: red is +/// |next.red − prev.red|, green `flags / 8`, blue `prev_time`, alpha 1. +fn neighbour_module(id: &str) -> String { + let manifest = format!(r#"{{"api":2,"id":"{id}","name":"Neighbours {id}","category":"Test Plug-ins","version":"1","params":[]}}"#); + let len = manifest.len(); + let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\""); + format!( + r#"(module + (memory (export "memory") 2) + (data (i32.const 16) "{escaped}") + (func (export "ec_api_version") (result i32) i32.const 2) + (func (export "ec_manifest_ptr") (result i32) i32.const 16) + (func (export "ec_manifest_len") (result i32) i32.const {len}) + (func (export "ec_alloc") (param i32) (result i32) i32.const 1024) + (func (export "ec_render2") + (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) + (param $t f64) (param $scale f64) + (param $prev i32) (param $prev_t f64) (param $next i32) (param $next_t f64) (param $flags i32) + (result i32) + (local $i i32) (local $n i32) + local.get $w + local.get $h + i32.mul + local.set $n + block $done + loop $per_pixel + local.get $i + local.get $n + i32.ge_u + br_if $done + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $next + local.get $i + i32.const 16 + i32.mul + i32.add + f32.load + local.get $prev + local.get $i + i32.const 16 + i32.mul + i32.add + f32.load + f32.sub + f32.abs + f32.store + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $flags + f32.convert_i32_s + f32.const 8 + f32.div + f32.store offset=4 + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + local.get $prev_t + f32.demote_f64 + f32.store offset=8 + local.get $px + local.get $i + i32.const 16 + i32.mul + i32.add + f32.const 1 + f32.store offset=12 + local.get $i + i32.const 1 + i32.add + local.set $i + br $per_pixel + end + end + i32.const 0))"# + ) +} + +fn neighbour_image(red: f32) -> effectcraft_effects::plugin::PluginImage { + effectcraft_effects::plugin::PluginImage { width: 2, height: 1, pixels: vec![[red, 0.0, 0.0, 1.0]; 2], origin: [0.0; 2], scale: 1.0 } +} + +/// A host with frames at two layer times. +struct Host { + time: f64, + fps: f64, + prev: Option<(f64, effectcraft_effects::plugin::PluginImage)>, + next: Option<(f64, effectcraft_effects::plugin::PluginImage)>, +} + +impl effectcraft_effects::plugin::PluginHost for Host { + fn input_at(&self, layer_time: f64) -> Option { + if let Some((t, img)) = &self.prev + && (t - layer_time).abs() < 1e-9 + { + return Some(img.clone()); + } + if let Some((t, img)) = &self.next + && (t - layer_time).abs() < 1e-9 + { + return Some(img.clone()); + } + None + } + + fn layer_input(&self, _param_id: &str, _layer_time: f64) -> Option { + None + } + + fn params_at(&self, _layer_time: f64) -> Option<(Vec, Vec)> { + None + } + + fn frame_rate(&self) -> f64 { + self.fps + } + + fn time(&self) -> f64 { + self.time + } +} + +#[test] +fn abi_v2_receives_neighbour_frames_and_times() { + load_wasm(neighbour_module("org.test.neighbours").as_bytes(), "neighbours.wat").unwrap(); + let p = effectcraft_effects::plugin::plugin("org.test.neighbours").unwrap(); + assert!(effectcraft_effects::plugin::reads_other_times("org.test.neighbours")); + assert!(effectcraft_effects::is_time_dependent("org.test.neighbours")); + // The module has no parameters, so the flattened vector is empty. + let params = effectcraft_effects::plugin::PluginParams { manifest: p.manifest(), flat: &[], texts: &[] }; + let mut px = vec![[0.0f32; 4]; 2]; + let host = Host { time: 0.5, fps: 4.0, prev: Some((0.25, neighbour_image(0.1))), next: Some((0.75, neighbour_image(0.6))) }; + let mut frame = effectcraft_effects::plugin::PluginFrame { width: 2, height: 1, pixels: &mut px, scale: 1.0, origin: [0.0; 2], layer_size: [2.0, 1.0] }; + p.render_v2(&mut frame, ¶ms, 0.5, &host).unwrap(); + assert!((px[0][0] - 0.5).abs() < 1e-6, "|next − prev|: {px:?}"); + assert!((px[0][1] - 3.0 / 8.0).abs() < 1e-6, "both flags: {px:?}"); + assert!((px[0][2] - 0.25).abs() < 1e-6, "previous frame's time: {px:?}"); + assert_eq!(px[0][3], 1.0); +} + +#[test] +fn abi_v2_without_a_host_gets_zeroed_neighbours() { + let spec = load_wasm(neighbour_module("org.test.neighbours.empty").as_bytes(), "empty.wat").unwrap(); + let mut next_id = 1; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next_id), spec.name, [2.0, 1.0]); + let params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone()); + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [2.0, 1.0], seed: 1, adjustment: false, env: EffectEnv::default() }; + let buf = Buf { img: Image::filled(2, 1, [0.5, 0.5, 0.5, 1.0]), offset: [0.0; 2], scale: 1.0 }; + // No host: both neighbour buffers are zeroed and no flag is set. + assert_eq!((spec.render)(&ctx, buf).img.get(0, 0), [0.0, 0.0, 0.0, 1.0]); + // ABI 1 plug-ins keep the time-independent behaviour. + let gain = load_wasm(gain_module("org.test.neighbours.v1", "").as_bytes(), "v1.wat").unwrap(); + assert!(!effectcraft_effects::plugin::reads_other_times("org.test.neighbours.v1")); + assert!(!effectcraft_effects::is_time_dependent("org.test.neighbours.v1")); + assert!(gain.id.starts_with("org.test.")); +} diff --git a/crates/plugin/src/wasm.rs b/crates/plugin/src/wasm.rs index 18a34548f..18f3fb0d0 100644 --- a/crates/plugin/src/wasm.rs +++ b/crates/plugin/src/wasm.rs @@ -1,10 +1,11 @@ -//! The wasmi-backed [`EffectPlugin`] (plug-in API v1 ABI, see the crate docs). +//! The wasmi-backed [`EffectPlugin`] (WebAssembly ABI v1 and v2, see the crate docs). use std::sync::Mutex; use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, WasmParams, WasmResults}; -use crate::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION}; +use crate::{EffectPlugin, MIN_WASM_API_VERSION, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION}; +use effectcraft_effects::plugin::{PluginHost, PluginImage}; /// Instruction budget per frame: a fixed allowance plus some per pixel. Generous for per-pixel /// work, but a runaway loop stops instead of hanging the render. @@ -12,50 +13,103 @@ const FUEL_BASE: u64 = 50_000_000; const FUEL_PER_PIXEL: u64 = 20_000; /// The most linear memory a plug-in instance may have (a 4K float frame is ~130 MB). const MAX_MEMORY: usize = 1 << 30; +/// The most elements one table of a plug-in may hold. +const MAX_TABLE_ELEMENTS: usize = 1 << 16; /// The longest manifest read. const MAX_MANIFEST: i32 = 1 << 20; +/// ABI 1: one buffer. type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>; +/// ABI 2: the same call plus the previous and next frame buffers and their layer times. +type Render2Fn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64, i32, f64, i32, f64, i32), i32>; + +enum Renderer { + V1(RenderFn), + V2(Render2Fn), +} /// One instantiated module (renders running on several threads each take their own). struct Inst { + store: Store, + memory: Memory, + alloc: TypedFunc, + render: Renderer, +} + +/// An instance whose ABI version (and so its render export) is not selected yet. +struct Pending { store: Store, instance: Instance, memory: Memory, alloc: TypedFunc, - render: RenderFn, } -impl Inst { +impl Pending { + /// A typed export by name, reporting a missing or mistyped export like [`missing`]. fn func(&self, name: &str) -> Result, String> { self.instance.get_typed_func::(&self.store, name).map_err(|_| missing(name)) } + + /// Select the render export for `abi`. + fn finish(self, abi: u32) -> Result { + let render = match abi { + 1 => Renderer::V1(self.instance.get_typed_func::<_, i32>(&self.store, "ec_render").map_err(|_| missing("ec_render"))?), + 2 => Renderer::V2(self.instance.get_typed_func::<_, i32>(&self.store, "ec_render2").map_err(|_| missing("ec_render2"))?), + v => return Err(unsupported(v as i32)), + }; + Ok(Inst { store: self.store, memory: self.memory, alloc: self.alloc, render }) + } } +/// The error for a missing or mistyped export. fn missing(name: &str) -> String { format!("the module exports no `{name}` (or with the wrong signature)") } +/// The error for an ABI this build does not implement. +fn unsupported(v: i32) -> String { + format!("the module implements WebAssembly ABI {v}, this build implements ABI {MIN_WASM_API_VERSION} to {WASM_API_VERSION}") +} + +/// The module's `ec_api_version`, when this build implements it. +fn abi_of(v: i32) -> Result { + match u32::try_from(v) { + Ok(v) if (MIN_WASM_API_VERSION..=WASM_API_VERSION).contains(&v) => Ok(v), + _ => Err(unsupported(v)), + } +} + +/// The error for a wasmi or trap failure. fn err(e: impl std::fmt::Display) -> String { e.to_string() } -fn instantiate(engine: &Engine, module: &Module) -> Result { - let mut store = Store::new(engine, StoreLimitsBuilder::new().memory_size(MAX_MEMORY).instances(1).build()); +/// Instantiate `module` in a fresh, limited store and read its memory and allocator. +fn begin(engine: &Engine, module: &Module) -> Result { + // One memory and a few small tables: a module declaring many of them would otherwise make + // the host allocate and initialise each before an export or fuel check could refuse it. + let limits = StoreLimitsBuilder::new().memory_size(MAX_MEMORY).memories(1).tables(4).table_elements(MAX_TABLE_ELEMENTS).instances(1).build(); + let mut store = Store::new(engine, limits); store.limiter(|l| l); store.set_fuel(FUEL_BASE).map_err(err)?; let linker = Linker::::new(engine); let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?; let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?; let alloc = instance.get_typed_func::(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?; - let render = instance.get_typed_func::<(i32, i32, i32, i32, i32, f64, f64), i32>(&store, "ec_render").map_err(|_| missing("ec_render"))?; - Ok(Inst { store, instance, memory, alloc, render }) + Ok(Pending { store, instance, memory, alloc }) +} + +/// Instantiate `module` and select the render export for `abi`. +fn instantiate(engine: &Engine, module: &Module, abi: u32) -> Result { + begin(engine, module)?.finish(abi) } /// A loaded WebAssembly effect plug-in. pub struct WasmPlugin { manifest: PluginManifest, source: String, + /// The WebAssembly ABI the module implements (`ec_api_version`). + abi: u32, engine: Engine, module: Module, /// Idle instances. @@ -63,6 +117,7 @@ pub struct WasmPlugin { } impl WasmPlugin { + /// Load `bytes` and its manifest; errors on imports, a bad manifest or a missing export. pub fn new(bytes: &[u8], source: &str) -> Result { let mut config = Config::default(); config.consume_fuel(true); @@ -71,51 +126,42 @@ impl WasmPlugin { if let Some(i) = module.imports().next() { return Err(format!("plug-ins can't import anything (the module imports `{}.{}`)", i.module(), i.name())); } - let mut inst = instantiate(&engine, &module)?; - let version = inst.func::<(), i32>("ec_api_version")?; - let v = version.call(&mut inst.store, ()).map_err(err)?; - if u32::try_from(v).ok() != Some(WASM_API_VERSION) { - return Err(format!("the module implements plug-in API {v}, this build implements API {WASM_API_VERSION} for WebAssembly")); - } - let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?; - let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?; + let mut pending = begin(&engine, &module)?; + let version = pending.func::<(), i32>("ec_api_version")?; + let abi = abi_of(version.call(&mut pending.store, ()).map_err(err)?)?; + let ptr = pending.func::<(), i32>("ec_manifest_ptr")?.call(&mut pending.store, ()).map_err(err)?; + let len = pending.func::<(), i32>("ec_manifest_len")?.call(&mut pending.store, ()).map_err(err)?; if !(0..=MAX_MANIFEST).contains(&len) { return Err(format!("bad manifest length {len}")); } let mut text = vec![0u8; len as usize]; - inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?; + pending.memory.read(&pending.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?; let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?; manifest.validate()?; if let Some(p) = manifest.params.iter().find(|p| p.kind.needs_v2()) { return Err(format!("parameter `{}`: WebAssembly plug-ins use plug-in API 1 parameter types (no point3, layer, text or hidden)", p.id)); } - Ok(WasmPlugin { manifest, source: source.to_string(), engine, module, pool: Mutex::new(vec![inst]) }) + let inst = pending.finish(abi)?; + Ok(WasmPlugin { manifest, source: source.to_string(), abi, engine, module, pool: Mutex::new(vec![inst]) }) } + /// An idle instance, or a fresh one when the pool is empty. fn take(&self) -> Result { if let Some(i) = self.pool.lock().ok().and_then(|mut p| p.pop()) { return Ok(i); } - instantiate(&self.engine, &self.module) + instantiate(&self.engine, &self.module, self.abi) } + /// Return a working instance to the pool. fn give_back(&self, inst: Inst) { if let Ok(mut p) = self.pool.lock() { p.push(inst); } } -} -impl EffectPlugin for WasmPlugin { - fn manifest(&self) -> &PluginManifest { - &self.manifest - } - - fn source(&self) -> String { - self.source.clone() - } - - fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> { + /// ABI 1: parameters, then the pixels; `ec_render`. + fn render_v1(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> { let n = frame.pixels.len(); let params_bytes = params.flat.len() * 8; let total = params_bytes + n * 16; @@ -130,25 +176,19 @@ impl EffectPlugin for WasmPlugin { for v in params.flat { buf.extend_from_slice(&v.to_le_bytes()); } - for px in frame.pixels.iter() { - for c in px { - buf.extend_from_slice(&c.to_le_bytes()); - } - } + push_pixels(&mut buf, frame.pixels); inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?; let px_ptr = (base + params_bytes) as i32; - let r = inst - .render - .call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale)) - .map_err(|e| format!("ec_render trapped: {e}")); + let r = match &inst.render { + Renderer::V1(f) => f + .call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale)) + .map_err(|e| format!("ec_render trapped: {e}")), + Renderer::V2(_) => return Err("the module implements WebAssembly ABI 1 but exports ec_render2".into()), + }; let r = match r { Ok(0) => { inst.memory.read(&inst.store, base + params_bytes, &mut buf[params_bytes..]).map_err(err)?; - for (px, b) in frame.pixels.iter_mut().zip(buf[params_bytes..].as_chunks::<16>().0.iter()) { - for (c, v) in px.iter_mut().zip(b.as_chunks::<4>().0.iter()) { - *c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]); - } - } + read_pixels(&buf[params_bytes..], frame.pixels); Ok(()) } Ok(code) => Err(format!("ec_render returned error {code}")), @@ -159,4 +199,170 @@ impl EffectPlugin for WasmPlugin { } r } + + /// ABI 2: parameters, the current frame, then the previous and next frames; `ec_render2`. + /// The neighbour buffers are always allocated (zeroed when absent), so a module can read + /// both without a null check; `flags` says which hold real frames. + fn render_v2( + &self, + frame: &mut PluginFrame, + params: &PluginParams, + time: f64, + prev: Option<(f64, &PluginImage)>, + next: Option<(f64, &PluginImage)>, + ) -> Result<(), String> { + let n = frame.pixels.len(); + let params_bytes = params.flat.len() * 8; + let frame_bytes = n * 16; + let total = params_bytes + frame_bytes * 3; + if total > i32::MAX as usize { + return Err("frame too large for a 32-bit plug-in".into()); + } + let prev_px = prev.map(|(t, img)| (t, img.on_grid(frame.width, frame.height, frame.origin, frame.scale))); + let next_px = next.map(|(t, img)| (t, img.on_grid(frame.width, frame.height, frame.origin, frame.scale))); + let prev = prev_px.as_ref().filter(|(_, px)| px.len() == n); + let next = next_px.as_ref().filter(|(_, px)| px.len() == n); + let mut inst = self.take()?; + inst.store.set_fuel(FUEL_BASE + FUEL_PER_PIXEL * (n as u64 * 3)).map_err(err)?; + let base = inst.alloc.call(&mut inst.store, total as i32).map_err(err)? as u32 as usize; + let mut buf: Vec = Vec::with_capacity(total); + for v in params.flat { + buf.extend_from_slice(&v.to_le_bytes()); + } + push_pixels(&mut buf, frame.pixels); + push_neighbour(&mut buf, prev, frame_bytes); + push_neighbour(&mut buf, next, frame_bytes); + inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?; + let px_ptr = (base + params_bytes) as i32; + let prev_ptr = (base + params_bytes + frame_bytes) as i32; + let next_ptr = (base + params_bytes + frame_bytes * 2) as i32; + let flags = i32::from(prev.is_some()) | (i32::from(next.is_some()) << 1); + let prev_time = prev.map_or(0.0, |(t, _)| *t); + let next_time = next.map_or(0.0, |(t, _)| *t); + let r = match &inst.render { + Renderer::V2(f) => f + .call( + &mut inst.store, + ( + px_ptr, + frame.width as i32, + frame.height as i32, + base as i32, + params.flat.len() as i32, + time, + frame.scale, + prev_ptr, + prev_time, + next_ptr, + next_time, + flags, + ), + ) + .map_err(|e| format!("ec_render2 trapped: {e}")), + Renderer::V1(_) => return Err("the module implements WebAssembly ABI 2 but exports ec_render".into()), + }; + let r = match r { + Ok(0) => { + let mut out = vec![0u8; frame_bytes]; + inst.memory.read(&inst.store, base + params_bytes, &mut out).map_err(err)?; + read_pixels(&out, frame.pixels); + Ok(()) + } + Ok(code) => Err(format!("ec_render2 returned error {code}")), + Err(e) => Err(e), + }; + if r.is_ok() { + self.give_back(inst); + } + r + } +} + +/// Append pixels to the little-endian buffer the module reads. +fn push_pixels(buf: &mut Vec, px: &[[f32; 4]]) { + for p in px { + for c in p { + buf.extend_from_slice(&c.to_le_bytes()); + } + } +} + +/// The neighbour's pixels, or `frame_bytes` zeroed bytes when there is no frame. +fn push_neighbour(buf: &mut Vec, neighbour: Option<&(f64, Vec<[f32; 4]>)>, frame_bytes: usize) { + match neighbour { + Some((_, px)) => push_pixels(buf, px), + None => buf.resize(buf.len() + frame_bytes, 0), + } +} + +/// Read the little-endian pixels the module wrote back. +fn read_pixels(bytes: &[u8], pixels: &mut [[f32; 4]]) { + for (px, b) in pixels.iter_mut().zip(bytes.as_chunks::<16>().0.iter()) { + for (c, v) in px.iter_mut().zip(b.as_chunks::<4>().0.iter()) { + *c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]); + } + } +} + +/// The layer times of the frames around `time` on a `fps` grid: the frame before and after it +/// at a frame boundary, the two frames that bracket it between frames. +fn neighbour_times(time: f64, fps: f64) -> (f64, f64) { + if !fps.is_finite() || fps <= 0.0 { + return (time - 1.0 / 30.0, time + 1.0 / 30.0); + } + let step = 1.0 / fps; + let at = time * fps; + // Frame times are not exact in floating point: at NTSC rates `time * fps` often lands a few + // ulps above or below an integer. Treat a near-integer as a frame boundary, so the current + // frame is never handed back as its own neighbour. + if (at - at.round()).abs() < 1e-6 { (time - step, time + step) } else { (at.floor() / fps, at.ceil() / fps) } +} + +impl EffectPlugin for WasmPlugin { + fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + fn source(&self) -> String { + self.source.clone() + } + + fn reads_other_times(&self) -> bool { + self.abi >= 2 + } + + fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> { + if self.abi == 1 { self.render_v1(frame, params, time) } else { self.render_v2(frame, params, time, None, None) } + } + + fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64, host: &dyn PluginHost) -> Result<(), String> { + if self.abi == 1 { + return self.render_v1(frame, params, time); + } + let (prev_time, next_time) = neighbour_times(time, host.frame_rate()); + let prev = host.input_at(prev_time); + let next = host.input_at(next_time); + let prev = prev.as_ref().map(|img| (prev_time, img)); + let next = next.as_ref().map(|img| (next_time, img)); + self.render_v2(frame, params, time, prev, next) + } +} + +#[cfg(test)] +mod tests { + use super::neighbour_times; + + /// NTSC frame times do not multiply back exactly; every frame boundary must still get the + /// frames either side of it, never the frame itself. + #[test] + fn frame_boundaries_get_their_neighbours_at_ntsc_rates() { + let fps = 24000.0 / 1001.0; + for n in 0..2400 { + // 24000/1001 fps at 254 016 000 000 ticks/s, as `Tick` stores it. + let time = (n * 10_594_584_000_i64) as f64 / 254_016_000_000.0; + let (prev, next) = neighbour_times(time, fps); + assert!((prev - (time - 1.0 / fps)).abs() < 1e-9, "frame {n}: prev {prev}, current {time}"); + assert!((next - (time + 1.0 / fps)).abs() < 1e-9, "frame {n}: next {next}, current {time}"); + } + } } diff --git a/crates/project/src/ae_order.rs b/crates/project/src/ae_order.rs new file mode 100644 index 000000000..b6dce1d44 --- /dev/null +++ b/crates/project/src/ae_order.rs @@ -0,0 +1,139 @@ +//! After Effects' numeric order of a layer's top-level property groups. +//! +//! In After Effects `thisLayer(4)` and `layer.property(4)` reach the same group on every layer of a +//! kind, whatever the layer holds: Marker is always 1, and the groups a layer has not used yet +//! (Time Remap, Motion Trackers, Layer Styles…) still take their slot. EffectCraft's tree holds only +//! the groups a layer has, without Marker, so its own positions differ and move as groups are +//! added. Rigs that address groups by number (`comp("Palette").layer(1)(4)(n)(1)`) read the wrong +//! group, or none, unless numbers go through this table. +//! +//! The order was observed in After Effects 26.3 by reading `layer.property(i).matchName` from a +//! script and `thisComp.layer(..)(i).name` from an expression on new layers of each kind; both +//! agree. 3D model and primitive layers have no table: their numbers stay EffectCraft's own. + +use crate::{Layer, LayerSource, Node}; + +/// One top-level slot in After Effects' order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AeSlot { + /// The group's match id in EffectCraft's tree, or `""` when EffectCraft has no such group. + pub match_id: &'static str, + /// The display name After Effects gives the slot. + pub name: &'static str, + /// After Effects' match name for the slot. + pub match_name: &'static str, +} + +const fn slot(match_id: &'static str, name: &'static str, match_name: &'static str) -> AeSlot { + AeSlot { match_id, name, match_name } +} + +/// The marker slot: number 1 on every layer. Markers are a field of [`Layer`], not a tree node. +pub const MARKER: AeSlot = slot("marker", "Marker", "ADBE Marker"); + +/// Footage, solid, adjustment, precomp and null layers. +const AV: &[AeSlot] = &[ + MARKER, + slot("timeRemap", "Time Remap", "ADBE Time Remapping"), + slot("motionTrackers", "Motion Trackers", "ADBE MTrackers"), + slot("masks", "Masks", "ADBE Mask Parade"), + slot("effects", "Effects", "ADBE Effect Parade"), + slot("transform", "Transform", "ADBE Transform Group"), + slot("layerStyles", "Layer Styles", "ADBE Layer Styles"), + slot("", "Geometry Options", "ADBE Plane Options Group"), + slot("geometryOptions", "Geometry Options", "ADBE Extrsn Options Group"), + slot("materialOptions", "Material Options", "ADBE Material Options Group"), + slot("audio", "Audio", "ADBE Audio Group"), + slot("", "Data", "ADBE Data Group"), + slot("", "Essential Properties", "ADBE Layer Overrides"), + slot("", "Sets", "ADBE Layer Sets"), + slot("", "Source Options", "ADBE Source Options Group"), +]; + +const TEXT: &[AeSlot] = &[ + MARKER, + slot("text", "Text", "ADBE Text Properties"), + slot("masks", "Masks", "ADBE Mask Parade"), + slot("effects", "Effects", "ADBE Effect Parade"), + slot("transform", "Transform", "ADBE Transform Group"), + slot("layerStyles", "Layer Styles", "ADBE Layer Styles"), + slot("geometryOptions", "Geometry Options", "ADBE Extrsn Options Group"), + slot("materialOptions", "Material Options", "ADBE Material Options Group"), + slot("audio", "Audio", "ADBE Audio Group"), + slot("", "Sets", "ADBE Layer Sets"), +]; + +const SHAPE: &[AeSlot] = &[ + MARKER, + slot("contents", "Contents", "ADBE Root Vectors Group"), + slot("masks", "Masks", "ADBE Mask Parade"), + slot("effects", "Effects", "ADBE Effect Parade"), + slot("transform", "Transform", "ADBE Transform Group"), + slot("layerStyles", "Layer Styles", "ADBE Layer Styles"), + slot("geometryOptions", "Geometry Options", "ADBE Extrsn Options Group"), + slot("materialOptions", "Material Options", "ADBE Material Options Group"), + slot("audio", "Audio", "ADBE Audio Group"), + slot("", "Sets", "ADBE Layer Sets"), +]; + +const CAMERA: &[AeSlot] = + &[MARKER, slot("transform", "Transform", "ADBE Transform Group"), slot("cameraOptions", "Camera Options", "ADBE Camera Options Group")]; + +const LIGHT: &[AeSlot] = &[MARKER, slot("transform", "Transform", "ADBE Transform Group"), slot("lightOptions", "Light Options", "ADBE Light Options Group")]; + +/// After Effects' top-level slots for a layer of this kind, or `None` for 3D model and primitive +/// layers, which keep EffectCraft's own numbers. +pub fn ae_slots(source: &LayerSource) -> Option<&'static [AeSlot]> { + match source { + LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. } | LayerSource::Null => Some(AV), + LayerSource::Text => Some(TEXT), + LayerSource::Shape => Some(SHAPE), + LayerSource::Camera => Some(CAMERA), + LayerSource::Light { .. } => Some(LIGHT), + LayerSource::Model { .. } | LayerSource::Primitive { .. } => None, + } +} + +/// What After Effects' top-level number `index` (1-based) reaches on `layer`. +#[derive(Clone, Copy, Debug)] +pub enum AeChild<'a> { + /// The layer's markers (number 1). + Marker, + /// A group or property the layer has. + Node(&'a Node), + /// A slot After Effects always has and this layer does not (an empty group there). + Absent(&'static AeSlot), +} + +/// The top-level node After Effects' number `index` (1-based) reaches, or `None` when the number is +/// past the last slot or the layer kind has no table ([`ae_slots`]). +pub fn ae_child(layer: &Layer, index: usize) -> Option> { + let s = ae_slots(&layer.source)?.get(index.checked_sub(1)?)?; + if *s == MARKER { + return Some(AeChild::Marker); + } + if s.match_id.is_empty() { + return Some(AeChild::Absent(s)); + } + Some(layer.props.children.iter().find(|n| n.match_id() == s.match_id).map_or(AeChild::Absent(s), AeChild::Node)) +} + +/// After Effects' 1-based number for the top-level node with this match id, when it has one. +pub fn ae_index(layer: &Layer, match_id: &str) -> Option { + if match_id.is_empty() { + return None; + } + ae_slots(&layer.source)?.iter().position(|s| s.match_id == match_id).map(|i| i + 1) +} + +/// How many top-level properties After Effects counts on `layer` (`numProperties`), or `None` for +/// layer kinds without a table. +pub fn ae_count(layer: &Layer) -> Option { + ae_slots(&layer.source).map(<[AeSlot]>::len) +} + +/// After Effects' 1-based number for the top-level slot with this After Effects match name +/// (`ADBE Effect Parade`), when the layer kind has one. +pub fn ae_index_of_match_name(layer: &Layer, match_name: &str) -> Option { + ae_slots(&layer.source)?.iter().position(|s| s.match_name == match_name).map(|i| i + 1) +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 1aa8d7c90..7a960dbfb 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -935,6 +935,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource Layer { id, name: comp.unique_layer_name(name), + name_follows_text: None, source, label, comment: String::new(), diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index d8d7cda16..c0c401c82 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -268,8 +268,19 @@ impl Comp { /// `base`, or the next free numbered name when a layer already has it (`Shape Layer 2`, /// `v-b1.mp4 2`; see `numbered_name`). pub fn unique_layer_name(&self, base: &str) -> String { - let taken = |n: &str| self.layers.iter().any(|l| l.name == n); - if taken(base) { crate::item::numbered_name(base, taken) } else { base.to_string() } + if !self.layers.iter().any(|l| l.name == base) { + return base.to_string(); + } + let stem = crate::item::name_stem(base); + // The suffixes in use, gathered in one pass: trying each candidate against every layer + // made importing many layers of one name cubic. + let taken: std::collections::HashSet = self + .layers + .iter() + .filter_map(|l| l.name.strip_prefix(stem)?.strip_prefix(' ')) + .filter_map(|s| s.parse::().ok().filter(|n| n.to_string() == s)) + .collect(); + (2..).find(|i| !taken.contains(i)).map(|i| format!("{stem} {i}")).unwrap_or_default() } } diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 2cf887b1f..eeace5e81 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -269,6 +269,13 @@ pub struct Footage { /// made from the footage starts with them, and Update Markers From Source copies new ones. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub markers: Vec, + /// PDF / Illustrator footage (`.pdf`, `.ai`; not EPS) from an After Effects project: the page + /// is framed as After Effects places it (the ArtBox, `/Rotate` ignored) and drawn to fill + /// `width` × `height`, the size After Effects stored, which the layers' anchors and masks are + /// measured against (#3099). Other footage (and every project saved before this field) shows + /// the page as a PDF viewer does, at its own size. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub placed_page: bool, } fn is_default(v: &T) -> bool { @@ -307,6 +314,7 @@ impl Default for Footage { start_timecode: None, file_timecode: None, markers: vec![], + placed_page: false, } } } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 4fcc06ab9..b055c60ba 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -8,12 +8,18 @@ use super::{Arc, BTreeMap, Deserialize, FrameRate, Label, Project, Serialize, Ti /// another layer of the same name. A number set off by a space is a counter and is replaced /// (`Red Solid 1` → `Red Solid 2`, `shot 2` → `shot 3`); digits that end a word belong to the /// name and stay (`v-b1.mp4` → `v-b1.mp4 2`, `clip2.mov` → `clip2.mov 2`). -pub(crate) fn numbered_name(base: &str, taken: impl Fn(&str) -> bool) -> String { +/// `base` without a trailing ` N`: digits are dropped only when a space separates them from a +/// nonempty stem, so `v-b1.mp4` and `Layer1` keep theirs. +pub(crate) fn name_stem(base: &str) -> &str { let head = base.trim_end_matches(|c: char| c.is_ascii_digit()); - let stem = match head.strip_suffix(' ') { + match head.strip_suffix(' ') { Some(stem) if head.len() < base.len() && !stem.trim_end().is_empty() => stem.trim_end(), _ => base.trim_end(), - }; + } +} + +pub(crate) fn numbered_name(base: &str, taken: impl Fn(&str) -> bool) -> String { + let stem = name_stem(base); (2u64..).map(|k| format!("{stem} {k}")).find(|n| !taken(n)).unwrap_or_default() } diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..b13c1d980 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -299,6 +299,8 @@ impl Layer { let Layer { id, name, + // Whether the name follows the text changes no pixels until the name changes. + name_follows_text: _, source, label, comment, @@ -470,10 +472,29 @@ pub enum AutoOrient { TowardsPointOfInterest, } +/// A stretch written as `null` (a NaN or infinite stretch: JSON holds neither, and auto-saves +/// write it so) reads as NaN, so the project still opens and loading brings it to 100 % with a +/// note ([`crate::limits::stretch`], #3247) instead of the whole file being refused. An infinite +/// stretch therefore also reopens at 100 %, not at ±9900 %. +fn stretch_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) +} + +/// `t × mul / div` in ticks, within [`Tick::MIN`]..=[`Tick::MAX`] (the cast saturates, NaN is 0), +/// so a layer time of an extreme stretch can't overflow later tick arithmetic. +fn scaled(t: Tick, mul: f64, div: f64) -> Tick { + Tick(((t.0 as f64 * mul / div) as i64).clamp(Tick::MIN.0, Tick::MAX.0)) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Layer { pub id: LayerId, pub name: String, + /// A text layer's name follows the first line of its Source Text until the user renames it + /// (After Effects). `Some(true)`: it follows, `Some(false)`: the user named it. `None` (older + /// projects, layers made before this was recorded): see [`Layer::name_follows_text()`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_follows_text: Option, pub source: LayerSource, pub label: Label, #[serde(default)] @@ -484,6 +505,7 @@ pub struct Layer { pub in_point: Tick, pub out_point: Tick, /// Time stretch in percent (100 = normal, negative = reversed). + #[serde(deserialize_with = "stretch_or_nan")] pub stretch: f64, pub switches: Switches, pub blend_mode: BlendMode, @@ -512,7 +534,26 @@ pub struct Layer { pub props: PropGroup, } +/// The name a text layer takes from its text: the first 40 characters of the first line. A line +/// ends at a line feed or at a bare carriage return (After Effects' paragraph break). +pub fn text_layer_name(text: &str) -> String { + text.split(['\r', '\n']).next().unwrap_or("").chars().take(40).collect() +} + +/// A text layer's default name when its text gives none: "Text", numbered "Text 2", "Text 3"… +fn is_default_text_name(name: &str) -> bool { + name == "Text" || name.strip_prefix("Text ").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) +} + impl Layer { + /// Whether this text layer's name follows its Source Text, whose first line gives the name + /// `current` (see [`text_layer_name`]). Without a recorded state (an older project), a name + /// that is empty, the text's own, or the default "Text"/"Text 2"… follows; any other name was + /// given by the user. + pub fn name_follows_text(&self, current: &str) -> bool { + self.name_follows_text.unwrap_or_else(|| self.name.is_empty() || self.name == current || is_default_text_name(&self.name)) + } + /// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map, /// Audio Spectrum's Audio Layer…) and tracker targets. pub fn referenced_layers(&self) -> Vec { @@ -555,15 +596,24 @@ impl Layer { t >= self.in_point && t < self.out_point } + /// The time stretch the layer plays at: [`Layer::stretch`] within + /// [`limits::stretch`](crate::limits::stretch) (a damaged value that got past loading, 0 or + /// NaN, never divides the layer's time by zero). + pub fn effective_stretch(&self) -> f64 { + crate::limits::stretch(self.stretch) + } + /// Layer time for a comp time (stretch-aware). pub fn layer_time(&self, comp_t: Tick) -> Tick { let d = comp_t - self.start_time; - if (self.stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / self.stretch) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { d } else { scaled(d, 100.0, s) } } /// Comp time for a layer time. pub fn comp_time(&self, layer_t: Tick) -> Tick { - if (self.stretch - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + scaled(layer_t, s, 100.0) } } pub fn transform(&self) -> Option<&PropGroup> { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 079b81765..9f5f399e0 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod ae_order; pub mod build; pub mod essential; mod file_check; @@ -35,6 +36,7 @@ mod footage; mod item; mod layer; mod layers; +mod reach; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; @@ -43,9 +45,10 @@ pub use item::NO_LAYER; pub use item::{Heard, Item, ItemId, ItemKind, LayerId}; pub use layer::{ AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect, - reads_other_frames, + reads_other_frames, text_layer_name, }; pub use layers::Layers; +pub use reach::{ExprReach, PixelReach, expression_reach}; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, diff --git a/crates/project/src/limits.rs b/crates/project/src/limits.rs index 998f40225..7a74c992b 100644 --- a/crates/project/src/limits.rs +++ b/crates/project/src/limits.rs @@ -27,6 +27,30 @@ pub const MAX_PIXEL_ASPECT: f64 = 10.0; pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64); /// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit. pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256); +/// The smallest time stretch, in percent, either way (After Effects sets a stretch between 0 and +/// 1 % to 1 %, between -1 % and 0 to -1 %; `layer.timeStretch` refuses less). +pub const MIN_STRETCH: f64 = 1.0; +/// The largest time stretch, in percent, either way (After Effects' `Layer.stretch` range is +/// -9900 to 9900). +pub const MAX_STRETCH: f64 = 9900.0; + +/// A layer's time stretch from [`MIN_STRETCH`] to [`MAX_STRETCH`] % either way, its sign (a +/// reversed layer) kept; 100 % for 0 or NaN. Layer and comp time divide by the stretch, and the +/// audio mix reads `100 / stretch` source samples per sample, so a stretch of 1e-300 % or NaN +/// from a damaged file must not reach them (#3247). +pub fn stretch(v: f64) -> f64 { + if v.is_nan() || v == 0.0 { + return 100.0; + } + // Infinity clamps to the largest stretch with its sign. + v.signum() * v.abs().clamp(MIN_STRETCH, MAX_STRETCH) +} + +/// A stretch for a note: a plain number in a readable range, scientific notation beyond it (a +/// plain 1e-300 would print 300 digits). +fn percent(v: f64) -> String { + if v == 0.0 || (1e-3..1e7).contains(&v.abs()) { format!("{v} %") } else { format!("{v:e} %") } +} /// A width or height from 1 to [`MAX_SIZE`]. pub fn size(v: u64) -> u32 { @@ -97,9 +121,11 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { let duration = c.duration.max(rate.frame_duration()); let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1); let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1); + let stretch_ok = |l: &crate::Layer| stretch(l.stretch).to_bits() == l.stretch.to_bits(); let unchanged = (width, height, rate, duration, samples, adaptive) == (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit) - && pa.to_bits() == c.pixel_aspect.to_bits(); + && pa.to_bits() == c.pixel_aspect.to_bits() + && c.layers.iter().all(stretch_ok); if unchanged { return None; } @@ -125,6 +151,12 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { } (n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration); (n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive); + // Only the layers out of range are copied (the others stay shared). + for l in n.layers.iter_mut_where(|l| !stretch_ok(l)) { + let fixed = stretch(l.stretch); + note(notes, name, &format!("layer '{}' time stretch", l.name), percent(l.stretch), percent(fixed)); + l.stretch = fixed; + } Some(n) } @@ -217,6 +249,85 @@ mod tests { ); } + #[test] + fn a_stretch_is_held_to_one_to_9900_percent_with_its_sign() { + assert_eq!(stretch(f64::NAN), 100.0); + assert_eq!(stretch(0.0), 100.0); + assert_eq!(stretch(-0.0), 100.0); + assert_eq!(stretch(1e-300), MIN_STRETCH); + assert_eq!(stretch(-1e-300), -MIN_STRETCH); + assert_eq!(stretch(0.5), MIN_STRETCH); + assert_eq!(stretch(1e300), MAX_STRETCH); + assert_eq!(stretch(f64::INFINITY), MAX_STRETCH); + assert_eq!(stretch(f64::NEG_INFINITY), -MAX_STRETCH); + for v in [1.0, -1.0, 33.3, 100.0, -100.0, 200.0, 9900.0, -9900.0] { + assert_eq!(stretch(v).to_bits(), v.to_bits(), "{v} is in range"); + } + } + + /// A layer whose stretch got past loading (0, NaN, 1e-300 %, set in memory) still maps time + /// through a stretch in range: before, 0 and 1e-300 % saturated every layer time to the end of + /// time and NaN collapsed it to 0 (#3247). + #[test] + fn layer_and_comp_time_use_a_stretch_in_range() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(10.0)); + let mut l = crate::build::layer(&mut p, &comp, "L", crate::LayerSource::Camera, (64, 64), None); + l.start_time = Tick::from_seconds_f64(1.0); + let at = |s: f64| Tick::from_seconds_f64(s); + for (bad, effective) in [(0.0, 100.0), (f64::NAN, 100.0), (1e-300, 1.0), (-1e-300, -1.0), (1e300, 9900.0), (f64::NEG_INFINITY, -9900.0)] { + l.stretch = bad; + assert_eq!(l.effective_stretch(), effective, "{bad}"); + // One comp second after the start is 100 / stretch layer seconds. + let want = Tick((at(1.0).0 as f64 * 100.0 / effective) as i64); + assert_eq!(l.layer_time(at(2.0)), want, "{bad}"); + // And back, within the rounding of the two conversions. + assert!((l.comp_time(l.layer_time(at(2.0))) - at(2.0)).abs() <= Tick(100), "{bad}"); + } + } + + #[test] + fn a_stretch_out_of_range_in_a_file_is_corrected_and_reported() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(2.0)); + let mut c = comp.clone(); + for (name, s) in [("tiny", 1e-300), ("reversed tiny", -1e-300), ("zero", 0.0), ("huge", 1e300), ("reversed huge", -5e4), ("fine", -50.0)] { + let mut l = crate::build::layer(&mut p, &comp, name, crate::LayerSource::Camera, (64, 64), None); + l.stretch = s; + c.layers.push(l); + } + // Added through `add_item`, the comp is held to the limits too: write the values back in. + let id = p.add_item("Comp", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(c.clone()))); + assert!(p.comp(id).unwrap().layers.iter().all(|l| l.stretch.abs() >= MIN_STRETCH && l.stretch.abs() <= MAX_STRETCH)); + let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); + for (k, l) in c.layers.iter().enumerate() { + v["items"][id.0.to_string()]["kind"]["layers"][k]["stretch"] = serde_json::json!(l.stretch); + } + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + let got: Vec = back.comp(id).unwrap().layers.iter().map(|l| l.stretch).collect(); + assert_eq!(got, [1.0, -1.0, 100.0, 9900.0, -9900.0, -50.0]); + assert_eq!( + notes, + [ + "Comp: layer 'tiny' time stretch 1e-300 % → 1 %", + "Comp: layer 'reversed tiny' time stretch -1e-300 % → -1 %", + "Comp: layer 'zero' time stretch 0 % → 100 %", + "Comp: layer 'huge' time stretch 1e300 % → 9900 %", + "Comp: layer 'reversed huge' time stretch -50000 % → -9900 %", + ] + ); + // A NaN stretch is written as null (an auto-save): the file opens, at 100 %, and says so. + // Before, the whole project was refused. + v["items"][id.0.to_string()]["kind"]["layers"][0]["stretch"] = serde_json::Value::Null; + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + assert_eq!(back.comp(id).unwrap().layers.iter().next().unwrap().stretch, 100.0); + assert_eq!(notes.first().map(String::as_str), Some("Comp: layer 'tiny' time stretch NaN % → 100 %")); + let mut nan = p.clone(); + nan.comp_mut(id).unwrap().layers.iter_mut().for_each(|l| l.stretch = f64::NAN); + let (back, _) = crate::Project::from_json_repaired(&nan.to_json_compact()).unwrap(); + assert!(back.comp(id).unwrap().layers.iter().all(|l| l.stretch == 100.0)); + } + #[test] fn a_comp_in_range_is_not_copied() { let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0)); diff --git a/crates/project/src/reach.rs b/crates/project/src/reach.rs new file mode 100644 index 000000000..31a2ed567 --- /dev/null +++ b/crates/project/src/reach.rs @@ -0,0 +1,405 @@ +//! What a composition's pixels can depend on, from the project in memory: the items it draws +//! and, when expressions are among them, what those expressions can read (#3184). The disk +//! cache keys a comp's frames by exactly these items, so an edit elsewhere in the project keeps +//! them. +//! +//! An expression reaches its own comp's layers (`thisComp`, `thisLayer`), the sources of the +//! layers it can read (`layer.source`, a precomp's layers through it), and other items by name: +//! `comp("Name")` and `footage("Name")`. A name given as a plain string literal is followed; a +//! computed name (`comp("Shot " + index)`), the object model's constructors and internals +//! (`new Comp(7)`, `__h`), dynamic code (`eval`, `Function`) and anything the scanner can't read +//! for sure (a regular expression literal, a template string, an escape in a name) count as +//! reaching every item of the project. The scan errs on the side of reaching more: an item +//! wrongly left out would serve a stale frame, one wrongly let in only renders again. +//! +//! Known limit (`ponytail`): an expression that builds the names of the object model's private +//! fields at run time into a variable and writes them with a computed key it reads from that +//! variable could forge another comp's id. That is deliberate tampering with the internals, not +//! an expression one writes; the scan refuses every direct form of it (`__` names, the +//! constructors, `Object`, `JSON`, `call`/`apply`/`bind`, computed writes and computed keys). +//! Upgrade: have the expression host record which items an evaluation reached. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{Comp, ItemId, ItemKind, LayerId, Node, Project, PropGroup, Value}; + +/// What an expression's text can reach outside its own comp and the layers it can read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExprReach { + /// Only items named by string literals: comps by `comp("…")`, footage by `footage("…")`. + Names { comps: Vec, footage: Vec }, + /// Any item of the project. + Anything, +} + +/// Names that hand an expression another item by id, run code built at run time, or give it the +/// tools to forge the object model's private fields. +const REACHES_ANYTHING: &[&str] = &[ + "Comp", + "Footage", + "Layer", + "Prop", + "Markers", + "eval", + "Function", + "globalThis", + "this", + "self", + "window", + "arguments", + "constructor", + "prototype", + "Object", + "Reflect", + "Proxy", + "JSON", + "call", + "apply", + "bind", + "class", + "with", + "import", + "require", + "fromCharCode", + "fromCodePoint", + "getOwnPropertyDescriptor", + "getOwnPropertyDescriptors", + "getPrototypeOf", + "setPrototypeOf", + "defineProperty", +]; + +/// Keywords after which a `/` starts a regular expression. +const BEFORE_REGEX: &[&str] = &["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"]; + +/// Keywords whose `( … )` a regular expression may follow (`if (a) /x/.test(s)`). +const CONTROL: &[&str] = &["if", "while", "for", "with"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tok<'a> { + Ident(&'a str), + /// A string literal's text between its quotes; `plain` when it has no escape. + Str { + text: &'a str, + plain: bool, + }, + Num, + Punct(u8), +} + +/// The tokens of `text`, or `None` when it can't be read for sure. +fn tokens(text: &str) -> Option>> { + let b = text.as_bytes(); + let mut out: Vec> = Vec::new(); + // Per open `(`: whether a control keyword is before it; whether the last `)` closed one + // (only looked at right after a `)`). + let mut parens: Vec = Vec::new(); + let mut control_close = false; + let mut i = 0; + while let Some(&c) = b.get(i) { + match c { + b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => i += 1, + b'/' if b.get(i + 1) == Some(&b'/') => { + while b.get(i).is_some_and(|c| *c != b'\n') { + i += 1; + } + } + b'/' if b.get(i + 1) == Some(&b'*') => { + let end = text.get(i + 2..)?.find("*/")?; + i += 2 + end + 2; + } + b'/' => { + if regex_may_start(out.last(), control_close) { + return None; + } + out.push(Tok::Punct(c)); + i += 1; + } + b'"' | b'\'' => { + let start = i + 1; + let mut j = start; + let mut plain = true; + loop { + match *b.get(j)? { + q if q == c => break, + b'\n' | b'\r' => return None, + b'\\' => { + // Only escapes that can't spell a name: `\n`, `\t`, a quote... + if !matches!(b.get(j + 1)?, b'n' | b'r' | b't' | b'b' | b'f' | b'v' | b'\'' | b'"' | b'\\') { + return None; + } + plain = false; + j += 2; + } + _ => j += 1, + } + } + let s = text.get(start..j)?; + if s.contains("__") { + return None; + } + out.push(Tok::Str { text: s, plain }); + i = j + 1; + } + b'0'..=b'9' => { + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') { + i += 1; + } + out.push(Tok::Num); + } + b'.' if b.get(i + 1).is_some_and(u8::is_ascii_digit) => { + i += 1; + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') { + i += 1; + } + out.push(Tok::Num); + } + c if c.is_ascii_alphabetic() || c == b'_' || c == b'$' => { + let start = i; + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'$') { + i += 1; + } + out.push(Tok::Ident(text.get(start..i)?)); + } + // A template string (code inside `${}`), an escaped identifier, a name in another + // alphabet: not followed. + b'`' | b'\\' => return None, + c if !c.is_ascii() => return None, + c => { + match c { + b'(' => parens.push(matches!(out.last(), Some(Tok::Ident(k)) if CONTROL.contains(k))), + b')' => control_close = parens.pop().unwrap_or(false), + _ => {} + } + out.push(Tok::Punct(c)); + i += 1; + } + } + } + Some(out) +} + +/// Whether a `/` after `prev` may start a regular expression literal (which the scanner doesn't +/// read: its text could hide a quote). +fn regex_may_start(prev: Option<&Tok<'_>>, control_close: bool) -> bool { + match prev { + None => true, + Some(Tok::Ident(k)) => BEFORE_REGEX.contains(k), + Some(Tok::Num | Tok::Str { .. }) => false, + Some(Tok::Punct(b')')) => control_close, + Some(Tok::Punct(b']')) => false, + Some(Tok::Punct(_)) => true, + } +} + +/// What the expression `text` can reach by name, or [`ExprReach::Anything`]. +pub fn expression_reach(text: &str) -> ExprReach { + expression_names(text).map_or(ExprReach::Anything, |(comps, footage)| ExprReach::Names { comps, footage }) +} + +/// The comp names and footage names an expression references by literal name. +/// Returns `None` when it can reach anything by a computed name or cannot be tokenised. +fn expression_names(text: &str) -> Option<(Vec, Vec)> { + let toks = tokens(text)?; + let (mut comps, mut footage) = (Vec::new(), Vec::new()); + // Per open `[`: a computed key (`{[k]: v}`), a computed member (`o[k]`). + let mut brackets: Vec<(bool, bool)> = Vec::new(); + for (i, t) in toks.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|p| toks.get(p)); + let next = |n: usize| toks.get(i + n); + match *t { + Tok::Ident(name) if name.starts_with("__") || REACHES_ANYTHING.contains(&name) => return None, + Tok::Ident(name @ ("comp" | "footage")) => { + // Only a call with a plain literal, not a property of something else. + let (Some(Tok::Punct(b'(')), Some(Tok::Str { text, plain: true }), Some(Tok::Punct(b')'))) = (next(1), next(2), next(3)) else { return None }; + if matches!(prev, Some(Tok::Punct(b'.'))) { + return None; + } + if name == "comp" { comps.push((*text).to_string()) } else { footage.push((*text).to_string()) } + } + Tok::Punct(b'[') => { + let key = matches!(prev, Some(Tok::Punct(b'{' | b',')) | Some(Tok::Ident("get" | "set" | "static" | "async")) | Some(Tok::Punct(b'*'))); + let member = matches!(prev, Some(Tok::Ident(_) | Tok::Str { .. } | Tok::Num | Tok::Punct(b')' | b']' | b'.'))); + brackets.push((key, member)); + } + Tok::Str { .. } if brackets.last().is_some_and(|(_, member)| *member) => return None, + Tok::Punct(b']') => { + let (key, _) = brackets.pop().unwrap_or((true, true)); + // A computed key (`{[k]: v}`, `{[k]() {}}`) or a write through brackets (`o[k] = v`, + // `o[k] ||= v`, `for (o[k] in …)`, `[o[k]] = v`) could name a private field. + if key && matches!(next(1), Some(Tok::Punct(b':' | b'('))) { + return None; + } + if assigns(&toks, i + 1) || matches!(next(1), Some(Tok::Ident("in" | "of"))) { + return None; + } + } + // A destructuring write (`({a: o[k]} = v)`). + Tok::Punct(b'}') if assigns(&toks, i + 1) => return None, + _ => {} + } + } + Some((comps, footage)) +} + +/// Whether the tokens from `at` are an assignment operator (`=`, `+=`, `>>>=`, `??=`...), not a +/// comparison (`==`, `<=`) or an arrow (`=>`). +fn assigns(toks: &[Tok<'_>], at: usize) -> bool { + let mut op = Vec::new(); + for t in toks.iter().skip(at).take(5) { + match t { + Tok::Punct(c) if b"+-*/%&|^<>?=".contains(c) => op.push(*c), + _ => break, + } + } + let Some(eq) = op.iter().position(|c| *c == b'=') else { return false }; + let (before, after) = op.split_at(eq); + if after.get(1).is_some_and(|c| *c == b'=' || *c == b'>') { + return false; + } + matches!(before, b"" | b"+" | b"-" | b"*" | b"/" | b"%" | b"&" | b"|" | b"^" | b"**" | b"<<" | b">>" | b">>>" | b"&&" | b"||" | b"??") +} + +/// The items a comp's pixels can depend on ([`Project::pixel_reach`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PixelReach { + /// The items whose state counts; `None`: every item of the project. + pub items: Option>, + /// Expressions are among them: they can read every layer of the comps they reach (hidden + /// and silent ones too) and the markers, which therefore count as well (#1083). + pub expressions: bool, + /// The names expressions look items up by (`true`: a comp's, `false`: footage's), each with + /// the ids of the items of that kind that have it now, in id order (the first one is what + /// the lookup finds; another item taking or giving up the name changes it). + pub names: Vec<(bool, String, Vec)>, +} + +/// Each property of a group and its subgroups (iterative: no depth limit to hit). +fn each_prop<'a>(g: &'a PropGroup, f: &mut dyn FnMut(&'a crate::Property) -> Option<()>) -> Option<()> { + let mut stack = vec![g]; + while let Some(g) = stack.pop() { + for n in &g.children { + match n { + Node::Prop(p) => f(p)?, + Node::Group(sub) => stack.push(sub), + } + } + } + Some(()) +} + +impl Project { + /// The items comp `comp` draws ([`Self::silent_layers`] aside, with `heard`) and the + /// footage its precomp layers swap in (Essential Graphics Media Replacement); with an + /// expression in any of its comps, everything those expressions can reach + /// ([`expression_reach`]). Memory only: no file is looked at. + pub fn pixel_reach(&self, comp: ItemId, heard: &crate::Heard) -> PixelReach { + let Some(drawn) = self.reach_walk(comp, &|id, c| self.silent_layers(id, c, heard), &mut |_| Some(vec![])) else { return PixelReach::every() }; + let has_expression = |g: &PropGroup| each_prop(g, &mut |p| p.expr.is_none().then_some(())).is_none(); + if !drawn.iter().filter_map(|id| self.comp(*id)).any(|c| c.layers.iter().any(|l| has_expression(&l.props))) { + return PixelReach { items: Some(drawn), expressions: false, names: vec![] }; + } + // Expressions: every layer of a comp they reach counts, and what they name. + let mut names: BTreeMap<(bool, String), Vec> = BTreeMap::new(); + let mut scan = |p: &crate::Property| match p.expr.as_ref().map(|e| expression_reach(&e.text)) { + None => Some(vec![]), + Some(ExprReach::Anything) => None, + Some(ExprReach::Names { comps, footage }) => { + let mut found = vec![]; + for (is_comp, name) in comps.into_iter().map(|n| (true, n)).chain(footage.into_iter().map(|n| (false, n))) { + let ids = self.named(is_comp, &name); + found.extend(ids.iter().copied()); + names.insert((is_comp, name), ids); + } + Some(found) + } + }; + let Some(mut items) = self.reach_walk(comp, &|_, _| BTreeSet::new(), &mut scan) else { return PixelReach::every() }; + // A source's data is read by its name (`layer.source.sourceData`). + let footage_names: BTreeSet<&str> = + items.iter().filter_map(|id| self.item(*id)).filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.name.as_str()).collect(); + let same_name: Vec = + self.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_)) && footage_names.contains(i.name.as_str())).map(|i| i.id).collect(); + items.extend(same_name); + PixelReach { items: Some(items), expressions: true, names: names.into_iter().map(|((c, n), ids)| (c, n, ids)).collect() } + } + + /// The items of one kind (`is_comp`: comps, else footage) named `name`, in id order. + fn named(&self, is_comp: bool, name: &str) -> Vec { + self.items + .values() + .filter(|i| i.name == name && if is_comp { matches!(i.kind, ItemKind::Comp(_)) } else { matches!(i.kind, ItemKind::Footage(_)) }) + .map(|i| i.id) + .collect() + } + + /// The items reached from `comp` through its layers (but those `quiet` names) and the items + /// `scan` returns for each of their properties; `None` when something reaches every item. + /// (A stack and a visited set: each item is visited once, cycles included.) + fn reach_walk( + &self, + comp: ItemId, + quiet: &dyn Fn(ItemId, &Comp) -> BTreeSet, + scan: &mut dyn FnMut(&crate::Property) -> Option>, + ) -> Option> { + let mut seen = BTreeSet::new(); + let mut stack = vec![comp]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(c) = self.comp(id) else { continue }; + let quiet = quiet(id, c); + for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) { + // Every source item: a 3D model's too (it is drawn, and `layer.source` reads its + // name and size). + if let Some(item) = l.source.item() { + stack.push(item); + } + stack.extend(self.media_replacements(l)?); + let mut reached = vec![]; + each_prop(&l.props, &mut |p| { + reached.extend(scan(p)?); + Some(()) + })?; + stack.extend(reached); + } + } + Some(seen) + } + + /// The footage a precomp layer swaps in through its Essential Properties (Media Replacement): + /// the items its overridden properties name. `None` when they can't be listed (an + /// expression or keyframes choose the item over time). + fn media_replacements(&self, layer: &crate::Layer) -> Option> { + let over = crate::essential::overridden(layer); + let Some(g) = crate::essential::group(layer).filter(|_| !over.is_empty()) else { return Some(vec![]) }; + let mut out = vec![]; + each_prop(g, &mut |p| { + if !over.contains(&p.uid) { + return Some(()); + } + if p.expr.is_some() || !p.keys.is_empty() { + return None; + } + if let Value::Scalar(id) = p.value { + let item = ItemId(id.max(0.0) as u64); + if matches!(self.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + out.push(item); + } + } + Some(()) + })?; + Some(out) + } +} + +impl PixelReach { + /// Every item of the project, with expressions. + fn every() -> PixelReach { + PixelReach { items: None, expressions: true, names: vec![] } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/project/src/reach/tests.rs b/crates/project/src/reach/tests.rs new file mode 100644 index 000000000..53db8172a --- /dev/null +++ b/crates/project/src/reach/tests.rs @@ -0,0 +1,198 @@ +use super::*; +use crate::{Expression, LayerSource, Solid, build}; +use effectcraft_time::{FrameRate, Tick}; + +fn names(comps: &[&str], footage: &[&str]) -> ExprReach { + ExprReach::Names { comps: comps.iter().map(|s| s.to_string()).collect(), footage: footage.iter().map(|s| s.to_string()).collect() } +} + +/// Expressions that read their own comp, or other items by literal names, are followed; every +/// way to reach items by a computed name or an id, or text the scanner can't read for sure, +/// reaches everything. +#[test] +fn expression_reach_follows_literal_names_and_refuses_the_rest() { + let follows = [ + ("wiggle(2, 20)", names(&[], &[])), + ("thisComp.layer(\"Comp 1 Layer\").transform.opacity * 2 / 3", names(&[], &[])), + ("value + [10, 0]; value[0] / 2", names(&[], &[])), + ("// comp(name) in a comment\n/* footage(x) */ value", names(&[], &[])), + ("\"a string that says comp(x) and Layer\"", names(&[], &[])), + ("comp(\"Other\").layer(1).transform.position", names(&["Other"], &[])), + ("comp( 'A' ).width + comp(\"B\").height", names(&["A", "B"], &[])), + ("footage(\"data.json\").sourceData.x", names(&[], &["data.json"])), + ("x = a[0] <= b ? a[1] : 0; y = a[0] == 1", names(&[], &[])), + ("thisLayer.source.layer(1).name", names(&[], &[])), + ("if (a) x = 1; else x = 2; (a + b) / 2", names(&[], &[])), + ("\"Ünïcödé name\"", names(&[], &[])), + ("const f = (v) => v * 2; f(1)", names(&[], &[])), + ]; + for (text, want) in follows { + assert_eq!(expression_reach(text), want, "{text}"); + } + let anything = [ + "comp(\"Shot \" + index).layer(1)", + "comp(name)", + "var n = \"Other\"; comp(n)", + "comp(\"O\\\"ther\")", + "x.comp(\"Other\")", + "var comp = 1", + "footage(thisComp.name)", + "new Comp(7).layer(1)", + "Layer(7, 3).name", + "__h(__COMP, 7)", + "o.__c = 7", + "eval('1')", + "Function('return 1')()", + "this.x", + "({}).constructor", + "Object.getPrototypeOf(thisComp)", + "JSON.parse(s)", + "thisComp.layer.call(o, 1)", + "o['_' + '_c']", + "o[k] = 7", + "o[k] ||= 7", + "o[k] += 7", + "[o[k]] = [7]", + "({a: o[k]} = {a: 7})", + "for (o[k] in x) {}", + "({[k]: 7})", + "({a: 1, [k]: 7})", + "o = {'__c': 7}", + "'\\x5f\\x5fc'", + "'\\_\\_c'", + "`${x}`", + "c\\u006fmp('A')", + "comp\u{0430}('A')", + "/\"/.test(s); comp(n); \"", + "return /x/", + "if (a) /\"/.test(s)", + "x = '", + "/* comp(n)", + "class X {}", + "String.fromCharCode(95)", + ]; + for text in anything { + assert_eq!(expression_reach(text), ExprReach::Anything, "{text}"); + } +} + +/// `a / b` divides after a name, a number, a string and a closing bracket or parenthesis. +#[test] +fn division_is_not_a_regular_expression() { + for text in ["a / b", "2 / 3", "'a' / 2", "x[0] / 2", "(a + b) / 2", "f(x) / 2 / g(y)"] { + assert_eq!(expression_reach(text), names(&[], &[]), "{text}"); + } +} + +struct Fixture { + p: Project, + main: ItemId, + other: ItemId, + third: ItemId, + unused: ItemId, + solid: ItemId, +} + +/// Comps "Main" (a solid), "Other" (a solid), "Third" (empty), an unused footage item. +fn fixture() -> Fixture { + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let solid = + p.add_item("Solid", effectcraft_color::Label::None, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 })); + let main = p.add_item("Main", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let other = p.add_item("Other", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let third = p.add_item("Third", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let unused = p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage { has_video: true, ..Default::default() })); + for c in [main, other] { + let l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: solid }, (64, 64), None); + p.comp_mut(c).unwrap().layers.push(l); + } + Fixture { p, main, other, third, unused, solid } +} + +fn set_expr(p: &mut Project, comp: ItemId, text: &str) { + let l = p.comp_mut(comp).unwrap().layers.first_mut().unwrap(); + l.props.prop_mut("transform/opacity").unwrap().expr = Some(Expression { text: text.into(), enabled: true }); +} + +fn reach(p: &Project, comp: ItemId) -> PixelReach { + p.pixel_reach(comp, &p.heard_layers()) +} + +#[test] +fn pixel_reach_is_what_the_comp_draws_and_what_its_expressions_name() { + let mut f = fixture(); + let ids = |r: &PixelReach| r.items.clone().map(|s| s.into_iter().collect::>()); + assert_eq!(reach(&f.p, f.main), PixelReach { items: Some([f.solid, f.main].into()), expressions: false, names: vec![] }); + // An expression reading only its own comp: what it draws, with expressions. + set_expr(&mut f.p, f.main, "thisComp.layer(1).transform.opacity"); + let r = reach(&f.p, f.main); + assert!(r.expressions); + assert_eq!(ids(&r), Some(vec![f.solid, f.main])); + // An expression in another comp doesn't reach this one. + set_expr(&mut f.p, f.other, "wiggle(1, 1)"); + assert_eq!(ids(&reach(&f.p, f.main)), Some(vec![f.solid, f.main])); + // A literal name: that comp and what it draws, with the name and the comps that have it. + set_expr(&mut f.p, f.main, "comp(\"Other\").layer(1).transform.opacity"); + let r = reach(&f.p, f.main); + assert_eq!(ids(&r), Some(vec![f.solid, f.main, f.other])); + assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other])]); + // Another comp taking the name is part of it, whichever the lookup finds. + f.p.item_mut(f.third).unwrap().name = "Other".into(); + let r = reach(&f.p, f.main); + assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other, f.third])]); + assert!(r.items.unwrap().contains(&f.third)); + // A name no item has yet still counts (with no item). + set_expr(&mut f.p, f.main, "footage(\"later.json\").sourceData"); + assert_eq!(reach(&f.p, f.main).names, vec![(false, "later.json".to_string(), vec![])]); + // A computed name reaches everything. + set_expr(&mut f.p, f.main, "comp(\"Ot\" + \"her\").width"); + assert_eq!(reach(&f.p, f.main), PixelReach { items: None, expressions: true, names: vec![] }); + // Footage reached as a layer's source brings the footage of its name (its data is read by + // name). + let comp = f.p.comp(f.main).unwrap().clone(); + let l = build::layer(&mut f.p, &comp, "U", LayerSource::Footage { item: f.unused }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(l); + let twin = f.p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage::default())); + set_expr(&mut f.p, f.main, "value"); + let r = reach(&f.p, f.main).items.unwrap(); + assert!(r.contains(&f.unused) && r.contains(&twin), "{r:?}"); +} + +/// A comp nested in the reach is scanned too: its expression's names count for the outer comp. +#[test] +fn nested_comps_bring_their_expressions_reach() { + let mut f = fixture(); + let comp = f.p.comp(f.main).unwrap().clone(); + let pre = build::layer(&mut f.p, &comp, "Pre", LayerSource::Comp { item: f.other }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(pre); + set_expr(&mut f.p, f.other, "comp(\"Third\").width"); + let r = reach(&f.p, f.main); + assert!(r.expressions && r.items.unwrap().contains(&f.third)); + // A cycle through names ends. + let l = build::layer(&mut f.p, &comp, "S", LayerSource::Solid { item: f.solid }, (64, 64), None); + f.p.comp_mut(f.third).unwrap().layers.push(l); + set_expr(&mut f.p, f.third, "comp(\"Main\").width"); + assert!(reach(&f.p, f.main).items.unwrap().contains(&f.main)); +} + +/// A 3D model layer's item is reached like any other source: it is drawn, and an expression +/// reads its name and size through `layer.source` (a renamed model left the comp's disk key and +/// its old frames in place). +#[test] +fn model_sources_are_reached() { + let mut f = fixture(); + let model = f.p.add_item( + "chair.glb", + effectcraft_color::Label::None, + None, + ItemKind::Footage(crate::Footage { kind: crate::FootageKind::Model, ..Default::default() }), + ); + let comp = f.p.comp(f.main).unwrap().clone(); + let l = build::layer(&mut f.p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(l); + assert!(reach(&f.p, f.main).items.unwrap().contains(&model), "drawn"); + set_expr(&mut f.p, f.main, "thisComp.layer(\"Model\").source.name.length"); + let r = reach(&f.p, f.main); + assert!(r.expressions && r.items.unwrap().contains(&model), "read by an expression"); +} diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 8635bc9a8..b935c2fc8 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -421,7 +421,12 @@ impl RenderSettings { /// The output frame rate: the Frame Rate override, unless the comp preserves its frame rate /// (Composition Settings ▸ Preserve frame rate when nested or in render queue). pub fn rate(&self, comp: &Comp) -> FrameRate { - if comp.preserve_frame_rate { comp.frame_rate } else { self.frame_rate.unwrap_or(comp.frame_rate) } + if comp.preserve_frame_rate { + return comp.frame_rate; + } + // A saved override is held to the supported rates, as a comp's own rate is: its frame count + // is the export's work. + self.frame_rate.map_or(comp.frame_rate, |r| crate::limits::frame_rate(r, comp.frame_rate)) } /// First output-rate frame starting at or after `t`. fn ceil_frame(r: FrameRate, t: Tick) -> i64 { @@ -480,6 +485,8 @@ impl RenderSettings { TimeSpan::Custom { start, end } => format!("Custom ({:.3}s – {:.3}s)", start.seconds(), end.seconds()), }; let rate = match (self.frame_rate, comp) { + // A saved override outside the supported rates renders at the clamped one: say so. + (Some(r), Some(c)) if self.rate(c) != r => format!("{:.3} fps (renders at {:.3})", r.as_f64(), self.rate(c).as_f64()), (Some(r), _) => format!("{:.3} fps", r.as_f64()), (None, Some(c)) => format!("Use comp's frame rate ({:.3})", c.frame_rate.as_f64()), (None, None) => "Use comp's frame rate".into(), @@ -779,6 +786,27 @@ pub struct Crop { pub right: i32, } +/// The largest side of a frame an output module writes (Resize is capped there too). +pub const MAX_OUTPUT_SIDE: i32 = 30_000; + +/// Crop edges `a`/`b` of one axis of `side` pixels: padding is taken back only as far as keeps +/// the result within [`MAX_OUTPUT_SIDE`], so a mixed pair that stays inside the cap is kept as +/// saved. Each edge is also bounded (what the larger of the side and the cap allows), so the +/// arithmetic on them cannot overflow. +fn bound_edges(a: i32, b: i32, side: i32) -> (i32, i32) { + let hi = side.max(MAX_OUTPUT_SIDE); + let (mut a, mut b) = (a.clamp(-MAX_OUTPUT_SIDE, hi), b.clamp(-MAX_OUTPUT_SIDE, hi)); + let excess = i64::from(side) - i64::from(a) - i64::from(b) - i64::from(MAX_OUTPUT_SIDE); + if excess > 0 { + // Only padding (negative edges) grows the side: take back as much of it as needed. + let excess = i32::try_from(excess).unwrap_or(i32::MAX); + let from_a = excess.min(-a.min(0)); + a += from_a; + b += (excess - from_a).min(-b.min(0)); + } + (a, b) +} + impl Crop { /// Edges `(top, left, bottom, right)` in pixels of a `w`×`h` frame rendered at `scale`. pub fn edges(&self, w: u32, h: u32, scale: f64) -> (i32, i32, i32, i32) { @@ -801,7 +829,12 @@ impl Crop { let b = conv((y + rh) * scale, hi).max(t); return (t, l, hi - b, wi - r); } - (self.top, self.left, self.bottom, self.right) + // Negative edges pad the frame: keep each padded side within the largest frame an + // output module writes, or a few saved integers ask for a canvas of gigapixels. + let (wi, hi) = (i32::try_from(w).unwrap_or(i32::MAX), i32::try_from(h).unwrap_or(i32::MAX)); + let (t, b) = bound_edges(self.top, self.bottom, hi); + let (l, r) = bound_edges(self.left, self.right, wi); + (t, l, b, r) } } @@ -1271,8 +1304,8 @@ impl OutputModule { // Width rules; height follows the cropped frame's aspect. rh = ((rw as f64 * ch as f64 / cw as f64).round() as u32).max(1); } - rw = rw.min(30_000); - rh = rh.min(30_000); + rw = rw.min(MAX_OUTPUT_SIDE as u32); + rh = rh.min(MAX_OUTPUT_SIDE as u32); (rw, rh) } /// The written frame size of an item (Render Settings resolution, Crop, Resize, codec @@ -1687,17 +1720,71 @@ pub fn numbered_template(template: &str, n: u32) -> String { /// Make a name safe as a file name component. pub fn sanitize(name: &str) -> String { - name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() + let mut s: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + // At most a file name's worth (255 UTF-8 bytes, whole characters only): a template repeating + // a long name token must not expand to gigabytes. A cut name ends in a hash of the whole + // name so long names that share a prefix stay distinct. + if s.len() > 255 { + // shortcut: 64-bit FNV-1a, not collision-resistant against crafted names; use a real hash if names become untrusted keys. + let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + let mut end = 255 - 17; + while !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + s.push_str(&format!("-{hash:016x}")); + } + // `.` and `..` would name the folder itself or its parent when the token is a path component. + if !s.is_empty() && s.chars().all(|c| c == '.') { "_".repeat(s.len()) } else { s } } #[cfg(test)] mod tests { + #[test] + fn crop_padding_stays_within_the_largest_output_frame() { + use super::*; + let crop = |top, left, bottom, right| Crop { enabled: true, top, left, bottom, right, ..Default::default() }; + let m = |c: Crop| OutputModule { crop: c, ..Default::default() }; + // Saved edges padding by billions of pixels: the frame stops at the cap. + assert_eq!(m(crop(i32::MIN, i32::MIN, i32::MIN, i32::MIN)).frame_size(100, 50, 1.0), (30_000, 30_000)); + assert_eq!(m(crop(-20_000, 0, -20_000, -1)).frame_size(100, 50, 1.0), (101, 30_000)); + // Ordinary crops and pads are unchanged. + assert_eq!(crop(1, -2, 3, 4).edges(100, 50, 1.0), (1, -2, 3, 4)); + // A mixed pair beyond the side that still ends within the cap is kept as saved (50 px). + assert_eq!(crop(0, 150, 0, -100).edges(100, 50, 1.0), (0, 150, 0, -100)); + assert_eq!(m(crop(0, 150, 0, -100)).frame_size(100, 50, 1.0), (50, 50)); + assert_eq!(m(crop(10, 10, 10, 10)).frame_size(100, 50, 1.0), (80, 30)); + assert_eq!(m(crop(i32::MAX, 0, 0, 0)).frame_size(100, 50, 1.0), (100, 1)); + } + use super::*; + #[test] + fn sanitize_limits_the_name_in_bytes() { + // 255 two-byte characters are cut to whole characters plus a 17-byte hash suffix. + let s = sanitize(&"é".repeat(255)); + assert!(s.len() <= 255 && s.len() > 235, "{}", s.len()); + assert!(s.starts_with("éé") && s.is_char_boundary(s.len() - 17)); + // Names that share their first 255 bytes stay distinct, and a name that fits is untouched. + let (a, b) = (sanitize(&format!("{}A", "界".repeat(85))), sanitize(&format!("{}B", "界".repeat(85)))); + assert_ne!(a, b); + assert!(a.len() <= 255 && b.len() <= 255); + assert_eq!(sanitize(&"n".repeat(255)), "n".repeat(255)); + assert_eq!(sanitize(&"é".repeat(127)), "é".repeat(127)); + } + #[test] fn templates_and_sequences() { let v = TemplateVars { comp_name: "Main/Title", project_name: "P", width: 1920, height: 1080, frame_rate: 29.97, start_frame: 0, end_frame: 9 }; assert_eq!(expand_template(DEFAULT_TEMPLATE, OutputFormat::H264, &v), "Main_Title.mp4"); + // A dot-only name is not a parent-folder component; a long one is cut to a file name. + for name in ["..", "."] { + let v = TemplateVars { comp_name: name, ..v }; + assert_eq!(expand_template("out/[compName]/x.[fileExtension]", OutputFormat::Gif, &v), format!("out/{}/x.gif", "_".repeat(name.len()))); + } + let long = "n".repeat(100_000); + let v2 = TemplateVars { comp_name: &long, ..v }; + assert_eq!(expand_template(&"[compName]".repeat(100), OutputFormat::Gif, &v2).len(), 255 * 100); assert_eq!(expand_template("[compName]_[width]x[height]@[frameRate].[fileExtension]", OutputFormat::Gif, &v), "Main_Title_1920x1080@29.97.gif"); assert_eq!(sequence_path("out/a_[#####].png", 42), "out/a_00042.png"); assert_eq!(sequence_path("a###.tif", 7), "a007.tif"); @@ -1847,6 +1934,22 @@ mod tests { assert_eq!(p.sample(&c, 3), FrameSample::Progressive(Tick::from_seconds_f64(0.12))); } + /// A saved Frame Rate override outside the supported rates is clamped, so a short comp cannot + /// ask for billions of output frames. + #[test] + fn frame_rate_override_is_held_to_the_supported_rates() { + let c = Comp::new(64, 48, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); + let s = |r| RenderSettings { frame_rate: Some(r), time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let huge = s(FrameRate::new(1_000_000_000_000, 1)); + assert_eq!(huge.rate(&c).as_f64(), crate::limits::MAX_FPS); + assert!(huge.frame_count(&c) <= 2001, "{}", huge.frame_count(&c)); + assert_eq!(s(FrameRate::new(0, 1)).rate(&c), c.frame_rate); + assert_eq!(s(FrameRate::new(60, 1)).rate(&c), FrameRate::new(60, 1)); + let shown = |r| s(r).describe(Some(&c)).into_iter().find(|l| l.0 == "Frame Rate").map(|l| l.1).unwrap_or_default(); + assert!(shown(FrameRate::new(1_000_000_000_000, 1)).contains("renders at 1000.000"), "{}", shown(FrameRate::new(1_000_000_000_000, 1))); + assert_eq!(shown(FrameRate::new(60, 1)), "60.000 fps"); + } + #[test] fn crop_and_resize_sizes() { let c = Comp::new(1920, 1080, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index d040c8f0b..80ba3c771 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -146,6 +146,18 @@ fn unique_names_and_cycles() { let c = p.comp(cid).unwrap(); assert_eq!(c.unique_layer_name("Red Solid 1"), "Red Solid 2"); assert_eq!(c.unique_layer_name("Shape Layer 1"), "Shape Layer 1"); + // A suffix written another way (`02`) is not the number taken. + let mut d = c.clone(); + for n in ["Red Solid 02", "Red Solid 3"] { + let mut l = d.layers[0].clone(); + l.name = n.into(); + d.layers.push(l); + } + assert_eq!(d.unique_layer_name("Red Solid 1"), "Red Solid 2"); + // Digits that end a word stay in the stem. + let mut e = c.clone(); + e.layers[0].name = "v-b1.mp4".into(); + assert_eq!(e.unique_layer_name("v-b1.mp4"), "v-b1.mp4 2"); assert!(p.comp_contains(cid, cid)); } diff --git a/crates/psd/Cargo.toml b/crates/psd/Cargo.toml index b6849041b..46ad240d7 100644 --- a/crates/psd/Cargo.toml +++ b/crates/psd/Cargo.toml @@ -10,4 +10,5 @@ rust-version.workspace = true workspace = true [dependencies] +craft-psd = { workspace = true } miniz_oxide = "0.9" diff --git a/crates/psd/src/engine_data.rs b/crates/psd/src/engine_data.rs index 20fa1676d..43dec23d5 100644 --- a/crates/psd/src/engine_data.rs +++ b/crates/psd/src/engine_data.rs @@ -1,316 +1,9 @@ -//! The text engine data of type layers (`EngineData` in the `TySh` descriptor): a PostScript-like -//! tree of dictionaries (`<< /Key value >>`), arrays (`[ ]`), numbers, names, booleans and -//! strings (`(þÿ` + UTF-16BE with backslash escapes `)`). Read best-effort. +//! Engine data (the text engine document of type layers, `TySh`): `craft-psd`'s +//! `engine_data`, re-exported. [`text_style`] is its typed view of the first style run. -/// Bytes that end a bare token or a `/Name` (besides whitespace). -const DELIMITERS: &[u8] = b"[]<>/("; +pub use craft_psd::engine_data::{TextStyle, Value, minimal, parse, parse_lossy, write}; -#[derive(Clone, Debug, PartialEq)] -pub enum EValue { - Dict(Vec<(String, EValue)>), - Array(Vec), - Num(f64), - Bool(bool), - Name(String), - Str(String), -} - -impl EValue { - pub fn get(&self, key: &str) -> Option<&EValue> { - match self { - EValue::Dict(v) => v.iter().find(|(k, _)| k == key).map(|(_, v)| v), - _ => None, - } - } - pub fn idx(&self, i: usize) -> Option<&EValue> { - match self { - EValue::Array(v) => v.get(i), - _ => None, - } - } - /// Follow a `/`-separated path; numeric segments index arrays. - pub fn path(&self, p: &str) -> Option<&EValue> { - let mut cur = self; - for seg in p.split('/').filter(|s| !s.is_empty()) { - cur = match seg.parse::() { - Ok(i) => cur.idx(i)?, - Err(_) => cur.get(seg)?, - }; - } - Some(cur) - } - pub fn num(&self) -> Option { - match self { - EValue::Num(n) => Some(*n), - _ => None, - } - } - pub fn str(&self) -> Option<&str> { - match self { - EValue::Str(s) | EValue::Name(s) => Some(s), - _ => None, - } - } -} - -struct P<'a> { - b: &'a [u8], - i: usize, -} - -impl P<'_> { - fn ws(&mut self) { - while self.i < self.b.len() && (self.b[self.i].is_ascii_whitespace() || self.b[self.i] == 0) { - self.i += 1; - } - } - fn peek(&self, s: &[u8]) -> bool { - self.b[self.i..].starts_with(s) - } - fn value(&mut self, depth: usize) -> Option { - if depth > 128 { - return None; - } - self.ws(); - if self.i >= self.b.len() { - return None; - } - if self.peek(b"<<") { - self.i += 2; - let mut v = vec![]; - loop { - self.ws(); - if self.i >= self.b.len() { - return Some(EValue::Dict(v)); - } - if self.peek(b">>") { - self.i += 2; - return Some(EValue::Dict(v)); - } - if self.b[self.i] != b'/' { - // Unexpected token: skip it. - self.i += 1; - continue; - } - let key = self.name(); - let val = self.value(depth + 1)?; - v.push((key, val)); - } - } - match self.b[self.i] { - b'[' => { - self.i += 1; - let mut v = vec![]; - loop { - self.ws(); - if self.i >= self.b.len() { - return Some(EValue::Array(v)); - } - if self.b[self.i] == b']' { - self.i += 1; - return Some(EValue::Array(v)); - } - let start = self.i; - match self.value(depth + 1) { - Some(x) => v.push(x), - None => return Some(EValue::Array(v)), - } - if self.i == start { - self.i += 1; - } - } - } - b'/' => Some(EValue::Name(self.name())), - b'(' => Some(EValue::Str(self.string())), - _ => { - let start = self.i; - while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !DELIMITERS.contains(&self.b[self.i]) { - self.i += 1; - } - let tok = std::str::from_utf8(&self.b[start..self.i]).unwrap_or(""); - if self.i == start { - self.i += 1; - return Some(EValue::Name(String::new())); - } - Some(match tok { - "true" => EValue::Bool(true), - "false" => EValue::Bool(false), - // "nan", "inf" and overflowing literals parse as f64 but are not numbers here. - t => match t.parse::() { - Ok(n) if n.is_finite() => EValue::Num(n), - _ => EValue::Name(t.to_string()), - }, - }) - } - } - } - fn name(&mut self) -> String { - self.i += 1; // '/' - let start = self.i; - while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !DELIMITERS.contains(&self.b[self.i]) { - self.i += 1; - } - String::from_utf8_lossy(&self.b[start..self.i]).into_owned() - } - fn string(&mut self) -> String { - self.i += 1; // '(' - let mut raw = vec![]; - while self.i < self.b.len() { - let c = self.b[self.i]; - self.i += 1; - match c { - b'\\' if self.i < self.b.len() => { - let e = self.b[self.i]; - self.i += 1; - raw.push(match e { - b'n' => b'\n', - b'r' => b'\r', - b't' => b'\t', - other => other, - }); - } - b')' => break, - _ => raw.push(c), - } - } - if raw.starts_with(&[0xFE, 0xFF]) { - let u: Vec = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - String::from_utf16_lossy(&u).trim_end_matches('\0').to_string() - } else { - String::from_utf8_lossy(&raw).into_owned() - } - } -} - -/// Parse engine data (returns an empty dictionary on garbage). -pub fn parse(b: &[u8]) -> EValue { - let mut p = P { b, i: 0 }; - p.value(0).unwrap_or(EValue::Dict(vec![])) -} - -/// Encode a string the way engine data stores it (UTF-16BE with a BOM, escaped). -pub fn encode_string(s: &str) -> Vec { - let mut out = vec![b'(', 0xFE, 0xFF]; - for u in s.encode_utf16() { - for b in u.to_be_bytes() { - if matches!(b, b'(' | b')' | b'\\') { - out.push(b'\\'); - } - out.push(b); - } - } - out.push(b')'); - out -} - -/// The first style run's character attributes. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct TextStyle { - pub font: Option, - pub size: Option, - /// RGB 0..1. - pub color: Option<[f64; 3]>, - /// 0 left, 1 right, 2 centre, 3+ justified variants. - pub justification: Option, - pub tracking: Option, - pub leading: Option, - pub faux_bold: bool, - pub faux_italic: bool, - /// Paragraph (box) text rather than point text. - pub box_text: bool, -} - -/// Sane upper bound for a font size in points (Photoshop's own limit is 1296 pt). -const MAX_FONT_SIZE: f64 = 10_000.0; -/// Sane bound for tracking (1/1000 em) and leading (points). -const MAX_SPACING: f64 = 100_000.0; - -pub fn text_style(ed: &EValue) -> TextStyle { - let engine = ed.get("EngineDict").unwrap_or(ed); - let resources = ed.get("ResourceDict").or_else(|| ed.get("DocumentResources")); - let run = engine.path("StyleRun/RunArray/0/StyleSheet/StyleSheetData"); - let normal = resources.and_then(|r| r.path("StyleSheetSet/0/StyleSheetData")); - let attr = |k: &str| run.and_then(|r| r.get(k)).or_else(|| normal.and_then(|n| n.get(k))); - let font = - attr("Font").and_then(EValue::num).and_then(|i| resources?.path(&format!("FontSet/{}/Name", i as usize))).and_then(EValue::str).map(str::to_string); - let color = attr("FillColor").and_then(|c| c.get("Values")).and_then(|v| match v { - EValue::Array(a) if a.len() >= 4 => Some([a[1].num()?, a[2].num()?, a[3].num()?]), - _ => None, - }); - let para = engine.path("ParagraphRun/RunArray/0/ParagraphSheet/Properties"); - let para_normal = resources.and_then(|r| r.path("ParagraphSheetSet/0/Properties")); - let justification = - para.and_then(|p| p.get("Justification")).or_else(|| para_normal.and_then(|p| p.get("Justification"))).and_then(EValue::num).map(|n| n as u8); - let auto_leading = attr("AutoLeading").is_none_or(|v| *v != EValue::Bool(false)); - let box_text = engine.path("Rendered/Shapes/Children/0/ShapeType").and_then(EValue::num) == Some(1.0); - TextStyle { - font, - size: attr("FontSize").and_then(EValue::num).filter(|s| *s > 0.0).map(|s| s.min(MAX_FONT_SIZE)), - color, - justification, - tracking: attr("Tracking").and_then(EValue::num).map(|t| t.clamp(-MAX_SPACING, MAX_SPACING)), - leading: if auto_leading { None } else { attr("Leading").and_then(EValue::num).map(|l| l.clamp(-MAX_SPACING, MAX_SPACING)) }, - faux_bold: attr("FauxBold") == Some(&EValue::Bool(true)), - faux_italic: attr("FauxItalic") == Some(&EValue::Bool(true)), - box_text, - } -} - -/// Minimal engine data for a type layer with one style run (fixtures, and what a writer needs). -pub fn minimal(text: &str, font: &str, size: f64, color: [f64; 3], justification: u8) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor << /Text "); - out.extend_from_slice(&encode_string(&format!("{text}\r"))); - out.extend_from_slice(b" >>\n\t\t/ParagraphRun << /RunArray [ << /ParagraphSheet << /Properties << /Justification "); - out.extend_from_slice(justification.to_string().as_bytes()); - out.extend_from_slice(b" >> >> >> ] >>\n\t\t/StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /Font 0 /FontSize "); - out.extend_from_slice(format!("{size:.1}").as_bytes()); - out.extend_from_slice( - format!(" /FillColor << /Type 1 /Values [ 1.0 {:.5} {:.5} {:.5} ] >> /AutoLeading true >> >> >> ] >>\n\t>>\n", color[0], color[1], color[2]).as_bytes(), - ); - out.extend_from_slice(b"\t/ResourceDict << /FontSet [ << /Name "); - out.extend_from_slice(&encode_string(font)); - out.extend_from_slice(b" /Type 0 >> ] >>\n>>"); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_minimal() { - let b = minimal("Hi (there)", "Arial-BoldMT", 36.0, [1.0, 0.5, 0.0], 2); - let v = parse(&b); - assert_eq!(v.path("EngineDict/Editor/Text").and_then(EValue::str), Some("Hi (there)\r")); - let st = text_style(&v); - assert_eq!(st.font.as_deref(), Some("Arial-BoldMT")); - assert_eq!(st.size, Some(36.0)); - assert_eq!(st.color, Some([1.0, 0.5, 0.0])); - assert_eq!(st.justification, Some(2)); - assert!(!st.box_text); - } - - #[test] - fn garbage_is_harmless() { - let _ = parse(b"<< /A [ 1 2 << /B (unterminated"); - let _ = parse(b"]]]>>>> /x"); - let _ = parse(&[0xFF; 64]); - } - - #[test] - fn non_finite_and_out_of_range_numbers_are_not_numbers() { - let b = b"<< /FontSize nan /Tracking inf /Leading -inf /Other 1e999 >>"; - let v = parse(b); - assert_eq!(v.get("FontSize").and_then(EValue::num), None); - assert_eq!(v.get("Other").and_then(EValue::num), None); - let ed = parse(b"<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /FontSize nan /Tracking inf /Leading -inf /AutoLeading false >> >> >> ] >> >> >>"); - let st = text_style(&ed); - assert_eq!((st.size, st.tracking, st.leading), (None, None, None)); - let ed = parse(b"<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /FontSize 1e300 /Tracking -1e300 /AutoLeading false /Leading 1e300 >> >> >> ] >> >> >>"); - let st = text_style(&ed); - assert_eq!(st.size, Some(MAX_FONT_SIZE)); - assert_eq!(st.tracking, Some(-MAX_SPACING)); - assert_eq!(st.leading, Some(MAX_SPACING)); - } +/// The first style run's character and paragraph attributes of a parsed document. +pub fn text_style(ed: &Value) -> TextStyle { + craft_psd::engine_data::first_style(ed) } diff --git a/crates/psd/src/limits.rs b/crates/psd/src/limits.rs index c1c23b390..6db366c00 100644 --- a/crates/psd/src/limits.rs +++ b/crates/psd/src/limits.rs @@ -107,7 +107,7 @@ impl Budget { /// Inflate a zlib stream to at most `limit` bytes. pub(crate) fn inflate(src: &[u8], limit: usize) -> Result> { - miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(src, limit).map_err(|e| Error::Invalid(format!("zip: {e:?}"))) + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(src, limit).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status))) } /// Give back what a channel buffer from `Psd::channel_bytes` / `Psd::decode_plane` was charged: diff --git a/crates/psd/src/memory_tests.rs b/crates/psd/src/memory_tests.rs index 108046b66..96ac5c70c 100644 --- a/crates/psd/src/memory_tests.rs +++ b/crates/psd/src/memory_tests.rs @@ -28,10 +28,11 @@ impl Psd { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let mut v = + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; v.resize(rowb * h, 0); if comp == 3 { - self.unpredict(&mut v, w, h); + self.unpredict(&mut v, w, h, &mut Budget::new(u64::MAX))?; } v } @@ -128,11 +129,12 @@ impl Psd { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h) + .map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; for (c, p) in planes.iter_mut().enumerate().take(want) { let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); if comp == 3 { - self.unpredict(&mut plane, w, h); + self.unpredict(&mut plane, w, h, &mut Budget::new(u64::MAX))?; } *p = Some(self.ref_samples(&plane, w, h)); } diff --git a/crates/psd/src/parse.rs b/crates/psd/src/parse.rs index d221b9044..6357b0024 100644 --- a/crates/psd/src/parse.rs +++ b/crates/psd/src/parse.rs @@ -287,7 +287,7 @@ impl Psd { let d = descriptor::read_descriptor(&mut r)?; let text = d.text("Txt ").unwrap_or_default().replace("\r\n", "\n").replace('\r', "\n"); let text = text.strip_suffix('\n').map(str::to_string).unwrap_or(text); - let style = d.raw("EngineData").map(|e| engine_data::text_style(&engine_data::parse(e))).unwrap_or_default(); + let style = d.raw("EngineData").map(|e| engine_data::text_style(&engine_data::parse_lossy(e))).unwrap_or_default(); let bounds = d.obj("bounds").or_else(|| d.obj("boundingBox")).and_then(|o| Some([o.num("Left")?, o.num("Top ")?, o.num("Rght")?, o.num("Btom")?])); l.text = Some(TextInfo { text, transform: t, style, bounds }); diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 21a5c8845..290a7b6f4 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -37,7 +37,7 @@ impl Psd { PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(&counts, 0, r, rowb, h, budget)?), PlaneStream::Zip(mut v) => { v.resize(size, 0); - self.zip_plane(comp, &mut v, 0, w, h)?; + self.zip_plane(comp, &mut v, 0, w, h, budget)?; Cow::Owned(v) } }) @@ -90,7 +90,7 @@ impl Psd { Ok(match planes { PlaneStream::Raw => Cow::Borrowed(r.bytes(size)?), PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(counts, c, r, rowb, h, budget)?), - PlaneStream::Zip(v) => Cow::Borrowed(self.zip_plane(comp, v, c, w, h)?), + PlaneStream::Zip(v) => Cow::Borrowed(self.zip_plane(comp, v, c, w, h, budget)?), }) } @@ -106,11 +106,11 @@ impl Psd { } /// Plane `c` of the inflated ZIP data of consecutive planes, un-predicted in place (`comp` 3). - pub(crate) fn zip_plane<'v>(&self, comp: u16, v: &'v mut [u8], c: usize, w: usize, h: usize) -> Result<&'v [u8]> { + pub(crate) fn zip_plane<'v>(&self, comp: u16, v: &'v mut [u8], c: usize, w: usize, h: usize, budget: &mut Budget) -> Result<&'v [u8]> { let rowb = self.row_bytes(w); let plane = v.get_mut(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?; if comp == 3 { - self.unpredict(plane, w, h); + self.unpredict(plane, w, h, budget)?; } Ok(plane) } @@ -132,7 +132,8 @@ impl Psd { if self.depth == 1 { w.div_ceil(8) } else { w * self.bytes_per_sample() } } - pub(crate) fn unpredict(&self, v: &mut [u8], w: usize, h: usize) { + /// Undo ZIP prediction in place; the 32-bit scratch row is charged to `budget`. + pub(crate) fn unpredict(&self, v: &mut [u8], w: usize, h: usize, budget: &mut Budget) -> Result<()> { match self.depth { 16 => { for row in v.chunks_exact_mut(w * 2).take(h) { @@ -145,8 +146,9 @@ impl Psd { } } 32 => { - // One scratch row for the whole plane. - let mut planar = vec![0u8; w * 4]; + // One scratch row for the whole plane, charged: a wide, short layer passes the + // decode's peak check and would otherwise allocate it on top. + let mut planar = budget.filled(w.checked_mul(4).ok_or_else(|| too_large(w, h))?, 0u8)?; #[cfg(test)] counters::bump(&counters::SCRATCH); for row in v.chunks_exact_mut(w * 4).take(h) { @@ -161,6 +163,7 @@ impl Psd { } } } + budget.release(planar.len() as u64); } _ => { for row in v.chunks_exact_mut(w).take(h) { @@ -170,6 +173,7 @@ impl Psd { } } } + Ok(()) } /// Hand every sample of `raw` (`w`×`h`, row-major, in order) to `f` as a float. Samples a short @@ -483,15 +487,17 @@ pub(crate) fn unpack_bits(src: &[u8], row: usize, out: &mut Vec) -> Result<( while i < src.len() && out.len() - start < row { let n = src[i] as i8; i += 1; + // A packet is written only up to the row's end, so the reserved plane never grows. + let room = row - (out.len() - start); if n >= 0 { let k = n as usize + 1; let s = src.get(i..i + k).ok_or(Error::Truncated)?; - out.extend_from_slice(s); + out.extend_from_slice(&s[..k.min(room)]); i += k; } else if n != -128 { let k = (1 - n as isize) as usize; let v = *src.get(i).ok_or(Error::Truncated)?; - out.extend(std::iter::repeat_n(v, k)); + out.extend(std::iter::repeat_n(v, k.min(room))); i += 1; } } diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 074976510..fd041138a 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -493,3 +493,29 @@ fn rle_composite_matches_the_raw_one() { assert_eq!(rle.layer_pixels(2, true).unwrap().data, raw.layer_pixels(2, true).unwrap().data, "{depth} bit"); } } + +#[test] +fn packbits_packets_stop_at_the_row_end() { + // A 128-byte repeat and a 128-byte literal into 3-byte rows: the reserved row is never + // outgrown (capacity stays what was reserved). + for src in [vec![0x81u8, 7], { + let mut v = vec![0x7fu8]; + v.extend([1u8; 128]); + v + }] { + let mut out = Vec::with_capacity(3); + unpack_bits(&src, 3, &mut out).unwrap(); + assert_eq!(out.len(), 3); + assert_eq!(out.capacity(), 3); + } +} + +#[test] +fn a_failed_inflate_reports_its_status_not_its_output() { + // A stream that inflates a megabyte of zeros, then breaks its checksum. + let mut z = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1 << 20], 6); + let n = z.len(); + z[n - 1] ^= 0xff; + let Err(Error::Invalid(msg)) = crate::limits::inflate(&z, 1 << 21) else { panic!("the checksum fails") }; + assert!(msg.len() < 100, "{} bytes", msg.len()); +} diff --git a/crates/psd/src/tests/opt.rs b/crates/psd/src/tests/opt.rs index 908026d07..44bcec071 100644 --- a/crates/psd/src/tests/opt.rs +++ b/crates/psd/src/tests/opt.rs @@ -69,7 +69,7 @@ fn ref_decode_channel(p: &Psd, offset: usize, len: usize, w: usize, h: usize) -> } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; v.resize(rowb * h, 0); if comp == 3 { ref_unpredict(p.depth, &mut v, w, h); @@ -112,7 +112,8 @@ fn ref_composite(p: &Psd) -> Result { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let v = + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; for (c, pl) in planes.iter_mut().enumerate().take(want) { let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); if comp == 3 { @@ -448,7 +449,7 @@ fn unpredict_matches_the_reference_and_allocates_one_scratch_row() { let mut a: Vec = (0..w * h * bps).map(|i| (i * 31 % 251) as u8).collect(); let mut b = a.clone(); counters::take(&SCRATCH); - p.unpredict(&mut a, w, h); + p.unpredict(&mut a, w, h, &mut Budget::new(u64::MAX)).unwrap(); let scratch = counters::take(&SCRATCH); let old_allocs = ref_unpredict(depth, &mut b, w, h); assert_eq!(a, b, "{depth} bit"); @@ -473,11 +474,11 @@ fn raw_planes_are_borrowed_and_zip_planes_are_unpredicted_in_place() { let mut v: Vec = (0..3 * 40 * 30).map(|i| (i % 255) as u8).collect(); let range = v.as_ptr_range(); for comp in [2, 3] { - let got = q.zip_plane(comp, &mut v, 1, 40, 30).unwrap(); + let got = q.zip_plane(comp, &mut v, 1, 40, 30, &mut Budget::new(u64::MAX)).unwrap(); assert_eq!(got.len(), 1200); assert!(range.contains(&got.as_ptr()), "compression {comp}"); } - assert!(q.zip_plane(2, &mut v, 3, 40, 30).is_err()); + assert!(q.zip_plane(2, &mut v, 3, 40, 30, &mut Budget::new(u64::MAX)).is_err()); } #[test] diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 7cd4fbe5a..9e11b781d 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-exr = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-color = { workspace = true } rayon = { workspace = true } diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index c8689a7b6..76e51f865 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -27,13 +27,25 @@ fn box_h_into(src: &[Px], out: &mut [Px], w: usize, r: usize, repeat: bool) { row[i as usize] } }; + // The starting window: the pixels inside the row once each, and the repeated edge pixels + // counted rather than visited (a radius far past the row would otherwise loop over it). let mut acc = [0.0f32; 4]; - for i in -(r as isize)..=(r as isize) { - let p = get(i); + let inside = r.min(w - 1); + for p in &row[..=inside] { for c in 0..4 { acc[c] += p[c]; } } + if repeat { + let (left, right) = (r as f32, (r - inside) as f32); + for c in 0..4 { + acc[c] += row[0][c] * left; + // A zero count adds nothing (0 * NaN would be NaN). + if right > 0.0 { + acc[c] += row[w - 1][c] * right; + } + } + } for x in 0..w { o[x] = acc.map(|v| v * norm); let add = get(x as isize + r as isize + 1); @@ -65,14 +77,27 @@ fn box_v_into(src: &[Px], out: &mut [Px], w: usize, h: usize, r: usize, repeat: out.par_chunks_mut(w * block).enumerate().for_each(|(bi, chunk)| { let ya = (bi * block) as isize; let mut acc = vec![[0.0f32; 4]; w]; - for i in ya - r as isize..=ya + r as isize { - if let Some(rw) = row(i) { - for (a, p) in acc.iter_mut().zip(rw) { - for c in 0..4 { - a[c] += p[c]; - } + // The starting window: rows inside the image once each, the repeated edge rows counted. + let (lo, hi) = (ya - r as isize, ya + r as isize); + let add = |acc: &mut [Px], rw: &[Px], k: f32| { + // A zero count adds nothing (0 * NaN would be NaN). + if k == 0.0 { + return; + } + for (a, p) in acc.iter_mut().zip(rw) { + for c in 0..4 { + a[c] += p[c] * k; } } + }; + for i in lo.max(0)..=hi.min(h as isize - 1) { + add(&mut acc, &src[i as usize * w..(i as usize + 1) * w], 1.0); + } + if repeat { + let above = (-lo).max(0) as f32; + let below = (hi - (h as isize - 1)).max(0) as f32; + add(&mut acc, &src[0..w], above); + add(&mut acc, &src[(h - 1) * w..h * w], below); } for (yy, o) in chunk.chunks_mut(w).enumerate() { let y = ya + yy as isize; @@ -166,6 +191,9 @@ pub fn gaussian_is_exact(sigma_x: f64, sigma_y: f64) -> bool { sigma_x.max(sigma_y) < EXACT_SIGMA_BELOW } +/// Pixels per tile of [`gauss_pass`]'s vertical pass. +const TILE: usize = 128; + /// 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). @@ -176,20 +204,65 @@ fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image { 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 (w, h) = (img.width as usize, img.height 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; + // Every output pixel adds its taps in kernel order, in f64, as a per-pixel loop would: the + // result is the same to the bit. Taps outside a transparent edge are skipped (adding `+0.0` + // leaves a sum that started at `+0.0` unchanged). + if vertical { + // Row by row: each output row adds whole input rows (contiguous, vectorises), instead of + // walking a column per pixel (a strided read per tap). + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + // 128 pixels at a time: the f64 sums (4 KB) stay in L1 across the taps. + for (t, out_tile) in row.chunks_mut(TILE).enumerate() { + let x0 = t * TILE; + let mut acc = [[0.0f64; 4]; TILE]; + for (i, &wt) in k.iter().enumerate() { + let sy = y as i64 + i as i64 - r; + let sy = if repeat { + sy.clamp(0, h as i64 - 1) + } else if sy < 0 || sy >= h as i64 { + continue; + } else { + sy + }; + let start = sy as usize * w + x0; + let Some(src) = img.data.get(start..start + out_tile.len()) else { continue }; + for (a, p) in acc.iter_mut().zip(src) { + for c in 0..4 { + a[c] += p[c] as f64 * wt; + } + } + } + for (o, a) in out_tile.iter_mut().zip(acc.iter()) { + *o = a.map(|v| v as f32); } } - row[x] = acc.map(|v| v as f32); + }); + return out; + } + let n = k.len(); + out.data.par_chunks_mut(w).zip(img.data.par_chunks(w)).for_each(|(row, src)| { + for (x, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f64; 4]; + // Inside the row: the taps are one slice (no per-tap edge checks). + if let Some(taps) = x.checked_sub(r as usize).and_then(|x0| src.get(x0..x0 + n)) { + for (p, wt) in taps.iter().zip(&k) { + for c in 0..4 { + acc[c] += p[c] as f64 * wt; + } + } + } else { + for (i, wt) in k.iter().enumerate() { + let sx = x as i64 + i as i64 - r; + let at = if repeat { Some(sx.clamp(0, w as i64 - 1)) } else { Some(sx) }; + let Some(p) = at.and_then(|i| usize::try_from(i).ok()).and_then(|i| src.get(i)) else { continue }; + for c in 0..4 { + acc[c] += p[c] as f64 * wt; + } + } + } + *o = acc.map(|v| v as f32); } }); out @@ -264,6 +337,107 @@ pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) -> mod tests { use super::*; + /// The sampled Gaussian pass as it was (one pixel at a time, bounds-checked taps): the + /// row-wise passes must match it to the bit. + fn gauss_pass_per_pixel(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image { + 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 mut out = Image::new(img.width, img.height); + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + 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, y + d) } else { (x + d, y) }; + 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; + } + } + out.set(x as u32, y as u32, acc.map(|v| v as f32)); + } + } + out + } + + #[test] + fn gauss_pass_is_bit_identical_to_the_per_pixel_kernel() { + // Sizes narrower and shorter than the kernel too (every tap near an edge), one wider + // than three tiles (tiles past the first, and a partial last one), with negative, zero + // and HDR values. + for (w, h) in [(37, 23), (3, 2), (1, 9), (64, 1), (300, 5)] { + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + let v = ((i * 7919) % 101) as f32 / 37.0 - 0.6; + *p = [v, -v * 0.5, if i % 5 == 0 { 0.0 } else { v * 3.0 }, (v + 0.6).min(1.0)]; + } + for sigma in [0.4, 1.3, 2.9] { + for vertical in [false, true] { + for repeat in [false, true] { + let (a, b) = (gauss_pass(&img, sigma, vertical, repeat), gauss_pass_per_pixel(&img, sigma, vertical, repeat)); + let bits = |i: &Image| i.data.iter().flat_map(|p| p.map(f32::to_bits)).collect::>(); + assert!(bits(&a) == bits(&b), "{w}×{h} sigma {sigma} vertical {vertical} repeat {repeat}"); + } + } + } + } + } + + #[test] + fn a_non_finite_edge_pixel_outside_every_window_is_not_counted() { + // 9 wide, radius 1: the last pixel is in no window of the first pixels; likewise the last row. + let mut src = vec![[1.0f32; 4]; 81]; + src[80] = [f32::NAN; 4]; + let mut out = vec![[0.0f32; 4]; 81]; + box_h_into(&src, &mut out, 9, 1, true); + assert!(out[0].iter().all(|v| v.is_finite()), "{:?}", out[0]); + box_v_into(&src, &mut out, 9, 9, 1, true); + assert!(out[0].iter().all(|v| v.is_finite()), "{:?}", out[0]); + } + + #[test] + fn box_windows_count_repeated_edges_and_take_any_radius() { + // Against a window summed pixel by pixel, for radii inside and far past the image. + let (w, h) = (7usize, 5usize); + let src: Vec = (0..w * h).map(|i| [i as f32, (i % 3) as f32, 1.0, 0.5]).collect(); + let at = |x: isize, y: isize, repeat: bool| -> Px { + if !repeat && (x < 0 || y < 0 || x >= w as isize || y >= h as isize) { + return [0.0; 4]; + } + src[y.clamp(0, h as isize - 1) as usize * w + x.clamp(0, w as isize - 1) as usize] + }; + for repeat in [true, false] { + for r in [1usize, 3, 6, 9, 40] { + let (mut hx, mut vx) = (vec![[0.0; 4]; w * h], vec![[0.0; 4]; w * h]); + box_h_into(&src, &mut hx, w, r, repeat); + box_v_into(&src, &mut vx, w, h, r, repeat); + for y in 0..h as isize { + for x in 0..w as isize { + let (mut eh, mut ev) = ([0.0f32; 4], [0.0f32; 4]); + for d in -(r as isize)..=r as isize { + for c in 0..4 { + eh[c] += at(x + d, y, repeat)[c]; + ev[c] += at(x, y + d, repeat)[c]; + } + } + let n = (2 * r + 1) as f32; + let i = y as usize * w + x as usize; + for c in 0..4 { + assert!((hx[i][c] - eh[c] / n).abs() < 1e-3, "h r={r} repeat={repeat}"); + assert!((vx[i][c] - ev[c] / n).abs() < 1e-3, "v r={r} repeat={repeat}"); + } + } + } + } + } + // A radius of 2^40 finishes at once. + let mut out = vec![[0.0; 4]; w * h]; + box_h_into(&src, &mut out, w, 1 << 40, true); + box_v_into(&src, &mut out.clone(), w, h, 1 << 40, true); + } + #[test] fn gaussian_preserves_mass() { let mut img = Image::new(64, 64); diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index ae2d38303..05473ca80 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -66,36 +66,19 @@ impl AuxChannels { /// The layers of the channels (OpenEXR layers: the name before the last `.`; `""` for the /// unnamed layer), in channel order. pub fn layers(&self) -> Vec<&str> { - let mut out: Vec<&str> = vec![]; - for (n, _) in &self.channels { - let (layer, _) = split_channel(n); - if !out.contains(&layer) { - out.push(layer); - } - } - out + craft_exr::channels::layers(self.channels.iter().map(|(n, _)| n.as_str())) } /// A layer's channels as the names to show in red, green, blue and alpha (`""` = none): /// its R, G, B, A channels (or red / green / blue / alpha, X / Y / Z, U / V); a layer of one /// channel (depth, an ID) shows it in all three colours. pub fn layer_rgba(&self, layer: &str) -> [String; 4] { - let chans: Vec<&str> = self.channels.iter().map(|(n, _)| n.as_str()).filter(|n| split_channel(n).0 == layer).collect(); - let pick = |names: &[&str]| -> String { - chans.iter().find(|c| names.iter().any(|n| split_channel(c).1.eq_ignore_ascii_case(n))).map_or(String::new(), |c| c.to_string()) - }; - if let [one] = chans.as_slice() { - return [one.to_string(), one.to_string(), one.to_string(), String::new()]; - } - [pick(&["R", "red", "X", "U"]), pick(&["G", "green", "Y", "V"]), pick(&["B", "blue", "Z"]), pick(&["A", "alpha"])] + craft_exr::channels::layer_rgba(self.channels.iter().map(|(n, _)| n.as_str()), layer) + .map(|i| i.and_then(|i| self.channels.get(i)).map_or(String::new(), |(n, _)| n.clone())) } } -/// A channel name's layer and channel: `diffuse.R` → (`diffuse`, `R`), `ViewLayer.Combined.R` -/// → (`ViewLayer.Combined`, `R`), `Z` → (`""`, `Z`). -pub fn split_channel(name: &str) -> (&str, &str) { - name.rsplit_once('.').unwrap_or(("", name)) -} +pub use craft_exr::channels::split_channel; impl AuxChannels { pub fn depth(&self) -> Option<&[f32]> { @@ -206,42 +189,7 @@ impl AuxChannels { } } -/// MurmurHash3 x86 32-bit (public-domain algorithm by Austin Appleby). -pub fn murmur3_32(data: &[u8], seed: u32) -> u32 { - const C1: u32 = 0xcc9e_2d51; - const C2: u32 = 0x1b87_3593; - let mut h = seed; - let (chunks, tail) = data.as_chunks::<4>(); - for c in chunks { - let mut k = u32::from_le_bytes([c[0], c[1], c[2], c[3]]); - k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2); - h ^= k; - h = h.rotate_left(13).wrapping_mul(5).wrapping_add(0xe654_6b64); - } - let mut k = 0u32; - for (i, b) in tail.iter().enumerate() { - k ^= (*b as u32) << (8 * i); - } - if !tail.is_empty() { - k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2); - h ^= k; - } - h ^= data.len() as u32; - h ^= h >> 16; - h = h.wrapping_mul(0x85eb_ca6b); - h ^= h >> 13; - h = h.wrapping_mul(0xc2b2_ae35); - h ^= h >> 16; - h -} - -/// Cryptomatte hash of a name (the manifest value). -pub fn crypto_hash(name: &str) -> u32 { - let h = murmur3_32(name.as_bytes(), 0); - // Keep the float finite and normal: flip the low exponent bit when it is all 0s or 1s. - let e = (h >> 23) & 0xff; - if e == 0 || e == 255 { h ^ (1 << 23) } else { h } -} +pub use craft_exr::cryptomatte::{hash as crypto_hash, murmur3_32}; /// The ID float stored in Cryptomatte channels for a hash. pub fn crypto_float(hash: u32) -> f32 { diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs index 180b235bf..3a4a9d6f8 100644 --- a/crates/raster/src/flow.rs +++ b/crates/raster/src/flow.rs @@ -113,15 +113,63 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { block_flow_window(a, b, block, block, radius) } +/// Largest coarse search radius honoured (the effects use at most 64). +const MAX_RADIUS: i32 = 1024; +/// Upper bound on the pixel comparisons of one motion search (about 4·10¹²: minutes of work on +/// many cores). A finer vector grid than this allows is made coarser, by doubling the tile. +const MAX_SEARCH_WORK: f64 = 4.4e12; + +/// The pixel comparisons [`block_flow_window`] makes for tiles of `bs`: every tile, at every +/// pyramid level, tries its candidate vectors with a `window`-sized block, then refines. +/// The pyramid depth follows the requested tile `fine`, not the coarsened one, so a coarser vector +/// grid keeps the full search range. +fn search_work(w: usize, h: usize, bs: usize, fine: usize, win: i64, radius: i64) -> f64 { + let tiles = (w.div_ceil(bs).max(1) as f64) * (h.div_ceil(bs).max(1) as f64); + let reach = (fine as i64).max(win) as usize * 2; + let (mut lw, mut lh, mut top) = (w, h, 0u32); + while top < 3 && lw / 2 >= reach && lh / 2 >= reach { + (lw, lh, top) = (lw / 2, lh / 2, top + 1); + } + let mut per_tile = 5.0 * (win * win) as f64; + for level in 0..=top { + let lbs = (win >> level).max(4) as f64; + let (seeds, r) = if level == top { (1.0, radius as f64) } else { (6.0, 1.0) }; + per_tile += seeds * (2.0 * r + 1.0).powi(2) * lbs * lbs; + } + tiles * per_tile +} + +/// The tile size and radius to search with: `tile`, doubled (while it still fits the frame) and +/// then, if that is not enough, the radius halved, until the search is within [`MAX_SEARCH_WORK`]. +fn planned(w: usize, h: usize, tile: usize, win: i64, radius: i64) -> (usize, i64) { + let (mut bs, mut r) = (tile, radius); + while bs * 2 <= w.min(h) && search_work(w, h, bs, tile, win, r) > MAX_SEARCH_WORK { + bs *= 2; + } + while r > 1 && search_work(w, h, bs, tile, win, r) > MAX_SEARCH_WORK { + r /= 2; + } + (bs, r) +} + /// [`block_flow`] with separate vector spacing and matching window: one vector per /// `tile × tile` tile, each matched with the `window × window` block centred on its tile /// (Timewarp's Vector Detail and Block Size). pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i32) -> Flow { - let bs = tile.max(2) as usize; - let win = window.max(4) as i64; + // The matching cost per vector grows with the window's area; 512 is far above any block size the effects offer. + let win = i64::from(window.clamp(4, 512)); + let radius = radius.min(MAX_RADIUS); + let fine = tile.max(2) as usize; + let (bs, radius) = planned(a.width as usize, a.height as usize, fine, win, i64::from(radius)); + let coarse = bs != fine; // Window origin relative to the tile origin. let wo = bs as i64 / 2 - win / 2; let (w, h) = (a.width as usize, a.height as usize); + // A coarsened grid's last (partial) tiles keep their window inside the frame. + let origin = |c: i64, dim: usize| { + let o = c * bs as i64 + wo; + if coarse { o.clamp(0, (dim as i64 - win).max(0)) } else { o } + }; let cols = w.div_ceil(bs).max(1); let rows = h.div_ceil(bs).max(1); let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] }; @@ -131,7 +179,7 @@ pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i // Pyramids (level 0 = full resolution); stop while blocks still fit. let mut pa = vec![Plane::luma(a)]; let mut pb = vec![Plane::luma(b)]; - while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= (bs as i64).max(win) as usize * 2 && p.h / 2 >= (bs as i64).max(win) as usize * 2) { + while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= (fine as i64).max(win) as usize * 2 && p.h / 2 >= (fine as i64).max(win) as usize * 2) { let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half)); pa.extend(na); pb.extend(nb); @@ -144,14 +192,14 @@ pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i let k = 1i64 << level; // The block covers the tile's footprint at this level (at least 4 pixels). let lbs = (win / k).max(4); - let r = if level == top { radius as i64 } else { 1 }; + let r = if level == top { radius } else { 1 }; let prev = cur.clone(); cur = (0..cols * rows) .into_par_iter() .map(|i| { let (c, rr) = ((i % cols) as i64, (i / cols) as i64); - let bx = (c * bs as i64 + wo) / k; - let by = (rr * bs as i64 + wo) / k; + let bx = origin(c, w) / k; + let by = origin(rr, h) / k; // Candidates: this tile's and its neighbours' vectors from the coarser level. let mut seeds: Vec<[i64; 2]> = vec![[0, 0]]; if level != top { @@ -184,12 +232,11 @@ pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i } // Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution. let (la, lb) = (&pa[0], &pb[0]); - let bsi = bs as i64; let wsi = win; let fine: Vec<[f32; 2]> = (0..cols * rows) .into_par_iter() .map(|i| { - let (bx, by) = ((i % cols) as i64 * bsi + wo, (i / cols) as i64 * bsi + wo); + let (bx, by) = (origin((i % cols) as i64, w), origin((i / cols) as i64, h)); let d = cur[i]; let e0 = sad(la, lb, bx, by, wsi, d[0], d[1]); let fit = |em: f32, ep: f32| { @@ -295,6 +342,26 @@ mod tests { s / n as f32 } + #[test] + fn a_fine_vector_grid_with_a_huge_window_is_made_coarser() { + // 8K frame, a vector per 2 px, a 512-px window and a 1024-px search: far past the budget. + let (t, r) = super::planned(7680, 4320, 2, 512, 1024); + assert!(t > 2 && t <= 4320 && super::search_work(7680, 4320, t, 2, 512, r) <= super::MAX_SEARCH_WORK, "{t} {r}"); + // The tile stays inside the frame, and the pyramid (search range) follows the requested tile. + let (t, _) = super::planned(7680, 4320, 2, 512, 710); + assert!(t <= 4320); + // Pixel Motion Blur's finest grid with its largest search keeps its grid on a 1080p frame. + assert_eq!(super::planned(1920, 1080, 2, 16, 64), (2, 64)); + assert_eq!(super::planned(1920, 1080, 8, 64, 4), (8, 4)); + } + + #[test] + fn a_huge_matching_window_is_held_to_the_cap() { + let (a, b) = (texture(520, 520, 0.0), texture(520, 520, 2.0)); + let capped = block_flow_window(&a, &b, 260, 512, 2); + assert_eq!(block_flow_window(&a, &b, 260, u32::MAX, 2).v, capped.v); + } + #[test] fn flow_finds_translation() { let a = texture(128, 96, 0.0); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index fd2fd183b..e18a24c63 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -31,15 +31,36 @@ pub const MAX_PADDED_SIDE: u32 = 30_000; /// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): /// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer /// conversion rounds like `round()` and vectorises. -#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN #[inline(always)] pub fn quantize_px(p: &mut Px, levels: f32, inv: f32) { - let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; + *p = quantize_levels(*p, levels, inv).map(|n| n * inv); +} + +/// [`quantize_px`]'s steps: the whole number of `levels` each channel rounds to (0..=levels). +#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN +#[inline(always)] +pub fn quantize_levels(p: Px, levels: f32, inv: f32) -> [f32; 4] { + let na = round_half_up(p[3].max(0.0).min(1.0) * levels); + let a = na * inv; let hi = [a, a, a, a]; + let mut n = [0.0, 0.0, 0.0, na]; for c in 0..3 { - p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; + n[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels); } - p[3] = a; + n +} + +/// [`quantize_px`] at 8 bpc, as bytes: [`expand_u8`] gives back exactly the pixel +/// [`quantize_px`] writes, so an 8 bpc buffer can be kept in a quarter of the memory. +#[inline(always)] +pub fn quantize_px_u8(p: Px) -> [u8; 4] { + quantize_levels(p, 255.0, 1.0 / 255.0).map(|n| n as u8) +} + +/// The 8 bpc pixel of [`quantize_px_u8`]'s bytes. +#[inline(always)] +pub fn expand_u8(n: [u8; 4]) -> Px { + n.map(|v| f32::from(v) * (1.0 / 255.0)) } /// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's @@ -403,6 +424,23 @@ impl Mask { mod tests { use super::*; + /// Pixels kept as `quantize_px_u8` bytes expand to the 8 bpc pixel `quantize_px` writes, bit + /// for bit: every level, exact halves, out-of-range, negative, NaN and premultiplied colour + /// brighter than its alpha. + #[test] + fn u8_levels_expand_to_the_quantised_pixel() { + let mut values: Vec = (0..=255 * 4).map(|i| i as f32 / (255.0 * 4.0)).collect(); + values.extend([-0.0, -1.0, 1.0, 1.5, f32::NAN, f32::INFINITY, 0.5 / 255.0, 254.5 / 255.0]); + for &a in &[1.0, 0.5, 0.2, 0.0, 2.0, f32::NAN] { + for &v in &values { + let p = [v, 1.0 - v, v * 0.5, a]; + let mut want = p; + quantize_px(&mut want, 255.0, 1.0 / 255.0); + assert_eq!(expand_u8(quantize_px_u8(p)).map(f32::to_bits), want.map(f32::to_bits), "{p:?}"); + } + } + } + #[test] fn dither_keeps_exact_codes_and_averages_the_rest() { // A value on an 8-bit code is that code at every position. diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index efbd1fc19..cd67e846a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -26,3 +26,6 @@ rayon = { workspace = true } web-time = { workspace = true } serde = { workspace = true } log = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index f545a62ee..21f609574 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -21,16 +21,35 @@ pub enum Backend { } impl Backend { + /// The backend `s` names, whatever case it is written in: the project's own values (`cpu`, + /// `gpu`, `auto`), the labels Project Settings ▸ Video Rendering and Effects draws + /// (`GPU Acceleration`, `Software Rendering`) and the older After Effects wording. pub fn parse(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { - "cpu" | "software" | "mercury software only" => Some(Backend::Cpu), - "gpu" | "mercury gpu acceleration" => Some(Backend::Gpu), + "cpu" | "software" | "mercury software only" | "software rendering" => Some(Backend::Cpu), + "gpu" | "mercury gpu acceleration" | "gpu acceleration" => Some(Backend::Gpu), "auto" => Some(Backend::Auto), _ => None, } } } +/// An accelerator's device memory, to size how many frames an export renders on it at once +/// (see [`Accelerator::frame_memory`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FrameMemory { + /// Bytes the accelerator may use (a conservative share of the device, not its free memory). + pub budget: u64, + /// The most its allocator has held while frames rendered (one frame's working memory + /// included). + pub peak: u64, + /// Bytes of that peak one frame needed (what it released again), or the whole budget once + /// frames have had to wait for memory. + pub frame: u64, + /// Bytes long-lived caches (uploaded layers) may still grow by. + pub reserve: u64, +} + /// One effect of a GPU effect chain (see [`Accelerator::effects`]). pub struct FxStep<'x> { pub spec: &'static effectcraft_effects::EffectSpec, @@ -91,6 +110,12 @@ pub trait Accelerator: Send + Sync { fn auto_pick(&self) -> Option<&AutoPick> { None } + /// The device memory budget and use, after at least one frame rendered: what an export + /// sizes its frames in flight by. `None` where it isn't known (no allocator report, no + /// budget): the export then renders one frame at a time. + fn frame_memory(&self) -> Option { + None + } /// Whether the accelerator can still render frames. `false` once it has stopped for good /// (its device was lost or retired): every frame it is asked for falls back to the CPU, so /// callers that limit frames in flight for it can stop doing so. @@ -224,7 +249,7 @@ impl<'a> Renderer<'a> { LayerSource::Footage { item } => { let item = ctx.media_item(layer, *item); let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; - let a = self.footage.aux(item, f, ctx.source_time(layer))?; + let a = self.read(|| 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; @@ -349,20 +374,19 @@ impl<'a> Renderer<'a> { pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { // Cached, so an accelerator sees the same buffer (and uploads it once) every frame. let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + let key = self.mask_gate_key(ctx, layer, self.clipped_key(ctx, layer, key)); + if let Some((b, _)) = self.cached(key) { return Some(b); } - let mut foot = self.source(ctx, layer)?; - let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); - let foot = Arc::new(foot); + let (foot, files) = self.recorded(key, |r| { + // (what the masks' expressions read, at shutter times too, goes to the buffer's files) + let ctx = &r.with_reads(ctx); + let mut foot = r.source(ctx, layer)?; + let _ = masks::apply(ctx, layer, &mut foot, r.mask_blur(ctx, layer)); + Some(foot) + }); // 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) + Some(self.keep(key, Arc::new(foot?), &files).0) } /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index e2861340c..2c1538133 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -24,6 +24,15 @@ use crate::{EvalCtx, ExprHost, FootageSource}; const MAX_DEPTH: usize = 16; +/// The fastest a layer's audio plays (a Stretch of 1%). +const MAX_SPEED: f64 = 100.0; + +/// The most effect preroll (history before a block) mixed, in seconds, across a layer's effects. +const MAX_PREROLL_SECONDS: f64 = 30.0; + +/// The longest footage a waveform summarises (a day). +const MAX_PEAK_SECONDS: i64 = 24 * 3600; + /// `frames` stereo sample frames of `comp`'s mix from comp time `start` at `rate` Hz, /// interleaved (L R L R …). pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec { @@ -35,22 +44,29 @@ pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dy /// Whether the comp has anything audible (footage with audio, a sound generator, or a precomp /// that has). pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool { - fn walk(project: &Project, comp: ItemId, depth: usize) -> bool { - let Some(c) = project.comp(comp) else { return false }; - depth < MAX_DEPTH - && c.layers.iter().any(|l| { - l.switches.audio - && (generates(l) - || match &l.source { - LayerSource::Footage { item } => { - matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing) - } - LayerSource::Comp { item } => walk(project, *item, depth + 1), - _ => false, - }) - }) + // Breadth first, so each comp is looked at once and at its shallowest level: a comp reached + // first through a long chain (past MAX_DEPTH) is still found by a shorter route, and a cycle + // cannot hide a result (nothing is cached as "silent" while it is being explored). + let mut seen = std::collections::HashSet::from([comp]); + let mut level = vec![comp]; + for _ in 0..MAX_DEPTH { + let mut next = vec![]; + for id in level { + let Some(c) = project.comp(id) else { continue }; + for l in c.layers.iter().filter(|l| l.switches.audio) { + match &l.source { + _ if generates(l) => return true, + LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing) => { + return true; + } + LayerSource::Comp { item } if seen.insert(*item) => next.push(*item), + _ => {} + } + } + } + level = next; } - walk(project, comp, 0) + false } /// Enabled audio effects of a layer (in stack order) with their effect ids. @@ -155,7 +171,9 @@ impl MixEnv<'_> { return out; } let m = b - a; - let speed = 100.0 / l.stretch; + // Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about + // usize::MAX source samples for one block. + let speed = (100.0 / l.effective_stretch()).min(MAX_SPEED); let src_n = ((m as f64 * speed).ceil() as usize).max(1); let lt = l.layer_time(sample_tick(i0 + a as i64, rate)); let buf = match &l.source { @@ -211,7 +229,7 @@ impl MixEnv<'_> { return self.layer_raw(l, i0, n, false); } let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id)); - let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum(); + let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum::().clamp(0.0, MAX_PREROLL_SECONDS); let pre = (pre_s * self.rate as f64).ceil() as usize; let b0 = i0 - pre as i64; let mut buf = self.layer_raw(l, b0, n + pre, reversed); @@ -291,7 +309,8 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l)); let s0 = start.to_units_floor(rate as i64); for l in &comp.layers { - if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 { + // The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`. + if !audible(project, l) || (any_solo && !l.switches.solo) || l.effective_stretch() <= 0.0 { continue; } // Sample range of the block that falls inside the layer's [in, out). @@ -339,12 +358,14 @@ pub fn footage_peaks(footage: &dyn FootageSource, item: ItemId, f: &effectcraft_ let bin = (64usize).max(4000 / bins_per_sec as usize); let rate = bin as u32 * bins_per_sec; let chunk = bin * bins_per_sec as usize; - let total = units_ceil(duration, rate).max(0) as usize; + // The stated duration comes from the project: at most a day is summarised, and the summary + // ends where the audio does. + let total = units_ceil(duration, rate).clamp(0, MAX_PEAK_SECONDS.saturating_mul(rate as i64)) as usize; let mut out = Vec::with_capacity(total / bin + 1); let mut i = 0usize; while i < total { let n = chunk.min(total - i); - let buf = footage.audio(item, f, sample_tick(i as i64, rate), n, rate).unwrap_or_else(|| vec![0.0; n * 2]); + let Some(buf) = footage.audio(item, f, sample_tick(i as i64, rate), n, rate) else { break }; out.extend(peak_summary(&buf, bin)); i += n; } @@ -372,6 +393,55 @@ mod tests { } } + #[test] + fn audio_detection_answers_each_comp_once() { + // Twenty levels, each comp holding two layers of the one below: 2^20 walks without a + // memo, and all silent. A cycle is answered too. + let mut p = Project::default(); + let mk = |p: &mut Project, layers: Vec| { + let mut c = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + c.layers = layers.into(); + p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(c.into())) + }; + let base = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + let mut below = mk(&mut p, vec![]); + for _ in 0..20 { + let a = build::layer(&mut p, &base, "a", LayerSource::Comp { item: below }, (0, 0), None); + let b = build::layer(&mut p, &base, "b", LayerSource::Comp { item: below }, (0, 0), None); + below = mk(&mut p, vec![a, b]); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(comp_has_audio(&p, below)).ok()); + assert!(!rx.recv_timeout(std::time::Duration::from_secs(20)).expect("detection finishes")); + } + + #[test] + fn audio_found_by_a_short_route_is_not_hidden_by_a_deep_one() { + let mut p = Project::default(); + let base = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + let mk = |p: &mut Project, layers: Vec| { + let mut c = base.clone(); + c.layers = layers.into(); + p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(c.into())) + }; + let f = Footage { kind: FootageKind::Audio, has_audio: true, ..Default::default() }; + let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); + let sound = build::layer(&mut p, &base, "s", LayerSource::Footage { item: fid }, (0, 0), None); + let audible = mk(&mut p, vec![sound]); + // A chain of MAX_DEPTH comps ends in `audible` one level past what is searched; the root + // reaches `audible` directly as well, after the chain. + let mut chain = audible; + for _ in 0..MAX_DEPTH { + let l = build::layer(&mut p, &base, "c", LayerSource::Comp { item: chain }, (0, 0), None); + chain = mk(&mut p, vec![l]); + } + assert!(!comp_has_audio(&p, chain), "too deep to be found"); + let via_chain = build::layer(&mut p, &base, "chain", LayerSource::Comp { item: chain }, (0, 0), None); + let direct = build::layer(&mut p, &base, "direct", LayerSource::Comp { item: audible }, (0, 0), None); + let root = mk(&mut p, vec![via_chain, direct]); + assert!(comp_has_audio(&p, root)); + } + #[test] fn mixes_layers_with_levels_and_in_out() { let mut p = Project::default(); diff --git a/crates/render/src/auto.rs b/crates/render/src/auto.rs index 39b3b02e6..ce714c1f0 100644 --- a/crates/render/src/auto.rs +++ b/crates/render/src/auto.rs @@ -10,6 +10,13 @@ //! (its first frame, with layer cache misses and texture uploads, is not counted; the fastest of //! the next ones starts the average), and the slower side is re-measured every //! [`AutoPick::REPROBE`] frames so the choice follows the comp as it changes. +//! +//! Frames render several at a time (a preview's lanes), and a frame is only measured once it +//! is done: so at most one GPU warm-up frame, or re-probe frame on either side, runs at once, +//! and the frames asked for meanwhile go to the other side. And a frame's time counts per +//! frame running on its side at once: twelve CPU frames that each take 200 ms side by side cost +//! about 17 ms a frame, against a GPU frame of 175 ms on its own. Otherwise every lane of a starting preview took a +//! warm-up frame on a GPU that turned out 2–4× slower (an integrated GPU), all at once. use std::collections::HashMap; use std::sync::Mutex; @@ -51,7 +58,36 @@ pub struct AutoStats { /// accelerator, see [`Accelerator::auto_pick`](crate::Accelerator::auto_pick)). #[derive(Default)] pub struct AutoPick { - stats: Mutex>, + stats: Mutex>, +} + +/// One key's history, when its running warm-up or re-probe frame started on each side, and +/// how many of its frames are running on each side (`[CPU, GPU]`). +#[derive(Default)] +struct Entry { + stats: AutoStats, + measuring: [Option; 2], + // Lowered by `record`, `declined` and `abandoned`: every frame asked for ends in one (an + // [`AutoTicket`] makes sure of it). + running: [u32; 2], +} + +impl Entry { + /// A warm-up or re-probe frame is running on `gpu`'s side. It counts as done once + /// [`AutoPick::MEASURING_FOR`] has passed (a frame that failed without reporting back). + fn measuring(&self, gpu: bool) -> bool { + self.measuring[usize::from(gpu)].is_some_and(|t| t.elapsed() < AutoPick::MEASURING_FOR) + } + + /// Render the next warm-up or re-probe frame on `gpu`'s side, unless one is running there: + /// then the other side's (`!gpu`). + fn measure(&mut self, gpu: bool) -> bool { + if self.measuring(gpu) { + return !gpu; + } + self.measuring[usize::from(gpu)] = Some(web_time::Instant::now()); + gpu + } } impl AutoPick { @@ -66,19 +102,43 @@ impl AutoPick { pub const MARGIN: f64 = 0.85; /// Keys remembered before the history is reset. const MAX_KEYS: usize = 1024; + /// Frames counted as running on one side at most (see `Entry::running`). + const MAX_RUNNING: u32 = 256; + /// A warm-up or re-probe frame not reported back by then no longer holds its side. + const MEASURING_FOR: std::time::Duration = std::time::Duration::from_secs(10); - /// Render the frame for `key` on the GPU? + /// [`AutoPick::choose`] for a frame that ends where it started: the ticket reports back, and + /// a ticket dropped without a time (an error, a retry elsewhere, a panic, a cancelled future) + /// ends the frame with [`AutoPick::abandoned`]. + pub fn ticket(&self, key: AutoKey) -> AutoTicket<'_> { + AutoTicket { pick: self, key, gpu: self.choose(key), open: true } + } + + /// Render the frame for `key` on the GPU? Every frame asked for reports back with + /// [`AutoPick::record`] (or [`AutoPick::declined`]), or ends with [`AutoPick::abandoned`] + /// ([`AutoPick::ticket`] does that on its own). pub fn choose(&self, key: AutoKey) -> bool { let Ok(mut m) = self.stats.lock() else { return true }; if m.len() > Self::MAX_KEYS && !m.contains_key(&key) { m.clear(); } - let s = m.entry(key).or_default(); - // Warm up both sides, alternating, GPU first. + let e = m.entry(key).or_default(); + let gpu = Self::pick(e); + let n = &mut e.running[usize::from(gpu)]; + *n = n.saturating_add(1).min(Self::MAX_RUNNING); + gpu + } + + fn pick(e: &mut Entry) -> bool { + let s = e.stats; + // Warm up both sides, alternating, CPU first: the first frames asked for are the + // ones on screen, and the CPU is the side that is never slow to start (no pipelines + // to build; an integrated GPU took 2 s for the frame at the playhead). let gpu_warm = s.declined || s.gpu_frames >= Self::WARMUP; - if !gpu_warm && s.gpu_frames <= s.cpu_frames { - return true; + if !gpu_warm && s.gpu_frames < s.cpu_frames { + return e.measure(true); } + // (CPU frames already render side by side, on lanes of their own.) if s.cpu_frames < Self::WARMUP { return false; } @@ -86,12 +146,12 @@ impl AutoPick { (Some(g), Some(c)) => c >= g * Self::MARGIN, (g, _) => g.is_some(), }; - // Periodically re-measure the slower side. - if s.since_probe >= Self::REPROBE { - s.since_probe = 0; - return !gpu_faster; + // Periodically re-measure the slower side (one frame at a time). + if s.since_probe >= Self::REPROBE && !e.measuring(!gpu_faster) { + e.stats.since_probe = 0; + return e.measure(!gpu_faster); } - s.since_probe += 1; + e.stats.since_probe = s.since_probe.saturating_add(1); gpu_faster } @@ -103,7 +163,13 @@ impl AutoPick { /// period). pub fn record(&self, key: AutoKey, gpu: bool, ms: f64) { let Ok(mut m) = self.stats.lock() else { return }; - let s = m.entry(key).or_default(); + let e = m.entry(key).or_default(); + e.measuring[usize::from(gpu)] = None; + // Per frame running on this side alongside it (itself included). + let n = &mut e.running[usize::from(gpu)]; + let ms = ms / f64::from((*n).max(1)); + *n = n.saturating_sub(1); + let s = &mut e.stats; let (avg, frames) = if gpu { (&mut s.gpu_ms, &mut s.gpu_frames) } else { (&mut s.cpu_ms, &mut s.cpu_frames) }; *frames = frames.saturating_add(1); if gpu { @@ -123,16 +189,36 @@ impl AutoPick { /// The GPU did not handle a frame for `key` (it rendered on the CPU instead). pub fn declined(&self, key: AutoKey) { if let Ok(mut m) = self.stats.lock() { - let s = m.entry(key).or_default(); + let e = m.entry(key).or_default(); + e.measuring[1] = None; + // The frame renders on the CPU instead (and reports back from there). + e.running[1] = e.running[1].saturating_sub(1); + e.running[0] = e.running[0].saturating_add(1).min(Self::MAX_RUNNING); + let s = &mut e.stats; s.declined = true; s.gpu_ms = None; s.since_probe = 0; } } + /// A frame for `key` asked of `gpu`'s side ends without a time (it failed, or was replaced + /// before it finished). Without this its side stays counted as running, and every later + /// frame there is timed as one of several running side by side: that side looks cheaper. + // ponytail: clears the side's warm-up/probe mark even when another frame holds it; that + // only lets the next probe start early. + pub fn abandoned(&self, key: AutoKey, gpu: bool) { + if let Ok(mut m) = self.stats.lock() + && let Some(e) = m.get_mut(&key) + { + e.measuring[usize::from(gpu)] = None; + let n = &mut e.running[usize::from(gpu)]; + *n = n.saturating_sub(1); + } + } + /// The history of `key`. pub fn stats(&self, key: AutoKey) -> Option { - self.stats.lock().ok()?.get(&key).copied() + self.stats.lock().ok()?.get(&key).map(|e| e.stats) } /// Forget all history (settings that change both paths' costs). @@ -142,3 +228,42 @@ impl AutoPick { } } } + +/// One frame [`AutoPick`] chose a side for ([`AutoPick::ticket`]): it reports the frame's time +/// once, and ends the frame unmeasured when dropped without one. +#[must_use = "a ticket dropped at once ends its frame unmeasured"] +pub struct AutoTicket<'a> { + pick: &'a AutoPick, + key: AutoKey, + gpu: bool, + open: bool, +} + +impl AutoTicket<'_> { + /// The frame renders on the GPU. + pub fn gpu(&self) -> bool { + self.gpu + } + + /// The frame took `ms` on its side. + pub fn record(mut self, ms: f64) { + self.open = false; + self.pick.record(self.key, self.gpu, ms); + } + + /// The GPU did not take the frame: it renders on the CPU, and the ticket reports from there. + pub fn declined(&mut self) { + if self.gpu { + self.pick.declined(self.key); + self.gpu = false; + } + } +} + +impl Drop for AutoTicket<'_> { + fn drop(&mut self) { + if self.open { + self.pick.abandoned(self.key, self.gpu); + } + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index cf1a84e82..4f72e79fa 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -22,27 +22,36 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use effectcraft_effects::Buf; +use effectcraft_keyframe::Value; use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup}; +use crate::disk_cache::{EntryFiles, SharedVirtualFileId, VirtualFileId, layer_disk_key}; use crate::eval::EvalCtx; +use crate::reads::ReadFiles; mod dependencies; /// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the /// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and -/// buffers that were slow to render are written there (keys salted per project, see -/// [`LayerCache::set_disk`]). +/// buffers that were slow to render are written there with the source files they were made of +/// (keys: [`crate::disk_cache::layer_disk_key`]; an entry is used only while its files are +/// unchanged, #3184). pub struct LayerCache { inner: Mutex, - disk: Mutex, u64)>>, + disk: Mutex>>, /// Another persistent store when there is no disk cache (the browser's, [`PrefetchStore`]). - store: Mutex, u64)>>, + store: Mutex>>, + /// Size and content hash of files served from memory (the browser file table), to store + /// and check the source files of persistent entries. + virtual_id: Mutex>, /// While set, inserts are dropped (see [`LayerCache::set_gate`]). gate: Mutex>>, } struct Entry { buf: Arc, + /// The source files the buffer was made of (see [`crate::reads`]). + files: Arc, bytes: usize, last_use: u64, } @@ -54,7 +63,9 @@ struct Inner { clock: u64, hits: u64, misses: u64, - /// When each recent miss happened (to time the render that follows it). + /// When each recent miss happened, to time the render that follows it (the drop generation + /// a render's reads started at travels with its files instead, [`ReadFiles::since`]: two + /// renders of a key may overlap). missed_at: HashMap, } @@ -80,6 +91,7 @@ impl LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0, missed_at: HashMap::new() }), disk: Mutex::new(None), store: Mutex::new(None), + virtual_id: Mutex::new(None), gate: Mutex::new(None), } } @@ -98,77 +110,75 @@ impl LayerCache { self.gate.lock().ok().and_then(|g| g.as_ref().map(|g| g.load(std::sync::atomic::Ordering::Relaxed))).unwrap_or(false) } - /// Attach (or detach) the disk cache. `salt` separates projects and footage versions (see - /// `footage_salt`); keys on disk are `(salt, key)`. - pub fn set_disk(&self, disk: Option<(Arc, u64)>) { + /// Attach (or detach) the disk cache. It is used at once: its entries check their own + /// source files when read, so nothing has to be looked at first. + pub fn set_disk(&self, disk: Option>) { if let Ok(mut d) = self.disk.lock() { *d = disk; } } - /// Change the disk salt (after footage changed) keeping the attached disk cache. - pub fn set_disk_salt(&self, salt: u64) { - if let Ok(mut d) = self.disk.lock() - && let Some((_, s)) = d.as_mut() - { - *s = salt; - } - } - + /// The attached disk cache, if any. pub fn disk(&self) -> Option> { - self.disk.lock().ok()?.as_ref().map(|d| d.0.clone()) + self.disk.lock().ok()?.clone() } /// Attach (or detach) a [`LayerStore`] used when no disk cache is attached (the browser's - /// Origin Private File System through a [`PrefetchStore`]); `salt` as in - /// [`LayerCache::set_disk`]. - pub fn set_store(&self, store: Option<(Arc, u64)>) { + /// Origin Private File System through a [`PrefetchStore`]). + pub fn set_store(&self, store: Option>) { if let Ok(mut s) = self.store.lock() { *s = store; } } - /// Change the store's salt (a new project or footage), keeping the store. - pub fn set_store_salt(&self, salt: u64) { - if let Ok(mut d) = self.store.lock() - && let Some((_, s)) = d.as_mut() - { - *s = salt; + /// Size and content hash of files served from memory (the browser file table): what a + /// persistent entry keeps for them and checks them against. + pub fn set_virtual_id(&self, id: Option) { + if let Ok(mut v) = self.virtual_id.lock() { + *v = id; } } - fn disk_key(&self, key: u64) -> Option<(Arc, u128)> { - let salted = |salt: u64| ((salt as u128) << 64) | key as u128; - if let Some((dc, salt)) = self.disk.lock().ok()?.as_ref() { - return Some((dc.clone() as Arc, salted(*salt))); - } - let s = self.store.lock().ok()?; - let (st, salt) = s.as_ref()?; - Some((st.clone(), salted(*salt))) + /// The attached virtual-file identity lookup, if any. + fn virtual_id(&self) -> Option { + self.virtual_id.lock().ok()?.clone() } - pub fn get(&self, key: u64) -> Option> { - let mut g = self.inner.lock().ok()?; - g.clock += 1; - let now = g.clock; - match g.map.get_mut(&key) { - Some(e) => { - e.last_use = now; - let b = e.buf.clone(); - g.hits += 1; - Some(b) - } + /// The persistent store behind the memory: the disk cache, else the other store. + fn persistent(&self) -> Option> { + if let Some(dc) = self.disk.lock().ok()?.as_ref() { + return Some(dc.clone() as Arc); + } + self.store.lock().ok()?.clone() + } + + /// The buffer kept under `key` and the source files it was made of (see [`crate::reads`]). + /// A buffer from the disk cache or another store comes with the files its entry lists, after + /// they were found unchanged. A buffer made of a file dropped since its reads began (File ▸ + /// Reload Footage) is never served: it is forgotten, and the lookup is a miss. + pub fn get(&self, key: u64) -> Option<(Arc, Arc)> { + match self.kept(key, true) { + Some(found) => Some(found), None => { + let mut g = self.inner.lock().ok()?; g.misses += 1; - if g.missed_at.len() > 4096 { - g.missed_at.clear(); - } - g.missed_at.insert(key, web_time::Instant::now()); drop(g); - // Disk cache: a hit comes back into memory. - let (dc, dk) = self.disk_key(key)?; - let buf = dc.get_layer(dk)?; - Some(self.insert_mem(key, buf)) + let store = self.persistent(); + // (before the entry is read: what it is made of may be dropped meanwhile) + let since = store.as_ref().map_or(0, |s| s.drop_generation()); + if let Ok(mut g) = self.inner.lock() { + if g.missed_at.len() > 4096 { + g.missed_at.clear(); + } + g.missed_at.insert(key, web_time::Instant::now()); + } + // Disk cache: a hit comes back into memory, read at generation `since`. + let store = store?; + let id = self.virtual_id(); + let (buf, mut files) = store.get_layer(layer_disk_key(key), id.as_deref())?; + files.note_since(since); + let files = Arc::new(files); + Some(self.insert_mem(key, buf, files)) } } } @@ -178,39 +188,118 @@ impl LayerCache { /// 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 { + /// the very work it waits for. `files`: the source files `buf` was made of (see + /// [`crate::reads`]); a buffer whose files can't all be named stays in memory. The files + /// returned are the ones the returned buffer was made of. + /// + /// The drop generation `buf`'s reads started at comes with `files` ([`ReadFiles::since`], + /// from [`LayerCache::drop_generation`] when the render began): a buffer made of a file + /// dropped since (File ▸ Reload Footage while it rendered) is kept neither on disk nor in + /// memory. A list that doesn't say counts as made before every drop. A buffer made of values + /// its key doesn't capture ([`ReadFiles::is_unkeyed`]) stays in memory. + pub fn insert(&self, key: u64, buf: Arc, files: Arc) -> (Arc, Arc) { if self.gated() { - return buf; + return (buf, files); } - if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) { + if let Some(kept) = self.kept(key, false) { return kept; } + let store = self.persistent(); + let since = files.since().unwrap_or(0); + // (before the miss's time is taken: it may time another render of the key) + if store.as_ref().is_some_and(|s| s.dropped_since(&files, since)) { + return (buf, files); + } let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); - if let Some((dc, dk)) = self.disk_key(key) { - let slow = started.is_some_and(|t| t.elapsed().as_secs_f64() * 1e3 >= dc.min_layer_ms() as f64); - if slow { - dc.put_layer(dk, &buf); + if let Some(store) = store { + let id = self.virtual_id(); + // (an incomplete list, or a file from memory without its identity: not stored) + if let Some(t) = started + && !files.is_unkeyed() + && t.elapsed().as_secs_f64() * 1e3 >= store.min_layer_ms() as f64 + && let Some(entry) = EntryFiles::of(&files, id.as_deref()) + { + store.put_layer(layer_disk_key(key), &buf, &entry, since); } } - self.insert_mem(key, buf) + self.insert_mem(key, buf, files) } - /// 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 { + /// The buffer kept in memory under `key` with its files, if it is not made of a file dropped + /// since its reads began (one that is, is forgotten). `hit`: count it as a hit and as just + /// used. (A render that started before a drop may publish its buffer after the drop's + /// purge, Reload Footage clearing the cache while the buffer was being written: the check + /// here, not the order of the two, keeps it from being served.) + /// Whether a buffer is kept in memory under `key` (a peek: no hit, no miss, no disk read). + pub(crate) fn holds(&self, key: u64) -> bool { + self.kept(key, false).is_some() + } + + fn kept(&self, key: u64, hit: bool) -> Option<(Arc, Arc)> { + let found = { + let mut g = self.inner.lock().ok()?; + g.clock += 1; + let now = g.clock; + let e = g.map.get_mut(&key)?; + if hit { + e.last_use = now; + } + (e.buf.clone(), e.files.clone()) + }; + if self.made_before_a_drop(&found.1) { + if let Ok(mut g) = self.inner.lock() + && g.map.get(&key).is_some_and(|e| Arc::ptr_eq(&e.files, &found.1)) + && let Some(e) = g.map.remove(&key) + { + g.bytes = g.bytes.saturating_sub(e.bytes); + } + return None; + } + if hit && let Ok(mut g) = self.inner.lock() { + g.hits += 1; + } + Some(found) + } + + /// Whether a buffer made of `files` holds a file as it was before a drop that came after its + /// reads began ([`ReadFiles::since`]; a list that doesn't say counts as made before every + /// drop). + fn made_before_a_drop(&self, files: &ReadFiles) -> bool { + self.persistent().is_some_and(|s| s.dropped_since(files, files.since().unwrap_or(0))) + } + + /// The persistent store's drop generation now ([`LayerStore::drop_generation`]), taken by a + /// render before it starts reading (`None` without a store). + pub fn drop_generation(&self) -> Option { + self.persistent().map(|s| s.drop_generation()) + } + + /// See [`LayerCache::insert`]: the buffer kept under `key` and its files (`buf` and `files`, + /// unless one was kept first or `buf` is too large to keep). + fn insert_mem(&self, key: u64, buf: Arc, files: Arc) -> (Arc, Arc) { let bytes = buf.img.data.len() * std::mem::size_of::(); - let Ok(mut g) = self.inner.lock() else { return buf }; + let Ok(mut g) = self.inner.lock() else { return (buf, files) }; if bytes > g.budget / 4 { - return buf; + return (buf, files); } g.clock += 1; let now = g.clock; if let Some(e) = g.map.get_mut(&key) { - e.last_use = now; - return e.buf.clone(); + // (one made before a drop gives way: see [`LayerCache::kept`]) + if !self.made_before_a_drop(&e.files) { + e.last_use = now; + return (e.buf.clone(), e.files.clone()); + } + if let Some(old) = g.map.remove(&key) { + g.bytes = g.bytes.saturating_sub(old.bytes); + } } - g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now }); + // (checked again just before it is published: a drop may have come since `insert` + // checked; one that comes later is caught when the buffer is looked up) + if self.made_before_a_drop(&files) { + return (buf, files); + } + g.map.insert(key, Entry { buf: buf.clone(), files: files.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. @@ -226,7 +315,7 @@ impl LayerCache { } } } - buf + (buf, files) } /// The memory budget in bytes. @@ -264,12 +353,29 @@ impl LayerCache { } } -/// Persistent storage of layer buffers behind a [`LayerCache`] (keys salted per project). +/// Persistent storage of layer buffers behind a [`LayerCache`] (keys: +/// [`crate::disk_cache::layer_disk_key`]). Every entry keeps the source files its buffer was made +/// of and is used only while they are unchanged (#3184). pub trait LayerStore: Send + Sync { - /// A stored buffer (synchronous: mid-render). - fn get_layer(&self, key: u128) -> Option>; - /// Keep a buffer that was slow to render. - fn put_layer(&self, key: u128, buf: &Buf); + /// A stored buffer whose source files are unchanged, with those files (synchronous: + /// mid-render). An entry whose files changed, or damaged, is a miss and is dropped, so the + /// buffer rendered again replaces it. `virtual_id` names files served from memory. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)>; + /// Keep a buffer that was slow to render, with the source files it was made of (shared, so + /// a store can encode it off the render thread). `since`: the + /// [`LayerStore::drop_generation`] when its render started (a buffer made of a file dropped + /// after that is refused). + fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, since: u64); + /// How many times files were dropped from the store (File ▸ Reload Footage, + /// [`crate::disk_cache::DiskCache::drop_files`]); `0` for a store that never drops. + fn drop_generation(&self) -> u64 { + 0 + } + /// Whether one of `files` was dropped after generation `since`: what was made of them is + /// from before the drop. `false` for a store that never drops. + fn dropped_since(&self, _files: &ReadFiles, _since: u64) -> bool { + false + } /// Buffers faster to render than this (ms) are not stored. fn min_layer_ms(&self) -> u64 { 0 @@ -281,9 +387,10 @@ pub const PREFETCH_MIN_LAYER_MS: u64 = 20; /// A [`LayerStore`] over storage that can't be read in the middle of a render (the browser's /// Origin Private File System, read asynchronously). Lookups are served from buffers fetched -/// before the frame ([`PrefetchStore::provide`]); the keys a render looked up and didn't find -/// are recorded ([`PrefetchStore::take_misses`]) so the transport can fetch them before the next -/// frame; buffers to store queue up as sealed entries ([`PrefetchStore::take_writes`]) for the +/// before the frame ([`PrefetchStore::provide`]), once the files their entries list are found +/// unchanged; the keys a render looked up and didn't find are recorded +/// ([`PrefetchStore::take_misses`]) so the transport can fetch them before the next frame; +/// buffers to store queue up as sealed entries ([`PrefetchStore::take_writes`]) for the /// transport to write after the frame. Fetched buffers are kept least recently used first, up /// to a byte budget. pub struct PrefetchStore { @@ -292,9 +399,21 @@ pub struct PrefetchStore { min_ms: u64, } +/// A fetched entry: the buffer, the source files it was made of, its last use. +struct Fetched { + buf: Arc, + files: Arc, + used: u64, +} + +/// The memory a buffer's pixels take, in bytes. +fn buf_bytes(b: &Buf) -> usize { + b.img.data.len().saturating_mul(std::mem::size_of::()) +} + #[derive(Default)] struct Prefetched { - ready: HashMap, u64)>, + ready: HashMap, bytes: usize, clock: u64, misses: Vec, @@ -304,6 +423,22 @@ struct Prefetched { known: std::collections::HashSet, } +impl Prefetched { + /// Records a missed key for prefetching, up to a fixed bound. + fn note_miss(&mut self, key: u128) { + if !self.misses.contains(&key) && self.misses.len() < 1024 { + self.misses.push(key); + } + } + + /// Drops the prefetched entry for `key` and frees its bytes. + fn remove(&mut self, key: u128) { + if let Some(f) = self.ready.remove(&key) { + self.bytes = self.bytes.saturating_sub(buf_bytes(&f.buf)); + } + } +} + impl Default for PrefetchStore { fn default() -> Self { PrefetchStore::new(256 << 20) @@ -327,23 +462,21 @@ impl PrefetchStore { } /// A fetched entry (sealed bytes, [`crate::disk_cache::layer_entry`]) for `key`; `false` - /// when damaged. + /// when damaged or of another format. Its files are checked when the buffer is used. pub fn provide(&self, key: u128, entry: &[u8]) -> bool { - let Some(buf) = crate::disk_cache::read_layer_entry(entry) else { return false }; - let bytes = buf.img.data.len() * std::mem::size_of::(); + let Some((buf, files)) = crate::disk_cache::read_layer_entry(entry) else { return false }; + let buf = Arc::new(buf); + let bytes = buf_bytes(&buf); let Ok(mut s) = self.st.lock() else { return false }; s.clock += 1; - let now = s.clock; - if let Some((old, _)) = s.ready.insert(key, (Arc::new(buf), now)) { - s.bytes -= old.img.data.len() * std::mem::size_of::(); - } - s.bytes += bytes; + let used = s.clock; + s.remove(key); + s.ready.insert(key, Fetched { buf, files: Arc::new(files), used }); + s.bytes = s.bytes.saturating_add(bytes); s.known.insert(key); while s.bytes > self.budget { - let Some((&k, _)) = s.ready.iter().min_by_key(|(_, (_, t))| *t) else { break }; - if let Some((b, _)) = s.ready.remove(&k) { - s.bytes -= b.img.data.len() * std::mem::size_of::(); - } + let Some(oldest) = s.ready.iter().min_by_key(|(_, f)| f.used).map(|(k, _)| *k) else { break }; + s.remove(oldest); } true } @@ -372,32 +505,46 @@ impl PrefetchStore { } impl LayerStore for PrefetchStore { - fn get_layer(&self, key: u128) -> Option> { + /// The prefetched layer for `key` with its read files, or `None` when it is not ready. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)> { + let found = { + let mut s = self.st.lock().ok()?; + s.clock += 1; + let now = s.clock; + match s.ready.get_mut(&key) { + Some(f) => { + f.used = now; + Some((f.buf.clone(), f.files.clone())) + } + None => { + s.note_miss(key); + None + } + } + }; + let (buf, files) = found?; + // (looked at without the lock: one call of the hook, or one `metadata`, per file) + let unchanged = files.unchanged(virtual_id); let mut s = self.st.lock().ok()?; - s.clock += 1; - let now = s.clock; - match s.ready.get_mut(&key) { - Some((b, t)) => { - *t = now; - let b = b.clone(); - if !s.hits.contains(&key) { - s.hits.push(key); - } - Some(b) - } - None => { - if !s.misses.contains(&key) && s.misses.len() < 1024 { - s.misses.push(key); - } - None - } + if !unchanged { + // Made of files that changed since: forgotten, so the buffer rendered again is + // written over its entry. + s.remove(key); + s.known.remove(&key); + s.note_miss(key); + return None; } + if !s.hits.contains(&key) { + s.hits.push(key); + } + Some((buf, files.to_read_files())) } - fn put_layer(&self, key: u128, buf: &Buf) { + /// Queues the layer for writing to the persistent store the first time `key` is seen. + fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, _since: u64) { let Ok(mut s) = self.st.lock() else { return }; if s.known.insert(key) { - s.writes.push((key, crate::disk_cache::layer_entry(buf))); + s.writes.push((key, crate::disk_cache::layer_entry(buf, files))); } } @@ -470,6 +617,12 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { false } +/// A text layer with an Expression Selector: its amount is evaluated per character, and the +/// layer's key holds only the first character's (see [`crate::reads::ReadFiles::is_unkeyed`]). +pub(crate) fn per_character_expressions(layer: &Layer) -> bool { + matches!(layer.source, LayerSource::Text) && layer.props.sub("text").is_some_and(|t| group_has(t, "expressionSelector")) +} + fn group_has(g: &PropGroup, match_id: &str) -> bool { g.children.iter().any(|c| match c { Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id), @@ -542,7 +695,9 @@ fn reads_other_times(layer: &Layer, effects: Option) -> bool { fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| { g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } - if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect)) + if effect.starts_with("ec.time.") + || effectcraft_effects::reads_param_history(effect) + || (!effect.starts_with("ec.") && effectcraft_effects::plugin::reads_other_times(effect))) }) }) } @@ -741,6 +896,21 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo Some(h.finish()) } +/// Does the layer read another layer through an enabled effect's layer parameter (Gradient Wipe, +/// Set Matte…)? Those layers are drawn by the reading layer's renderer, so the switches it +/// passes down belong in the key (`Renderer::nested_look`). +pub(crate) fn reads_layers(ctx: &EvalCtx, layer: &Layer) -> bool { + /// Whether any enabled layer-valued property of `layer` points at another layer. + fn reads(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, depth: usize) -> bool { + depth < 32 + && g.children.iter().any(|n| match n { + Node::Prop(p) => matches!(p.value, Value::Layer(_)) && ctx.value(layer, p).as_layer().is_some_and(|id| id != layer.id.0), + Node::Group(sub) => sub.enabled && reads(ctx, layer, sub, depth + 1), + }) + } + layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled && reads(ctx, layer, g, 0))) +} + /// Does the layer run an effect that reads the comp camera or lights (Card Dance, CC Particle /// World…)? fn reads_comp_scene(layer: &Layer) -> bool { @@ -786,8 +956,211 @@ mod shared_insert_tests { 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)); + assert!(Arc::ptr_eq(&cache.insert(3, a.clone(), Default::default()).0, &a)); + assert!(Arc::ptr_eq(&cache.insert(3, b, Default::default()).0, &a), "the later render gets the first buffer"); + assert!(Arc::ptr_eq(&cache.get(3).unwrap().0, &a)); + } + + /// The browser's store (#3184): a fetched entry is used only while the files it lists are + /// unchanged (here from memory, through the hook), and comes with them; one made of files + /// that changed is dropped and counts as a miss, so the buffer rendered again is queued over + /// it. A buffer whose files can't all be named is not queued at all. + #[test] + fn the_prefetch_store_checks_the_files_of_its_entries() { + use super::{LayerStore, PrefetchStore}; + use crate::disk_cache::{EntryFiles, layer_disk_key, layer_entry}; + use crate::reads::{FileStamp, ReadFile, ReadFiles}; + let buf = || Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }; + let files: ReadFiles = [ReadFile::new("/v.png", FileStamp::Virtual)].into_iter().collect(); + let (one, two) = (|_: &str| Some((3, 1)), |_: &str| Some((3, 2))); + let entry = layer_entry(&buf(), &EntryFiles::of(&files, Some(&one)).unwrap()); + let store = PrefetchStore::new(1 << 20).with_min_ms(0); + assert!(store.provide(5, &entry)); + assert!(!store.provide(6, b"damaged")); + let (_, got) = store.get_layer(5, Some(&one)).unwrap(); + assert_eq!(got, files); + assert_eq!(store.take_hits(), [5]); + // The file changed: a miss, forgotten, and the new buffer is queued over it. + assert!(store.get_layer(5, Some(&two)).is_none()); + assert!(!store.has(5)); + assert_eq!(store.take_misses(), [5]); + store.put_layer(5, &Arc::new(buf()), &EntryFiles::of(&files, Some(&two)).unwrap(), 0); + assert_eq!(store.take_writes().len(), 1, "written over the changed entry"); + // Through the layer cache: a hit reports its files; incomplete lists are not queued. + let cache = LayerCache::default(); + let store = Arc::new(PrefetchStore::new(1 << 20).with_min_ms(0)); + cache.set_store(Some(store.clone())); + cache.set_virtual_id(Some(Arc::new(one))); + assert!(store.provide(layer_disk_key(9), &entry)); + let (_, from_store) = cache.get(9).unwrap(); + assert!(*from_store == files && from_store.is_complete()); + let mut incomplete = files.clone(); + incomplete.mark_incomplete(); + assert!(cache.get(10).is_none()); + cache.insert(10, Arc::new(buf()), Arc::new(incomplete)); + assert!(cache.get(11).is_none()); + cache.insert(11, Arc::new(buf()), Arc::new(files.clone())); + let written: Vec = store.take_writes().into_iter().map(|w| w.0).collect(); + assert_eq!(written, [layer_disk_key(11)], "only the buffer whose files are all named"); + } + + /// A layer buffer whose render started before Reload Footage dropped its file (#3184) is + /// not written to the disk cache when it is kept afterwards; one whose render started after + /// the drop is. + #[test] + fn a_buffer_rendered_across_a_drop_is_not_written() { + use crate::disk_cache::{DiskCache, Kind, layer_disk_key}; + use crate::reads::{FileStamp, ReadFile, ReadLog}; + let dir = tempfile::tempdir().unwrap(); + let dc = DiskCache::open(dir.path(), 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + let cache = LayerCache::default(); + cache.set_disk(Some(dc.clone())); + let buf = || Arc::new(Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }); + // A render's files, read after it took the drop generation (`Renderer::recorded`). + let render = || { + let log = ReadLog::starting_at(cache.drop_generation().unwrap()); + move || { + log.note(ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })); + Arc::new(log.into_files()) + } + }; + // The miss starts the render; the file is reloaded before the buffer is kept. + assert!(cache.get(1).is_none()); + let a = render(); + dc.drop_files(["/a.png"]); + dc.flush(); + // Another render of the same key starts after the drop, while the first one still runs + // (its miss must not lend the first render its generation). + assert!(cache.get(1).is_none()); + let b = render(); + let (stale, _) = cache.insert(1, buf(), a()); + dc.flush(); + assert!(!dc.contains(Kind::Layer, layer_disk_key(1)), "made of the file as it was before the drop"); + // Not kept in memory either: the second render keeps its own buffer, and writes it. + let fresh = buf(); + let (kept, _) = cache.insert(1, fresh.clone(), b()); + assert!(Arc::ptr_eq(&kept, &fresh) && !Arc::ptr_eq(&kept, &stale)); + // A render of another key after the drop. + assert!(cache.get(2).is_none()); + cache.insert(2, buf(), render()()); + dc.flush(); + assert!(dc.contains(Kind::Layer, layer_disk_key(1)), "the render after the drop"); + assert!(dc.contains(Kind::Layer, layer_disk_key(2))); + // A list that doesn't say when its reads began counts as made before the drop. + assert!(cache.get(3).is_none()); + cache.insert(3, buf(), Arc::new([ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })].into_iter().collect())); + dc.flush(); + assert!(!dc.contains(Kind::Layer, layer_disk_key(3))); + drop((cache, dc)); + } + + /// A disk cache whose layer writes stop at a barrier until the test lets them go on. + struct PausedWrites { + dc: Arc, + arrived: std::sync::Barrier, + go_on: std::sync::Barrier, + } + + impl super::LayerStore for PausedWrites { + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, crate::reads::ReadFiles)> { + super::LayerStore::get_layer(&*self.dc, key, virtual_id) + } + fn put_layer(&self, key: u128, buf: &Arc, files: &crate::disk_cache::EntryFiles, since: u64) { + self.arrived.wait(); + self.go_on.wait(); + super::LayerStore::put_layer(&*self.dc, key, buf, files, since); + } + fn drop_generation(&self) -> u64 { + self.dc.drop_generation() + } + fn dropped_since(&self, files: &crate::reads::ReadFiles, since: u64) -> bool { + self.dc.dropped_since(files, since) + } + } + + /// Reload Footage while a render from before it is writing its buffer: the cache is cleared + /// and the file dropped after the buffer passed its check, before it is kept. The buffer + /// then reaches memory after the purge, but it is never served, and a render after the drop + /// keeps its own buffer (#3191 review). + #[test] + fn a_buffer_kept_after_the_purge_of_a_reload_is_not_served() { + use crate::reads::{FileStamp, ReadFile, ReadLog}; + let dir = tempfile::tempdir().unwrap(); + let dc = crate::disk_cache::DiskCache::open(dir.path(), 1 << 30).unwrap(); + let store = Arc::new(PausedWrites { dc: dc.clone(), arrived: std::sync::Barrier::new(2), go_on: std::sync::Barrier::new(2) }); + let cache = Arc::new(LayerCache::default()); + cache.set_store(Some(store.clone())); + let buf = || Arc::new(Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }); + let files = |cache: &LayerCache| { + let log = ReadLog::starting_at(cache.drop_generation().unwrap()); + log.note(ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })); + Arc::new(log.into_files()) + }; + // The old render: a miss, its reads, then its insert, which stops in the disk write. + assert!(cache.get(1).is_none()); + let old_files = files(&cache); + let old = buf(); + let writer = { + let (cache, old) = (cache.clone(), old.clone()); + std::thread::spawn(move || cache.insert(1, old, old_files)) + }; + store.arrived.wait(); + // Reload Footage meanwhile: the layer cache is purged and the file dropped. + cache.clear(); + dc.drop_files(["/a.png"]); + dc.flush(); + store.go_on.wait(); + let _ = writer.join().unwrap(); + dc.flush(); + assert!(!dc.contains(crate::disk_cache::Kind::Layer, crate::disk_cache::layer_disk_key(1)), "not on disk"); + assert!(cache.get(1).is_none(), "the old pixels are not served from memory"); + // A render after the drop keeps its own buffer. + let fresh = buf(); + let new_files = files(&cache); + let mut kept = None; + std::thread::scope(|s| { + s.spawn(|| { + store.arrived.wait(); + store.go_on.wait(); + }); + kept = Some(cache.insert(1, fresh.clone(), new_files).0); + }); + let kept = kept.unwrap(); + assert!(Arc::ptr_eq(&kept, &fresh) && !Arc::ptr_eq(&kept, &old)); + assert!(Arc::ptr_eq(&cache.get(1).unwrap().0, &fresh)); + } + + /// A render that gets another render's buffer gets that buffer's files too (#3184): when a + /// file changed between the two reads, its frame lists the file with both identities, so the + /// list is incomplete and the frame is not kept on disk with the new identity and the old + /// pixels. + #[test] + fn the_buffer_kept_first_brings_its_files() { + use crate::reads::{FileStamp, ReadFile, ReadFiles, ReadLog}; + use crate::{NoFootage, RenderOpts, Renderer}; + let cache = LayerCache::default(); + let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); + let files = |len| Arc::new([ReadFile::new("/a.png", FileStamp::Native { len, modified: Some(1) })].into_iter().collect::()); + let (first, second) = (files(1), files(2)); + let a = buf(); + cache.insert(3, a.clone(), first.clone()); + let project = effectcraft_project::Project::default(); + let log = ReadLog::new(); + let mut r = Renderer::new(&project, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.reads = Some(&log); + // (what `Renderer::recorded` noted for the second render) + log.note_all(&second); + let (kept, kept_files) = r.keep(Some(3), buf(), &second); + assert!(Arc::ptr_eq(&kept, &a)); + assert_eq!(kept_files, first); + assert!(!log.files().is_complete()); + // The same files: nothing changes. + let same = ReadLog::new(); + r.reads = Some(&same); + same.note_all(&first); + let _ = r.keep(Some(3), buf(), &files(1)); + assert!(same.files().is_complete()); } } diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index db1465c07..6f14ddcde 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -33,6 +33,8 @@ impl Dependencies<'_> { { for g in fx.groups().take(limit.unwrap_or(usize::MAX)).filter(|g| g.enabled) { self.group(ctx, layer, g, 0)?; + // A LUT file edited on disk changes the pixels without an edit (#3493). + crate::reads::lut_files(ctx, layer, g).hash(self.h); } } self.other_times = previous; @@ -156,7 +158,7 @@ impl Dependencies<'_> { layer.layer_time(ctx.time).0.hash(self.h); } match layer.source { - LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Model { item } => { let item = match layer.source { LayerSource::Footage { .. } => ctx.media_item(layer, item), _ => item, @@ -169,6 +171,14 @@ impl Dependencies<'_> { ItemKind::Comp(comp) => { let time = ctx.source_time(layer); time.0.hash(self.h); + // The nested layers are walked without this instance's Essential + // Properties overrides, which the render applies: a LUT file one of + // them names is read too (#3493). + for o in crate::fx_host::essential_overrides(ctx, layer) { + if let effectcraft_keyframe::Value::Str(src) = &o.value { + crate::reads::source_files(src).hash(self.h); + } + } let nested = EvalCtx::new(ctx.project, item, comp, time); for child in &comp.layers { self.layer(&nested, child)?; diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index c423665d2..158dde42f 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -153,6 +153,26 @@ impl Pipe { p } + /// A copy of `src` converted by `c` and quantised to the bit depth, in one pass: one read and + /// one write of the frame instead of a copy, a conversion and a quantise pass (a 1080p float + /// frame is 33 MB, and these passes are limited by memory speed). The same pixels as + /// [`convert`] then [`Pipe::quantize`]. + pub fn copy_converted(&self, src: &Image, c: Option<&Conversion>) -> Image { + let levels = self.levels; + let data = src + .data + .par_iter() + .map(|&p| { + let mut p = c.map_or(p, |c| convert_px(p, c)); + if let Some(l) = levels { + quantize_px(&mut p, l, 1.0 / l); + } + p + }) + .collect(); + Image { width: src.width, height: src.height, data } + } + /// Clamp and quantise to the bit depth (no-op in 32 bpc). pub fn quantize(&self, img: &mut Image) { self.quantize_region(img, Region::Full); @@ -265,14 +285,18 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { /// Convert premultiplied pixels (on straight colour). pub fn convert(img: &mut Image, c: &Conversion) { - img.data.par_iter_mut().for_each(|p| { - let a = p[3]; - if a <= 0.0 { - return; - } - let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); - *p = [o[0] * a, o[1] * a, o[2] * a, a]; - }); + img.data.par_iter_mut().for_each(|p| *p = convert_px(*p, c)); +} + +/// [`convert`] of one pixel. +#[inline] +fn convert_px(p: [f32; 4], c: &Conversion) -> [f32; 4] { + let a = p[3]; + if a <= 0.0 { + return p; + } + let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); + [o[0] * a, o[1] * a, o[2] * a, a] } /// Compand: an exponential knee from 80%, approaching 1.0 (linear light, per channel). @@ -349,6 +373,35 @@ mod tests { } } + /// The fused footage pass gives the pixels of a copy, a conversion and a quantise pass, bit + /// for bit, at every depth, with and without a conversion. + #[test] + fn copy_converted_is_convert_then_quantize() { + let mut img = Image::new(17, 5); + for (i, p) in img.data.iter_mut().enumerate() { + *p = match i % 7 { + 0 => [0.3, -0.2, 0.71, 0.9], + 1 => [-0.0, 0.0, 0.0, 0.0], + 2 => [1.7, 0.4, f32::NAN, 0.5], + 3 => [0.0; 4], + _ => [i as f32 / 90.0, 0.5, 0.25, 1.0], + }; + } + for depth in [effectcraft_project::BitDepth::Bpc8, effectcraft_project::BitDepth::Bpc16, effectcraft_project::BitDepth::Bpc32] { + let pipe = Pipe::of(&ProjectSettings { bit_depth: depth, ..Default::default() }); + for c in [None, Some(Conversion::linearize(ColorSpace::Srgb))] { + let mut want = img.clone(); + if let Some(c) = &c { + convert(&mut want, c); + } + pipe.quantize(&mut want); + let got = pipe.copy_converted(&img, c.as_ref()); + let bits = |i: &Image| i.data.iter().map(|p| p.map(f32::to_bits)).collect::>(); + assert_eq!(bits(&got), bits(&want), "{depth:?} conversion {}", c.is_some()); + } + } + } + /// Both forms `round_half_up` picks between agree on everything quantising feeds it: /// 0..=levels, exact halves included. #[test] diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 427198ad2..3674b2667 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -2,15 +2,25 @@ //! and layer cache entries kept on disk between sessions, like After Effects' disk cache. //! //! * Entries are files keyed by a 128-bit content hash (`/v1//<2 hex>/<32 hex>.ecc`): -//! frames by the hash of the composition's content (the comp and everything it uses, footage -//! files' size and modification time included) plus frame, scale and view; layer buffers by -//! the layer cache's content key salted with the project's footage fingerprint. A changed -//! project therefore never reads stale pixels: its keys simply change. +//! frames by the hash of the composition's content as it is in memory (the comp and what it can +//! draw, footage items with their settings and paths, and what its expressions can read; no +//! file is looked at, [`comp_content_key`]) plus frame, scale and view; layer buffers by the +//! layer cache's content key (which names the footage items a buffer uses with their settings +//! and paths) and the renderer version ([`layer_disk_key`]). A changed project therefore never +//! reads stale pixels: its keys simply change, and an edit elsewhere keeps them. +//! * Frame and layer entries keep the source files they were made of (footage, and the font +//! files text is drawn with), each with the size and modification time it was read with +//! ([`EntryFiles`], #3184). An entry is used only while those files are unchanged: a handful +//! of `metadata` calls when it is read, never a scan of the project's files. A frame or +//! buffer whose files can't all be named is not stored (text in a family that isn't installed +//! or with fallback glyphs, footage refused by automation read roots, …). File ▸ Reload +//! Footage deletes the entries made of the reloaded files ([`DiskCache::drop_files`]): a file +//! replaced by one of the same size and time is read again. //! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA (or, for //! 32 bpc viewer frames, half-float RGBA with the bytes shuffled into planes; the entry -//! header's format byte tells them apart, 8-bit entries keep format 0), layer -//! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh -//! renders are bit-identical), Roto Brush / Object Matte segmentations as +//! header's format byte tells them apart: 2 and 3, both with their source files first), layer +//! buffers (format 4, their source files first) as `f32` RGBA with the bytes shuffled into +//! planes (exact, so cached and fresh renders are bit-identical), Roto Brush / Object Matte segmentations as //! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are //! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and //! are deleted. @@ -32,6 +42,11 @@ use std::sync::{Arc, Condvar, Mutex}; use effectcraft_effects::Buf; use effectcraft_raster::Image; +use crate::reads::ReadFiles; + +mod entry_files; +pub use entry_files::{EntryFile, EntryFiles, EntryStamp}; + /// File format version (part of the folder layout). const VERSION: &str = "v1"; const MAGIC: &[u8; 4] = b"ECDC"; @@ -43,6 +58,10 @@ const QUEUE_LIMIT: usize = 64; /// frames, so a fully cached comp came back with gaps in its blue bar. #[cfg(not(target_arch = "wasm32"))] const FRAME_QUEUE_BYTES: usize = 1 << 30; +/// Pending layer and matte bytes before new ones are dropped: a layer job holds its float +/// buffer until the writer compresses it (64 of them at 4K were ~8.5 GB). +#[cfg(not(target_arch = "wasm32"))] +const LAYER_QUEUE_BYTES: usize = 1 << 30; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Kind { @@ -87,9 +106,16 @@ pub struct FrameF16 { pub rgba: Vec, } -/// The entry header's format byte of frame entries. +/// The entry header's format byte: segmentations (and 8-bit frames and layer buffers before +/// #3184). const FORMAT_RGBA8: u8 = 0; -const FORMAT_F16: u8 = 1; +/// Frame entries: their source files ([`EntryFiles`]), then 8-bit or half-float pixels. The +/// formats before them (0: 8-bit, 1: half float, without files) read as misses and are deleted. +const FORMAT_RGBA8_FILES: u8 = 2; +const FORMAT_F16_FILES: u8 = 3; +/// Layer entries: their source files ([`EntryFiles`]), then the buffer. The format before it +/// (0, without files) reads as a miss and is deleted. +const FORMAT_LAYER_FILES: u8 = 4; /// Counters for tests, the Info panel and `cache.diskStats`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -220,7 +246,7 @@ pub struct LayerPrefetch { const PREFETCH_RECENT: usize = 512; impl LayerPrefetch { - /// A frame's layer-cache misses (salted keys). + /// A frame's layer-cache misses (disk keys, [`layer_disk_key`]). pub fn note_misses(&mut self, keys: impl IntoIterator) { for k in keys { self.recent.retain(|x| *x != k); @@ -242,7 +268,39 @@ impl LayerPrefetch { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] enum Job { /// Kind, key, header format byte, payload. - Write(Kind, u128, u8, Vec), + Write(Kind, u128, u8, Payload), + /// Delete the frame and layer entries made of these files ([`DiskCache::drop_files`]). + Drop(Vec>), +} + +/// What a queued write holds. 8-bit frames and layer buffers are encoded (LZ4) on the writer +/// thread, not on the render thread that queued them: encoding a 1080p frame took ~9 ms of every +/// frame's render, and a full queue no longer throws away encoding work. +enum Payload { + Encoded(Vec), + /// 8-bit premultiplied RGBA, `width * height * 4` bytes, and the files it was made of. + Frame(u32, u32, Vec, EntryFiles), + Layer(Arc, EntryFiles), +} + +impl Payload { + /// Bytes it holds (the frame queue's budget). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn len(&self) -> usize { + match self { + Payload::Encoded(p) | Payload::Frame(_, _, p, _) => p.len(), + Payload::Layer(b, _) => b.img.data.len().saturating_mul(std::mem::size_of::()), + } + } + + /// The entry's payload: its source files, then its pixels. + fn encode(self) -> Vec { + match self { + Payload::Encoded(p) => p, + Payload::Frame(w, h, rgba, files) => with_files(&files, encode_frame(w, h, &rgba)), + Payload::Layer(b, files) => with_files(&files, encode_layer(&b)), + } + } } struct Queue { @@ -250,22 +308,79 @@ struct Queue { frames: std::collections::VecDeque, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] frame_bytes: usize, - /// Layer and matte writes. + /// Layer and matte writes, and their payload bytes. jobs: std::collections::VecDeque, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + job_bytes: usize, busy: bool, /// Keys of frames queued or being written. pending_frames: std::collections::HashSet, + /// The drops so far (under the queue's lock: a write is queued either before a drop, which + /// then sweeps it, or after it, when this refuses it if it was made from before). + drops: Drops, +} + +/// Files dropped from the cache files beyond this are not remembered one by one: everything made +/// before the drop that forgot them is refused instead. +const MAX_DROPPED_PATHS: usize = 1 << 16; + +/// The [`DiskCache::drop_files`] calls so far: their number (the drop generation) and the last one +/// of each file. An entry made of a file read before that file's last drop (its render started at +/// an earlier generation) holds the pixels from before and is refused. +#[derive(Default)] +struct Drops { + generation: u64, + last: HashMap, u64>, + /// Everything made before this generation is refused (the per-file record was forgotten). + floor: u64, +} + +impl Drops { + /// Notes that the given paths were dropped, bumping the generation and tracking them for later checks. + fn record(&mut self, paths: &[Arc]) { + self.generation = self.generation.saturating_add(1); + if self.last.len().saturating_add(paths.len()) > MAX_DROPPED_PATHS { + self.last.clear(); + self.floor = self.generation; + return; + } + for p in paths { + self.last.insert(p.clone(), self.generation); + } + } + + /// Whether something made of `paths`, whose reads started at generation `since`, read one of + /// them before it was dropped. + fn stale<'a>(&self, mut paths: impl Iterator>, since: u64) -> bool { + since < self.floor || paths.any(|p| self.last.get(p).is_some_and(|g| *g > since)) + } } impl Queue { + /// Takes the next queued job, frames first, and releases the bytes it held. #[cfg(not(target_arch = "wasm32"))] fn pop(&mut self) -> Option { if let Some(j) = self.frames.pop_front() { - let Job::Write(_, _, _, p) = &j; - self.frame_bytes = self.frame_bytes.saturating_sub(p.len()); + if let Job::Write(_, _, _, p) = &j { + self.frame_bytes = self.frame_bytes.saturating_sub(p.len()); + } return Some(j); } - self.jobs.pop_front() + let j = self.jobs.pop_front()?; + if let Job::Write(_, _, _, p) = &j { + self.job_bytes = self.job_bytes.saturating_sub(p.len()); + } + Some(j) + } + + /// No room for a `kind` write of `len` bytes. A layer bigger than the whole budget is still + /// taken when nothing else is queued. + #[cfg(not(target_arch = "wasm32"))] + fn full(&self, kind: Kind, len: usize) -> bool { + match kind { + Kind::Frame => self.frame_bytes.saturating_add(len) > FRAME_QUEUE_BYTES, + _ => self.jobs.len() >= QUEUE_LIMIT || (self.job_bytes > 0 && self.job_bytes.saturating_add(len) > LAYER_QUEUE_BYTES), + } } fn is_empty(&self) -> bool { self.frames.is_empty() && self.jobs.is_empty() @@ -280,16 +395,25 @@ pub struct DiskCache { disk_cap: AtomicU64, /// Free space is limiting the cache (warned once per episode). space_limited: AtomicBool, + /// The free space the cache leaves on its disk ([`disk_reserve`]); 0 = unknown. + reserve: AtomicU64, hits: AtomicU64, misses: AtomicU64, writes: AtomicU64, evictions: AtomicU64, dropped: AtomicU64, failed: AtomicU64, + /// Source files looked at to check frame and layer entries ([`DiskCache::file_checks`]). + file_checks: AtomicU64, + /// Frame and layer entries compressed for a queued write ([`DiskCache::encodes`]). + encodes: AtomicU64, tmp_counter: AtomicU64, queue: Arc<(Mutex, Condvar)>, /// Layer buffers that took less than this to render are not written (ms). min_layer_ms: AtomicU64, + /// Files whose entries are being deleted ([`DiskCache::drop_files`]), each with the number + /// of drops of it pending: until then an entry made of one is a miss. + dropping: Mutex, u32>>, } /// FNV-1a 128-bit (content keys). @@ -326,6 +450,23 @@ fn checksum(data: &[u8]) -> u64 { h } +/// The least free space the disk cache leaves on its disk. +pub const MIN_DISK_RESERVE: u64 = 2 << 30; +/// The most free space the disk cache leaves on its disk: 5% of a large disk is far more than +/// the system needs to keep working (on a 4 TB disk it held a 20 GB cache at 0 with 190 GB free). +pub const MAX_DISK_RESERVE: u64 = 10 << 30; +/// Below this much room left by free disk space, frame and layer writes are refused before they +/// are encoded ([`DiskCache::accepts_writes`]): a few frames would fill it, and the rest would be +/// compressed only to be refused. +const MIN_WRITE_ROOM: u64 = 64 << 20; + +/// The free space the disk cache leaves on a disk of `total` bytes: 5% of it, at least 2 GB and +/// at most 10 GB. It keeps the cache from filling the disk; the cache's size is the user's +/// Maximum Disk Cache Size (After Effects has that maximum and no reserve). +pub fn disk_reserve(total: u64) -> u64 { + (total / 20).clamp(MIN_DISK_RESERVE, MAX_DISK_RESERVE) +} + /// The platform's default disk cache folder. pub fn default_folder() -> PathBuf { let home = std::env::var_os("HOME").map(PathBuf::from); @@ -342,12 +483,54 @@ pub fn default_folder() -> PathBuf { if let Some(x) = std::env::var_os("XDG_CACHE_HOME") { return PathBuf::from(x).join("effectcraft/disk-cache"); } - home.map(|h| h.join(".cache/effectcraft/disk-cache")).unwrap_or_else(|| std::env::temp_dir().join("effectcraft-disk-cache")) + // Without a home folder: a per-user folder in the shared temp folder, made private by `open`. + let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default(); + home.map(|h| h.join(".cache/effectcraft/disk-cache")).unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-disk-cache-{user}"))) +} + +/// A cache folder in the shared temp folder must be ours alone: not a link someone placed there, +/// and private (setting the mode fails unless we own it), so another user cannot redirect the +/// cache's writes and clean-up or read its files. +#[cfg(unix)] +fn make_private(folder: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + // Judge the folder as written and as it resolves (a relative path, a link or an alias such as + // /tmp -> /private/tmp can all land in the shared temp folder). + // The shared roots: the process's temp folder (`TMPDIR`) and the system's own, which a cache + // configured by path lands in whatever `TMPDIR` says. + let roots: Vec = [std::env::temp_dir(), PathBuf::from("/tmp"), PathBuf::from("/var/tmp")] + .into_iter() + .flat_map(|t| { + let real = t.canonicalize().unwrap_or_else(|_| t.clone()); + [t, real] + }) + .collect(); + let given = std::path::absolute(folder).unwrap_or_else(|_| folder.to_path_buf()); + let real = folder.canonicalize().unwrap_or_else(|_| given.clone()); + // The shared folder itself is not ours to change or to keep a cache in: refused. + if [&given, &real].iter().any(|p| roots.contains(p)) { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the disk cache folder is the shared temp folder")); + } + // Only folders inside it are made private. + if ![&given, &real].iter().any(|p| roots.iter().any(|t| p.starts_with(t) && **p != *t)) { + return Ok(()); + } + if std::fs::symlink_metadata(folder)?.file_type().is_symlink() { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the disk cache folder is a link")); + } + std::fs::set_permissions(folder, std::fs::Permissions::from_mode(0o700)) +} + +#[cfg(not(unix))] +fn make_private(_: &Path) -> std::io::Result<()> { + Ok(()) } impl DiskCache { /// Open (or create) the cache in `folder`, holding at most `max_bytes`. pub fn open(folder: impl AsRef, max_bytes: u64) -> std::io::Result> { + std::fs::create_dir_all(folder.as_ref())?; + make_private(folder.as_ref())?; let dir = folder.as_ref().join(VERSION); std::fs::create_dir_all(&dir)?; let dc = Arc::new(DiskCache { @@ -356,24 +539,30 @@ impl DiskCache { max_bytes: AtomicU64::new(max_bytes), disk_cap: AtomicU64::new(u64::MAX), space_limited: AtomicBool::new(false), + reserve: AtomicU64::new(0), hits: AtomicU64::new(0), misses: AtomicU64::new(0), writes: AtomicU64::new(0), evictions: AtomicU64::new(0), dropped: AtomicU64::new(0), failed: AtomicU64::new(0), + file_checks: AtomicU64::new(0), + encodes: AtomicU64::new(0), tmp_counter: AtomicU64::new(0), queue: Arc::new(( Mutex::new(Queue { frames: Default::default(), frame_bytes: 0, jobs: Default::default(), + job_bytes: 0, busy: cfg!(not(target_arch = "wasm32")), pending_frames: Default::default(), + drops: Drops::default(), }), Condvar::new(), )), min_layer_ms: AtomicU64::new(30), + dropping: Mutex::new(HashMap::new()), }); dc.rescan(); // Shrinking a large cache deletes many files: the writer thread does it. @@ -446,19 +635,22 @@ impl DiskCache { self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed)) } - /// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least - /// 2 GB or 5% of the disk free, whichever is more (evicting when it must). + /// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves + /// [`disk_reserve`] free (5% of the disk, at least 2 GB and at most 10 GB), evicting when it + /// must. pub fn set_disk_space(&self, space: Option<(u64, u64)>) { + let mut reserve = 0; let cap = match space { // (a disk reported as 0 bytes, such as an automount map, is unknown) Some((total, available)) if total > 0 => { - let reserve = (2u64 << 30).max(total / 20); + reserve = disk_reserve(total); let used = self.index.lock().map(|g| g.total()).unwrap_or_default(); // The cache's own files count as free: below the reserve it gives space back. used.saturating_add(available).saturating_sub(reserve) } _ => u64::MAX, }; + self.reserve.store(reserve, Ordering::Relaxed); self.disk_cap.store(cap, Ordering::Relaxed); let limited = cap < self.max_bytes(); if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited { @@ -467,6 +659,33 @@ impl DiskCache { self.evict(); } + /// Whether frame and layer writes are worth encoding now: `false` while free disk space + /// holds the limit at 0 or under 64 MB (a limit the setting asks for is always used). Two + /// atomic reads, so writes check it before they spend time converting and compressing + /// pixels that [`DiskCache::write_format`] would refuse anyway. (Whether a given entry fits + /// is known only once it is compressed: `write_format` decides.) + pub fn accepts_writes(&self) -> bool { + let (max, limit) = (self.max_bytes(), self.limit_bytes()); + limit > 0 && (limit >= MIN_WRITE_ROOM || limit == max) + } + + /// The free space the cache leaves on its disk ([`disk_reserve`]), once the disk's size is + /// known ([`DiskCache::set_disk_space`]). + pub fn reserve_bytes(&self) -> Option { + Some(self.reserve.load(Ordering::Relaxed)).filter(|r| *r > 0) + } + + /// Frame and layer entries compressed for writing so far (tests: a write refused up front + /// costs no encode). + pub fn encodes(&self) -> u64 { + self.encodes.load(Ordering::Relaxed) + } + + /// Count one entry compressed for a queued write. + fn count_encode(&self) { + self.encodes.fetch_add(1, Ordering::Relaxed); + } + /// Layer buffers faster to render than this are not worth a disk write. pub fn set_min_layer_ms(&self, ms: u64) { self.min_layer_ms.store(ms, Ordering::Relaxed); @@ -524,6 +743,7 @@ impl DiskCache { #[cfg(target_arch = "wasm32")] { q.jobs.clear(); + q.job_bytes = 0; q.frames.clear(); q.pending_frames.clear(); } @@ -541,36 +761,94 @@ impl DiskCache { /// An entry's payload and its header's format byte. fn read_entry(&self, kind: Kind, key: u128) -> Option<(u8, Vec)> { + let entry = self.load(kind, key); + match &entry { + Some(_) => self.hit(kind, key), + None => _ = self.misses.fetch_add(1, Ordering::Relaxed), + } + entry + } + + /// An entry's payload and format byte, without counting it as a hit or miss. A damaged file + /// is deleted; a missing one forgotten. + fn load(&self, kind: Kind, key: u128) -> Option<(u8, Vec)> { if !self.contains(kind, key) { - self.misses.fetch_add(1, Ordering::Relaxed); return None; } let path = self.path(kind, key); let data = std::fs::read(&path).ok(); - let ok = data.as_deref().and_then(|d| verify(d, kind).map(|p| (d[5], p))); - match ok { - Some((format, payload)) => { - let payload = (format, payload.to_vec()); - self.hits.fetch_add(1, Ordering::Relaxed); - if let Ok(mut g) = self.index.lock() { - g.touch(kind, key); - } - // Persist the recency for the next session (best effort). - if let Ok(f) = std::fs::File::options().append(true).open(&path) { - let _ = f.set_modified(std::time::SystemTime::now()); - } - Some(payload) - } - None => { - self.misses.fetch_add(1, Ordering::Relaxed); - // Gone (another process evicted it) or damaged: forget it. - if data.is_some() { - let _ = std::fs::remove_file(&path); - } - self.forget(kind, key); - None + let ok = data.as_deref().and_then(|d| Some((*d.get(5)?, verify(d, kind)?.to_vec()))); + if ok.is_none() { + // Gone (another process evicted it) or damaged: forget it. + if data.is_some() { + let _ = std::fs::remove_file(&path); } + self.forget(kind, key); } + ok + } + + /// Count a hit: the entry becomes the most recently used, also for the next session. + fn hit(&self, kind: Kind, key: u128) { + self.hits.fetch_add(1, Ordering::Relaxed); + if let Ok(mut g) = self.index.lock() { + g.touch(kind, key); + } + // Persist the recency for the next session (best effort). + if let Ok(f) = std::fs::File::options().append(true).open(self.path(kind, key)) { + let _ = f.set_modified(std::time::SystemTime::now()); + } + } + + /// An entry of `kind` and `format` whose source files are unchanged, decoded by `decode`, + /// with those files. An entry whose files changed, of the format before #3184, or damaged, + /// is a miss and is deleted (it renders again and is stored anew). A frame entry of the + /// other current frame format (8-bit and half-float frames have keys of their own) is a miss + /// and stays. + fn get_checked( + &self, + kind: Kind, + key: u128, + format: u8, + virtual_id: Option>, + decode: impl FnOnce(&[u8]) -> Option, + ) -> Option<(T, ReadFiles)> { + let miss = || { + self.misses.fetch_add(1, Ordering::Relaxed); + None + }; + // (before the file is read: the writer may finish a drop's sweep between the read and the + // check below, and a drop may start meanwhile) + let drops = self.drops_now(); + let Some((found, payload)) = self.load(kind, key) else { return miss() }; + if found != format { + let other_frame_format = kind == Kind::Frame && (found == FORMAT_RGBA8_FILES || found == FORMAT_F16_FILES); + if !other_frame_format { + self.discard(kind, key); + } + return miss(); + } + let Some((files, body)) = EntryFiles::decode(&payload) else { + self.discard(kind, key); + return miss(); + }; + self.file_checks.fetch_add(files.len() as u64, Ordering::Relaxed); + if self.made_of_dropped(&files, &drops) || !files.unchanged(virtual_id) { + self.discard(kind, key); + return miss(); + } + let Some(value) = decode(body) else { + self.discard(kind, key); + return miss(); + }; + self.hit(kind, key); + Some((value, files.to_read_files())) + } + + /// Source files looked at so far to check frame and layer entries before using them (one per + /// file a read entry lists; tests). + pub fn file_checks(&self) -> u64 { + self.file_checks.load(Ordering::Relaxed) } /// A valid file whose payload does not decode: delete it, as a damaged one. @@ -587,25 +865,30 @@ impl DiskCache { /// Queue a write; `false` when it was dropped (a full queue). fn enqueue(&self, kind: Kind, key: u128, payload: Vec) -> bool { - self.enqueue_format(kind, key, FORMAT_RGBA8, payload) + self.enqueue_format(kind, key, FORMAT_RGBA8, Payload::Encoded(payload), None) } - /// [`DiskCache::enqueue`] with the entry's header format byte. - fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Vec) -> bool { + /// [`DiskCache::enqueue`] with the entry's header format byte. `made`: the source files of a + /// frame or layer entry and the drop generation its reads started at + /// ([`DiskCache::drop_generation`]); refused (`false`) when one of them was dropped since. + fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Payload, made: Option<(&EntryFiles, u64)>) -> bool { + let stale = |q: &Queue| made.is_some_and(|(files, since)| q.drops.stale(files.files().iter().map(|f| &f.path), since)); #[cfg(target_arch = "wasm32")] { - self.write_format(kind, key, format, &payload); + if self.queue.0.lock().is_ok_and(|q| stale(&q)) { + return false; + } + self.write_format(kind, key, format, &payload.encode()); true } #[cfg(not(target_arch = "wasm32"))] { let (m, cv) = &*self.queue; let Ok(mut q) = m.lock() else { return false }; - let full = match kind { - Kind::Frame => q.frame_bytes.saturating_add(payload.len()) > FRAME_QUEUE_BYTES, - _ => q.jobs.len() >= QUEUE_LIMIT, - }; - if full { + if stale(&q) { + return false; + } + if q.full(kind, payload.len()) { self.dropped.fetch_add(1, Ordering::Relaxed); return false; } @@ -614,6 +897,7 @@ impl DiskCache { q.frame_bytes = q.frame_bytes.saturating_add(payload.len()); q.frames.push_back(Job::Write(kind, key, format, payload)); } else { + q.job_bytes = q.job_bytes.saturating_add(payload.len()); q.jobs.push_back(Job::Write(kind, key, format, payload)); } cv.notify_all(); @@ -622,10 +906,6 @@ impl DiskCache { } /// Write an entry now (atomic rename), then evict down to the limit. - fn write_now(&self, kind: Kind, key: u128, payload: &[u8]) { - self.write_format(kind, key, FORMAT_RGBA8, payload); - } - fn write_format(&self, kind: Kind, key: u128, format: u8, payload: &[u8]) { if self.contains(kind, key) { return; @@ -680,77 +960,261 @@ impl DiskCache { } } + /// Delete the frame and layer entries made of any of `paths` (File ▸ Reload Footage: a file + /// replaced by one with the same size and modification time must be read again, which the + /// check on hit can't tell). The index knows keys, not files, so the writer thread reads the + /// files section at the start of each frame and layer entry (no pixels) and deletes the + /// entries that list one of `paths`, after the writes queued before (those are swept too); + /// until it is done, an entry made of one of them is a miss. Entries are deleted for good, + /// so they don't come back after a restart. Nothing else in the cache is touched. + /// + /// A render that was under way meanwhile may still offer an entry made of one of `paths` as + /// it was before: it is refused, see [`DiskCache::drop_generation`]. + pub fn drop_files(&self, paths: impl IntoIterator>>) { + let paths: Vec> = paths.into_iter().map(Into::into).collect(); + if paths.is_empty() { + return; + } + { + let mut d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for p in &paths { + let n = d.entry(p.clone()).or_default(); + *n = n.saturating_add(1); + } + } + #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] + let (m, cv) = &*self.queue; + match m.lock() { + Ok(mut q) => { + q.drops.record(&paths); + #[cfg(not(target_arch = "wasm32"))] + { + // (never refused for a full queue) + q.jobs.push_back(Job::Drop(paths)); + cv.notify_all(); + return; + } + } + // (no writer thread to take the job any more) + Err(poisoned) => poisoned.into_inner().drops.record(&paths), + } + self.sweep(&paths); + } + + /// The number of [`DiskCache::drop_files`] calls so far. A render takes it before it starts + /// reading and hands it to `put_frame` / `put_layer` with what it made: an entry listing a + /// file dropped after that is refused (the render may have read the file as it was before). + pub fn drop_generation(&self) -> u64 { + self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.generation + } + + /// Whether one of `files` was dropped after generation `since` ([`DiskCache::drop_generation`]), + /// or after the earlier generation the list's own reads started at ([`ReadFiles::since`]: a + /// buffer made by an earlier render, from memory): what was made of them then is from before + /// the drop (shown, never stored). + pub fn dropped_since(&self, files: &ReadFiles, since: u64) -> bool { + let since = files.since().map_or(since, |s| s.min(since)); + self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.stale(files.files().iter().map(|f| &f.path), since) + } + + /// Whether a [`DiskCache::drop_files`] is pending. + pub fn is_dropping(&self) -> bool { + !self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_empty() + } + + /// The drop generation and the files being dropped now, taken before an entry is read + /// (see [`DiskCache::made_of_dropped`]). + fn drops_now(&self) -> (u64, Vec>) { + let generation = self.drop_generation(); + let d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + (generation, d.keys().cloned().collect()) + } + + /// An entry read after [`DiskCache::drops_now`] gave `drops` that is made of a file whose + /// drop was pending then (its sweep may have ended since, after the entry was read) or that + /// was dropped since. + fn made_of_dropped(&self, files: &EntryFiles, drops: &(u64, Vec>)) -> bool { + let (since, pending) = drops; + let paths = || files.files().iter().map(|f| &f.path); + paths().any(|p| pending.contains(p)) || self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.stale(paths(), *since) + } + + /// The writer's part of [`DiskCache::drop_files`]: delete the entries made of `paths`, then + /// end their drop. + fn sweep(&self, paths: &[Arc]) { + let set: std::collections::HashSet<&str> = paths.iter().map(|p| &**p).collect(); + for kind in [Kind::Frame, Kind::Layer] { + for key in self.keys(kind) { + if self.entry_files(kind, key).is_some_and(|f| f.files().iter().any(|f| set.contains(&*f.path))) { + self.discard(kind, key); + } + } + } + let mut d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for p in paths { + if let Some(n) = d.get_mut(p) { + *n = n.saturating_sub(1); + if *n == 0 { + d.remove(p); + } + } + } + } + + /// The source files a frame or layer entry lists, read from the start of its file (the + /// whole file only when the list is longer than that). `None` when it has none to read (an + /// earlier format, gone, damaged): such an entry is never used anyway. + fn entry_files(&self, kind: Kind, key: u128) -> Option { + use std::io::Read; + const HEAD: u64 = 64 << 10; + let parse = |d: &[u8]| { + let format = *d.get(5)?; + let ours = d.get(..4)? == MAGIC && *d.get(4)? == kind.tag(); + if !ours || !matches!(format, FORMAT_RGBA8_FILES | FORMAT_F16_FILES | FORMAT_LAYER_FILES) { + return None; + } + EntryFiles::decode(d.get(8..)?).map(|(files, _)| files) + }; + let path = self.path(kind, key); + let mut head = Vec::new(); + std::fs::File::open(&path).ok()?.take(HEAD).read_to_end(&mut head).ok()?; + parse(&head).or_else(|| if head.len() as u64 == HEAD { parse(&std::fs::read(&path).ok()?) } else { None }) + } + // ------------------------------------------------------------ typed entries - /// An 8-bit frame (`None` for a half-float entry of that key, which [`DiskCache::get_frame_f16`] - /// reads). - pub fn get_frame(&self, key: u128) -> Option { - let (format, p) = self.read_entry(Kind::Frame, key)?; - if format != FORMAT_RGBA8 { - return None; - } - decode_frame(&p) + /// An 8-bit frame whose source files are unchanged (see [`DiskCache::get_checked`]), with + /// those files; `None` for a half-float entry of that key, which + /// [`DiskCache::get_frame_f16`] reads. `virtual_id` names files served from memory. + pub fn get_frame(&self, key: u128, virtual_id: Option>) -> Option<(Frame8, ReadFiles)> { + self.get_checked(Kind::Frame, key, FORMAT_RGBA8_FILES, virtual_id, decode_frame) } - /// A half-float frame (32 bpc viewer frames). A damaged one is deleted. - pub fn get_frame_f16(&self, key: u128) -> Option { - let (format, p) = self.read_entry(Kind::Frame, key)?; - if format != FORMAT_F16 { - return None; - } - let f = decode_frame_f16(&p); - if f.is_none() { - self.discard(Kind::Frame, key); - } - f + /// A half-float frame (32 bpc viewer frames), as [`DiskCache::get_frame`]. + pub fn get_frame_f16(&self, key: u128, virtual_id: Option>) -> Option<(FrameF16, ReadFiles)> { + self.get_checked(Kind::Frame, key, FORMAT_F16_FILES, virtual_id, decode_frame_f16) } - /// Queue a half-float frame for writing (`width * height * 4` binary16 values). `true` when - /// it is on disk or queued; `false` when refused (wrong size, a full queue). - pub fn put_frame_f16(&self, key: u128, width: u32, height: u32, rgba: &[u16]) -> bool { + /// Queue a half-float frame for writing (`width * height * 4` binary16 values) with the + /// source files it was made of. `true` when it is on disk or queued; `false` when refused + /// (wrong size, a full queue, no room on the disk, files that can't be checked later: see + /// [`EntryFiles::of`]; a file dropped after `since`, the [`DiskCache::drop_generation`] the + /// render started at). + #[allow(clippy::too_many_arguments)] + pub fn put_frame_f16( + &self, + key: u128, + width: u32, + height: u32, + rgba: &[u16], + files: &ReadFiles, + virtual_id: Option>, + since: u64, + ) -> bool { if rgba.len() as u64 != width as u64 * height as u64 * 4 { return false; } - self.contains(Kind::Frame, key) || self.enqueue_format(Kind::Frame, key, FORMAT_F16, encode_frame_f16(width, height, rgba)) - } - - /// Write a half-float frame synchronously (tests). - pub fn put_frame_f16_now(&self, key: u128, width: u32, height: u32, rgba: &[u16]) { - self.write_format(Kind::Frame, key, FORMAT_F16, &encode_frame_f16(width, height, rgba)); - } - - /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes). `true` - /// when it is on disk or queued; `false` when refused (wrong size, a full queue). - pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) -> bool { - if rgba.len() != width as usize * height as usize * 4 { + let Some(files) = EntryFiles::of(files, virtual_id) else { return false }; + if self.contains(Kind::Frame, key) { + return true; + } + // (no room on the disk: refused before the pixels are compressed) + if !self.accepts_writes() { return false; } - self.contains(Kind::Frame, key) || self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba)) + self.count_encode(); + let payload = with_files(&files, encode_frame_f16(width, height, rgba)); + self.enqueue_format(Kind::Frame, key, FORMAT_F16_FILES, Payload::Encoded(payload), Some((&files, since))) } - pub fn get_layer(&self, key: u128) -> Option { - let p = self.read(Kind::Layer, key)?; - decode_layer(&p) + /// Write a half-float frame synchronously (tests); a list that can't be stored is not. + pub fn put_frame_f16_now(&self, key: u128, width: u32, height: u32, rgba: &[u16], files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Frame, key, FORMAT_F16_FILES, &with_files(&files, encode_frame_f16(width, height, rgba))); + } } - /// Queue a layer buffer for writing. - pub fn put_layer(&self, key: u128, buf: &Buf) { - if self.contains(Kind::Layer, key) { + /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes) with the + /// source files it was made of. `true` when it is on disk or queued; `false` when refused + /// (wrong size, a full queue, no room on the disk, files that can't be checked later: see + /// [`EntryFiles::of`]; a file dropped after `since`, the [`DiskCache::drop_generation`] the + /// render started at). + #[allow(clippy::too_many_arguments)] + pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8], files: &ReadFiles, virtual_id: Option>, since: u64) -> bool { + self.put_frame_owned(key, width, height, rgba.to_vec(), files, virtual_id, since) + } + + /// [`DiskCache::put_frame`] of a frame the caller hands over (no copy; compressed on the + /// writer thread). + #[allow(clippy::too_many_arguments)] + pub fn put_frame_owned( + &self, + key: u128, + width: u32, + height: u32, + rgba: Vec, + files: &ReadFiles, + virtual_id: Option>, + since: u64, + ) -> bool { + if rgba.len() as u64 != width as u64 * height as u64 * 4 { + return false; + } + let Some(files) = EntryFiles::of(files, virtual_id) else { return false }; + if self.contains(Kind::Frame, key) { + return true; + } + // (no room on the disk: refused before the pixels are queued and compressed) + if !self.accepts_writes() { + return false; + } + self.count_encode(); + let made = files.clone(); + self.enqueue_format(Kind::Frame, key, FORMAT_RGBA8_FILES, Payload::Frame(width, height, rgba, files), Some((&made, since))) + } + + /// A layer buffer (key: [`layer_disk_key`]) whose source files are unchanged, with those + /// files, as [`DiskCache::get_checked`]: an entry whose files changed, damaged, or of the + /// format before #3184 is a miss and is deleted, so the buffer renders again and replaces it. + /// `virtual_id` names files served from memory. + pub fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Buf, ReadFiles)> { + self.get_checked(Kind::Layer, key, FORMAT_LAYER_FILES, virtual_id, decode_layer) + } + + /// Queue a layer buffer for writing with the source files it was made of (see + /// [`EntryFiles::of`]: a buffer whose files can't all be named must not be stored). Refused + /// when one of them was dropped after `since`, the [`DiskCache::drop_generation`] its render + /// started at. + /// The buffer is shared, and encoded on the writer thread. + pub fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, since: u64) { + // (no room on the disk, or a buffer no entry could hold: refused before it is queued and + // compressed) + if !layer_fits(buf.img.data.len()) || self.contains(Kind::Layer, key) || !self.accepts_writes() { return; } - let _ = self.enqueue(Kind::Layer, key, encode_layer(buf)); + self.count_encode(); + let _ = self.enqueue_format(Kind::Layer, key, FORMAT_LAYER_FILES, Payload::Layer(buf.clone(), files.clone()), Some((files, since))); } - /// Write a layer buffer synchronously (tests). - pub fn put_layer_now(&self, key: u128, buf: &Buf) { - self.write_now(Kind::Layer, key, &encode_layer(buf)); + /// Write a layer buffer synchronously (tests); a list that can't be stored is not. + pub fn put_layer_now(&self, key: u128, buf: &Buf, files: &ReadFiles) { + if !layer_fits(buf.img.data.len()) { + return; + } + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Layer, key, FORMAT_LAYER_FILES, &with_files(&files, encode_layer(buf))); + } } /// A segmentation (chain key, scale) written by [`DiskCache::put_seg`]. pub fn get_seg(&self, key: u64, scale: f64) -> Option { let p = self.read(Kind::Matte, seg_key(key, scale))?; - let raw = lz4_flex::decompress_size_prepended(&p).ok()?; - effectcraft_effects::roto::FrameSeg::from_bytes(&raw) + let seg = decompress_checked(&p, None).and_then(|raw| effectcraft_effects::roto::FrameSeg::from_bytes(&raw)); + if seg.is_none() { + // A rejected entry would otherwise stay indexed and keep `put_seg` from replacing it. + self.discard(Kind::Matte, seg_key(key, scale)); + } + seg } /// Queue a segmentation for writing. @@ -767,7 +1231,8 @@ impl DiskCache { /// has no links. `false` when `from` isn't cached, `to` already is, or the file system /// refused. (Entries don't contain their key, so the same file serves both.) pub fn link_frame(&self, from: u128, to: u128) -> bool { - if !self.contains(Kind::Frame, from) || self.contains(Kind::Frame, to) { + // (while files are being dropped, `from` may be made of one: it renders again instead) + if !self.contains(Kind::Frame, from) || self.contains(Kind::Frame, to) || self.is_dropping() { return false; } let (src, dst) = (self.path(Kind::Frame, from), self.path(Kind::Frame, to)); @@ -794,12 +1259,23 @@ impl DiskCache { true } - /// Write a frame synchronously (tests). - pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8]) { - self.write_now(Kind::Frame, key, &encode_frame(width, height, rgba)); + /// Write a frame synchronously (tests); a list that can't be stored is not. + pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8], files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Frame, key, FORMAT_RGBA8_FILES, &with_files(&files, encode_frame(width, height, rgba))); + } } } +/// A frame or layer entry's payload: its source files, then its pixels. +fn with_files(files: &EntryFiles, pixels: Vec) -> Vec { + let mut out = Vec::with_capacity(pixels.len() + 64); + files.encode(&mut out); + out.extend_from_slice(&pixels); + out +} + +/// The writer thread: evicts to the limit at open, then drains the job queue until the cache is dropped. #[cfg(not(target_arch = "wasm32"))] fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let (m, cv) = &*q; @@ -830,12 +1306,20 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { } }; let Some(cache) = dc.upgrade() else { return }; - let Job::Write(kind, key, format, payload) = job; - cache.write_format(kind, key, format, &payload); + let written = match job { + Job::Write(kind, key, format, payload) => { + cache.write_format(kind, key, format, &payload.encode()); + (kind == Kind::Frame).then_some(key) + } + Job::Drop(paths) => { + cache.sweep(&paths); + None + } + }; drop(cache); if let Ok(mut g) = m.lock() { g.busy = false; - if kind == Kind::Frame { + if let Some(key) = written { g.pending_frames.remove(&key); } } @@ -843,12 +1327,9 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { } } -/// An entry file's bytes: header (magic, kind, format 0), payload, checksum. -pub fn seal(kind: Kind, payload: &[u8]) -> Vec { - seal_format(kind, FORMAT_RGBA8, payload) -} - -/// [`seal`] with the header's format byte (frames: 0 = 8-bit, 1 = half float). +/// An entry file's bytes: header (magic, kind, the format byte: [`FORMAT_RGBA8`] for +/// segmentations, [`FORMAT_RGBA8_FILES`] / [`FORMAT_F16_FILES`] for frames, +/// [`FORMAT_LAYER_FILES`] for layer buffers), payload, checksum. fn seal_format(kind: Kind, format: u8, payload: &[u8]) -> Vec { let mut data = Vec::with_capacity(payload.len() + 16); data.extend_from_slice(MAGIC); @@ -860,26 +1341,47 @@ fn seal_format(kind: Kind, format: u8, payload: &[u8]) -> Vec { data } -/// A sealed frame entry ([`seal`] of the encoded frame): what the browser's frame workers -/// write to the Origin Private File System. -pub fn frame_entry(width: u32, height: u32, rgba: &[u8]) -> Vec { - seal(Kind::Frame, &encode_frame(width, height, rgba)) +/// A sealed 8-bit frame entry with the source files it was made of: what the browser's frame +/// workers write to the Origin Private File System. `None` when the frame must not be stored +/// (its files can't be checked later, see [`EntryFiles::of`]; or a wrong size). +pub fn frame_entry(width: u32, height: u32, rgba: &[u8], files: &ReadFiles, virtual_id: Option>) -> Option> { + if rgba.len() as u64 != width as u64 * height as u64 * 4 { + return None; + } + let files = EntryFiles::of(files, virtual_id)?; + Some(seal_format(Kind::Frame, FORMAT_RGBA8_FILES, &with_files(&files, encode_frame(width, height, rgba)))) } -/// A frame from a sealed entry's bytes (`None` when damaged). -pub fn read_frame_entry(data: &[u8]) -> Option { - decode_frame(verify(data, Kind::Frame)?) +/// The frame of a sealed entry's bytes, if its source files are unchanged. `None` when damaged, +/// of another format, or made of files that changed since: a miss, to be rendered again. +pub fn read_frame_entry(data: &[u8], virtual_id: Option>) -> Option { + let payload = verify(data, Kind::Frame)?; + if data.get(5) != Some(&FORMAT_RGBA8_FILES) { + return None; + } + let (files, pixels) = EntryFiles::decode(payload)?; + if !files.unchanged(virtual_id) { + return None; + } + decode_frame(pixels) } -/// A sealed layer-buffer entry: what the browser's frame workers write to the Origin Private -/// File System (`layers/<32 hex>.ecc`). -pub fn layer_entry(buf: &Buf) -> Vec { - seal(Kind::Layer, &encode_layer(buf)) +/// A sealed layer-buffer entry with the source files it was made of: what the browser's frame +/// workers write to the Origin Private File System (`layers/<32 hex>.ecc`). +pub fn layer_entry(buf: &Buf, files: &EntryFiles) -> Vec { + seal_format(Kind::Layer, FORMAT_LAYER_FILES, &with_files(files, encode_layer(buf))) } -/// A layer buffer from a sealed entry's bytes (`None` when damaged). -pub fn read_layer_entry(data: &[u8]) -> Option { - decode_layer(verify(data, Kind::Layer)?) +/// A layer buffer and its source files from a sealed entry's bytes (`None` when damaged or of +/// another format). The files are not looked at here: the store checks them when the buffer is +/// used ([`EntryFiles::unchanged`]). +pub fn read_layer_entry(data: &[u8]) -> Option<(Buf, EntryFiles)> { + let payload = verify(data, Kind::Layer)?; + if data.get(5) != Some(&FORMAT_LAYER_FILES) { + return None; + } + let (files, body) = EntryFiles::decode(payload)?; + Some((decode_layer(body)?, files)) } /// The disk key of a segmentation: its chain key and scale. @@ -901,11 +1403,20 @@ impl effectcraft_effects::roto::SegStore for DiskCache { } impl crate::cache::LayerStore for DiskCache { - fn get_layer(&self, key: u128) -> Option> { - DiskCache::get_layer(self, key).map(Arc::new) + /// The layer for `key` from the disk cache, or `None` when it is missing or stale. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)> { + DiskCache::get_layer(self, key, virtual_id).map(|(b, f)| (Arc::new(b), f)) } - fn put_layer(&self, key: u128, buf: &Buf) { - DiskCache::put_layer(self, key, buf); + /// Queues a layer write on the disk cache. + fn put_layer(&self, key: u128, buf: &Arc, files: &EntryFiles, since: u64) { + DiskCache::put_layer(self, key, buf, files, since); + } + /// The current drop generation of the disk cache. + fn drop_generation(&self) -> u64 { + DiskCache::drop_generation(self) + } + fn dropped_since(&self, files: &ReadFiles, since: u64) -> bool { + DiskCache::dropped_since(self, files, since) } fn min_layer_ms(&self) -> u64 { DiskCache::min_layer_ms(self) @@ -942,16 +1453,38 @@ fn encode_frame(w: u32, h: u32, rgba: &[u8]) -> Vec { out } +/// The most bytes one cache entry decompresses to (a 16384² frame of 8-bit RGBA): the size an +/// entry states is checked against it, and against what the entry's dimensions need, before +/// anything is allocated. +const MAX_ENTRY_BYTES: usize = 1 << 30; + +/// An LZ4 payload with its stated size prepended, decompressed only when that size is +/// `expected` (any size up to [`MAX_ENTRY_BYTES`] when `None`). +fn decompress_checked(p: &[u8], expected: Option) -> Option> { + let size = u32::from_le_bytes(p.get(..4)?.try_into().ok()?) as usize; + if size > MAX_ENTRY_BYTES || expected.is_some_and(|e| e != size) { + return None; + } + // A block that ends short of the size it states is damaged: callers index by that size. + lz4_flex::decompress(p.get(4..)?, size).ok().filter(|raw| raw.len() == size) +} + fn decode_frame(p: &[u8]) -> Option { if p.len() < 8 { return None; } let w = u32::from_le_bytes(p[0..4].try_into().ok()?); let h = u32::from_le_bytes(p[4..8].try_into().ok()?); - let rgba = lz4_flex::decompress_size_prepended(&p[8..]).ok()?; + let rgba = decompress_checked(&p[8..], Some((w as usize).checked_mul(h as usize)?.checked_mul(4)?))?; (rgba.len() == w as usize * h as usize * 4).then_some(Frame8 { width: w, height: h, rgba }) } +/// Whether a layer of `pixels` pixels (16 bytes each, decompressed) is within what a cache entry +/// may hold: a larger one could never be read back, so it is not compressed or written. +fn layer_fits(pixels: usize) -> bool { + pixels.checked_mul(16).is_some_and(|b| b <= MAX_ENTRY_BYTES) +} + /// Half floats with their low and high bytes in planes (per channel), then LZ4: the high bytes /// (sign, exponent) repeat a lot, mixed with the low ones LZ4 finds little. fn encode_frame_f16(w: u32, h: u32, rgba: &[u16]) -> Vec { @@ -979,10 +1512,7 @@ fn decode_frame_f16(p: &[u8]) -> Option { let w = u32::from_le_bytes(p[0..4].try_into().ok()?); let h = u32::from_le_bytes(p[4..8].try_into().ok()?); let n = (w as usize).checked_mul(h as usize)?; - let planes = lz4_flex::decompress_size_prepended(&p[8..]).ok()?; - if planes.len() != n.checked_mul(8)? { - return None; - } + let planes = decompress_checked(&p[8..], Some(n.checked_mul(8)?))?; let mut rgba = vec![0u16; n * 4]; for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { for (c, v) in px.iter_mut().enumerate() { @@ -1015,6 +1545,7 @@ fn encode_layer(b: &Buf) -> Vec { out } +/// Decodes a stored layer payload into a buffer, or `None` when it is too short or damaged. fn decode_layer(p: &[u8]) -> Option { if p.len() < 32 { return None; @@ -1024,8 +1555,8 @@ fn decode_layer(p: &[u8]) -> Option { let f = |i: usize| -> Option { Some(f64::from_le_bytes(p[i..i + 8].try_into().ok()?)) }; let offset = [f(8)?, f(16)?]; let scale = f(24)?; - let planes = lz4_flex::decompress_size_prepended(&p[32..]).ok()?; - let n = w as usize * h as usize; + let n = (w as usize).checked_mul(h as usize)?; + let planes = decompress_checked(&p[32..], Some(n.checked_mul(16)?))?; if planes.len() != n * 16 { return None; } @@ -1045,8 +1576,9 @@ fn decode_layer(p: &[u8]) -> Option { // ---------------------------------------------------------------- content keys /// Bump when rendering changes in a way that makes old disk entries wrong. -const RENDER_VERSION: &str = "effectcraft-render-6"; +const RENDER_VERSION: &str = "effectcraft-render-7"; +/// Feeds the `Debug` form of `v` into the hash, followed by a separator byte. fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); h.write(&[0xff]); @@ -1055,126 +1587,63 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { /// 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; - } - if let Ok(md) = std::fs::metadata(path) { - h.write_u64(md.len()); - if let Ok(t) = md.modified() - && let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) - { - 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); - } -} +/// A [`VirtualFileId`] a cache can keep (the browser file table's identities). +pub type SharedVirtualFileId = Arc Option<(u64, u64)> + Send + Sync>; -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, virtual_id); - } -} +/// Layer entries with their source files (#3184), part of every layer disk key: entries of the +/// earlier format (keyed by a project-wide footage salt instead) are never looked up again; +/// least recently used, they are evicted. +const LAYER_ENTRY_VERSION: &[u8] = b"layer-entry-files-2"; -/// 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 { +/// Disk key of a layer buffer: the layer cache's content key ([`crate::cache::layer_key`] and +/// the keys derived from it), the layer entry format and the renderer version. The content key +/// comes from memory alone: it names the footage items the buffer uses with their settings and +/// paths, so two projects only share an entry for the same pixels of the same files; whether +/// those files still hold what the buffer was made of is checked when the entry is read +/// ([`EntryFiles`]). No file is looked at here. +pub fn layer_disk_key(key: u64) -> u128 { let mut h = Hash128::default(); + h.write(LAYER_ENTRY_VERSION); h.write(RENDER_VERSION.as_bytes()); - // Footage another layer reads (Audio Spectrum's Audio Layer); a read precomp can hold any. - let mut read = std::collections::BTreeSet::new(); - let mut read_comp = false; - for (_, c) in project.comps() { - for l in c.layers.iter() { - for r in l.referenced_layers().into_iter().filter(|r| *r != l.id) { - match c.layer(r).map(|x| &x.source) { - Some(effectcraft_project::LayerSource::Footage { item }) => _ = read.insert(*item), - Some(effectcraft_project::LayerSource::Comp { .. }) => read_comp = true, - _ => {} - } - } - } - } - for (id, it) in &project.items { - // (files without video make layer buffers only through a layer that reads them: - // importing music keeps the keys) - if let effectcraft_project::ItemKind::Footage(f) = &it.kind - && (f.has_video || read_comp || read.contains(id)) - { - h.write_u64(id.0); - hash_debug(&mut h, f); - footage_stamp(&mut h, f, virtual_id); - } - if let Some(proxy) = it.proxy.as_ref().filter(|p| p.enabled) { - h.write_u64(id.0); - hash_debug(&mut h, proxy); - footage_stamp(&mut h, &proxy.footage, virtual_id); - } - } - let v = h.finish(); - (v as u64) ^ ((v >> 64) as u64) + h.write_u64(key); + h.finish() } -/// Content key of a composition's pixels: project render settings, the comp and everything it -/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy -/// 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, and -/// neither do markers unless an expression mentions them. +/// Content key of a composition's pixels, from the project in memory alone (no file is looked +/// at: the frame entries check their own source files when read, see [`EntryFiles`]): project +/// render settings, the comp and the items it can draw (nested comps, footage items with their +/// settings and paths, solids, Media Replacement footage, their proxies and Use Proxy switches; +/// [`effectcraft_project::Project::pixel_reach`]). With expressions among them, everything those +/// expressions can read counts too: every layer of their comps, markers, the comps and footage +/// they name with literal names (and which items have those names), or the whole project when +/// they compute a name or reach items another way. An edit to an item the comp can't reach keeps +/// its key and its frames on disk (#3184, #1492). Switches that don't change pixels (Audio, Lock, +/// Shy, Hide Shy Layers) don't count, and neither do markers unless an expression can read them. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { - comp_content_key_with(project, comp, None) + comp_content_key_for_renderer(project, comp, RENDER_VERSION) } -/// [`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 { - comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION) -} - -fn comp_content_key_for_renderer( - project: &effectcraft_project::Project, - comp: effectcraft_project::ItemId, - virtual_id: Option>, - renderer: &str, -) -> u128 { - use effectcraft_project::{ItemKind, LayerSource}; +/// The content key of `comp` for one renderer: its settings, the comps and footage it uses, and the renderer name. +fn comp_content_key_for_renderer(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, renderer: &str) -> u128 { + use effectcraft_project::ItemKind; let mut h = Hash128::default(); h.write(renderer.as_bytes()); hash_debug(&mut h, &project.settings); - // Expressions can read anything (every item counts); without them, layers that can't - // change the picture (music, hidden layers nothing reads) and their files don't count. - let expressions = project.reads_markers(); let heard = project.heard_layers(); + let reach = project.pixel_reach(comp, &heard); + // Expressions can read any layer of the comps they reach, hidden or not, and their markers + // (#1083); without them, layers that can't change the picture (music, hidden layers nothing + // reads) don't count. + let expressions = reach.expressions; let silent = |id, c: &effectcraft_project::Comp| if expressions { Default::default() } else { project.silent_layers(id, c, &heard) }; - let mut seen = std::collections::BTreeSet::new(); - let mut stack = vec![comp]; - while let Some(id) = stack.pop() { - if !seen.insert(id) { - continue; - } - let Some(it) = project.item(id) else { continue }; - if let ItemKind::Comp(c) = &it.kind { - let quiet = silent(id, c); - for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) { - if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { - stack.push(item); - } - } - } + // (the names expressions look items up by: which items have them now) + if !reach.names.is_empty() { + hash_debug(&mut h, &reach.names); } - // Markers are pixels only to the expressions that read them (#1083). - let markers = expressions; - let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; + let ids: Vec = match reach.items { + Some(items) => items.into_iter().collect(), + None => project.items.keys().copied().collect(), + }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); @@ -1184,30 +1653,30 @@ fn comp_content_key_for_renderer( let quiet = silent(id, c); let heard = |l| heard.contains(id, l); if quiet.is_empty() { - hash_debug(&mut h, &c.pixel_form_with(markers, &heard)); + hash_debug(&mut h, &c.pixel_form_with(expressions, &heard)); } else { let mut pic = (**c).clone(); pic.layers.retain(|l| !quiet.contains(&l.id)); - hash_debug(&mut h, &pic.pixel_form_with(markers, &heard)); + hash_debug(&mut h, &pic.pixel_form_with(expressions, &heard)); } } k => hash_debug(&mut h, k), } - if let ItemKind::Footage(f) = &it.kind { - 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, virtual_id); - } } h.write_u64(comp.0); h.finish() } +/// Frame entries with their source files (#3184), part of every frame key: entries of the +/// earlier format (keyed by footage stamps instead) are never looked up again; least recently +/// used, they are evicted. +const FRAME_ENTRY_VERSION: &[u8] = b"frame-entry-files-2"; + /// Disk key of one viewer frame. pub fn frame_key(content: u128, frame: i64, scale_milli: u32, view: u64) -> u128 { let mut h = Hash128::default(); + h.write(FRAME_ENTRY_VERSION); h.write(&content.to_le_bytes()); h.write_u64(frame as u64); h.write_u64(scale_milli as u64); @@ -1219,6 +1688,82 @@ pub fn frame_key(content: u128, frame: i64, scale_milli: u32, view: u64) -> u128 mod tests { use super::*; + #[cfg(unix)] + #[test] + fn a_cache_folder_in_the_temp_folder_is_made_private_and_never_a_link() { + use std::os::unix::fs::PermissionsExt; + let d = tmpdir("private"); + let dc = DiskCache::open(&d, 1 << 20).unwrap(); + assert_eq!(std::fs::metadata(&d).unwrap().permissions().mode() & 0o777, 0o700); + drop(dc); + let link = tmpdir("link-private-temp"); + std::os::unix::fs::symlink(&d, &link).unwrap(); + assert!(DiskCache::open(&link, 1 << 20).is_err()); + let _ = std::fs::remove_file(&link); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn the_shared_temp_folder_itself_is_refused_as_a_cache_folder() { + let tmp = std::env::temp_dir(); + assert!(DiskCache::open(&tmp, 1 << 20).is_err()); + } + + #[cfg(unix)] + #[test] + fn a_folder_that_resolves_into_the_temp_folder_is_made_private() { + use std::os::unix::fs::PermissionsExt; + // `/link` names a folder in the temp folder only through the link. + let real = tmpdir("alias-real"); + std::fs::create_dir_all(&real).unwrap(); + let link = std::env::current_dir().unwrap().join(format!("effectcraft-alias-link-{}", std::process::id())); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let dc = DiskCache::open(link.join("cache"), 1 << 20).unwrap(); + let mode = std::fs::metadata(real.join("cache")).map(|m| m.permissions().mode() & 0o777); + drop(dc); + let _ = std::fs::remove_file(&link); + let _ = std::fs::remove_dir_all(&real); + assert_eq!(mode.unwrap(), 0o700); + } + + #[test] + fn layers_too_big_to_read_back_are_not_written() { + assert!(layer_fits(MAX_ENTRY_BYTES / 16)); + assert!(!layer_fits(MAX_ENTRY_BYTES / 16 + 1)); + assert!(!layer_fits(10_000 * 10_000)); + assert!(!layer_fits(usize::MAX)); + } + + #[test] + fn an_entry_stating_a_size_its_dimensions_do_not_need_is_not_decompressed() { + // A 2×2 frame whose payload states 3 GiB: refused before any allocation. + let mut p = 2u32.to_le_bytes().to_vec(); + p.extend_from_slice(&2u32.to_le_bytes()); + p.extend_from_slice(&(3u32 << 30).to_le_bytes()); + p.extend_from_slice(&[0; 16]); + assert!(decode_frame(&p).is_none()); + assert!(decompress_checked(&p[8..], None).is_none()); + // A well-formed entry still decodes. + let ok = encode_frame(2, 2, &[7; 16]); + assert_eq!(decode_frame(&ok).map(|f| f.rgba), Some(vec![7; 16])); + } + + #[test] + fn a_block_shorter_than_the_size_it_states_is_rejected() { + // The payload states 100 bytes but holds a valid block of 10: `lz4_flex` returns the 10. + let mut p = 100u32.to_le_bytes().to_vec(); + p.extend_from_slice(&lz4_flex::block::compress(&[5u8; 10])); + assert!(decompress_checked(&p, None).is_none()); + // A half-float frame whose planes come up short is refused, not indexed past. + let mut f16 = 2u32.to_le_bytes().to_vec(); + f16.extend_from_slice(&2u32.to_le_bytes()); + f16.extend_from_slice(&32u32.to_le_bytes()); + f16.extend_from_slice(&lz4_flex::block::compress(&[1u8; 8])); + assert!(decode_frame_f16(&f16).is_none()); + } + fn tmpdir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("effectcraft-disk-cache-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); @@ -1231,18 +1776,18 @@ mod tests { fn link_frame_serves_a_frame_under_a_new_key() { let dc = DiskCache::open(tmpdir("link"), 1 << 30).unwrap(); let rgba: Vec = (0..16).collect(); - dc.put_frame_now(1, 2, 2, &rgba); + dc.put_frame_now(1, 2, 2, &rgba, &ReadFiles::default()); assert!(dc.link_frame(1, 2)); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).map(|f| f.rgba), Some(rgba.clone())); assert!(!dc.link_frame(1, 2), "already there"); assert!(!dc.link_frame(3, 4), "nothing to link"); assert!(!dc.contains(Kind::Frame, 4)); - dc.put_frame_now(5, 2, 2, &[7; 16]); + dc.put_frame_now(5, 2, 2, &[7; 16], &ReadFiles::default()); assert!(!dc.link_frame(5, 2), "an existing entry stays"); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).map(|f| f.rgba), Some(rgba.clone())); let _ = std::fs::remove_file(dc.path(Kind::Frame, 1)); dc.forget(Kind::Frame, 1); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba), "the link outlives the original"); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).map(|f| f.rgba), Some(rgba), "the link outlives the original"); } fn buf(seed: u32, w: u32, h: u32) -> Buf { @@ -1277,11 +1822,21 @@ mod tests { #[test] fn frame_entries_round_trip_and_names_parse() { let rgba: Vec = (0..4 * 6 * 3).map(|i| (i * 7 % 256) as u8).collect(); - let e = frame_entry(6, 3, &rgba); - assert_eq!(read_frame_entry(&e), Some(Frame8 { width: 6, height: 3, rgba: rgba.clone() })); + let files: ReadFiles = [crate::reads::ReadFile::new("/v.png", crate::reads::FileStamp::Virtual)].into_iter().collect(); + let (one, two) = (|_: &str| Some((4, 1)), |_: &str| Some((4, 2))); + assert!(frame_entry(6, 3, &rgba, &files, None).is_none(), "a file from memory needs its identity"); + assert!(frame_entry(6, 3, &rgba[1..], &files, Some(&one)).is_none(), "a wrong size"); + let e = frame_entry(6, 3, &rgba, &files, Some(&one)).unwrap(); + assert_eq!(read_frame_entry(&e, Some(&one)), Some(Frame8 { width: 6, height: 3, rgba: rgba.clone() })); + assert!(read_frame_entry(&e, Some(&two)).is_none(), "the file changed"); + let mut incomplete = files.clone(); + incomplete.mark_incomplete(); + assert!(frame_entry(6, 3, &rgba, &incomplete, Some(&one)).is_none(), "an incomplete list is not stored"); let mut bad = e.clone(); bad[20] ^= 1; - assert!(read_frame_entry(&bad).is_none()); + assert!(read_frame_entry(&bad, Some(&one)).is_none()); + // An entry of the format before #3184 (pixels without files) is never read. + assert!(read_frame_entry(&seal_format(Kind::Frame, FORMAT_RGBA8, &encode_frame(6, 3, &rgba)), Some(&one)).is_none()); let key = 0x0123_4567_89ab_cdef_0011_2233_4455_6677u128; assert_eq!(parse_entry_name(&entry_name(key)), Some(key)); assert_eq!(parse_entry_name("abc.ecc"), None); @@ -1298,27 +1853,27 @@ mod tests { let (w, h) = (7u32, 5u32); // 4.0, 16.0, -0.25, 65504 and a spread of bit patterns. let rgba: Vec = (0..w * h * 4).map(|i| [0x4400, 0x4c00, 0xb400, 0x7bff][i as usize % 4] ^ ((i * 37) as u16 & 0x03ff)).collect(); - dc.put_frame_f16_now(1, w, h, &rgba); - assert_eq!(dc.get_frame_f16(1), Some(FrameF16 { width: w, height: h, rgba: rgba.clone() })); - assert!(dc.get_frame(1).is_none(), "not an 8-bit frame"); - dc.put_frame_now(2, 2, 2, &[9; 16]); - assert!(dc.get_frame_f16(2).is_none() && dc.contains(Kind::Frame, 2), "an 8-bit entry stays valid"); - assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]); + dc.put_frame_f16_now(1, w, h, &rgba, &ReadFiles::default()); + assert_eq!(dc.get_frame_f16(1, None).map(|f| f.0), Some(FrameF16 { width: w, height: h, rgba: rgba.clone() })); + assert!(dc.get_frame(1, None).map(|f| f.0).is_none(), "not an 8-bit frame"); + dc.put_frame_now(2, 2, 2, &[9; 16], &ReadFiles::default()); + assert!(dc.get_frame_f16(2, None).map(|f| f.0).is_none() && dc.contains(Kind::Frame, 2), "an 8-bit entry stays valid"); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); assert!(dc.stats().bytes > 0); // Reopened, still there (format byte persisted). drop(dc); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - assert_eq!(dc.get_frame_f16(1).unwrap().rgba, rgba); + assert_eq!(dc.get_frame_f16(1, None).map(|f| f.0).unwrap().rgba, rgba); // A sealed entry with a payload of the wrong size: deleted on read. let p = dc.path(Kind::Frame, 3); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); - std::fs::write(&p, seal_format(Kind::Frame, FORMAT_F16, &encode_frame_f16(w, h + 1, &rgba[..])[..])).unwrap(); + std::fs::write(&p, seal_format(Kind::Frame, FORMAT_F16_FILES, &with_files(&EntryFiles::default(), encode_frame_f16(w, h + 1, &rgba[..])))).unwrap(); dc.rescan(); assert!(dc.contains(Kind::Frame, 3)); - assert!(dc.get_frame_f16(3).is_none()); + assert!(dc.get_frame_f16(3, None).map(|f| f.0).is_none()); assert!(!dc.contains(Kind::Frame, 3) && !p.exists()); // Wrong lengths are not written. - dc.put_frame_f16(4, w, h, &rgba[1..]); + dc.put_frame_f16(4, w, h, &rgba[1..], &ReadFiles::default(), None, 0); dc.flush(); assert!(!dc.contains(Kind::Frame, 4)); let _ = std::fs::remove_dir_all(&dir); @@ -1360,26 +1915,31 @@ mod tests { fn hit_miss_and_exact_round_trip() { let dir = tmpdir("hit"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - assert!(dc.get_layer(42).is_none()); + assert!(dc.get_layer(42, None).is_none()); let b = buf(1, 33, 17); - dc.put_layer(42, &b); + dc.put_layer(42, &Arc::new(b.clone()), &EntryFiles::default(), 0); dc.flush(); - let back = dc.get_layer(42).unwrap(); + let (back, files) = dc.get_layer(42, None).unwrap(); + assert!(files.is_empty() && files.is_complete()); assert_eq!(back.img, b.img); assert_eq!((back.offset, back.scale), (b.offset, b.scale)); let rgba: Vec = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect(); - assert!(dc.put_frame(7, 20, 10, &rgba)); - assert!(!dc.put_frame(8, 2, 2, &[0; 3]), "a wrong-size frame is refused"); + assert!(dc.put_frame(7, 20, 10, &rgba, &ReadFiles::default(), None, 0)); + assert!(!dc.put_frame(8, 2, 2, &[0; 3], &ReadFiles::default(), None, 0), "a wrong-size frame is refused"); + // Handed over and compressed on the writer thread: the same pixels come back. + assert!(dc.put_frame_owned(9, 20, 10, rgba.clone(), &ReadFiles::default(), None, 0)); + assert!(!dc.put_frame_owned(10, 2, 2, vec![0; 3], &ReadFiles::default(), None, 0), "a wrong-size frame is refused"); dc.flush(); - assert_eq!(dc.get_frame(7).unwrap().rgba, rgba); + assert_eq!(dc.get_frame(7, None).map(|f| f.0).unwrap().rgba, rgba); + assert_eq!(dc.get_frame(9, None).map(|f| f.0).unwrap().rgba, rgba); let st = dc.stats(); - assert_eq!((st.entries, st.hits, st.misses, st.writes), (2, 2, 1, 2)); + assert_eq!((st.entries, st.hits, st.misses, st.writes), (3, 3, 1, 3)); // Wrong kind is a miss. - assert!(dc.get_frame(42).is_none()); + assert!(dc.get_frame(42, None).map(|f| f.0).is_none()); } /// Music doesn't change a comp's disk keys (adding it to the timeline kept no blue frame); - /// hiding a clip does. Importing music keeps the layer-cache salt. + /// hiding a clip does. Importing music keeps them too. #[test] fn music_keeps_the_disk_keys() { use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build}; @@ -1391,14 +1951,14 @@ mod tests { let clip = build::layer(&mut p, &comp, "Clip", LayerSource::Solid { item: sid }, (64, 64), None); let clip_id = clip.id; p.comp_mut(cid).unwrap().layers.push(clip); - let (key, salt) = (comp_content_key(&p, cid), footage_salt(&p)); + let key = comp_content_key(&p, cid); let song = p.add_item( "song.wav", Default::default(), None, ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }), ); - assert_eq!(footage_salt(&p), salt, "importing music"); + assert_eq!(comp_content_key(&p, cid), key, "importing music"); let music = build::layer(&mut p, &comp, "Music", LayerSource::Footage { item: song }, (64, 64), None); p.comp_mut(cid).unwrap().layers.insert(0, music); assert_eq!(comp_content_key(&p, cid), key, "music on the timeline"); @@ -1406,6 +1966,58 @@ mod tests { assert_ne!(comp_content_key(&p, cid), key, "a hidden clip"); } + /// An expression that reads a 3D model layer's source (`thisComp.layer("Model").source.name`) + /// gives a new key when the model item is renamed: the frames on disk hold the old name. + #[test] + fn renaming_a_model_an_expression_reads_changes_the_disk_key() { + use effectcraft_project::{Comp, Expression, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build}; + use effectcraft_time::{FrameRate, Tick}; + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Default::default(), None, ItemKind::Comp(comp.clone().into())); + let model = p.add_item("chair.glb", Default::default(), None, ItemKind::Footage(Footage { kind: FootageKind::Model, ..Default::default() })); + let sid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 })); + let mut m = build::layer(&mut p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None); + m.switches.video = false; + let mut s = build::layer(&mut p, &comp, "Reader", LayerSource::Solid { item: sid }, (64, 64), None); + s.props.prop_mut("transform/opacity").unwrap().expr = + Some(Expression { text: "thisComp.layer(\"Model\").source.name.length * 10".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.extend([s, m]); + let key = comp_content_key(&p, cid); + p.item_mut(model).unwrap().name = "table.glb".into(); + assert_ne!(comp_content_key(&p, cid), key, "the expression reads the model's name"); + } + + /// Layer jobs hold float buffers until they are compressed: the queue holds at most + /// [`LAYER_QUEUE_BYTES`] of them (64 at 4K held ~8.5 GB), and a single bigger one only alone. + #[test] + fn layer_writes_queue_within_a_byte_budget() { + let mut q = Queue { + frames: Default::default(), + frame_bytes: 0, + jobs: Default::default(), + job_bytes: 0, + busy: true, + pending_frames: Default::default(), + drops: Drops::default(), + }; + let layer = LAYER_QUEUE_BYTES / 4 + 1; + for _ in 0..3 { + assert!(!q.full(Kind::Layer, layer)); + q.jobs.push_back(Job::Write(Kind::Layer, 0, 0, Payload::Encoded(vec![]))); + q.job_bytes += layer; + } + assert!(q.full(Kind::Layer, layer), "a fourth would pass the budget"); + assert!(!q.full(Kind::Frame, 1024), "frames have their own budget"); + let mut empty = Queue { jobs: Default::default(), job_bytes: 0, ..q }; + assert!(!empty.full(Kind::Layer, 2 * LAYER_QUEUE_BYTES), "a single big layer, alone"); + // Taking a job gives its bytes back. + empty.job_bytes = 3; + empty.jobs.push_back(Job::Write(Kind::Layer, 0, 0, Payload::Encoded(vec![0; 3]))); + assert!(empty.pop().is_some()); + assert_eq!(empty.job_bytes, 0); + } + /// A burst of layer writes never crowds out frames: every frame of a fully cached comp is /// on disk afterwards (the blue bar came back with gaps). #[test] @@ -1413,11 +2025,11 @@ mod tests { let dir = tmpdir("burst"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); for k in 0..300 { - dc.put_layer(10_000 + k, &buf(1, 64, 64)); + dc.put_layer(10_000 + k, &Arc::new(buf(1, 64, 64)), &EntryFiles::default(), 0); } let rgba = vec![7u8; 32 * 32 * 4]; for k in 0..300 { - dc.put_frame(k, 32, 32, &rgba); + dc.put_frame(k, 32, 32, &rgba, &ReadFiles::default(), None, 0); } dc.flush(); assert!((0..300).all(|k| dc.contains(Kind::Frame, k)), "every frame written"); @@ -1432,17 +2044,17 @@ mod tests { let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let key = frame_key(comp_content_key(&project, id), 0, 1000, 0); - let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-5"), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, "effectcraft-render-5"), 0, 1000, 0); let dir = tmpdir("previous-renderer"); { let cache = DiskCache::open(&dir, 1 << 30).unwrap(); - cache.put_frame_now(previous_key, 2, 2, &[9; 16]); + cache.put_frame_now(previous_key, 2, 2, &[9; 16], &ReadFiles::default()); } let cache = DiskCache::open(&dir, 1 << 30).unwrap(); - assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]); - assert!(cache.get_frame(key).is_none()); - cache.put_frame_now(key, 2, 2, &[17; 16]); - assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]); + assert_eq!(cache.get_frame(previous_key, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); + assert!(cache.get_frame(key, None).map(|f| f.0).is_none()); + cache.put_frame_now(key, 2, 2, &[17; 16], &ReadFiles::default()); + assert_eq!(cache.get_frame(key, None).map(|f| f.0).unwrap().rgba, vec![17; 16]); } #[test] @@ -1450,16 +2062,16 @@ mod tests { let dir = tmpdir("persist"); { let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - dc.put_layer_now(1, &buf(1, 8, 8)); - dc.put_frame_now(2, 2, 2, &[9; 16]); + dc.put_layer_now(1, &buf(1, 8, 8), &ReadFiles::default()); + dc.put_frame_now(2, 2, 2, &[9; 16], &ReadFiles::default()); } let dc = DiskCache::open(&dir, 1 << 30).unwrap(); assert_eq!(dc.stats().entries, 2); assert!(dc.contains(Kind::Layer, 1) && dc.contains(Kind::Frame, 2)); - assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); dc.clear(); assert_eq!(dc.stats().entries, 0); - assert!(dc.get_frame(2).is_none()); + assert!(dc.get_frame(2, None).map(|f| f.0).is_none()); let again = DiskCache::open(&dir, 1 << 30).unwrap(); assert_eq!(again.stats().entries, 0); } @@ -1469,11 +2081,11 @@ mod tests { let dir = tmpdir("lru"); let dc = DiskCache::open(&dir, u64::MAX).unwrap(); for k in 0..6u128 { - dc.put_layer_now(k, &buf(k as u32, 64, 64)); + dc.put_layer_now(k, &buf(k as u32, 64, 64), &ReadFiles::default()); } let per = dc.stats().bytes / 6; // Touch 0 so it is the most recently used. - assert!(dc.get_layer(0).is_some()); + assert!(dc.get_layer(0, None).is_some()); dc.set_max_bytes(per * 3 + per * 3 / 4); let st = dc.stats(); assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}"); @@ -1485,7 +2097,7 @@ mod tests { let files = walk(&dir); assert_eq!(files, 3); // New writes keep evicting. - dc.put_layer_now(99, &buf(9, 64, 64)); + dc.put_layer_now(99, &buf(9, 64, 64), &ReadFiles::default()); assert!(dc.stats().bytes <= per * 3 + per * 3 / 4); } @@ -1510,7 +2122,7 @@ mod tests { let dir = tmpdir("space"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); for k in 0..4u128 { - dc.put_layer_now(k, &buf(k as u32, 64, 64)); + dc.put_layer_now(k, &buf(k as u32, 64, 64), &ReadFiles::default()); } let used = dc.stats().bytes; // Unknown space: the setting alone. @@ -1519,17 +2131,18 @@ mod tests { // A disk reported as 0 bytes is unknown. dc.set_disk_space(Some((0, 0))); assert_eq!(dc.stats().limit_bytes, 1 << 30); - // 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow. + // 100 GB disk, 5 GB reserved (5%, between the 2 GB floor and the 10 GB cap), exactly + // 5 GB free: the cache may not grow. dc.set_disk_space(Some((100 << 30, 5 << 30))); let st = dc.stats(); assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used)); - dc.put_layer_now(9, &buf(9, 64, 64)); + dc.put_layer_now(9, &buf(9, 64, 64), &ReadFiles::default()); let st = dc.stats(); assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}"); assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used"); // Below the reserve the cache gives its space back, and skips writes that can't fit. dc.set_disk_space(Some((100 << 30, 4 << 30))); - dc.put_layer_now(10, &buf(10, 64, 64)); + dc.put_layer_now(10, &buf(10, 64, 64), &ReadFiles::default()); let st = dc.stats(); assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}"); // Plenty of space again: back to the setting. @@ -1538,14 +2151,86 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The reserve is 5% of the disk, at least 2 GB and at most 10 GB: a large disk with plenty + /// free keeps the user's size. (A 4 TB disk with 190 GB free reserved 200 GB, held a 20 GB + /// cache at 0, and every write was refused.) + #[test] + fn a_large_disk_reserves_at_most_ten_gb() { + const GB: u64 = 1 << 30; + assert_eq!(disk_reserve(4096 * GB), 10 * GB); + assert_eq!(disk_reserve(100 * GB), 5 * GB); + assert_eq!(disk_reserve(32 * GB), 2 * GB); + assert_eq!(disk_reserve(1), 2 * GB); + let dir = tmpdir("space-large"); + let dc = DiskCache::open(&dir, 20 * GB).unwrap(); + dc.set_disk_space(Some((4096 * GB, 190 * GB))); + let st = dc.stats(); + assert_eq!((st.max_bytes, st.limit_bytes), (20 * GB, 20 * GB), "{st:?}"); + assert!(dc.accepts_writes()); + assert_eq!(dc.reserve_bytes(), Some(10 * GB)); + // Just above the 10 GB reserve: the cache may use what is left above it. + dc.set_disk_space(Some((4096 * GB, 15 * GB))); + assert_eq!(dc.stats().limit_bytes, 5 * GB); + // A small disk still keeps 2 GB free: 3 GB free leaves 1 GB for the cache. + dc.set_disk_space(Some((32 * GB, 3 * GB))); + assert_eq!(dc.stats().limit_bytes, GB); + // Below the floor: nothing. + dc.set_disk_space(Some((32 * GB, GB))); + assert_eq!(dc.stats().limit_bytes, 0); + assert!(!dc.accepts_writes()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A write the free disk space refuses is refused before its pixels are compressed: the + /// frame workers spent about 8 ms per frame and 14 ms per layer encoding entries that were + /// then refused (`limit_bytes=0`). + #[test] + fn no_encode_while_the_disk_has_no_room() { + const GB: u64 = 1 << 30; + let dir = tmpdir("space-no-encode"); + let dc = DiskCache::open(&dir, 20 * GB).unwrap(); + // 4 TB disk, 1 GB free: under the 10 GB reserve, the limit is 0. + dc.set_disk_space(Some((4096 * GB, GB))); + assert_eq!(dc.stats().limit_bytes, 0); + let rgba = vec![7u8; 16 * 16 * 4]; + assert!(!dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0), "refused"); + assert!(!dc.put_frame_f16(2, 16, 16, &[0x3c00u16; 16 * 16 * 4], &ReadFiles::default(), None, 0), "refused"); + dc.put_layer(3, &Arc::new(buf(3, 16, 16)), &EntryFiles::default(), 0); + dc.flush(); + assert_eq!(dc.encodes(), 0, "nothing compressed"); + assert_eq!(dc.stats().entries, 0); + // 32 MB of room above the reserve: a few frames would fill it, the rest would be + // compressed only to be refused. Not worth encoding either. + dc.set_disk_space(Some((4096 * GB, 10 * GB + (32 << 20)))); + assert_eq!(dc.stats().limit_bytes, 32 << 20); + assert!(!dc.accepts_writes()); + assert!(!dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0), "refused"); + dc.put_layer(3, &Arc::new(buf(3, 16, 16)), &EntryFiles::default(), 0); + assert_eq!(dc.encodes(), 0, "nothing compressed"); + // A small limit the setting asks for (not free space) is used. + dc.set_disk_space(None); + dc.set_max_bytes(32 << 20); + assert!(dc.accepts_writes()); + dc.set_max_bytes(20 * GB); + // Room again: the same writes are compressed and stored. + dc.set_disk_space(Some((4096 * GB, 500 * GB))); + assert!(dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0)); + assert!(dc.put_frame_f16(2, 16, 16, &[0x3c00u16; 16 * 16 * 4], &ReadFiles::default(), None, 0)); + dc.put_layer(3, &Arc::new(buf(3, 16, 16)), &EntryFiles::default(), 0); + dc.flush(); + assert_eq!(dc.encodes(), 3); + assert_eq!(dc.stats().entries, 3); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn failed_writes_are_counted() { let dir = tmpdir("failed"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); // A file where the layers folder should be: every layer write fails. std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap(); - dc.put_layer_now(1, &buf(1, 8, 8)); - dc.put_layer_now(2, &buf(2, 8, 8)); + dc.put_layer_now(1, &buf(1, 8, 8), &ReadFiles::default()); + dc.put_layer_now(2, &buf(2, 8, 8), &ReadFiles::default()); let st = dc.stats(); assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0)); let _ = std::fs::remove_dir_all(&dir); @@ -1570,13 +2255,13 @@ mod tests { fn damaged_files_are_misses() { let dir = tmpdir("damaged"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - dc.put_frame_now(5, 4, 4, &[1; 64]); + dc.put_frame_now(5, 4, 4, &[1; 64], &ReadFiles::default()); let p = dc.path(Kind::Frame, 5); let mut d = std::fs::read(&p).unwrap(); let n = d.len(); d[n / 2] ^= 0xff; std::fs::write(&p, d).unwrap(); - assert!(dc.get_frame(5).is_none()); + assert!(dc.get_frame(5, None).map(|f| f.0).is_none()); assert!(!dc.contains(Kind::Frame, 5)); assert!(!p.exists()); } @@ -1594,8 +2279,8 @@ mod tests { for i in 0..20u128 { let k = i % 5; let w = if t % 2 == 0 { &a } else { &b }; - w.put_layer_now(k, &buf(k as u32, 16, 16)); - if let Some(x) = w.get_layer(k) { + w.put_layer_now(k, &buf(k as u32, 16, 16), &ReadFiles::default()); + if let Some((x, _)) = w.get_layer(k, None) { assert_eq!(x.img, buf(k as u32, 16, 16).img); } } @@ -1604,39 +2289,16 @@ mod tests { }); b.rescan(); for k in 0..5u128 { - assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img); + assert_eq!(b.get_layer(k, None).unwrap().0.img, buf(k as u32, 16, 16).img); } } - /// An audio file nothing reads stays out of the layer salt; one a layer reads (Audio - /// Spectrum) is in it, so overwriting it doesn't reuse that layer's buffers. + /// Replaced bytes of a path with no native metadata (the browser file table) change neither + /// the comp key nor a layer's disk key (both from memory): the frame and layer entries made + /// of them check the hook's size and content hash when read and are misses, deleted (#1253, + /// #3184). #[test] - fn read_audio_counts_in_the_footage_salt() { - use effectcraft_project::build; - use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, MatteKind, Project, TrackMatte}; - use effectcraft_time::{FrameRate, Tick}; - - let mut p = Project::default(); - let f = Footage { path: "/song.wav".into(), kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }; - let song = p.add_item("song.wav", effectcraft_color::Label::None, None, ItemKind::Footage(f)); - let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); - let music = build::layer(&mut p, &comp, "music", LayerSource::Footage { item: song }, (16, 9), None); - let mut reader = build::layer(&mut p, &comp, "reader", LayerSource::Null, (16, 9), None); - let music_id = music.id; - comp.layers = vec![reader.clone(), music].into(); - let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); - - let id = |mark: u64| move |path: &str| (path == "/song.wav").then_some((8, mark)); - let (one, two) = (id(1), id(2)); - assert_eq!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "unread music stays out"); - reader.track_matte = Some(TrackMatte { layer: music_id, kind: MatteKind::Alpha }); - *p.comp_mut(cid).unwrap().layers.first_mut().unwrap() = reader; - assert_ne!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "read audio counts"); - } - - /// 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() { + fn virtual_file_bytes_are_checked_by_the_entries_not_the_keys() { use effectcraft_project::build; use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project}; use effectcraft_time::{FrameRate, Tick}; @@ -1650,12 +2312,335 @@ mod tests { 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)); + let dc = DiskCache::open(tmpdir("virtual"), 1 << 30).unwrap(); + let k = frame_key(comp_content_key(&p, cid), 0, 1000, 0); + let files: ReadFiles = [crate::reads::ReadFile::new("/virtual.mov", crate::reads::FileStamp::Virtual)].into_iter().collect(); + assert!(dc.put_frame(k, 1, 1, &[1, 2, 3, 4], &files, Some(&one), 0)); + dc.flush(); + assert!(dc.get_frame(k, Some(&one)).is_some_and(|(_, f)| f == files)); + assert!(dc.get_frame(k, Some(&two)).is_none() && !dc.contains(Kind::Frame, k), "replaced: a miss, deleted"); + // A layer buffer made of it. + let lk = layer_disk_key(7); + dc.put_layer(lk, &Arc::new(buf(1, 4, 4)), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + dc.flush(); + assert!(dc.get_layer(lk, Some(&one)).is_some_and(|(b, f)| b.img == buf(1, 4, 4).img && f == files && f.is_complete())); + assert!(dc.get_layer(lk, None).is_none() && !dc.contains(Kind::Layer, lk), "no identity: a miss, deleted"); + dc.put_layer(lk, &Arc::new(buf(1, 4, 4)), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + dc.flush(); + assert!(dc.get_layer(lk, Some(&two)).is_none() && !dc.contains(Kind::Layer, lk), "replaced: a miss, deleted"); + // The browser's sealed layer entries carry the same list. + let e = layer_entry(&buf(2, 3, 2), &EntryFiles::of(&files, Some(&one)).unwrap()); + let (b, list) = read_layer_entry(&e).unwrap(); + assert!(b.img == buf(2, 3, 2).img && list.unchanged(Some(&one)) && !list.unchanged(Some(&two))); + assert!(read_layer_entry(&seal_format(Kind::Layer, FORMAT_RGBA8, &encode_layer(&buf(2, 3, 2)))).is_none(), "the format before #3184"); + let mut bad = e.clone(); + bad[12] ^= 1; + assert!(read_layer_entry(&bad).is_none(), "damaged"); + } + + /// Layer disk keys come from the layer cache's key alone (no file, no project-wide salt) and + /// differ per key. + #[test] + fn layer_disk_keys_need_no_files() { + assert_eq!(layer_disk_key(1), layer_disk_key(1)); + assert_ne!(layer_disk_key(1), layer_disk_key(2)); + // Not the layout of the salted keys before #3184 (`salt << 64 | key`). + assert_ne!(layer_disk_key(1) as u64, 1); + } + + /// A layer entry is used only while the files it lists are what the buffer was made of: a + /// new size, a new time, a missing file that is back, a file replaced by a folder, a damaged + /// files section and the format before #3184 are misses, and the entry is deleted so the + /// buffer is rendered and stored again. A buffer whose files can't all be named is not + /// stored. Only the listed files are looked at. + #[test] + fn layer_entries_check_their_files() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("layer-check"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let gone = src.join("gone.png").to_string_lossy().to_string(); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap_or(FileStamp::Missing))).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_layer_now(1, &buf(1, 4, 4), &read(&[&a])); + dc.put_layer_now(2, &buf(2, 4, 4), &read(&[&b, &gone])); + dc.put_layer_now(3, &buf(3, 4, 4), &read(&[&b])); + let (one, files) = dc.get_layer(1, None).unwrap(); + assert!(one.img == buf(1, 4, 4).img && files == read(&[&a]) && files.is_complete()); + assert_eq!(dc.file_checks(), 1, "only the file of that buffer"); + // a.png gets a new size: entry 1 is a miss and deleted, the others stay. + std::fs::write(&a, b"aaa").unwrap(); + assert!(dc.get_layer(1, None).is_none() && !dc.contains(Kind::Layer, 1)); + assert!(dc.get_layer(2, None).is_some() && dc.get_layer(3, None).is_some()); + // b.png gets a new time only. + let t = std::fs::metadata(&b).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(&b).unwrap().set_modified(t).unwrap(); + assert!(dc.get_layer(3, None).is_none() && !dc.contains(Kind::Layer, 3)); + assert!(dc.get_layer(2, None).is_none(), "b.png is listed there too"); + // A missing file that is back. + dc.put_layer_now(4, &buf(4, 4, 4), &read(&[&gone])); + assert!(dc.get_layer(4, None).is_some(), "missing, still missing"); + file("gone.png", b"g"); + assert!(dc.get_layer(4, None).is_none() && !dc.contains(Kind::Layer, 4), "back"); + // A file replaced by a folder (unreadable). + let c = file("c.png", b"cc"); + dc.put_layer_now(5, &buf(5, 4, 4), &read(&[&c])); + std::fs::remove_file(&c).unwrap(); + std::fs::create_dir(&c).unwrap(); + assert!(dc.get_layer(5, None).is_none() && !dc.contains(Kind::Layer, 5)); + // Written again after the miss: a hit with the new identity. + dc.put_layer_now(1, &buf(9, 4, 4), &read(&[&a])); + assert_eq!(dc.get_layer(1, None).map(|l| l.0.img), Some(buf(9, 4, 4).img)); + // The format before #3184 (no files): a miss, deleted. + dc.write_format(Kind::Layer, 6, FORMAT_RGBA8, &encode_layer(&buf(6, 4, 4))); + assert!(dc.contains(Kind::Layer, 6)); + assert!(dc.get_layer(6, None).is_none() && !dc.contains(Kind::Layer, 6)); + // A valid checksum over a damaged files section, or over a cut buffer: misses, deleted. + let mut payload = vec![]; + payload.extend_from_slice(&3u32.to_le_bytes()); + payload.extend_from_slice(&encode_layer(&buf(7, 4, 4))); + dc.write_format(Kind::Layer, 7, FORMAT_LAYER_FILES, &payload); + assert!(dc.get_layer(7, None).is_none() && !dc.contains(Kind::Layer, 7)); + let mut cut = vec![]; + EntryFiles::default().encode(&mut cut); + cut.extend_from_slice(&encode_layer(&buf(8, 4, 4))[..40]); + dc.write_format(Kind::Layer, 8, FORMAT_LAYER_FILES, &cut); + assert!(dc.get_layer(8, None).is_none() && !dc.contains(Kind::Layer, 8)); + // A damaged file on disk. + dc.put_layer_now(9, &buf(9, 4, 4), &ReadFiles::default()); + let p = dc.path(Kind::Layer, 9); + let mut d = std::fs::read(&p).unwrap(); + let n = d.len(); + d[n / 2] ^= 0xff; + std::fs::write(&p, d).unwrap(); + assert!(dc.get_layer(9, None).is_none() && !dc.contains(Kind::Layer, 9) && !p.exists()); + // Buffers whose files can't all be named are not stored. + let mut incomplete = read(&[&a]); + incomplete.mark_incomplete(); + dc.put_layer_now(10, &buf(10, 4, 4), &incomplete); + assert!(!dc.contains(Kind::Layer, 10)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A frame entry is used only while the files it lists are what it was made of: a new size, + /// a new time, a missing file that is back, a file replaced by a folder are misses, and the + /// entry is deleted so the frame is rendered and stored again. Only the listed files are + /// looked at. Entries of the format before #3184 are misses and deleted. + #[test] + fn frame_entries_check_their_files() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("check"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let gone = src.join("gone.png").to_string_lossy().to_string(); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap_or(FileStamp::Missing))).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&[&a])); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&[&b, &gone])); + dc.put_frame_now(3, 1, 1, &[3; 4], &read(&[&b])); + assert_eq!(dc.get_frame(1, None).map(|f| f.1), Some(read(&[&a]))); + assert_eq!(dc.file_checks(), 1, "only the file of that frame"); + // a.png gets a new size: frame 1 is a miss and deleted, the others stay. + std::fs::write(&a, b"aaa").unwrap(); + assert!(dc.get_frame(1, None).is_none() && !dc.contains(Kind::Frame, 1)); + assert!(dc.get_frame(2, None).is_some() && dc.get_frame(3, None).is_some()); + // b.png gets a new time only. + let t = std::fs::metadata(&b).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(&b).unwrap().set_modified(t).unwrap(); + assert!(dc.get_frame(3, None).is_none() && !dc.contains(Kind::Frame, 3)); + assert!(dc.get_frame(2, None).is_none(), "b.png is listed there too"); + // A missing file that is back. + dc.put_frame_now(4, 1, 1, &[4; 4], &read(&[&gone])); + assert!(dc.get_frame(4, None).is_some(), "missing, still missing"); + file("gone.png", b"g"); + assert!(dc.get_frame(4, None).is_none() && !dc.contains(Kind::Frame, 4), "back"); + // A file replaced by a folder (unreadable). + let c = file("c.png", b"cc"); + dc.put_frame_now(5, 1, 1, &[5; 4], &read(&[&c])); + std::fs::remove_file(&c).unwrap(); + std::fs::create_dir(&c).unwrap(); + assert!(dc.get_frame(5, None).is_none() && !dc.contains(Kind::Frame, 5)); + // Written again after the miss: a hit with the new identity. + let fresh = file("d.png", b"dd"); + dc.put_frame_now(1, 1, 1, &[9; 4], &read(&[&fresh])); + assert_eq!(dc.get_frame(1, None).map(|f| f.0.rgba), Some(vec![9; 4])); + // The format before #3184 (no files): a miss, deleted. + dc.write_format(Kind::Frame, 6, FORMAT_RGBA8, &encode_frame(1, 1, &[6; 4])); + assert!(dc.contains(Kind::Frame, 6)); + assert!(dc.get_frame(6, None).is_none() && !dc.contains(Kind::Frame, 6)); + // A valid checksum over a damaged files section: a miss, deleted. + let mut payload = vec![]; + payload.extend_from_slice(&3u32.to_le_bytes()); + payload.extend_from_slice(&encode_frame(1, 1, &[7; 4])); + dc.write_format(Kind::Frame, 7, FORMAT_RGBA8_FILES, &payload); + assert!(dc.get_frame(7, None).is_none() && !dc.contains(Kind::Frame, 7)); + // Frames whose files can't all be named are not stored. + let mut incomplete = read(&[&fresh]); + incomplete.mark_incomplete(); + assert!(!dc.put_frame(8, 1, 1, &[8; 4], &incomplete, None, 0)); + dc.flush(); + assert!(!dc.contains(Kind::Frame, 8)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Reload Footage drops the entries made of the reloaded files (#3184): a file replaced by + /// one with the same size and modification time passes the check on hit, so only the drop + /// makes the next use a miss. Entries of other files stay; an entry written after the drop + /// is used; a layer entry and one written while the drop waits in the queue are dropped too; + /// the drop survives a restart (the files are gone). + #[test] + fn dropped_files_take_their_entries_with_them() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap())).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&[&a])); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&[&b])); + dc.put_frame_now(3, 1, 1, &[3; 4], &read(&[&a, &b])); + dc.put_layer_now(4, &buf(4, 2, 2), &read(&[&a])); + // a.png gets other bytes of the same size and its old time back: the check can't tell. + let t = std::fs::metadata(&a).unwrap().modified().unwrap(); + std::fs::write(&a, b"AA").unwrap(); + std::fs::File::options().append(true).open(&a).unwrap().set_modified(t).unwrap(); + assert!(dc.get_frame(1, None).is_some(), "same size and time: still a hit"); + // A frame queued (not written yet) when the drop comes is dropped too. + assert!(dc.put_frame(5, 1, 1, &[5; 4], &read(&[&a]), None, 0)); + dc.drop_files([a.clone()]); + dc.flush(); + assert!(!dc.is_dropping()); + for (kind, key) in [(Kind::Frame, 1), (Kind::Frame, 3), (Kind::Layer, 4), (Kind::Frame, 5)] { + assert!(!dc.contains(kind, key), "{kind:?} {key} made of a.png is gone"); + } + assert!(dc.get_frame(1, None).is_none() && dc.get_layer(4, None).is_none()); + assert_eq!(dc.get_frame(2, None).map(|f| f.0.rgba), Some(vec![2; 4]), "b.png's frame stays"); + // Written again from the reloaded file: used. + dc.put_frame_now(1, 1, 1, &[9; 4], &read(&[&a])); + assert_eq!(dc.get_frame(1, None).map(|f| f.0.rgba), Some(vec![9; 4])); + // The drop is for good: a new session on the folder finds what is left. + drop(dc); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(dc.keys(Kind::Frame).into_iter().collect::>(), [1, 2].into()); + assert!(dc.keys(Kind::Layer).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A render under way when Reload Footage drops its files offers its entries after the drop + /// (#3184), when the writer has already deleted the old ones: made of the files as they were, + /// they are refused, frames and layer buffers alike. What the same render made of other files + /// is stored, and so is what a render that started after the drop makes. + #[test] + fn entries_made_before_a_drop_are_refused_after_it() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-in-flight"); + std::fs::create_dir_all(&dir).unwrap(); + let file = |name: &str| { + let p = dir.join(name); + std::fs::write(&p, name.as_bytes()).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png"), file("b.png")); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap())).collect() }; + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + // The render starts (and reads a.png and b.png), then a.png is reloaded. + let before = dc.drop_generation(); + dc.drop_files([a.clone()]); + dc.flush(); + assert!(!dc.is_dropping()); + assert!(dc.dropped_since(&read(&[&a]), before) && !dc.dropped_since(&read(&[&b]), before)); + assert!(!dc.put_frame(1, 1, 1, &[1; 4], &read(&[&a, &b]), None, before), "a frame from before the drop"); + assert!(!dc.put_frame_f16(2, 1, 1, &[1; 4], &read(&[&a]), None, before)); + dc.put_layer(3, &Arc::new(buf(3, 2, 2)), &EntryFiles::of(&read(&[&a]), None).unwrap(), before); + assert!(dc.put_frame(4, 1, 1, &[4; 4], &read(&[&b]), None, before), "b.png wasn't dropped"); + dc.flush(); + assert!(!dc.contains(Kind::Frame, 1) && !dc.contains(Kind::Frame, 2) && !dc.contains(Kind::Layer, 3)); + assert!(dc.get_frame(1, None).is_none() && dc.get_layer(3, None).is_none()); + assert_eq!(dc.get_frame(4, None).map(|f| f.0.rgba), Some(vec![4; 4])); + // A render that starts after the drop reads the reloaded file: stored and served. + let after = dc.drop_generation(); + assert!(after > before && !dc.dropped_since(&read(&[&a]), after)); + assert!(dc.put_frame(5, 1, 1, &[5; 4], &read(&[&a]), None, after)); + dc.put_layer(6, &Arc::new(buf(6, 2, 2)), &EntryFiles::of(&read(&[&a]), None).unwrap(), after); + dc.flush(); + assert_eq!(dc.get_frame(5, None).map(|f| f.0.rgba), Some(vec![5; 4])); + assert!(dc.get_layer(6, None).is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The record of dropped files is bounded: past it, everything made before the drop that + /// forgot the record is refused, and what is made after it is stored. + #[test] + fn the_record_of_dropped_files_is_bounded() { + let mut d = Drops::default(); + let a: Arc = "/a.png".into(); + d.record(std::slice::from_ref(&a)); + assert!(d.stale([&a].into_iter(), 0) && !d.stale([&a].into_iter(), 1)); + let many: Vec> = (0..=MAX_DROPPED_PATHS).map(|i| format!("/f{i}").into()).collect(); + d.record(&many); + assert!(d.last.is_empty(), "forgotten, not grown"); + let other: Arc = "/other.png".into(); + assert!(d.stale([&other].into_iter(), 1), "made before the drop that forgot the record"); + assert!(!d.stale([&a].into_iter(), 2), "made after it"); + } + + /// While a drop waits, an entry made of a dropped file is a miss (and deleted), and frames + /// aren't linked to new keys. + #[test] + fn entries_of_a_pending_drop_are_misses() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-pending"); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.png"); + std::fs::write(&a, b"aa").unwrap(); + let a = a.to_string_lossy().to_string(); + let files: ReadFiles = [ReadFile::new(a.as_str(), FileStamp::native(&a).unwrap())].into_iter().collect(); + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &files); + dc.dropping.lock().unwrap().insert(a.as_str().into(), 1); + assert!(!dc.link_frame(1, 2)); + assert!(dc.get_frame(1, None).is_none() && !dc.contains(Kind::Frame, 1)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An entry read while its file's drop was pending is a miss even when the writer ends the + /// sweep between the read and the check (it deleted the file, but its bytes were read), and + /// so is one read before a drop that starts meanwhile. Found under load: a stale hit. + #[test] + fn a_sweep_ending_during_a_read_never_makes_a_hit() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-race"); + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + let files = EntryFiles::of(&[ReadFile::new("/a.png", FileStamp::Missing)].into_iter().collect(), None).unwrap(); + // Read while the drop is pending; the sweep ends before the check. + dc.dropping.lock().unwrap().insert("/a.png".into(), 1); + let drops = dc.drops_now(); + dc.dropping.lock().unwrap().clear(); + assert!(dc.made_of_dropped(&files, &drops)); + // Read before a drop that starts (and ends) before the check. + let drops = dc.drops_now(); + dc.drop_files(["/a.png"]); + dc.flush(); + assert!(dc.made_of_dropped(&files, &drops)); + assert!(!dc.made_of_dropped(&files, &dc.drops_now()), "read after the drop: used"); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/render/src/disk_cache/entry_files.rs b/crates/render/src/disk_cache/entry_files.rs new file mode 100644 index 000000000..a36ca1033 --- /dev/null +++ b/crates/render/src/disk_cache/entry_files.rs @@ -0,0 +1,276 @@ +//! The source files a frame entry on disk was made of (#3184): stored with the pixels, checked +//! when the entry is used. Only the files that frame read are looked at, never every file of the +//! project. +//! +//! Layout (little endian), before the frame's pixel payload: +//! `u32 count`, then per file `u32 path length`, the path (UTF-8), a tag byte and its fields: +//! `0` a file on disk (`u64 size`, `u8 has time`, `i128 time`: nanoseconds since the Unix epoch, +//! negative before it), `1` bytes from the browser's file table (`u64 size`, `u64 content +//! hash`), `2` a file that could not be read. + +use std::sync::Arc; + +use super::VirtualFileId; +use crate::reads::{FileStamp, MAX_READ_FILES, ReadFile, ReadFiles}; + +/// Longest path an entry stores (bytes): a longer one is not a path this app opened. +const MAX_PATH_BYTES: usize = 1 << 16; + +/// A file's identity as a frame entry keeps it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryStamp { + /// Size and modification time (nanoseconds since the Unix epoch, negative before it) on + /// disk. + Native { len: u64, modified: Option }, + /// Size and content hash from the [`VirtualFileId`] hook (the browser's file table). + Virtual { len: u64, hash: u64 }, + /// It could not be read when the frame was made: it still must not be readable. + Missing, +} + +/// One file of a frame entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EntryFile { + pub path: Arc, + pub stamp: EntryStamp, +} + +/// Every file a frame entry was made of. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EntryFiles(Vec); + +impl EntryFiles { + /// The list to store for a frame that read `files`: `None` when the frame can't be checked + /// against its files later (the list is incomplete, or a file from memory has no identity), + /// so the frame must not be stored. Native stamps are the ones the files were read with, + /// never a fresh look at the disk (a file changed during the render keeps the old one). + pub fn of(files: &ReadFiles, virtual_id: Option>) -> Option { + if !files.is_complete() { + return None; + } + let mut out = Vec::with_capacity(files.len()); + for f in files.files() { + let stamp = match f.stamp { + FileStamp::Native { len, modified } => EntryStamp::Native { len, modified }, + FileStamp::Virtual => { + let (len, hash) = virtual_id.and_then(|id| id(&f.path))?; + EntryStamp::Virtual { len, hash } + } + FileStamp::Missing => EntryStamp::Missing, + }; + out.push(EntryFile { path: f.path.clone(), stamp }); + } + Some(EntryFiles(out)) + } + + /// The listed files. + pub fn files(&self) -> &[EntryFile] { + &self.0 + } + + /// The number of listed files. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether no files are listed. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The files as a render reports them (to keep with the frame in memory). + pub fn to_read_files(&self) -> ReadFiles { + self.0 + .iter() + .map(|f| { + let stamp = match f.stamp { + EntryStamp::Native { len, modified } => FileStamp::Native { len, modified }, + EntryStamp::Virtual { .. } => FileStamp::Virtual, + EntryStamp::Missing => FileStamp::Missing, + }; + ReadFile::new(f.path.clone(), stamp) + }) + .collect() + } + + /// Whether every file still is what the frame was made of: one `metadata` call per file + /// (and the hook for files from memory). + pub fn unchanged(&self, virtual_id: Option>) -> bool { + self.0.iter().all(|f| file_unchanged(f, virtual_id)) + } + + /// Appends the list to `out` in little-endian binary form. + pub(super) fn encode(&self, out: &mut Vec) { + out.extend_from_slice(&(self.0.len() as u32).to_le_bytes()); + for f in &self.0 { + out.extend_from_slice(&(f.path.len() as u32).to_le_bytes()); + out.extend_from_slice(f.path.as_bytes()); + match f.stamp { + EntryStamp::Native { len, modified } => { + out.push(0); + out.extend_from_slice(&len.to_le_bytes()); + out.push(u8::from(modified.is_some())); + out.extend_from_slice(&modified.unwrap_or(0).to_le_bytes()); + } + EntryStamp::Virtual { len, hash } => { + out.push(1); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&hash.to_le_bytes()); + } + EntryStamp::Missing => out.push(2), + } + } + } + + /// The list at the start of `p` and what follows it; `None` when it is damaged. + pub(super) fn decode(p: &[u8]) -> Option<(EntryFiles, &[u8])> { + let mut r = Reader(p); + let n = r.u32()? as usize; + if n > MAX_READ_FILES { + return None; + } + let mut out = Vec::with_capacity(n); + for _ in 0..n { + let len = r.u32()? as usize; + if len > MAX_PATH_BYTES { + return None; + } + let path: Arc = std::str::from_utf8(r.take(len)?).ok()?.into(); + let stamp = match r.u8()? { + 0 => { + let len = r.u64()?; + let has = r.u8()?; + let t = r.i128()?; + match has { + 0 => EntryStamp::Native { len, modified: None }, + 1 => EntryStamp::Native { len, modified: Some(t) }, + _ => return None, + } + } + 1 => EntryStamp::Virtual { len: r.u64()?, hash: r.u64()? }, + 2 => EntryStamp::Missing, + _ => return None, + }; + out.push(EntryFile { path, stamp }); + } + Some((EntryFiles(out), r.0)) + } +} + +/// Whether the file still matches its stored stamp, by its native stamp or by its in-memory identity. +fn file_unchanged(f: &EntryFile, virtual_id: Option>) -> bool { + let from_memory = || virtual_id.and_then(|id| id(&f.path)); + match f.stamp { + EntryStamp::Native { len, modified } => FileStamp::native(&f.path) == Some(FileStamp::Native { len, modified }), + EntryStamp::Virtual { len, hash } => from_memory() == Some((len, hash)), + // (a folder at the path is still not a file to read) + EntryStamp::Missing => !std::fs::metadata(&*f.path).is_ok_and(|m| m.is_file()) && from_memory().is_none(), + } +} + +/// Reads little-endian fields off the front of a byte slice. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + /// Takes the next `n` bytes, or `None` when fewer remain. + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let head = self.0.get(..n)?; + self.0 = self.0.get(n..)?; + Some(head) + } + /// Reads one byte. + fn u8(&mut self) -> Option { + self.take(1)?.first().copied() + } + /// Reads a little-endian `u32`. + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + /// Reads a little-endian `u64`. + fn u64(&mut self) -> Option { + Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?)) + } + /// Reads a little-endian `i128`. + fn i128(&mut self) -> Option { + Some(i128::from_le_bytes(self.take(16)?.try_into().ok()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A sample list of native, missing and virtual files for the tests. + fn files() -> ReadFiles { + [ + ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(9) }), + ReadFile::new("/b.png", FileStamp::Native { len: 4, modified: None }), + // Before 1970, and past where `u64` nanoseconds end (2554). + ReadFile::new("/d.png", FileStamp::Native { len: 5, modified: Some(-86_400_000_000_000) }), + ReadFile::new("/e.png", FileStamp::Native { len: 6, modified: Some(i128::from(u64::MAX) + 1) }), + ReadFile::new("/c.png", FileStamp::Missing), + ReadFile::new("/v.mov", FileStamp::Virtual), + ] + .into_iter() + .collect() + } + + #[test] + fn lists_round_trip_and_damage_is_caught() { + let id = |p: &str| (p == "/v.mov").then_some((8, 77)); + let list = EntryFiles::of(&files(), Some(&id)).unwrap(); + assert_eq!(list.files()[5].stamp, EntryStamp::Virtual { len: 8, hash: 77 }); + let mut bytes = vec![]; + list.encode(&mut bytes); + bytes.extend_from_slice(b"rest"); + let (back, rest) = EntryFiles::decode(&bytes).unwrap(); + assert_eq!((back, rest), (list.clone(), &b"rest"[..])); + assert_eq!(list.to_read_files(), files()); + // Every truncation is caught (never a panic, never a shorter list). + let mut plain = vec![]; + list.encode(&mut plain); + for n in 0..plain.len() { + assert!(EntryFiles::decode(&plain[..n]).is_none(), "cut at {n}"); + } + // Bad tags and counts. + let mut bad = plain.clone(); + bad[0..4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(EntryFiles::decode(&bad).is_none()); + } + + #[test] + fn frames_that_cant_be_checked_are_not_stored() { + let mut incomplete = files(); + incomplete.mark_incomplete(); + let id = |_: &str| Some((1, 1)); + assert!(EntryFiles::of(&incomplete, Some(&id)).is_none(), "an incomplete list"); + assert!(EntryFiles::of(&files(), None).is_none(), "a file from memory without the hook"); + assert!(EntryFiles::of(&ReadFiles::default(), None).is_some_and(|l| l.is_empty()), "a frame that reads no file"); + } + + #[test] + fn the_check_looks_at_the_listed_files() { + let dir = std::env::temp_dir().join(format!("effectcraft-entry-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("f.bin"); + std::fs::write(&p, b"abc").unwrap(); + let path = p.to_string_lossy().to_string(); + let gone = dir.join("gone.bin").to_string_lossy().to_string(); + let stamp = FileStamp::native(&path).unwrap(); + let read: ReadFiles = [ReadFile::new(path.as_str(), stamp), ReadFile::new(gone.as_str(), FileStamp::Missing)].into_iter().collect(); + let list = EntryFiles::of(&read, None).unwrap(); + assert!(list.unchanged(None)); + std::fs::write(&p, b"abcd").unwrap(); + assert!(!list.unchanged(None), "a new size"); + std::fs::write(&gone, b"x").unwrap(); + let list = EntryFiles::of(&[ReadFile::new(gone.as_str(), FileStamp::Missing)].into_iter().collect(), None).unwrap(); + assert!(!list.unchanged(None), "a missing file that is back"); + let v: ReadFiles = [ReadFile::new("/v", FileStamp::Virtual)].into_iter().collect(); + let one = |_: &str| Some((1, 1)); + let two = |_: &str| Some((1, 2)); + let list = EntryFiles::of(&v, Some(&one)).unwrap(); + assert!(list.unchanged(Some(&one)) && !list.unchanged(Some(&two)) && !list.unchanged(None)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 0a10afa45..a9abefb16 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -44,11 +44,15 @@ pub struct EvalCtx<'a> { /// Media Replacement values overridden by the Essential Properties of a collapsed precomp /// instance. pub media_overrides: &'a [crate::MediaOverride], + /// Where the source files of what expressions render (`sampleImage`) are noted: the + /// renderer's [`Renderer::reads`](crate::Renderer::reads) while rendering. + pub reads: Option<&'a crate::reads::ReadLog>, } impl<'a> EvalCtx<'a> { + /// A context for `comp` at `time` with no expressions, footage, overrides or read log. pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[], reads: None } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } @@ -279,7 +283,7 @@ impl<'a> EvalCtx<'a> { } impl crate::PropertyOverride { - fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value { + pub(crate) fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value { if self.font { // A Font control merges only the font into the target text, as // `essential::with_overrides` does. diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 4c90967d3..c14961d0f 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -1,4 +1,5 @@ use super::{Arc, AtomicBool, Footage, Image, ItemId, ItemKind, Ordering, Tick}; +use crate::reads::ReadLog; /// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are /// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other @@ -36,6 +37,14 @@ pub trait FootageSource: Send + Sync { None => self.frame(item, footage, t), } } + /// [`FootageSource::frame_with`]'s full-size frame as 8-bit levels: each pixel is + /// [`effectcraft_raster::quantize_px_u8`] of the float pixel `frame_with` would give, so + /// [`effectcraft_raster::expand_u8`] makes that frame quantised to 8 bpc, bit for bit, from a + /// quarter of the memory (an 8 bpc render of 8-bit footage). `None` when the source has no + /// cheaper way than the float frame; the renderer then takes that. + fn frame8(&self, _item: ItemId, _footage: &Footage, _t: Tick, _float: FloatCurve) -> Option> { + None + } /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at /// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`; /// a sample boundary uses the earliest representable tick that selects that sample. @@ -75,8 +84,9 @@ pub trait FootageSource: Send + Sync { fn cache_budget(&self) -> Option { None } - /// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize. - /// `None` when the footage is not vector or cannot be read. + /// Vector footage (SVG, PDF, Illustrator, EPS: [`is_vector_footage`]) rasterised at `scale` × + /// its size as [`vector_fit`] draws it, for Continuously Rasterize. `None` when the footage + /// is not vector or cannot be read. fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option> { None } @@ -92,6 +102,15 @@ pub trait FootageSource: Send + Sync { fn called_off(&self) -> bool { false } + /// Run `f`, which reads from this source on this thread, noting in `reads` every source file + /// those reads use, with its identity as read (see [`crate::reads`]): the file a frame was + /// decoded from, also when it comes from the source's own memory cache (without reading the + /// file again). Sources that can't tell which files they read keep the default, which marks + /// `reads` incomplete. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + reads.mark_incomplete(); + f() + } /// Why `footage` shows no picture although its file is there: a read of it failed because /// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged /// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it @@ -194,24 +213,58 @@ impl FootageSource for CancellableFootage<'_> { fn called_off(&self) -> bool { self.cancel.load(Ordering::Relaxed) || self.inner.called_off() } + /// Runs `f` with the inner source, collecting the files it reads into `reads`. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + self.inner.recording(reads, f) + } fn problem(&self, footage: &Footage) -> Option { self.inner.problem(footage) } fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.read(|| self.inner.frame_with(item, footage, t, size, float)) } + fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.read(|| self.inner.frame8(item, footage, t, float)) + } } -/// Footage that can be rasterised at any scale (SVG). +/// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a +/// placed page of an After Effects project (its codec is not stored there). pub fn is_vector_footage(f: &Footage) -> bool { - matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") + matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") || f.placed_page +} + +/// How vector footage `f` is drawn at `scale` × its size: the image size, and the x and y scale +/// from document pixels (`doc` = the document's width and height, `own` = its pixel size, the +/// size rounded up) to image pixels. +/// +/// Footage from an After Effects project ([`Footage::placed_page`]) fills its stored size, which +/// can round or measure the page differently: the layer's anchor and masks are measured against +/// that size, and the renderer resamples a reduced-resolution frame to it, so drawn at its own +/// size the art was scaled by the width ratio alone at full resolution (off against the masks) +/// and stretched to the stored aspect below 3/4 resolution (#3099). Other footage is drawn 1:1 at +/// the document's own size, as it always was. +pub fn vector_fit(f: &Footage, doc: [f64; 2], own: (u32, u32), scale: f64) -> ((u32, u32), [f64; 2]) { + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let side = |px: u32| (f64::from(px) * scale).ceil().clamp(1.0, f64::from(u32::MAX)) as u32; + let size = (f.width, f.height); + let fills = f.placed_page && size != own && size.0 > 0 && size.1 > 0 && doc.iter().all(|d| d.is_finite() && *d > 0.0); + if !fills { + return ((side(own.0), side(own.1)), [scale, scale]); + } + ((side(size.0), side(size.1)), [scale * f64::from(size.0) / doc[0], scale * f64::from(size.1) / doc[1]]) } /// No footage available (renders footage layers as transparent). pub struct NoFootage; impl FootageSource for NoFootage { + /// Always `None`: there is no footage. fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } + /// Reads no files. + fn recording(&self, _: &ReadLog, f: &mut dyn FnMut()) { + f() + } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 65bafe002..3a6f1a34c 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -95,6 +95,7 @@ impl EffectHost for FxHost<'_, '_, '_> { Some(self.pixels(other, buf, false)) } + /// The audio of footage layer `id` from `start`, or `None` when the layer is not footage. fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; @@ -104,7 +105,7 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(item, f, st, frames, rate) + self.r.read(|| self.r.footage.audio(item, f, st, frames, rate)) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index d105c6f13..cfae5dbab 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -34,21 +34,43 @@ impl Shutter { } } +/// `files` (a content's) with the files of the buffers made of it that the layer cache kept: +/// `files` itself when they are all its own. +fn with_kept_files(files: Arc, kept: &[Arc]) -> Arc { + if kept.iter().all(|f| Arc::ptr_eq(f, &files)) { + return files; + } + let mut all = (*files).clone(); + kept.iter().for_each(|f| all.extend(f)); + Arc::new(all) +} + impl<'a> Renderer<'a> { /// The layer with its styles: exterior passes and body (cached per pass). pub(crate) fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option { - let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; + let (content, ckey, files) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; if !styles::active(ctx, layer) { - return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true }); + return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true, files }); } let modes = styles::pass_modes(ctx, layer); let key = ckey.map(|k| cache::styles_key(ctx, layer, k)); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(body) = c.get(k) - { - let passes: Option> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect(); + // (a cached body or pass may have been made of other files than the content: another + // worker's, or one from disk; the styled layer lists the files of every buffer it holds) + if let Some((body, body_files)) = self.cached(key) { + let mut kept_files = vec![body_files]; + let passes: Option> = modes + .iter() + .enumerate() + .map(|(i, m)| { + self.cached(key.map(|k| cache::derive(k, i as u64 + 1))).map(|(b, f)| { + kept_files.push(f); + (b, *m) + }) + }) + .collect(); if let Some(passes) = passes { - return Some(StyledLayer { passes, body, content, key, ckey, plain: false }); + let files = with_kept_files(files, &kept_files); + return Some(StyledLayer { passes, body, content, key, ckey, plain: false, files }); } } let t0 = web_time::Instant::now(); @@ -59,16 +81,23 @@ impl<'a> Renderer<'a> { t.cached = false; t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3)); } - 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) { - 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 }) + // The buffers the cache keeps (another worker's, if it kept them first), made of the + // content's files; the styled layer then lists the files of every buffer it holds. + let mut kept_files = vec![]; + let (body, f) = self.keep(key, Arc::new(st.body), &files); + kept_files.push(f); + let passes: Vec<_> = st + .passes + .into_iter() + .enumerate() + .map(|(i, (b, m))| { + let (b, f) = self.keep(key.map(|k| cache::derive(k, i as u64 + 1)), Arc::new(b), &files); + kept_files.push(f); + (b, m) + }) + .collect(); + let files = with_kept_files(files, &kept_files); + Some(StyledLayer { passes, body, content, key, ckey, plain: false, files }) } /// The styled layer's buffers in the blending space. @@ -76,9 +105,10 @@ impl<'a> Renderer<'a> { if self.pipe.to_blend().is_none() { return st; } - let body = self.to_blend(st.body, st.key); - let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) }; - let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect(); + let body = self.to_blend(st.body, st.key, &st.files); + let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey, &st.files) }; + let passes = + st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1)), &st.files), m)).collect(); StyledLayer { passes, body, content, ..st } } @@ -236,7 +266,11 @@ impl<'a> Renderer<'a> { /// A processed-layer cache key that also covers content motion blur (the averaged source /// depends on the content at every sub-sample). pub(crate) fn content_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { - let k = key?; + let k = self.mask_gate_key(ctx, layer, key)?; + // A layer that reads other layers through its effects' layer parameters gets them drawn + // by this renderer: the switches it passes down (Switches Affect Nested Comps, the + // precomp layers above, motion blur on) change those pixels, not just the layers' own. + let k = if cache::reads_layers(ctx, layer) { cache::derive(k, self.nested_look()) } else { k }; let Some(times) = self.content_times(ctx, layer) else { return Some(k) }; times.iter().try_fold(cache::derive(k, 0x6d62_6c72), |d, t| Some(cache::derive(d, cache::layer_key(&ctx.at(*t), layer, 1.0, false, false)?))) } @@ -257,6 +291,23 @@ impl<'a> Renderer<'a> { } } + /// A cache key of a masked layer's buffer, told apart when mask motion blur can't run at all + /// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above). + /// The key has the layer's own motion blur state, but a mask set to On blurs without it. + pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + // Queue jobs share this cache but may override Proxy Use differently. The item's + // proxy switch alone does not describe the source a renderer actually reads. + let k = key?; + let k = match self.opts.proxy { + effectcraft_project::render_queue::ProxyUse::CurrentSettings => k, + effectcraft_project::render_queue::ProxyUse::UseAll => cache::derive(k, 0x7072_6f78_7901), + effectcraft_project::render_queue::ProxyUse::UseCompOnly => cache::derive(k, 0x7072_6f78_7902), + effectcraft_project::render_queue::ProxyUse::UseNone => cache::derive(k, 0x7072_6f78_7903), + }; + let masked = layer.masks().is_some_and(|m| !m.children.is_empty()); + Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k }) + } + /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D /// layer gets about one sample per output pixel its corners travel during the shutter, /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that @@ -582,6 +633,8 @@ pub struct StyledLayer { ckey: Option, /// No layer styles: composite `body` the usual way. pub plain: bool, + /// The source files the layer's buffers were made of (see [`crate::reads`]). + pub(crate) files: Arc, } /// Where a layer buffer lands (see [`Renderer::placement`]). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dbd948320..f47443b18 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,6 +17,7 @@ use color::Region; pub mod eval; pub mod masks; pub mod passes; +pub mod reads; pub mod shapes; pub mod styles; pub mod text; @@ -25,7 +26,7 @@ pub mod three_d; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -pub use auto::{AutoKey, AutoPick}; +pub use auto::{AutoKey, AutoPick, AutoTicket}; pub use cache::{CacheStats, LayerCache, LayerStore, PrefetchStore}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; @@ -45,8 +46,8 @@ mod layers; mod opts; mod pipeline; -pub use accel::{Accelerator, Backend, FxStep, FxTarget}; -pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem}; +pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget}; +pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem, vector_fit}; pub use frames::{ adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, roi_inside, @@ -60,7 +61,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; -pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; +pub use pipeline::{Collapsed, FootagePicture, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, @@ -69,6 +70,10 @@ pub struct Renderer<'a> { pub opts: RenderOpts, /// Processed-layer cache shared across frames (see [`cache`]). `None` renders everything. pub cache: Option<&'a LayerCache>, + /// Where the source files this render reads are noted (see [`reads`]): the frame's own, + /// from the footage source and from the buffers the layer cache serves. `None` records + /// nothing for the frame (buffers kept in the layer cache still record theirs). + pub reads: Option<&'a reads::ReadLog>, /// Optional per-layer timing sink (`frame --bench` / perf readouts). pub profile: Option<&'a std::sync::Mutex>>, /// GPU backend used when [`RenderOpts::backend`] asks for it. @@ -97,6 +102,8 @@ pub struct Renderer<'a> { through: [Option>; MAX_THROUGH], /// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred. through_lost: bool, + /// Layer-space consumers must not use the viewport raster optimization. + pub(crate) whole_raster: bool, } #[cfg(test)] diff --git a/crates/render/src/passes.rs b/crates/render/src/passes.rs index 37c94fff4..b8f94fc46 100644 --- a/crates/render/src/passes.rs +++ b/crates/render/src/passes.rs @@ -86,10 +86,10 @@ pub async fn comp_frame(r: &Renderer<'_>, comp: ItemId, t: Tick) -> Image { } else if opts.roi.is_none() && let Some(pick) = a.auto_pick() { - let key = AutoKey::new(comp, opts.scale, false); - let gpu = pick.choose(key); - opts.backend = if gpu { Backend::Gpu } else { Backend::Cpu }; - auto = Some((key, gpu)); + // (a frame cancelled or panicking between passes drops it: ended unmeasured) + let ticket = pick.ticket(AutoKey::new(comp, opts.scale, false)); + opts.backend = if ticket.gpu() { Backend::Gpu } else { Backend::Cpu }; + auto = Some(ticket); } } let t0 = web_time::Instant::now(); @@ -98,8 +98,8 @@ pub async fn comp_frame(r: &Renderer<'_>, comp: ItemId, t: Tick) -> Image { } else { in_passes(Some(a), |acc| Renderer { opts, accel: acc, ..*r }.comp_frame(comp, t)).await.value }; - if let (Some((key, chose)), Some(pick)) = (auto, a.auto_pick()) { - pick.record(key, chose, t0.elapsed().as_secs_f64() * 1e3); + if let Some(t) = auto { + t.record(t0.elapsed().as_secs_f64() * 1e3); } img } diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index e84348fdf..c0f4c8dd9 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -3,9 +3,11 @@ use super::footage::{FootageSource, is_vector_footage}; use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; +use super::reads::{ReadFiles, ReadLog}; use super::{ - Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, - ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, + Arc, AutoKey, AutoPick, AutoTicket, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, + ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, + vec2, }; use effectcraft_keyframe::Value; use effectcraft_project::LayerId; @@ -18,6 +20,24 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A footage layer's source as the decoded picture itself (see [`Renderer::footage_picture`]): +/// its buffer in layer space is `img` at offset `[0, 0]` and `scale`; in effect space (what +/// [`Renderer::layer_input`] gives) at `effect_offset`. +#[derive(Clone, Debug)] +pub struct FootagePicture { + pub img: Arc, + pub scale: f64, + pub effect_offset: [f64; 2], +} + +/// How a decoded footage frame becomes a layer buffer ([`Renderer::footage_buf`]). +enum FootageLayout { + /// Its own pixels, at this buffer scale. + Native { scale: f64 }, + /// Resampled to `width`×`height` at this buffer scale. + Resample { width: u32, height: u32, scale: f64 }, +} + /// A property value an Essential Properties override gives a layer of a collapsed precomp. #[derive(Clone, Debug)] pub struct PropertyOverride { @@ -36,6 +56,29 @@ pub struct MediaOverride { pub(crate) item: ItemId, } +/// The most pixels a solid layer's buffer holds (as the other layer buffers are held to). +const MAX_SOLID_PIXELS: f64 = 16.0e6; + +/// The scale and whole-pixel size of a `width`×`height` solid rendered at `scale`, lowered until +/// the rounded size (each side at least one pixel) is within [`MAX_SOLID_PIXELS`]. +pub(crate) fn solid_size(width: u32, height: u32, mut scale: f64) -> (f64, u32, u32) { + let size = |s: f64| (((width as f64 * s).ceil() as u32).max(1), ((height as f64 * s).ceil() as u32).max(1)); + // Rounding up leaves an elongated solid over the cap after one step; each step shrinks the + // overshoot geometrically, so a few are enough. + for i in 0..64 { + let (w, h) = size(scale); + let area = w as f64 * h as f64; + if area <= MAX_SOLID_PIXELS { + break; + } + // The first step is the exact area ratio; later ones undershoot a little so rounding up + // cannot leave the size stuck just over the cap. + scale *= (MAX_SOLID_PIXELS / area).sqrt() * if i == 0 { 1.0 } else { 0.99 }; + } + let (w, h) = size(scale); + (scale, w, h) +} + impl MediaOverride { /// A Media Replacement of `layer`'s footage by `item`. pub fn new(layer: LayerId, item: ItemId) -> Self { @@ -101,6 +144,7 @@ fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec } impl<'a> Renderer<'a> { + /// A renderer over `project` and `footage` with the given options. pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { project, @@ -108,6 +152,7 @@ impl<'a> Renderer<'a> { expr: None, opts, cache: None, + reads: None, profile: None, accel: None, depth: 0, @@ -120,6 +165,7 @@ impl<'a> Renderer<'a> { top: true, through: [None; MAX_THROUGH], through_lost: false, + whole_raster: false, } } @@ -129,6 +175,67 @@ impl<'a> Renderer<'a> { self.cache.filter(|_| !self.footage.called_off()) } + /// `read`, a read of this render's footage source, with the files it uses noted in + /// [`Renderer::reads`] (see [`FootageSource::recording`]). + pub(crate) fn read(&self, read: impl FnOnce() -> Option) -> Option { + let Some(log) = self.reads else { return read() }; + let (mut read, mut out) = (Some(read), None); + self.footage.recording(log, &mut || out = read.take().and_then(|f| f())); + out + } + + /// `ctx` noting what it reads (fonts, `sampleImage`) in this render's [`Renderer::reads`]: + /// a context made by an outer render carries that render's log, not the one of the buffer + /// [`Renderer::recorded`] makes. + pub(crate) fn with_reads<'c>(&'c self, ctx: &EvalCtx<'c>) -> EvalCtx<'c> { + EvalCtx { reads: self.reads, ..*ctx } + } + + /// The buffer the layer cache keeps under `key`, with the files it was made of noted in + /// [`Renderer::reads`]. + pub(crate) fn cached(&self, key: Option) -> Option<(Arc, Arc)> { + let (buf, files) = self.cache?.get(key?)?; + if let Some(log) = self.reads { + log.note_all(&files); + } + Some((buf, files)) + } + + /// Keep `buf`, made of `files`, under `key` (see [`Renderer::recorded`]): the buffer the cache + /// keeps (another worker's, if it kept one first), or `buf` when nothing is kept, with the + /// files that buffer was made of. Another worker's files are noted in [`Renderer::reads`] + /// too: its pixels are the ones this render uses (a file that changed between the two reads + /// then has two identities, and the list is incomplete). + pub(crate) fn keep(&self, key: Option, buf: Arc, files: &Arc) -> (Arc, Arc) { + let (Some(c), Some(k)) = (self.cache_to_keep(), key) else { return (buf, files.clone()) }; + let (kept, kept_files) = c.insert(k, buf, files.clone()); + if !Arc::ptr_eq(&kept_files, files) + && let Some(log) = self.reads + { + log.note_all(&kept_files); + } + (kept, kept_files) + } + + /// Run `make`, which makes a buffer to keep under `key` in the layer cache, on this renderer + /// with its reads noted apart: what `make` returns and the files it read (from the footage + /// source, or the files of cached buffers it used), which go to [`Renderer::reads`] too. A + /// cached buffer then reports the files it was made of whichever render uses it. Without a + /// cache or a key, `make` runs on this renderer as it is (no files: nothing is kept). + pub(crate) fn recorded(&self, key: Option, make: impl FnOnce(&Renderer<'_>) -> T) -> (T, Arc) { + if key.is_none() || self.cache.is_none() { + return (make(self), Arc::default()); + } + // (the generation before any read: a file dropped while `make` runs makes it stale) + let log = self.cache.and_then(LayerCache::drop_generation).map_or_else(ReadLog::new, ReadLog::starting_at); + let out = make(&Renderer { reads: Some(&log), ..*self }); + let files = log.into_files(); + if let Some(own) = self.reads { + own.note_all(&files); + } + (out, Arc::new(files)) + } + /// A layer's Quality switch, limited by the precomp layers above it when /// [`RenderOpts::nested_switches`] is on. pub fn quality(&self, layer: &Layer) -> Quality { @@ -143,6 +250,24 @@ impl<'a> Renderer<'a> { } } + /// What this renderer passes down to the layers it draws beyond their own switches + /// ([`RenderOpts::nested_switches`], the switches inherited from the precomp layers above, + /// motion blur on): part of the cache key of a buffer that reads other layers. + pub(crate) fn nested_look(&self) -> u64 { + let inherited = match self.inherited { + None => 0, + Some((q, mb)) => { + let q = match q { + Quality::Best => 0, + Quality::Draft => 1, + Quality::Wireframe => 2, + }; + 1 + q * 2 + u64::from(mb) + } + }; + 0x6e65_7374_0000_0000 | inherited << 2 | u64::from(self.opts.nested_switches) << 1 | u64::from(self.opts.motion_blur) + } + /// A layer's Motion Blur switch, limited by the precomp layers above it. pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool { layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb) @@ -176,6 +301,8 @@ impl<'a> Renderer<'a> { top: false, through: [None; MAX_THROUGH], through_lost: false, + // Effects and nested renders need whole layer-space pixels. + whole_raster: true, ..*self } } @@ -316,8 +443,9 @@ impl<'a> Renderer<'a> { } let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)); let prev = ctx.time - ctx.comp.frame_rate.frame_duration(); - let now = cache::layer_key(ctx, layer, scale, draft, blur); - now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur) + let before = ctx.at(prev); + let now = self.clipped_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur)); + now.is_some() && now == self.clipped_key(&before, layer, cache::layer_key(&before, layer, scale, draft, blur)) } /// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers, @@ -351,14 +479,73 @@ impl<'a> Renderer<'a> { // a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390: // rounded up to 2^¼, unscaled text was drawn larger and resampled, softer). let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0); - // Keep the buffer within about 16 million pixels. + // Keep the buffer within about 16 million pixels (of the part the output shows, when + // only that is rasterised: see `raster_clip`). if let Some(b) = content_bounds(ctx, layer) { + let b = match self.raster_clip(ctx, layer) { + Some(c) => { + let r = kurbo::Rect::new(b[0], b[1], b[2], b[3]).intersect(c); + [r.x0, r.y0, r.x1, r.y1] + } + None => b, + }; let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s; k = k.min((16.0e6 / area).sqrt().max(1.0)); } s * k } + /// The layer-space part of a 3D text or shape layer the output shows, when only that part + /// needs rasterising (#1388: text close to the camera was rasterised whole at up to 16 + /// million pixels, a quarter gigabyte per frame worker, though the frame showed a fraction + /// of it; behind the camera it was rasterised and not drawn). `None` rasterises it whole: + /// whenever pixels outside the frame can matter — effects, layer styles, masks, motion blur, + /// depth of field, lights (shadows and transmission), Advanced 3D, per-character 3D, a + /// collapsed 2D transform — or the visible part can't be bounded (see + /// [`three_d::compose::visible_clip`]). + pub(crate) fn raster_clip(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + if self.whole_raster || !layer.is_3d() || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) || self.outer.is_some() { + return None; + } + if (layer.switches.effects && layer.effects().is_some_and(|fx| !fx.children.is_empty())) + || layer.masks().is_some_and(|m| !m.children.is_empty()) + || styles::active(ctx, layer) + || ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. })) + || self.is_adv3d(ctx) + || (matches!(layer.source, LayerSource::Text) && (text::per_char_3d(ctx, layer) || text::animator_blur(ctx, layer))) + { + return None; + } + let v = three_d::compose::visible_clip(self, ctx, layer)?; + if v.area() <= 0.0 { + // Behind the camera. + return Some(v); + } + let b = content_bounds(ctx, layer).map(|b| kurbo::Rect::new(b[0], b[1], b[2], b[3]))?; + if v.x0 <= b.x0 && v.y0 <= b.y0 && v.x1 >= b.x1 && v.y1 >= b.y1 { + return None; + } + // Whole cells of a layer-space grid whose cell is about a quarter of the part that shows, + // rounded down to a power of two: a moving camera keeps one raster (and its cache entry) + // while that part stays within the same cells, and a dolly changes the grid only at each + // doubling. The cells cover at most about 2.25 times the part that shows. Worth it only + // when it leaves out at least half of the content. + let c = 2f64.powf(((v.width().max(v.height()) / 4.0).max(1e-6)).log2().floor()); + let snap = |v: f64, up: bool| if up { (v / c).ceil() * c } else { (v / c).floor() * c }; + // (Not cut to the content bounds: a stroke or a glyph's padding reaches past them.) + let cells = kurbo::Rect::new(snap(v.x0, false), snap(v.y0, false), snap(v.x1, true), snap(v.y1, true)); + (cells.is_finite() && cells.intersect(b).area() <= b.area() * 0.5).then_some(cells) + } + + /// A layer content cache key that also names the part rasterised ([`Self::raster_clip`]). + pub(crate) fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + let k = key?; + Some(match self.raster_clip(ctx, layer) { + Some(c) => raster_clip_key(k, c), + None => k, + }) + } + /// Draw a Wireframe-quality layer: its bounds as a one-pixel outline. pub(crate) fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) { @@ -366,8 +553,19 @@ impl<'a> Renderer<'a> { } } + /// The evaluation context of `comp` at `t`, carrying this renderer's expressions, footage and read log. pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } + EvalCtx { + project: self.project, + comp_id, + comp, + time: t, + expr: self.expr, + footage: Some(self.footage), + property_overrides: &[], + media_overrides: &[], + reads: self.reads, + } } /// This renderer with another accelerator (or none): the passes of @@ -389,32 +587,49 @@ impl<'a> Renderer<'a> { /// Render a composition at comp time `t` (transparent background, comp size × scale). /// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them. pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image { + self.comp_frame_with(comp_id, t, false, |a| Ok::<_, std::convert::Infallible>(a.comp_frame(self, comp_id, t))).unwrap_or_else(|never| match never {}) + } + + /// Render a frame, validating the accelerated attempt before starting any CPU fallback. + /// The caller's scope must run only the accelerator, not this whole method: a GPU fault + /// must release failed resources before a potentially large CPU render (#3357). + /// A scope error returns immediately; the caller can then retry with a fresh CPU renderer + /// without accelerator-produced cache entries. Healthy declines and Auto timing retain + /// the same behavior as [`Self::comp_frame`]. Its fallback disables acceleration for + /// effects too: GPU work must never escape the supplied error scope. + pub fn comp_frame_scoped(&self, comp_id: ItemId, t: Tick, scope: impl FnOnce(&dyn Accelerator) -> Result, E>) -> Result { + self.comp_frame_with(comp_id, t, true, scope) + } + + fn comp_frame_with(&self, comp_id: ItemId, t: Tick, scoped: bool, scope: impl FnOnce(&dyn Accelerator) -> Result, E>) -> Result { + let cpu = if scoped { self.with_accel(None) } else { self.with_accel(self.accel) }; // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well; // Auto renders each comp on whichever compositor measured faster ([`AutoPick`]). if self.depth == 0 && let Some(a) = self.active_accel() { - let auto = self.frame_auto(a, comp_id, false); - if auto.is_none_or(|(p, key)| p.choose(key)) { + // (an error returns with the ticket, which ends the frame unmeasured) + let mut ticket = self.frame_auto(a, comp_id, false).map(|(p, key)| p.ticket(key)); + if ticket.as_ref().is_none_or(AutoTicket::gpu) { let t0 = web_time::Instant::now(); - if let Some(img) = a.comp_frame(self, comp_id, t) { - if let Some((p, key)) = auto { - p.record(key, true, t0.elapsed().as_secs_f64() * 1e3); + if let Some(img) = scope(a)? { + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1e3); } - return img; + return Ok(img); } - if let Some((p, key)) = auto { - p.declined(key); + if let Some(t) = &mut ticket { + t.declined(); } } let t0 = web_time::Instant::now(); - let img = self.comp_frame_cpu(comp_id, t); - if let Some((p, key)) = auto { - p.record(key, false, t0.elapsed().as_secs_f64() * 1e3); + let img = cpu.comp_frame_cpu(comp_id, t); + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1e3); } - return img; + return Ok(img); } - self.comp_frame_cpu(comp_id, t) + Ok(cpu.comp_frame_cpu(comp_id, t)) } /// The Auto timing history and key for a top-level frame of `comp` (`None` unless the @@ -565,6 +780,15 @@ impl<'a> Renderer<'a> { return; } let Some(fx) = layer.effects() else { return }; + // The LUT files the stack reads go to this render's files: a frame on disk made with one + // that changed since is not used (#3493). + if let Some(log) = self.reads { + for g in fx.groups().take(limit).filter(|g| g.enabled) { + for f in super::reads::lut_files(ctx, layer, g) { + log.note(f); + } + } + } let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that @@ -688,12 +912,19 @@ impl<'a> Renderer<'a> { /// Source pixels of a layer in layer space (before masks/effects). pub(crate) fn source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + self.source_with(ctx, layer, false) + } + + /// [`Renderer::source`]; with `quantized`, footage that [`Renderer::prequantized_footage`] + /// counts comes quantised to the project depth. + fn source_with(&self, ctx: &EvalCtx, layer: &Layer, quantized: bool) -> Option { let s = self.opts.scale; match &layer.source { LayerSource::Solid { item } => { let ItemKind::Solid(sol) = &self.project.item(*item)?.kind else { return None }; - let w = ((sol.width as f64 * s).ceil() as u32).max(1); - let h = ((sol.height as f64 * s).ceil() as u32).max(1); + // One colour: a solid larger than MAX_SOLID_PIXELS is made at a lower scale (the + // buffer carries it) instead of allocating gigabytes for a uniform plane. + let (s, w, h) = solid_size(sol.width, sol.height, s); if layer.switches.adjustment { return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s }); } @@ -720,7 +951,7 @@ impl<'a> Renderer<'a> { && let Some(nc) = self.project.comp(*item) { let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?; - return Some(self.footage_buf(&img, pf, nc.width, nc.height)); + return Some(self.footage_buf(&img, pf, nc.width, nc.height, false)); } // Essential Properties overrides render the nested comp with this instance's // values. @@ -760,7 +991,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(item, f, k) { + if let Some(img) = self.read(|| self.footage.vector_frame(item, f, k)) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -768,16 +999,31 @@ impl<'a> Renderer<'a> { return Some(buf); } } - let lt = ctx.source_time(layer); - // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(item); - let pf = proxy.unwrap_or(f); - let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; - Some(self.footage_buf(&img, pf, f.width, f.height)) + // 8 bpc plain footage: made from the footage's 8-bit levels, no float frame. + if quantized && let Some(buf) = self.footage_buf8(ctx, layer, item, self.proxy_for(item).unwrap_or(f), f, ctx.source_time(layer)) { + return Some(buf); + } + let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?; + Some(self.footage_buf(&img, pf, f.width, f.height, quantized && Self::prequantized_footage(layer))) } - LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), - LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))), + // (the font files go to this render's log, which `ctx` may not carry: see + // [`Renderer::with_reads`]) + LayerSource::Text => { + // An Expression Selector evaluates per character: its results at other characters + // than the first are not in the layer's cache key (an amount reading a footage + // item's size by name, `textIndex == 1 ? 0 : footage("ref.png").width`), so the + // buffer is kept in memory only, never on disk (the frame's key covers them). + if cache::per_character_expressions(layer) + && let Some(log) = self.reads + { + log.mark_unkeyed(); + } + Some(self.authored(text::render_in(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))) + } + LayerSource::Shape => layer + .props + .sub("contents") + .map(|c| self.authored(shapes::render_in(ctx, layer, c, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))), _ => None, } } @@ -791,20 +1037,147 @@ impl<'a> Renderer<'a> { /// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's /// nominal size; proxies are scaled up to it), converted from its colour profile. - fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf { + /// + /// `quantized`: also clamp and quantise it to the project depth, as [`Renderer::masked_source`] + /// would (fused with the copy of the cached frame: one pass over it instead of three). + fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32, quantized: bool) -> Buf { + let c = self.pipe.media_in(f); + match self.footage_layout(img, w, h) { + FootageLayout::Native { scale } => { + let img = match (quantized, &c) { + (true, _) => self.pipe.copy_converted(img, c.as_ref()), + (false, Some(c)) => { + let mut img = img.clone(); + color::convert(&mut img, c); + img + } + (false, None) => img.clone(), + }; + Buf { img, offset: [0.0; 2], scale } + } + FootageLayout::Resample { width, height, scale } => { + let mut img = effectcraft_raster::resample(img, width, height); + if let Some(c) = &c { + color::convert(&mut img, c); + } + if quantized { + self.pipe.quantize(&mut img); + } + Buf { img, offset: [0.0; 2], scale } + } + } + } + + /// [`Renderer::footage_buf`]'s quantised buffer made from the footage's 8-bit levels + /// ([`FootageSource::frame8`]), the same pixels to the bit, when nothing before the quantise + /// changes them: an 8 bpc render at Full size of plain footage (no masks, colour profile, + /// Separate Fields, Frame Blending, Cineon, Invert Alpha or linear light interpretation). + /// `None` otherwise, and when the source has no 8-bit frame. + // ponytail: the media pool keeps the 8-bit and the float frame as separate entries, so a + // movie shown by both a plain and a masked layer in one 8 bpc comp is decoded twice per + // frame; derive one from the other in the pool if a comp like that turns out common. + #[allow(clippy::too_many_arguments)] + fn footage_buf8(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, pf: &Footage, f: &Footage, t: Tick) -> Option { + let plain = self.pipe.levels == Some(255.0) + && Self::prequantized_footage(layer) + && self.reduced_size(f.width, f.height).is_none() + && self.pipe.media_in(pf).is_none() + && pf.fields == effectcraft_project::FieldOrder::Off + && !(pf.invert_alpha || pf.linear_light_applies() || pf.cineon_applies()) + && frame_blend_mode(ctx, layer) == FrameBlend::Off; + if !plain { + return None; + } + let fr = self.read(|| self.footage.frame8(item, pf, t, self.pipe.float_curve(pf)))?; + let n = (fr.width as usize).checked_mul(fr.height as usize)?; + let px = fr.rgba.as_chunks::<4>().0; + if px.len() != n { + return None; + } + use rayon::prelude::*; + let data = px.par_iter().map(|&p| effectcraft_raster::expand_u8(p)).collect(); + let k = if fr.width > 0 { f.width.max(1) as f64 / fr.width as f64 } else { 1.0 }; + Some(Buf { img: Image { width: fr.width, height: fr.height, data }, offset: [0.0; 2], scale: 1.0 / k }) + } + + /// The layer's source is footage that [`Renderer::source`] hands over already quantised + /// ([`Renderer::footage_buf`]): footage without masks (masks apply to unquantised pixels). + /// Collapsed layers may be vector footage, drawn elsewhere: not counted. + fn prequantized_footage(layer: &Layer) -> bool { + matches!(layer.source, LayerSource::Footage { .. }) && !layer.switches.collapse && layer.masks().is_none_or(|m| m.groups().next().is_none()) + } + + /// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer + /// pixels: its own pixels, or resampled. + fn footage_layout(&self, img: &Image, w: u32, h: u32) -> FootageLayout { let s = self.opts.scale; let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 }; // Keep native pixels when downsampling is small; resample otherwise. - let mut buf = match self.reduced_size(w, h) { + match self.reduced_size(w, h) { // The source resampled it already (`FootageSource::frame_at_size`). - Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s }, - Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }, - _ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }, - }; - if let Some(c) = self.pipe.media_in(f) { - color::convert(&mut buf.img, &c); + Some(size) if (img.width, img.height) == size => FootageLayout::Native { scale: s }, + Some((width, height)) if k <= 1.0 + 1e-9 => FootageLayout::Resample { width, height, scale: s }, + _ => FootageLayout::Native { scale: 1.0 / k }, } - buf + } + + /// A footage layer's decoded frame at the context time (the proxy's when one is used) and + /// the footage it was decoded from: what [`Renderer::source`] makes the layer's buffer of. + fn footage_picture_of(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &'a Footage) -> Option<(Arc, &'a Footage)> { + let lt = ctx.source_time(layer); + // The proxy is decoded instead, at its own size, and fills the footage's frame. + let proxy = self.proxy_for(item); + let pf = proxy.unwrap_or(f); + let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; + Some((img, pf)) + } + + /// The decoded footage picture a layer's masked source is, bit for bit, with no copy: a + /// footage layer (not continuously rasterised vector footage) without masks whose frame + /// needs no colour conversion, no resampling and no quantisation (32 bpc). The buffer + /// [`Renderer::layer_input`] gives for no effects is this picture at `effect_offset` and + /// `scale`. + /// Layers showing the same frame get the same `Arc` (the footage source's cached frame), so + /// an accelerator uploads it once and shares the texture. `None`: the layer's buffer is + /// made the usual way (any of the above applies, or the frame is missing), and also when the + /// frame is made per call rather than served by the footage source (frame blending, + /// separate fields, Invert Alpha, Interpret As Linear Light, Cineon conversion: a new + /// picture each time, nothing to share) or when the layer cache already holds the layer's + /// buffer (served from memory as before, without asking the footage source). + pub fn footage_picture(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + let LayerSource::Footage { item } = &layer.source else { return None }; + if self.pipe.levels.is_some() || layer.masks().is_some_and(|m| !m.children.is_empty()) || frame_blend_mode(ctx, layer) != FrameBlend::Off { + return None; + } + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + if !f.has_video || (layer.switches.collapse && is_vector_footage(f)) { + return None; + } + let pf = self.proxy_for(item).unwrap_or(f); + if self.pipe.media_in(pf).is_some() + || pf.fields != effectcraft_project::FieldOrder::Off + || pf.invert_alpha + || pf.linear_light_applies() + || pf.cineon_applies() + { + return None; + } + if let Some(cache) = self.cache { + let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)); + let content = self.content_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur)); + let input = self.content_key(ctx, layer, cache::input_key(ctx, layer, scale, draft, blur, 0)); + if [content, input].into_iter().flatten().any(|k| cache.holds(k)) { + return None; + } + } + let (img, _) = self.footage_picture_of(ctx, layer, item, f)?; + let FootageLayout::Native { scale } = self.footage_layout(&img, f.width, f.height) else { return None }; + // `layer_input`'s rebase into effect space (`Buf::rebase` of a buffer at the origin). + let mut at = Buf { img: Image { width: 0, height: 0, data: Vec::new() }, offset: [0.0; 2], scale }; + at.rebase(ctx.effect_bounds(layer).1); + Some(FootagePicture { img, scale, effect_offset: at.offset }) } /// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage @@ -831,7 +1204,7 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); + let img = cineon_frame(self.read(|| self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f)))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; @@ -844,6 +1217,8 @@ impl<'a> Renderer<'a> { if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } + /// The footage frame at source time `t` after frame blending: the average of the source frames one comp frame spans, else a blend of the two neighbouring frames, else the plain frame. + /// Log scans are converted first, and blended frames are cached. fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { let mode = frame_blend_mode(ctx, layer); let float = self.pipe.float_curve(f); @@ -855,6 +1230,9 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); + // Every file of a sequence: the key must tell two projects apart whose items + // share an id and a first frame (on disk, no project-wide salt does, #3184). + f.sequence.hash(&mut h); (f.alpha as u8).hash(&mut h); if f.alpha == effectcraft_project::AlphaMode::Premultiplied { f.premul_color.map(f32::to_bits).hash(&mut h); @@ -865,35 +1243,34 @@ impl<'a> Renderer<'a> { } h.finish() }); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + if let Some((b, _)) = self.cached(key) { return Some(Arc::new(b.img.clone())); } // Log scans are converted before they blend: the conversion is not linear. - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); + let ((a, b), files) = self.recorded(key, |r| { + let frame = |i| r.read(|| r.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float)).map(|a| cineon_frame(a, f)); + (frame(i), frame(i + 1)) + }); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); - } + let _kept = self.keep(key, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }), &files); return Some(Arc::new(img)); } } } - self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) + self.read(|| self.footage.frame_with(item, f, t, size, float)).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { - let mut buf = match self.content_times(ctx, layer) { - Some(times) => self.blurred_source(ctx, layer, ×)?, - None => self.source(ctx, layer)?, + let (mut buf, quantized) = match self.content_times(ctx, layer) { + Some(times) => (self.blurred_source(ctx, layer, ×)?, false), + None => (self.source_with(ctx, layer, true)?, Self::prequantized_footage(layer)), }; - // A solid is filled with a quantised colour: only masks can take it off the grid. + // A solid is filled with a quantised colour, and plain footage comes quantised: only + // masks can take them off the grid. let solid = matches!(layer.source, LayerSource::Solid { .. }); - if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid { + if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !(solid || quantized) { self.pipe.quantize(&mut buf.img); } Some(buf) @@ -902,59 +1279,54 @@ impl<'a> Renderer<'a> { /// Fully processed layer buffer (source → masks → effects → layer styles, flattened). /// Served from the layer cache when the layer's content key matches. pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - self.layer_buf_keyed(ctx, layer).map(|(b, _)| b) + self.layer_buf_keyed(ctx, layer).map(|(b, _, _)| b) } /// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma). pub fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - let (b, k) = self.layer_buf_keyed(ctx, layer)?; - Some(self.to_blend(b, k)) + let (b, k, files) = self.layer_buf_keyed(ctx, layer)?; + Some(self.to_blend(b, k, &files)) } - /// A buffer converted to the blending space (cached under a key derived from `key`). - pub(crate) fn to_blend(&self, b: Arc, key: Option) -> Arc { + /// A buffer converted to the blending space (cached under a key derived from `key`, with + /// `files`, the source files `b` was made of). + pub(crate) fn to_blend(&self, b: Arc, key: Option, files: &Arc) -> Arc { let Some(c) = self.pipe.to_blend() else { return b }; let key = key.map(|k| cache::derive(k, 0x1ea7)); - if let (Some(cache), Some(k)) = (self.cache, key) - && let Some(x) = cache.get(k) - { + if let Some((x, _)) = self.cached(key) { return x; } let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); - let nb = Arc::new(nb); - // 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, - } + self.keep(key, Arc::new(nb), files).0 } - fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { + /// The flattened layer buffer, its cache key and the source files it was made of. + fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option, Arc)> { let st = self.styled(ctx, layer, None)?; if st.passes.is_empty() { - return Some((st.body, st.key)); + return Some((st.body, st.key, st.files)); } let key = st.key.map(|k| cache::derive(k, 0xf1a7)); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { - return Some((b, key)); + if let Some((b, files)) = self.cached(key) { + return Some((b, key, files)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - // 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)) + let (flat, files) = self.keep(key, flat, &st.files); + Some((flat, key, files)) } /// A layer's source pixels at the context time, before masks, effects and the transform /// (what the motion tracker analyses, like After Effects' Layer panel). `None` for layers /// without pixels. pub fn layer_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { - self.source(ctx, layer) + Renderer { whole_raster: true, ..*self }.source(ctx, layer) + } + + /// Layer-space pixels after masks and effects, including content outside the viewport. + /// Expression sampleImage reads this rather than the frame-oriented clipped buffer. + pub fn layer_content(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + Renderer { whole_raster: true, ..*self }.content_buf(ctx, layer) } /// A 3D model or primitive layer rendered on its own, through the comp's camera: what an @@ -983,28 +1355,32 @@ impl<'a> Renderer<'a> { /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) + self.content_buf_timed(ctx, layer, None).map(|(b, _, _)| b) } - pub(crate) fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { + /// [`Self::content_buf`], its cache key and the source files it was made of. + pub(crate) fn content_buf_timed( + &self, + ctx: &EvalCtx, + layer: &Layer, + mut timing: Option<&mut LayerTiming>, + ) -> Option<(Arc, Option, Arc)> { let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); - let key = self.content_key(ctx, layer, key); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key)); + if let Some((b, files)) = self.cached(key) { if let Some(t) = timing.as_deref_mut() { t.cached = true; } - return Some((b, key)); + return Some((b, key, files)); } - 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))); - // 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)) + let (buf, files) = self.recorded(key, |r| { + // (what expressions read while it is made, at shutter times too, goes to its files) + let ctx = &r.with_reads(ctx); + let buf = r.masked_source(ctx, layer)?; + Some(r.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))) + }); + let (buf, files) = self.keep(key, Arc::new(buf?), &files); + Some((buf, key, files)) } /// A layer's input at the context time: source → masks → its first `effects` effects @@ -1012,25 +1388,32 @@ impl<'a> Renderer<'a> { /// what effect `effects` sees. Served from / stored in the layer cache under its own key, /// so scrubbing reuses frames rendered for earlier output frames. pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { + // The Layer panel, analysis tools and effect inputs read the whole layer, not the viewport. + Renderer { whole_raster: true, ..*self }.whole_layer_input(ctx, layer, effects) + } + + fn whole_layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects)); - let key = self.content_key(ctx, layer, key); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key)); + if let Some((b, _)) = self.cached(key) { return Some(b); } - let mut buf = self.masked_source(ctx, layer)?; - let origin = ctx.effect_bounds(layer).1; - buf.rebase(origin); - if effects > 0 { - buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); - } - let buf = Arc::new(buf); - // 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) + let (buf, files) = self.recorded(key, |r| { + let ctx = &r.with_reads(ctx); + let mut buf = r.masked_source(ctx, layer)?; + let origin = ctx.effect_bounds(layer).1; + buf.rebase(origin); + if effects > 0 { + buf = r.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); + } + Some(buf) + }); + Some(self.keep(key, Arc::new(buf?), &files).0) } } + +/// Snap cells can be finer than 1/16 unit: preserve every coordinate bit so visible +/// clips cannot collide with another cell or the empty behind-camera raster. +pub(crate) fn raster_clip_key(key: u64, clip: kurbo::Rect) -> u64 { + [clip.x0, clip.y0, clip.x1, clip.y1].iter().fold(cache::derive(key, 0xc11b), |key, coordinate| cache::derive(key, coordinate.to_bits())) +} diff --git a/crates/render/src/reads.rs b/crates/render/src/reads.rs new file mode 100644 index 000000000..af0535e5a --- /dev/null +++ b/crates/render/src/reads.rs @@ -0,0 +1,386 @@ +//! The source files a render read: which footage files its pixels came from, each with its +//! identity (size and modification time) as it was when it was read. +//! +//! A [`FootageSource`](crate::FootageSource) that knows which files it opens notes them while +//! [`FootageSource::recording`](crate::FootageSource::recording) runs; the renderer collects them +//! in a [`ReadLog`] ([`Renderer::reads`](crate::Renderer::reads)). Buffers in the layer cache keep +//! the files they were made of, so a frame served from memory reports the same files as the +//! render that made it, without touching the disk. +//! +//! The list is what a cache entry on disk has to check before it is used: a handful of files per +//! frame, never every file of every footage item. + +use std::sync::{Arc, Mutex, PoisonError}; + +/// The identity of a file as a render read it (frame and layer entries on disk store it, see +/// [`EntryFiles`](crate::disk_cache::EntryFiles)). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum FileStamp { + /// A file on disk: its size in bytes and its modification time (nanoseconds since the Unix + /// epoch, negative before it; `None` only where the file system has none). + Native { len: u64, modified: Option }, + /// Bytes given from memory instead of the file system (the browser's file table): their + /// identity is the size and content hash a + /// [`VirtualFileId`](crate::disk_cache::VirtualFileId) gives for the path. + Virtual, + /// The file could not be read (missing, not a regular file, no permission). What was made + /// without it changes once it can be read again. + Missing, +} + +impl FileStamp { + /// The identity of a file from metadata the caller already has (of an open file). + pub fn of_metadata(md: &std::fs::Metadata) -> FileStamp { + FileStamp::Native { len: md.len(), modified: md.modified().ok().map(epoch_nanos) } + } + + /// The identity of the file at `path` now: one `metadata` call. `None` when the file system + /// can't describe it (missing, not a regular file, or a path served from memory). + pub fn native(path: &str) -> Option { + let md = std::fs::metadata(path).ok()?; + md.is_file().then(|| FileStamp::of_metadata(&md)) + } +} + +/// `t` in nanoseconds since the Unix epoch, negative before it: every time a file system can +/// store fits (`i128` nanoseconds span far more than any `SystemTime`), saturating otherwise. +pub fn epoch_nanos(t: std::time::SystemTime) -> i128 { + let nanos = |d: std::time::Duration| i128::try_from(d.as_nanos()).unwrap_or(i128::MAX); + match t.duration_since(std::time::UNIX_EPOCH) { + Ok(after) => nanos(after), + Err(before) => nanos(before.duration()).saturating_neg(), + } +} + +/// One file a render read. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ReadFile { + pub path: Arc, + pub stamp: FileStamp, +} + +impl ReadFile { + /// A file read with the given path and stamp. + pub fn new(path: impl Into>, stamp: FileStamp) -> ReadFile { + ReadFile { path: path.into(), stamp } + } +} + +/// The files effect `g` of `layer` reads at the context time (Apply Color LUT, Lumetri's Input +/// LUT and Look, OCIO File Transform, a custom OCIO config and its LUTs: see +/// [`effectcraft_effects::lut_params`]), each with its identity now. A layer's cache key holds them and a render +/// notes them, so what was made with a file that changed since is not used (#3493). +pub(crate) fn lut_files(ctx: &crate::EvalCtx, layer: &effectcraft_project::Layer, g: &effectcraft_project::PropGroup) -> Vec { + let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { return Vec::new() }; + effectcraft_effects::lut_params(effect) + .iter() + .filter_map(|param| match ctx.value(layer, g.prop(param)?) { + effectcraft_keyframe::Value::Str(src) => Some(source_files(&src)), + _ => None, + }) + .flatten() + .collect() +} + +/// The files a file parameter set to `src` reads (an OCIO config's also the LUTs it names, #3538), +/// each with its identity now. +pub(crate) fn source_files(src: &str) -> Vec { + effectcraft_effects::lut_files(src) + .into_iter() + .map(|path| { + let stamp = FileStamp::native(&path).unwrap_or(FileStamp::Missing); + ReadFile::new(path, stamp) + }) + .collect() +} + +/// At most this many files are listed per render or cached buffer; more mark the list +/// incomplete (a comp reading more files than this for one frame is not checked file by file). +pub const MAX_READ_FILES: usize = 4096; + +/// The files a render (or one cached buffer) read, each once, sorted by path. +/// +/// Incomplete when it can't say for sure: a footage source that doesn't tell what it reads, a +/// buffer from a store that doesn't keep its files, more than [`MAX_READ_FILES`] files, or one +/// file read with two identities (it changed during the render). A cache entry whose list is +/// incomplete can't be checked against the files, so it must not outlive the session. +/// +/// It also carries where its reads come from, which equality and hashing ignore: the drop +/// generation the earliest of them started at ([`ReadFiles::since`]), and whether a layer cache +/// key can't capture what it was made of ([`ReadFiles::is_unkeyed`]). +#[derive(Clone, Debug, Default)] +pub struct ReadFiles { + files: Vec, + incomplete: bool, + /// The disk cache's drop generation ([`DiskCache::drop_generation`]) when the earliest read + /// behind the list started; `None` when nothing says (a list built by hand). + /// + /// [`DiskCache::drop_generation`]: crate::disk_cache::DiskCache::drop_generation + since: Option, + /// Made of values a layer's cache key doesn't capture (a text Expression Selector evaluates + /// per character): such a buffer is not stored on disk. + unkeyed: bool, +} + +impl PartialEq for ReadFiles { + /// The same files with the same identities (where the reads come from doesn't count). + fn eq(&self, other: &Self) -> bool { + self.files == other.files && self.incomplete == other.incomplete + } +} + +impl Eq for ReadFiles {} + +impl std::hash::Hash for ReadFiles { + /// Hashes what [`PartialEq`] compares. + fn hash(&self, state: &mut H) { + self.files.hash(state); + self.incomplete.hash(state); + } +} + +impl ReadFiles { + /// Note a file read (once per path). + pub fn add(&mut self, file: ReadFile) { + match self.files.binary_search_by(|f| f.path.cmp(&file.path)) { + Ok(i) => { + if self.files.get(i).is_some_and(|f| f.stamp != file.stamp) { + self.incomplete = true; + } + } + Err(i) => { + if self.files.len() >= MAX_READ_FILES { + self.incomplete = true; + } else { + self.files.insert(i, file); + } + } + } + } + + /// Note every file of `other` (whether it is incomplete or unkeyed, and its generation). + pub fn extend(&mut self, other: &ReadFiles) { + self.incomplete |= other.incomplete; + self.unkeyed |= other.unkeyed; + if let Some(g) = other.since { + self.note_since(g); + } + for f in &other.files { + self.add(f.clone()); + } + } + + /// Some files read may be missing from the list (see [`ReadFiles`]). + pub fn mark_incomplete(&mut self) { + self.incomplete = true; + } + + /// Whether the list names every file read. + pub fn is_complete(&self) -> bool { + !self.incomplete + } + + /// The drop generation the earliest read behind the list started at (`None`: unknown). A + /// buffer made of a file dropped after it holds that file as it was before the drop. + pub fn since(&self) -> Option { + self.since + } + + /// A read behind the list started at drop generation `generation` (the earliest one counts). + pub fn note_since(&mut self, generation: u64) { + self.since = Some(self.since.map_or(generation, |s| s.min(generation))); + } + + /// Made of values a layer cache key doesn't capture (see [`ReadFiles::is_unkeyed`]). + pub fn mark_unkeyed(&mut self) { + self.unkeyed = true; + } + + /// Made of values a layer cache key doesn't capture: kept in memory, never stored on disk as + /// a layer buffer. (A frame's key captures them: frame entries are not affected.) + pub fn is_unkeyed(&self) -> bool { + self.unkeyed + } + + /// The files, sorted by path. + pub fn files(&self) -> &[ReadFile] { + &self.files + } + + /// The identity `path` was read with. + pub fn get(&self, path: &str) -> Option { + let i = self.files.binary_search_by(|f| (*f.path).cmp(path)).ok()?; + self.files.get(i).map(|f| f.stamp) + } + + /// The number of files in the list. + pub fn len(&self) -> usize { + self.files.len() + } + + /// Whether the list holds no files. + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +impl FromIterator for ReadFiles { + /// Collects files into a list, adding each in turn. + fn from_iter>(iter: I) -> Self { + let mut out = ReadFiles::default(); + for f in iter { + out.add(f); + } + out + } +} + +/// Collects the files a render reads, from any thread (the layers of a frame render in +/// parallel). +#[derive(Debug, Default)] +pub struct ReadLog { + files: Mutex, +} + +impl ReadLog { + /// An empty read log. + pub fn new() -> ReadLog { + ReadLog::default() + } + + /// An empty read log for reads that start at drop generation `generation` (see + /// [`ReadFiles::since`]). + pub fn starting_at(generation: u64) -> ReadLog { + let mut files = ReadFiles::default(); + files.note_since(generation); + ReadLog { files: Mutex::new(files) } + } + + /// Locks the file list, recovering from a poisoned lock. + fn lock(&self) -> std::sync::MutexGuard<'_, ReadFiles> { + self.files.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Note a file read. + pub fn note(&self, file: ReadFile) { + self.lock().add(file); + } + + /// Note every file of `files` (and whether it is incomplete). + pub fn note_all(&self, files: &ReadFiles) { + if files.is_empty() && files.is_complete() && files.since.is_none() && !files.unkeyed { + return; + } + self.lock().extend(files); + } + + /// Some files read can't be listed (see [`ReadFiles`]). + pub fn mark_incomplete(&self) { + self.lock().mark_incomplete(); + } + + /// What is read is made of values a layer cache key doesn't capture + /// ([`ReadFiles::is_unkeyed`]). + pub fn mark_unkeyed(&self) { + self.lock().mark_unkeyed(); + } + + /// The files noted so far. + pub fn files(&self) -> ReadFiles { + self.lock().clone() + } + + /// The files noted. + pub fn into_files(self) -> ReadFiles { + self.files.into_inner().unwrap_or_else(PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A native file with the given length for the tests. + fn file(path: &str, len: u64) -> ReadFile { + ReadFile::new(path, FileStamp::Native { len, modified: Some(7) }) + } + + #[test] + fn files_are_listed_once_sorted_by_path() { + let files: ReadFiles = [file("/b", 1), file("/a", 2), file("/b", 1)].into_iter().collect(); + assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/a", "/b"]); + assert!(files.is_complete()); + assert_eq!(files.get("/a"), Some(FileStamp::Native { len: 2, modified: Some(7) })); + assert_eq!(files.get("/c"), None); + } + + /// A file read twice with different identities changed during the render: the list can't + /// vouch for what was made of it. + #[test] + fn a_file_read_with_two_identities_makes_the_list_incomplete() { + let files: ReadFiles = [file("/a", 1), file("/a", 2)].into_iter().collect(); + assert_eq!(files.len(), 1); + assert!(!files.is_complete()); + } + + /// Modification times keep their full value: before 1970 (not "no time") and past 2554 + /// (where `u64` nanoseconds end), so two such files never look alike. + #[test] + fn modification_times_are_kept_whole() { + use std::time::{Duration, UNIX_EPOCH}; + let day = Duration::from_secs(86_400); + assert_eq!(epoch_nanos(UNIX_EPOCH), 0); + assert_eq!(epoch_nanos(UNIX_EPOCH - day), -86_400_000_000_000); + let far = 600 * 365 * day; + if let Some(t) = UNIX_EPOCH.checked_add(far) { + assert_eq!(epoch_nanos(t), i128::try_from(far.as_nanos()).unwrap()); + } + let dir = std::env::temp_dir().join(format!("effectcraft-stamp-1969-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.bin"), dir.join("b.bin")); + let mut stamps = vec![]; + for (p, back) in [(&a, day), (&b, 2 * day)] { + std::fs::write(p, b"abc").unwrap(); + let f = std::fs::File::options().write(true).open(p).unwrap(); + if f.set_modified(UNIX_EPOCH - back).is_err() { + // (a file system that can't store times before 1970) + let _ = std::fs::remove_dir_all(&dir); + return; + } + drop(f); + stamps.push(FileStamp::native(&p.to_string_lossy()).unwrap()); + } + assert!(matches!(stamps[0], FileStamp::Native { modified: Some(t), .. } if t < 0), "{:?}", stamps[0]); + assert_ne!(stamps[0], stamps[1], "two files from before 1970 with the same size"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_directory_is_not_a_native_file_stamp() { + let dir = std::env::temp_dir().join(format!("effectcraft-stamp-directory-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!(FileStamp::native(&dir.to_string_lossy()), None); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_list_is_bounded() { + let files: ReadFiles = (0..MAX_READ_FILES + 3).map(|i| file(&format!("/f{i:06}"), 1)).collect(); + assert_eq!(files.len(), MAX_READ_FILES); + assert!(!files.is_complete(), "files past the bound make the list incomplete, never silently short"); + } + + #[test] + fn incompleteness_carries_over() { + let mut part = ReadFiles::default(); + part.mark_incomplete(); + let log = ReadLog::new(); + log.note(file("/a", 1)); + log.note_all(&part); + let all = log.into_files(); + assert_eq!(all.len(), 1); + assert!(!all.is_complete()); + } +} diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 2497ea807..df92846ce 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -686,6 +686,12 @@ const BATCH_PIXELS: usize = 8 << 20; /// Each draw rasterises and composites only its own pixel rectangle; coverage masks of /// consecutive draws are rasterised in parallel, then composited in stacking order. pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf { + render_in(ctx, layer, contents, s, None) +} + +/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows, +/// see `Renderer::raster_clip`). +pub fn render_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64, clip: Option) -> Buf { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); // Bottom-most draw first. @@ -702,9 +708,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } }; // Keep buffers sane for runaway geometry. let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); - let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); - let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); - let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let Some((w, h, offset)) = crate::text::raster_window(b, s, clip) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } }; let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); let px_rect = |r: kurbo::Rect| -> Option { let x0 = (r.x0 * s + offset[0]).floor().max(0.0); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c77fc2d94..2d0f90ac5 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -11,6 +11,7 @@ mod auto; mod aux_channels; mod collapse; mod color; +mod drop_shadow_cover; mod footage_size; mod frame_blend; mod guides; @@ -20,8 +21,11 @@ mod mask_feather; mod motion_tile; mod nested; mod paint; +mod proxy_cache; +mod raster_clip; mod remap; mod roi; +mod stretch_limits; mod styles; mod time; @@ -328,7 +332,7 @@ fn card_dance_comp_camera_matches_the_default_view() { assert!(img.get(10, 50)[3] < 0.01); } -/// A black 2D solid with CC Particle World, under a camera layer panned right by `pan` px. +/// A black 2D solid with Particle Emitter 3D, under a camera layer panned right by `pan` px. fn particle_world_scene(pan: f64) -> (Project, ItemId) { let (mut p, cid, comp) = setup(); let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); @@ -358,7 +362,7 @@ fn particles_x(img: &crate::Image) -> f32 { sx / m } -/// #397: CC Particle World on a 2D layer is seen through the comp's camera layer, and the layer +/// #397: Particle Emitter 3D on a 2D layer is seen through the comp's camera layer, and the layer /// cache notices the camera moving. #[test] fn particle_world_follows_the_comp_camera() { @@ -373,7 +377,7 @@ fn particle_world_follows_the_comp_camera() { assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}"); } -/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a +/// A black solid with Particle Emitter 3D whose Birth Rate is 3 until layer time `cut`, then 0 a /// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %. fn burst_scene(cut: Option, start: f64, stretch: f64) -> (Project, ItemId) { burst_scene_keys(cut, start, stretch, false) @@ -482,9 +486,148 @@ impl crate::eval::ExprHost for ControllerRate { } } +/// An expression that reads a file (as `sampleImage` does, into [`EvalCtx::reads`]) and keeps +/// the value. +/// +/// [`EvalCtx::reads`]: crate::eval::EvalCtx::reads +struct ReadsAFile; + +impl crate::eval::ExprHost for ReadsAFile { + fn eval(&self, ctx: &crate::eval::EvalCtx, _: &effectcraft_project::Layer, _: &effectcraft_project::Property, value: &Value) -> Result { + if let Some(log) = ctx.reads { + log.note(crate::reads::ReadFile::new("/sampled.png", crate::reads::FileStamp::Native { len: 3, modified: Some(1) })); + } + Ok(value.clone()) + } +} + +/// What expressions read while a cached buffer is made (a mask expression, which mask motion +/// blur also runs at shutter times the cache key doesn't see) goes to that buffer's files, not +/// only to the frame's (#3184): a later render served the buffer lists the file too. Content, +/// a Time effect's input and an adjustment layer's footprint. +#[test] +fn what_expressions_read_goes_to_the_files_of_the_buffer_they_make() { + use crate::reads::ReadLog; + use crate::{NoFootage, RenderOpts, Renderer}; + let (mut p, cid, comp) = setup(); + for adjustment in [false, true] { + let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + l.switches.adjustment = adjustment; + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + l.props.prop_mut("masks/#1/path").unwrap().expr = Some(effectcraft_project::Expression { text: "sampled".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + } + let cache = crate::LayerCache::default(); + let c = p.comp(cid).unwrap(); + let (content, adjustment) = (&c.layers[0], &c.layers[1]); + // `make`, rendered once with the expressions (the files go to `log`) and then served from + // the cache to a render without them: the files that render lists. + let files_served = |what: &str, make: &dyn Fn(&Renderer, &crate::eval::EvalCtx) -> bool| { + let log = ReadLog::new(); + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.expr = Some(&ReadsAFile); + r.cache = Some(&cache); + r.reads = Some(&log); + assert!(make(&r, &r.ctx(cid, c, Tick::ZERO)), "{what}"); + assert!(log.files().get("/sampled.png").is_some(), "{what}: the frame lists the file"); + let hits = cache.stats().hits; + let served = ReadLog::new(); + r.expr = None; + r.reads = Some(&served); + assert!(make(&r, &r.ctx(cid, c, Tick::ZERO)), "{what}"); + assert!(cache.stats().hits > hits, "{what}: served from the cache"); + served.into_files() + }; + let files = files_served("content", &|r, ctx| r.content_buf(ctx, content).is_some()); + assert!(files.get("/sampled.png").is_some(), "content: {files:?}"); + let files = files_served("input", &|r, ctx| r.layer_input(ctx, content, 0).is_some()); + assert!(files.get("/sampled.png").is_some(), "input: {files:?}"); + let files = files_served("footprint", &|r, ctx| r.adjustment_footprint(ctx, adjustment).is_some()); + assert!(files.get("/sampled.png").is_some(), "footprint: {files:?}"); +} + +/// A text Expression Selector whose Amount is 0 for the first character and the width of the +/// footage item named `ref.png` for the others (`textIndex == 1 ? 0 : footage("ref.png").width`). +struct WidthOfRef; + +impl crate::eval::ExprHost for WidthOfRef { + fn eval(&self, _: &crate::eval::EvalCtx, _: &effectcraft_project::Layer, _: &effectcraft_project::Property, value: &Value) -> Result { + Ok(value.clone()) + } + fn eval_text_selector( + &self, + ctx: &crate::eval::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + index: usize, + _: usize, + _: [f64; 3], + ) -> Result<[f64; 3], String> { + let width = ctx.project.items.values().find(|i| i.name == "ref.png").and_then(|i| i.dimensions()).map_or(0.0, |d| d.0 as f64); + Ok([if index == 1 { 0.0 } else { width }; 3]) + } +} + +/// A text layer whose Expression Selector reads a footage item's size for every character but +/// the first: the layer's key holds the first character's amount only, so its buffer is not +/// stored on disk. Reopened over the same disk cache after the footage's width changed (16 to +/// 64), the frame matches one rendered without a cache. +#[test] +fn a_text_expression_selector_is_not_served_from_the_disk_cache() { + use crate::disk_cache::{DiskCache, Kind}; + use crate::{LayerCache, NoFootage, RenderOpts, Renderer}; + let project = |width: u32| { + let (mut p, cid, comp) = setup(); + let f = effectcraft_project::Footage { has_video: true, width, height: 16, ..Default::default() }; + p.add_item("ref.png", Label::None, None, ItemKind::Footage(f)); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HH".into(), size: 60.0, ..Default::default() })); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut props = build::text_anim_props(&mut ids, "opacity", false); + for pr in &mut props { + pr.value = Value::Scalar(0.0); + } + let mut animator = build::text_animator(&mut ids, "Animator 1", props); + let sel = build::expression_selector(&mut ids, "Expression Selector 1"); + p.next_id = next; + animator.sub_mut("selectors").unwrap().children.push(sel.into()); + l.props.group_mut("text/animators").unwrap().children.push(animator.into()); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) + }; + let render = |p: &Project, cid: ItemId, cache: Option<&LayerCache>| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&WidthOfRef); + r.cache = cache; + r.comp_frame(cid, Tick::ZERO) + }; + let dir = tempfile::tempdir().unwrap(); + let dc = DiskCache::open(dir.path(), 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + // First session: the 16 px wide footage. + let (p16, cid) = project(16); + let cache = LayerCache::default(); + cache.set_disk(Some(dc.clone())); + let first = render(&p16, cid, Some(&cache)); + dc.flush(); + assert!(first.data.iter().any(|px| px[3] > 0.5), "the text renders"); + // Reopened with a fresh memory cache, the footage now 64 px wide. + let (p64, cid) = project(64); + let uncached = render(&p64, cid, None); + assert_ne!(uncached.data, first.data, "the second character's opacity follows the width"); + let reopened = LayerCache::default(); + reopened.set_disk(Some(dc.clone())); + assert_eq!(render(&p64, cid, Some(&reopened)).data, uncached.data, "not the buffer from before the change"); + assert!(dc.keys(Kind::Layer).is_empty(), "the text layer's buffer is not on disk"); +} + /// A Birth Rate expression reading another layer: changing that layer changes the history but /// not the current rate (0) nor this layer's properties, which is all the layer cache key sees. -/// CC Particle World layers with expressions therefore aren't layer cached. +/// Particle Emitter 3D layers with expressions therefore aren't layer cached. #[test] fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() { let scene = |opacity: f64| { @@ -517,8 +660,8 @@ fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cach assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a)); } -/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems -/// II, CC Mr. Mercury): Birth Rate `rate`, +/// A grey solid (Liquid Particles shades its blobs from it) with effect `id` (CC Particle Systems +/// II, Liquid Particles): Birth Rate `rate`, /// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s. fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { let (mut p, cid, comp) = setup(); @@ -537,7 +680,7 @@ fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { (p, cid) } -/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst +/// #1373 for Particle Emitter 2D and Liquid Particles through the renderer: a keyed burst /// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate /// of 0 throughout), and draws as nothing born once Longevity has run out. #[test] @@ -820,6 +963,41 @@ fn effects_read_layer_params() { assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}"); } +/// A cached layer whose effect reads a precomp layer: how the precomp's layers render depends on +/// Switches Affect Nested Comps (the precomp layer's Draft quality passes down) and on the +/// switches the reading layer's own comp inherits, so the key must tell them apart (a buffer +/// made under the other setting, from memory or disk, is stale). +#[test] +fn a_cached_layer_reading_a_precomp_is_keyed_by_the_nested_switches() { + let (mut p, cid, comp) = setup(); + let mut inner = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + // A rotated square off the pixel grid: Draft (nearest) and Best (bilinear) sampling differ. + let mut dot = solid(&mut p, &inner, [1.0, 1.0, 1.0], 40, 40); + dot.props.prop_mut("transform/position").unwrap().value = Value::Vec3([100.3, 50.6, 0.0]); + dot.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(30.0); + inner.layers.push(dot); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &comp, "Inner", LayerSource::Comp { item: iid }, (200, 100), None); + pre.switches.video = false; + pre.switches.quality = effectcraft_project::Quality::Draft; + let mut top = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + add_effect_200(&mut p, &mut top, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0)))]); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(top); + c.layers.push(pre); + let frame = |nested_switches: bool, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts { nested_switches, ..Default::default() }); + r.cache = cache; + r.comp_frame(cid, Tick::ZERO).data + }; + let (on, off) = (frame(true, None), frame(false, None)); + assert!(on != off, "the precomp layer's Draft quality passes down"); + let cache = crate::LayerCache::default(); + assert!(frame(true, Some(&cache)) == on); + assert!(frame(false, Some(&cache)) == off, "not the buffer made with switches affecting nested comps"); + assert!(frame(true, Some(&cache)) == on, "and back"); +} + /// Records whether the GPU compositor was asked to draw a frame. struct ProbeAccel(std::sync::atomic::AtomicBool); impl crate::Accelerator for ProbeAccel { @@ -882,6 +1060,27 @@ fn comp_frame_ends_the_frame_when_the_render_panics() { assert_eq!(a.ends.load(Ordering::SeqCst), 1); } +/// The renderer labels Project Settings ▸ Video Rendering and Effects draws parse back as the +/// backend they name (in any case), and the older spellings still work. +#[test] +fn renderer_labels_parse_back_and_keep_the_older_spellings() { + use crate::Backend; + for (label, backend) in [ + ("GPU Acceleration", Backend::Gpu), + ("gpu acceleration", Backend::Gpu), + ("Mercury GPU Acceleration", Backend::Gpu), + ("gpu", Backend::Gpu), + ("Software Rendering", Backend::Cpu), + ("software rendering", Backend::Cpu), + ("Mercury Software Only", Backend::Cpu), + ("cpu", Backend::Cpu), + ("Auto", Backend::Auto), + ] { + assert_eq!(Backend::parse(label), Some(backend), "{label}"); + } + assert_eq!(Backend::parse("Vulkan"), None); +} + #[test] fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() { let (mut p, cid, comp) = setup(); @@ -1048,11 +1247,11 @@ fn effects_on_text_reach_past_the_text_bounds() { } assert!( painted > 50 && hi.saturating_sub(lo) > 100, - "CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}" + "Scaled Tiles tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}" ); } -/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past +/// #658: Directional Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past /// the painted rectangle used to fall off its edge, so the liquid trail never appeared. #[test] fn cc_smear_trails_text_past_its_bounds() { @@ -1071,7 +1270,7 @@ fn cc_smear_trails_text_past_its_bounds() { ); p.comp_mut(cid).unwrap().layers = vec![l].into(); let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); - assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); + assert!(plain > 40 && smeared > plain + 25, "Directional Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); } /// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the @@ -1327,7 +1526,7 @@ fn solo_excludes_other_layers_outside_the_soloed_layers_range() { assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range"); } -/// #1252: a layer blended with a CC Particle World layer, under a camera panned by `pan` px. +/// #1252: a layer blended with a Particle Emitter 3D layer, under a camera panned by `pan` px. fn scene_reader_dependency(pan: f64) -> (Project, ItemId) { let (mut p, cid, comp) = setup(); let mut source = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); @@ -1803,3 +2002,17 @@ fn a_copy_of_an_overridden_clip_is_not_served_the_keyed_clips_pixels() { assert_same(&render(&keyed, Some(&cache)), &render(&keyed, None), "the keyed clip's copy"); assert_same(&render(&overridden, Some(&cache)), &render(&overridden, None), "the overridden clip's copy"); } + +#[test] +fn elongated_solids_stay_within_the_pixel_cap_after_rounding() { + use crate::pipeline::solid_size; + // 1 × 100 million at full scale: rounding up used to leave 40 million pixels. + for (w, h) in [(1u32, 100_000_000u32), (100_000_000, 1), (u32::MAX, u32::MAX), (1, u32::MAX)] { + let (s, pw, ph) = solid_size(w, h, 1.0); + assert!(s > 0.0 && f64::from(pw) * f64::from(ph) <= 16.0e6, "{w}x{h} -> {pw}x{ph} at {s}"); + } + // A solid within the cap keeps its scale; an over-cap square is lowered by the exact ratio. + assert_eq!(solid_size(4000, 4000, 1.0), (1.0, 4000, 4000)); + let (s, pw, ph) = solid_size(5000, 5000, 1.0); + assert!((s - 0.8).abs() < 0.01 && pw * ph <= 16_000_000); +} diff --git a/crates/render/src/tests/auto.rs b/crates/render/src/tests/auto.rs index 22f1a6215..a53bb718a 100644 --- a/crates/render/src/tests/auto.rs +++ b/crates/render/src/tests/auto.rs @@ -14,12 +14,13 @@ struct MockAccel { handle: bool, calls: AtomicUsize, pick: AutoPick, + effect_calls: AtomicUsize, } 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() } + MockAccel { handle, calls: Default::default(), pick: Default::default(), effect_calls: Default::default() } } /// Number of full-frame requests dispatched to this accelerator. fn calls(&self) -> usize { @@ -42,6 +43,7 @@ impl Accelerator for MockAccel { }) } fn supports_effect(&self, _id: &str) -> bool { + self.effect_calls.fetch_add(1, Ordering::SeqCst); false } fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option) -> Option { @@ -160,3 +162,201 @@ fn auto_pick_follows_changing_costs() { assert!(pick.stats(AutoKey::new(ItemId(7), 1.0, true)).is_none()); assert!(pick.stats(AutoKey::new(ItemId(7), 0.5, false)).is_none()); } + +/// Counts actual CPU source reads and rejects any CPU fallback inside a GPU error scope. +struct ScopedFootage { + in_scope: std::sync::atomic::AtomicBool, + reads: AtomicUsize, +} +impl crate::FootageSource for ScopedFootage { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + assert!(!self.in_scope.load(Ordering::SeqCst), "CPU fallback ran before GPU validation/cleanup"); + self.reads.fetch_add(1, Ordering::SeqCst); + Some(std::sync::Arc::new(crate::Image::filled(f.width, f.height, [1.0, 0.0, 0.0, 1.0]))) + } +} + +fn scoped_project() -> (Project, ItemId) { + let (mut p, cid) = project(); + let comp = p.comp(cid).unwrap().clone(); + let footage = effectcraft_project::Footage { + path: "synthetic.png".into(), + kind: effectcraft_project::FootageKind::Still, + width: 50, + height: 50, + has_video: true, + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + let item = p.add_item("Source", Label::Red, None, ItemKind::Footage(footage)); + let mut layer = build::layer(&mut p, &comp, "Source", LayerSource::Footage { item }, (50, 50), None); + super::add_effect_200(&mut p, &mut layer, "ec.blur.gaussian", &[]); + p.comp_mut(cid).unwrap().layers = vec![layer].into(); + (p, cid) +} + +/// #3357: a declined GPU frame must not allocate a CPU frame only to discard it when the +/// enclosing GPU scope reports a fault. The caller retries on a fresh CPU renderer once. +#[test] +fn failed_gpu_scope_returns_before_cpu_fallback() { + let (p, cid) = scoped_project(); + for handled in [false, true] { + let a = MockAccel::new(handled); + let src = ScopedFootage { in_scope: false.into(), reads: 0.into() }; + let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() }); + r.accel = Some(&a); + let result = r.comp_frame_scoped(cid, Tick::ZERO, |a| { + src.in_scope.store(true, Ordering::SeqCst); + let frame = a.comp_frame(&r, cid, Tick::ZERO); + drop(frame); + src.in_scope.store(false, Ordering::SeqCst); + Err::, _>("device retired") + }); + assert_eq!(result.unwrap_err(), "device retired"); + assert_eq!(src.reads.load(Ordering::SeqCst), 0); + let frame = r.with_accel(None).comp_frame(cid, Tick::ZERO); + assert!(frame.get(100, 50)[3] > 0.99); + assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(a.calls(), 1); + } +} + +/// A healthy decline closes its scope before CPU work, and still records Auto timings. +#[test] +fn healthy_gpu_decline_renders_cpu_once_outside_scope() { + let (p, cid) = scoped_project(); + let a = MockAccel::new(false); + let src = ScopedFootage { in_scope: false.into(), reads: 0.into() }; + let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Auto, ..Default::default() }); + r.accel = Some(&a); + // Auto warms up CPU first: the first frame renders on the CPU without a GPU scope. + r.comp_frame_scoped(cid, Tick::ZERO, |_| -> Result, ()> { panic!("Auto's CPU warm-up frame must not enter GPU error scopes") }) + .unwrap(); + assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(a.calls(), 0); + // The next one is the GPU's warm-up frame, which the accelerator declines. + let frame = r + .comp_frame_scoped(cid, Tick::ZERO, |a| { + src.in_scope.store(true, Ordering::SeqCst); + let frame = a.comp_frame(&r, cid, Tick::ZERO); + src.in_scope.store(false, Ordering::SeqCst); + Ok::<_, ()>(frame) + }) + .unwrap(); + assert!(frame.get(100, 50)[3] > 0.99); + assert_eq!(src.reads.load(Ordering::SeqCst), 2); + assert_eq!(a.calls(), 1); + assert_eq!(a.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap().cpu_frames, 2); + // Auto's next frame stays on CPU, without entering a GPU scope at all. + r.comp_frame_scoped(cid, Tick::ZERO, |_| -> Result, ()> { panic!("Auto CPU choice must not enter GPU error scopes") }).unwrap(); + assert_eq!(src.reads.load(Ordering::SeqCst), 3); + assert_eq!(a.effect_calls.load(Ordering::SeqCst), 0, "CPU effects must not use the GPU outside its scope"); +} + +/// A preview starts with every lane asking at once, before any frame is measured: the first +/// frames (the ones on screen) render on the CPU; once one is measured, one lane takes a GPU +/// warm-up frame, and the next waits for its time. (Every lane took a GPU warm-up frame on an +/// integrated GPU 2–4× slower than the CPU, and each first playback stalled at its start.) +#[test] +fn auto_pick_warms_the_gpu_up_one_frame_at_a_time() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(3), 1.0, false); + let first: Vec = (0..12).map(|_| pick.choose(key)).collect(); + assert!(!first.iter().any(|g| *g), "{first:?}"); + pick.record(key, false, 10.0); + let next: Vec = (0..12).map(|_| pick.choose(key)).collect(); + assert_eq!(next.iter().filter(|g| **g).count(), 1, "{next:?}"); + // The CPU frames finish first: still no second GPU frame while the first runs. + for _ in 0..22 { + pick.record(key, false, 10.0); + } + assert!(!(0..12).any(|_| pick.choose(key)), "a second GPU warm-up frame while one runs"); + // Measured: the next warm-up frame may go to the GPU. + pick.record(key, true, 40.0); + assert!(pick.choose(key)); + // A re-probe of the slow side runs one frame at a time too. + let pick = AutoPick::default(); + let mut gpu_asked = 0; + for _ in 0..(4 * AutoPick::REPROBE) { + // (sequential warm-up, then GPU frames held: as lanes that never report back) + if pick.choose(key) { + gpu_asked += 1; + if pick.stats(key).is_none_or(|s| s.gpu_frames < AutoPick::WARMUP) { + pick.record(key, true, 40.0); + } + } else { + pick.record(key, false, 10.0); + } + } + assert!(gpu_asked <= AutoPick::WARMUP as usize + 1, "asked the GPU {gpu_asked} times"); +} + +/// Frames render side by side: twelve CPU frames of 210 ms each at once cost ~18 ms a frame, +/// so a lone GPU frame of 175 ms is the slower side (it measured faster and took the comp). +#[test] +fn auto_pick_counts_frames_running_side_by_side() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(4), 0.5, false); + // Twelve lanes: each starts its next frame as soon as one is done. + let mut lanes: std::collections::VecDeque = (0..12).map(|_| pick.choose(key)).collect(); + for _ in 0..240 { + let Some(gpu) = lanes.pop_front() else { break }; + pick.record(key, gpu, if gpu { 175.0 } else { 210.0 }); + lanes.push_back(pick.choose(key)); + } + let s = pick.stats(key).unwrap(); + assert!(s.cpu_ms.unwrap() < 30.0 && s.gpu_ms.unwrap() > 100.0, "{s:?}"); + assert!(lanes.iter().filter(|g| **g).count() <= 1, "{lanes:?}"); +} + +/// A frame that ends without a time (a GPU failure, a remote frame replaced before it finished) +/// no longer counts as running: the frames after it are timed as one at a time. (It stayed +/// counted, and every later frame on its side was timed as half its cost.) +#[test] +fn auto_pick_abandoned_frames_stop_counting_as_running() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(5), 1.0, false); + let mut dropped = [false; 2]; + for _ in 0..16 { + let gpu = pick.choose(key); + if !std::mem::replace(&mut dropped[usize::from(gpu)], true) { + pick.abandoned(key, gpu); + continue; + } + pick.record(key, gpu, if gpu { 40.0 } else { 10.0 }); + } + let s = pick.stats(key).unwrap(); + assert_eq!((s.cpu_ms, s.gpu_ms), (Some(10.0), Some(40.0)), "{s:?}"); +} + +/// A ticket that never reports (dropped on an error, a retry elsewhere, a cancelled export +/// future) ends its frame like [`AutoPick::abandoned`]; one the GPU declined reports from the +/// CPU. (Each exit that skipped `record` left its frame counted as running, and every later +/// frame on that side was timed as one of several side by side.) +#[test] +fn auto_tickets_end_their_frame_on_every_exit() { + let pick = AutoPick::default(); + let key = AutoKey::new(ItemId(6), 1.0, false); + let mut dropped = [false; 2]; + for _ in 0..16 { + let t = pick.ticket(key); + let gpu = t.gpu(); + if !std::mem::replace(&mut dropped[usize::from(gpu)], true) { + drop(t); + continue; + } + t.record(if gpu { 40.0 } else { 10.0 }); + } + let s = pick.stats(key).unwrap(); + assert_eq!((s.cpu_ms, s.gpu_ms), (Some(10.0), Some(40.0)), "{s:?}"); + // Declined: the frame renders on the CPU and reports there, one frame on it. + let key = AutoKey::new(ItemId(7), 1.0, false); + pick.ticket(key).record(10.0); + let mut t = pick.ticket(key); + assert!(t.gpu(), "the GPU's warm-up frame"); + t.declined(); + assert!(!t.gpu()); + t.record(10.0); + pick.ticket(key).record(10.0); + assert_eq!(pick.stats(key).unwrap().cpu_ms, Some(10.0), "timed as one frame on the CPU"); +} diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 30d2163c1..4104e0b38 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -161,8 +161,17 @@ fn referenced_layer_overrides_change_the_owner_cache_key() { let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; let key = |value: f64| { let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; - let ctx = - EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &overrides, + media_overrides: &[], + reads: None, + }; crate::cache::layer_key(&ctx, b, 1.0, false, false) }; let (opaque, half) = (key(100.0), key(50.0)); diff --git a/crates/render/src/tests/drop_shadow_cover.rs b/crates/render/src/tests/drop_shadow_cover.rs new file mode 100644 index 000000000..94d4ca67b --- /dev/null +++ b/crates/render/src/tests/drop_shadow_cover.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer}; +use effectcraft_effects::Buf; + +// A synthetic painted source avoids installed-font dependencies and the multi-gigabyte +// allocation in #3357 while exercising the same text/shape effect-stack bounds. +fn source(scale: f64) -> Buf { + let mut img = Image::new(20, 12); + for y in 2..10 { + for x in 2..18 { + img.set(x, y, [0.8, 0.2, 0.1, 1.0]); + } + } + Buf { img, offset: [7.0, -3.0], scale } +} + +#[test] +fn drop_shadow_does_not_cover_the_zoomed_layer_rectangle() { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + // The old cover makes this at least 800 x 400. Alignment and the shadow's + // own padding need less than 128 pixels per side for this source. + assert!(out.img.width < 128 && out.img.height < 128, "{} x {}", out.img.width, out.img.height); +} + +#[test] +fn drop_shadow_matches_the_covered_reference() { + for scale in [0.5, 1.0, 4.0] { + for only in [false, true] { + for direction in [0.0, 135.0, 270.0] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Text", LayerSource::Text, (0, 0), None); + add_effect_200( + &mut p, + &mut layer, + "ec.perspective.dropshadow", + &[ + ("distance", Value::Scalar(3.5)), + ("softness", Value::Scalar(2.0)), + ("direction", Value::Scalar(direction)), + ("shadowOnly", Value::Bool(only)), + ], + ); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let input = source(scale); + let mut covered = input.clone(); + let (size, origin) = ctx.effect_bounds(&layer); + covered.rebase(origin); + covered.cover(size); + covered.rebase([-origin[0], -origin[1]]); + let actual = r.apply_effects(&ctx, &layer, input, false); + let reference = r.apply_effects(&ctx, &layer, covered, false); + // Compare the union in layer-pixel coordinates, including the shadow outside + // the original content. Buffer origins differ by integral pixels. + let x0 = (-actual.offset[0]).min(-reference.offset[0]) as i32; + let y0 = (-actual.offset[1]).min(-reference.offset[1]) as i32; + let x1 = (f64::from(actual.img.width) - actual.offset[0]).max(f64::from(reference.img.width) - reference.offset[0]) as i32; + let y1 = (f64::from(actual.img.height) - actual.offset[1]).max(f64::from(reference.img.height) - reference.offset[1]) as i32; + for y in y0..y1 { + for x in x0..x1 { + let sample = |b: &Buf| b.img.sample_bilinear(f64::from(x) + b.offset[0] + 0.5, f64::from(y) + b.offset[1] + 0.5); + let (a, b) = (sample(&actual), sample(&reference)); + for c in 0..4 { + assert!( + (a[c] - b[c]).abs() < 1e-5, + "scale {scale}, only {only}, direction {direction}, ({x}, {y}), channel {c}: {} != {}", + a[c], + b[c] + ); + } + } + } + } + } + } +} +#[test] +fn drop_shadow_with_glow_stays_local_but_other_effects_keep_cover() { + for other in ["ec.stylize.glow", "ec.color.tint"] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let values = if other == "ec.stylize.glow" { vec![("radius", Value::Scalar(1.0))] } else { vec![] }; + add_effect_200(&mut p, &mut layer, other, &values); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + if other == "ec.stylize.glow" { + assert!(out.img.width < 192 && out.img.height < 192, "{} x {}", out.img.width, out.img.height); + } else { + assert!(out.img.width >= 800 && out.img.height >= 400); + } + } +} +#[test] +fn parented_text_and_shape_keep_small_working_images_at_2200_percent() { + for text in [false, true] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(80, 40, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut parent = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None); + parent.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + parent.props.prop_mut("transform/position").unwrap().value = Value::Vec3([40.0, 20.0, 0.0]); + parent.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([2200.0, 2200.0, 100.0]); + let source = if text { LayerSource::Text } else { LayerSource::Shape }; + let mut layer = build::layer(&mut p, &comp, "Painted", source, (80, 40), None); + layer.parent = Some(parent.id); + layer.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([0.0; 3]); + if text { + layer.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "I".into(), size: 8.0, ..Default::default() })); + } else { + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [4.0, 4.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0; 4]); + let group = build::shape_group(&mut ids, "Rectangle", vec![rect, fill]); + p.next_id = next; + layer.props.sub_mut("contents").unwrap().children.push(group.into()); + } + add_effect(&mut p, &mut layer, "ec.stylize.glow", [80.0, 40.0], &[("radius", Value::Scalar(0.1))]); + add_effect(&mut p, &mut layer, "ec.perspective.dropshadow", [80.0, 40.0], &[("distance", Value::Scalar(0.1)), ("softness", Value::Scalar(0.1))]); + p.comp_mut(cid).unwrap().layers = vec![layer, parent].into(); + let comp = p.comp(cid).unwrap(); + let layer = &comp.layers[0]; + for scale in [1.0, 0.5] { + let r = Renderer::new(&p, &NoFootage, RenderOpts { scale, ..Default::default() }); + let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO); + let input = r.layer_input(&ctx, layer, usize::MAX).unwrap(); + assert!(input.img.width < 512 && input.img.height < 512, "text={text}, output scale={scale}: {} x {}", input.img.width, input.img.height); + assert!(input.img.data.iter().any(|pixel| pixel[3] > 0.5), "the painted source is not empty"); + let frame = r.comp_frame(cid, Tick::ZERO); + assert!(frame.data.iter().any(|pixel| pixel[3] > 0.5), "the layer still draws in the composition"); + } + } +} diff --git a/crates/render/src/tests/footage_size.rs b/crates/render/src/tests/footage_size.rs index 065941d8a..6c59f3fb2 100644 --- a/crates/render/src/tests/footage_size.rs +++ b/crates/render/src/tests/footage_size.rs @@ -106,3 +106,94 @@ fn still_layers_are_cached_across_times_and_movies_are_not() { assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}"); } } + +/// [`Clip`] that also keeps its frames as 8-bit levels, counting the times it's asked for them. +struct Clip8 { + asked: std::sync::atomic::AtomicUsize, +} + +impl FootageSource for Clip8 { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + let mut img = (*Clip { sizes: None }.frame(item, f, t)?).clone(); + // (partly transparent, premultiplied, as decoded with alpha) + img.data.iter_mut().enumerate().for_each(|(i, p)| { + let a = (i % 7) as f32 / 6.0; + *p = [p[0] * a, p[1] * a, p[2] * a, a]; + }); + Some(Arc::new(img)) + } + fn frame8(&self, item: ItemId, f: &Footage, t: Tick, _: crate::FloatCurve) -> Option> { + self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let img = self.frame(item, f, t)?; + let rgba = img.data.iter().flat_map(|p| effectcraft_raster::quantize_px_u8(*p)).collect(); + Some(Arc::new(crate::disk_cache::Frame8 { width: img.width, height: img.height, rgba })) + } +} + +/// An 8 bpc render at Full takes plain footage as 8-bit levels and renders the same pixels to +/// the bit; other depths, reduced renders and interpreted pixels never ask for them. +#[test] +fn footage_kept_as_8_bit_levels_renders_the_same_pixels() { + struct Float(Clip8); + impl FootageSource for Float { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.0.frame(item, f, t) + } + } + for (depth, invert, scale, used) in [ + (BitDepth::Bpc8, false, 1.0, true), + (BitDepth::Bpc8, false, 0.75, true), + (BitDepth::Bpc8, false, 0.5, false), + (BitDepth::Bpc8, true, 1.0, false), + (BitDepth::Bpc16, false, 1.0, false), + (BitDepth::Bpc32, false, 1.0, false), + ] { + let (p, cid) = project(depth, invert); + let opts = RenderOpts { scale, ..Default::default() }; + let q8 = Clip8 { asked: Default::default() }; + let frame = Renderer::new(&p, &q8, opts).comp_frame(cid, Tick::ZERO); + let plain = Renderer::new(&p, &Float(Clip8 { asked: Default::default() }), opts).comp_frame(cid, Tick::ZERO); + assert!(frame.data.iter().zip(&plain.data).all(|(a, b)| a.map(f32::to_bits) == b.map(f32::to_bits)), "{depth:?} {invert} {scale}"); + assert_eq!(q8.asked.into_inner() > 0, used, "{depth:?} {invert} {scale}"); + } +} + +/// The 8-bit levels are read while the render records its reads: the movie is in the files the +/// frame (and a cached layer buffer) was made of. (Read outside, a buffer of a movie listed no +/// files and was still marked complete: changed on disk, it was served stale.) +#[test] +fn footage_kept_as_8_bit_levels_is_noted_as_read() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + struct Noted { + clip: Clip8, + recording: AtomicBool, + outside: AtomicUsize, + } + impl FootageSource for Noted { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.clip.frame(item, f, t) + } + fn frame8(&self, item: ItemId, f: &Footage, t: Tick, float: crate::FloatCurve) -> Option> { + if !self.recording.load(Ordering::SeqCst) { + self.outside.fetch_add(1, Ordering::SeqCst); + } + self.clip.frame8(item, f, t, float) + } + // (as the media pool does: the files of what `f` reads go to `reads`) + fn recording(&self, reads: &crate::reads::ReadLog, f: &mut dyn FnMut()) { + reads.note(crate::reads::ReadFile::new("clip.mov", crate::reads::FileStamp::Native { len: 1, modified: Some(1) })); + self.recording.store(true, Ordering::SeqCst); + f(); + self.recording.store(false, Ordering::SeqCst); + } + } + let (p, cid) = project(BitDepth::Bpc8, false); + let src = Noted { clip: Clip8 { asked: Default::default() }, recording: AtomicBool::new(false), outside: AtomicUsize::new(0) }; + let log = crate::reads::ReadLog::new(); + let mut r = Renderer::new(&p, &src, RenderOpts::default()); + r.reads = Some(&log); + r.comp_frame(cid, Tick::ZERO); + assert!(src.clip.asked.load(Ordering::SeqCst) > 0, "the 8-bit levels were not asked for"); + assert_eq!(src.outside.load(Ordering::SeqCst), 0, "8-bit levels read outside the render's recording"); + assert!(log.files().files().iter().any(|f| &*f.path == "clip.mov"), "the movie is not in the frame's files"); +} diff --git a/crates/render/src/tests/mask_blur.rs b/crates/render/src/tests/mask_blur.rs index 07bc1e95f..5f3ff4e2a 100644 --- a/crates/render/src/tests/mask_blur.rs +++ b/crates/render/src/tests/mask_blur.rs @@ -75,3 +75,32 @@ fn mask_motion_blur_follows_the_render_and_the_comp() { let (p, cid) = project(false); assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off"); } + +/// Whether mask motion blur may run at all (the render options, Switches Affect Nested Comps with +/// a precomp layer's switch off) is part of the layer cache key, also for a layer whose own +/// switch is off: a buffer made with the gate open must not serve a render with it closed. +#[test] +fn the_mask_motion_blur_gate_keys_the_layer_cache() { + let (mut p, inner) = project(true); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + outer.enable_motion_blur = true; + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: inner }, (100, 100), None); + pre.switches.motion_blur = false; + outer.layers.push(pre); + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + let half = Tick::from_seconds_f64(0.5 / 30.0); + let cache = crate::LayerCache::default(); + let render = |cid: ItemId, opts: RenderOpts, cached: bool| { + let mut r = Renderer::new(&p, &NoFootage, opts); + r.cache = cached.then_some(&cache); + r.comp_frame(cid, half).data + }; + let on = RenderOpts { motion_blur: true, nested_switches: true, ..Default::default() }; + let off = RenderOpts { nested_switches: false, ..on }; + assert!(render(oid, on, false) != render(oid, off, false), "the precomp layer's switch closes the gate"); + assert!(render(oid, off, true) == render(oid, off, false)); + assert!(render(oid, on, true) == render(oid, on, false), "not the buffer blurred with the gate open"); + let still = RenderOpts { motion_blur: false, ..on }; + assert!(render(inner, on, true) == render(inner, on, false)); + assert!(render(inner, still, true) == render(inner, still, false), "Render Settings ▸ Motion Blur off"); +} diff --git a/crates/render/src/tests/proxy_cache.rs b/crates/render/src/tests/proxy_cache.rs new file mode 100644 index 000000000..85343a25d --- /dev/null +++ b/crates/render/src/tests/proxy_cache.rs @@ -0,0 +1,45 @@ +use super::setup; +use crate::eval::EvalCtx; +use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer}; +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Proxy, build, render_queue::ProxyUse}; +use effectcraft_time::Tick; +use std::sync::Arc; + +/// A source and its proxy deliberately have different pixels. +struct Pictures; +impl FootageSource for Pictures { + fn frame(&self, _: ItemId, footage: &Footage, _: Tick) -> Option> { + let color = if footage.path == "proxy" { [0.0, 1.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(Arc::new(Image::filled(16, 16, color))) + } +} + +/// A queue run reuses its layer cache across jobs with different Proxy Use settings. +/// Time effects read layer_input, which must never serve another job's source pixels. +#[test] +fn shared_layer_cache_separates_proxy_use() { + let (mut p, cid, comp) = setup(); + let footage = Footage { path: "original".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }; + let item = p.add_item("Footage", Label::Red, None, ItemKind::Footage(footage.clone())); + p.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage: Footage { path: "proxy".into(), ..footage }, enabled: false })); + let layer = build::layer(&mut p, &comp, "Footage", LayerSource::Footage { item }, (16, 16), None); + p.comp_mut(cid).unwrap().layers.push(layer); + for enabled in [false, true] { + p.item_mut(item).unwrap().proxy.as_mut().unwrap().enabled = enabled; + let cache = LayerCache::default(); + let ctx = EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let layer = &ctx.comp.layers[0]; + for proxy in [ProxyUse::UseAll, ProxyUse::UseNone, ProxyUse::CurrentSettings, ProxyUse::UseCompOnly, ProxyUse::UseAll] { + let mut r = Renderer::new(&p, &Pictures, RenderOpts { proxy, ..Default::default() }); + let input = r.layer_input(&ctx, layer, 0).unwrap(); + let content = r.content_buf(&ctx, layer).unwrap(); + r.cache = Some(&cache); + assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img.data, input.img.data, "{proxy:?}, enabled={enabled}"); + assert_eq!(r.content_buf(&ctx, layer).unwrap().img.data, content.img.data, "{proxy:?}, enabled={enabled}"); + let hits = cache.stats().hits; + r.layer_input(&ctx, layer, 0).unwrap(); + assert!(cache.stats().hits > hits, "unchanged proxy settings still reuse inputs"); + } + } +} diff --git a/crates/render/src/tests/raster_clip.rs b/crates/render/src/tests/raster_clip.rs new file mode 100644 index 000000000..c85176fca --- /dev/null +++ b/crates/render/src/tests/raster_clip.rs @@ -0,0 +1,360 @@ +//! #1388: 3D text and shape layers rasterise only the part of the layer the output shows. Where +//! the whole raster fits its pixel budget the frame is identical to rasterising the whole layer; +//! close to the camera the raster stays small instead of growing to ~16 million pixels. + +use effectcraft_color::Label; +use effectcraft_keyframe::{TextDoc, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// The default camera's distance to the z = 0 plane. +fn zoom() -> f64 { + W as f64 * 50.0 / 36.0 +} + +fn setup() -> (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) +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; +} + +/// A 3D text layer at `pos`, turned `rot_y` degrees about Y. +fn text3(p: &mut Project, comp: &Comp, pos: [f64; 3], rot_y: f64) -> Layer { + let mut l = build::layer(p, comp, "Text", LayerSource::Text, (W, H), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA TEXT".into(), size: 60.0, ..Default::default() })); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + set(&mut l, "transform/rotationY", Value::Scalar(rot_y)); + l +} + +/// A 3D shape layer (a 300×120 rectangle with a stroke) at `pos`. +fn shape3(p: &mut Project, comp: &Comp, pos: [f64; 3]) -> Layer { + let mut l = build::layer(p, comp, "Shape", LayerSource::Shape, (W, H), None); + let mut next = p.next_id; + { + let mut ids = Ids(&mut next); + let r = build::shape_rect(&mut ids, [300.0, 120.0], [0.0, 0.0], 0.0); + let f = build::shape_fill(&mut ids, [0.9, 0.4, 0.1, 1.0]); + let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 6.0); + let g = build::shape_group(&mut ids, "Box", vec![r, f, s]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + p.next_id = next; + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + l +} + +fn scene(l: impl FnOnce(&mut Project, &Comp) -> Layer) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let layer = l(&mut p, &comp); + p.comp_mut(cid).unwrap().layers.push(layer); + (p, cid) +} + +fn frame(p: &Project, cid: ItemId, whole: bool) -> effectcraft_raster::Image { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.whole_raster = whole; + r.comp_frame(cid, Tick::ZERO) +} + +/// Pixels of the layer's processed buffer. +fn raster_px(p: &Project, cid: ItemId, whole: bool) -> u64 { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.whole_raster = whole; + let c = p.comp(cid).unwrap(); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let b = r.layer_buf(&ctx, &c.layers[0]).unwrap(); + b.img.width as u64 * b.img.height as u64 +} + +#[test] +fn the_visible_part_renders_the_same_frame_as_the_whole_layer() { + let cx = W as f64 / 2.0; + let cy = H as f64 / 2.0; + let cases: Vec<(&str, (Project, ItemId))> = vec![ + ("far", scene(|p, c| text3(p, c, [cx, cy, 300.0], 0.0))), + ("in the frame plane", scene(|p, c| text3(p, c, [cx, cy, 0.0], 0.0))), + ("past the frame edge", scene(|p, c| text3(p, c, [60.0, cy, -200.0], 0.0))), + ("turned", scene(|p, c| text3(p, c, [cx, cy, -150.0], 55.0))), + ("close to the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 40.0], 0.0))), + ("partly behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 30.0], 80.0))), + ("behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() - 200.0], 0.0))), + ("shape", scene(|p, c| shape3(p, c, [cx + 150.0, cy, -250.0]))), + ]; + for (label, (p, cid)) in &cases { + let clipped = frame(p, *cid, false); + let whole = frame(p, *cid, true); + assert_eq!(clipped.data, whole.data, "{label}"); + } +} + +/// Close to the camera only part of the text shows: its raster is a fraction of the whole one +/// (millions of pixels), and the frame still shows text. +#[test] +fn text_close_to_the_camera_rasterises_what_shows() { + let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let clipped = raster_px(&p, cid, false); + let whole = raster_px(&p, cid, true); + assert!(whole > 5_000_000, "the whole raster is huge ({whole} px)"); + eprintln!("near camera: {clipped} px rasterised, {whole} px whole"); + assert!(clipped * 10 < whole, "{clipped} px of {whole}"); + let img = frame(&p, cid, false); + let covered = img.data.iter().filter(|px| px[3] > 0.5).count(); + assert!(covered > (W * H / 20) as usize, "text shows ({covered} px)"); + // Behind the camera there is nothing to rasterise. + let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0], 0.0)); + assert!(raster_px(&p, cid, false) <= 1); +} + +/// The #1388 case at full size: 200 pt text in a 1920 × 1080 comp, close to the camera. Rasterised +/// whole it fills the ~16 million pixel cap; clipped, it stays within a few frames' worth of pixels +/// (and the text still shows). +#[test] +fn hd_text_close_to_the_camera_stays_within_a_few_frames() { + let mut p = Project::default(); + 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 mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (1920, 1080), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA".into(), size: 200.0, ..Default::default() })); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0 + 60.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let (clipped, whole) = (raster_px(&p, cid, false), raster_px(&p, cid, true)); + eprintln!("HD near camera: {clipped} px rasterised, {whole} px whole"); + assert!(whole > 12_000_000, "{whole}"); + assert!(clipped <= 4 * 1920 * 1080, "{clipped} px"); + assert!(frame(&p, cid, false).data.iter().any(|px| px[3] > 0.5), "the text shows"); +} + +/// A slow move of the layer against the camera (a camera move does the same) keeps the part +/// rasterised within the same grid cells for several frames: they reuse the cached raster instead +/// of drawing the text again each frame. +#[test] +fn a_slow_move_reuses_the_clipped_raster() { + let cache = crate::LayerCache::default(); + let at = |x: f64| { + let (p, cid) = scene(|p, c| text3(p, c, [x, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.comp_frame(cid, Tick::ZERO); + }; + at(W as f64 / 2.0); + let misses = cache.stats().misses; + // 16 frames, a quarter of a layer pixel apart (here about three output pixels). + for i in 1..=16 { + at(W as f64 / 2.0 + i as f64 * 0.25); + } + let rasters = cache.stats().misses - misses; + assert!(rasters <= 2, "{rasters} new rasters over 16 frames"); +} + +/// One keyframed motion: (seconds, value) pairs. +type Keys<'a> = &'a [(f64, [f64; 3])]; + +fn keyed(l: &mut Layer, path: &str, keys: Keys) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).keys = + keys.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Vec3(*v))).collect(); +} + +/// A camera keyed through `pos` / `poi`, and the text keyed through `text_pos` (and turning about +/// Y through `text_rot`): every frame of a second, rendered with the clipped raster and a layer +/// cache shared across the frames, matches rasterising the whole layer without a cache (or differs +/// by an anti-aliasing sub-sample or two on a few edge pixels) — except where the whole raster is held at +/// its pixel cap (softer than the clipped one, which isn't): there the text shows and the clipped +/// raster stays small. +fn moving(label: &str, pos: Keys, poi: Keys, text_pos: Keys, text_rot: &[(f64, f64)]) { + let (mut p, cid, comp) = setup(); + let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, 0.0], 0.0); + if !text_pos.is_empty() { + keyed(&mut text, "transform/position", text_pos); + } + if !text_rot.is_empty() { + text.props.prop_mut("transform/rotationY").unwrap().keys = + text_rot.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Scalar(*v))).collect(); + } + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (W, H), None); + set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom())); + keyed(&mut cam, "transform/position", pos); + keyed(&mut cam, "transform/poi", poi); + p.comp_mut(cid).unwrap().layers = vec![cam, text].into(); + let cache = crate::LayerCache::default(); + let (mut exact, mut near, mut capped) = (0, 0, 0); + for f in 0..30 { + let t = Tick::from_seconds_f64(f as f64 / 30.0); + let mut clipped = Renderer::new(&p, &NoFootage, RenderOpts::default()); + clipped.cache = Some(&cache); + let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default()); + whole.whole_raster = true; + let ctx = whole.eval_ctx(cid, t).unwrap(); + let layer = &p.comp(cid).unwrap().layers[1]; + let a = clipped.comp_frame(cid, t); + if clipped.raster_scale(&ctx, layer) == whole.raster_scale(&ctx, layer) { + let b = whole.comp_frame(cid, t); + if a.data == b.data { + exact += 1; + } else { + // The clipped raster places the glyphs at other coordinates (smaller ones: the whole + // raster can be thousands of pixels wide), and tiny-skia transforms them in f32: its + // 4 × 4 anti-aliasing may land a sub-sample or two differently on a few edge pixels. + let (n, worst) = a.data.iter().zip(&b.data).fold((0usize, 0.0f32), |(n, w), (p, q)| { + let d = (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0f32, f32::max); + (n + usize::from(d > 0.0), w.max(d)) + }); + assert!(worst <= 2.0 / 16.0 + 1e-4 && n * 1000 <= (W * H) as usize, "{label}: frame {f}: {n} px differ, by up to {worst}"); + near += 1; + } + } else { + // Held at the cap whole: the clipped raster is sharper, and small. + let buf = clipped.layer_buf(&ctx, layer).unwrap(); + assert!(buf.img.width as u64 * buf.img.height as u64 <= 16_500_000, "{label}: frame {f}"); + let shows = |img: &effectcraft_raster::Image| img.data.iter().filter(|px| px[3] > 0.5).count(); + let (sa, sb) = (shows(&a), shows(&whole.comp_frame(cid, t))); + // The same text shows (or none, the camera past it); coverage within a few percent. + assert!(sa.abs_diff(sb) * 50 <= sb.max(1) + 50, "{label}: frame {f}: {sa} px of text, {sb} rasterised whole"); + capped += 1; + } + } + eprintln!("{label}: {exact} frames identical, {near} within anti-aliasing noise, {capped} sharper than the capped whole raster"); + assert!(exact + near >= 10, "{label}: {exact} identical, {near} close, {capped} at the cap"); +} + +#[test] +fn a_camera_moving_any_way_renders_every_frame_as_the_whole_layer() { + let (cx, cy, z) = (W as f64 / 2.0, H as f64 / 2.0, -zoom()); + let still = [(0.0, [cx, cy, 0.0])]; + // In towards the text, through it and past it; and back out. + moving("dolly in through the text", &[(0.0, [cx, cy, 2.0 * z]), (1.0, [cx, cy, 150.0])], &[(0.0, [cx, cy, 400.0]), (1.0, [cx, cy, 700.0])], &[], &[]); + moving("dolly out", &[(0.0, [cx, cy, -60.0]), (1.0, [cx, cy, 3.0 * z])], &still, &[], &[]); + // Sideways and up and down close to the text, so it leaves the frame on both sides. + moving( + "pan x", + &[(0.0, [cx - 500.0, cy, -120.0]), (1.0, [cx + 500.0, cy, -120.0])], + &[(0.0, [cx - 500.0, cy, 0.0]), (1.0, [cx + 500.0, cy, 0.0])], + &[], + &[], + ); + moving("pan y", &[(0.0, [cx, cy - 300.0, -90.0]), (1.0, [cx, cy + 300.0, -90.0])], &[(0.0, [cx, cy - 300.0, 0.0]), (1.0, [cx, cy + 300.0, 0.0])], &[], &[]); + // Around the text, close, looking at it. + let orbit: Vec<(f64, [f64; 3])> = (0..=8) + .map(|i| { + let a = i as f64 / 8.0 * std::f64::consts::TAU * 0.4 - 0.8; + (i as f64 / 8.0, [cx + 150.0 * a.sin(), cy, -150.0 * a.cos()]) + }) + .collect(); + moving("orbit", &orbit, &still, &[], &[]); + // The text moves instead: towards the camera, past it, and across. + moving( + "text towards and past the camera", + &[(0.0, [cx, cy, z])], + &still, + &[(0.0, [cx + 200.0, cy - 80.0, 400.0]), (1.0, [cx - 150.0, cy + 60.0, z - 100.0])], + &[], + ); + // Turning close to the camera. + moving("text turning near the camera", &[(0.0, [cx, cy, z])], &still, &[(0.0, [cx, cy, z + 70.0])], &[(0.0, -70.0), (1.0, 70.0)]); +} + +/// Reads of a layer in its own space get the whole layer, not the part the frame shows: an effect +/// taking another layer as a parameter (Set Matte, stretched to fit, from hidden text close to the +/// camera), and the source the motion tracker analyses. +#[test] +fn layer_space_reads_get_the_whole_layer() { + let (mut p, cid, comp) = setup(); + let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0); + text.switches.video = false; + let text_id = text.id; + let sid = + p.add_item("Solid", Label::Red, None, ItemKind::Solid(effectcraft_project::Solid { color: [0.2, 0.6, 1.0], width: W, height: H, pixel_aspect: 1.0 })); + let mut solid = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (W, H), None); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + g.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(text_id.0)); + solid.props.sub_mut("effects").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers = vec![text, solid].into(); + assert_eq!(frame(&p, cid, false).data, frame(&p, cid, true).data, "Set Matte from the text"); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default()); + whole.whole_raster = true; + assert_eq!(r.layer_source(&ctx, layer).unwrap().img, whole.layer_source(&ctx, layer).unwrap().img, "the tracker's source"); +} + +/// A layer whose pixels outside the frame can matter (an effect here) is rasterised whole. +#[test] +fn layers_with_effects_rasterise_whole() { + let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let mut next = p.next_id; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into()); + assert_eq!(raster_px(&p, cid, false), raster_px(&p, cid, true)); +} + +/// The Layer panel and analysis tools consume layer-space inputs, even with no effects. +#[test] +fn layer_panel_input_reads_the_whole_layer_behind_the_camera() { + let (p, cid) = scene(|p, c| shape3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0])); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default()); + whole.whole_raster = true; + let expected = whole.layer_input(&ctx, layer, 0).unwrap(); + assert!(expected.img.data.iter().any(|pixel| pixel[3] > 0.5)); + assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img, expected.img); +} + +#[test] +fn fine_clip_cells_have_distinct_cache_keys() { + let a = kurbo::Rect::new(0.0, 0.0, 1.0 / 128.0, 1.0 / 128.0); + let b = kurbo::Rect::new(1.0 / 128.0, 0.0, 2.0 / 128.0, 1.0 / 128.0); + let key = |clip| crate::pipeline::raster_clip_key(42, clip); + // All three clips rounded to identical coordinates in the old 1/16-unit key. + assert_ne!(key(a), key(b)); + assert_ne!(key(a), key(kurbo::Rect::ZERO)); + assert_ne!(key(b), key(kurbo::Rect::ZERO)); +} + +/// Text animator Blur reads beyond the fixed anti-aliasing padding of a clipped window. +#[test] +fn animated_text_blur_keeps_offscreen_glyph_pixels_while_panning() { + let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let props = build::text_anim_props(&mut ids, "blur", false); + let mut animator = build::text_animator(&mut ids, "Blur", props); + animator.prop_mut("properties/blur").unwrap().value = Value::Vec2([40.0, 40.0]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.push(animator.into()); + let opts = RenderOpts { scale: 0.125, ..Default::default() }; + let cache = crate::LayerCache::default(); + for dx in [-10.0, 0.0, 10.0] { + set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3([W as f64 / 2.0 + dx, H as f64 / 2.0, -zoom() + 40.0])); + let mut r = Renderer::new(&p, &NoFootage, opts); + r.cache = Some(&cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + assert!(r.raster_clip(&ctx, layer).is_none(), "animator blur needs off-window pixels"); + let mut whole = Renderer::new(&p, &NoFootage, opts); + whole.whole_raster = true; + assert_eq!(r.comp_frame(cid, Tick::ZERO).data, whole.comp_frame(cid, Tick::ZERO).data); + } +} diff --git a/crates/render/src/tests/stretch_limits.rs b/crates/render/src/tests/stretch_limits.rs new file mode 100644 index 000000000..17bdff317 --- /dev/null +++ b/crates/render/src/tests/stretch_limits.rs @@ -0,0 +1,96 @@ +//! A project file whose layers carry a time stretch outside ±1 % to ±9900 % (1e-300 %, 0, +//! 1e300 %…) opens with the stretch brought into that range, and a frame of it draws and its audio +//! mixes with bounded source reads. Before, a stretch of 1e-300 % asked the footage for about +//! `usize::MAX` samples and the mix overflowed or tried a huge allocation, aborting the app (#3247). + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build, limits}; +use effectcraft_time::Tick; + +use super::{setup, solid}; +use crate::{FootageSource, Image, audio::mix_comp, render_frame}; + +/// Silent audio footage that records the longest read asked of it and refuses a read past a +/// thousand seconds (so a runaway read can't take the machine's memory: the mix then fills the +/// read with silence itself). +#[derive(Default)] +struct Probe { + longest: AtomicUsize, +} + +impl FootageSource for Probe { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, rate: u32) -> Option> { + self.longest.fetch_max(frames, Ordering::Relaxed); + (frames <= rate as usize * 1000).then(|| vec![0.0; frames * 2]) + } +} + +/// The project file of a comp holding a red solid and a sound, both with their stretch set to +/// `stretch` in the file text. +fn file_with_stretch(stretch: f64) -> (String, ItemId) { + let (mut p, cid, comp) = setup(); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let f = Footage { + path: "tone.wav".into(), + kind: FootageKind::Audio, + has_audio: true, + frame_rate: comp.frame_rate, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f)); + let sound = build::layer(&mut p, &comp, "sound", LayerSource::Footage { item: fid }, (0, 0), None); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(red); + c.layers.push(sound); + // Both layers are at 100 %; `{:e}` writes a JSON number for any finite value, and NaN is + // written as null, as serializing the project writes it. + let text = p.to_json(); + assert_eq!(text.matches("\"stretch\": 100.0").count(), 2); + let value = if stretch.is_nan() { "null".to_string() } else { format!("{stretch:e}") }; + (text.replace("\"stretch\": 100.0", &format!("\"stretch\": {value}")), cid) +} + +#[test] +fn a_file_with_an_extreme_stretch_opens_in_range_draws_and_mixes_with_bounded_reads() { + for (stored, opened) in + [(1e-300, 1.0), (-1e-300, -1.0), (0.0, 100.0), (1e300, limits::MAX_STRETCH), (-1e300, -limits::MAX_STRETCH), (0.5, 1.0), (f64::NAN, 100.0)] + { + let (text, cid) = file_with_stretch(stored); + let (p, notes) = Project::from_json_repaired(&text).unwrap(); + assert!(notes.iter().filter(|n| n.contains("time stretch")).count() == 2, "{stored}: {notes:?}"); + let c = p.comp(cid).unwrap(); + assert!(c.layers.iter().all(|l| l.stretch == opened), "{stored}: {:?}", c.layers.iter().map(|l| l.stretch).collect::>()); + // A frame draws: the solid has no keys and keeps its in and out points, so it is red. + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0], "{stored}"); + // The audio mixes, reading at most 100 source samples per comp sample (a stretch of 1 %). + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stored}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stored}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} + +/// A stretch set in memory (not through a file, so not repaired) mixes as the clamped stretch the +/// layer's time uses: before, 1e-300 % read about `usize::MAX` source samples and overflowed. +#[test] +fn an_in_memory_extreme_stretch_mixes_with_bounded_reads() { + for stretch in [1e-300, 0.0, f64::NAN, 1e300] { + let (text, cid) = file_with_stretch(100.0); + let (mut p, _) = Project::from_json_repaired(&text).unwrap(); + p.comp_mut(cid).unwrap().layers.iter_mut().for_each(|l| l.stretch = stretch); + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stretch}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stretch}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} diff --git a/crates/render/src/tests/styles.rs b/crates/render/src/tests/styles.rs index 2450fcf64..a9de6fb52 100644 --- a/crates/render/src/tests/styles.rs +++ b/crates/render/src/tests/styles.rs @@ -507,3 +507,34 @@ fn workers_styling_a_layer_at_once_share_its_buffers() { assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes"); } } + +/// A styled layer served from the cache lists the files of the body and passes it was served, +/// not only its content's (#3184): one made of other files (another worker's, or from disk, +/// whose list can't vouch for them) passes that on to the buffers made of it (flattened, in the +/// blending space), which are then not kept on disk as complete. +#[test] +fn a_cached_styled_layer_lists_the_files_of_its_cached_buffers() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "dropShadow")); + let layer = &p.comp(cid).unwrap().layers[0]; + let styled = |cache: &crate::LayerCache| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + r.styled_layer(&ctx, layer).unwrap() + }; + let first = styled(&crate::LayerCache::default()); + assert!(first.files.is_complete() && !first.passes.is_empty()); + let key = first.key.unwrap(); + // Body and passes kept with a list that can't vouch for them; the content renders again. + let cache = crate::LayerCache::default(); + let mut unsure = crate::reads::ReadFiles::default(); + unsure.mark_incomplete(); + let unsure = std::sync::Arc::new(unsure); + cache.insert(key, first.body.clone(), unsure.clone()); + for (i, (b, _)) in first.passes.iter().enumerate() { + cache.insert(crate::cache::derive(key, i as u64 + 1), b.clone(), unsure.clone()); + } + let hit = styled(&cache); + assert!(std::sync::Arc::ptr_eq(&hit.body, &first.body), "the cached body"); + assert!(!hit.files.is_complete(), "{:?}", hit.files); +} diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index e9599f70b..f959401bb 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -1,7 +1,10 @@ //! Time effects through the renderer: neighbouring frames via `EffectHost::self_at`, and the //! layer cache staying correct while scrubbing. +use std::sync::Arc; + use effectcraft_color::Label; +use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, register_plugin}; use effectcraft_keyframe::{Keyframe, ShapePath, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Project, Solid}; @@ -143,6 +146,7 @@ fn input_keys_ignore_later_effects() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) @@ -156,3 +160,89 @@ fn input_keys_ignore_later_effects() { set(&mut p, "effects/#1/blurriness", 8.0); assert_ne!(key(&p).0, one); } + +/// A plug-in that reads the layer's neighbouring frames (WebAssembly ABI 2) makes its layer's +/// cache key depend on the layer time, even when the source and every property are static. A +/// plug-in that does not keeps the old key (#1977: a static layer stays cached across a scrub). +#[test] +fn abi2_plugin_layers_key_on_layer_time() { + struct NeighbourReader { + m: PluginManifest, + } + + impl EffectPlugin for NeighbourReader { + fn manifest(&self) -> &PluginManifest { + &self.m + } + + fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> { + Ok(()) + } + + fn reads_other_times(&self) -> bool { + true + } + } + + struct Plain { + m: PluginManifest, + } + + impl EffectPlugin for Plain { + fn manifest(&self) -> &PluginManifest { + &self.m + } + + fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> { + Ok(()) + } + } + + /// A static white solid with one plug-in effect, registered by `plugin`. + fn plugin_scene(plugin: Arc) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 200, height: 100, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (200, 100), None); + let spec = register_plugin(plugin).unwrap(); + let mut next = p.next_id; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]); + p.next_id = next; + l.props.sub_mut("effects").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) + } + + fn key(p: &Project, cid: ItemId, t: f64) -> Option { + let comp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx { + project: p, + comp_id: cid, + comp, + time: Tick::from_seconds_f64(t), + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; + crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false) + } + + let manifest = |id: &str| PluginManifest { + api: 2, + id: id.into(), + name: "Key".into(), + category: "Test Plug-ins".into(), + version: "1".into(), + author: String::new(), + description: String::new(), + params: vec![], + }; + + let (plain, c1) = plugin_scene(Arc::new(Plain { m: manifest("org.test.render.time-key.plain") })); + assert_eq!(key(&plain, c1, 0.25), key(&plain, c1, 0.5), "a plain plug-in keeps the old key"); + let (reader, c2) = plugin_scene(Arc::new(NeighbourReader { m: manifest("org.test.render.time-key.reader") })); + assert_ne!(key(&reader, c2, 0.25), key(&reader, c2, 0.5), "an ABI 2 plug-in keys on the layer time"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 683da82ee..eef5b4067 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -198,6 +198,35 @@ pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool { /// 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]> { + animator_selection_within(ctx, layer, anim, lay, &SelectorBudget::new()) +} + +/// The Expression Selector evaluations one layer's animators may run in all: counted, not timed, +/// so the same frame renders the same on every machine. +pub struct SelectorBudget(std::cell::Cell); + +impl SelectorBudget { + pub fn new() -> SelectorBudget { + SelectorBudget(std::cell::Cell::new(MAX_SELECTOR_EVALS)) + } + + /// Take one evaluation; `false` when none are left. + fn take(&self) -> bool { + let left = self.0.get(); + self.0.set(left.saturating_sub(1)); + left > 0 + } +} + +impl Default for SelectorBudget { + fn default() -> Self { + Self::new() + } +} + +/// [`animator_selection`] drawing its Expression Selector evaluations from `budget`, which the +/// caller shares across the layer's animators. +pub fn animator_selection_within(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout, budget: &SelectorBudget) -> Vec<[f64; 3]> { let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default(); if sels.is_empty() { // No selectors: every character is fully affected. @@ -243,13 +272,38 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: & } "expressionSelector" => { let Some(prop) = sel.get("amount") else { continue }; - let stat = ctx.v3(layer, sel, "amount", [100.0; 3]); + // Evaluated per character (`textIndex`): the layer's cache key holds the amount + // for the first character only, so the buffer must not outlive the session. + if prop.has_expression() + && let Some(log) = ctx.reads + { + log.mark_unkeyed(); + } + // The keyframed amount, read without running its expression (the fallback when the + // expression cannot run); an Essential Property override still applies. + let stat = if prop.has_expression() { + // The expression is not run here, but an Essential Property override still wins over the base value. + match ctx.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) { + Some(o) => o.value(layer, prop, ctx.time), + None => prop.value_at(layer.layer_time(ctx.time)), + } + } else { + ctx.value(layer, prop) + } + .as_vec3(); + // One allowance for the whole layer (`budget`, shared by its animators): the + // expression runs per character, so a slow expression on a long text is stopped + // once it has failed or the layer's evaluations pass MAX_SELECTOR_EVALS. + let stopped = std::cell::Cell::new(false); Box::new(move |i, n, prev| { let pv = prev.map(|x| x * 100.0); // Without an expression engine: the static amount scales the selection. let fallback = [0, 1, 2].map(|d| stat[d] * prev[d]); - let r = match ctx.expr.filter(|_| prop.has_expression()) { - Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(fallback), + let r = match ctx.expr.filter(|_| prop.has_expression() && !stopped.get() && budget.take()) { + Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or_else(|_| { + stopped.set(true); + fallback + }), None => fallback, }; r.map(|x| (x / 100.0).clamp(-1.0, 1.0)) @@ -275,13 +329,17 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: & acc } +/// The most Expression Selector evaluations one layer's animators run across all its characters +/// (a text of 32,768 characters under one expression selector, or fewer under several). +const MAX_SELECTOR_EVALS: usize = 32_768; /// Evaluate animators for every glyph. pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &TextLayout) -> Vec { let mut out = vec![CharXf::default(); lay.glyphs.len()]; let Some(anims) = text.sub("animators") else { return out }; + let budget = SelectorBudget::new(); for anim in anims.groups().filter(|g| g.enabled) { let Some(props) = anim.sub("properties") else { continue }; - let sel = animator_selection(ctx, layer, anim, lay); + let sel = animator_selection_within(ctx, layer, anim, lay, &budget); let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p).components()); let get1 = |m: &str| props.get(m).map(|p| ctx.value(layer, p).as_f64()); let c3 = |v: Vec, d: f64| [v.first().copied().unwrap_or(d), v.get(1).copied().unwrap_or(d), v.get(2).copied().unwrap_or(d)]; @@ -502,6 +560,16 @@ pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool { layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d")) } +/// Animator blur can read glyph pixels beyond a viewport raster window. Conservatively +/// keep the whole raster even when the current selector happens to select no characters. +pub(crate) fn animator_blur(ctx: &EvalCtx, layer: &Layer) -> bool { + layer.props.group("text/animators").is_some_and(|anims| { + anims.groups().filter(|anim| anim.enabled).any(|anim| { + anim.sub("properties").and_then(|props| props.get("blur")).is_some_and(|blur| ctx.value(layer, blur).as_vec2().iter().any(|value| *value != 0.0)) + }) + }) +} + /// One drawn character. #[derive(Clone, Debug)] pub struct PlacedGlyph { @@ -638,11 +706,73 @@ fn align_index(j: Justify) -> u8 { } } +/// Note the font files `lay` is drawn with in [`EvalCtx::reads`] (#3184), each with its identity +/// as its bytes were read, so a cache entry of the text is checked against them. What the +/// files can't vouch for makes the list incomplete: a family that is not installed (installing +/// it changes the text), a character drawn with a fallback face or with none (another installed +/// font could take it), a font from memory. +fn note_fonts(ctx: &EvalCtx, lay: &TextLayout) { + note_layout_fonts(ctx, &lay.styles, &lay.layout); +} + +/// A character put in by Character Offset or Character Value (`ch`, in style `st`), outside a +/// relayout: its outline and advance, with the font it is drawn with noted as [`note_fonts`] does +/// (the run's face may not have it: a fallback face then draws it). +fn substituted_glyph(ctx: &EvalCtx, st: &effectcraft_keyframe::CharStyle, ch: char) -> (BezPath, f64) { + if ctx.reads.is_some() { + // (the layout `char_glyph_style` makes, served from the layout cache there) + let lay = effectcraft_text::layout_text(&ch.to_string(), &effectcraft_text::text_style(st), &effectcraft_text::ParagraphStyle::default()); + note_layout_fonts(ctx, std::slice::from_ref(st), &lay); + } + char_glyph_style(st, ch) +} + +/// [`note_fonts`] for `layout`, whose glyphs' runs index `styles`. +fn note_layout_fonts(ctx: &EvalCtx, styles: &[effectcraft_keyframe::CharStyle], layout: &effectcraft_text::Layout) { + use crate::reads::{FileStamp, ReadFile}; + use effectcraft_text::fonts; + let Some(log) = ctx.reads else { return }; + let resolved: Vec = styles.iter().map(|s| fonts::resolve(&s.font, &s.style)).collect(); + if resolved.iter().any(|r| r.missing) { + log.mark_incomplete(); + } + // Each run's face, glyphs or not: an empty paragraph's face sets its line's metrics (in a + // box, the first baseline that places every later line). + let mut faces: Vec = Vec::new(); + for r in &resolved { + if !faces.contains(&r.face) { + faces.push(r.face); + } + } + for g in &layout.glyphs { + if g.id == 0 || resolved.get(g.run).is_none_or(|r| r.face != g.face) { + log.mark_incomplete(); + } + if !faces.contains(&g.face) { + faces.push(g.face); + } + } + for id in faces { + let face = fonts::face(id); + match &face.info.data { + fonts::FaceData::Static(_) => {} + fonts::FaceData::Shared(_) => log.mark_incomplete(), + fonts::FaceData::File(_) => { + if let Some((path, meta)) = face.file_read() { + let stamp = meta.map_or(FileStamp::Missing, FileStamp::of_metadata); + log.note(ReadFile::new(path.to_string_lossy().as_ref(), stamp)); + } + } + } + } +} + /// Lay out and animate a text layer. pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let doc = source_text(ctx, layer)?; let text = layer.props.sub("text")?; let lay = layout_doc(&doc); + note_fonts(ctx, &lay); let xfs = char_transforms(ctx, layer, text, &lay); let three = per_char_3d(ctx, layer); let more = text.sub("moreOptions"); @@ -665,6 +795,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { // Same character count: the style runs still line up. let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() }; let l2 = layout_doc(&d2); + note_fonts(ctx, &l2); let by_char: std::collections::HashMap = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect(); let x2: Vec = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect(); subs = vec![None; l2.glyphs.len()]; @@ -789,7 +920,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { // Outline (substituted characters aligned in the original slot). let (outline, adv) = match subs[gi] { Some(c) => { - let (p, a) = char_glyph_style(st, c); + let (p, a) = substituted_glyph(ctx, st, c); let dx = match x.char_align { 0 => 0.0, 2 => g.advance - a, @@ -1052,8 +1183,46 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt } } +/// Buffer pixels kept around a clip (see [`raster_window`]). +const CLIP_PAD: f64 = 24.0; + +/// The buffer for content bounds `b` (layer space) at scale `s`: (width, height, layer → buffer +/// offset), two pixels of padding around it. With `clip`, only the pixels of that grid inside +/// `clip`, padded by [`CLIP_PAD`]: a whole-pixel window of the whole buffer's grid. Its pixels are +/// the whole buffer's, except that a glyph cut by the window is rasterised from the window's edge: +/// tiny-skia's 4 × 4 anti-aliasing then lands a sub-sample differently on a few edge pixels, up to +/// a dozen rows in from that edge — inside the padding, out of the frame. `None` when `clip` +/// misses the content. +pub(crate) fn raster_window(b: kurbo::Rect, s: f64, clip: Option) -> Option<(u32, u32, [f64; 2])> { + let (full_w, full_h) = ((b.width() * s).ceil() + 4.0, (b.height() * s).ceil() + 4.0); + let full = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let (x0, y0, x1, y1) = match clip { + None => (0.0, 0.0, full_w, full_h), + // Nothing shows (behind the camera). + Some(c) if c.area() <= 0.0 => return None, + Some(c) => ( + (c.x0 * s + full[0] - CLIP_PAD).floor().max(0.0), + (c.y0 * s + full[1] - CLIP_PAD).floor().max(0.0), + (c.x1 * s + full[0] + CLIP_PAD).ceil().min(full_w), + (c.y1 * s + full[1] + CLIP_PAD).ceil().min(full_h), + ), + }; + if !(x1 > x0 && y1 > y0) { + return None; + } + let w = ((x1 - x0) as u32).clamp(1, 16384); + let h = ((y1 - y0) as u32).clamp(1, 16384); + Some((w, h, [full[0] - x0, full[1] - y0])) +} + /// Render a text layer into a layer-space buffer at scale `s`. pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { + render_in(ctx, layer, s, None) +} + +/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows, +/// see `Renderer::raster_clip`). +pub fn render_in(ctx: &EvalCtx, layer: &Layer, s: f64, clip: Option) -> Buf { let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s }; let Some(geom) = text_geom(ctx, layer) else { return empty() }; let mut bounds: Option = None; @@ -1067,9 +1236,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { } let Some(b) = bounds else { return empty() }; let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); - let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); - let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); - let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let Some((w, h, offset)) = raster_window(b, s, clip) else { return empty() }; let mut img = Image::new(w, h); let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); draw_glyphs(&geom, &mut img, &base, s, None); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 0280115a5..bbc1cd28e 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -715,3 +715,99 @@ fn range_bar_drag_values() { assert_eq!(range_value_of(3.6 / 7.0, 0.0, true, 7), 4.0); assert_eq!(range_value_of(3.6 / 7.0, 2.0 / 7.0, true, 7), 2.0, "less a 2-character Offset"); } + +/// Text names the font files it is drawn with (#3184), with their identity as they were read, so +/// a cache entry of it is checked against them; what the files can't vouch for (a family that +/// isn't installed) leaves the list incomplete. +#[test] +fn text_notes_the_font_files_it_draws_with() { + use crate::reads::{FileStamp, ReadLog}; + use effectcraft_text::fonts::{self, FaceData, FaceInfo, FontSource}; + struct OneFile(std::path::PathBuf); + impl FontSource for OneFile { + fn faces(&self) -> Vec { + vec![FaceInfo { + family: "Reads Test Sans".into(), + style: "Regular".into(), + weight: 400, + italic: false, + index: 0, + data: FaceData::File(self.0.clone()), + origin: "system", + native_family: None, + full_name: "Reads Test Sans Regular".into(), + postscript: None, + variable: false, + instances: vec![], + }] + } + } + // (a fresh directory, removed when the test ends, panicking or not) + let dir = tempfile::tempdir().unwrap(); + let font = dir.path().join("reads-test-sans.ttf"); + std::fs::write(&font, fonts::INTER_REGULAR).unwrap(); + fonts::add_source(&OneFile(font.clone())); + let stamp = FileStamp::native(&font.to_string_lossy()).unwrap(); + // (`value`: a Character Value animator puts that character in for every one) + let files_with = |family: &str, value: Option| { + let (mut p, cid, comp) = setup(200, 80); + let mut l = + text_layer(&mut p, &comp, TextDoc { text: "Hi there\rtwo".into(), font: family.into(), style: "Regular".into(), size: 30.0, ..Default::default() }); + if let Some(c) = value { + animator(&mut p, &mut l, &["characterValue"], &["range"]); + set(&mut l, "text/animators/#1/properties/characterValue", Value::Scalar(c as u32 as f64)); + } + p.comp_mut(cid).unwrap().layers.push(l); + let log = ReadLog::new(); + let c = EvalCtx { reads: Some(&log), ..ctx(&p, cid, 0.0) }; + assert!(text_geom(&c, &p.comp(cid).unwrap().layers[0]).is_some()); + log.into_files() + }; + let files_of = |family: &str| files_with(family, None); + let files = files_of("Reads Test Sans"); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.files().len(), 1, "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp)); + // The bundled family reads no file. + let files = files_of(effectcraft_text::fonts::DEFAULT_FAMILY); + assert!(files.is_complete() && files.is_empty(), "{files:?}"); + // A family that isn't installed falls back: installing it would change the text. + assert!(!files_of("No Such Family 3184").is_complete()); + // A substituted character the face has is drawn with it: the same one file. + let files = files_with("Reads Test Sans", Some('B')); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp)); + // One it lacks (Inter has no CJK) is drawn with a fallback face, or none: another installed + // font could take it, so the list can't vouch for the text. + assert!(!files_with("Reads Test Sans", Some('\u{4e2d}')).is_complete()); + // An empty first paragraph in the face draws no glyph, but in a box its face sets the first + // baseline that places the lines after it: the file is read all the same. + let (mut p, cid, comp) = setup(200, 80); + let base = TextDoc { + text: "\rHi".into(), + font: fonts::DEFAULT_FAMILY.into(), + style: "Regular".into(), + size: 30.0, + box_size: Some([180.0, 60.0]), + ..Default::default() + }; + let first = effectcraft_keyframe::CharStyle { font: "Reads Test Sans".into(), ..base.base_style() }; + let runs = vec![effectcraft_keyframe::StyleRun { len: 1, style: first }, effectcraft_keyframe::StyleRun { len: 2, style: base.base_style() }]; + let l = text_layer(&mut p, &comp, TextDoc { runs, ..base }); + p.comp_mut(cid).unwrap().layers.push(l); + let log = ReadLog::new(); + let c = EvalCtx { reads: Some(&log), ..ctx(&p, cid, 0.0) }; + assert!(text_geom(&c, &p.comp(cid).unwrap().layers[0]).is_some()); + let files = log.into_files(); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp), "{files:?}"); +} + +#[test] +fn selector_evaluations_are_counted_across_the_layer_not_timed() { + let b = SelectorBudget(std::cell::Cell::new(3)); + // Taken by any animator's selectors in turn; the fourth finds none, and stays none. + assert!([b.take(), b.take(), b.take()].iter().all(|t| *t)); + assert!(!b.take() && !b.take()); + assert_eq!(SelectorBudget::new().0.get(), MAX_SELECTOR_EVALS); +} diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 1c37a6267..f0d6028f9 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -126,11 +126,14 @@ fn setup(s: &Scene, clip: &[[f32; 4]], range: std::ops::Range) -> Vec= bbox[2] || bbox[1] >= bbox[3] { continue; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 22507069e..6d2b071d5 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -477,6 +477,7 @@ fn clip_start(ctx: &EvalCtx, layer: &Layer, prop: &Property, lt: Tick) -> Tick { start } +/// Adds a model layer to the Advanced 3D scene with its material options, opacity and world transform. fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let lin = b.s.linear_io; let (opts, prim_mat) = model_options(ctx, layer, lin); @@ -487,7 +488,7 @@ fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { LayerSource::Model { item } => { let Some(it) = r.project.item(*item) else { return }; let ItemKind::Footage(f) = &it.kind else { return }; - let Some(m) = r.footage.model(*item, f) else { return }; + let Some(m) = r.read(|| r.footage.model(*item, f)) else { return }; let unit = geo.map_or(1.0, |g| ctx.f(layer, g, "unitScale", 1.0)); let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize; let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len()); @@ -651,12 +652,13 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude out } +/// Adds an extruded layer to the Advanced 3D scene with its card material, opacity and world transform. fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) { let base = card_material(ctx, layer); let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); let lin = b.s.linear_io; - for (mesh, c, local) in extruded_meshes(ctx, layer, p) { + for (mesh, c, local) in extruded_meshes(&r.with_reads(ctx), layer, p) { let l = |v: f32| if lin { v } else { srgb_to_linear(v) }; let m = b.material(Material { base: [l(c[0]), l(c[1]), l(c[2]), c[3]], @@ -694,13 +696,14 @@ fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) { b.emit(&p, m, &|_| world); } +/// Adds a flat card layer to the Advanced 3D scene with its material, opacity and world transform. fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let mut mat = card_material(ctx, layer); mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); if crate::text::per_char_3d(ctx, layer) { - for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) { - let buf = r.to_blend(Arc::new(buf), None); + for (buf, m) in crate::text::per_char_planes(&r.with_reads(ctx), layer, r.opts.scale) { + let buf = r.to_blend(Arc::new(buf), None, &Arc::default()); card(b, &buf, world * m, mat); } return; diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index b2612de98..88e805882 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -822,6 +822,7 @@ fn extruded_text_renders_as_a_mesh() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -870,6 +871,7 @@ fn shape_surface_items_colour_the_extruded_surfaces() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -915,6 +917,7 @@ fn text_animators_colour_the_extruded_surfaces() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -1122,6 +1125,7 @@ fn extruded_strokes_render_as_bevelled_meshes() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 6aee50a78..18f6dfe8a 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -302,6 +302,13 @@ pub fn auto_orient_2d(ctx: &EvalCtx, layer: &Layer) -> f64 { path_tangent(ctx, layer).map(|t| t.y.atan2(t.x).to_degrees()).unwrap_or(0.0) } +/// The most camera-facing layers whose parents are evaluated inside one another. +const MAX_ORIENT_NESTING: usize = 8; + +thread_local! { + static ORIENT_NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Whether `id` is `layer` or one of its parents. fn in_parent_chain(ctx: &EvalCtx, layer: &Layer, id: effectcraft_project::LayerId) -> bool { let mut cur = Some(layer); @@ -336,8 +343,27 @@ pub fn auto_orient_3d(ctx: &EvalCtx, layer: &Layer, pos: Vec3) -> Option { if ctx.comp.active_camera(ctx.time).is_some_and(|c| in_parent_chain(ctx, c, layer.id)) { return None; } + // The parent's world matrix evaluates the parent's own orientation: a parent cycle + // of camera-facing layers would recurse without end, and a chain of them is walked + // again from every link. Past MAX_ORIENT_NESTING the layer keeps its Orientation. + let nesting = ORIENT_NESTING.with(|n| n.get()); + if nesting >= MAX_ORIENT_NESTING { + return None; + } + // Restored when this scope ends, a panic unwinding through it included (the CPU retry + // after one must not start with a raised count). + struct Restore(usize); + impl Drop for Restore { + fn drop(&mut self) { + ORIENT_NESTING.with(|n| n.set(self.0)); + } + } + ORIENT_NESTING.with(|n| n.set(nesting + 1)); + let restore = Restore(nesting); + let parent = parent_world(ctx, layer); + drop(restore); let eye = active_camera(ctx).eye; - let e = parent_world(ctx, layer).inverse().unwrap_or(Mat4::IDENTITY).apply(eye); + let e = parent.inverse().unwrap_or(Mat4::IDENTITY).apply(eye); let d = pos - e; (d.length() > 1e-9).then(|| basis_rotation(d, vec3(0.0, 1.0, 0.0))) } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index a5a7c170e..8713833c3 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -185,6 +185,52 @@ pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) { (c.width as f64, c.height as f64) } +/// The layer-space rectangle of a 3D layer that this render's output can show: the output frame +/// (padded by two pixels) projected back onto the layer's plane, the way [`geo`] draws it. `None` +/// when that can't be bounded: an orthographic camera, depth of field (its blur reads past the +/// frame), motion blur (each sub-sample sees another part), or a frame corner whose ray misses the +/// plane in front of the camera (a plane seen towards its horizon). An empty rectangle when the +/// whole content is behind the camera. +pub(crate) fn visible_clip(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option { + let cam = camera_for(r, ctx); + if cam.ortho || cam.dof.is_some() || mb_samples(r, ctx, layer) > 1 { + return None; + } + let comp = view_size(r, ctx); + let s = r.opts.scale; + let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); + let world = outer * ctx.world_matrix(layer); + let (off, w, h) = match r.roi_offset() { + Some((x, y, w, h)) => ((x, y), w as f64, h as f64), + None => ((0.0, 0.0), (comp.0 * s).round(), (comp.1 * s).round()), + }; + let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(s, s, 1.0)) * cam.projection(comp.0, comp.1); + let hinv = (proj * world).plane_to_mat3().inverse()?; + let cv = cam.view * world; + let depth = |u: f64, v: f64| cv.0[2][0] * u + cv.0[2][1] * v + cv.0[2][3]; + if let Some(b) = crate::content_bounds(ctx, layer) + && [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].iter().all(|&(u, v)| depth(u, v) < NEAR) + { + return Some(kurbo::Rect::ZERO); + } + const PAD: f64 = 2.0; + let mut rect: Option = None; + for (sx, sy) in [(-PAD, -PAD), (w + PAD, -PAD), (w + PAD, h + PAD), (-PAD, h + PAD)] { + let m = &hinv.0; + let qz = m[2][0] * sx + m[2][1] * sy + m[2][2]; + if qz <= 1e-300 { + return None; + } + let (u, v) = ((m[0][0] * sx + m[0][1] * sy + m[0][2]) / qz, (m[1][0] * sx + m[1][1] * sy + m[1][2]) / qz); + if !u.is_finite() || !v.is_finite() || depth(u, v) < NEAR { + return None; + } + let p = kurbo::Rect::new(u, v, u, v); + rect = Some(rect.map_or(p, |r| r.union(p))); + } + rect +} + /// Number of motion-blur sub-samples for a layer. fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) { @@ -203,9 +249,11 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, ord /// Prepare a layer as planes: one per character for per-character 3D text, else one. pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { - return crate::text::per_char_planes(ctx, layer, r.opts.scale) + return crate::text::per_char_planes(&r.with_reads(ctx), layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None), local: m })) + .filter_map(|(buf, m)| { + prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None, &Arc::default()), local: m }) + }) .collect(); } prepare(r, ctx, layer, order, out, in_run).into_iter().collect() diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 11d837677..21774cb24 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -79,6 +79,7 @@ pub fn install(s: &mut Session) { s.script = Some(runner); s.script_ui.dispatch = Some(dispatch_ui); s.script_ui.tasks = Some(run_tasks); + s.script_ui.poll_render = Some(ui::poll_render_wait); } /// Run `code` as a one-off script named `name`. diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index e662c1cdc..62f3a8557 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -5,6 +5,7 @@ use effectcraft_engine::Session; use effectcraft_engine::color::Label; use effectcraft_keyframe::{Interp, TextDoc, Value as KV}; +use effectcraft_project::ae_order::{self, AeChild}; use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup}; use effectcraft_render::EvalCtx; use effectcraft_time::Tick; @@ -318,7 +319,8 @@ fn indexed_parade(g: &PropGroup) -> bool { fn node_json(c: &Comp, l: &Layer, uid: u64) -> R { if uid == 0 || uid == l.props.uid { - let n = l.props.children.len(); + // After Effects' count (every slot, used or not); 3D model layers count their own groups. + let n = ae_order::ae_count(l).unwrap_or(l.props.children.len()); return Ok(json!({ "uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer", "index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n, @@ -329,7 +331,8 @@ fn node_json(c: &Comp, l: &Layer, uid: u64) -> R { let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?; let node = *ch.last().ok_or("bad property")?; let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props }; - let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0); + let top = if ch.len() == 1 { ae_order::ae_index(l, node.match_id()) } else { None }; + let index = top.unwrap_or_else(|| parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0)); let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid }; let match_name = matchnames::node(&ch); let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents"); @@ -475,6 +478,25 @@ fn child(s: &Session, a: &J) -> R { let uid = u(a, "uid").unwrap_or(0); let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? }; let key = a.get("key").cloned().unwrap_or(J::Null); + // A layer's numbers and match names are After Effects' (Marker is 1, unused groups keep their + // slot and resolve to the same empty group by number or by match name). + let slot = match &key { + J::Number(n) => Some(n.as_u64().and_then(|i| usize::try_from(i).ok())), + J::String(name) => ae_order::ae_index_of_match_name(l, name).map(Some), + _ => None, + }; + if std::ptr::eq(g, &l.props) + && ae_order::ae_slots(&l.source).is_some() + && let Some(slot) = slot + { + let Some(i) = slot else { return Ok(J::Null) }; + return match ae_order::ae_child(l, i) { + Some(AeChild::Node(n)) => node_json(c, l, n.uid()), + Some(AeChild::Marker) => Ok(json!({"marker": true})), + Some(AeChild::Absent(s)) => Ok(json!({"absent": true, "name": s.name, "matchName": s.match_name, "index": i})), + None => Ok(J::Null), + }; + } let found = match &key { J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)), J::String(k) => find_child(l, g, k, NESTED_CHILD_DEPTH), diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index b9108b2a1..ce6cd44f4 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -26,7 +26,7 @@ function __err(msg) { } function __num(v, what) { var n = Number(v); - if (typeof v === "boolean" || isNaN(n)) throw __err((what || "value") + " must be a number"); + if (typeof v === "boolean" || Number.isNaN(n)) throw __err((what || "value") + " must be a number"); return n; } function __str(v) { @@ -43,11 +43,11 @@ if (typeof String.prototype.substr !== "function") { if (this === null || this === undefined) throw new TypeError("String.substr called on null or undefined"); var text = String(this), n = text.length; var from = Number(start); - if (isNaN(from)) from = 0; + if (Number.isNaN(from)) from = 0; // ToIntegerOrInfinity, then clamp positive or negative offsets. from = from < 0 ? Math.max(n + Math.ceil(from), 0) : Math.min(Math.floor(from), n); var count = length === undefined ? n - from : Number(length); - if (isNaN(count) || count <= 0) return ""; + if (Number.isNaN(count) || count <= 0) return ""; return text.slice(from, from + count); }, writable: true, configurable: true @@ -79,9 +79,10 @@ BlendingMode.SILHOUETTE_ALPHA = BlendingMode.SILHOUETE_ALPHA; var KeyframeInterpolationType = __enum("KeyframeInterpolationType", ["LINEAR", "BEZIER", "HOLD"], 6612); var TrackMatteType = __enum("TrackMatteType", ["NO_TRACK_MATTE", "ALPHA", "ALPHA_INVERTED", "LUMA", "LUMA_INVERTED"], 5012); var LightType = __enum("LightType", ["PARALLEL", "SPOT", "POINT", "AMBIENT", "ENVIRONMENT"], 4412); +// In After Effects' order and numbers (26.3): right comes before centre. var ParagraphJustification = __enum("ParagraphJustification", [ - "LEFT_JUSTIFY", "CENTER_JUSTIFY", "RIGHT_JUSTIFY", "FULL_JUSTIFY_LASTLINE_LEFT", "FULL_JUSTIFY_LASTLINE_CENTER", - "FULL_JUSTIFY_LASTLINE_RIGHT", "FULL_JUSTIFY_LASTLINE_FULL", "MULTIPLE_JUSTIFICATIONS"], 7413); + "MULTIPLE_JUSTIFICATIONS", "LEFT_JUSTIFY", "RIGHT_JUSTIFY", "CENTER_JUSTIFY", "FULL_JUSTIFY_LASTLINE_LEFT", + "FULL_JUSTIFY_LASTLINE_RIGHT", "FULL_JUSTIFY_LASTLINE_CENTER", "FULL_JUSTIFY_LASTLINE_FULL"], 7412); var PropertyValueType = __enum("PropertyValueType", [ "NO_VALUE", "ThreeD_SPATIAL", "ThreeD", "TwoD_SPATIAL", "TwoD", "OneD", "COLOR", "CUSTOM_VALUE", "MARKER", "LAYER_INDEX", "MASK_INDEX", "SHAPE", "TEXT_DOCUMENT"], 6412); @@ -120,7 +121,14 @@ function __blendLabel(v) { var __interpKey = { 6612: "linear", 6613: "bezier", 6614: "hold" }; var __matteKind = { 5013: "alpha", 5014: "alphaInverted", 5015: "luma", 5016: "lumaInverted" }; var __matteEnum = { Alpha: 5013, AlphaInverted: 5014, Luma: 5015, LumaInverted: 5016 }; -var __justKey = { 7413: "left", 7414: "center", 7415: "right", 7416: "justifyLeft", 7417: "justifyCenter", 7418: "justifyRight", 7419: "justifyAll" }; +var __justKey = {}; +__justKey[ParagraphJustification.LEFT_JUSTIFY] = "left"; +__justKey[ParagraphJustification.CENTER_JUSTIFY] = "center"; +__justKey[ParagraphJustification.RIGHT_JUSTIFY] = "right"; +__justKey[ParagraphJustification.FULL_JUSTIFY_LASTLINE_LEFT] = "justifyLeft"; +__justKey[ParagraphJustification.FULL_JUSTIFY_LASTLINE_CENTER] = "justifyCenter"; +__justKey[ParagraphJustification.FULL_JUSTIFY_LASTLINE_RIGHT] = "justifyRight"; +__justKey[ParagraphJustification.FULL_JUSTIFY_LASTLINE_FULL] = "justifyAll"; // ------------------------------------------------------------------ output, $ and dialogs @@ -292,6 +300,34 @@ function Folder(path) { if (!(this instanceof Folder)) return new Folder(path); this.__path = path === undefined ? "" : __str(path); } + +// ECMAScript's legacy case canonicalisation (what a `/i` regular expression compares with): the +// upper case form when it is one code unit, and not an ASCII letter for a non-ASCII one. +function __canon(ch) { + var u = ch.toUpperCase(); + if (u.length !== 1) return ch; + if (ch.charCodeAt(0) >= 128 && u.charCodeAt(0) < 128) return ch; + return u; +} + +// A case-insensitive `*` / `?` file mask match in time bounded by mask × name length (a +// regular expression of `.*` runs backtracks combinatorially on overlapping wildcards). +function __globMatch(mask, name) { + // Wildcards run over the original name (lowercasing it can change its length: "İ" becomes + // two code units); literal characters compare one at a time, case-insensitively as `/i` does + // ("ς" and "σ" and "Σ" match each other). + var p = mask, s = name; + var i = 0, j = 0, star = -1, mark = 0; + while (j < s.length) { + if (i < p.length && (p[i] === "?" || p[i] === s[j] || __canon(p[i]) === __canon(s[j]))) { i++; j++; } + else if (i < p.length && p[i] === "*") { star = i++; mark = j; } + else if (star !== -1) { i = star + 1; j = ++mark; } + else return false; + } + while (i < p.length && p[i] === "*") i++; + return i === p.length; +} + Folder.prototype = { constructor: Folder, get fsName() { return this.__path; }, @@ -310,10 +346,7 @@ Folder.prototype = { var out = []; for (var i = 0; i < names.length; i++) { var n = names[i]; - if (typeof mask === "string" && mask !== "*") { - var re = new RegExp("^" + mask.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i"); - if (!re.test(n.split(/[\/\\]/).pop())) continue; - } + if (typeof mask === "string" && mask !== "*" && !__globMatch(mask, n.split(/[\/\\]/).pop())) continue; var isDir = JSON.parse(__file("isDir", n)); var f = isDir ? new Folder(n) : new File(n); if (typeof mask === "function" && !mask(f)) continue; @@ -436,7 +469,7 @@ function __callable(o) { // 1-based collections with `[n]` access. var __coll = { get: function (t, k, r) { - if (typeof k === "string" && /^[0-9]+$/.test(k)) return t.__at(parseInt(k, 10)); + if (typeof k === "string" && /^[0-9]+$/.test(k)) return t.__at(Number.parseInt(k, 10)); return Reflect.get(t, k, r); }, }; @@ -628,10 +661,46 @@ TextDocument.prototype = { function __propOf(layer, info) { if (!info) return null; if (info.isLayer) return layer; + if (info.marker) return layer.marker; + if (info.absent) return __absentGroup(layer, info); if (info.kind === "group") return info.isMask ? new MaskPropertyGroup(layer, info.uid) : new PropertyGroup(layer, info.uid); return new Property(layer, info.uid); } +// A top-level slot After Effects always has and this layer does not use yet (Layer Styles before +// any style, Data, Sets…): an empty group, so loops over `numProperties` meet no null. +function __absentGroup(layer, info) { + var g = { + __kind: "PropertyGroup", + get name() { return info.name; }, + get matchName() { return info.matchName; }, + get propertyIndex() { return info.index; }, + get propertyDepth() { return 1; }, + get propertyType() { return PropertyType.NAMED_GROUP; }, + get parentProperty() { return layer; }, + get numProperties() { return 0; }, + get enabled() { return true; }, + get active() { return layer.enabled; }, + get canSetEnabled() { return false; }, + get isModified() { return false; }, + get isEffect() { return false; }, + get isMask() { return false; }, + get elided() { return false; }, + get selected() { return false; }, + property: function () { return null; }, + propertyGroup: function (n) { return n === undefined || n === 1 ? layer : null; }, + canAddProperty: function () { return false; }, + addProperty: function (n) { throw __err("Can not add \"" + n + "\" to \"" + info.name + "\""); }, + toString: function () { return "[object PropertyGroup]"; }, + }; + // Callable like any group: `layer.property(7)(1)` is `.property(1)`, null. + var f = function (k) { return g.property(k); }; + delete f.name; + delete f.length; + for (const k of Object.getOwnPropertyNames(g)) Object.defineProperty(f, k, Object.getOwnPropertyDescriptor(g, k)); + return f; +} + function PropertyBase() {} PropertyBase.prototype = { __info: function () { return __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); }, @@ -867,6 +936,12 @@ Property.prototype.property = function () { return null; }; if (!idx) return null; return this.__layer.containingComp.layer(idx).__id; } + // A string parameter (a LUT or look file, a text field) takes the text, or a File's path (#3395). + // A hidden one (Mesh Warp's mesh) stays refused by the key setters too, as by setValue. + if (info.type === "string") { + if (info.hidden) throw __err("\"" + info.name + "\" can not be set"); + return __str(v); + } if (info.type === "enum") return __num(v) - 1; if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; if (info.type === "color") { @@ -1652,7 +1727,7 @@ CompItem.prototype.constructor = CompItem; P.saveFrameToPng = function (time, file) { var f = __str(file); if (!f) throw __err("saveFrameToPng: missing file"); - __file("write", f, ""); + __file("checkWrite", f); __call("comp.saveFrameAs", { comp: this.__id, path: f, time: __num(time, "time") }); }; P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; @@ -1662,8 +1737,8 @@ CompItem.prototype.constructor = CompItem; if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; if (this.__controllers().length === 0) return false; - // The file gate applies to templates too. - __file("write", f, ""); + // The file gate applies to templates too (checked without touching the file). + __file("checkWrite", f); __call("essential.exportTemplate", { comp: this.__id, path: f }); return true; }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 2c18039e8..28e490158 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -30,6 +30,8 @@ pub struct Outcome { /// The script is waiting in a modal ScriptUI dialog's `show()` (it goes on when the dialog /// closes; see [`crate::dispatch_ui`]). pub waiting: bool, + /// The native frontend owns the session while this host waits for its render worker. + pub render_host: Option, } /// An uncaught script error. @@ -55,6 +57,9 @@ impl Outcome { if self.waiting { j["waiting"] = json!(true); } + if let Some(host) = self.render_host { + j["renderHost"] = json!(host); + } j } } @@ -74,13 +79,16 @@ pub(crate) struct Active { /// Running on a script-host thread: modal dialogs hand the session back to the caller /// through this link while they wait. pub(crate) link: Option, + /// Captured at invocation start: the frontend resets its opt-in when the caller returns. + pub(crate) yield_renders: bool, /// The files `$.evalFile` is evaluating, outermost first. pub(crate) loading: Vec, } impl Active { pub(crate) fn new(session: Session, name: &str, host: u32) -> Active { - Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, loading: vec![] } + let yield_renders = session.script_ui.yield_renders; + Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, yield_renders, loading: vec![] } } } @@ -130,6 +138,13 @@ fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult exec_on_keys(&mut a.session, &id, params, k), @@ -137,7 +152,10 @@ fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(js_str(&v.to_string())), + Ok(Ok(v)) => { + let v = if yield_render { crate::ui::wait_for_render(ctx, v).map_err(throw)? } else { v }; + Ok(js_str(&v.to_string())) + } Ok(Err(e)) | Err(e) => Err(throw(e)), } } @@ -409,6 +427,8 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult check_access(s, &path, true).map(|_| json!(true)), "read" | "readBinary" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; @@ -512,8 +532,33 @@ fn native_eval_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResul r } +/// Host hooks: code a script compiles at run time (`eval`, `new Function`, …) passes the same +/// nesting check as the script itself, since the parser recurses per level and a deep string +/// built at run time would overflow the stack. +struct Hooks; + +impl boa_engine::context::HostHooks for Hooks { + fn ensure_can_compile_strings( + &self, + _realm: boa_engine::realm::Realm, + parameters: &[JsString], + body: &JsString, + _direct: bool, + _context: &mut Context, + ) -> JsResult<()> { + let mut code: String = parameters.iter().map(|p| p.to_std_string_escaped()).collect::>().join(","); + // With no parameters (`eval`) the body is the source as it stands, so a leading `#!` line + // is still its first line; with parameters it sits inside a function, where it is no comment. + if !parameters.is_empty() { + code.push('\n'); + } + code.push_str(&body.to_std_string_escaped()); + check_nesting(&code).map_err(|e| JsNativeError::syntax().with_message(e).into()) + } +} + pub(crate) fn new_context() -> Result { - let mut ctx = Context::default(); + let mut ctx = boa_engine::context::ContextBuilder::new().host_hooks(std::rc::Rc::new(Hooks)).build().map_err(|e| e.to_string())?; let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); @@ -718,6 +763,10 @@ pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome { }; let Some(x) = expanded else { return run_expanded(session, req) }; let mut out = run_expanded(session, &ScriptRequest { code: &x.code, name: req.name, console: req.console }); + if let Some(host) = out.render_host { + crate::ui::keep_render_source_map(host, x); + return out; + } if let Some(e) = out.error.as_mut() { x.locate(e); } @@ -747,19 +796,129 @@ fn run_expanded(session: &mut Session, req: &ScriptRequest) -> Outcome { /// 8 MiB thread such as the Script Console's, so deeper scripts are rejected up front. pub const MAX_NESTING: usize = 96; -/// An error when `code` nests brackets deeper than [`MAX_NESTING`] (strings, template literals -/// and comments are skipped). +/// Skip template-literal text from `*i` (just past a backtick or an interpolation's `}`). Returns +/// true when it stopped past a `${` (code follows), false at the closing backtick or the end. +fn skip_template_text(b: &[u8], i: &mut usize) -> bool { + while let Some(&d) = b.get(*i) { + match d { + b'\\' => *i += 2, + b'`' => { + *i += 1; + return false; + } + b'$' if b.get(*i + 1) == Some(&b'{') => { + *i += 2; + return true; + } + _ => *i += 1, + } + } + false +} + +/// Whether a `/` after the last significant byte `prev` (at `at` in `b`) starts a regular +/// expression literal rather than dividing: after an operator, an opening bracket, a separator, +/// a keyword that takes an expression, or at the start. After a name, a number, a string or a +/// closing bracket it divides (a regular expression after `)` is the one case this misreads). +/// `after_dot`: the word ending at `at` follows a `.` (a property name, never a keyword); a `++` +/// or `--` before the `/` is a postfix update, so it divides. +fn regex_may_start(b: &[u8], prev: u8, at: usize, after_dot: bool) -> bool { + match prev { + 0 => true, + b'+' | b'-' if at > 0 && b.get(at - 1) == Some(&prev) => false, + b'(' | b',' | b'=' | b':' | b'[' | b'!' | b'&' | b'|' | b'?' | b'{' | b';' | b'+' | b'-' | b'*' | b'%' | b'<' | b'>' | b'~' | b'^' => true, + _ if after_dot => false, + c if c.is_ascii_alphanumeric() || c == b'_' || c == b'$' => { + let start = (0..=at).rev().find(|&k| !b.get(k).is_some_and(|d| d.is_ascii_alphanumeric() || *d == b'_' || *d == b'$')).map_or(0, |k| k + 1); + let word = b.get(start..=at).unwrap_or_default(); + [&b"return"[..], b"typeof", b"instanceof", b"in", b"of", b"new", b"delete", b"void", b"throw", b"case", b"do", b"else", b"yield", b"await"] + .contains(&word) + } + _ => false, + } +} + +/// Whether the `.` at `at` is a property access: not the decimal point of a number (`1.`, `12.`) +/// that ends there. A number written `1.5.x` has a fraction before the dot, so that dot accesses. +fn dot_is_property(b: &[u8], at: usize) -> bool { + let word = |d: u8| d.is_ascii_alphanumeric() || d == b'_' || d == b'$'; + let start = (0..at).rev().find(|&k| !b.get(k).is_some_and(|d| word(*d))).map_or(0, |k| k + 1); + let before = b.get(start..at).unwrap_or_default(); + let integer = !before.is_empty() && before.iter().all(u8::is_ascii_digit); + // `1.5.x`: digits that follow another `.` are a fraction. + !(integer && !(start > 0 && b.get(start - 1) == Some(&b'.'))) +} + +/// Skip a regular expression literal that starts at `b[*i]` (a `/`), to its closing `/` (flags +/// follow as ordinary letters). A `/` inside a class `[…]` or after a backslash does not end it, +/// and neither a quote nor a backtick inside it starts a string; a line break ends it. +fn skip_regex(b: &[u8], i: &mut usize) { + *i += 1; + let mut class = false; + while let Some(&d) = b.get(*i) { + match d { + b'\\' => *i += 1, + b'[' => class = true, + b']' => class = false, + b'/' if !class => return, + b'\n' => return, + _ => {} + } + *i += 1; + } +} + +/// An error when `code` nests brackets deeper than [`MAX_NESTING`] (strings, regular expression +/// literals, template literal text and comments are skipped; the code inside `${…}` +/// interpolations is checked). pub fn check_nesting(code: &str) -> Result<(), String> { let b = code.as_bytes(); let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize); + // The last significant byte (not space or comment), and where it is. + let (mut prev, mut prev_at) = (0u8, 0usize); + // A `#!` line at the very start is a comment (Boa reads it so): its text is not scanned. + if b.starts_with(b"#!") { + i = b.iter().position(|d| *d == b'\n').unwrap_or(b.len()); + } + // Whether the word that ends at `prev_at` came right after a `.` token (comments and + // whitespace between them do not matter). + let mut word_after_dot = false; + // The bracket depth of each open `${`, to tell its closing `}` from an inner one. + let mut interpolations: Vec = vec![]; while let Some(&c) = b.get(i) { match c { + b'`' => { + i += 1; + if skip_template_text(b, &mut i) { + depth += 1; + max = max.max(depth); + interpolations.push(depth); + (prev, prev_at) = (b'{', i - 1); + } else { + (prev, prev_at) = (b'`', i.saturating_sub(1)); + } + continue; + } + b'}' if interpolations.last() == Some(&depth) => { + interpolations.pop(); + depth -= 1; + i += 1; + if skip_template_text(b, &mut i) { + depth += 1; + max = max.max(depth); + interpolations.push(depth); + (prev, prev_at) = (b'{', i - 1); + } else { + (prev, prev_at) = (b'`', i.saturating_sub(1)); + } + continue; + } b'(' | b'[' | b'{' => { depth += 1; max = max.max(depth); } b')' | b']' | b'}' => depth = depth.saturating_sub(1), - b'"' | b'\'' | b'`' => { + b'"' | b'\'' => { // Skip to the closing quote (escapes skip a byte). i += 1; while let Some(&d) = b.get(i) { @@ -775,16 +934,29 @@ pub fn check_nesting(code: &str) -> Result<(), String> { while b.get(i).is_some_and(|d| *d != b'\n') { i += 1; } + continue; } b'/' if b.get(i + 1) == Some(&b'*') => { i += 2; while i < b.len() && !(b.get(i) == Some(&b'*') && b.get(i + 1) == Some(&b'/')) { i += 1; } - i += 1; + i += 2; + continue; } + b'/' if regex_may_start(b, prev, prev_at, word_after_dot) => skip_regex(b, &mut i), _ => {} } + if !c.is_ascii_whitespace() { + let word = |d: u8| d.is_ascii_alphanumeric() || d == b'_' || d == b'$'; + // A word starts where the byte before it is not a word byte (white space and comments + // end a word too): it is a property name when a `.` that is not part of a number + // comes right before it. + if word(c) && (i == 0 || !b.get(i - 1).is_some_and(|d| word(*d))) { + word_after_dot = prev == b'.' && dot_is_property(b, prev_at); + } + (prev, prev_at) = (c, i); + } i += 1; } if max > MAX_NESTING { @@ -858,6 +1030,44 @@ mod nesting_tests { assert!(check_nesting(&("(".repeat(MAX_NESTING) + &")".repeat(MAX_NESTING))).is_ok()); } + #[test] + fn regular_expression_literals_are_skipped_not_read_as_strings_or_templates() { + let deep = |n: usize| format!("{}1{}", "(".repeat(n), ")".repeat(n)); + // A backtick, a quote or a slash inside a class in a regular expression opens nothing, so + // the deep expression after it is still counted. + for re in ["/`/", "/\"/", "/'/", "/[/]/", "/\\//"] { + assert!(check_nesting(&format!("`x${{ {re}.test('x') ? {} : 0 }}`", deep(5000))).is_err(), "{re} in an interpolation"); + assert!(check_nesting(&format!("x = {re}.test(y) ? {} : 0", deep(5000))).is_err(), "{re}"); + assert!(check_nesting(&format!("x = {re}.test(y) ? {} : 0", deep(10))).is_ok(), "{re} shallow"); + } + // Division is not a regular expression: what follows it is still scanned. + assert!(check_nesting(&format!("a = 4 / 2; b = {}", deep(5000))).is_err()); + assert!(check_nesting(&format!("a = (4) / 2 / 3; b = {}", deep(5000))).is_err()); + // After a keyword that takes an expression a slash starts a regular expression. + assert!(check_nesting(&format!("typeof /`/ ; {}", deep(5000))).is_err()); + // A postfix update or a property named like a keyword is followed by a division, not a + // regular expression (the deep brackets after it must still be counted). + for head in ["i++ /", "i-- /", "obj.in /", "obj . in /", "obj . /* c */ in /", "obj.\n typeof /", "a1.in /", "1.5.in /"] { + assert!(check_nesting(&format!("x = {head} {} / 2", deep(5000))).is_err(), "{head}"); + } + // A keyword after a property name is still a keyword (`obj.in in /re/`), and the dot of a + // number does not make the word after it a property (`1. in /re/`): the regular expression + // is skipped, so the backtick inside it opens nothing and the brackets after it count. + for head in ["obj.in in", "1. in", "12. typeof"] { + assert!(check_nesting(&format!("x = {head} /`/; y = {}", deep(5000))).is_err(), "{head}"); + } + } + + #[test] + fn template_interpolations_are_checked_but_template_text_is_not() { + let deep = |n: usize| format!("`a${{{}1{}}}b`", "(".repeat(n), ")".repeat(n)); + assert!(check_nesting(&deep(5000)).is_err()); + assert!(check_nesting(&deep(10)).is_ok()); + // Brackets in the literal text, and a nested template, are fine; depth carries across `}`. + assert!(check_nesting(&format!("`{} ${{ `x${{1+(2)}}y` }} {}`", "(".repeat(500), "{".repeat(500))).is_ok()); + assert!(check_nesting(&format!("`${{1}}${{{}2{}}}`", "[".repeat(MAX_NESTING), "]".repeat(MAX_NESTING))).is_err()); + } + /// A canonical drive path and a lexical one compare once the `\\?\` prefix is gone; a UNC /// share and a Unix path are left as they are. #[test] diff --git a/crates/script/src/scriptfile.rs b/crates/script/src/scriptfile.rs index 2b6afcfbe..c3179711b 100644 --- a/crates/script/src/scriptfile.rs +++ b/crates/script/src/scriptfile.rs @@ -56,6 +56,10 @@ pub(crate) fn read(s: &Session, path: &Path) -> Result { if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES as u64) { return Err(too_big()); } + // A pipe or device reports no size and may never end: only regular files are read. + if std::fs::metadata(path).is_ok_and(|m| !m.is_file()) { + return Err(format!("Unable to read {}: not a regular file", path.display())); + } let bytes = s.services.read_file(&path.to_string_lossy()).map_err(|e| format!("Unable to read {}: {e}", path.display()))?; if bytes.len() > MAX_FILE_BYTES { return Err(too_big()); diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index ddfbc1f3e..9c5000962 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -750,7 +750,7 @@ function __uiMouseWanted(c, inherited) { function __uiMouseEvent(type, target, v) { var num = function (x, d) { x = Number(x); - return isFinite(x) ? x : d; + return Number.isFinite(x) ? x : d; }; var keys = { alt: !!v.altKey, control: !!v.ctrlKey, ctrl: !!v.ctrlKey, meta: !!v.metaKey, shift: !!v.shiftKey }; return { diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index c024fcad5..ff5d6d200 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -142,7 +142,7 @@ mod imp { /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. Fails only when /// the read deadline can't be set. - fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) -> std::io::Result<()> { + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, mut done: impl FnMut(&[u8]) -> bool) -> std::io::Result<()> { let end = Instant::now() + limit; let mut chunk = [0u8; 4096]; while !done(buf) && !*eof { @@ -322,7 +322,15 @@ mod imp { } } ("readln", Sock::Stream { s, buf, eof }) => { - if let Err(e) = fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')) { + // Only the bytes that arrived since the last look are searched: a peer + // sending no newline must not make every read rescan the whole buffer. + let mut seen = 0; + let newline = |b: &[u8]| { + let hit = b.get(seen..).is_some_and(|new| new.contains(&b'\n')); + seen = b.len(); + hit + }; + if let Err(e) = fill(s, buf, eof, timeout(p), newline) { return err(format!("cannot set the read timeout: {e}")); } let line: Vec = match buf.iter().position(|&c| c == b'\n') { diff --git a/crates/script/src/tasks.rs b/crates/script/src/tasks.rs index 563f3bbc0..8b5c0cd7a 100644 --- a/crates/script/src/tasks.rs +++ b/crates/script/src/tasks.rs @@ -208,7 +208,9 @@ pub(crate) fn run_tasks_at(session: &mut Session, now: Instant) -> J { if !running { #[cfg(not(target_arch = "wasm32"))] crate::ui::tick_threaded(session, now, &mut report); - crate::ui::tick_inline(session, now, &mut report); + if session.script_ui.render_host.is_none() { + crate::ui::tick_inline(session, now, &mut report); + } crate::ui::prune_task_hosts(session); } let errors: Vec = report.errors.iter().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})).collect(); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 9e934b555..25933d165 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -10,11 +10,15 @@ use serde_json::{Value, json}; use crate::{Outcome, run_code}; +mod ae_layer_order; mod callable; mod ease_presets; mod evalfile; mod include; mod panels; +mod paragraph_justification; +#[cfg(not(target_arch = "wasm32"))] +mod render_wait; #[cfg(not(windows))] mod services; mod startup; @@ -698,6 +702,56 @@ fn save_frame_to_png_writes_behind_the_file_gate() { let _ = std::fs::remove_dir_all(&dir); } +/// A script that runs the export commands itself (`app.run`, `__exec`) meets the same file-write +/// preference as the wrappers; with the preference off nothing is created or replaced. +#[test] +fn export_commands_run_directly_by_a_script_respect_the_file_gate() { + let dir = std::env::temp_dir().join(format!("ec-direct-export-{}", std::process::id())); + // A leftover dir from an interrupted run (same pid) must not feed stale absence checks. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("frame.png"); + let tpl = dir.join("card.ectemplate"); + let kept = dir.join("kept.png"); + std::fs::write(&kept, b"original").unwrap(); + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + ok(&mut s, "var c = app.project.activeItem; c.layer(1).property('ADBE Transform Group').property('ADBE Opacity').addToMotionGraphicsTemplate(c);"); + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + let comp = s.active_comp_id().unwrap().0; + for (code, target) in [ + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&png)), &png), + (format!("__exec('comp.saveFrameAs', JSON.stringify({{comp: {comp}, path: {}}}))", q(&png)), &png), + (format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl)), &tpl), + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&kept)), &kept), + ] { + let o = run_code(&mut s, &code, "direct.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was not refused")); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + if target == &kept { + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + } else { + assert!(!target.exists(), "{} was written", target.display()); + } + } + // The wrappers check the gate without touching the file: an existing one is left as it was. + let o = run_code(&mut s, &format!("app.project.activeItem.saveFrameToPng(0, new File({}))", q(&kept)), "wrap.jsx"); + assert!(o.error.is_some()); + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + let o = run_code(&mut s, &format!("app.project.activeItem.exportAsMotionGraphicsTemplate(true, {})", q(&tpl)), "wrap.jsx"); + assert!(o.error.is_some()); + assert!(!tpl.exists()); + // The user, from the UI, is not a script: the same command writes. + s.execute("comp.saveFrameAs", json!({"comp": comp, "path": png.to_string_lossy()})).unwrap(); + assert!(png.exists()); + // With the preference on, the script's direct call writes too. + s.prefs.scripting.allow_scripts_write_files = true; + ok(&mut s, &format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl))); + assert!(std::fs::metadata(&tpl).unwrap().len() > 100); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still /// gets the question; only the user's answer opens the file. #[test] @@ -1947,3 +2001,77 @@ fn setting_comp_width_and_height_leaves_the_layers_where_they_are() { 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])); } + +#[test] +fn a_hashbang_line_does_not_hide_deep_nesting_from_eval() { + // Boa reads a leading `#!` line of eval source as a comment, so the backtick on it opens no + // template: the brackets after it are counted and refused. + let mut s = session(); + let code = "var deep = new Array(5000).join('(') + '1' + new Array(5000).join(')'); var r; \ + try { eval('#! `\\n' + deep); r = 'ran'; } catch (e) { r = 'refused'; } r"; + assert_eq!(ok(&mut s, code).result.as_str(), Some("refused")); + assert!(crate::runtime::check_nesting(&format!("#! `\n{}1{}", "(".repeat(5000), ")".repeat(5000))).is_err()); + assert!(crate::runtime::check_nesting("#! `\n(1)").is_ok()); +} + +#[test] +fn code_compiled_at_run_time_passes_the_nesting_check() { + // `eval` and `new Function` of a string nested past the limit are refused with a catchable + // error instead of overflowing the parser's stack; shallow code still compiles. + let mut s = session(); + for call in ["eval(deep)", "new Function(deep)", "Function('a', deep)"] { + let code = format!( + "var deep = new Array(5000).join('(') + '1' + new Array(5000).join(')'); var r; try {{ {call}; r = 'ran'; }} catch (e) {{ r = 'refused'; }} r" + ); + assert_eq!(ok(&mut s, &code).result.as_str(), Some("refused"), "{call}"); + } + assert_eq!(ok(&mut s, "eval('1 + (2 * 3)') + new Function('return 4')()").result.as_f64(), Some(11.0)); +} + +#[test] +fn file_masks_match_without_backtracking() { + let mut s = session(); + let r = ok( + &mut s, + "[__globMatch('*.JSX', 'a.jsx'), __globMatch('a?c*', 'abcdef'), __globMatch('*a*b', 'xaxb'), __globMatch('*.png', 'a.jsx'), __globMatch('*a*a*a*a*a*a*a*a*a*a*a*b', new Array(60).join('a'))].join()", + ); + assert_eq!(r.result.as_str(), Some("true,true,true,false,false")); + // `?` still matches one character that lowercases to two. + let r = ok(&mut s, "[__globMatch('?.txt', '\\u0130.txt'), __globMatch('\\u0130*', '\\u0130x')].join()"); + assert_eq!(r.result.as_str(), Some("true,true")); + // Greek sigma matches in every case form, as the case-insensitive regular expression did. + let r = ok( + &mut s, + "[__globMatch('\\u03bf\\u03c2.txt', '\\u039f\\u03a3.txt'), __globMatch('\\u03a3*', '\\u03c2x'), __globMatch('k', '\\u212a'), __globMatch('a', 'b')].join()", + ); + assert_eq!(r.result.as_str(), Some("true,true,false,false")); +} + +/// #3395: setValue on a string effect parameter stores the text, or a File's path, as `prop.set` +/// does. Before, every string fell through to the number check ("value must be a number"). +/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused, keys included. +#[test] +fn set_value_stores_a_string_parameter() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Str", 64, 36, 1, 1, 24); + var l = c.layers.addSolid([1, 0, 0], "s", 64, 36, 1); + var fx = l.property("ADBE Effect Parade"); + var lut = fx.addProperty("Apply Color LUT").property("LUT"); + lut.setValue("/luts/look.cube"); + var fromString = lut.value; + lut.setValue(new File("/luts/other.cube")); + var mesh = fx.addProperty("Mesh Warp").property("Distortion Mesh"); + function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } } + var hidden = attempt(function () { mesh.setValue("x"); }); + var keyed = attempt(function () { mesh.setValueAtTime(0, "x"); }); + [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden, keyed, mesh.numKeys] + "#, + ); + let refused = "\"Distortion Mesh\" can not be set"; + assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", refused, refused, 0])); + let (_, c) = comp_named(&s, "Str"); + assert_eq!(layer(c, "s").props.prop("effects/#1/lut").unwrap().value, KV::Str("/luts/other.cube".into())); +} diff --git a/crates/script/src/tests/ae_layer_order.rs b/crates/script/src/tests/ae_layer_order.rs new file mode 100644 index 000000000..869ce348a --- /dev/null +++ b/crates/script/src/tests/ae_layer_order.rs @@ -0,0 +1,121 @@ +//! `layer.property(n)` and the layer's `numProperties` use After Effects' top-level numbers +//! (`project::ae_order`): Marker is 1 and unused slots keep their place as empty groups, so a loop +//! over `numProperties` meets every slot and never a null. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use serde_json::json; + +use crate::{Outcome, run_code}; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() }; + crate::install(&mut s); + s +} + +fn ok(s: &mut Session, code: &str) -> Outcome { + let o = run_code(s, code, "test.jsx"); + assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output); + o +} + +#[test] +fn layer_numbers_follow_after_effects() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("M", 200, 200, 1, 2, 30); + var solid = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1); + solid.Effects.addProperty("ADBE Slider Control"); + var shape = c.layers.addShape(); + var text = c.layers.addText("t"); + var cam = c.layers.addCamera("Cam", [100, 100]); + function names(l) { var o = []; for (var i = 1; i <= l.numProperties; i++) o.push(l.property(i).matchName); return o; } + [names(solid), names(shape), names(text), names(cam), + solid.property(5)(1)(1).value, solid.property(5).propertyIndex, solid.property("Effects").propertyIndex, + solid.property(1) === solid.marker || solid.property(1).matchName, solid.property(16)] + "#, + ); + let av = [ + "ADBE Marker", + "ADBE Time Remapping", + "ADBE MTrackers", + "ADBE Mask Parade", + "ADBE Effect Parade", + "ADBE Transform Group", + "ADBE Layer Styles", + "ADBE Plane Options Group", + "ADBE Extrsn Options Group", + "ADBE Material Options Group", + "ADBE Audio Group", + "ADBE Data Group", + "ADBE Layer Overrides", + "ADBE Layer Sets", + "ADBE Source Options Group", + ]; + let vector = |first: &str| { + json!([ + "ADBE Marker", + first, + "ADBE Mask Parade", + "ADBE Effect Parade", + "ADBE Transform Group", + "ADBE Layer Styles", + "ADBE Extrsn Options Group", + "ADBE Material Options Group", + "ADBE Audio Group", + "ADBE Layer Sets" + ]) + }; + let r = o.result.as_array().unwrap(); + assert_eq!(r[0], json!(av), "solid"); + assert_eq!(r[1], vector("ADBE Root Vectors Group"), "shape"); + assert_eq!(r[2], vector("ADBE Text Properties"), "text"); + assert_eq!(r[3], json!(["ADBE Marker", "ADBE Transform Group", "ADBE Camera Options Group"]), "camera"); + assert_eq!(r[4], json!(0), "the slider's value through property(5)"); + assert_eq!(r[5], json!(5)); + assert_eq!(r[6], json!(5), "propertyIndex by name is After Effects' too"); + assert_eq!(r[7], json!("ADBE Marker")); + assert_eq!(r[8], json!(null), "past the last slot"); +} + +#[test] +fn walking_every_property_meets_no_null() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("M", 200, 200, 1, 2, 30); + var l = c.layers.addShape(); + var n = 0; + function walk(g) { + for (var i = 1; i <= g.numProperties; i++) { + var p = g.property(i); + if (p === null) throw new Error("null at " + i + " of " + g.name); + if (p.propertyType === PropertyType.PROPERTY) n++; else walk(p); + } + } + walk(l); + n > 0 + "#, + ); + assert_eq!(o.result, json!(true)); +} + +#[test] +fn an_unused_slot_is_the_same_by_number_and_match_name_and_callable() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("M", 200, 200, 1, 2, 30); + var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1); + var byName = l.property("ADBE Layer Styles"), byNumber = l.property(7); + [byName !== null && byName.matchName, byName !== null && byName.numProperties, byNumber.propertyIndex, l.property(7)(1), l(12).name] + "#, + ); + assert_eq!(o.result, json!(["ADBE Layer Styles", 0, 7, null, "Data"])); +} diff --git a/crates/script/src/tests/paragraph_justification.rs b/crates/script/src/tests/paragraph_justification.rs new file mode 100644 index 000000000..3b618170c --- /dev/null +++ b/crates/script/src/tests/paragraph_justification.rs @@ -0,0 +1,48 @@ +//! `ParagraphJustification` has After Effects' numbers (26.3): MULTIPLE_JUSTIFICATIONS 7412, +//! LEFT 7413, RIGHT 7414, CENTER 7415, then the justified variants with the last line left (7416), +//! right (7417), centred (7418) and full (7419). A script that stores or compares the numbers gets +//! the same paragraph in both applications. + +use serde_json::json; + +use super::{comp_named, ok, session}; +use effectcraft_engine::keyframe::{Justify, Value as KV}; + +#[test] +fn the_numbers_are_after_effects() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var P = ParagraphJustification; + [P.MULTIPLE_JUSTIFICATIONS, P.LEFT_JUSTIFY, P.RIGHT_JUSTIFY, P.CENTER_JUSTIFY, P.FULL_JUSTIFY_LASTLINE_LEFT, + P.FULL_JUSTIFY_LASTLINE_RIGHT, P.FULL_JUSTIFY_LASTLINE_CENTER, P.FULL_JUSTIFY_LASTLINE_FULL] + "#, + ); + assert_eq!(o.result, json!([7412, 7413, 7414, 7415, 7416, 7417, 7418, 7419])); +} + +#[test] +fn each_number_sets_and_reads_its_paragraph() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("J", 200, 100, 1, 2, 30); + var t = c.layers.addText("ab"); + var st = t.property("ADBE Text Properties").property("ADBE Text Document"); + var back = []; + for (var n = 7413; n <= 7419; n++) { + var d = st.value; d.justification = n; st.setValue(d); + back.push(st.value.justification); + } + var d = st.value; d.justification = 7414; st.setValue(d); + back + "#, + ); + assert_eq!(o.result, json!([7413, 7414, 7415, 7416, 7417, 7418, 7419])); + // 7414 is right-aligned, as in After Effects. + let (_, c) = comp_named(&s, "J"); + let KV::Text(d) = &c.layers[0].props.prop("text/sourceText").unwrap().value else { panic!("not text") }; + assert_eq!(d.justify, Justify::Right); +} diff --git a/crates/script/src/tests/render_wait.rs b/crates/script/src/tests/render_wait.rs new file mode 100644 index 000000000..591bd211a --- /dev/null +++ b/crates/script/src/tests/render_wait.rs @@ -0,0 +1,243 @@ +//! #3289: a native frontend may service the event loop while a script waits for rendering. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use effectcraft_engine::project::render_queue::OutputFormat; +use effectcraft_engine::{ExportJob, ExportResult, Exporter, Session}; +use serde_json::{Value, json}; + +/// A slow exporter without expensive frames. A bounded wait makes the old blocking path +/// fail the assertions instead of hanging the test process. +struct Gate { + entered: AtomicUsize, + permits: AtomicUsize, + failed: bool, +} + +impl Exporter for Gate { + fn formats(&self) -> Vec { + vec![OutputFormat::H264] + } + fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + let call = self.entered.fetch_add(1, Ordering::SeqCst) + 1; + let end = Instant::now() + Duration::from_secs(2); + while self.permits.load(Ordering::SeqCst) < call { + if !progress(0, 1) { + return Err(effectcraft_engine::render_queue::CANCELLED.into()); + } + if Instant::now() > end { + return Err("test exporter timed out waiting for the frontend".into()); + } + std::thread::sleep(Duration::from_millis(1)); + } + if self.failed { Err("test encoder failure".into()) } else { Ok(ExportResult { path: job.path.into(), frames: 1, ..Default::default() }) } + } +} + +fn session(permits: usize, failed: bool) -> (Session, Arc) { + let gate = Arc::new(Gate { entered: AtomicUsize::new(0), permits: AtomicUsize::new(permits), failed }); + let mut s = super::session(); + s.exporter = Some(gate.clone()); + s.execute("comp.new", json!({"name": "Render", "width": 16, "height": 16, "duration": 1})).unwrap(); + let path = std::env::temp_dir().join(format!("ec-script-render-{}.mp4", std::process::id())); + s.execute("renderQueue.add", json!({"output": path.to_string_lossy()})).unwrap(); + s.prefs.auto_save.enabled = false; + (s, gate) +} + +fn finish(s: &mut Session) -> Value { + let end = Instant::now() + Duration::from_secs(3); + loop { + if let Some(result) = crate::ui::poll_render_wait(s) { + return result; + } + assert!(Instant::now() < end, "script did not resume"); + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn render_yields_without_finishing_the_script_or_its_undo_group() { + let (mut s, gate) = session(0, false); + s.script_ui.yield_renders = true; + let before = s.history.undo.len(); + let out = crate::run_code( + &mut s, + "app.beginUndoGroup('Render transaction'); app.project.activeItem.layers.addNull().name = 'Before'; $.writeln('before'); app.project.renderQueue.render(); app.project.activeItem.layers.addNull().name = 'After'; $.writeln('after'); app.endUndoGroup(); [42, app.project.renderQueue.rendering];", + "render.jsx", + ); + assert!(out.error.is_none(), "{out:?}"); + assert!(out.render_host.is_some(), "blocking render returned instead of yielding: {out:?}"); + assert!(!out.waiting, "a render suspension is not a modal dialog"); + assert!(out.output.is_empty(), "output belongs to the eventual reply"); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "the continuation has not run"); + assert!(crate::ui::poll_render_wait(&mut s).is_none()); + assert!(!s.undo(), "undo cannot discard the suspended script's live edits"); + assert!(s.execute("engine.batch", json!({"steps": [{"command": "layer.newNull"}]})).is_err()); + assert!(s.execute("jobs.list", json!({})).is_err(), "status polling must not apply unrelated jobs"); + // The frontend resets the invocation-only opt-in after script.run returns. + s.script_ui.yield_renders = false; + gate.permits.store(1, Ordering::SeqCst); + let done = finish(&mut s); + assert_eq!(done["ok"], true, "{done}"); + assert_eq!(done["result"], json!([42, false])); + assert_eq!(done["output"], "before\nafter"); + assert_eq!(s.project.render_queue[0].status.label(), "Done"); + assert_eq!(s.history.undo.len(), before + 1, "one undo group spans the render"); + assert_eq!(s.history.undo.last().unwrap().0, "Render transaction"); + assert!(s.script_ui.render_host.is_none()); + s.undo(); + assert!(s.active_comp().unwrap().layers.is_empty()); +} + +#[test] +fn a_second_render_keeps_the_same_script_pending() { + let (mut s, gate) = session(0, false); + s.script_ui.yield_renders = true; + let out = crate::run_code( + &mut s, + "app.project.renderQueue.render(); app.project.renderQueue.item(1).render = true; app.project.renderQueue.render(); 17;", + "twice.jsx", + ); + let host = out.render_host.unwrap(); + s.script_ui.yield_renders = false; + gate.permits.store(1, Ordering::SeqCst); + let end = Instant::now() + Duration::from_secs(3); + while gate.entered.load(Ordering::SeqCst) < 2 { + assert!(crate::ui::poll_render_wait(&mut s).is_none(), "no premature reply after the first render"); + assert!(Instant::now() < end); + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(s.script_ui.render_host, Some(host)); + gate.permits.store(2, Ordering::SeqCst); + assert_eq!(finish(&mut s)["result"], 17); +} + +#[test] +fn cancellation_and_encoder_failure_resume_with_terminal_statuses() { + for failed in [false, true] { + let (mut s, gate) = session(0, failed); + s.script_ui.yield_renders = true; + let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [9, app.project.renderQueue.rendering];", "stopped.jsx"); + assert!(out.render_host.is_some()); + if failed { + gate.permits.store(1, Ordering::SeqCst); + } else { + s.stop_render(); + } + let done = finish(&mut s); + assert_eq!(done["ok"], true, "the synchronous command reports status, not a thrown script error: {done}"); + assert_eq!(done["result"], json!([9, false])); + assert_ne!(s.project.render_queue[0].status.label(), "Done"); + } +} + +#[test] +fn headless_scripts_still_wait_and_start_errors_do_not_suspend() { + let (mut s, _) = session(1, false); + let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [7, app.project.renderQueue.rendering];", "headless.jsx"); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.result, json!([7, false])); + assert!(out.render_host.is_none()); + assert_eq!(s.project.render_queue[0].status.label(), "Done"); + s.script_ui.yield_renders = true; + let out = crate::run_code(&mut s, "app.project.renderQueue.render();", "empty.jsx"); + assert!(out.error.is_some()); + assert!(out.render_host.is_none()); + assert!(s.script_ui.render_host.is_none()); +} + +#[test] +fn retained_palette_callbacks_can_yield_after_the_original_control_invocation() { + let (mut s, gate) = session(0, false); + s.script_ui.yield_renders = true; + let out = crate::run_code( + &mut s, + "var w = new Window('palette', 'Render'); var b = w.add('button', undefined, 'Go', {name:'go'}); b.onClick = function () { app.project.renderQueue.render(); $.writeln('resumed'); }; w.show();", + "palette-render.jsx", + ); + assert!(out.error.is_none(), "{out:?}"); + s.script_ui.yield_renders = false; + let result = s.execute("scriptui.click", json!({"widget": "go"})).unwrap(); + assert!(result["renderHost"].is_number(), "{result}"); + gate.permits.store(1, Ordering::SeqCst); + assert_eq!(finish(&mut s)["output"], "resumed"); + let window = s.script_ui.windows[0].id; + s.execute("scriptui.close", json!({"window": window})).unwrap(); +} + +#[test] +fn post_render_actions_complete_before_the_script_resumes() { + use effectcraft_engine::project::{Footage, FootageKind}; + struct Probe; + impl effectcraft_engine::Importer for Probe { + fn probe(&self, path: &str) -> Result { + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }) + } + } + for action in ["import", "setProxy"] { + let (mut s, gate) = session(0, false); + s.importer = Some(Arc::new(Probe)); + let id = s.project.render_queue[0].id; + s.execute("renderQueue.setOutputModule", json!({"item": id, "postRenderAction": action})).unwrap(); + let before = s.project.items.len(); + let comp = s.active_comp_id().unwrap(); + s.script_ui.yield_renders = true; + let out = crate::run_code(&mut s, "app.project.renderQueue.render(); 29;", "post-render.jsx"); + assert!(out.render_host.is_some(), "{out:?}"); + gate.permits.store(1, Ordering::SeqCst); + // A query must not consume updates while the edit guard is still active. + let end = Instant::now() + Duration::from_secs(3); + while s.is_rendering() { + assert!(Instant::now() < end); + std::thread::sleep(Duration::from_millis(1)); + } + s.execute("renderQueue.list", json!({})).unwrap(); + assert_eq!(s.project.items.len(), before); + assert_eq!(finish(&mut s)["result"], 29); + if action == "import" { + assert_eq!(s.project.items.len(), before + 1); + } else { + assert!(s.project.item(comp).unwrap().proxy.is_some()); + } + } +} + +#[test] +fn deferred_errors_keep_their_included_source_location() { + let (mut s, gate) = session(0, false); + let dir = std::env::temp_dir().join(format!("ec-render-include-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nmissingAfterRender();").unwrap(); + std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap(); + s.script_ui.yield_renders = true; + let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy()); + assert!(out.render_host.is_some(), "{out:?}"); + gate.permits.store(1, Ordering::SeqCst); + let done = finish(&mut s); + assert_eq!(done["ok"], false); + assert_eq!(done["error"]["line"], 2); + assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn included_error_mapping_survives_a_modal_yield_after_rendering() { + let (mut s, gate) = session(0, false); + let dir = std::env::temp_dir().join(format!("ec-render-modal-include-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nvar d = new Window('dialog', 'Continue'); d.add('button', undefined, 'OK', {name:'ok'}); d.show();\nmissingAfterModal();").unwrap(); + std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap(); + s.script_ui.yield_renders = true; + let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy()); + assert!(out.render_host.is_some(), "{out:?}"); + gate.permits.store(1, Ordering::SeqCst); + assert_eq!(finish(&mut s)["waiting"], true); + let done = s.execute("scriptui.click", json!({"widget": "ok"})).unwrap(); + assert_eq!(done["ok"], false); + assert_eq!(done["error"]["line"], 3); + assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 483d686ef..c15aee0ae 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -231,6 +231,7 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index ab6f69846..ecf4cd28b 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -3,7 +3,8 @@ //! //! * **Script files** (native builds) run on a *script-host thread*. The caller's session moves //! to that thread while JavaScript runs and comes back whenever the script stops: at the end, -//! or while a modal dialog waits in `show()`. Each [`ScriptUiEvent`] moves the session there +//! while a modal dialog waits in `show()`, or while an opted-in frontend waits for rendering. +//! Each [`ScriptUiEvent`] moves the session there //! again, runs the handler, and brings it back with the windows' new state. The host ends //! when its last window closes. Only one side holds the session at a time, so nothing runs //! concurrently and every edit is still an ordinary undoable engine command. @@ -137,6 +138,8 @@ pub(crate) enum ToHost { /// script-host threads). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] Tick(Session, Instant), + /// Resume the suspended native render call, never a callback or scheduled task. + ResumeRender(Session), } impl ToHost { @@ -145,7 +148,7 @@ impl ToHost { match self { #[cfg(not(target_arch = "wasm32"))] ToHost::Start(s) => s, - ToHost::Event(s, _) | ToHost::Tick(s, _) => s, + ToHost::Event(s, _) | ToHost::Tick(s, _) | ToHost::ResumeRender(s) => s, } } } @@ -159,6 +162,7 @@ pub(crate) struct FromHost { done: bool, /// When its next scheduled task is due. next_due: Option, + render_wait: bool, } /// The host thread's end of the hand-off. @@ -177,6 +181,8 @@ struct HostEnd { /// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] epoch: u64, + /// Retained across render yields so a later error still names its included source file. + source_map: Option, } fn hosts() -> &'static std::sync::Mutex> { @@ -195,6 +201,7 @@ enum Wake { Event(ScriptUiEvent), #[cfg_attr(target_arch = "wasm32", allow(dead_code))] Tick(Instant), + Render, } /// On the host thread: hand the session (with the windows' state and what the script printed) @@ -206,18 +213,20 @@ fn yield_wait(outcome: crate::Outcome, windows: Vec, done: bool) - match yield_wake(out, windows.clone(), done, None)? { Wake::Event(ev) => return Some(ev), Wake::Tick(_) => out = crate::Outcome { waiting: true, ..Default::default() }, + Wake::Render => return None, } } } /// [`yield_wait`] that also wakes for the scheduled tasks due at `next_due`. fn yield_wake(outcome: crate::Outcome, windows: Vec, done: bool, next_due: Option) -> Option { + let render_wait = outcome.render_host.is_some(); let (session, link, name) = ACTIVE.with(|a| { let mut a = a.borrow_mut(); let a = a.as_mut()?; Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone())) })?; - if link.tx.send(FromHost { session, outcome, windows, name, done, next_due }).is_err() { + if link.tx.send(FromHost { session, outcome, windows, name, done, next_due, render_wait }).is_err() { return None; } if done { @@ -226,6 +235,7 @@ fn yield_wake(outcome: crate::Outcome, windows: Vec, done: bool, n let (s, wake) = match link.rx.recv() { Ok(ToHost::Event(s, ev)) => (s, Wake::Event(ev)), Ok(ToHost::Tick(s, now)) => (s, Wake::Tick(now)), + Ok(ToHost::ResumeRender(s)) => (s, Wake::Render), _ => return None, }; ACTIVE.with(|a| { @@ -269,8 +279,9 @@ fn host_main(host: u32, name: String, code: String, link: Link) { } Some(Wake::Tick(now)) => { let (ran, error) = crate::tasks::run_due(&mut ctx, host, now); - out = crate::Outcome { result: json!(ran), output: settle(), error, waiting: false }; + out = crate::Outcome { result: json!(ran), output: settle(), error, ..Default::default() }; } + Some(Wake::Render) => break, None => break, } } @@ -283,15 +294,30 @@ fn host_main(host: u32, name: String, code: String, link: Link) { { let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" }; let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() }; - let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true, next_due: None }); + let _ = link.tx.send(FromHost { + session: std::mem::take(&mut a.session), + outcome: out, + windows: vec![], + name, + done: true, + next_due: None, + render_wait: false, + }); } } /// Wait for the host to hand the session back; keep its link while it has windows open. fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome { match end.rx.recv() { - Ok(FromHost { session: s, outcome, windows, name, done, next_due }) => { + Ok(FromHost { session: s, mut outcome, windows, name, done, next_due, render_wait }) => { *session = s; + session.script_ui.render_host = render_wait.then_some(host); + if !render_wait + && let Some(map) = &end.source_map + && let Some(error) = &mut outcome.error + { + map.locate(error); + } publish(session, host, &name, windows); if done { session.script_ui.windows.retain(|w| w.host != host); @@ -324,7 +350,61 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri *session = msg.into_session(); return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() }; } - receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0 }) + receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0, source_map: None }) +} + +/// Suspend only the JavaScript stack, not the window's event loop (#3289). Keep the output +/// and open undo groups in `Active`: settling here would split the script's undo transaction. +pub(crate) fn wait_for_render(ctx: &mut Context, mut result: J) -> Result { + let host = with_active(|a| Ok(a.host))?; + let windows = snapshot(ctx); + let out = crate::Outcome { render_host: Some(host), ..Default::default() }; + if !matches!(yield_wake(out, windows, false, None), Some(Wake::Render)) { + return Err("the frontend stopped waiting for the script's render".into()); + } + with_active(|a| { + let list = a.session.execute_for_script("renderQueue.list", json!({})).map_err(|e| e.to_string())?; + let ids: Vec = result["items"].as_array().into_iter().flatten().map(|i| i["id"].clone()).collect(); + result["items"] = json!(list["items"].as_array().into_iter().flatten().filter(|i| ids.contains(&i["id"])).collect::>()); + result["rendering"] = list["rendering"].clone(); + Ok(result) + }) +} + +pub(crate) fn keep_render_source_map(host: u32, map: crate::include::Expanded) { + if let Ok(mut h) = hosts().lock() + && let Some(end) = h.get_mut(&host) + { + end.source_map = Some(map); + } +} + +/// Called by the native frontend while its normal editing/callback tick is paused. Polling +/// applies terminal statuses and post-render actions before the suspended JS call returns. +pub(crate) fn poll_render_wait(session: &mut Session) -> Option { + let host = session.script_ui.render_host?; + // Only this completion boundary may apply the render's own import/proxy undo steps. + session.script_ui.render_host = None; + session.poll_render(); + if session.is_rendering() { + session.script_ui.render_host = Some(host); + return None; + } + session.script_ui.render_host = None; + let fail = |message: &str| crate::Outcome { error: Some(ScriptError { message: message.into(), ..Default::default() }), ..Default::default() }.to_json(); + let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else { + return Some(fail("the script host stopped while rendering")); + }; + if end.epoch != session.script_ui.project_epoch { + return Some(fail("the project changed while the script was rendering")); + } + if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::ResumeRender(std::mem::take(session))) { + *session = msg.into_session(); + return Some(fail("the script host stopped while rendering")); + } + let out = receive(session, host, end); + // A second render in this invocation keeps the original control reply pending. + out.render_host.is_none().then(|| out.to_json()) } /// Record the windows a host published (named after the script that made them). @@ -429,6 +509,9 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr } drop(stale); for host in due { + if session.script_ui.render_host.is_some() { + break; + } let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else { continue }; if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Tick(std::mem::take(session), now)) { *session = msg.into_session(); @@ -490,7 +573,7 @@ fn tick_inline_host(session: &mut Session, host: u32, name: &str, now: Instant) drop(guard); crate::tasks::set_epoch(host, session.script_ui.project_epoch); publish(session, host, name, windows); - crate::Outcome { result: json!(ran), output, error, waiting: false } + crate::Outcome { result: json!(ran), output, error, ..Default::default() } } /// Forget the session's task hosts that have ended (inline ones: that have no tasks left). diff --git a/crates/segment/src/mobilesam.rs b/crates/segment/src/mobilesam.rs index 6599b018a..abb12fb0f 100644 --- a/crates/segment/src/mobilesam.rs +++ b/crates/segment/src/mobilesam.rs @@ -19,6 +19,9 @@ const IMG: usize = 1024; const EMB: usize = 64; const DIM: usize = 256; const LOW: usize = 256; +/// Most prompt points the decoder takes (each is an attention token, and self-attention is +/// quadratic in them); SAM is used with a handful. More are thinned evenly, keeping both ends. +const MAX_POINTS: usize = 256; const MEAN: [f32; 3] = [123.675, 116.28, 103.53]; const STD: [f32; 3] = [58.395, 57.12, 57.375]; @@ -609,7 +612,7 @@ impl MobileSam { /// Foreground probability (`w×h`) for a prompt on an embedded image, and the predicted IoU. pub fn predict(&self, e: &Embedding, prompt: &Prompt) -> (Vec, f32) { let s = e.scale; - let points: Vec<([f32; 2], bool)> = prompt.points.iter().map(|(p, fg)| ([p[0] * s, p[1] * s], *fg)).collect(); + let points: Vec<([f32; 2], bool)> = thin(&prompt.points).iter().map(|(p, fg)| ([p[0] * s, p[1] * s], *fg)).collect(); let bbox = prompt.bbox.map(|b| b.map(|v| v * s)); // A prior mask (probabilities at image size) → logits on the low-res grid. let mask: Option> = prompt.mask.as_ref().filter(|m| m.len() == e.w * e.h).map(|m| { @@ -675,3 +678,28 @@ fn sample(rgb: &[[f32; 3]], w: usize, h: usize, x: f32, y: f32, foot: f32) -> [f } s.map(|v| v / f32::max(n, 1.0)) } + +/// `points`, or `MAX_POINTS` evenly spaced ones (first and last kept) when there are more. +fn thin(points: &[([f32; 2], bool)]) -> Vec<([f32; 2], bool)> { + let n = points.len(); + if n <= MAX_POINTS { + return points.to_vec(); + } + (0..MAX_POINTS).filter_map(|i| points.get(i * (n - 1) / (MAX_POINTS - 1)).copied()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn many_prompt_points_are_thinned_to_the_budget() { + let few: Vec<_> = (0..MAX_POINTS).map(|i| ([i as f32, 0.0], i % 2 == 0)).collect(); + assert_eq!(thin(&few), few, "a prompt within the budget is untouched"); + let many: Vec<_> = (0..1_000_000).map(|i| ([i as f32, 0.0], i % 2 == 0)).collect(); + let t = thin(&many); + assert_eq!(t.len(), MAX_POINTS); + assert_eq!((t[0].0, t[MAX_POINTS - 1].0), (many[0].0, many[many.len() - 1].0)); + assert!(t.windows(2).all(|w| w[0].0[0] < w[1].0[0]), "spread in order"); + } +} diff --git a/crates/segment/src/pt.rs b/crates/segment/src/pt.rs index 35ed57297..728cc84f7 100644 --- a/crates/segment/src/pt.rs +++ b/crates/segment/src/pt.rs @@ -108,10 +108,14 @@ 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 { + // Deflate expands at most about 1032:1, so a stated size past that is not this entry's. + if e.size > MAX_ENTRY_BYTES || e.size > e.stored.saturating_mul(1032).saturating_add(64) { 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:?}")) + // The status only: the error's partial output can be as large as the entry. + 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.status)) } m => Err(format!("unsupported zip compression {m}")), } @@ -162,6 +166,11 @@ 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(), + // A string counts one node per 64 bytes it holds (a vector one per 8 elements): copies + // are charged for everything they own, not as one object. + Obj::Str(s) => return 1 + s.len() / 64, + Obj::Global(a, b) | Obj::Storage(a, b) => return 1 + (a.len() + b.len()) / 64, + Obj::Tensor { dtype, key, size, stride, .. } => return 1 + (dtype.len() + key.len()) / 64 + (size.len() + stride.len()) / 8, _ => return 1, }; let mut n = 1; @@ -209,6 +218,17 @@ fn memo_get(memo: &HashMap, k: u64, cloned: &mut usize) -> Result Ok(o.clone()) } +/// A copy of the stack top to memoize, charged against the clone budget like a memo read: a +/// program memoizing one large object many times keeps a copy each time. +fn memo_put(stack: &[Obj], cloned: &mut usize) -> Result { + let o = stack.last().ok_or("pickle stack underflow")?; + *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(); @@ -310,15 +330,15 @@ fn unpickle(p: &[u8]) -> Result { } b'q' => { let k = take(&mut i, 1)?[0] as u64; - memo.insert(k, stack.last().cloned().ok_or("pickle stack underflow")?); + memo.insert(k, memo_put(&stack, &mut cloned)?); } b'r' => { let s = take(&mut i, 4)?; - memo.insert(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, stack.last().cloned().ok_or("pickle stack underflow")?); + memo.insert(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, memo_put(&stack, &mut cloned)?); } 0x94 => { let k = memo.len() as u64; - memo.insert(k, stack.last().cloned().ok_or("pickle stack underflow")?); + memo.insert(k, memo_put(&stack, &mut cloned)?); } b'h' => { let k = take(&mut i, 1)?[0] as u64; @@ -609,4 +629,34 @@ mod tests { assert_eq!(half_to_f32(0x3c00), 1.0); assert_eq!(half_to_f32(0xc000), -2.0); } + + #[test] + fn memoizing_a_long_string_again_and_again_runs_out_of_the_budget() { + // A 1 MiB string memoized 300 times would keep 300 MiB of copies. + let mut p = vec![0x80, 2, b'X']; + p.extend_from_slice(&(1u32 << 20).to_le_bytes()); + p.extend(std::iter::repeat_n(b'a', 1 << 20)); + p.extend(std::iter::repeat_n(0x94, 300)); + p.push(b'.'); + assert!(unpickle(&p).is_err_and(|e| e.contains("object limit"))); + } + + #[test] + fn every_owned_field_is_charged_to_the_clone_budget() { + let long = || "a".repeat(1 << 20); + let budget = MAX_PICKLE_NODES; + // A one-byte module with a 1 MiB name (and the same for storages and tensors). + assert!(nodes(&Obj::Global("m".into(), long()), budget) >= (1 << 20) / 64); + assert!(nodes(&Obj::Storage("m".into(), long()), budget) >= (1 << 20) / 64); + let t = Obj::Tensor { dtype: "m".into(), key: long(), offset: 0, size: vec![1; 4096], stride: vec![1; 4096] }; + assert!(nodes(&t, budget) >= (1 << 20) / 64 + 1024); + // Small ones stay cheap. + assert_eq!(nodes(&Obj::Global("a".into(), "b".into()), budget), 1); + } + + #[test] + fn an_entry_stating_more_than_deflate_can_expand_to_is_refused() { + let e = Entry { data: 0, size: 1 << 29, stored: 4, method: 8 }; + assert!(read_entry(&[0u8; 4], &e).is_err_and(|m| m.contains("size limit"))); + } } diff --git a/crates/segment/src/tflite.rs b/crates/segment/src/tflite.rs index 34e85dfab..6aafd056f 100644 --- a/crates/segment/src/tflite.rs +++ b/crates/segment/src/tflite.rs @@ -131,6 +131,8 @@ const MAX_VALUES: u64 = 1 << 30; const MAX_LIVE: u64 = 1 << 29; /// The most constant values decoded and laid out at load (a weight shared by several tensors or /// operators counts once per use). +/// Bytes of tensor names and shapes a model may carry in all (the official ones take well under 1 MB). +const MAX_META: usize = 1 << 24; const MAX_CONSTS: usize = 1 << 27; /// Tensor types (`TensorType`). @@ -261,6 +263,7 @@ impl Model { let mut ints: Vec>> = vec![None; n]; let mut data: Vec<(usize, u8, usize, &[u8])> = vec![]; let mut consts = 0usize; + let mut meta = 0usize; for (i, t) in tensors.iter().enumerate() { let shape: Vec = fb .ints(*t, 0)? @@ -269,7 +272,13 @@ impl Model { .collect::>()?; let ty = fb.byte(*t, 1, FLOAT32)?; let buf = fb.int(*t, 2, 0)? as u32 as usize; - names.push(fb.string(*t, 3)?); + let name = fb.string(*t, 3)?; + // Names and shapes are copied per tensor: bound them in all. + meta = meta.saturating_add(name.len()).saturating_add(shape.len().saturating_mul(8)); + if meta > MAX_META { + return Err("the model's tensor metadata is too large".into()); + } + names.push(name); // Every size computed from shapes later stays in range, even a product of some of the // dimensions (a zero dimension does not let the others grow without bound). let count = shape.iter().try_fold(1usize, |a, &d| a.checked_mul(d)).ok_or("a tensor is too large")?; @@ -329,6 +338,11 @@ impl Model { && let Some(i) = ins.first().copied().flatten() && let Some(len) = floats[i].as_ref().map(Vec::len) { + // The folded constant is what the graph outputs and the run's memory estimate count: + // it must be the declared size. + if shapes[output].iter().try_fold(1usize, |a, &d| a.checked_mul(d)) != Some(len) { + return Err(format!("{}: DEQUANTIZE output size disagrees with its input", names[output])); + } spend(len)?; floats[output] = floats[i].clone(); continue; diff --git a/crates/segment/src/tflite/tests.rs b/crates/segment/src/tflite/tests.rs index 29a781d34..9ba96971f 100644 --- a/crates/segment/src/tflite/tests.rs +++ b/crates/segment/src/tflite/tests.rs @@ -334,6 +334,26 @@ fn shapes_computed_at_run_time_must_match_the_declared_ones() { refused(&zero, "tensor is too large"); } +#[test] +fn a_folded_dequantize_has_its_declared_size() { + let half = |n: usize| vec![0u8; 2 * n]; + let fold = |declared: Vec| { + model(vec![half(4)], vec![(vec![1, 4], FLOAT16, Some(0)), (declared, FLOAT32, None)], vec![(op::DEQUANTIZE, vec![0], vec![1], vec![])], vec![], vec![1]) + }; + // A declared output of one value would let the folded four-value constant be copied out per reference. + refused(&fold(vec![1, 1]), "DEQUANTIZE output size"); + assert!(Model::read(&fold(vec![1, 4])).is_ok()); +} + +#[test] +fn tensor_metadata_is_bounded_in_all() { + // A rank of 4 million dimensions of 1 is a valid shape by size, but copied per tensor. + let m = model(vec![], vec![(vec![1; 1 << 22], FLOAT32, None)], vec![], vec![], vec![]); + refused(&m, "metadata is too large"); + let ok = model(vec![], (0..70).map(|_| (vec![1, 2, 3], FLOAT32, None)).collect(), vec![], vec![], vec![]); + assert!(Model::read(&ok).is_ok()); +} + #[test] fn shared_weights_count_once_per_use() { // One 128 KB buffer decoded for 2100 tensors would be 2100 copies of 2¹⁶ floats. diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 0e1af7719..6a1e4d741 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-svg" -description = "SVG import: parses SVG 1.1 / SVG 2 static vector content (paths, basic shapes, groups, transforms, use, CSS styles, solid and gradient fills/strokes, opacity) and rasterises it at any scale" +description = "SVG import: parses SVG 1.1 / SVG 2 static vector content (paths, basic shapes, groups, transforms, use, CSS styles, solid, gradient and pattern fills/strokes, opacity) and rasterises it at any scale" version.workspace = true edition.workspace = true license.workspace = true @@ -10,9 +10,11 @@ rust-version.workspace = true workspace = true [dependencies] +craft-svg = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-path = { workspace = true } kurbo = { workspace = true } roxmltree = "0.21" rayon = { workspace = true } +log = { workspace = true } diff --git a/crates/svg/README.md b/crates/svg/README.md index 862bd01d0..f39156f22 100644 --- a/crates/svg/README.md +++ b/crates/svg/README.md @@ -18,9 +18,31 @@ here, plus **CSS Color Module Level 3** for colour keywords and functions: `stroke-opacity`, `opacity`, `display`, `visibility`, `color`/`currentColor`; - `linearGradient` and `radialGradient` (with `fx`/`fy` focal point), `gradientUnits`, `gradientTransform`, `spreadMethod`, `href` inheritance of stops and attributes; +- static (non-animated) `` fill/stroke with `patternUnits`, `patternContentUnits`, `patternTransform`, + tile `x`/`y`/`width`/`height`, local `href`/`xlink:href` inheritance and solid paints on + supported shape/group children; - styling through presentation attributes, the `style` attribute and `")); } #[test] @@ -245,7 +769,7 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() { "".repeat(1000), "".repeat(1000) ); - assert_eq!(crate::xml_depth(&s), 2); + assert!(!craft_svg::guard::deeper_than(&s, 3) && craft_svg::guard::deeper_than(&s, 2)); assert!(crate::parse(s.as_bytes()).is_ok()); } @@ -446,3 +970,183 @@ fn at_rule_blocks_are_skipped_by_brace_depth() { assert_eq!(sheet.rules.len(), 1); assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]); } + +/// Build a `w` x `h` document from `nodes` for sparse-content render tests. +fn sparse_doc(w: f64, h: f64, nodes: Vec) -> Doc { + let mut root = Group::new("root"); + root.children = nodes; + Doc { width: w, height: h, root, skipped: Vec::new() } +} + +/// A tiny image on a large canvas is still drawn. +#[test] +fn sparse_minified_image_on_a_large_canvas_is_still_drawn() { + let image = Image { + name: "i".into(), + transform: Affine::scale(1.0 / 8.0), + width: 8, + height: 8, + rgba: std::sync::Arc::new([255u8, 0, 0, 255].repeat(64)), + opacity: 1.0, + }; + let doc = sparse_doc(2049.0, 2049.0, vec![Node::Image(image)]); + let img = try_rasterize(&doc, 2049, 2049, 1.0).expect("sparse image is within budget"); + assert!(px(&img, 0, 0)[3] > 0.99, "the covered pixel must not be dropped by whole-canvas budgeting"); +} + +/// A small shape with a many-element clip is still drawn. +#[test] +fn sparse_shape_with_a_many_element_clip_is_still_drawn() { + let mut clip = kurbo::BezPath::new(); + clip.move_to((0.0, 0.0)); + for i in 0..1026 { + clip.line_to((2.0 * (i % 2) as f64, 2.0 * ((i + 1) % 2) as f64)); + } + clip.line_to((2.0, 2.0)); + clip.line_to((0.0, 2.0)); + clip.close_path(); + assert!(clip.elements().len() > 1000); + let mut g = Group::new("clipped"); + g.clip = vec![(clip, FillRule::NonZero)]; + let doc = parse(br#""#).unwrap(); + g.children = doc.root.children.clone(); + let doc = sparse_doc(512.0, 512.0, vec![Node::Group(g)]); + let img = try_rasterize(&doc, 512, 512, 1.0).expect("sparse clip is within budget"); + assert!(img.data.iter().any(|p| p[3] > 0.5), "clip work is charged by clip area, not canvas area"); +} + +/// Sparse pattern fills on a 4K canvas all render. +#[test] +fn sparse_pattern_fills_on_a_4k_canvas_all_render() { + let rects: String = (0..6).map(|i| format!(r#""#, i * 4)).collect(); + let svg = format!( + r#" + + {rects}"# + ); + let doc = parse(svg.as_bytes()).unwrap(); + let img = try_rasterize(&doc, 3840, 2160, 1.0).expect("sparse patterns are within budget"); + for i in 0..6 { + assert!(px(&img, i * 4, 0)[3] > 0.99, "rect {i} must be painted"); + } +} + +/// A genuinely huge covered area is still refused. +#[test] +fn genuinely_huge_covered_area_is_still_refused() { + // A 16392x1 strip squeezed 8:1 across the whole canvas needs 8x8 taps per pixel everywhere. + let image = Image { + name: "i".into(), + transform: Affine::scale_non_uniform(1.0 / 8.0, 2049.0), + width: 16392, + height: 1, + rgba: std::sync::Arc::new([255u8, 0, 0, 255].repeat(16392)), + opacity: 1.0, + }; + let doc = sparse_doc(2049.0, 2049.0, vec![Node::Image(image)]); + assert_eq!( + try_rasterize(&doc, 2049, 2049, 1.0).err(), + Some(RasterError::WorkLimit("image sampling")), + "a fully covered 64-tap canvas exceeds the work cap" + ); + let img = rasterize(&doc, 2049, 2049, 1.0); + assert!(img.data.iter().all(|p| p[3] == 0.0), "the lossy wrapper drops the refused image"); +} + +/// A group of `count` 1x1 red rects, each inside its own tiny clip, spread over a large canvas. +fn tiny_clipped_rects(count: usize) -> Vec { + (0..count) + .map(|i| { + let (x, y) = ((i % 100) as f64 * 4.0, (i / 100) as f64 * 4.0); + let mut g = Group::new("clipped"); + g.clip = vec![(kurbo::Rect::new(x, y, x + 2.0, y + 2.0).to_path(0.01), FillRule::NonZero)]; + let mut rect = Group::new("r"); + rect.children = parse( + format!(r#""#) + .as_bytes(), + ) + .map(|d| d.root.children) + .unwrap_or_default(); + g.children = rect.children; + Node::Group(g) + }) + .collect() +} + +/// Thousands of tiny clipped items on a big canvas cost region-proportional work: a clipped +/// group works on a crop of its clip region, so the render finishes quickly and completely. +#[test] +fn thousands_of_tiny_clips_on_a_large_canvas_finish_quickly() { + let doc = sparse_doc(2048.0, 2048.0, tiny_clipped_rects(3000)); + let start = std::time::Instant::now(); + let img = try_rasterize(&doc, 2048, 2048, 1.0).expect("tiny clips stay within every budget"); + assert!(start.elapsed() < std::time::Duration::from_secs(20), "region-bounded work: {:?}", start.elapsed()); + assert!(px(&img, 0, 0)[3] > 0.99 && px(&img, 396, 28 * 4)[3] > 0.99); +} + +/// Thousands of tiny pattern-filled shapes and tiny images on a big canvas finish quickly because +/// masks and scans cover only each item's region. +#[test] +fn thousands_of_tiny_pattern_and_image_items_on_a_large_canvas_finish_quickly() { + let mut nodes = Vec::new(); + let rects: String = (0..3000).map(|i| format!(r#""#, (i % 100) * 4, (i / 100) * 4)).collect(); + let svg = format!( + r#" + + {rects}"# + ); + nodes.extend(parse(svg.as_bytes()).unwrap().root.children); + for i in 0..3000 { + nodes.push(Node::Image(Image { + name: "i".into(), + transform: Affine::translate(((i % 100) as f64 * 4.0, 100.0 + (i / 100) as f64 * 4.0)), + width: 1, + height: 1, + rgba: std::sync::Arc::new(vec![0, 255, 0, 255]), + opacity: 1.0, + })); + } + let doc = sparse_doc(3840.0, 2160.0, nodes); + let start = std::time::Instant::now(); + let img = try_rasterize(&doc, 3840, 2160, 1.0).expect("tiny items stay within every budget"); + assert!(start.elapsed() < std::time::Duration::from_secs(20), "region-bounded work: {:?}", start.elapsed()); + assert!(px(&img, 396, 0)[3] > 0.99 && px(&img, 396, 100)[3] > 0.99); +} + +#[test] +fn gradient_stops_shared_across_references_are_not_charged_per_copy() { + // 4,000 stops referenced by 2,100 shapes would be 8.4 million stops if copied, but resolved + // gradients share one stop array, so the import keeps only the 4,000 and succeeds. + let stops: String = (0..4000).map(|i| format!("", i as f64 / 4000.0)).collect(); + let rects = "".repeat(2100); + let svg = format!("{stops}{rects}"); + assert!(parse(svg.as_bytes()).is_ok()); +} + +#[test] +fn nested_knockout_groups_stop_at_the_draw_budget() { + // Each knockout level draws its children twice and each non-isolated level twice more: + // 30 nested levels would be 4^30 draws. The draw budget ends it. + let mut g = Group::new("leaf"); + g.children.push(Node::Shape(Shape { + name: "s".into(), + geom: Geom::Rect { x: 0.0, y: 0.0, w: 4.0, h: 4.0, rx: 0.0, ry: 0.0 }, + transform: Affine::IDENTITY, + fill: Some(Paint::Color([1.0, 0.0, 0.0])), + fill_opacity: 1.0, + fill_rule: FillRule::NonZero, + stroke: None, + opacity: 1.0, + })); + for _ in 0..30 { + let mut p = Group::new("k"); + p.knockout = true; + p.isolated = false; + p.children.push(Node::Group(g)); + g = p; + } + let doc = Doc { width: 4.0, height: 4.0, root: g, skipped: vec![] }; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(rasterize(&doc, 4, 4, 1.0).width).ok()); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(20)).expect("rendering finishes"), 4); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5388629cf..c0eea0bd7 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -107,17 +107,29 @@ fn extra_dirs(var: Option<&std::ffi::OsStr>) -> Vec { var.map(|v| std::env::split_paths(v).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default() } -fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { - let Ok(rd) = std::fs::read_dir(dir) else { return }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - // Bounded, so a symlink loop ends. - if depth < 8 { - walk(&p, depth + 1, out); +/// Font files under `roots`, each real folder listed once. Breadth first from every root, so a +/// folder reached by several routes is listed at its shallowest and keeps the most depth below it. +fn walk(roots: &[PathBuf], out: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + let mut queue: std::collections::VecDeque<(PathBuf, u32)> = roots.iter().map(|r| (r.clone(), 0)).collect(); + while let Some((dir, depth)) = queue.pop_front() { + // Symlinks fanning out to the same folders would otherwise be walked again along every + // path to them (exponentially many within the depth bound). + if !seen.insert(std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone())) { + continue; + } + let Ok(rd) = std::fs::read_dir(&dir) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + // Bounded, so a symlink loop ends. + if depth < 8 { + queue.push_back((p, depth + 1)); + } + } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) + { + out.push(p); } - } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { - out.push(p); } } } @@ -125,9 +137,7 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { impl FontSource for DirectorySource { fn faces(&self) -> Vec { let mut files = Vec::new(); - for d in &self.dirs { - walk(d, 0, &mut files); - } + walk(&self.dirs, &mut files); files.sort(); files.dedup(); let mut out = Vec::new(); @@ -163,6 +173,8 @@ pub struct Face { pub id: FaceId, pub info: FaceInfo, bytes: OnceLock>, + /// The file's metadata as its bytes were read (a [`FaceData::File`] face, once loaded). + file_meta: OnceLock, shaper: OnceLock, } @@ -194,17 +206,60 @@ pub struct VMetrics { pub underline_thickness: f32, } +/// A font file's bytes, shared by every face of it that is loaded (the faces of a collection +/// each held their own copy of the whole file), with the metadata of the open file they were read +/// from (so it describes the bytes, not a later version of the file). +fn file_bytes(p: &std::path::Path) -> std::io::Result<(Arc>, std::fs::Metadata)> { + use std::io::Read; + type Key = (PathBuf, u64, Option); + type Entry = (std::sync::Weak>, std::fs::Metadata); + static FILES: std::sync::LazyLock>> = std::sync::LazyLock::new(Default::default); + let lock = || FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let hit = |files: &std::collections::HashMap, key: &Key| files.get(key).and_then(|(w, m)| Some((w.upgrade()?, m.clone()))); + let mut f = std::fs::File::open(p)?; + // Keyed by size and modification time too: a file replaced at the same path is read afresh. + let m = f.metadata()?; + let key: Key = (p.to_path_buf(), m.len(), m.modified().ok()); + if let Some(found) = hit(&lock(), &key) { + return Ok(found); + } + // Read outside the lock: a slow file must not hold up every other face's first load. + let mut bytes = Vec::new(); + f.read_to_end(&mut bytes)?; + let b = Arc::new(bytes); + let mut files = lock(); + if let Some(found) = hit(&files, &key) { + return Ok(found); + } + files.retain(|_, (w, _)| w.strong_count() > 0); + files.insert(key, (Arc::downgrade(&b), m.clone())); + Ok((b, m)) +} + impl Face { + /// The font file bytes, loaded once on first use, or `None` when they cannot be read. fn bytes(&self) -> Option<&[u8]> { self.bytes .get_or_init(|| match &self.info.data { FaceData::Static(b) => Some(FaceBytes::Static(b)), FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())), - FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))), + FaceData::File(p) => { + let (bytes, meta) = file_bytes(p).ok()?; + let _ = self.file_meta.set(meta); + Some(FaceBytes::Shared(bytes)) + } }) .as_ref() .map(FaceBytes::get) } + /// The file a [`FaceData::File`] face is drawn from, with its metadata as its bytes were read + /// (`None` when it could not be read): what text drawn with the face was made of. Loads the + /// face. `None` for bundled faces and faces from memory. + pub fn file_read(&self) -> Option<(&std::path::Path, Option<&std::fs::Metadata>)> { + let FaceData::File(p) = &self.info.data else { return None }; + let _ = self.bytes(); + Some((p, self.file_meta.get())) + } /// The parsed font (None if the file is missing or unreadable). pub fn font(&self) -> Option> { FontRef::from_index(self.bytes()?, self.info.index).ok() @@ -314,7 +369,7 @@ fn bundled_default_face() -> Arc { variable: false, instances: Vec::new(), }); - Arc::new(Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() }) + Arc::new(Face { id: 0, info, bytes: OnceLock::new(), file_meta: OnceLock::new(), shaper: OnceLock::new() }) } fn db() -> &'static RwLock { @@ -331,9 +386,10 @@ fn db() -> &'static RwLock { }) } +/// Adds a face to the database and returns its id. fn push(db: &mut Db, info: FaceInfo) -> FaceId { let id = db.faces.len(); - db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), shaper: OnceLock::new() })); + db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), file_meta: OnceLock::new(), shaper: OnceLock::new() })); id } diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index d95f07c6a..2e04e5f0e 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -567,3 +567,72 @@ fn unknown_face_id_returns_bundled_default() { assert!(fallback.font().is_some()); assert!(Arc::ptr_eq(&fallback, &face(0))); } + +/// A scratch folder this test created itself (never one that already exists), removed on drop. +struct Scratch(std::path::PathBuf); +impl Scratch { + fn new(tag: &str) -> Self { + let d = (0u32..) + .map(|n| std::env::temp_dir().join(format!("effectcraft-{tag}-{}-{n}", std::process::id()))) + .find_map(|d| match std::fs::create_dir(&d) { + Ok(()) => Some(d), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => None, + Err(e) => panic!("create scratch dir {}: {e}", d.display()), + }) + .unwrap(); + Scratch(d) + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(unix)] +#[test] +fn a_folder_reached_through_many_links_is_listed_once() { + // Six links back to the folder itself: 6^8 listings within the depth bound before. + let scratch = Scratch::new("font-walk"); + let d = &scratch.0; + std::fs::write(d.join("a.ttf"), b"x").unwrap(); + for i in 0..6 { + std::os::unix::fs::symlink(d, d.join(format!("l{i}"))).unwrap(); + } + let mut out = Vec::new(); + let start = std::time::Instant::now(); + walk(std::slice::from_ref(d), &mut out); + assert!(start.elapsed() < std::time::Duration::from_secs(5)); + assert_eq!(out.len(), 1); +} + +#[test] +fn a_folder_inside_another_root_keeps_its_own_depth() { + // root/a/b/c/d/e/f/g/h is also a root: the walk of `root` reaches it at depth 8 and could not + // go below it, so it must be listed from the shallower visit. + let scratch = Scratch::new("font-depth"); + let root = &scratch.0; + let deep = root.join("a/b/c/d/e/f/g/h"); + std::fs::create_dir_all(deep.join("sub")).unwrap(); + std::fs::write(deep.join("sub/x.ttf"), b"x").unwrap(); + let mut out = Vec::new(); + walk(&[root.clone(), deep.clone()], &mut out); + assert_eq!(out, vec![deep.join("sub/x.ttf")]); +} + +#[test] +fn a_font_file_replaced_at_the_same_path_is_read_again() { + let scratch = Scratch::new("font-bytes"); + let p = scratch.0.join("a.ttf"); + std::fs::write(&p, b"old").unwrap(); + let (old, old_meta) = file_bytes(&p).unwrap(); + // Faces of one file share its bytes, and the metadata is that of the file the bytes came from. + let (again, _) = file_bytes(&p).unwrap(); + assert!(Arc::ptr_eq(&old, &again)); + assert_eq!(old_meta.len(), 3); + std::fs::write(&p, b"new, longer").unwrap(); + let (new, new_meta) = file_bytes(&p).unwrap(); + assert_eq!(new.as_slice(), b"new, longer"); + assert_eq!(new_meta.len(), 11); + assert_eq!(old.as_slice(), b"old"); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4c0b5a436..9eb19d919 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -278,19 +278,26 @@ pub struct Layout { impl Layout { /// Line index for a caret at byte `pos`. pub fn line_of(&self, pos: usize) -> usize { - let mut li = 0; - for (i, l) in self.lines.iter().enumerate() { - if l.range.start <= pos { - li = i; - } - } - li + // Lines are in text order: the last one starting at or before `pos`. + self.lines.partition_point(|l| l.range.start <= pos).saturating_sub(1) } /// Caret position `(x, baseline, line)` for byte offset `pos`. pub fn caret(&self, pos: usize) -> (f32, f32, usize) { let li = self.line_of(pos); let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) }; - let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1); + // Carets are sorted by byte offset and unique: the nearest is next to the insertion point (the lower on a tie). + let i = l.carets.partition_point(|c| c.0 < pos); + let x = match (i.checked_sub(1).and_then(|k| l.carets.get(k)), l.carets.get(i)) { + (Some(p), Some(n)) => { + if pos - p.0 <= n.0 - pos { + p.1 + } else { + n.1 + } + } + (Some(c), None) | (None, Some(c)) => c.1, + (None, None) => l.x, + }; (x, l.baseline, li) } /// Nearest caret byte offset to point `(x, y)`. @@ -371,6 +378,34 @@ struct Item { range: Range, rtl: bool, glyphs: Vec, + /// Whether the glyphs' clusters never decrease (`Some(true)`) or never increase + /// (`Some(false)`) along `glyphs`: the glyphs of a cluster range are then contiguous. + cluster_order: Option, +} + +impl Item { + fn new(range: Range, rtl: bool, glyphs: Vec) -> Self { + let cluster_order = if glyphs.windows(2).all(|w| w[0].cluster <= w[1].cluster) { + Some(true) + } else if glyphs.windows(2).all(|w| w[0].cluster >= w[1].cluster) { + Some(false) + } else { + None + }; + Self { range, rtl, glyphs, cluster_order } + } + + /// The glyphs whose cluster lies in `run`, in glyph order, found by bisection when the clusters + /// are ordered (a line of a long paragraph otherwise scanned the whole item). + fn glyphs_in(&self, run: &Range) -> impl Iterator { + let g = &self.glyphs; + let (lo, hi) = match self.cluster_order { + Some(true) => (g.partition_point(|x| x.cluster < run.start), g.partition_point(|x| x.cluster < run.end)), + Some(false) => (g.partition_point(|x| x.cluster >= run.end), g.partition_point(|x| x.cluster >= run.start)), + None => (0, g.len()), + }; + g[lo..hi].iter().filter(|x| run.contains(&x.cluster)) + } } fn upper_single(c: char) -> char { @@ -621,9 +656,31 @@ fn is_hanging_open(c: char) -> bool { matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}') } +/// Whether no run is reversed and each starts at or after the end of the one before it (the runs +/// of a document): the precondition of [`run_at`]'s bisection. +fn runs_ordered(runs: &[(Range, TextStyle)]) -> bool { + runs.iter().all(|(r, _)| r.start <= r.end) && runs.windows(2).all(|w| w[0].0.end <= w[1].0.start) +} + +/// Style runs with whether they are [`runs_ordered`]. +#[derive(Clone, Copy)] +struct Runs<'a> { + list: &'a [(Range, TextStyle)], + ordered: bool, +} + /// The style run index covering global byte `b` (the run before it at a boundary, for empty -/// paragraphs at the end). -fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize { +/// paragraphs at the end). `ordered` is [`runs_ordered`] of `runs`. +fn run_at(runs: &[(Range, TextStyle)], ordered: bool, b: usize) -> usize { + // Ordered, non-overlapping runs: the last one starting at or before `b` is the only one that + // can hold it, found by bisection (a scan from the first run per character was quadratic). + // Anything else (a gap, overlapping or unordered runs) takes the scan, whose first match wins. + if ordered { + let i = runs.partition_point(|(r, _)| r.start <= b).saturating_sub(1); + if runs.get(i).is_some_and(|(r, _)| r.contains(&b)) { + return i; + } + } runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0)) } @@ -699,7 +756,7 @@ impl<'a> Para<'a> { fn new( text: &'a str, base: usize, - runs: &'a [(Range, TextStyle)], + runs: Runs<'a>, primaries: &'a [Resolved], variation_ids: &'a [u32], para: &'a ParagraphStyle, @@ -709,7 +766,7 @@ impl<'a> Para<'a> { let bidi = BidiInfo::new(text, default_level); let base_rtl = bidi.paragraphs[0].level.is_rtl(); let chars = char_forms(text, base, runs, primaries, &bidi); - let items = build_items(text, &chars, runs); + let items = build_items(text, &chars, runs.list); // Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1). // Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes // or rescanning its trailing whitespace would otherwise add another factor of text length. @@ -732,7 +789,7 @@ impl<'a> Para<'a> { } previous_non_ws[b + c.len_utf8()] = non_ws_end; } - Para { text, base, runs, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws } + Para { text, base, runs: runs.list, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws } } fn range_w(&self, r: Range) -> f32 { @@ -900,7 +957,7 @@ impl<'a> Para<'a> { idx.reverse(); } for k in idx { - for g in self.items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) { + for g in self.items[k].glyphs_in(&run) { let st = &self.runs[g.run].1; let x0 = pen; pen += g.pre; @@ -961,9 +1018,10 @@ impl<'a> Para<'a> { } } // end of line caret - let end_x = match self.chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) { + let last_char = self.chars.partition_point(|c| c.0 < r.end).checked_sub(1).and_then(|i| self.chars.get(i)).filter(|c| c.0 >= r.start); + let end_x = match last_char { Some(c) => { - let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied(); + let cs = cluster_starts.partition_point(|s| *s <= c.0).checked_sub(1).and_then(|i| cluster_starts.get(i)).copied(); match cs.and_then(|s| extents.get(&s)) { Some(&(x0, x1, rtl)) => { if rtl { @@ -1014,11 +1072,11 @@ impl<'a> Para<'a> { } /// Each char with its shaped form, face, direction and run. -fn char_forms(text: &str, base: usize, runs: &[(Range, TextStyle)], primaries: &[Resolved], bidi: &BidiInfo) -> Vec { +fn char_forms(text: &str, base: usize, runs: Runs, primaries: &[Resolved], bidi: &BidiInfo) -> Vec { text.char_indices() .map(|(b, c)| { - let si = run_at(runs, base + b); - let style = &runs[si].1; + let si = run_at(runs.list, runs.ordered, base + b); + let style = &runs.list[si].1; let primary = primaries[si].face; let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) }; let all_small = style.opentype.all_small_caps && style.caps != Caps::All; @@ -1066,23 +1124,15 @@ fn build_items(text: &str, chars: &[CharInfo], runs: &[(Range, TextStyle) let mut glyphs = shape_item(&sub, rtl, face, size, style, si, form); space_item(&mut glyphs, style, text, true); let end = if j < chars.len() { chars[j].0 } else { text.len() }; - items.push(Item { range: chars[i].0..end, rtl, glyphs }); + items.push(Item::new(chars[i].0..end, rtl, glyphs)); i = j; } items } -fn paragraph( - text: &str, - base: usize, - runs: &[(Range, TextStyle)], - primaries: &[Resolved], - variation_ids: &[u32], - para: &ParagraphStyle, - width: Option, -) -> Vec { +fn paragraph(text: &str, base: usize, runs: Runs, primaries: &[Resolved], variation_ids: &[u32], para: &ParagraphStyle, width: Option) -> Vec { if text.is_empty() { - let si = run_at(runs, base); + let (runs, si) = (runs.list, run_at(runs.list, runs.ordered, base)); let pm = style_metrics(primaries, runs, si); // No text to resolve a direction from: the explicit one. let rtl = para.rtl == Some(true); @@ -1160,18 +1210,20 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para }); let runs = cleared.as_deref().unwrap_or(runs); let variation_ids: Vec = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect(); + let ordered = runs_ordered(runs); let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() }; let mut plines: Vec<(usize, ParaLine)> = Vec::new(); for (pi, span) in paragraph_ranges(text).enumerate() { let p = text.get(span.bytes.clone()).unwrap_or_default(); - plines.extend(paragraph(p, span.bytes.start, runs, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l))); + plines + .extend(paragraph(p, span.bytes.start, Runs { list: runs, ordered }, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l))); } let mut baseline = 0.0f32; for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { // Vertical type keeps its first column at the font ascent (not measured in AE). - let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + let vertical = runs.get(run_at(runs, ordered, pl.range.start)).is_some_and(|r| r.1.vertical) || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { @@ -1191,7 +1243,7 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para })); let x = shift + pl.left_trim; if let Some((u0, u1)) = pl.underline { - let si = run_at(runs, pl.range.start); + let si = run_at(runs, ordered, pl.range.start); let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size); lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]); } diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 5792c1c16..bf275cad8 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -171,6 +171,17 @@ fn caps_and_small_caps() { assert_eq!(l.underlines.len(), 1); } +#[test] +fn indexed_caret_lookup_matches_a_linear_scan() { + let l = layout("fi abc\n\nde fg hi", &st(40.0), &ParagraphStyle::default()); + for pos in 0..=l.lines.iter().map(|x| x.range.end).max().unwrap_or(0) + 2 { + let li = l.lines.iter().enumerate().filter(|(_, x)| x.range.start <= pos).map(|(i, _)| i).next_back().unwrap_or(0); + let line = &l.lines[li]; + let want = line.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(line.x, |c| c.1); + assert_eq!(l.caret(pos), (want, line.baseline, li), "pos {pos}"); + } +} + #[test] fn ligature_caret_interpolates() { // Inter has an "fi"-like ligature only via calt in some versions; use "ffi" which most @@ -494,3 +505,48 @@ fn a_later_vertical_run_keeps_the_first_line_baseline() { let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]); assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline); } + +/// `Item::glyphs_in` finds a cluster range by bisection when the clusters are ordered, and agrees +/// with a full scan for ascending, descending and unordered clusters. +#[test] +fn item_glyphs_in_matches_a_full_scan() { + let glyph = |cluster| ShapedGlyph { face: 0, id: cluster as u32, cluster, adv: 1.0, pre: 0.0, dx: 0.0, dy: 0.0, size: 10.0, run: 0, rise: 0.0 }; + let asc: Vec = vec![0, 0, 1, 3, 3, 3, 5, 8]; + let desc: Vec = asc.iter().rev().copied().collect(); + let mixed: Vec = vec![3, 0, 5, 1, 8, 3, 0, 3]; + for (clusters, order) in [(&asc, Some(true)), (&desc, Some(false)), (&mixed, None)] { + let item = Item::new(0..9, false, clusters.iter().map(|&c| glyph(c)).collect()); + assert_eq!(item.cluster_order, order); + for (a, b) in [(0, 9), (0, 1), (1, 4), (3, 4), (4, 9), (6, 8), (9, 9), (2, 3)] { + let got: Vec = item.glyphs_in(&(a..b)).map(|g| g.cluster).collect(); + let want: Vec = clusters.iter().copied().filter(|c| (a..b).contains(c)).collect(); + assert_eq!(got, want, "{clusters:?} {a}..{b}"); + } + } +} + +#[test] +fn run_lookup_by_bisection_matches_the_scan() { + let scan = |runs: &[(Range, TextStyle)], b: usize| { + runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0)) + }; + let runs: Vec<(Range, TextStyle)> = (0..50).map(|i| (i * 3..i * 3 + 3, st(10.0))).collect(); + let gapped: Vec<(Range, TextStyle)> = (0..50).map(|i| (i * 4..i * 4 + 3, st(10.0))).collect(); + // Overlapping runs: the first run holding a byte wins, as in the scan. + let overlapping: Vec<(Range, TextStyle)> = vec![(0..10, st(10.0)), (5..15, st(20.0)), (12..20, st(30.0))]; + assert!(runs_ordered(&runs) && runs_ordered(&gapped) && !runs_ordered(&overlapping)); + for b in 0..220 { + assert_eq!(run_at(&runs, true, b), scan(&runs, b), "{b}"); + assert_eq!(run_at(&gapped, true, b), scan(&gapped, b), "{b}"); + assert_eq!(run_at(&overlapping, false, b), scan(&overlapping, b), "{b}"); + } + assert_eq!(run_at(&overlapping, false, 6), 0); + // A reversed range (20..0) among others is not ordered either, and takes the scan. + #[allow(clippy::reversed_empty_ranges)] + let reversed: Vec<(Range, TextStyle)> = vec![(0..10, st(10.0)), (20..0, st(20.0)), (5..15, st(30.0)), (15..20, st(40.0))]; + assert!(!runs_ordered(&reversed)); + for b in 0..30 { + assert_eq!(run_at(&reversed, runs_ordered(&reversed), b), scan(&reversed, b), "{b}"); + } + assert_eq!(run_at(&[], true, 5), 0); +} diff --git a/crates/text/src/path_text.rs b/crates/text/src/path_text.rs index 85cab0719..7455456ad 100644 --- a/crates/text/src/path_text.rs +++ b/crates/text/src/path_text.rs @@ -19,7 +19,26 @@ impl PathMeasure { let mut closed = false; let mut started = false; let mut done = false; - kurbo::flatten(path.iter(), 0.05, |el| { + // The tolerance grows with the path's size (0.05 for ordinary layer-sized paths): a + // path with huge coordinates would otherwise flatten into hundreds of millions of points. + // Only the first subpath is measured, so only it sets the tolerance and gets flattened. + let mut first = BezPath::new(); + for el in path.iter() { + if matches!(el, PathEl::MoveTo(_)) && !first.elements().is_empty() { + break; + } + first.push(el); + if matches!(el, PathEl::ClosePath) { + break; + } + } + // A non-finite coordinate (an infinite control point too) measures nothing. + if !first.is_finite() { + return PathMeasure::default(); + } + let bbox = kurbo::Shape::bounding_box(&first); + let size = bbox.width().max(bbox.height()); + kurbo::flatten(first.iter(), 0.05_f64.max(size * 1e-6), |el| { if done { return; } @@ -128,6 +147,38 @@ pub fn arc_positions(xs: &[f64], line: [f64; 2], align: u8, length: f64, first_m mod tests { use super::*; + #[test] + fn a_huge_curve_flattens_into_a_bounded_number_of_points() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((1e15, 0.0), (1e15, 1e15), (0.0, 1e15)); + let m = PathMeasure::new(&p, false); + assert!(m.pts.len() < 100_000, "{}", m.pts.len()); + assert!(m.length() > 1e15); + // Non-finite coordinates measure nothing. + let mut q = BezPath::new(); + q.move_to((0.0, 0.0)); + q.curve_to((f64::INFINITY, 0.0), (1.0, 1.0), (2.0, 0.0)); + assert_eq!(PathMeasure::new(&q, false).pts.len(), 0); + // Even with finite endpoints, an infinite control point is refused before flattening. + let mut r = BezPath::new(); + r.move_to((0.0, 0.0)); + r.curve_to((f64::INFINITY, f64::INFINITY), (f64::INFINITY, 0.0), (1.0, 0.0)); + assert_eq!(PathMeasure::new(&r, false).pts.len(), 0); + } + + #[test] + fn a_distant_second_subpath_does_not_coarsen_the_first() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((0.0, 100.0), (100.0, 100.0), (100.0, 0.0)); + p.move_to((1e9, 1e9)); + p.line_to((1e9 + 1.0, 1e9)); + let m = PathMeasure::new(&p, false); + assert!(m.pts.len() > 10, "{}", m.pts.len()); + assert!(m.length() > 150.0, "{}", m.length()); + } + #[test] fn measures_a_line_and_a_circle() { let mut p = BezPath::new(); diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index a5e3907ce..0ca447f5b 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -257,7 +257,12 @@ fn name_string(b: &[u8], id: u16) -> Option { // Best rank first; a blank record falls through to the next. Decoding is lazy, so aliased // records are not decoded once a usable one is found. cands.sort_by_key(|c| c.0); + // Records may alias one string: each (encoding, bytes) is decoded at most once. + let mut tried = std::collections::HashSet::new(); cands.into_iter().find_map(|(_, plat, enc, raw)| { + if !tried.insert((plat, enc, raw.as_ptr(), raw.len())) { + return None; + } let s = match (plat, enc) { (3, 1 | 10) | (0, _) => { let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index a4412dbce..5cdaa38ca 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -485,6 +485,14 @@ fn adjust_impl(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts, al let build_point = |j: usize, s_acc: &mut Vec, bc: &mut Vec| -> PtSys { let mut ps = PtSys { v: [[0.0; 3]; 3], bp: [0.0; 3], w: vec![] }; let mut jc = CameraJacobian::new(); + // Observations that repeat a camera add to the same parameters: combine them, so the + // point's entries stay at most one per parameter (the reduction is quadratic in them). + let mut cams_seen: Vec = by_pt[j].iter().map(|&oi| obs[oi as usize].cam).collect(); + cams_seen.sort_unstable(); + let repeated_camera = cams_seen.windows(2).any(|p| p[0] == p[1]); + // Parameter index → entry in `ps.w`, for points that repeat a camera (a linear search + // per entry would be quadratic in their observations). + let mut slot: std::collections::HashMap = std::collections::HashMap::new(); for &oi in &by_pt[j] { let ob = &obs[oi as usize]; let ci = ob.cam as usize; @@ -537,6 +545,16 @@ fn adjust_impl(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts, al // Each observation of a point is on another camera: only the shared focal // length repeats. let repeats = Some(*ia) == l.shared || l.dist.is_some_and(|g| *ia == g || *ia == g + 1); + if repeated_camera { + match slot.get(ia) { + Some(&k) => ps.w[k].1 = add(ps.w[k].1, wv), + None => { + slot.insert(*ia, ps.w.len()); + ps.w.push((*ia, wv)); + } + } + continue; + } let found = if repeats { ps.w.iter_mut().find(|e| e.0 == *ia) } else { None }; match found { Some(e) => e.1 = add(e.1, wv), @@ -820,6 +838,25 @@ mod tests { BaCam { pose: Pose::IDENTITY, f: 900.0, dist: Distortion::NONE } } + /// Many observations of one point by one camera combine into one contribution per parameter: + /// the point's reduction used to compare every duplicate with every later one. + #[test] + fn repeated_observations_of_one_camera_are_combined() { + let cams = [test_camera(), BaCam { pose: Pose { c: [1.0, 0.0, 0.0], ..Pose::IDENTITY }, ..test_camera() }]; + let truth = [0.2, -0.1, 5.0]; + let mut obs = Vec::new(); + for _ in 0..30_000 { + for (cam, c) in cams.iter().enumerate() { + let uv = residual(c, truth, [0.0; 2]).unwrap(); + obs.push(BaObs { cam: cam as u32, pt: 0, uv }); + } + } + let (mut cams, mut pts) = (cams, [[0.25, -0.05, 5.2]]); + let o = BaOpts::default(); + let r = adjust(&mut cams, &mut pts, &obs, &o).unwrap(); + assert!(r.cost <= r.initial_cost, "{r:?}"); + } + #[test] fn cost_rejects_invalid_observation_indices() { for (cam, pt) in [(1, 0), (0, 1)] { diff --git a/crates/track/src/camtrack/solver.rs b/crates/track/src/camtrack/solver.rs index 8e212127b..99e8a9920 100644 --- a/crates/track/src/camtrack/solver.rs +++ b/crates/track/src/camtrack/solver.rs @@ -932,7 +932,10 @@ fn solve_core(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBo } lap("keyframe adjustment"); // Every other frame: resect from the points (nearest registered camera first). - let mut todo: Vec = (0..d.n).filter(|k| !r.cams.contains_key(k)).collect(); + // A frame seeing fewer than six reconstructed points cannot be resected (`register`): it is + // skipped before the reconstruction is copied for it. + let mut todo: Vec = + (0..d.n).filter(|k| !r.cams.contains_key(k) && d.by_frame[*k].iter().filter(|(ti, _)| r.pts.contains_key(ti)).nth(5).is_some()).collect(); todo.sort_by_key(|k| r.cams.keys().map(|c| c.abs_diff(*k)).min().unwrap_or(usize::MAX)); let base = r.clone(); let resected: Vec<(usize, Option)> = todo diff --git a/crates/track/src/face.rs b/crates/track/src/face.rs index 708a05c4f..7c69c02af 100644 --- a/crates/track/src/face.rs +++ b/crates/track/src/face.rs @@ -558,16 +558,19 @@ fn fit_from_face(face: &Face, topo: &Topology, offset: [f64; 2]) -> Option Option<(usize, usize)> { let (w, h) = size; let (ci, cj) = center; + // The radius comes from mask geometry: past the window's larger side every ring is outside it. + let radius = radius.min(w.max(h) as i64 + 1); for r in 0..radius { for dj in -r..=r { - for di in -r..=r { - if di.abs() != r && dj.abs() != r { - continue; - } + // Only the ring's own pixels: the full rows at the top and bottom, the two ends between. + let step = if dj.abs() == r { 1 } else { (2 * r).max(1) }; + let mut di = -r; + while di <= r { let (i, j) = (ci as i64 + di, cj as i64 + dj); if i >= 0 && j >= 0 && (i as usize) < w && (j as usize) < h && skin[j as usize * w + i as usize] { return Some((i as usize, j as usize)); } + di += step; } } } @@ -1187,6 +1190,16 @@ pub fn measure(l: &[[f64; 2]; N], reference_height: f64) -> [f64; 14] { mod tests { use super::*; + #[test] + fn the_skin_search_stays_within_the_window_for_a_huge_radius() { + let mut skin = vec![false; 64 * 64]; + skin[60 * 64 + 5] = true; + // A radius from hostile mask geometry costs no more than one that covers the window. + assert_eq!(nearest_skin(&skin, (64, 64), (32, 32), i64::MAX), Some((5, 60))); + assert_eq!(nearest_skin(&skin, (64, 64), (5, 60), 3), Some((5, 60))); + assert_eq!(nearest_skin(&skin, (64, 64), (0, 0), 10), None); + } + /// Pearson correlation. fn corr(a: &[f64], b: &[f64]) -> f64 { let n = a.len() as f64; diff --git a/crates/track/src/lib.rs b/crates/track/src/lib.rs index 8867bfcb8..83e5c2e75 100644 --- a/crates/track/src/lib.rs +++ b/crates/track/src/lib.rs @@ -359,6 +359,9 @@ impl<'a> Scorer<'a> { } } +/// The largest pre-tracking blur (pixels), as the options command allows. +const MAX_BLUR: f64 = 100.0; + /// The pre-processed pixels around one point: a pyramid of a crop. Plane coordinates are image /// pixels minus `origin`, divided by `2^level`. struct Crop { @@ -369,7 +372,10 @@ struct Crop { impl Crop { /// Crop image pixels `[c - ext, c + ext]` (image pixel coordinates), pre-processed. fn new(frame: &Frame, opts: &TrackOptions, c: [f64; 2], ext: [f64; 2], levels: usize) -> Crop { - let sigma = opts.blur.max(0.0) / 2.0; + // A stored blur or search area past the frame adds only repeated edge pixels: both are + // held to what the frame can use, so a few project numbers cannot size a huge crop. + let sigma = opts.blur.clamp(0.0, MAX_BLUR) / 2.0; + let ext = [ext[0].clamp(0.0, frame.img.width as f64), ext[1].clamp(0.0, frame.img.height as f64)]; let margin = (sigma * 3.0).ceil() + 3.0 + if opts.enhance { 6.0 } else { 0.0 } + (1 << levels) as f64; let x0 = (c[0] - ext[0] - margin).floor(); let y0 = (c[1] - ext[1] - margin).floor(); diff --git a/crates/track/src/roto.rs b/crates/track/src/roto.rs index 3bab65dff..4ce4bfa95 100644 --- a/crates/track/src/roto.rs +++ b/crates/track/src/roto.rs @@ -80,6 +80,10 @@ impl StrokeKind { } } +/// The most frames a propagation chain reaches on either side of its base frame (about two +/// hours at 24 fps). +const MAX_CHAIN: i64 = 200_000; + /// One stroke: a polyline swept by a disc of `radius` layer pixels, on layer frame `frame`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -151,8 +155,16 @@ impl RotoData { pub fn strokes_of(&self, frame: i64, object: u32) -> Vec<&Stroke> { self.strokes.iter().filter(|s| s.frame == frame && (s.object == object || matches!(s.kind, StrokeKind::Refine | StrokeKind::RefineErase))).collect() } + /// The stored span cut to at most MAX_CHAIN frames either side of the base: a project's + /// numbers must not make one render build billions of keys. Frames past it have no key. + fn bounded_span(&self, base: i64) -> [i64; 2] { + [self.span[0].max(base.saturating_sub(MAX_CHAIN)), self.span[1].min(base.saturating_add(MAX_CHAIN))] + } pub fn in_span(&self, f: i64) -> bool { - self.base.is_some() && f >= self.span[0] && f <= self.span[1] + self.base.is_some_and(|b| { + let span = self.bounded_span(b); + f >= span[0] && f <= span[1] + }) } /// Add a stroke: the first segmentation stroke sets the base frame and a span of /// `default_span` frames each side (clamped to `limits`); strokes outside the span extend it. @@ -181,10 +193,15 @@ impl RotoData { pub fn chain_keys(&self, seed: u64) -> BTreeMap { let mut out = BTreeMap::new(); let Some(base) = self.base else { return out }; + // The strokes by frame, grouped once (each frame looked through every stroke before). + let mut by_frame: BTreeMap> = BTreeMap::new(); + for s in &self.strokes { + by_frame.entry(s.frame).or_default().push(s); + } let key = |prev: u64, f: i64| { let mut h = prev ^ 0x9e37_79b9_7f4a_7c15; fnv(&mut h, &f.to_le_bytes()); - for s in self.strokes.iter().filter(|s| s.frame == f) { + for s in by_frame.get(&f).into_iter().flatten() { fnv(&mut h, serde_json::to_string(s).unwrap_or_default().as_bytes()); } h @@ -192,12 +209,16 @@ impl RotoData { let k0 = key(seed, base); out.insert(base, k0); let mut k = k0; - for f in base + 1..=self.span[1] { - k = key(k, f); - out.insert(f, k); + let span = self.bounded_span(base); + // `base + 1` only when there is a frame after the base (it overflows at i64::MAX). + if base < span[1] { + for f in base + 1..=span[1] { + k = key(k, f); + out.insert(f, k); + } } k = k0; - for f in (self.span[0]..base).rev() { + for f in (span[0]..base).rev() { k = key(k, f); out.insert(f, k); } diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index e50dbbce8..4b2a040b4 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -723,6 +723,29 @@ fn the_union_of_objects_is_their_largest_extent() { assert_eq!(FrameSeg::union(vec![seg(&[1]), odd]).matte, vec![0, 255, 0, 0]); } +#[test] +fn a_stored_span_reaches_a_bounded_number_of_frames() { + // A project can store any span: the chain stops MAX_CHAIN frames either side of the base. + let d = RotoData { base: Some(0), span: [i64::MIN, i64::MAX], strokes: vec![] }; + let k = d.chain_keys(1); + assert_eq!(k.len() as i64, 2 * super::MAX_CHAIN + 1); + assert_eq!(k.keys().next(), Some(&-super::MAX_CHAIN)); +} + +#[test] +fn in_span_matches_the_frames_that_have_keys_and_extreme_bases_do_not_overflow() { + let d = RotoData { base: Some(0), span: [i64::MIN, i64::MAX], strokes: vec![] }; + assert!(d.in_span(super::MAX_CHAIN) && !d.in_span(super::MAX_CHAIN + 1)); + assert!(d.in_span(-super::MAX_CHAIN) && !d.in_span(-super::MAX_CHAIN - 1)); + assert_eq!(d.source_of(super::MAX_CHAIN + 1), None); + assert!(d.chain_keys(1).keys().all(|f| d.in_span(*f))); + // A base at the end of i64: no forward frames, and no `base + 1` overflow. + let d = RotoData { base: Some(i64::MAX), span: [i64::MIN, i64::MAX], strokes: vec![] }; + let k = d.chain_keys(1); + assert_eq!(k.len() as i64, super::MAX_CHAIN + 1); + assert!(k.contains_key(&i64::MAX)); +} + /// A scene for the band re-cut: `rgb` of a `w`×`h` frame where the foreground is `inside`, with /// a background that turns red left of `x = 30` (far from most shapes), and a soft prior that is /// `inside` shifted by (3, 2) px with an edge `soft_edge` px wide. diff --git a/crates/track/src/stabilize.rs b/crates/track/src/stabilize.rs index 092fc4ab4..e8f90f28d 100644 --- a/crates/track/src/stabilize.rs +++ b/crates/track/src/stabilize.rs @@ -440,6 +440,12 @@ fn remove_scale(b: &Homography, c: [f64; 2]) -> Homography { Homography::scale_about(1.0 / s, p0).then_after(b) } +/// Widest window: 3 sigma of the largest Smoothness (1000%) with Smooth More at its maximum, at 240 fps. +const MAX_SMOOTH_RADIUS: usize = 8640; +/// Most window taps (frames x radius) one smoothing pass does; past it the window narrows so planning +/// stays bounded (an hour at 60 fps with the widest setting is about 4.7e8). +const MAX_SMOOTH_WORK: usize = 1 << 30; + /// Corrective transforms (source → stabilized, before framing) for every frame. pub fn corrections(a: &WarpAnalysis, s: &StabSettings) -> Vec { let n = a.frames.len(); @@ -470,7 +476,8 @@ pub fn corrections(a: &WarpAnalysis, s: &StabSettings) -> Vec { } StabResult::SmoothMotion => { let sigma = s.sigma(); - let rad = ((3.0 * sigma).ceil() as usize).min(n.saturating_sub(1)); + // 3 sigma covers every supported setting; the work cap only bites on extreme frame counts. + let rad = (((3.0 * sigma).ceil() as usize).min(MAX_SMOOTH_RADIUS)).min(n.saturating_sub(1)).min(MAX_SMOOTH_WORK / n); for t in 0..n { if sigma < 0.3 || rad == 0 { out.push(Homography::IDENTITY); @@ -714,6 +721,47 @@ mod tests { s / (p.len() - 2) as f64 } + #[test] + fn a_huge_stored_fps_does_not_widen_the_smoothing_window_past_the_cap() { + let (a, _) = shaky(9500); + let s = StabSettings { fps: 1e9, preserve_scale: false, ..Default::default() }; + // Window 8640 each side: one frame is smoothed over at most 17281 frames, not all 9500. + let c = corrections(&a, &s); + assert_eq!(c.len(), 9500); + assert!(c.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite()))); + // A jump at frame 9400 is outside frame 0's window (frames 0..=8640): its correction is + // unchanged. Smoothing over the whole clip would move it. + let (mut jumped, _) = shaky(9500); + let f = &mut jumped.frames[9400]; + f.t[0] += 80.0; + f.s[2] += 80.0; + f.h[2] += 80.0; + let c2 = corrections(&jumped, &s); + for (x, y) in c[0].0.iter().flatten().zip(c2[0].0.iter().flatten()) { + assert!((x - y).abs() < 1e-9, "{x} vs {y}"); + } + assert!( + c[9499].0.iter().flatten().zip(c2[9499].0.iter().flatten()).any(|(x, y)| (x - y).abs() > 1e-6), + "the jump does move frames whose window holds it" + ); + } + + #[test] + fn the_widest_supported_smoothness_keeps_its_full_window() { + // 1000% Smoothness, Smooth More at its maximum, 60 fps: sigma 720 frames, radius 2160. + let s = StabSettings { smoothness: 1000.0, crop_less_smooth_more: 100.0, fps: 60.0, preserve_scale: false, ..Default::default() }; + let (a, _) = shaky(3000); + let c = corrections(&a, &s); + let (mut jumped, _) = shaky(3000); + let f = &mut jumped.frames[2000]; + f.t[0] += 80.0; + f.s[2] += 80.0; + f.h[2] += 80.0; + let c2 = corrections(&jumped, &s); + // Frame 0 sees a jump 2000 frames away (the old 1024-frame cap could not). + assert!(c[0].0.iter().flatten().zip(c2[0].0.iter().flatten()).any(|(x, y)| (x - y).abs() > 1e-9)); + } + #[test] fn smoothing_removes_jitter_and_no_motion_locks() { let (a, path) = shaky(90); diff --git a/crates/track/src/subspace.rs b/crates/track/src/subspace.rs index 9c7d5ceeb..d68615892 100644 --- a/crates/track/src/subspace.rs +++ b/crates/track/src/subspace.rs @@ -494,16 +494,27 @@ fn gauss(lo: usize, hi: usize, t: usize, sigma: f64) -> Vec { /// subspace dimension (9 in the paper). Frames without enough trajectories get no pairs. pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: usize) -> Vec> { let max_r = ((3.0 * sigma).ceil() as usize).clamp(1, 30); + // The trajectories seen on each frame (in track order): a frame only ever uses those, so none + // scans the whole collection (every frame did, once per window shrink). + let mut seen: Vec> = vec![Vec::new(); frames]; + for (i, tr) in tracks.iter().enumerate() { + let (a, b) = (tr.start as usize, (tr.end() as usize).min(frames)); + for list in seen.iter_mut().take(b).skip(a) { + list.push(i as u32); + } + } (0..frames) .into_par_iter() .map(|t| { let t32 = t as u32; + let on_frame = &seen[t]; let mut r = max_r; // The window around t: shrink it until enough trajectories span it. let (lo, hi, complete) = loop { let lo = t.saturating_sub(r); let hi = (t + r).min(frames.saturating_sub(1)); - let complete: Vec<&Track2D> = tracks.iter().filter(|tr| tr.start as usize <= lo && tr.end() as usize > hi).collect(); + let complete: Vec<&Track2D> = + on_frame.iter().map(|&i| &tracks[i as usize]).filter(|tr| tr.start as usize <= lo && tr.end() as usize > hi).collect(); if complete.len() >= 8 || r <= 2 { break (lo, hi, complete); } @@ -556,7 +567,7 @@ pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: // Incomplete trajectories seen on t: coefficients by least squares over the frames // they cover in the window. let need = (k + 3).max(m / 2); - for tr in tracks.iter().filter(|tr| tr.at(t32).is_some() && !(tr.start as usize <= lo && tr.end() as usize > hi)) { + for tr in on_frame.iter().map(|&i| &tracks[i as usize]).filter(|tr| !(tr.start as usize <= lo && tr.end() as usize > hi)) { let cols: Vec = (0..m).filter(|c| tr.at((lo + c) as u32).is_some()).collect(); if cols.len() < need { continue; @@ -601,6 +612,22 @@ pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: mod tests { use super::*; + /// Smoothing visits only the trajectories seen on each frame: tracks that start after the clip + /// (or on other frames) change nothing and cost nothing, however many there are. + #[test] + fn smoothing_ignores_trajectories_off_the_frame() { + let frames = 60usize; + let wob = |k: usize, f: usize| [10.0 + k as f32 * 3.0 + (f as f32 * 0.2).sin() * 4.0, 20.0 + (k % 7) as f32 * 5.0 + f as f32 * 0.5]; + let mut tracks: Vec = (0..30).map(|k| Track2D { id: k as u32, start: 0, pts: (0..frames).map(|f| wob(k, f)).collect() }).collect(); + // Partial tracks: some seen on only part of the clip. + tracks.extend((0..10).map(|k| Track2D { id: 100 + k as u32, start: 5 * k as u32, pts: (5 * k..frames.min(5 * k + 30)).map(|f| wob(k, f)).collect() })); + let base = smooth_trajectories(&tracks, frames, 3.0, 9); + assert!(base.iter().any(|v| v.len() >= 30), "the fixture gives frames with pairs"); + let mut more = tracks.clone(); + more.extend((0..200_000u32).map(|i| Track2D { id: 1000 + i, start: frames as u32 + i % 50, pts: vec![[1.0, 2.0]] })); + assert_eq!(smooth_trajectories(&more, frames, 3.0, 9), base); + } + #[test] fn mesh_identity_sample_and_forward() { let m = Mesh::identity(4, 3, [100.0, 60.0]); diff --git a/crates/ui-egui/examples/cache_bench.rs b/crates/ui-egui/examples/cache_bench.rs index 828075249..43e937c92 100644 --- a/crates/ui-egui/examples/cache_bench.rs +++ b/crates/ui-egui/examples/cache_bench.rs @@ -10,9 +10,18 @@ //! //! Options: `--project P --comp C` (a saved project's comp instead), `--scale S` (render scale, //! default 0.5: a viewer at half size), `--frames N` (frames to cache, default the whole comp), -//! `--slots N` (frames in flight, at least 1, default one per frame thread as the app does when -//! idle), -//! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`. +//! `--slots N` (frames in flight, at least 1, default one per lane as the app does when idle), +//! `--lane-width W` (threads per lane; the app's default when absent; `--play` takes it too), +//! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`, +//! `--gpu` (composite on a GPU of its own, as the desktop app does with Mercury GPU Acceleration: +//! the GPU or the CPU, whichever Auto measures faster per comp, read back to the CPU; the viewer's +//! backend), `--backend cpu|gpu|auto` (with `--gpu`: force one compositor, default `auto`). +//! +//! `--play SECS` instead plays the comp in the app (headless, a viewer at 100 %, Resolution +//! Auto, nothing cached, Cache Frames When Idle off) for SECS seconds of wall time, as a first +//! playback, and prints the frames shown per second, the time stalled and the scale it played at. +//! With `--gpu` the app composites on the GPU (Mercury Auto), otherwise on the CPU; `--res N` +//! sets the comp's Resolution to every N-th pixel instead of Auto. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -28,6 +37,11 @@ fn arg(name: &str) -> Option { a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned()) } +/// `--lane-width W`: threads per frame lane (the app's default when absent). +fn lane_width() -> Option { + arg("--lane-width").and_then(|v| v.parse().ok()) +} + fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } @@ -112,23 +126,39 @@ fn main() { cid } }; + if let Some(secs) = arg("--play").and_then(|v| v.parse().ok()) { + play(s, cid, secs, std::env::args().any(|a| a == "--gpu")); + return; + } let comp = s.project.comp(cid).expect("comp").clone(); let total = comp.frame_rate.frame_at(comp.duration); let n = arg("--frames").and_then(|v| v.parse().ok()).unwrap_or(total).min(total); let scale: f64 = arg("--scale").and_then(|v| v.parse().ok()).unwrap_or(0.5); - let frames = Frames::default(); - // (as Cache Frames When Idle: one frame per frame thread, see `app/prefetch.rs`) - let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.parallelism().max(1)); + let frames = lane_width().map_or_else(Frames::default, Frames::with_lane_width); + // (as Cache Frames When Idle: one frame per lane, see `app/prefetch.rs`) + let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.lanes()); // Room for every frame: this measures speed, not eviction. frames.set_budget(64 << 30); - let opts = RenderOpts { scale, motion_blur: true, ..Default::default() }; + use effectcraft_engine::render::Backend; + let backend = match arg("--backend").as_deref() { + Some("cpu") => Backend::Cpu, + Some("gpu") => Backend::Gpu, + _ => Backend::Auto, + }; + let opts = RenderOpts { scale, motion_blur: true, backend, ..Default::default() }; + let gpu = std::env::args().any(|a| a == "--gpu").then(|| { + effectcraft_gpu::Gpu::headless().unwrap_or_else(|| { + eprintln!("--gpu: no usable GPU adapter"); + std::process::exit(2) + }) + }); let src = RenderSource { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), layer_cache: s.layer_cache.clone(), - gpu: None, + gpu, gpu_display: false, disk: s.disk_cache.clone(), }; @@ -142,7 +172,13 @@ fn main() { opts: opts_hash(&opts), }; - eprintln!("caching {n} frames of {}×{} at scale {scale} with {slots} in flight…", comp.width, comp.height); + eprintln!( + "caching {n} frames of {}×{} at scale {scale} with {slots} in flight on {} lanes{}…", + comp.width, + comp.height, + frames.lanes(), + if src.gpu.is_some() { " on the GPU" } else { "" } + ); let (gop0, t0) = (effectcraft_media::gop_stats(), Instant::now()); let mut next = 0; loop { @@ -166,12 +202,115 @@ fn main() { gop.seeks - gop0.seeks, gop.hits - gop0.hits ); + if let Some(g) = &src.gpu + && let Some(p) = effectcraft_engine::render::Accelerator::auto_pick(g) + && let Some(a) = p.stats(effectcraft_engine::render::AutoKey::new(cid, scale, false)) + { + let ms = |v: Option| v.map_or("–".into(), |v| format!("{v:.1} ms")); + println!("auto (Mercury) GPU {} over {} frames, CPU {} over {} frames", ms(a.gpu_ms), a.gpu_frames, ms(a.cpu_ms), a.cpu_frames); + } let p = pool.stats(); println!("footage cache {} hits, {} misses, {} frames ({} MB) kept", p.hits, p.misses, p.frames, p.bytes >> 20); if let Some(dc) = &s.disk_cache { // (writes finish on the disk cache's own thread) + let t = Instant::now(); dc.flush(); + let flushed = t.elapsed().as_secs_f64(); let d = dc.stats(); - println!("disk cache {} written, {} dropped, {} entries ({} MB)", d.writes, d.dropped, d.entries, d.bytes >> 20); + println!("disk cache {} written, {} dropped, {} entries ({} MB), flushed in {flushed:.2} s", d.writes, d.dropped, d.entries, d.bytes >> 20); } } + +/// `--play`: a first playback of `cid` in the app, as someone pressing play in it would see. +fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) { + use effectcraft_ui_egui::{ControlRequest, EffectcraftApp}; + let (tx, rx) = std::sync::mpsc::channel::(); + let builder = egui_kittest::Harness::builder().with_size(egui::vec2(1920.0, 1200.0)).with_pixels_per_point(1.0); + let builder = if gpu { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(move |_| EffectcraftApp::new(session).with_control(rx)); + { + let app = h.state_mut(); + if let Some(w) = lane_width() { + app.frames = effectcraft_ui_egui::frames::Frames::with_lane_width(w); + } + let _ = app.session.execute("comp.open", json!({"comp": cid.0})); + app.ui.viewer.zoom = Some(1.0); + app.ui.cache_when_idle = false; + if let (Some(n), Some(c)) = (arg("--res").and_then(|v| v.parse().ok()), Arc::make_mut(&mut app.session.project).comp_mut(cid)) { + c.resolution = effectcraft_engine::project::Resolution::from_factor(n); + } + } + // Let the UI settle (fonts, the GPU compositor's pipelines) before the clock starts. + let ready = Instant::now() + Duration::from_secs(if gpu { 8 } else { 2 }); + while Instant::now() < ready { + h.step(); + std::thread::sleep(Duration::from_millis(10)); + } + h.state().frames.clear(); + let (reply, _r) = std::sync::mpsc::channel(); + let _ = tx.send(ControlRequest { method: "ui.playback".into(), params: json!({"action": "play"}), reply }); + let t0 = Instant::now(); + let (mut started, mut stalled, mut last, mut scales) = (None, 0.0, t0, std::collections::BTreeMap::::new()); + // When each new frame went on screen (seconds since play was pressed): the cadence. The + // current time, not `Playback::shown`: frames shown while the clock holds don't count there. + let fr = h.state().session.project.comp(cid).map(|c| c.frame_rate); + let (mut shown_at, mut last_frame) = (Vec::::new(), None); + while t0.elapsed().as_secs_f64() < secs { + h.step(); + let now = Instant::now(); + let app = h.state(); + if app.playback.playing { + let t = *started.get_or_insert(now); + if app.playback.waiting && now > t { + stalled += (now - last).as_secs_f64(); + } + } + let frame = fr.map(|fr| fr.frame_at(app.session.time())); + if app.playback.playing && frame != last_frame { + last_frame = frame; + shown_at.push((now - t0).as_secs_f64()); + } + *scales.entry(app.shown_series(cid).scale).or_default() += 1; + last = now; + std::thread::sleep(Duration::from_millis(2)); + } + let app = h.state(); + let played = started.map_or(0.0, |t| t.elapsed().as_secs_f64()); + let shown = shown_at.len(); + let frame = fr.map_or(0, |fr| fr.frame_at(app.session.time())); + println!( + "played {shown} frames shown in {played:.2} s: {:.1} frames/s, stalled {stalled:.2} s, reached frame {frame}{}", + shown as f64 / played.max(1e-9), + if gpu { " (GPU)" } else { "" } + ); + // Cadence: how evenly the frames went on screen (the intervals between consecutive shown + // frames after the first), and how long the first two took. + let mut gaps: Vec = shown_at.windows(2).map(|w| w[1] - w[0]).collect(); + gaps.sort_by(f64::total_cmp); + let at = |q: f64| gaps.get(((gaps.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0) * 1000.0; + let holds = gaps.iter().filter(|g| **g > 0.2).count(); + println!( + "cadence first frame {:.2} s, second {:.2} s; gaps median {:.0} ms, p95 {:.0} ms, max {:.0} ms, {holds} holds > 200 ms", + shown_at.first().copied().unwrap_or(0.0), + shown_at.get(1).copied().unwrap_or(0.0), + at(0.5), + at(0.95), + at(1.0) + ); + // The opening (the first 3 s, before an adaptive step down can have taken effect): how + // evenly the uncached frames at the starting resolution went on screen. + let mut early: Vec = shown_at.windows(2).filter(|w| w[1] <= 3.0).map(|w| w[1] - w[0]).collect(); + early.sort_by(f64::total_cmp); + let at = |q: f64| early.get(((early.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0) * 1000.0; + println!( + "opening (3 s) {} frames; gaps median {:.0} ms, p95 {:.0} ms, max {:.0} ms, {} holds > 200 ms", + early.len() + usize::from(!early.is_empty()), + at(0.5), + at(0.95), + at(1.0), + early.iter().filter(|g| **g > 0.2).count() + ); + let passes: u64 = scales.values().sum(); + let s: Vec = scales.iter().map(|(k, v)| format!("{:.3}: {:.0} %", *k as f64 / 1000.0, 100.0 * *v as f64 / passes.max(1) as f64)).collect(); + println!("viewer scale {}", s.join(", ")); +} diff --git a/crates/ui-egui/examples/rq_bench.rs b/crates/ui-egui/examples/rq_bench.rs new file mode 100644 index 000000000..6d470108b --- /dev/null +++ b/crates/ui-egui/examples/rq_bench.rs @@ -0,0 +1,149 @@ +//! Render Queue benchmark (#3335): a 1080p 32 bpc job on the GPU, the CPU or the measured choice. +//! +//! `cargo run --release -p effectcraft-ui-egui --example rq_bench -- [options]` +//! +//! It builds a comp like a compositing shot: a 1920 × 1080 OpenEXR sequence (float RGBA with a +//! Cryptomatte ID channel and manifest, generated once into `target/rq-bench/media`, no +//! committed media) under Gaussian Blur, Curves and the Cryptomatte effect and a moving +//! translucent solid, at 32 bpc. It queues the comp twice (the second item shows a warm start) and +//! renders the queue as the app does, printing each item's milliseconds per frame and a hash of +//! the written pixels (equal hashes: identical output). +//! +//! Options: `--renderer gpu|cpu` (the project's renderer, default gpu; with gpu the Render +//! Queue measures both unless `EFFECTCRAFT_RQ_RENDERER` says otherwise), `--frames N` (default +//! 60), `--format png|exr|tiff` (default png), `--out DIR` (default `target/rq-bench/out`). + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; +use serde_json::{Value, json}; + +const W: usize = 1920; +const H: usize = 1080; + +fn arg(name: &str) -> Option { + let a: Vec = std::env::args().collect(); + a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned()) +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// The plate's object at pixel (x, y) in frame `f`: 24 bands that drift sideways. +fn object(x: usize, y: usize, f: usize) -> String { + format!("obj{}", ((x + f * 7) / 80 + y / 270 * 24) % 96) +} + +/// Frame `f` of the plate: float colour over 1.0 (32 bpc), alpha, and Cryptomatte IDs. +fn plate(path: &std::path::Path, f: usize) { + use exr::prelude::*; + let px = |c: usize| -> Vec { + (0..W * H) + .map(|i| { + let (x, y) = (i % W, i / W); + let v = ((x as f32 * 0.003 + f as f32 * 0.05 + c as f32).sin() * 0.5 + 0.5) * (1.0 + y as f32 / H as f32); + if c == 3 { 1.0 } else { v } + }) + .collect() + }; + let ids: Vec = (0..W * H).map(|i| crypto_float(crypto_hash(&object(i % W, i / W, f)))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let channels = AnyChannels::sort( + vec![ch("R", px(0)), ch("G", px(1)), ch("B", px(2)), ch("A", px(3)), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", vec![1.0; W * H])].into(), + ); + let mut layer = Layer::new((W, H), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let names: serde_json::Map = (0..96).map(|k| format!("obj{k}")).map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))).collect(); + for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", Value::Object(names).to_string())] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + Image::from_layer(layer).write().to_file(path).expect("write plate"); +} + +/// FNV-1a over every written frame file, in name order (equal: identical output). +fn hash_dir(dir: &std::path::Path) -> (usize, u64) { + let mut names: Vec<_> = std::fs::read_dir(dir).map(|d| d.filter_map(|e| e.ok()).map(|e| e.path()).collect()).unwrap_or_default(); + names.retain(|p| !p.to_string_lossy().ends_with(".txt")); + names.sort(); + let mut h = 0xcbf2_9ce4_8422_2325u64; + for p in &names { + for b in std::fs::read(p).unwrap_or_default() { + h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3); + } + } + (names.len(), h) +} + +fn main() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/rq-bench"); + let frames: usize = arg("--frames").and_then(|n| n.parse().ok()).unwrap_or(60); + let format = arg("--format").unwrap_or_else(|| "png".into()); + let gpu = arg("--renderer").as_deref() != Some("cpu"); + let out = arg("--out").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("out")); + let media = root.join("media"); + std::fs::create_dir_all(&media).expect("media folder"); + let first = media.join("plate_0000.exr"); + for f in 0..frames { + let p = media.join(format!("plate_{f:04}.exr")); + if !p.exists() { + if f == 0 { + eprintln!("writing the {frames}-frame EXR plate (once)…"); + } + plate(&p, f); + } + } + + let mut s = effectcraft_host::session(); + let accel = effectcraft_gpu::Gpu::try_headless().unwrap_or_else(|e| panic!("no GPU: {e}")); + eprintln!("GPU: {}", effectcraft_engine::render::Accelerator::name(&accel)); + s.accel = Some(Arc::new(accel)); + let r = run(&mut s, "file.import", json!({"paths": [first.to_string_lossy()], "sequence": true})); + let footage = r["items"][0].as_u64().expect("the plate"); + let secs = frames as f64 / 30.0; + run(&mut s, "comp.new", json!({"name": "Shot", "width": W, "height": H, "frameRate": 30, "duration": secs})); + let plate = run(&mut s, "layer.addItem", json!({"item": footage}))["layer"].clone(); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Gaussian Blur"})); + run(&mut s, "prop.set", json!({"layer": plate, "path": "effects/#1/blurriness", "value": 12.0})); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Curves"})); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Cryptomatte"})); + let solid = run(&mut s, "layer.newSolid", json!({"color": "#d04020", "width": 400, "height": 300}))["layer"].clone(); + run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": 0.0, "value": [200.0, 540.0]})); + run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": secs, "value": [1720.0, 540.0]})); + run(&mut s, "prop.set", json!({"layer": solid, "path": "transform/opacity", "value": 70.0})); + let mut p = (*s.project).clone(); + p.settings.bit_depth = effectcraft_engine::project::BitDepth::Bpc32; + p.settings.gpu_acceleration = gpu; + s.project = Arc::new(p); + + let _ = std::fs::remove_dir_all(&out); + let dirs = [out.join("a"), out.join("b")]; + for d in &dirs { + std::fs::create_dir_all(d).expect("output folder"); + let path = d.join(format!("f_[####].{format}")); + run(&mut s, "renderQueue.add", json!({"comp": "Shot", "format": format, "output": path.to_string_lossy(), "log": "plusSettings"})); + } + let t0 = std::time::Instant::now(); + run(&mut s, "renderQueue.render", json!({"wait": true})); + let total = t0.elapsed().as_secs_f64(); + println!( + "renderer {} (EFFECTCRAFT_RQ_RENDERER={:?}, EFFECTCRAFT_GPU_INFLIGHT={:?}), {frames} frames {W}x{H} 32 bpc, {format}", + if gpu { "gpu" } else { "cpu" }, + std::env::var("EFFECTCRAFT_RQ_RENDERER").ok(), + std::env::var("EFFECTCRAFT_GPU_INFLIGHT").ok() + ); + for (i, (item, d)) in s.project.render_queue.iter().zip(&dirs).enumerate() { + let secs = item.render_time.unwrap_or(f64::NAN); + let (n, h) = hash_dir(d); + println!(" item {}: {:?}, {:.1} ms/frame, {n} files, hash {h:016x}", i + 1, item.status, secs * 1000.0 / frames as f64); + // The render log's account of the compositor and the GPU frames in flight. + let logs = + std::fs::read_dir(d).map(|r| r.filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with(".txt")).collect::>()); + for log in logs.unwrap_or_default() { + for line in std::fs::read_to_string(log).unwrap_or_default().lines().filter(|l| l.starts_with("Renderer:") || l.starts_with("GPU frames")) { + println!(" {line}"); + } + } + } + println!(" queue: {total:.2} s"); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9d32ba4cd..518f4e809 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -68,6 +68,7 @@ impl EffectcraftApp { forwarded: ForwardedInput::default(), input_waiters: vec![], job_waiters: vec![], + script_waiters: vec![], pending_screenshots: vec![], shot_bytes: 0, last_activity: (0.0, 0), @@ -96,6 +97,9 @@ impl EffectcraftApp { gpu_failure_applied: None, footage_watcher: None, footage_watch_key: None, + lut_watcher: None, + lut_generation: effectcraft_engine::lut_reload::generation(), + purges: 0, device_lost_close: None, dialog_answers: std::sync::mpsc::channel(), file_dialogs: FileDialogsUp::default(), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 56794cfe4..f3129c66d 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -47,6 +47,10 @@ impl EffectcraftApp { self.job_waiters.push((w, reply)); ctx.request_repaint(); } + control::Outcome::AwaitScript(host) => { + self.script_waiters.push((host, reply)); + ctx.request_repaint(); + } control::Outcome::Screenshot { path, target } => { // A covered or minimized window presents no frames (macOS skips its redraws), so // a screenshot would never arrive and nothing would tick its timeout. The @@ -70,11 +74,16 @@ impl EffectcraftApp { if self.job_waiters.is_empty() { return; } - self.session.poll_render(); - self.session.poll_offload(); + let script_busy = self.session.script_ui.render_host.is_some(); + if !script_busy { + self.session.poll_render(); + self.session.poll_offload(); + } let waiters = std::mem::take(&mut self.job_waiters); for (w, reply) in waiters { - if w.pending(self) { + // Completed native snapshots are immutable, so even a later suspended script + // cannot delay their replies. Other completions can edit its open undo group. + if (script_busy && w.render.is_none()) || w.pending(self) { self.job_waiters.push((w, reply)); } else { let v = w.finish(self); @@ -86,6 +95,42 @@ impl EffectcraftApp { } } + /// A suspended script still owns its undo groups. Only its render, inspection and Stop + /// run until the continuation reaches its next real script boundary; no scheduled callback + /// or background analysis may edit the document in the middle of the group. + fn tick_script_render(&mut self, ctx: &egui::Context) { + let Some(host) = self.session.script_ui.render_host else { return }; + let result = match self.session.script_ui.poll_render { + Some(poll) => poll(&mut self.session), + None => { + self.session.script_ui.render_host = None; + Some(json!({"ok": false, "error": {"message": "The script render waiter is unavailable."}})) + } + }; + if let Some(result) = result { + if let Some(message) = result["error"]["message"].as_str() { + self.ui.status = message.to_string(); + } else if let Some(output) = result["output"].as_str().filter(|s| !s.is_empty()) { + self.ui.status = output.lines().last().unwrap_or(output).to_string(); + } + self.script_waiters.retain(|(id, reply)| { + if *id == host { + let _ = reply.send(json!({"ok": true, "result": result})); + false + } else { + true + } + }); + } + // Do not interpret a close as permission to abandon the still-running script. Once + // Stop finishes it, the normal unsaved-changes prompt can handle a new close request. + if ctx.input(|i| i.viewport().close_requested()) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + self.drain_control(ctx, self.now); + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + // ---------------------------------------------------------------- frame /// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command, @@ -106,7 +151,11 @@ impl EffectcraftApp { self.ui.status = e; } } - effectcraft_engine::Event::PurgeCaches => self.frames.clear(), + effectcraft_engine::Event::PurgeCaches => { + self.frames.clear(); + // (frames made after it get keys of their own: see `Self::purge_stamp`) + self.purges = self.purges.wrapping_add(1); + } effectcraft_engine::Event::CopyImage { width, height, rgba } => { if let Err(e) = copy_image(ctx, width, height, &rgba) { self.ui.status = e; @@ -136,6 +185,10 @@ impl EffectcraftApp { craft_quit::on_close_request(ctx); return; } + if self.session.script_ui.render_host.is_some() { + self.tick_script_render(ctx); + return; + } // Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a // fallback (a logic-only tick leaves it at the previous pass's value). let focused = ctx.input(|i| i.viewport().focused.or(Some(i.focused))); @@ -170,10 +223,6 @@ impl EffectcraftApp { self.session.poll_jobs(); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } - // The disk cache's footage salt, taken off the UI thread after footage changed. - if self.session.poll_disk_salt() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } // Analyses running in a worker (the web app). if !self.session.offloaded.is_empty() { self.session.poll_offload(); @@ -199,8 +248,20 @@ impl EffectcraftApp { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } self.run_script_tasks(ctx); + if self.session.script_ui.render_host.is_some() { + self.tick_script_render(ctx); + return; + } self.run_dialog_answers(); + if self.session.script_ui.render_host.is_some() { + self.tick_script_render(ctx); + return; + } self.handle_events(ctx); + if self.session.script_ui.render_host.is_some() { + self.tick_script_render(ctx); + return; + } if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { if let Err(e) = menus::invoke(self, ctx, &id, json!({})) { @@ -209,6 +270,10 @@ impl EffectcraftApp { } self.command_inbox = Some(rx); } + if self.session.script_ui.render_host.is_some() { + self.tick_script_render(ctx); + return; + } // A close request (the window's button, Quit, `app.quit`) must be answered while hidden. // An answered file dialog can change the project, so the answers above run first. The host // calls `panels::unsaved::quit_on_close` last in its `logic`. @@ -231,6 +296,12 @@ impl EffectcraftApp { self.advance_playback(ctx, scale, now, active, cid); } self.drain_control(ctx, now); + // A clean project may have passed the close guard before a control script created + // unsaved edits and suspended. Cancel that same tick's close before the host handles it. + if self.session.script_ui.render_host.is_some() { + self.tick_script_render(ctx); + return; + } // Screenshot completion, deadlines and issuance are app-side work: the callback only // stores pixels and wakes this viewport. self.tick_screenshots(ctx); @@ -319,7 +390,7 @@ impl EffectcraftApp { /// fonts are ready, draw the frame, finish pointer wrapping, pump queued wasm frames, record input activity, /// publish the completed pass's geometry and pass sequence, acknowledge applied synthetic input and answer /// `AfterInput` replies only after a non-discarded pass. Requests repaints while frames are in flight. - fn draw_inner(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) { + fn draw_inner(&mut self, ui: &mut egui::Ui, _frame: &eframe::Frame) { let ctx = ui.ctx().clone(); if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(&ctx); @@ -337,7 +408,7 @@ impl EffectcraftApp { self.frame(ui); panels::crypto_pick::upkeep(self, &ctx); #[cfg(not(target_arch = "wasm32"))] - pointer_wrap::finish_native(&ctx, frame); + pointer_wrap::finish_native(&ctx, _frame); // wasm32: no frame threads; render queued frames now, between UI frames. if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) { ctx.request_repaint(); @@ -379,6 +450,23 @@ impl EffectcraftApp { /// The panels, menus, dialogs and overlays of one pass. fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + if self.session.script_ui.render_host.is_some() { + self.auto.begin_frame(); + ui.vertical_centered(|ui| { + ui.add_space(32.0); + ui.heading("Rendering script"); + ui.label("The script will continue when rendering finishes. Editing is paused to preserve its undo group."); + if let Some(progress) = self.session.render_progress() { + ui.label(format!("{} / {} frames", progress.done, progress.total)); + } + let stop = ui.button("Stop Render"); + self.auto.add("scriptRender.stop", stop.rect, "Stop Render"); + if stop.clicked() { + self.session.stop_render(); + } + }); + return; + } // Only a viewer that draws in *this* pass records a scale: a closed viewer, the Home // screen or another comp must stop idle prefetch at a stale scale. self.last_viewer_scale = None; @@ -571,6 +659,23 @@ impl EffectcraftApp { ctx.request_repaint_after(std::time::Duration::from_secs(10)); } self.tick_footage_reload(ctx); + self.tick_lut_files(ctx); + } + + /// A LUT file in use changed on disk (#3493): the frames kept were rendered with the old one. + /// Renders read it again by themselves; the watcher wakes the interface when no render did. + /// The frame keys carry the generation ([`Self::frame_series`]), so the viewers show the + /// frames rendered again, and a frame still rendering with the old LUT is never asked for. + fn tick_lut_files(&mut self, ctx: &egui::Context) { + if self.lut_watcher.is_none() { + let wake = ctx.clone(); + self.lut_watcher = effectcraft_engine::lut_reload::Watcher::start(move || wake.request_repaint()); + } + let generation = effectcraft_engine::lut_reload::generation(); + if generation != self.lut_generation { + self.lut_generation = generation; + self.frames.clear(); + } } /// One background worker tracks source availability and optional content reloads. It wakes diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index c9849e921..92ea7665c 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -44,6 +44,9 @@ impl EffectcraftApp { pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) { if let Some(gpu) = &self.gpu { bridge.forward_to(gpu.context().device_failure_notifier()); + if let Some(observer) = bridge.scoped_error_observer() { + gpu.set_scoped_error_observer(observer); + } } self.gpu_failures = Some(bridge); } @@ -257,6 +260,9 @@ impl EffectcraftApp { Ok(Ok(g)) => { if let Some(bridge) = &self.gpu_failures { bridge.forward_to(g.context().device_failure_notifier()); + if let Some(observer) = bridge.scoped_error_observer() { + g.set_scoped_error_observer(observer); + } if bridge.failure().is_some() { return; } diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index d40bc69ff..c6cb5d577 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -35,15 +35,20 @@ impl EffectcraftApp { plan: Some(plan), caching: plan.cache_first && plan.video, res: preset.resolution.factor(preset.custom_resolution), + adaptive: None, overlays: preset.include_overlays, layer_controls: preset.include_layer_controls, restore_max, audio_frame: (!plan.video).then_some(plan.first), audio_held: false, + silent_project: None, starting: false, starting_since: now, first_cached: None, shown_at: Default::default(), + pace: 0.0, + pace_acc: None, + paced_at: None, }; if !self.playback.caching { if plan.video { @@ -56,12 +61,10 @@ impl EffectcraftApp { } } - /// The preview includes audio at the comp's frame rate and the comp has something audible. - fn audio_plays(&self) -> bool { + /// The preview includes audio and the comp has something audible. + pub(super) fn audio_plays(&self) -> bool { let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false }; - let Some(c) = self.session.project.comp(cid) else { return false }; - // A preview at another frame rate plays slower or faster than real time: silent. - pl.audio && (pl.fps - c.frame_rate.as_f64()).abs() <= 0.01 && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) + pl.audio && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) } /// Start audio preview from comp time `t` when [`Self::audio_plays`] and an output device @@ -75,7 +78,17 @@ impl EffectcraftApp { let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return }; let mix = self.session.prefs.audio.preview_sample_rate; let fr = c.frame_rate; - match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) { + match audio::AudioPlayback::start_at_speed( + dev, + self.render_source(), + cid, + t, + fr.tick_of(pl.start), + fr.tick_of(pl.end + 1), + pl.looping, + mix, + pl.fps / fr.as_f64(), + ) { Ok(a) => self.audio = Some(a), Err(e) => log::warn!("audio preview: {e}"), } diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 4af91782f..37486eb6d 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -1,4 +1,4 @@ -use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames}; +use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, frames}; /// Seconds without a project change before paused prefetch resumes (see /// [`EffectcraftApp::editing`]). @@ -27,6 +27,74 @@ fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> ahead.min(window).min(fit).max(1) } +/// Whether frame `f` comes after the current frame `cur` and before the sound's frame `target` in +/// play order. A looping preview whose sound is at a lower frame than the picture has wrapped +/// to the loop's start, so the frames up to the end and then from the start count; otherwise +/// (and always without a loop) only the frames strictly between them do. +fn between_picture_and_sound(cur: i64, f: i64, target: i64, looping: bool) -> bool { + if target >= cur || !looping { cur < f && f < target } else { f > cur || f < target } +} + +/// How long the pace takes to follow the rate frames arrive at (seconds, the time constant of +/// its smoothing): short enough to follow a change, long enough to play through a batch of +/// frames finishing together. +const PACE_TAU: f64 = 0.5; + +/// The rate a preview plays at when frames arrive slower than its frame rate: proportional to +/// the cached frames ahead (`lead` of `target`), so it settles where the clock takes frames as +/// fast as they arrive and the lead stays put; smoothed over [`PACE_TAU`] (`dt` seconds since +/// the last pass). Never above the frame rate, never under an eighth of it (slower reads as +/// stopped: the clock holds instead). No frame ahead: the previous pace, the clock holds anyway. +fn pace_for(prev: f64, lead: i64, target: i64, fps: f64, dt: f64) -> f64 { + if lead <= 0 || target <= 0 || fps <= 0.0 { + return prev; + } + let raw = (fps * lead as f64 / target as f64).clamp(fps / 8.0, fps); + let k = 1.0 - (-dt.max(0.0) / PACE_TAU).exp(); + prev + (raw - prev) * k +} + +/// How many frames after the current one the pace measures its lead over: twice the prefetch +/// depth `ahead`, but only frames the RAM preview can keep cached together with the current one. +/// `prefetch_playback` requests offsets `0..fit_ahead` from the current frame (the current frame +/// and `fit_ahead - 1` after it), so a lead counted over more frames than that could never fill: +/// a preview that renders every frame at once would still be slowed down to a fraction of its +/// frame rate. 0 when not even one frame after the current one is prefetched: the pace can't +/// measure anything, and the clock runs in real time (holding on frames that aren't cached). +fn pace_horizon(ahead: i64, fit_ahead: i64) -> i64 { + (ahead * 2).max(1).min(fit_ahead - 1).max(0) +} + +/// Cached frames (`cached`, sorted) among the next `max` frames `after(1..=max)` plays, and how +/// many frames it plays (`after` is `None` past the end). Counted, not the consecutive run: one +/// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock +/// for a moment when it gets there, rather than slowing the whole preview to a crawl. +fn lead_of(after: impl Fn(i64) -> Option, cached: &[i64], max: i64) -> (i64, i64) { + let ahead: Vec = (1..=max).map_while(after).collect(); + (ahead.iter().filter(|f| cached.binary_search(f).is_ok()).count() as i64, ahead.len() as i64) +} + +impl PlaybackPass<'_> { + /// The frame `k` played frames after `from` (wrapping as the preview loops), `None` once a + /// preview that doesn't loop has reached its end. + fn frame_after(&self, from: i64, k: i64) -> Option { + match self.plan { + Some(p) => p.frame_after(from, k), + None => { + let FrameWindow { first: wa, last: wb } = self.window; + let f = from + k; + if f <= wb { + Some(f) + } else if self.looping { + Some(wa + (f - wa).rem_euclid((wb - wa + 1).max(1))) + } else { + None + } + } + } + } +} + impl EffectcraftApp { /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). @@ -42,11 +110,15 @@ impl EffectcraftApp { let video = plan.is_none_or(|p| p.video); // Prefetch ahead. let cur = fr.frame_at(self.session.time()); + // While the sound drives a video preview, frames are rendered and kept ahead of what it + // plays rather than of the picture, which drops frames behind it when rendering can't + // keep up (#2473). + let lead = if video { self.sound_frame(fr, wa, wb, plan.map_or(1, |p| p.step)).unwrap_or(cur) } else { cur }; let ahead = self.prefetch_ahead(ctx, now); let queued = self.frames.inflight(); let series = self.frame_series(cid, scale); // The preview's position orders prefetch and eviction (#595). - let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping }); + let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur: lead, start: p.start, end: p.end, step: p.step, looping: p.looping }); self.frames.set_playhead(playhead); let pass = PlaybackPass { ctx, @@ -69,7 +141,8 @@ impl EffectcraftApp { } let step = plan.map_or(1, |p| p.step); let fit_ahead = self.frames_that_fit(&c, scale) - 1; - self.prefetch_playback(&pass, fit_ahead); + self.prefetch_playback(&PlaybackPass { cur: lead, ..pass }, fit_ahead); + self.wake_silent_preview(); self.pump_meters(ctx, now); if !self.playback.playing { self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb)); @@ -83,7 +156,7 @@ impl EffectcraftApp { if self.advance_audio_clock(&pass, step) { return; } - self.advance_frame_clock(&pass, step); + self.advance_frame_clock(&pass, step, fit_ahead); } /// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few @@ -189,6 +262,10 @@ impl EffectcraftApp { /// one second. With no source but an active meter, drive the meter to silence and request a repaint. fn pump_meters(&mut self, ctx: &egui::Context, now: f64) { if let Some(a) = &mut self.audio { + // An edit while playing (mute, unmute, solo, levels): the sound follows it (#2104). + if a.project().is_some_and(|p| !std::sync::Arc::ptr_eq(&p, &self.session.project)) { + a.follow_project(self.session.project.clone()); + } a.pump(); self.meter.update(a.feed.take_peaks(), now); } else if let Some(s) = &mut self.scrub { @@ -238,8 +315,8 @@ impl EffectcraftApp { } self.playback.starting = false; self.playback.waiting = false; - // Not on a frame that isn't cached: the stall below would stop the sound at once. The - // preview goes on as a stalled one does, and the sound starts once frames are cached. + // Not on a frame that isn't cached: the picture couldn't show where the sound starts. The + // preview goes on silently, and the sound starts once frames are cached. if need.is_some() { self.playback.audio_held = false; self.start_audio(fr.tick_of(cur)); @@ -247,16 +324,40 @@ impl EffectcraftApp { false } - /// Held sound starts once the frames ahead are cached again after a stall (see - /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else - /// the rest of the preview. + /// A silent preview whose project was edited so that it has sound now (a layer unmuted while + /// playing, #2104): video previews wait for cached frames; audio-only previews start from + /// their moving audio frame, not the stationary timeline. Checked once per project edit. + fn wake_silent_preview(&mut self) { + let pb = &self.playback; + if self.audio.is_some() || !pb.playing || pb.caching || pb.starting || pb.audio_held { + return; + } + if pb.silent_project.as_ref().is_some_and(|p| std::sync::Arc::ptr_eq(p, &self.session.project)) { + return; + } + self.playback.silent_project = Some(self.session.project.clone()); + if !self.audio_plays() { + return; + } + if self.playback.plan.is_some_and(|p| p.video) { + self.playback.audio_held = true; + } else if let Some(c) = self.session.active_comp() { + let frame = self.playback.audio_frame.unwrap_or(self.playback.start_frame); + self.start_audio(c.frame_rate.tick_of(frame)); + } + } + + /// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the + /// prefetch's `ahead` when the silent preview keeps real time, else the rest of the preview. fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) { let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; if !self.playback.audio_held { return; } let Some(p) = plan else { return }; - if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit) { + // No more than the prefetch requests: offsets below `fit - 1` (`advance_playback`). + let need = ahead.min(fit - 1).max(1); + if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, need)) || self.cached_ahead(&p, &series, cur, fit) { self.playback.audio_held = false; self.playback.start_wall = now; self.playback.start_frame = cur; @@ -280,7 +381,7 @@ impl EffectcraftApp { return true; } let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; - let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb)); + let target = snap(fr.frame_at(a.clock_time()).clamp(wa, wb)); if !video { self.playback.audio_frame = Some(target); } else if self.frames.is_cached(&FrameKey { frame: target, ..series }) { @@ -290,44 +391,92 @@ impl EffectcraftApp { self.playback.frame_shown(now); } } else { - // Rendering can't keep up: rather than skip frames, the sound stops and every - // frame shows as it renders until the frames ahead are cached again. - self.audio = None; - self.playback.audio_held = true; + // Rendering can't keep up: the sound plays on and the picture drops frames, showing + // the latest one rendered up to the sound, while `advance_playback` renders ahead of + // the sound (#2473). Stopping the sound instead and starting it again once frames were + // cached chopped it into bursts that played as distortion (#3260). self.playback.waiting = true; - // The rate measured before the stall no longer says it keeps up. - self.playback.shown_at.clear(); - self.playback.start_wall = now; - self.playback.start_frame = cur; + if let Some(f) = self.latest_cached_before(pass, target).filter(|&f| f != cur) { + self.session.set_time(fr.tick_of(f)); + self.playback.frame_shown(now); + } + // Dropping frames: a lower resolution may keep up (the rate shown says so). + self.step_down(now, pass.scale); } ctx.request_repaint(); true } + /// The frame under the audible sample while the sound drives the preview, kept in the + /// preview's range and on its frame step; `None` without sound. + fn sound_frame(&self, fr: effectcraft_engine::time::FrameRate, wa: i64, wb: i64, step: i64) -> Option { + let a = self.audio.as_ref()?; + let step = step.max(1); + Some(wa + (fr.frame_at(a.clock_time()).clamp(wa, wb) - wa).div_euclid(step) * step) + } + + /// The last cached frame after the current one and before `target`, in play order: what a + /// preview behind its sound shows (`None` when no such frame is cached). The walk stops at + /// the first frame that isn't before the sound's, which it can step over without meeting + /// (a picture off the plan's frame step) or never reach (a sound behind the picture). + fn latest_cached_before(&self, pass: &PlaybackPass<'_>, target: i64) -> Option { + let PlaybackPass { plan, series, cur, window, looping, .. } = *pass; + let cached = self.frames.cached_frames(&series); + let mut best = None; + for k in 1..=(window.last - window.first + 1).max(1) { + let Some(f) = plan.map_or(Some(cur + k), |p| p.frame_after(cur, k)) else { break }; + if !between_picture_and_sound(cur, f, target, looping) { + break; + } + if cached.binary_search(&f).is_ok() { + best = Some(f); + } + } + best + } + /// The wall clock advances the preview when no audio device drives it, and shows the target - /// frame (or holds when it is not cached yet). - fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) { - let PlaybackPass { ctx, now, plan, video, looping, frame_rate: fr, cur, series, .. } = *pass; - let FrameWindow { first: wa, last: wb } = pass.window; - let elapsed = now - self.playback.start_wall; + /// frame (or holds when it is not cached yet). While frames arrive slower than the frame + /// rate it runs at their pace ([`pace_for`]): every frame shows, evenly, as After Effects + /// plays a preview it hasn't cached yet; real time again once the frames ahead are cached. + fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64, fit_ahead: i64) { + let PlaybackPass { ctx, now, plan, video, frame_rate: fr, cur, series, .. } = *pass; + let FrameWindow { last: wb, .. } = pass.window; // The plan carries the rate the preview was started with (and `play_with` copies it to // `Playback::fps`). Deriving the frame from the plan keeps a logic-only tick correct even // when a host/test supplies only the plan; a stale duplicate cannot hold playback open. let fps = plan.map_or(self.playback.fps, |p| p.fps); - let n = ((elapsed * fps) / step as f64).floor() as i64; - let target = match plan { - Some(p) => p.frame_after(self.playback.start_frame, n), - None => { - let t = self.playback.start_frame + n; - if t <= wb { - Some(t) - } else if looping { - Some(wa + (t - wa).rem_euclid((wb - wa + 1).max(1))) - } else { - None - } + let dt = self.playback.paced_at.map_or(0.0, |t| (now - t).max(0.0)); + self.playback.paced_at = Some(now); + if video { + // The lead counts out of twice the prefetch depth: the prefetch keeps that many + // frames coming (or the frames left before the end), so a full lead means rendering + // keeps up. No more than the RAM preview holds ([`pace_horizon`]). + let horizon = pace_horizon(pass.ahead, fit_ahead); + let prev = if self.playback.pace > 0.0 { self.playback.pace } else { fps }; + let pace = if horizon > 0 { + let (lead, target) = self.cached_lead(pass, horizon); + pace_for(prev, lead, target, fps, dt) + } else { + fps + }; + self.playback.pace = pace; + if pace < fps * 0.8 { + self.step_down(now, pass.scale); } - }; + if pace < fps * 0.98 { + self.advance_paced(pass, pace, dt); + return; + } + if self.playback.pace_acc.take().is_some() { + // Back to real time from here. + self.playback.start_wall = now; + self.playback.start_frame = cur; + } + } + let elapsed = now - self.playback.start_wall; + let n = ((elapsed * fps) / step as f64).floor() as i64; + let target = pass.frame_after(self.playback.start_frame, n); let Some(target) = target else { if video { self.session.set_time(fr.tick_of(wb)); @@ -352,7 +501,8 @@ impl EffectcraftApp { } else { // Not cached yet: hold the clock at the current frame (cache first, then play). self.playback.waiting = true; - let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 }); + self.step_down(now, pass.scale); + let next = pass.frame_after(cur, 1).unwrap_or(cur); if self.frames.is_cached(&FrameKey { frame: next, ..series }) { self.session.set_time(fr.tick_of(next)); } @@ -362,6 +512,74 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// The paced clock: it owes `pace` played frames a second (plus what the last passes left + /// over) and shows them one by one as far as they are cached; on a frame that isn't it holds + /// (owing one frame, so that frame shows the moment it arrives), and at the end of a preview + /// that doesn't loop it stops. + fn advance_paced(&mut self, pass: &PlaybackPass<'_>, pace: f64, dt: f64) { + let PlaybackPass { ctx, now, frame_rate: fr, cur, series, .. } = *pass; + let step = pass.plan.map_or(1, |p| p.step).max(1) as f64; + let mut owed = self.playback.pace_acc.unwrap_or(0.0) + dt * pace / step; + let mut f = cur; + while owed >= 1.0 { + match pass.frame_after(f, 1) { + Some(n) if self.frames.is_cached(&FrameKey { frame: n, ..series }) => { + f = n; + owed -= 1.0; + } + Some(_) => { + owed = 1.0; + break; + } + None => { + self.session.set_time(fr.tick_of(pass.window.last)); + self.stop(); + return; + } + } + } + self.playback.waiting = owed >= 1.0; + if f != cur { + self.session.set_time(fr.tick_of(f)); + self.playback.frame_shown(now); + } + self.playback.pace_acc = Some(owed); + ctx.request_repaint(); + } + + /// Cached frames among the next `max` in play order, and how many of those there are (fewer + /// near the end of a preview that doesn't loop: its cached tail is a full lead). See + /// [`lead_of`]. + fn cached_lead(&self, pass: &PlaybackPass<'_>, max: i64) -> (i64, i64) { + lead_of(|k| pass.frame_after(pass.cur, k), &self.frames.cached_frames(&pass.series), max) + } + + /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at + /// Resolution Auto plays under 80 % of its frame rate (its pace, or stalled on a frame that + /// isn't cached) two seconds after it started or last stepped down (the first frames at a + /// new scale, and the GPU's warm-up frames, take a moment), so it plays on at half `scale`, + /// no lower than the Adaptive Resolution Limit. An explicit Resolution is kept. + // ponytail: it never steps back up during a play, and frames cached at the higher scale aren't + // shown once it stepped down; add both if loops over a partly cached comp need them. + fn step_down(&mut self, now: f64, scale: f64) { + let pb = &self.playback; + let since = pb.adaptive.map_or(pb.starting_since, |(_, t)| t); + let step = pb.plan.map_or(1, |p| p.step).max(1) as f64; + let lower = scale * 0.5; + if !self.session.prefs.previews.adaptive_playback + || pb.res.is_some() + || !self.viewer_res().is_auto() + || now - since < 2.0 + // (a rate from before a stall: nothing shown for a second isn't keeping up) + || (pb.shown_at.back().is_some_and(|t| now - t <= 1.0) && pb.achieved_fps().is_some_and(|f| f * step >= pb.fps * 0.8)) + || lower < self.session.prefs.adaptive_limit() - 1e-9 + { + return; + } + self.playback.adaptive = Some((lower, now)); + self.playback.shown_at.clear(); + } + /// While the project keeps changing (an edit within [`EDIT_QUIET`] seconds), how long until /// it counts as settled. fn editing(&mut self, now: f64) -> Option { @@ -452,19 +670,95 @@ impl EffectcraftApp { side(comp.width) * side(comp.height) * frames::frame_pixel_bytes(&self.session.project.settings) as f64 } + /// Frames rendered at once by a preview or idle caching: the lanes (in the browser the frame + /// workers; without them frames render one at a time on the UI thread, `Frames::pump`). fn frames_parallelism(&self) -> usize { - if cfg!(target_arch = "wasm32") { - // frame workers render one frame each; without them frames render one at a time - // on the UI thread (`Frames::pump`) - return self.frames.remote_slots().max(1); - } - self.frames.parallelism() + self.frames.lanes() + } +} + +#[cfg(test)] +mod pace_tests { + use super::{lead_of, pace_for, pace_horizon}; + + /// The lead is measured over no more frames than the RAM preview keeps ahead of the current + /// one, so a full cache plays in real time. (It counted out of twice the prefetch depth: with + /// room for 4 frames, at most 2 after the current one were ever cached, and a preview that + /// rendered at once played at a quarter of its frame rate.) + #[test] + fn the_pace_horizon_fits_in_the_ram_preview() { + // A roomy budget: twice the prefetch depth. + assert_eq!(pace_horizon(6, 400), 12); + // Room for 4 frames: the prefetch requests offsets 0..3, so 2 after the current one. + assert_eq!(pace_horizon(6, 3), 2); + let (lead, target) = lead_of(|k| Some(10 + k), &[10, 11, 12], pace_horizon(6, 3)); + assert!((pace_for(30.0, lead, target, 30.0, 10.0) - 30.0).abs() < 1e-6, "a full cache plays in real time"); + // Room for the current frame only (or nothing): no lead to measure. + assert_eq!(pace_horizon(6, 1), 0); + assert_eq!(pace_horizon(6, 0), 0); + // No prefetch depth: still the next frame. + assert_eq!(pace_horizon(0, 400), 1); + } + + /// The pace follows the cached lead: real time with a full lead, a third of it with a third, + /// never under an eighth, unchanged with nothing ahead, and smoothed over time. + #[test] + fn pace_follows_the_cached_lead() { + // Settled (a long dt): proportional to the lead. + assert!((pace_for(30.0, 12, 12, 30.0, 10.0) - 30.0).abs() < 1e-6); + assert!((pace_for(30.0, 4, 12, 30.0, 10.0) - 10.0).abs() < 1e-6); + assert!((pace_for(30.0, 1, 100, 30.0, 10.0) - 3.75).abs() < 1e-6, "never under an eighth"); + assert_eq!(pace_for(17.0, 0, 12, 30.0, 10.0), 17.0, "no frame ahead: unchanged"); + // Smoothing: after one time constant it has gone 63 % of the way. + let p = pace_for(30.0, 4, 12, 30.0, 0.5); + assert!((p - (30.0 - 20.0 * (1.0 - (-1.0f64).exp()))).abs() < 1e-6); + assert_eq!(pace_for(30.0, 4, 12, 30.0, 0.0), 30.0, "no time passed: unchanged"); + assert!(pace_for(10.0, 12, 12, 30.0, 0.5) > 10.0, "and up again as the lead fills"); + } + + /// Near the end of a preview that doesn't loop, the lead counts out of the frames left: a + /// cached tail plays in real time. (It counted out of the full depth, and a cached preview + /// slowed down over its last frames, a short one for its whole length.) + #[test] + fn a_cached_end_is_a_full_lead() { + let cached: Vec = (0..=20).collect(); + let to_20 = |cur: i64| move |k: i64| Some(cur + k).filter(|f| *f <= 20); + assert_eq!(lead_of(to_20(5), &cached, 12), (12, 12)); + assert_eq!(lead_of(to_20(16), &cached, 12), (4, 4)); + assert_eq!(lead_of(to_20(20), &cached, 12), (0, 0)); + let (lead, target) = lead_of(to_20(16), &cached, 12); + assert!((pace_for(30.0, lead, target, 30.0, 10.0) - 30.0).abs() < 1e-6); + // Frames left uncached still count against it. + assert_eq!(lead_of(to_20(16), &[17, 19], 12), (2, 4)); + // Looping: the walk wraps, always `max` frames. + assert_eq!(lead_of(|k| Some((18 + k) % 21), &cached, 12), (12, 12)); } } #[cfg(test)] mod startup_tests { - use super::startup_frames; + use super::{between_picture_and_sound, startup_frames}; + + /// The frames a preview behind its sound may show are only those before the sound's frame in + /// play order, so the picture is never ahead of the sound (#2473). + #[test] + fn a_dropped_frame_preview_never_shows_past_the_sound() { + // Sound ahead of the picture: only the frames between them. + assert!(between_picture_and_sound(10, 12, 14, false)); + assert!(!between_picture_and_sound(10, 14, 14, false), "not the sound's own frame"); + assert!(!between_picture_and_sound(10, 15, 14, true), "nor one past it, stepped over off the grid"); + assert!(!between_picture_and_sound(10, 10, 14, true), "nor the current frame"); + // A looping preview whose sound wrapped to the start: the end of the loop, then its start. + assert!(between_picture_and_sound(90, 95, 3, true)); + assert!(between_picture_and_sound(90, 1, 3, true)); + assert!(!between_picture_and_sound(90, 3, 3, true)); + assert!(!between_picture_and_sound(90, 50, 3, true), "the wrapped walk stops before reaching the picture again"); + // Without a loop, a sound behind the picture allows nothing ahead of it. + assert!(!between_picture_and_sound(90, 95, 3, false)); + assert!(!between_picture_and_sound(90, 1, 3, false)); + // The sound on the picture's frame: nothing. + assert!(!between_picture_and_sound(10, 11, 10, true)); + } /// The frames a preview with sound waits for are always requested and fit together (#947). #[test] diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index a1a0faaf5..d9e6e84f2 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -98,6 +98,13 @@ impl EffectcraftApp { // play back, and frames cached by a preview stay on screen when it stops. let res = self.viewer_res(); let full = res.scale(zoom, ppp); + // A preview that couldn't keep up stepped down (`step_down`). + if self.playback.playing + && res.is_auto() + && let Some((s, _)) = self.playback.adaptive + { + return full.min(s); + } // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting); if k < 1.0 && res.is_auto() { @@ -121,7 +128,7 @@ impl EffectcraftApp { frame: 0, scale: (scale * 1000.0).round() as u32, view: self.view_hash(comp), - opts: frames::opts_hash(&self.frame_opts(comp, scale)) ^ self.roto_model_stamp(), + opts: frames::opts_hash(&self.frame_opts(comp, scale)) ^ self.roto_model_stamp() ^ self.lut_stamp() ^ self.purge_stamp(), } } @@ -135,6 +142,21 @@ impl EffectcraftApp { h.finish() | 1 } + /// Changes when a LUT file in use changed on disk (#3493): the frame on screen and the ones + /// still rendering were made with the old LUT, so the new ones must not share their keys + /// (the viewers show a frame again only when its key changes). 0 until a LUT file changes. + fn lut_stamp(&self) -> u64 { + self.lut_generation.wrapping_mul(0x9e37_79b9_7f4a_7c15) + } + + /// Changes when the caches are purged (Event::PurgeCaches: footage reloaded, Purge): the + /// frames on screen and the ones still rendering may show the files as they were, and a reload + /// that changes no metadata keeps the project's revision, so the new frames must not share + /// their keys (#3539; the viewers show a frame again only when its key changes). 0 until then. + fn purge_stamp(&self) -> u64 { + self.purges.wrapping_mul(0xc2b2_ae3d_27d4_eb4f) + } + /// The frame series the viewer of `comp` shows now (its scale, view and options, for the /// comp's current content): what the cache bars count. pub fn shown_series(&self, comp: ItemId) -> FrameKey { @@ -231,3 +253,94 @@ impl EffectcraftApp { request(&self.frames, &self.render_source(), key, comp, t, opts); } } + +#[cfg(test)] +mod tests { + /// #3493: the viewer shows the frame again once the LUT file it was graded with changes, + /// though its key was otherwise the same (same project revision, frame and options). + #[test] + fn the_viewer_shows_a_lut_file_changed_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("viewer.cube"); + let write = |text: &str, secs: u64| { + std::fs::write(&lut, text).unwrap(); + let f = std::fs::File::options().write(true).open(&lut).unwrap(); + f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap(); + }; + write("LUT_1D_SIZE 2\n0 0 0\n0 0 0\n", 1_000); + let mut session = effectcraft_engine::Session::default(); + session.execute("comp.new", serde_json::json!({"width": 64, "height": 36, "duration": 2})).unwrap(); + session.execute("layer.newSolid", serde_json::json!({"color": "#808080"})).unwrap(); + session.execute("effect.apply", serde_json::json!({"layer": 1, "effect": "Apply Color LUT"})).unwrap(); + session.execute("prop.set", serde_json::json!({"layer": 1, "path": "effects/#1/lut", "value": lut.to_string_lossy()})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| crate::EffectcraftApp::new(session)); + let centre = |h: &egui_kittest::Harness<'_, crate::EffectcraftApp>| { + h.state().viewer_image.as_ref().map(|img| img.pixels[img.pixels.len() / 2 + img.size[0] / 2].r()) + }; + let settle = |h: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>, want: u8| { + for _ in 0..400 { + h.step(); + if centre(h) == Some(want) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the viewer shows {:?}, not {want}", centre(h)); + }; + settle(&mut harness, 0); + write("LUT_1D_SIZE 2\n1 1 1\n1 1 1\n", 2_000); + // (what the app's watcher does every two seconds) + effectcraft_engine::lut_reload::check(); + settle(&mut harness, 255); + } + + /// #3539: a still re-saved at the same size and reloaded as changed on disk keeps the project + /// as it was (same revision and content key); the viewers showed the old picture, the new + /// frame having the key of the one on screen. A reload's purge gives the frames new keys, once. + #[test] + fn the_viewers_show_a_still_reloaded_at_the_same_size() { + let dir = tempfile::tempdir().unwrap(); + let png = dir.path().join("still.png"); + let save = |rgb: [u8; 3]| image::RgbaImage::from_pixel(64, 36, image::Rgba([rgb[0], rgb[1], rgb[2], 255])).save(&png).unwrap(); + save([255, 0, 0]); + let mut session = effectcraft_host::session(); + session.execute("comp.new", serde_json::json!({"width": 64, "height": 36, "duration": 2})).unwrap(); + let cid = session.active_comp_id().unwrap(); + let item = session.execute("file.import", serde_json::json!({"paths": [png.to_string_lossy()]})).unwrap()["items"][0].clone(); + session.execute("layer.addItem", serde_json::json!({"item": item})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| crate::EffectcraftApp::new(session)); + let centre = |h: &egui_kittest::Harness<'_, crate::EffectcraftApp>| { + h.state().viewer_image.as_ref().map(|img| img.pixels[img.pixels.len() / 2 + img.size[0] / 2].r()) + }; + let settle = |h: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>, want: u8| { + for _ in 0..400 { + h.step(); + if centre(h) == Some(want) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the viewer shows {:?}, not {want}", centre(h)); + }; + settle(&mut harness, 255); + // (every viewer, passive ones too, asks for its frame by this key) + let key = |h: &egui_kittest::Harness<'_, crate::EffectcraftApp>| h.state().frame_key(cid, 0, 1.0); + let before = key(&harness); + harness.step(); + assert_eq!(key(&harness), before, "no purge, the same key: nothing is uploaded again"); + // Reload Footage Changed on Disk: the app's watcher saw the file's stamp move. + let path = png.to_string_lossy().into_owned(); + let stamps = |n| vec![(path.clone(), Some((n, u128::from(n))))]; + effectcraft_engine::footage_reload::apply_stamps(&mut harness.state_mut().session, stamps(1)).unwrap(); + save([0, 0, 255]); + let revision = harness.state().session.revision; + let reloaded = effectcraft_engine::footage_reload::apply_stamps(&mut harness.state_mut().session, stamps(2)).unwrap(); + assert_eq!(reloaded["files"], 1, "{reloaded}"); + assert_eq!(harness.state().session.revision, revision, "the project is as it was"); + settle(&mut harness, 0); + let after = key(&harness); + assert_ne!(after, before, "the reloaded frame has a key of its own"); + harness.step(); + assert_eq!(key(&harness), after, "one new key per purge"); + } +} diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index fbfe4b8af..e0d6b4d1b 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -10,7 +10,7 @@ //! **A/V sync:** while audio plays, the audio clock drives playback. The clock is the number of //! sample frames the device has actually consumed (underruns do not advance it) minus the //! device's output latency, mapped through the work-area loop ([`clock_sample`]); the viewer -//! shows the comp frame at that sample ([`frame_of_sample`]), dropping video frames rather +//! shows the comp frame at that time ([`AudioPlayback::clock_time`]), dropping video frames rather //! than drifting. //! //! The feed also tracks per-channel peaks for the Audio panel's VU meters ([`Meter`]). @@ -18,16 +18,19 @@ //! **Scrubbing:** Ctrl/Cmd-dragging the current-time indicator plays a short snippet of the mix //! at each new frame ([`AudioScrub`], `playback.scrubAudio`), as in After Effects. -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; -use effectcraft_engine::project::ItemId; +use effectcraft_engine::project::{GroupKind, ItemId, ItemKind, Layer, LayerSource, Project, PropGroup}; use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; +mod preview_rate; +use preview_rate::PreviewRate; + /// An audio output (implemented by the native app with cpal). pub trait AudioDevice: Send { /// Device sample rate in Hz. @@ -88,9 +91,11 @@ impl AudioFeed { pk[i & 1] = pk[i & 1].max(s.abs()); } real = n; + // Queue removal and the clock advance are one handoff: an edit must not restart + // behind samples already handed to the device. + self.consumed.fetch_add((real / 2) as u64, Ordering::Relaxed); } out[real..].iter_mut().for_each(|o| *o = 0.0); - self.consumed.fetch_add((real / 2) as u64, Ordering::Relaxed); for (c, v) in pk.into_iter().enumerate() { // Rust 1.99 renamed `fetch_update` to `try_update`; keep the old name while the // workspace supports Rust 1.95 (`rust-version`). @@ -106,6 +111,14 @@ impl AudioFeed { } } + /// Discard an old mix and snapshot the first unconsumed frame under the same queue lock + /// as [`Self::pull`], so a concurrent device callback cannot leave the cursor behind. + fn discard_pending(&self) -> Option { + let mut q = self.queue.lock().ok()?; + q.clear(); + Some(self.consumed()) + } + pub fn push(&self, samples: &[f32]) { if let Ok(mut q) = self.queue.lock() { q.extend(samples.iter().copied()); @@ -131,7 +144,7 @@ impl AudioFeed { } } -/// What a preview plays, in comp sample indices at the device rate. +/// What a preview plays, in output sample coordinates on its playback timeline. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PlaySpan { pub start: i64, @@ -149,15 +162,23 @@ impl PlaySpan { } } -/// The comp sample index audible now: `consumed` frames played by the device minus its +/// The output sample coordinate audible now: `consumed` frames played by the device minus its /// `latency`, from the span start, wrapped into the work area when looping (clamped at its end /// otherwise). pub fn clock_sample(span: &PlaySpan, consumed: u64, latency: u64) -> i64 { - let s = span.start + consumed.saturating_sub(latency) as i64; - if s < span.wb { - return s; + // Slow previews can span more output samples than fit in a signed difference. Keep the + // elapsed count and loop width wide until the result is back inside the span. + let s = i128::from(span.start) + i128::from(consumed.saturating_sub(latency)); + if s < i128::from(span.wb) { + return s as i64; + } + if span.looping { + let width = (i128::from(span.wb) - i128::from(span.wa)).max(1); + let wrapped = i128::from(span.wa) + (s - i128::from(span.wb)).rem_euclid(width); + wrapped.clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } else { + span.wb.saturating_sub(1) } - if span.looping { span.wa + (s - span.wb).rem_euclid((span.wb - span.wa).max(1)) } else { span.wb - 1 } } /// Comp time of sample index `s` at `rate` Hz. @@ -179,10 +200,23 @@ pub struct AudioPlayback { device: Box, stop: Arc, thread: Option>, - /// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`]. - feeder: Option<(RenderSource, ItemId, i64, u32)>, + /// What the mixer reads and advances: the feeder thread, or [`AudioPlayback::pump`] on wasm32 + /// (no threads). + state: Arc>, + comp: ItemId, + mix: u32, pub rate: u32, pub span: PlaySpan, + timeline: PreviewRate, +} + +/// The mixer's position and its view of the project. +struct Feeder { + src: RenderSource, + /// The next output sample index to mix (mapped to composition time by the preview rate). + cursor: i64, + /// Bumped when the project changes: a block mixed from an older project is dropped. + generation: u64, } /// Audio kept queued ahead of the device: ~200 ms (a feeder thread tops it up continuously); @@ -197,7 +231,7 @@ impl AudioPlayback { /// at (resampled to the device's rate when they differ; 0 = the device's rate). #[allow(clippy::too_many_arguments)] pub fn start( - mut device: Box, + device: Box, src: RenderSource, comp: ItemId, start: Tick, @@ -205,15 +239,34 @@ impl AudioPlayback { wb: Tick, looping: bool, mix_rate: u32, + ) -> Result { + Self::start_at_speed(device, src, comp, start, wa, wb, looping, mix_rate, 1.0) + } + + /// Start at a preview speed relative to composition time. Both the sound (including pitch) + /// and its composition clock follow this speed; the physical device rate stays unchanged. + #[allow(clippy::too_many_arguments)] + pub fn start_at_speed( + mut device: Box, + src: RenderSource, + comp: ItemId, + start: Tick, + wa: Tick, + wb: Tick, + looping: bool, + mix_rate: u32, + speed: f64, ) -> Result { let rate = device.sample_rate().max(8000); let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) }; - let span = PlaySpan::new(start, wa, wb, looping, rate); + let timeline = PreviewRate::new(rate, speed, start)?; + let span = timeline.span(start, wa, wb, looping); let feed = Arc::new(AudioFeed::default()); let stop = Arc::new(AtomicBool::new(false)); + let state = Arc::new(Mutex::new(Feeder { src, cursor: span.start, generation: 0 })); if cfg!(target_arch = "wasm32") { // The device takes samples only in `pump`, which fills the queue first. - let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, span.start, mix)), rate, span }; + let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span, timeline }; p.device.start(feed)?; p.pump(); return Ok(p); @@ -223,15 +276,14 @@ impl AudioPlayback { // as that took (#408). The device plays silence until the first block arrives, and the // clock doesn't count it. let thread = { - let (feed, stop) = (feed.clone(), stop.clone()); + let (feed, stop, state) = (feed.clone(), stop.clone(), state.clone()); std::thread::Builder::new() .name("ec-audio-feed".into()) .spawn(move || { - let mut cursor = span.start; let target = queue_target(rate); while !stop.load(Ordering::Relaxed) { if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) { - cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix); + feed_block(&state, comp, &feed, &span, timeline, mix); } else { std::thread::sleep(std::time::Duration::from_millis(4)); } @@ -240,24 +292,63 @@ impl AudioPlayback { .map_err(|e| e.to_string())? }; device.start(feed.clone())?; - Ok(AudioPlayback { feed, device, stop, thread: Some(thread), feeder: None, rate, span }) + Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span, timeline }) } /// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread /// does elsewhere), then let the device take what it needs. pub fn pump(&mut self) { - if let Some((src, comp, cursor, mix)) = &mut self.feeder { + if cfg!(target_arch = "wasm32") { let target = queue_target(self.rate); while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) { - *cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate, *mix); + feed_block(&self.state, self.comp, &self.feed, &self.span, self.timeline, self.mix); } } self.device.pump(); } - /// The comp sample index audible now. + /// The project the sound is mixed from. + pub fn project(&self) -> Option> { + self.state.lock().ok().map(|s| s.src.project.clone()) + } + + /// Mix from `project` from now on (the project was edited while playing, e.g. a layer's Audio + /// switch, #2104): what is queued came from the old project, so it is dropped and mixing + /// resumes at the first sample the device has not taken yet (also when the feeder had + /// already mixed up to the end of a non-looping span). + pub fn set_project(&mut self, project: Arc) { + let Ok(mut s) = self.state.lock() else { return }; + let Some(consumed) = self.feed.discard_pending() else { return }; + s.src.project = project; + s.generation = s.generation.wrapping_add(1); + self.feed.finished.store(false, Ordering::Relaxed); + // The display clock clamps to the last *audible* sample. A finished non-looping + // feeder must instead stay at the exclusive end, never replay that last sample. + let next = i128::from(self.span.start) + i128::from(consumed); + s.cursor = if !self.span.looping && next >= i128::from(self.span.wb) { self.span.wb } else { clock_sample(&self.span, consumed, 0) }; + } + + /// Follow an edited `project`: mixing continues from it, and the queued sound is dropped only + /// when the edit changed what is heard ([`audio_changed`]); a transform or colour edit leaves + /// the queue alone, so playback does not drop out. + pub fn follow_project(&mut self, project: Arc) { + let Some(old) = self.project() else { return }; + if audio_changed(&old, &project, self.comp) { + self.set_project(project); + } else if let Ok(mut s) = self.state.lock() { + s.src.project = project; + } + } + + /// The comp sample index audible now, expressed at the device's rate. pub fn clock(&self) -> i64 { - clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()) + let sample = clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()); + if self.timeline.normal() { sample } else { self.timeline.time(sample).to_units_floor(i64::from(self.rate)) } + } + + /// Composition time under the audible output sample, including preview speed and latency. + pub fn clock_time(&self) -> Tick { + self.timeline.time(clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames())) } pub fn finished(&self) -> bool { @@ -279,20 +370,101 @@ impl Drop for AudioPlayback { } } -/// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at -/// the work-area end). -fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32, mix: u32) -> i64 { - let mut cursor = cursor; - if cursor >= span.wb { - if !span.looping { - feed.finished.store(true, Ordering::Relaxed); - return cursor; +/// Whether `new` sounds different from `old` for `comp`: a layer's timing, source, Audio / Solo / +/// Effects switch, Audio Levels or effects, or the same for a nested comp. Other edits (video +/// properties, names, labels) do not change the mix unless an audio expression can read them. +fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { + audio_changed_inner(old, new, comp, &mut HashSet::new()) +} + +/// Each shared precomp is compared once per depth. A depth limit alone still visits an +/// exponentially large number of paths in a small graph of reused precomps, blocking the UI. +fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut HashSet<(ItemId, usize)>) -> bool { + /// Compare one comp and its reachable audio sources, without revisiting a shared path depth. + fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize, seen: &mut HashSet<(ItemId, usize)>) -> bool { + if depth >= 16 || !seen.insert((comp, depth)) { + return false; } + let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; + a.layers.len() != b.layers.len() + || a.layers.iter().zip(&b.layers).any(|(x, y)| { + x.id != y.id + || x.source != y.source + || x.start_time != y.start_time + || x.in_point != y.in_point + || x.out_point != y.out_point + || x.stretch != y.stretch + || x.switches.audio != y.switches.audio + || x.switches.solo != y.switches.solo + || x.switches.effects != y.switches.effects + || x.props.sub("audio") != y.props.sub("audio") + || audio_effects(x) != audio_effects(y) + || audio_has_expression(x) + || audio_has_expression(y) + || match &x.source { + LayerSource::Footage { item } => footage_differs(old, new, *item), + LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1, seen), + _ => false, + } + }) + } + /// The layer's audio effects (Effect > Audio), the only effects the mixer applies; a video effect + /// edit leaves them as they were. + fn audio_effects(l: &Layer) -> Vec<&PropGroup> { + let is_audio = |g: &&PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_engine::effects::audio_fx::is_audio_effect(effect)); + l.effects().into_iter().flat_map(|fx| fx.groups()).filter(is_audio).collect() + } + /// Audio expressions can read any property in any comp (including video-only effects and + /// names). Without a complete dependency graph, keeping the old mix would be stale. + fn audio_has_expression(l: &Layer) -> bool { + l.switches.audio + && (l.props.sub("audio").and_then(|g| g.get("levels")).is_some_and(|p| p.has_expression()) + || (l.switches.effects && audio_effects(l).iter().any(|g| g.enabled && g.props().any(|p| p.has_expression())))) + } + /// The footage item's audio availability changed (relinked, missing, no audio). + fn footage_differs(old: &Project, new: &Project, item: ItemId) -> bool { + let f = |p: &Project| match p.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => Some((f.has_audio, f.missing, f.path.clone())), + _ => None, + }; + f(old) != f(new) + } + comps_differ(old, new, comp, 0, seen) +} + +/// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or +/// finishing at the work-area end). The block is mixed without holding the lock, so a project +/// change never waits for a slow mix; a block mixed from the old project is dropped. +fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, timeline: PreviewRate, mix: u32) { + let Ok((src, mut cursor, generation)) = state.lock().map(|s| (s.src.clone(), s.cursor, s.generation)) else { + // Poisoned: nothing more can be mixed. + feed.finished.store(true, Ordering::Relaxed); + return; + }; + let mut block = None; + if cursor >= span.wb && span.looping { cursor = span.wa; } - let n = ((span.wb - cursor) as usize).min(BLOCK); - feed.push(&mix_block(src, comp, sample_time(cursor, rate), n, rate, mix)); - cursor + n as i64 + if cursor < span.wb { + let n = span.wb.saturating_sub(cursor).clamp(0, BLOCK as i64) as usize; + block = Some(if timeline.normal() { + mix_block(&src, comp, timeline.time(cursor), n, timeline.rate(), mix) + } else { + timeline.mix(cursor, n, mix, |t, frames| { + effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, frames, mix) + }) + }); + cursor += n as i64; + } + let Ok(mut s) = state.lock() else { return }; + if s.generation != generation { + return; + } + match block { + Some(b) => feed.push(&b), + None => feed.finished.store(true, Ordering::Relaxed), + } + s.cursor = cursor; } /// `n` stereo frames of `comp`'s mix from comp time `t` at the device `rate`, mixed at `mix` Hz @@ -457,6 +629,13 @@ mod tests { assert_eq!(clock_sample(&once, 100_000, 0), 47999); } + #[test] + fn slow_preview_clocks_do_not_overflow_a_wide_output_span() { + let span = PlaySpan { start: 0, wa: i64::MIN, wb: i64::MAX, looping: true }; + assert_eq!(clock_sample(&span, u64::MAX, 0), 0); + assert_eq!(clock_sample(&PlaySpan { looping: false, ..span }, u64::MAX, 0), i64::MAX - 1); + } + #[test] fn frame_mapping_matches_comp_frames() { let fps = FrameRate::new(30, 1); @@ -559,6 +738,145 @@ mod tests { playback.stop(); } + /// #2104: muting a layer while the preview plays silences it: the queued sound is dropped + /// and everything mixed afterwards comes from the edited project. + #[test] + fn muting_a_layer_while_playing_silences_the_preview() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, FootageKind, ItemKind, LayerSource, build}; + use effectcraft_engine::render::FootageSource; + use effectcraft_engine::render::cache::LayerCache; + use std::time::{Duration, Instant}; + + /// Footage audio that is a constant 0.5. + struct Steady; + impl FootageSource for Steady { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option> { + Some(vec![0.5; frames * 2]) + } + } + struct Device; + impl AudioDevice for Device { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _: Arc) -> Result<(), String> { + Ok(()) + } + fn stop(&mut self) {} + } + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); + let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let mut project = Arc::new(p); + let src = RenderSource { + project: project.clone(), + footage: Arc::new(Steady), + expr: None, + layer_cache: Arc::new(LayerCache::default()), + gpu: None, + gpu_display: false, + disk: None, + }; + let mut playback = + AudioPlayback::start(Box::new(Device), src, cid, Tick::ZERO, Tick::ZERO, Tick::from_seconds_f64(10.0), true, 0).expect("the preview starts"); + let t = Instant::now(); + while playback.feed.queued_frames() == 0 { + assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered"); + std::thread::sleep(Duration::from_millis(10)); + } + // Mute the layer while the sound plays. + if let Some(c) = Arc::make_mut(&mut project).comp_mut(cid) { + c.layers[0].switches.audio = false; + } + playback.set_project(project.clone()); + assert!(playback.project().is_some_and(|p| Arc::ptr_eq(&p, &project))); + // The feeder tops the queue up again, now from the muted project. + while playback.feed.queued_frames() == 0 { + assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered"); + std::thread::sleep(Duration::from_millis(10)); + } + let mut out = vec![1.0f32; playback.feed.queued_frames() * 2]; + playback.feed.pull(&mut out); + assert!(out.iter().all(|v| *v == 0.0), "the muted layer still plays"); + playback.stop(); + } + + /// #2104: only edits that change the mix drop the queued sound. + #[test] + fn only_audible_edits_change_the_audio() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, FootageKind, build}; + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); + let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let old = p; + // A video-only edit: the layer is hidden, its name and label change. + let mut video = old.clone(); + if let Some(l) = video.comp_mut(cid).and_then(|c| c.layers.first_mut()) { + l.switches.video = false; + l.name = "renamed".into(); + } + assert!(!audio_changed(&old, &video, cid), "a video edit must not drop the sound"); + // Muting, solo and a moved in point do change it. + for edit in 0..3 { + let mut edited = old.clone(); + if let Some(l) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()) { + match edit { + 0 => l.switches.audio = false, + 1 => l.switches.solo = true, + _ => l.in_point = Tick::from_seconds_f64(1.0), + } + } + assert!(audio_changed(&old, &edited, cid), "audible edit {edit} must be followed"); + } + } + + /// #2104: a video effect edit leaves the mix alone, an audio effect changes it. + #[test] + fn only_audio_effects_change_the_audio() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::build::{self, Ids}; + use effectcraft_engine::project::{Comp, Footage, FootageKind}; + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); + let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let with_effect = |id: &str| { + let mut edited = p.clone(); + let spec = effectcraft_engine::effects::find(id).expect("the effect exists"); + let mut next = edited.next_id; + let g = effectcraft_engine::effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 36.0]); + edited.next_id = next; + if let Some(fx) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()).and_then(|l| l.props.sub_mut("effects")) { + fx.children.push(g.into()); + } + edited + }; + assert!(!audio_changed(&p, &with_effect("ec.blur.gaussian"), cid), "a video effect must not drop the sound"); + assert!(audio_changed(&p, &with_effect("ec.audio.backwards"), cid), "an audio effect must be followed"); + } + #[test] fn meter_decays_and_holds_peaks() { let mut m = Meter::default(); @@ -573,3 +891,7 @@ mod tests { assert!(m.peak_db[0] < -10.0, "{}", m.peak_db[0]); } } + +#[cfg(test)] +#[path = "audio/live_tests.rs"] +mod live_tests; diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs new file mode 100644 index 000000000..a7b845c3b --- /dev/null +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -0,0 +1,217 @@ +//! #2104: exercise the real mixer and queue without relying on thread timing or an audio device. + +use super::{AudioDevice, AudioFeed, AudioPlayback, Feeder, PlaySpan, PreviewRate, feed_block}; +use crate::frames::RenderSource; +use effectcraft_engine::color::Label; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::{Comp, Expression, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Property, build}; +use effectcraft_engine::render::{EvalCtx, ExprHost, FootageSource, LayerCache}; +use effectcraft_engine::time::{FrameRate, Tick}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::Duration; + +struct Device; +impl AudioDevice for Device { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _: Arc) -> Result<(), String> { + Ok(()) + } + fn stop(&mut self) {} +} + +/// A ramp identifies the exact source sample, exposing skips and repeats after an edit. +struct Ramp; +impl FootageSource for Ramp { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { + let first = t.to_units_floor(i64::from(rate)); + Some((0..frames).flat_map(|i| [(first + i as i64) as f32 / 128.0; 2]).collect()) + } +} + +/// Construct a paused feeder whose short nonzero span makes every source sample observable. +fn playback(footage: Arc) -> AudioPlayback { + let mut project = Project::default(); + let f = Footage { path: "ramp.wav".into(), kind: FootageKind::Audio, has_audio: true, ..Default::default() }; + let item = project.add_item("Ramp", Label::SeaFoam, None, ItemKind::Footage(f)); + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let layer = build::layer(&mut project, &comp, "Ramp", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer].into(); + let cid = project.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let src = RenderSource { + project: Arc::new(project), + footage, + expr: None, + layer_cache: Arc::new(LayerCache::default()), + gpu: None, + gpu_display: false, + disk: None, + }; + AudioPlayback { + feed: Arc::new(AudioFeed::default()), + device: Box::new(Device), + stop: Arc::new(AtomicBool::new(false)), + thread: None, + state: Arc::new(Mutex::new(Feeder { src, cursor: 24, generation: 0 })), + comp: cid, + mix: 48000, + rate: 48000, + span: PlaySpan { start: 24, wa: 16, wb: 64, looping: false }, + timeline: PreviewRate::new(48000, 1.0, Tick::ZERO).unwrap(), + } +} + +/// Run exactly one production feeder block, without a background refill racing assertions. +fn mix(p: &AudioPlayback) { + feed_block(&p.state, p.comp, &p.feed, &p.span, p.timeline, p.mix); +} + +/// Copy the active project and apply a layer edit through the same snapshot boundary as the UI. +fn edit(p: &AudioPlayback, update: impl FnOnce(&mut Layer)) -> Arc { + let mut project = p.project().unwrap(); + update(&mut Arc::make_mut(&mut project).comp_mut(p.comp).unwrap().layers[0]); + project +} + +/// Levels edits retain sample position across the loop boundary and the exclusive non-looping end. +#[test] +fn project_replacement_resumes_at_the_first_unconsumed_sample() { + for looping in [false, true] { + let mut p = playback(Arc::new(Ramp)); + p.span.looping = looping; + mix(&p); + let mut out = [0.0; 64]; + p.feed.pull(&mut out); + assert_eq!(out[0], 24.0 / 128.0); + assert_eq!(p.feed.consumed(), 32); + let updated = edit(&p, |l| l.props.sub_mut("audio").unwrap().get_mut("levels").unwrap().value = Value::Vec2([-6.020599913279624; 2])); + p.follow_project(updated); + assert_eq!(p.feed.queued_frames(), 0); + assert_eq!(p.state.lock().unwrap().cursor, 56); + mix(&p); + let mut tail = [0.0; 16]; + p.feed.pull(&mut tail); + assert!((tail[0] - 56.0 / 256.0).abs() < 1e-6); + assert!((tail[14] - 63.0 / 256.0).abs() < 1e-6); + p.set_project(p.project().unwrap()); + assert_eq!(p.state.lock().unwrap().cursor, if looping { 16 } else { 64 }); + mix(&p); + if looping { + let mut first = [0.0; 2]; + p.feed.pull(&mut first); + assert!((first[0] - 16.0 / 256.0).abs() < 1e-6, "resume at the work-area start"); + } else { + assert!(p.finished(), "an edit after the last sample must not replay it"); + assert_eq!(p.feed.queued_frames(), 0); + } + } +} + +struct Gated { + entered: mpsc::Sender<()>, + resume: Mutex>, +} +impl FootageSource for Gated { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option> { + self.entered.send(()).unwrap(); + self.resume.lock().unwrap().recv_timeout(Duration::from_secs(10)).unwrap(); + Some(vec![0.5; frames * 2]) + } +} + +/// A slow mix must not block edits or publish samples from the superseded project generation. +#[test] +fn a_block_in_flight_cannot_publish_after_a_mute() { + let (entered, waiting) = mpsc::channel(); + let (resume, gate) = mpsc::channel(); + let mut p = playback(Arc::new(Gated { entered, resume: Mutex::new(gate) })); + let (state, feed, span, comp, rate) = (p.state.clone(), p.feed.clone(), p.span, p.comp, p.rate); + let worker = std::thread::spawn(move || feed_block(&state, comp, &feed, &span, PreviewRate::new(rate, 1.0, Tick::ZERO).unwrap(), rate)); + waiting.recv_timeout(Duration::from_secs(10)).unwrap(); + // The edit completes while the mixer is blocked, without waiting on its work. + p.follow_project(edit(&p, |l| l.switches.audio = false)); + resume.send(()).unwrap(); + worker.join().unwrap(); + assert_eq!(p.feed.queued_frames(), 0, "the old generation cannot publish"); + assert_eq!(p.state.lock().unwrap().cursor, 24, "nor advance the replacement cursor"); + mix(&p); + let mut out = [1.0; 16]; + p.feed.pull(&mut out); + assert_eq!(out, [0.0; 16]); +} + +/// Models a levels expression that reads a video property through the renderer's expression API. +struct OpacityLevels; +impl ExprHost for OpacityLevels { + fn eval(&self, _: &EvalCtx, layer: &Layer, _: &Property, _: &Value) -> Result { + let opacity = layer.transform().unwrap().get("opacity").unwrap().value.as_f64(); + Ok(Value::Vec2([if opacity == 0.0 { -96.0 } else { 0.0 }; 2])) + } +} + +/// Video properties are irrelevant to a plain mix, but may be dependencies of audio expressions. +#[test] +fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { + for expression in [false, true] { + let mut p = playback(Arc::new(Ramp)); + if expression { + let updated = edit(&p, |l| { + l.props.sub_mut("audio").unwrap().get_mut("levels").unwrap().expr = + Some(Expression { text: "[transform.opacity, transform.opacity]".into(), enabled: true }); + }); + let mut state = p.state.lock().unwrap(); + state.src.project = updated; + state.src.expr = Some(Arc::new(OpacityLevels)); + } + mix(&p); + assert_eq!(p.feed.queued_frames(), 40); + let updated = edit(&p, |l| l.props.sub_mut("transform").unwrap().get_mut("opacity").unwrap().value = Value::Scalar(0.0)); + p.follow_project(updated); + if expression { + assert_eq!(p.feed.queued_frames(), 0, "the expression can read the edited video property"); + mix(&p); + let mut out = [1.0; 16]; + p.feed.pull(&mut out); + assert_eq!(out, [0.0; 16]); + } else { + assert_eq!(p.feed.queued_frames(), 40, "an unrelated edit cannot interrupt audio"); + } + } +} + +/// Reused nested comps cost linear visits while edits to their descendants remain detectable. +#[test] +fn shared_nested_comps_are_compared_once_per_depth() { + let mut project = Project::default(); + let mut child = None; + for _ in 0..10 { + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + if let Some(item) = child { + for _ in 0..10 { + let mut layer = build::layer(&mut project, &comp, "Nested", LayerSource::Comp { item }, (64, 36), None); + layer.switches.audio = false; + comp.layers.push(layer); + } + } + child = Some(project.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(comp.into()))); + } + let root = child.unwrap(); + let mut edited = project.clone(); + edited.comp_mut(root).unwrap().layers[0].name = "Renamed".into(); + let mut seen = std::collections::HashSet::new(); + assert!(!super::audio_changed_inner(&project, &edited, root, &mut seen)); + assert_eq!(seen.len(), 10, "compare the ten comps, not a billion paths through them"); + let leaf = *seen.iter().find(|(_, depth)| *depth == 9).map(|(item, _)| item).unwrap(); + let mut layer = project.comp(root).unwrap().layers[0].clone(); + layer.source = LayerSource::Null; + edited.comp_mut(leaf).unwrap().layers.push(layer); + assert!(super::audio_changed(&project, &edited, root), "memoization must still find a nested edit"); +} diff --git a/crates/ui-egui/src/audio/preview_rate.rs b/crates/ui-egui/src/audio/preview_rate.rs new file mode 100644 index 000000000..06124b4c9 --- /dev/null +++ b/crates/ui-egui/src/audio/preview_rate.rs @@ -0,0 +1,199 @@ +//! Preview speed changes composition time, not the output device's sample rate. +//! Keep the clock and the resampler on the same rational output-sample grid. + +use effectcraft_engine::time::{FrameRate, TICKS_PER_SECOND, Tick}; + +use super::PlaySpan; + +/// At most this many composition samples are mixed at once. At very high preview speeds, +/// skip samples that no output frame needs instead of allocating the entire source interval. +const SOURCE_PAGE: usize = 4096; + +#[derive(Clone, Copy)] +pub(super) struct PreviewRate { + rate: u32, + speed: FrameRate, + origin: Tick, +} + +impl PreviewRate { + pub(super) fn new(rate: u32, speed: f64, start: Tick) -> Result { + // A billionth keeps the ratio of decimal preview rates and rational comp rates without + // rounding it to an integer sample rate. Reject unrepresentable values, never mute them. + let num = (speed * 1_000_000_000.0).round(); + if rate == 0 || !speed.is_finite() || speed <= 0.0 || num < 1.0 || num >= i64::MAX as f64 { + return Err("The preview playback rate must be finite, positive and representable".into()); + } + let speed = FrameRate::new(num as i64, 1_000_000_000); + // Keep the existing 1x sample grid. At another speed, anchor the grid to the exact + // requested frame rather than rounding a nonzero start backwards by an output sample. + let origin = if speed.num == speed.den { Tick::ZERO } else { start }; + Ok(Self { rate, speed, origin }) + } + + pub(super) fn rate(self) -> u32 { + self.rate + } + + pub(super) fn normal(self) -> bool { + self.speed.num == self.speed.den + } + + pub(super) fn time(self, sample: i64) -> Tick { + if self.normal() { + // Preserve the legacy grid, including negative samples at unusual device rates. + return super::sample_time(sample, self.rate); + } + self.origin + Tick::from_rational(sample, self.speed.num, i64::from(self.rate) * self.speed.den) + } + + /// First output sample at or after a composition time. Ceiling keeps loop endpoints inside + /// the work area. A fractional loop boundary is quantized by less than one output sample. + fn sample_ceil(self, time: Tick) -> i64 { + let n = i128::from((time - self.origin).0) * i128::from(self.rate) * i128::from(self.speed.den); + let d = i128::from(TICKS_PER_SECOND) * i128::from(self.speed.num); + let sample = n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0); + sample.clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } + + pub(super) fn span(self, start: Tick, wa: Tick, wb: Tick, looping: bool) -> PlaySpan { + if self.normal() { + return PlaySpan::new(start, wa, wb, looping, self.rate); + } + let wa = self.sample_ceil(wa); + PlaySpan { start: 0, wa, wb: self.sample_ceil(wb).max(wa.saturating_add(1)), looping } + } + + /// Source sample and interpolation phase at an absolute output cursor. Computing this + /// from the timeline (not by accumulating block lengths) makes partitioning immaterial. + fn position(self, sample: i64, mix: u32) -> (i64, f32) { + let n = i128::from(self.time(sample).0) * i128::from(mix); + let d = i128::from(TICKS_PER_SECOND); + (n.div_euclid(d) as i64, n.rem_euclid(d) as f32 / d as f32) + } + + pub(super) fn mix(self, cursor: i64, n: usize, mix: u32, mut render: impl FnMut(Tick, usize) -> Vec) -> Vec { + let mut out = Vec::with_capacity(n.saturating_mul(2)); + let mut page = Vec::new(); + let mut first = 0; + for i in 0..n { + let sample = cursor.saturating_add(i as i64); + let (at, fraction) = self.position(sample, mix); + let offset = usize::try_from(at.saturating_sub(first)).ok(); + if page.is_empty() || offset.is_none_or(|i| i >= (page.len() / 2).saturating_sub(1)) { + first = at; + let last = self.position(cursor.saturating_add(n.saturating_sub(1) as i64), mix).0; + let next = self.position(sample.saturating_add(1), mix).0; + let frames = if next.saturating_sub(first) >= SOURCE_PAGE as i64 - 1 { + // No following output sample can reuse this page. Read only this sample + // and its interpolation neighbor, rather than mixing an unused gap. + 2 + } else { + last.saturating_sub(first).saturating_add(2).clamp(2, SOURCE_PAGE as i64) as usize + }; + // mix_comp floors its starting sample. Add the fractional tick back so exotic + // source rates start on `first`, not the preceding composition sample. + let mut t = Tick::from_units(first, i64::from(mix)); + if t.to_units_floor(i64::from(mix)) < first { + t.0 = t.0.saturating_add(1); + } + page = render(t, frames); + } + let index = usize::try_from(at.saturating_sub(first)).unwrap_or(usize::MAX).saturating_mul(2); + for channel in 0..2 { + let a = page.get(index.saturating_add(channel)).copied().unwrap_or(0.0); + let b = page.get(index.saturating_add(2 + channel)).copied().unwrap_or(a); + out.push(a + (b - a) * fraction); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::clock_sample; + + #[test] + fn normal_speed_preserves_the_legacy_sample_grid() { + for rate in [44_100, 48_000, 48_001] { + let timeline = PreviewRate::new(rate, 1.0, Tick::from_seconds_f64(-0.3)).unwrap(); + for sample in [-48_001, -1, 0, 1, 48_001] { + assert_eq!(timeline.time(sample), crate::audio::sample_time(sample, rate)); + } + } + } + + #[test] + fn speed_clock_matches_slow_fast_and_fractional_previews() { + for (comp, preview) in [(FrameRate::FPS_60, 30.0), (FrameRate::FPS_30, 60.0), (FrameRate::FPS_59_94, 29.97)] { + let speed = preview / comp.as_f64(); + let start = comp.tick_of(17); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + assert_eq!(rate.time(0), start, "the first frame stays exact"); + let time = rate.time(48_000); + assert!(((time - start).seconds() - speed).abs() < 1e-9); + assert_eq!(comp.frame_at(time), comp.frame_at(start + Tick::from_seconds_f64(speed))); + } + } + + #[test] + fn speed_clock_subtracts_output_latency_and_wraps_inside_the_work_area() { + for speed in [0.5, 2.0, 0.73] { + let start = FrameRate::FPS_29_97.tick_of(19); + let wa = FrameRate::FPS_29_97.tick_of(13); + let wb = FrameRate::FPS_29_97.tick_of(43); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + let span = rate.span(start, wa, wb, true); + assert_eq!(rate.time(clock_sample(&span, 512, 1024)), start); + assert!(rate.time(span.wb - 1) < wb); + let wrapped = rate.time(clock_sample(&span, span.wb as u64 + 1024, 1024)); + assert!(wrapped >= wa && (wrapped - wa).seconds() < speed / 48_000.0); + let once = PlaySpan { looping: false, ..span }; + assert!(rate.time(clock_sample(&once, u32::MAX as u64, 0)) < wb); + } + } + + fn ramp(t: Tick, n: usize, mix: u32) -> Vec { + let first = t.to_units_floor(i64::from(mix)); + (0..n).flat_map(|i| [(first + i as i64) as f32 / 100_000.0, -(first + i as i64) as f32 / 100_000.0]).collect() + } + + #[test] + fn speed_mix_keeps_fractional_phase_across_uneven_blocks() { + for speed in [0.5, 2.0, 0.73, 29.97 / FrameRate::FPS_59_94.as_f64()] { + let start = FrameRate::FPS_29_97.tick_of(17); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + for mix in [44_100, 48_000, 96_000] { + let whole = rate.mix(0, 2048, mix, |t, n| ramp(t, n, mix)); + let split: Vec<_> = [(0, 321), (321, 107), (428, 1620)].into_iter().flat_map(|(at, n)| rate.mix(at, n, mix, |t, n| ramp(t, n, mix))).collect(); + assert_eq!(whole, split, "speed {speed}, source rate {mix}"); + for (i, frame) in whole.as_chunks::<2>().0.iter().enumerate() { + let expected = (start.seconds() + i as f64 * speed / 48_000.0) * mix as f64 / 100_000.0; + assert!((f64::from(frame[0]) - expected).abs() < 1e-6); + assert_eq!(frame[1], -frame[0]); + } + } + } + } + + #[test] + fn extreme_speeds_keep_source_reads_bounded_without_changing_speed() { + for speed in [0.00001, 99_900.0] { + let rate = PreviewRate::new(8_000, speed, Tick::ZERO).unwrap(); + let mut rendered = 0; + let out = rate.mix(0, 1024, 192_000, |_, n| { + assert!(n <= SOURCE_PAGE); + rendered += n; + vec![0.25; n * 2] + }); + assert!(rendered <= SOURCE_PAGE, "unused source intervals were mixed: {rendered} samples"); + assert_eq!(out, vec![0.25; 2048]); + assert!((rate.time(8000).seconds() - speed).abs() < 1e-9); + } + for speed in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX] { + assert!(PreviewRate::new(48_000, speed, Tick::ZERO).is_err()); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 15a10380d..9f86c7ace 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -21,6 +21,7 @@ //! //! The full reference with examples is `docs/control-protocol.md`. +use std::sync::Arc; use std::sync::mpsc::Sender; use serde_json::{Value, json}; @@ -60,6 +61,8 @@ pub enum Outcome { }, /// Reply once background jobs end (a `wait: true` command whose job runs in a worker). AwaitJobs(JobWaiter), + /// A script yielded its session for rendering; reply after its continuation returns. + AwaitScript(u32), } /// A `wait: true` engine command whose job was sent to the session's offload (the browser's @@ -73,6 +76,8 @@ pub struct JobWaiter { pub jobs: Vec, /// Absolute completion-log position when it started (including evicted entries). pub log_cursor: usize, + /// The exact native render, not the reusable `jobs.list` identifier. + pub render: Option>, } /// Commands that block with `wait` (and its default). @@ -81,34 +86,74 @@ fn waits(command: &str, params: &Value) -> bool { params.get("wait").and_then(Value::as_bool).unwrap_or(default) } -/// Run an engine command. A waiting command (`wait: true`) whose job the session offloads -/// runs with `wait: false` instead, and the reply waits for the job ([`Outcome::AwaitJobs`]), -/// so the browser's UI keeps drawing while it runs. +/// Run an engine command. A waiting offloaded command or native render runs with `wait: false` +/// instead, and the reply waits for the job ([`Outcome::AwaitJobs`]), keeping the UI responsive. fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result { - if !app.session.offloads() || !waits(id, ¶ms) { - return app.session.execute_checked(id, params).map(ok).map_err(|e| e.to_string()); + if !(app.session.offloads() || (cfg!(not(target_arch = "wasm32")) && id == "renderQueue.render")) || !waits(id, ¶ms) { + let result = app.session.execute_checked(id, params); + return result.map(script_outcome).map_err(|e| e.to_string()); } let before: Vec = app.session.jobs().into_iter().map(|j| j.id).collect(); let log_cursor = app.session.offload_log_start.saturating_add(app.session.offload_log.len()); let mut p = params.clone(); - p["wait"] = json!(false); + p.as_object_mut().ok_or("command params must be an object")?.insert("wait".into(), json!(false)); let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?; let jobs: Vec = app.session.jobs().into_iter().map(|j| j.id).filter(|j| !before.contains(j)).collect(); - if jobs.is_empty() { - return Ok(ok(result)); + let render = if cfg!(not(target_arch = "wasm32")) && id == "renderQueue.render" && !app.session.offloads() { + app.session.render_job.as_ref().map(|job| { + job.shared.retain_reply_items(result["items"].as_array().cloned().unwrap_or_default()); + job.shared.clone() + }) + } else { + None + }; + Ok(JobWaiter { command: id.to_string(), params, result, jobs, log_cursor, render }.into_outcome()) +} + +fn script_outcome(result: Value) -> Outcome { + match result.get("renderHost").and_then(Value::as_u64).and_then(|id| u32::try_from(id).ok()) { + Some(host) => Outcome::AwaitScript(host), + None => ok(result), + } +} + +/// Suspended scripts retain open undo groups. Let the UI inspect and stop the render, but +/// never fold an unrelated edit, another script or a project replacement into that group. +pub(crate) fn check_script_render(app: &EffectcraftApp, id: &str) -> Result<(), String> { + if app.session.script_ui.render_host.is_some() && !effectcraft_engine::scriptui::allowed_during_render(id) { + Err(effectcraft_engine::scriptui::RENDER_WAIT_MESSAGE.into()) + } else { + Ok(()) } - Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_cursor })) } impl JobWaiter { + fn into_outcome(self) -> Outcome { + // A finished but unpolled native worker still needs its completion boundary even + // when jobs() no longer lists it. Already-polled commands have their final result. + if self.jobs.is_empty() && self.render.is_none() { ok(self.result) } else { Outcome::AwaitJobs(self) } + } + /// Whether its jobs are still running. pub fn pending(&self, app: &EffectcraftApp) -> bool { + if let Some(render) = &self.render { + return !render.reply_finished() && app.session.render_job.as_ref().is_some_and(|job| Arc::ptr_eq(render, &job.shared)); + } app.session.jobs().iter().any(|j| self.jobs.contains(&j.id)) } /// The reply: what the blocking command would have returned (`running: false`, the final /// progress, render items and analysis status), or the job's error. pub fn finish(self, app: &mut EffectcraftApp) -> Value { + if let Some(render) = self.render { + let Some(items) = render.completed_items() else { + return json!({"ok": false, "error": "render result unavailable: the project or render job was replaced before completion"}); + }; + let mut result = self.result; + result["items"] = json!(items); + result["rendering"] = json!(false); + return json!({"ok": true, "result": result}); + } let s = &mut app.session; s.poll_render(); let error = s @@ -178,6 +223,14 @@ fn point(app: &EffectcraftApp, p: &Value) -> Result { /// [`Outcome::AwaitJobs`] instead of replying in this call. pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest, now: f64) -> Outcome { let p = &req.params; + if app.session.script_ui.render_host.is_some() + && !matches!( + req.method.as_str(), + "engine.execute" | "ui.menu.invoke" | "engine.commands" | "ui.menu.list" | "ui.inspect" | "ui.elements" | "ui.resize" | "ui.focus" + ) + { + return err(effectcraft_engine::scriptui::RENDER_WAIT_MESSAGE); + } match req.method.as_str() { "engine.execute" | "ui.menu.invoke" => execute_command(app, ctx, &req.method, p), "engine.commands" => engine_commands(app, p), @@ -211,15 +264,25 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome { let s = |k: &str| p.get(k).and_then(Value::as_str); let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + if let Err(e) = check_script_render(app, id) { + return err(e); + } let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + // Only this invocation opts in: CLI, lifecycle scripts and the inline console retain + // their synchronous contract. Its host remembers the opt-in across successive renders. + let previous = app.session.script_ui.yield_renders; + app.session.script_ui.yield_renders = cfg!(not(target_arch = "wasm32")) && id == "script.run"; // `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g. // `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`, // `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click. - let r = if method == "engine.execute" && effectcraft_engine::find_command(id).is_some() { + let r = if (method == "engine.execute" && effectcraft_engine::find_command(id).is_some()) + || (id == "renderQueue.render" && params.get("wait").and_then(Value::as_bool) == Some(true)) + { execute_engine(app, id, params) } else { - crate::menus::invoke(app, ctx, id, params).map(ok) + crate::menus::invoke(app, ctx, id, params).map(script_outcome) }; + app.session.script_ui.yield_renders = previous; match r { Ok(o) => o, Err(e) => { @@ -908,6 +971,26 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { #[cfg(test)] mod tests { + #[test] + fn a_native_render_with_no_running_jobs_still_checks_its_own_completion() { + let waiter = super::JobWaiter { + command: "renderQueue.render".into(), + params: serde_json::json!({}), + result: serde_json::json!({"rendering": true, "items": []}), + jobs: vec![], + log_cursor: 0, + render: Some(std::sync::Arc::new(effectcraft_engine::render_queue::JobShared::default())), + }; + let super::Outcome::AwaitJobs(waiter) = waiter.into_outcome() else { + panic!("a finished native render must still pass through completion polling"); + }; + let mut app = crate::EffectcraftApp::new(effectcraft_engine::Session::default()); + assert!(!waiter.pending(&app)); + let reply = waiter.finish(&mut app); + assert_eq!(reply["ok"], false, "an abandoned render cannot silently report a replacement project's queue"); + assert!(reply["error"].as_str().unwrap().contains("replaced before completion")); + } + #[test] fn base64_rfc4648_vectors() { for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5f03af14c..e19763f90 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -124,7 +124,7 @@ impl PanelKind { PanelKind::EssentialGraphics => "Essential Graphics", PanelKind::ScriptPanel(_) => "ScriptUI Panel", PanelKind::Viewer(_) => "Composition", - PanelKind::LumetriScopes => "Lumetri Scopes", + PanelKind::LumetriScopes => "Video Scopes", PanelKind::Footage => "Footage", PanelKind::MediaBrowser => "Media Browser", PanelKind::Metadata => "Metadata", @@ -709,6 +709,16 @@ mod tests { w.apply(action, permissions).unwrap(); } + /// Only the title changes: existing saved workspace layouts still deserialize. + #[test] + fn scopes_label_is_descriptive_and_its_saved_identity_stays_compatible() { + let panel: PanelKind = serde_json::from_str("\"LumetriScopes\"").unwrap(); + assert_eq!(panel, LumetriScopes); + assert_eq!(panel.title(), "Video Scopes"); + assert_eq!(panel.id(), "LumetriScopes"); + assert_eq!(serde_json::to_string(&panel).unwrap(), "\"LumetriScopes\""); + } + #[test] fn after_effects_workspaces_exist_with_their_core_panels() { for name in WORKSPACES { diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 001790116..a0c2f94b5 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -15,9 +15,12 @@ //! egui frames, within a time budget. //! //! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also -//! written to disk under a content key (the comp's content hash, frame, scale, view and render -//! options), and a job first looks there: frames survive restarts and project reopenings. The -//! timeline shows disk-only frames as a blue cache bar. +//! written to disk under a content key (the comp's content hash from the project in memory, +//! frame, scale, view and render options) with the source files it read, and a job first looks +//! there: frames survive restarts and project reopenings. An entry is used only while the files +//! it lists are unchanged, checked by the frame worker when it reads the entry (a frame whose +//! files can't all be named is shown but not stored). The timeline shows disk-only frames as a +//! blue cache bar (present entries; their files are checked only when a frame is used). //! //! With Mercury GPU Acceleration (a [`Gpu`] on egui-wgpu's device and the project's renderer set //! to the GPU) frames stay on the GPU: the compositor writes an RGBA8 texture that the viewer @@ -29,12 +32,13 @@ //! cache keeps them as half-float entries. use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; -use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; +use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; -use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; +use effectcraft_engine::render::reads::{ReadFiles, ReadLog}; +use effectcraft_engine::render::{AutoTicket, CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; use effectcraft_gpu::{DisplayFrame, FrameError, Gpu}; use rayon::prelude::*; @@ -50,6 +54,96 @@ const GPU_BUDGET: usize = 1 << 30; const MIN_GPU_BUDGET: usize = 64 << 20; const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5); const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); +/// GPU frames that render at once, however many frame threads there are. Each one holds its own +/// working textures, so 16 at once ran an RTX 3090 out of video memory (#3028). +const MAX_GPU_RENDERS: usize = 2; + +/// Counts the GPU renders in flight; the rest of the frame threads wait their turn. +struct GpuRenderSlots { + #[cfg(test)] + entered: std::sync::atomic::AtomicUsize, + busy: Mutex, + freed: Condvar, +} + +/// One GPU render slot, given back on drop. +struct GpuRenderSlot<'a>(&'a GpuRenderSlots); + +static GPU_RENDER_SLOTS: GpuRenderSlots = GpuRenderSlots::new(); + +impl GpuRenderSlots { + const fn new() -> Self { + Self { + #[cfg(test)] + entered: std::sync::atomic::AtomicUsize::new(0), + busy: Mutex::new(0), + freed: Condvar::new(), + } + } + + /// The UI-thread display path must hand a frame to the remote worker instead of waiting + /// behind preview lanes. Never block the window to enforce the native memory cap. + fn try_acquire(&self, max: usize) -> Option> { + let mut busy = match self.busy.try_lock() { + Ok(busy) => busy, + Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + if !cfg!(target_arch = "wasm32") && *busy >= max { + return None; + } + *busy += 1; + #[cfg(test)] + self.entered.fetch_add(1, Ordering::SeqCst); + Some(GpuRenderSlot(self)) + } + + /// A Rayon helper may be executing a nested frame while an admitted render waits for + /// its work. Blocking that helper on a permit would deadlock the render (#3028). + fn for_render(&self, max: usize) -> Option> { + // Only a lane's drain thread waits: it renders one frame at a time, protected from + // nested drains, and a lane runs one drain at a time (it is idle again, and can be + // started anew, only once its drain found no job, so no frame of the lane is still + // running). The lane's other threads ([`LANE_WIDTH`]) only help with that frame's + // parallel work and are Rayon helpers here: they never wait. So no thread waits while + // an admitted frame waits for it, and waiting keeps the GPU preview's throughput. + #[cfg(not(target_arch = "wasm32"))] + if DRAINING_LANE.get() { + return Some(self.acquire(max)); + } + if rayon::current_thread_index().is_some() { self.try_acquire(max) } else { Some(self.acquire(max)) } + } + + /// Wait until fewer than `max` renders are in flight, then take a slot. The browser has no + /// threads to wait on: it never blocks. + fn acquire(&self, max: usize) -> GpuRenderSlot<'_> { + let mut busy = self.busy.lock().unwrap_or_else(PoisonError::into_inner); + while !cfg!(target_arch = "wasm32") && *busy >= max { + busy = self.freed.wait(busy).unwrap_or_else(PoisonError::into_inner); + } + *busy += 1; + #[cfg(test)] + self.entered.fetch_add(1, Ordering::SeqCst); + GpuRenderSlot(self) + } +} + +impl Drop for GpuRenderSlot<'_> { + fn drop(&mut self) { + let mut busy = self.0.busy.lock().unwrap_or_else(PoisonError::into_inner); + *busy = busy.saturating_sub(1); + self.0.freed.notify_one(); + } +} + +/// The window's frame gaps that adapt [`Queue::gpu_limit`] while a preview plays: a longer one +/// is a stall, shorter ones for a while are smooth. +const UI_STALL: std::time::Duration = std::time::Duration::from_millis(100); +const UI_SMOOTH: std::time::Duration = std::time::Duration::from_millis(50); +/// How long the window must stay smooth before one more lane composites on the GPU, and before it +/// tries again a limit that stalled. +const GPU_LANE_STEP: std::time::Duration = std::time::Duration::from_millis(500); +const GPU_LANE_RETRY: std::time::Duration = std::time::Duration::from_secs(10); mod f16; pub use f16::{F16_PIXEL_BYTES, F16Frame}; @@ -156,8 +250,41 @@ impl Playhead { } } +/// A frame in the RAM preview and the source files it was made of (`None`: unknown, a frame +/// rendered elsewhere or kept on the GPU), so it can be written to the disk cache later +/// ([`Frames::persist_missing`]). +struct Cached { + img: FrameImage, + /// (the browser's frames are stored by its frame workers, not from here) + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + files: Option>, + /// The disk cache its render started with and that cache's drop generation then: the frame + /// is written to that cache only, and only while none of its files was dropped since + /// (`None`: rendered without a disk cache, or elsewhere; never written from here). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + since: Option, +} + +/// A disk cache and its drop generation ([`DiskCache::drop_generation`]) when a render started. +#[derive(Clone)] +struct DiskSince(std::sync::Weak, u64); + +impl DiskSince { + /// A marker for the drop generation of `dc` at the time of a render. + fn of(dc: &Arc, generation: u64) -> DiskSince { + DiskSince(Arc::downgrade(dc), generation) + } + + /// The generation, when `dc` is the cache it was taken from (the weak reference keeps that + /// cache's address from being reused by another). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn for_cache(&self, dc: &Arc) -> Option { + std::ptr::eq(self.0.as_ptr(), Arc::as_ptr(dc)).then_some(self.1) + } +} + struct Cache { - map: HashMap, + map: HashMap, order: VecDeque, bytes: usize, /// Bytes of GPU frames (also counted in `bytes`). @@ -174,10 +301,17 @@ struct Cache { } impl Cache { + /// A frame without its source files (tests). + #[cfg(test)] fn insert(&mut self, k: FrameKey, img: FrameImage) { + self.insert_with(k, img, None, None); + } + /// [`Cache::insert`] with the source files the frame was made of and the disk cache's drop + /// generation when its render started. + fn insert_with(&mut self, k: FrameKey, img: FrameImage, files: Option>, since: Option) { let (sz, gpu) = (img.bytes(), img.is_gpu()); - if let Some(old) = self.map.insert(k, img) { - self.forget(&old); + if let Some(old) = self.map.insert(k, Cached { img, files, since }) { + self.forget(&old.img); } else { self.order.push_back(k); } @@ -215,7 +349,7 @@ impl Cache { /// than the one on screen first (a zoom or resolution change). fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { - let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(FrameImage::is_gpu); + let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(|c| c.img.is_gpu()); let other_scale = self.order.iter().position(|k| Some(k.scale) != self.scale && is_gpu(k)); let Some(i) = other_scale.or_else(|| self.order.iter().position(is_gpu)) else { break }; if let Some(old) = self.order.remove(i) { @@ -251,9 +385,10 @@ impl Cache { self.gpu_bytes = self.gpu_bytes.saturating_sub(img.bytes()); } } + /// Removes the entry for `k` and releases its image. fn remove(&mut self, k: &FrameKey) { if let Some(i) = self.map.remove(k) { - self.forget(&i); + self.forget(&i.img); } } /// Shown again: last in line for eviction. @@ -316,10 +451,10 @@ pub struct RenderSource { /// ([`Frames::identity`]). type Identity = (u64, Option>); -/// Content keys of (project revision, comp, virtual-file generation). The generation is one -/// counter bumped when a browser file is replaced, so a lookup stays a map read. Hashing the -/// new bytes happens only when that generation misses. -type ContentKeys = Arc>>; +/// Disk-cache content keys ([`disk_cache::comp_content_key`]) of (project revision, comp). They +/// come from the project in memory alone: the files a frame read are checked by its entry +/// when it is read (#3184). +type ContentKeys = Arc>>; #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] @@ -328,16 +463,17 @@ struct ContentKeyJobs { pending: VecDeque<(Arc, u64, u64)>, } -fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, epoch: u64, virtual_id: Option>) -> u128 { - if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp, epoch)).copied()) { +/// The content key of `comp` at `revision`, memoised per revision. +fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { + if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { return k; } - let k = disk_cache::comp_content_key_with(project, ItemId(comp), virtual_id); + let k = disk_cache::comp_content_key(project, ItemId(comp)); if let Ok(mut m) = keys.lock() { if m.len() > 256 { m.clear(); } - m.insert((revision, comp, epoch), k); + m.insert((revision, comp), k); } k } @@ -388,7 +524,8 @@ fn comp_content_with<'a>( let Some(ItemKind::Comp(c)) = item(id).map(|i| &i.kind) else { continue }; let c = version(id, c); for l in &c.layers { - if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { + // (a 3D model's item too) + if let Some(item) = l.source.item() { stack.push(item); } expressions = expressions || (everything && has_expression(&l.props)); @@ -423,18 +560,28 @@ pub fn opts_hash(o: &RenderOpts) -> u64 { } /// Disk key of a viewer frame (render options included: region of interest, draft…). -fn frame_disk_key( - keys: &ContentKeys, - project: &Project, - key: &FrameKey, - opts_hash: u64, - epoch: u64, - virtual_id: Option>, -) -> u128 { - let content = content_key(keys, project, key.revision, key.comp, epoch, virtual_id); +fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 { + let content = content_key(keys, project, key.revision, key.comp); disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash) } +/// Queue `img` for the disk cache under `dk` with the source files it was made of. `false` when +/// refused: a GPU frame (no CPU pixels), files that can't be checked when the entry is read +/// (an incomplete list, #3184: the frame is shown, not stored), a full queue, a file dropped +/// (File ▸ Reload Footage) after `since`, the drop generation its render started at; no room on +/// the disk (checked first: the conversion and compression would be wasted). +fn put_disk_frame(dc: &DiskCache, dk: u128, img: &FrameImage, files: &ReadFiles, virtual_id: Option>, since: u64) -> bool { + if !files.is_complete() || !dc.accepts_writes() { + return false; + } + match img { + // (one copy; the disk cache's writer thread compresses it) + FrameImage::Cpu(img) => dc.put_frame_owned(dk, img.size[0] as u32, img.size[1] as u32, img.as_raw().to_vec(), files, virtual_id, since), + FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits(), files, virtual_id, since), + FrameImage::Gpu(_) => false, + } +} + /// A frame for a [`RemoteFrames`] renderer. pub struct RemoteJob { pub project: Arc, @@ -485,14 +632,30 @@ pub trait RemoteFrames: Send + Sync { /// Whether a comp (or a comp it nests) has a layer with enabled effects. pub fn comp_has_effects(project: &Project, comp: ItemId) -> bool { - fn walk(p: &Project, comp: ItemId, depth: usize) -> bool { - let Some(c) = p.comp(comp) else { return false }; - c.layers.iter().any(|l| { - l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) - || depth < 16 && matches!(l.source, effectcraft_engine::project::LayerSource::Comp { item } if walk(p, item, depth + 1)) - }) + // Breadth first with a visited set: each comp is looked at once, at its shallowest level, so a + // comp first met deep in one chain is still found by a shorter route (a result memoised by + // comp alone would depend on the depth it was first reached at). + let mut seen = std::collections::HashSet::from([comp]); + let mut level = vec![comp]; + // Comps nested up to 16 levels below the first are searched. + for _ in 0..=16 { + let mut next = vec![]; + for id in level { + let Some(c) = project.comp(id) else { continue }; + for l in &c.layers { + if l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) { + return true; + } + if let effectcraft_engine::project::LayerSource::Comp { item } = l.source + && seen.insert(item) + { + next.push(item); + } + } + } + level = next; } - walk(project, comp, 0) + false } pub struct Frames { @@ -512,9 +675,8 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, - /// Bumped when a virtual file is replaced. Lookups key on it instead of statting footage. - virtual_epoch: Arc, - /// Size and content hash for paths with no native metadata. Called only on a cache miss. + /// Size and content hash for paths with no native metadata (the browser's file table): to + /// store and check the files of disk entries. virtual_id: Option Option<(u64, u64)> + Send + Sync>>, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc>, @@ -565,15 +727,36 @@ struct DiskCarry { #[cfg(not(target_arch = "wasm32"))] const MAX_DISK_SERIES: usize = 8; +/// Threads per lane (see [`Queue::lanes`]). A frame's passes are memory-bound and stop scaling +/// at about 4 threads, so a core per lane (12 frames at once, each on one thread) and 4 cores +/// per lane cache frames at the same rate (within 3 %, widths 1–4 measured on 12 cores); but the +/// 12 single-threaded frames start and finish together, so a preview that can't keep up shows +/// them in bursts, and its second frame waits for the whole batch (0.3–1.3 s). Three lanes of +/// four threads finish frames 3× as often and the second frame shows in 0.13–0.45 s. +/// Never fewer than two lanes: one would finish frames in a single file. +const LANE_WIDTH: usize = 4; + impl Default for Frames { fn default() -> Self { + Self::with_lane_width(LANE_WIDTH) + } +} + +impl Frames { + /// A frame cache whose lanes are `width` threads each (see [`LANE_WIDTH`]); `Default` uses + /// [`LANE_WIDTH`], benchmarks compare widths. + pub fn with_lane_width(width: usize) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = width; #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); #[cfg(not(target_arch = "wasm32"))] - let lanes: Vec = (0..threads) + let width = width.clamp(1, (threads / 2).max(1)); + #[cfg(not(target_arch = "wasm32"))] + let lanes: Vec = (0..(threads / width).max(1)) .map_while(|i| { rayon::ThreadPoolBuilder::new() - .num_threads(1) + .num_threads(width) .thread_name(move |_| format!("ec-preview-{i}")) .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) .build() @@ -581,7 +764,7 @@ impl Default for Frames { }) .collect(); #[cfg(not(target_arch = "wasm32"))] - let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() }; + let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), gpu_limit: 1, ..Default::default() }; #[cfg(target_arch = "wasm32")] let queue = Queue::default(); Frames { @@ -602,7 +785,6 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), - virtual_epoch: Arc::new(AtomicU64::new(0)), virtual_id: None, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc::default(), @@ -624,6 +806,15 @@ impl Default for Frames { disk_series: Arc::default(), } } + + /// How many frames render at once on the lanes (a playing preview's prefetch, Cache Frames + /// When Idle); in the browser, the frame workers. + pub fn lanes(&self) -> usize { + #[cfg(target_arch = "wasm32")] + return self.remote_slots().max(1); + #[cfg(not(target_arch = "wasm32"))] + self.queue.lock().map_or(1, |q| q.lanes.max(1)) + } } /// Whether viewer frames of a project with `settings` are half floats ([`F16Frame`], rendered @@ -754,7 +945,7 @@ impl Frames { pub fn retire_gpu(&self) { self.gpu_retired.store(true, Ordering::Release); if let Ok(mut cache) = self.cache.lock() { - let keys: Vec<_> = cache.map.iter().filter_map(|(key, image)| image.is_gpu().then_some(*key)).collect(); + let keys: Vec<_> = cache.map.iter().filter_map(|(key, c)| c.img.is_gpu().then_some(*key)).collect(); for key in keys { cache.remove(&key); } @@ -789,7 +980,7 @@ impl Frames { /// The cached frame, which becomes the most recently used. pub fn get(&self, k: &FrameKey) -> Option { let mut c = self.cache.lock().ok()?; - let img = c.map.get(k).cloned()?; + let img = c.map.get(k).map(|c| c.img.clone())?; c.touch(k); Some(img) } @@ -825,7 +1016,7 @@ impl Frames { }) .max_by_key(|k| k.revision) .copied()?; - let img = c.map.get(&newest).cloned()?; + let img = c.map.get(&newest).map(|c| c.img.clone())?; c.touch(&newest); Some((newest, img)) } @@ -968,7 +1159,6 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } - let epoch = self.virtual_epoch.load(Ordering::Relaxed); #[cfg(not(target_arch = "wasm32"))] { self.note_disk_series(base.comp, base.scale, base.view ^ opts_hash(opts)); @@ -976,7 +1166,7 @@ impl Frames { } #[cfg(not(target_arch = "wasm32"))] let content = { - let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return vec![]; @@ -984,7 +1174,7 @@ impl Frames { content }; #[cfg(target_arch = "wasm32")] - let content = content_key(&self.content_keys, &src.project, base.revision, base.comp, epoch, self.virtual_id.as_deref()); + let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect() @@ -995,20 +1185,27 @@ impl Frames { /// are converted on the frame pool, each queued once. `true` while there may be more to /// write (a content key being computed, frames being converted): call again soon, since a /// refused write is only retried by a later call. Disk keys hash `opts` the way the worker - /// writes them (`base.opts` also carries the Roto Brush model stamp). + /// writes them (`base.opts` also carries the Roto Brush model stamp). Only frames that know + /// every source file they were made of are written (an entry is checked against them). #[cfg(not(target_arch = "wasm32"))] pub fn persist_missing(&self, src: &RenderSource, base: &FrameKey, opts: &RenderOpts) -> bool { let Some(dc) = src.disk.clone() else { return false }; let oh = opts_hash(opts); self.note_disk_series(base.comp, base.scale, base.view ^ oh); self.carry_disk(src); - let epoch = self.virtual_epoch.load(Ordering::Relaxed); - let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); + // Free disk space leaves the cache (next to) no room: nothing would be stored, so nothing + // is converted (a refusal doesn't count as a try, so every idle pass would convert them + // all again). A later idle pass writes them once there is room (`prefs_live::memory_watch` + // keeps frames coming meanwhile). + if !dc.accepts_writes() { + return false; + } + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return true; }; - let (missing, converting): (Vec<(u128, FrameImage)>, bool) = { + let (missing, converting): (Vec<(u128, FrameImage, Arc, u64)>, bool) = { let (Ok(c), Ok(mut queued), Ok(mut written)) = (self.cache.lock(), self.persisted.lock(), self.written.lock()) else { return false }; if queued.len() > 100_000 { queued.clear(); @@ -1021,14 +1218,17 @@ impl Frames { .map .iter() .filter(|(k, _)| k.same_series(base)) - .filter_map(|(k, img)| { - // (GPU frames have no CPU pixels to write) - if img.is_gpu() { - return None; - } + .filter_map(|(k, c)| { + // (GPU frames have no CPU pixels to write; a frame that can't name its files + // can't be checked when read) + let files = c.files.as_ref().filter(|f| f.is_complete() && !c.img.is_gpu())?; + // (only with the generation its render started at, for this cache: a frame + // made of a file Reload Footage dropped since holds the pixels from before) + let since = c.since.as_ref().and_then(|s| s.for_cache(&dc)).filter(|s| !dc.dropped_since(files, *s))?; let dk = disk_cache::frame_key(content, k.frame, k.scale, k.view ^ oh); let tries = written.1.get(&dk).copied().unwrap_or(0); - (tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)).then(|| (dk, img.clone())) + (tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)) + .then(|| (dk, c.img.clone(), files.clone(), since)) }) .collect(); (missing, !queued.is_empty()) @@ -1036,19 +1236,12 @@ impl Frames { if missing.is_empty() { return converting; } - let (persisted, written) = (self.persisted.clone(), self.written.clone()); + let (persisted, written, virtual_id) = (self.persisted.clone(), self.written.clone(), self.virtual_id.clone()); let work = move || { - for (dk, img) in missing { + for (dk, img, files, since) in missing { // (accepted: the disk cache tracks it until written, and it counts as a try; // refused by a full queue: retried, without counting) - let accepted = match &img { - FrameImage::Cpu(img) => { - let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba) - } - FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits()), - FrameImage::Gpu(_) => false, - }; + let accepted = put_disk_frame(&dc, dk, &img, &files, virtual_id.as_deref(), since); if accepted && let Ok(mut w) = written.lock() { let n = w.1.entry(dk).or_default(); *n = n.saturating_add(1); @@ -1091,7 +1284,6 @@ impl Frames { return; } let (carries, scheduled, series, ctx) = (self.disk_carries.clone(), self.carry_scheduled.clone(), self.disk_series.clone(), self.ctx.clone()); - let virtual_id = self.virtual_id.clone(); let work = move || { let mut linked = false; loop { @@ -1111,7 +1303,7 @@ impl Frames { let Some(series) = series.lock().ok().and_then(|m| m.get(&c.comp).cloned()) else { return false }; let comp = ItemId(c.comp); let n = c.to.comp(comp).map_or(0, |k| k.frame_rate.frame_at(k.duration)).clamp(0, 100_000); - let key = |p: &Project| disk_cache::comp_content_key_with(p, comp, virtual_id.as_deref()); + let key = |p: &Project| disk_cache::comp_content_key(p, comp); let (a, b) = (key(&c.from), key(&c.to)); if a == b { return false; @@ -1137,6 +1329,7 @@ impl Frames { } } + /// Queues a background hash of `comp` at `revision`, coalescing rapid edits so only a bounded number of snapshots are kept. #[cfg(not(target_arch = "wasm32"))] fn request_content_key(&self, project: Arc, revision: u64, comp: u64) { let mut jobs = self.content_key_jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1153,7 +1346,6 @@ impl Frames { jobs.running = true; drop(jobs); let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone()); - let (epoch, id) = (self.virtual_epoch.clone(), self.virtual_id.clone()); let work = move || loop { let next = { let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1164,9 +1356,7 @@ impl Frames { next }; let Some((project, revision, comp)) = next else { return }; - let epoch = epoch.load(Ordering::Relaxed); - let id = id.clone(); - if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp, epoch, id.as_deref()))).is_err() { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() { log::error!("could not compute a preview disk-cache key; the cache indicator will retry"); } if let Some(ctx) = &ctx { @@ -1192,6 +1382,7 @@ impl Frames { self.cache.lock().map(|c| c.budget).unwrap_or(0) } + /// Drops every cached frame and purges the remote server, if there is one. pub fn clear(&self) { if let Some(r) = &self.remote { r.purge(); @@ -1205,9 +1396,8 @@ impl Frames { c.gpu_pressure = None; c.gpu_adjusted = None; } - // Disk keys hash the footage files' size and time, but are remembered per revision; a - // file reloaded from disk without a new revision (a re-rendered image sequence) must be - // keyed again, or the disk cache serves the frames from before. + // (the disk keys of a revision don't depend on files: a file changed on disk is caught + // when its frames' entries are read; they are forgotten with the frames all the same) if let Ok(mut k) = self.content_keys.lock() { k.clear(); } @@ -1226,6 +1416,8 @@ impl Frames { #[cfg(not(target_arch = "wasm32"))] let moved = q.playhead.is_some() != playhead.is_some(); q.playhead = playhead; + // (called once per frame of the window while a preview plays) + let more_gpu = q.ui_frame(playhead.map(|_| web_time::Instant::now())); if let Some(p) = playhead && let Ok(mut inf) = self.inflight.lock() { @@ -1237,6 +1429,13 @@ impl Frames { !gone }); } + // A lane more may composite: one that stopped at the limit starts again. + #[cfg(not(target_arch = "wasm32"))] + if more_gpu && !moved { + self.start_lanes(&mut q, 1); + } + #[cfg(target_arch = "wasm32")] + let _ = more_gpu; #[cfg(not(target_arch = "wasm32"))] if moved { self.start_lanes(&mut q, usize::MAX); @@ -1386,6 +1585,7 @@ impl Frames { } } + /// A render worker sharing this cache's queue, cache and in-flight set. fn worker(&self) -> Worker { Worker { queue: self.queue.clone(), @@ -1394,20 +1594,15 @@ impl Frames { ctx: self.ctx.clone(), last: self.last_ms.clone(), content_keys: self.content_keys.clone(), - virtual_epoch: self.virtual_epoch.clone(), virtual_id: self.virtual_id.clone(), gpu_retired: self.gpu_retired.clone(), } } - /// Browser file table: `epoch` bumps on each replacement, and `id` is the size and content - /// hash used when a content key is rebuilt. Neither runs on a cache hit. - pub fn set_virtual_media(&mut self, epoch: Arc, id: Arc Option<(u64, u64)> + Send + Sync>) { - self.virtual_epoch = epoch; + /// Browser file table: `id` gives the size and content hash of a path served from memory, + /// stored with the disk entries of the frames that read it and checked when they are read. + pub fn set_virtual_id(&mut self, id: Arc Option<(u64, u64)> + Send + Sync>) { self.virtual_id = Some(id); - if let Ok(mut keys) = self.content_keys.lock() { - keys.clear(); - } } /// Render frames with `remote` from now on (`None`: on this thread again). @@ -1465,8 +1660,10 @@ impl Frames { let gpu_here = job.src.gpu.is_some() && job.src.gpu_display && !(remote.gpu() && comp_has_effects(&job.src.project, job.comp)); if gpu_here { let t0 = web_time::Instant::now(); - if let Some(img) = w.render_gpu(&job) { - w.finish(&job, img, t0); + // (A frame kept on the GPU is not written to the disk cache: its files are not + // needed.) + if let Some((img, _files)) = w.render_gpu(&job) { + w.finish(&job, img, None, None, t0); continue; } } @@ -1478,8 +1675,7 @@ impl Frames { let fw = self.worker(); let busy = self.remote_busy.clone(); let t0 = web_time::Instant::now(); - let epoch = w.virtual_epoch.load(Ordering::Relaxed); - let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts), epoch, w.virtual_id.as_deref())); + let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts))); let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key }; remote.start( rj, @@ -1490,7 +1686,7 @@ impl Frames { match r { Ok(f) => { let img = egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba); - fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), t0); + fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), None, None, t0); } Err(e) => { // Not rendered (a stale revision, a lost worker): the viewer asks again. @@ -1536,7 +1732,6 @@ struct Worker { ctx: Option, last: Arc>, content_keys: ContentKeys, - virtual_epoch: Arc, virtual_id: Option Option<(u64, u64)> + Send + Sync>>, gpu_retired: Arc, } @@ -1545,19 +1740,44 @@ struct Worker { thread_local! { /// This pool thread is inside [`Worker::drain`] (rendering a frame). static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Only a lane's drain thread may wait for GPU admission (see [`GpuRenderSlots::for_render`]). + static DRAINING_LANE: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Clears [`DRAINING`] when the drain ends, even by a panic. #[cfg(not(target_arch = "wasm32"))] struct Draining; +#[cfg(not(target_arch = "wasm32"))] +impl Draining { + fn enter(lane: Option) -> Option { + if DRAINING.replace(true) { + return None; + } + DRAINING_LANE.set(lane.is_some()); + Some(Self) + } +} + #[cfg(not(target_arch = "wasm32"))] impl Drop for Draining { fn drop(&mut self) { + DRAINING_LANE.set(false); DRAINING.set(false); } } +/// A lane's hold on one of the [`Queue::gpu_limit`] GPU slots (see [`Queue::gpu_lanes`]), given back when dropped, +/// even by a panic. +struct GpuSlot<'a>(&'a Mutex); + +impl Drop for GpuSlot<'_> { + fn drop(&mut self) { + let mut q = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + q.gpu_lanes = q.gpu_lanes.saturating_sub(1); + } +} + impl Worker { /// Render queued jobs, most important first, until the queue is empty. /// @@ -1571,11 +1791,7 @@ impl Worker { /// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool. #[cfg(not(target_arch = "wasm32"))] fn drain(&self, lane: Option) { - if DRAINING.get() { - return; - } - DRAINING.set(true); - let _reset = Draining; + let Some(_reset) = Draining::enter(lane) else { return }; while self.run_next(lane) {} } @@ -1584,43 +1800,42 @@ impl Worker { /// idle again. fn run_next(&self, lane: Option) -> bool { let Worker { queue, content_keys, .. } = self; - let job = { + // (asked before the queue's lock: it takes the cache's) + let gpu = lane.is_some() && self.gpu_ready(); + let (job, mut slot) = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = q.next(lane.is_some()) else { + let Some(i) = q.next(lane.is_some(), gpu) else { q.idle_lanes.extend(lane); return false; }; let job = q.jobs.swap_remove(i); + let slot = (gpu && q.gpu_job(&job)).then(|| { + q.gpu_lanes += 1; + GpuSlot(queue) + }); q.running.insert(job.key); if job.urgent { q.urgent_running += 1; q.urgent_cancels.push((job.key, job.cancel.clone())); } - job + (job, slot) }; let t0 = web_time::Instant::now(); - let epoch = self.virtual_epoch.load(Ordering::Relaxed); - let disk = job - .src - .disk - .as_ref() - .map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref()))); - // Half-float frames (32 bpc) have entries of their own (their key has the options). - let from_disk = disk.as_ref().and_then(|(dc, k)| { - if job.opts.skip_hdr_step { - let f = dc.get_frame_f16(*k)?; - let encoding = effectcraft_engine::viewer::FrameEncoding::of(&job.src.project.settings, true); - Some(FrameImage::CpuF16(Arc::new(F16Frame::from_bits(f.width, f.height, f.rgba, encoding)))) - } else { - let f = dc.get_frame(*k)?; - Some(FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba)))) - } - }); + // (the drop generation before the frame reads anything: File ▸ Reload Footage meanwhile + // makes what it read from a reloaded file stale) + let disk = + job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)), dc.drop_generation())); + let from_disk = disk.as_ref().and_then(|(dc, k, _)| self.disk_frame(dc, *k, &job)); let disk_hit = from_disk.is_some(); - let ci = match from_disk { - Some(f) => f, + if disk_hit { + // Nothing for the GPU to do: another lane may composite meanwhile. + slot = None; + } + // `files`: the source files the frame was made of (see `render::reads`). + let (ci, files) = match from_disk { + Some(hit) => hit, None => match self.render_guarded(&job).filter(|_| !job.cancel.load(Ordering::Relaxed)) { - Some(ci) => ci, + Some(rendered) => rendered, // Called off (frames it read may be missing) or failed: not cached, and not // "in progress" forever: the viewer asks for it again. None => { @@ -1629,42 +1844,63 @@ impl Worker { } }, }; + let mut files = files; + if let Some((dc, _, since)) = &disk + && dc.dropped_since(&files, *since) + { + // Made of a file as it was before File ▸ Reload Footage dropped it: shown, but its + // list can't vouch for it, so neither this worker nor `persist_missing` stores it. + files.mark_incomplete(); + } + let files = Arc::new(files); // Publish the shared frame and wake the viewer before optional RGBA conversion // and compression. Persistence still runs on this worker, using the same snapshot. - self.finish(&job, ci.clone(), t0); - if !disk_hit && let Some((dc, k)) = &disk { - let written = match &ci { - FrameImage::Cpu(img) => { - let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba); - true - } - FrameImage::CpuF16(f) => { - dc.put_frame_f16(*k, f.width, f.height, f.bits()); - true - } - FrameImage::Gpu(_) => false, - }; - if written && let Some(ctx) = &self.ctx { - ctx.request_repaint(); - } + let since = disk.as_ref().map(|(dc, _, since)| DiskSince::of(dc, *since)); + self.finish(&job, ci.clone(), Some(files.clone()), since, t0); + // The GPU's part is done; storing it on disk is the CPU's. + drop(slot); + if !disk_hit + && let Some((dc, k, since)) = &disk + && put_disk_frame(dc, *k, &ci, &files, self.virtual_id.as_deref(), *since) + && let Some(ctx) = &self.ctx + { + ctx.request_repaint(); } true } - /// Store a finished frame and release its job. - fn finish(&self, job: &Job, img: FrameImage, t0: web_time::Instant) { - self.finish_key(job.key, job.urgent, img, t0); + /// The frame of `job` from the disk cache (key `k`), if its entry is there and the source + /// files it lists are unchanged (checked here, on the frame worker: a few `metadata` calls + /// for that frame's files). An entry whose files changed is deleted: the frame renders again + /// and its new entry replaces it. + fn disk_frame(&self, dc: &DiskCache, k: u128, job: &Job) -> Option<(FrameImage, ReadFiles)> { + let virtual_id = self.virtual_id.as_deref(); + // Half-float frames (32 bpc) have entries of their own (their key has the options). + if job.opts.skip_hdr_step { + let (f, files) = dc.get_frame_f16(k, virtual_id)?; + let encoding = effectcraft_engine::viewer::FrameEncoding::of(&job.src.project.settings, true); + Some((FrameImage::CpuF16(Arc::new(F16Frame::from_bits(f.width, f.height, f.rgba, encoding))), files)) + } else { + let (f, files) = dc.get_frame(k, virtual_id)?; + Some((FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), files)) + } } - fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, t0: web_time::Instant) { + /// Store a finished frame (with the source files it was made of, when known) and release its + /// job. + fn finish(&self, job: &Job, img: FrameImage, files: Option>, since: Option, t0: web_time::Instant) { + self.finish_key(job.key, job.urgent, img, files, since, t0); + } + + /// Stores a finished frame in the cache with its read files, records the render time for urgent jobs, then releases the job. + fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, files: Option>, since: Option, t0: web_time::Instant) { if urgent && let Ok(mut l) = self.last.lock() { *l = t0.elapsed().as_secs_f64() * 1000.0; } if let Ok(mut c) = self.cache.lock() && (!img.is_gpu() || !self.gpu_retired.load(Ordering::Acquire)) { - c.insert(key, img); + c.insert_with(key, img, files, since); } self.release(key, urgent); } @@ -1689,9 +1925,13 @@ impl Worker { } /// [`Worker::render_job`], again on the CPU if it panicked (the panic hook has logged it); - /// `None` if that panicked too. - fn render_guarded(&self, job: &Job) -> Option { - let attempt = |gpu: bool| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu))).ok(); + /// `None` if that panicked too. With the source files the frame was made of. + fn render_guarded(&self, job: &Job) -> Option<(FrameImage, ReadFiles)> { + let attempt = |gpu: bool| { + let files = ReadLog::new(); + let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu, &files))).ok()?; + Some((img, files.into_files())) + }; let img = attempt(true).or_else(|| { log::error!("a frame render panicked (frame {})", job.key.frame); job.src.gpu.as_ref().and_then(|_| attempt(false)) @@ -1721,36 +1961,44 @@ impl Worker { } } + /// Tells the cache that a GPU render succeeded. fn gpu_succeeded(&self) { if let Ok(mut c) = self.cache.lock() { c.gpu_succeeded(); } } + /// Whether GPU rendering is available and has not been retired. fn gpu_ready(&self) -> bool { !self.gpu_retired.load(Ordering::Acquire) && self.cache.lock().is_ok_and(|c| c.gpu_ready()) } - /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone. - fn render_gpu(&self, job: &Job) -> Option { + /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone, with + /// the source files it was made of. + fn render_gpu(&self, job: &Job) -> Option<(FrameImage, ReadFiles)> { if !self.gpu_ready() { return None; } let g = job.src.gpu.as_ref()?; let footage = job.footage(); + let files = ReadLog::new(); let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); r.cache = Some(&job.src.layer_cache); + r.reads = Some(&files); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); r.active_accel()?; - let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); + let f = { + let _slot = GPU_RENDER_SLOTS.try_acquire(MAX_GPU_RENDERS)?; + g.within_memory(|| g.render_display(&r, job.comp, job.t)) + }; effectcraft_engine::render::Accelerator::frame_end(g); match f { Ok(f) => { if f.is_some() { self.gpu_succeeded(); } - f.map(|f| FrameImage::Gpu(Arc::new(f))) + f.map(|f| (FrameImage::Gpu(Arc::new(f)), files.into_files())) } Err(e) => { self.gpu_attempt_failed(g, &e); @@ -1759,11 +2007,13 @@ impl Worker { } } - /// Render a frame; `gpu: false` keeps it off the GPU compositor. - fn render_job(&self, job: &Job, gpu: bool) -> FrameImage { + /// Render a frame; `gpu: false` keeps it off the GPU compositor. The source files it reads + /// are noted in `files` (a GPU attempt that falls back to the CPU notes both renders'). + fn render_job(&self, job: &Job, gpu: bool, files: &ReadLog) -> FrameImage { let footage = job.footage(); let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); + r.reads = Some(files); let gpu_allowed = gpu && self.gpu_ready(); // A failed GPU attempt may have populated intermediate entries before its scope // reported failure. The CPU retry must compute its own pixels from the snapshot. @@ -1779,55 +2029,86 @@ impl Worker { { // The browser cannot wait for the GPU to time it (and its CPU path is slow): always // the GPU there. - let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) }; - if auto.is_none_or(|(p, key)| p.choose(key)) { + // Every exit that doesn't record ends the frame unmeasured: the ticket is dropped + // before a retry (`take`), or on return. + let mut ticket = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true).map(|(p, key)| p.ticket(key)) }; + if ticket.as_ref().is_none_or(AutoTicket::gpu) { let t0 = web_time::Instant::now(); - match g.within_memory(|| g.render_display(&r, job.comp, job.t)) { + match gpu_attempt(g, || g.render_display(&r, job.comp, job.t)) { Ok(Some(f)) => { self.gpu_succeeded(); - if let Some((p, key)) = auto { + if let Some(t) = ticket.take() { // Time the GPU's work, not just its recording. g.wait(); if g.context().check_health().is_err() { - return self.render_job(job, false); + drop(t); + return self.render_job(job, false, files); } - p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0); + t.record(t0.elapsed().as_secs_f64() * 1000.0); } return FrameImage::Gpu(Arc::new(f)); } Ok(None) => { - if let Some((p, key)) = auto { - p.declined(key); + if let Some(t) = &mut ticket { + t.declined(); } } Err(e) => { - self.gpu_attempt_failed(g, &e); - return self.render_job(job, false); + drop(ticket.take()); + self.gpu_attempt_rejected(g, &e); + return self.render_job(job, false, files); } } } let t0 = web_time::Instant::now(); - let img = cpu_frame(&r.comp_frame_cpu(job.comp, job.t), &job.src.project, &job.opts); - if let Some((p, key)) = auto { - p.record(key, false, t0.elapsed().as_secs_f64() * 1000.0); + // CPU compositing in Auto can still run GPU layer effects. Keep those inside the + // same admission and error scope as a GPU-composited frame (#3028). + let img = match gpu_attempt(g, || r.comp_frame_cpu(job.comp, job.t)) { + Ok(img) => cpu_frame(&img, &job.src.project, &job.opts), + Err(e) => { + drop(ticket.take()); + self.gpu_attempt_rejected(g, &e); + return self.render_job(job, false, files); + } + }; + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1000.0); } return img; } if let Some(g) = gpu { // The GPU composites and the frame is read back. - return match g.within_memory(|| r.comp_frame(job.comp, job.t)) { + return match r.comp_frame_scoped(job.comp, job.t, |a| gpu_attempt(g, || a.comp_frame(&r, job.comp, job.t))) { Ok(img) => { self.gpu_succeeded(); cpu_frame(&img, &job.src.project, &job.opts) } Err(e) => { - self.gpu_attempt_failed(g, &e); - self.render_job(job, false) + self.gpu_attempt_rejected(g, &e); + self.render_job(job, false, files) } }; } cpu_frame(&r.comp_frame(job.comp, job.t), &job.src.project, &job.opts) } + /// Busy shared-pool workers retry on the CPU without treating contention as a GPU fault. + fn gpu_attempt_rejected(&self, gpu: &Gpu, error: &GpuAttemptError) { + if let GpuAttemptError::Failed(error) = error { + self.gpu_attempt_failed(gpu, error); + } + } +} + +enum GpuAttemptError { + Busy, + Failed(FrameError), +} + +/// Bound all preview GPU attempts, including effects in an Auto CPU composite. The permit +/// ends before the caller retries on the CPU, so failed frames cannot block GPU admissions. +fn gpu_attempt(gpu: &Gpu, render: impl FnOnce() -> T) -> Result { + let _slot = GPU_RENDER_SLOTS.for_render(MAX_GPU_RENDERS).ok_or(GpuAttemptError::Busy)?; + gpu.within_memory(render).map_err(GpuAttemptError::Failed) } /// Who asked for a frame (see [`Frames::request_with`]). @@ -1880,6 +2161,24 @@ struct Queue { /// caching left the same gaps, and held up the viewer's frame when it shared a piece. lanes: usize, idle_lanes: Vec, + /// Lanes compositing a frame on the GPU, at most [`Queue::gpu_limit`]. The GPU is the + /// viewer's device: each lane records, submits and reads back a whole frame on it, and the + /// window can't draw until that work is done. With a lane per core on a GPU that can't keep + /// up, the window froze for hundreds of milliseconds at a time while a preview played, and + /// the preview jumped ahead when it came back (#3345). The other lanes go on with the frames + /// that don't need the GPU (the disk cache's, or all of them while the GPU is out of memory + /// or retired); the pool and the viewer's frame are not counted. + gpu_lanes: usize, + /// How many lanes may composite on the GPU at a time (at least 1, see [`Queue::ui_frame`]). + gpu_limit: usize, + /// The limit that last stalled the window: tried again only after [`GPU_LANE_RETRY`]. + gpu_stalled_at: usize, + /// While a preview plays: the window's last frame, and since when it has been smooth. + ui_last: Option, + ui_smooth_since: Option, + /// Tests: as if there were a GPU compositor (no adapter is there). + #[cfg(test)] + fake_gpu: bool, } impl Queue { @@ -1888,9 +2187,63 @@ impl Queue { self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some()) } - /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. - fn next(&self, lane: bool) -> Option { - self.best_of(|j| self.on_lane(j) == lane) + /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. `gpu`: + /// the GPU composites (a lane's frames), so a lane takes no GPU frame while + /// [`Queue::gpu_limit`] lanes are compositing. + fn next(&self, lane: bool, gpu: bool) -> Option { + let gpu_full = gpu && self.gpu_lanes >= self.gpu_limit.max(1); + self.best_of(|j| self.on_lane(j) == lane && !(gpu_full && self.gpu_job(j))) + } + + /// The window drew a frame at `now` (`None`: no preview plays). A window that stalls with + /// lanes compositing gets fewer of them: the GPU can't keep up with them and the window. One + /// that stays smooth while every allowed lane composites gets one more, up to all of them: a + /// GPU that keeps up renders the preview on every lane. A limit that stalled is tried again + /// only after a longer smooth spell, so the window doesn't stall over and over. `true` when + /// one more lane may composite. + fn ui_frame(&mut self, now: Option) -> bool { + let Some(now) = now else { + self.ui_last = None; + return false; + }; + let limit = self.gpu_limit.max(1); + let gap = self.ui_last.map(|last| now.saturating_duration_since(last)); + self.ui_last = Some(now); + let Some(gap) = gap else { + self.ui_smooth_since = Some(now); + return false; + }; + if gap > UI_STALL && self.gpu_lanes > 0 { + self.gpu_stalled_at = limit; + self.gpu_limit = (limit / 2).max(1); + self.ui_smooth_since = Some(now); + } else if gap > UI_SMOOTH { + self.ui_smooth_since = Some(now); + } else { + let since = *self.ui_smooth_since.get_or_insert(now); + let wait = if limit + 1 >= self.gpu_stalled_at && self.gpu_stalled_at > 0 { GPU_LANE_RETRY } else { GPU_LANE_STEP }; + if self.gpu_lanes >= limit && limit < self.lanes && now.saturating_duration_since(since) >= wait { + self.gpu_limit = limit + 1; + self.ui_smooth_since = Some(now); + return true; + } + } + false + } + + /// Whether `j` is composited on the GPU when the GPU is ready: there is a GPU compositor and + /// the frame's renderer uses it (as `Renderer::active_accel` decides: Software Only keeps + /// every frame on the CPU). + fn gpu_job(&self, j: &Job) -> bool { + let adapter = j.src.gpu.is_some(); + #[cfg(test)] + let adapter = adapter || self.fake_gpu; + adapter + && match j.opts.backend { + effectcraft_engine::render::Backend::Cpu => false, + effectcraft_engine::render::Backend::Gpu => true, + effectcraft_engine::render::Backend::Auto => j.src.project.settings.gpu_acceleration, + } } /// The job that matters most: urgent first (newest), then, while a preview plays, the diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index df7b3900c..45398de21 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1,4 +1,5 @@ use super::*; +use effectcraft_engine::project::LayerSource; #[test] fn render_scales_are_the_factors_the_thousandths_stand_for() { @@ -60,7 +61,7 @@ fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, Rende disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts)))); let mut frames = Frames::default(); frames.set_remote(Some(disk.clone())); - frames.content_keys.lock().unwrap().insert((base.revision, base.comp, 0), content); + frames.content_keys.lock().unwrap().insert((base.revision, base.comp), content); let source = RenderSource { project: session.project.clone(), footage: session.footage.clone(), @@ -126,7 +127,7 @@ fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() { assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); release.send(()).unwrap(); let until = Instant::now() + Duration::from_secs(3); - while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) { + while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) { assert!(Instant::now() < until); std::thread::sleep(Duration::from_millis(1)); } @@ -153,7 +154,7 @@ fn disk_indicator_range_tracks_live_membership() { let mut revised = base; revised.revision += 1; - frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp, 0), 5678); + frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp), 5678); assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty()); let changed = FrameKey { scale: base.scale + 1, ..base }; @@ -211,7 +212,7 @@ fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live( let next = key(1, 1, 0); frames.inflight.lock().unwrap().insert(next); frames.queue.lock().unwrap().running.insert(next); - worker.finish_key(next, false, img(), web_time::Instant::now()); + worker.finish_key(next, false, img(), None, None, web_time::Instant::now()); assert!(frames.is_cached(&next), "CPU retry can still publish"); assert_eq!(frames.inflight(), 0); assert!(frames.queue.lock().unwrap().running.is_empty()); @@ -609,15 +610,21 @@ struct ParallelClip { started: std::sync::atomic::AtomicUsize, /// Frames started by the time frame 1 was done. started_at_first: std::sync::atomic::AtomicUsize, + /// Frames decoding now, and the most at a time. + active: std::sync::atomic::AtomicUsize, + most_active: std::sync::atomic::AtomicUsize, } impl FootageSource for ParallelClip { fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option> { self.started.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.most_active.fetch_max(active, Ordering::SeqCst); (0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)))); if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 { self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst); } + self.active.fetch_sub(1, Ordering::SeqCst); Some(Arc::new(effectcraft_engine::render::Image::new(4, 4))) } } @@ -728,6 +735,166 @@ fn queued_frames_render_when_a_preview_starts_or_stops() { assert_eq!(f.cached_frames(&series).len(), 119); } +/// Waits for the lanes to be idle (after their last frame they give back their GPU slot) and +/// checks every GPU slot was given back. +fn wait_for_idle_lanes(f: &Frames) { + for _ in 0..2000 { + let q = f.queue.lock().unwrap(); + if q.idle_lanes.len() == f.lanes.len() { + break; + } + drop(q); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let q = f.queue.lock().unwrap(); + assert_eq!(q.idle_lanes.len(), f.lanes.len(), "every lane is idle again"); + assert_eq!(q.gpu_lanes, 0, "every lane gave its GPU slot back"); +} + +/// Plays a preview of `frames` frames of a [`parallel_clip`] as the viewer renders them (the +/// project's renderer: `backend` Auto) or with `backend`, as if there were a GPU compositor +/// (`gpu`), `retired` or with the project on Mercury Software Only (`!gpu_acceleration`), and +/// waits for them: the frame cache and the most frames that rendered at a time. +fn play_preview(gpu: bool, retired: bool, backend: effectcraft_engine::render::Backend, gpu_acceleration: bool) -> (Frames, usize) { + use effectcraft_engine::time::FrameRate; + let (f, footage, mut src, cid, series) = parallel_clip(); + Arc::make_mut(&mut src.project).settings.gpu_acceleration = gpu_acceleration; + f.queue.lock().unwrap().fake_gpu = gpu; + if retired { + f.retire_gpu(); + } + let opts = RenderOpts { backend, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + let frames = 24; + for frame in 1..=frames { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), frames as usize); + wait_for_idle_lanes(&f); + let most = footage.most_active.load(Ordering::SeqCst); + (f, most) +} + +/// Every lane composited a playing preview's frame on the GPU at the same time, on the device +/// the window draws with: the window froze for hundreds of milliseconds and the preview jumped +/// ahead (#3345). A preview starts on one GPU lane (see [`Queue::ui_frame`]). +#[test] +fn a_preview_composites_on_a_few_lanes_at_a_time() { + use effectcraft_engine::render::Backend; + for backend in [Backend::Auto, Backend::Gpu] { + let (_, most) = play_preview(true, false, backend, true); + assert!(most <= 1, "{most} lanes composited on the GPU at once ({backend:?})"); + } +} + +/// Frames that render on the CPU use every lane: with no GPU compositor, with the GPU retired, +/// and with Mercury Software Only (the project's renderer, or the CPU backend). +#[test] +fn frames_on_the_cpu_render_on_every_lane() { + use effectcraft_engine::render::Backend; + for (gpu, retired, backend, gpu_acceleration) in + [(false, false, Backend::Auto, true), (true, true, Backend::Auto, true), (true, false, Backend::Auto, false), (true, false, Backend::Cpu, true)] + { + let (f, most) = play_preview(gpu, retired, backend, gpu_acceleration); + if f.lanes.len() > 2 { + assert!(most > 2, "frames on the CPU rendered on {most} lanes at a time ({gpu} {retired} {backend:?} {gpu_acceleration})"); + } + } +} + +/// A queue of `lanes` lanes, `busy` of them compositing on the GPU. +fn gpu_queue(lanes: usize, busy: usize) -> Queue { + Queue { lanes, gpu_lanes: busy, gpu_limit: 1, ..Default::default() } +} + +/// Window frames every `gap` ms from `t` for `ms` ms; the time after the last. +fn ui_frames(q: &mut Queue, t: web_time::Instant, gap: u64, ms: u64) -> web_time::Instant { + let mut t = t; + for _ in 0..ms / gap { + t += std::time::Duration::from_millis(gap); + q.ui_frame(Some(t)); + } + t +} + +/// A GPU that keeps up with the window gets every lane, one more each half second the window +/// stays smooth with every allowed lane compositing. +#[test] +fn a_smooth_window_gives_the_gpu_every_lane() { + let mut q = gpu_queue(8, 0); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + // Nothing compositing: the limit isn't what holds the preview back. + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 1); + let mut t = t; + for _ in 0..20 { + q.gpu_lanes = q.gpu_limit; + t = ui_frames(&mut q, t, 16, 520); + } + assert_eq!(q.gpu_limit, 8, "no more than the lanes"); +} + +/// A window that stalls while lanes composite halves the limit, and the limit that stalled is +/// tried again only after a long smooth spell; a window that is only slow keeps the limit. +#[test] +fn a_stalling_window_takes_lanes_from_the_gpu() { + let mut q = gpu_queue(8, 4); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + let t = ui_frames(&mut q, t, 300, 300); + assert_eq!((q.gpu_limit, q.gpu_stalled_at), (2, 4)); + let t = ui_frames(&mut q, t, 80, 5000); + assert_eq!(q.gpu_limit, 2, "a slow (not stalling) window keeps the limit"); + q.gpu_lanes = 2; + let t = ui_frames(&mut q, t, 16, 600); + assert_eq!(q.gpu_limit, 3); + q.gpu_lanes = 3; + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 3, "4 stalled: it waits longer"); + ui_frames(&mut q, t, 16, 10_000); + assert_eq!(q.gpu_limit, 4); + // A stall with nothing compositing isn't the GPU's. + let mut q = gpu_queue(8, 0); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 500, 500); + assert_eq!(q.gpu_limit, 4); + // Never below one lane; a stopped preview's pause is no stall. + let mut q = gpu_queue(8, 1); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 400, 400); + assert_eq!(q.gpu_limit, 1); + q.gpu_limit = 2; + q.ui_frame(None); + q.ui_frame(Some(t + std::time::Duration::from_secs(5))); + assert_eq!(q.gpu_limit, 2); +} + +/// A preview that stops while lanes composite: their frames finish, the queued ones move to the +/// pool and render, the viewer's frame is not held up, and every GPU slot is given back. +#[test] +fn a_preview_stopping_mid_render_gives_the_gpu_slots_back() { + use effectcraft_engine::time::FrameRate; + let (f, _, src, cid, series) = parallel_clip(); + f.queue.lock().unwrap().fake_gpu = true; + let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Auto, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + for frame in 1..=24 { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + f.set_playhead(None); + f.request_urgent(&src, FrameKey { frame: 100, ..series }, cid, FrameRate::FPS_30.tick_of(100), opts); + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), 25); + wait_for_idle_lanes(&f); +} + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back /// at went before the ones of the abandoned zoom. The other size's frames go first, then the @@ -996,7 +1163,7 @@ fn the_frames_an_edit_keeps_match_a_fresh_render() { // The work area is a preview and render range: setting it (B, N) keeps every frame, on // disk too. let n = s.project.comp(comp).map(|c| c.frame_rate.frame_at(c.duration)).unwrap(); - let disk = |s: &Session| effectcraft_engine::render::disk_cache::comp_content_key_with(&s.project, comp, None); + let disk = |s: &Session| effectcraft_engine::render::disk_cache::comp_content_key(&s.project, comp); let d0 = disk(&s); let work_area = |start: f64, end: f64| json!({"cmd": "comp.workArea", "params": {"comp": comp.0, "start": start, "end": end}}); assert_eq!(kept(&mut s, comp, work_area(1.0, 4.0)), (0..n).collect::>()); @@ -1593,6 +1760,71 @@ fn half_float_ram_frames_missing_on_disk_are_written() { ram_frames_missing_on_disk_are_written_as(true); } +/// A RAM frame whose render started before Reload Footage dropped one of its files (#3184) +/// holds the pixels from before: writing it later to the disk cache (an idle pass after the +/// drop) would bring them back, also after a restart, since the file kept its size and time. It +/// is written only with the drop generation its render started at, for the cache it started +/// with: so it is not written, nor is a frame rendered without that cache. Frames of other files, +/// and frames rendered after the drop, are written. +#[test] +fn ram_frames_from_before_a_drop_are_not_written() { + use effectcraft_engine::Session; + use effectcraft_engine::render::reads::{FileStamp, ReadFile}; + use serde_json::json; + let dir = tempfile::tempdir().unwrap(); + let disk = DiskCache::open(dir.path().join("cache"), 1 << 30).unwrap(); + let other = DiskCache::open(dir.path().join("other"), 1 << 30).unwrap(); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let f = Frames::default(); + let series = FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()), ..key(0, 0, 0) }; + let read = |p: &str| Some(Arc::new([ReadFile::new(p, FileStamp::Native { len: 3, modified: Some(1) })].into_iter().collect::())); + let before = DiskSince::of(&disk, disk.drop_generation()); + { + let mut c = f.cache.lock().unwrap(); + c.insert_with(FrameKey { frame: 0, ..series }, img(), read("/a.png"), Some(before.clone())); + c.insert_with(FrameKey { frame: 1, ..series }, img(), read("/b.png"), Some(before)); + c.insert_with(FrameKey { frame: 2, ..series }, img(), read("/a.png"), None); + c.insert_with(FrameKey { frame: 3, ..series }, img(), read("/a.png"), Some(DiskSince::of(&other, other.drop_generation()))); + } + // Reload Footage of a.png. + disk.drop_files(["/a.png"]); + disk.flush(); + f.cache.lock().unwrap().insert_with(FrameKey { frame: 4, ..series }, img(), read("/a.png"), Some(DiskSince::of(&disk, disk.drop_generation()))); + let on_disk = |fr: i64| { + disk.contains( + disk_cache::Kind::Frame, + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), + ) + }; + // Idle passes until there is nothing left to write. + let mut done = false; + for _ in 0..1000 { + let more = f.persist_missing(&src, &series, &RenderOpts::default()); + disk.flush(); + if !more && f.persisted.lock().unwrap().is_empty() { + done = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(done, "the idle passes end"); + assert!(on_disk(1) && on_disk(4), "another file's frame, and a frame rendered after the drop"); + assert!(!on_disk(0), "made of a.png as it was before the drop"); + assert!(!on_disk(2) && !on_disk(3), "rendered without this disk cache"); + assert_eq!(disk.stats().entries, 2); +} + fn ram_frames_missing_on_disk_are_written_as(half: bool) { use effectcraft_engine::Session; use serde_json::json; @@ -1616,19 +1848,12 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) }; for fr in 0..10 { let frame = if half { FrameImage::CpuF16(Arc::new(F16Frame::from_image(&effectcraft_engine::render::Image::new(8, 8), SRGB))) } else { img() }; - f.cache.lock().unwrap().insert(FrameKey { frame: fr, ..series }, frame); + f.cache.lock().unwrap().insert_with(FrameKey { frame: fr, ..series }, frame, Some(Arc::default()), Some(DiskSince::of(&disk, disk.drop_generation()))); } let on_disk = |fr: i64| { disk.contains( disk_cache::Kind::Frame, - frame_disk_key( - &f.content_keys, - &src.project, - &FrameKey { frame: fr, ..series }, - opts_hash(&RenderOpts::default()), - f.virtual_epoch.load(Ordering::Relaxed), - None, - ), + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), ) }; let persist = || { @@ -1674,6 +1899,71 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { let _ = std::fs::remove_dir_all(&dir); } +/// While free disk space holds the disk cache at 0, frames are not converted or compressed for +/// it (the frame workers spent about 8 ms per frame on writes that were then refused), and idle +/// passes don't keep converting the RAM frames; with room again they are written. +#[test] +fn no_frame_is_encoded_while_the_disk_has_no_room() { + use effectcraft_engine::Session; + use serde_json::json; + const GB: u64 = 1 << 30; + let dir = std::env::temp_dir().join(format!("ec-persist-full-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 20 * GB).unwrap(); + // 4 TB disk, 1 GB free: under the 10 GB reserve. + disk.set_disk_space(Some((4096 * GB, GB))); + assert!(!disk.accepts_writes()); + // The frame worker's write: refused up front. + assert!(!put_disk_frame(&disk, 1, &img(), &ReadFiles::default(), None, disk.drop_generation())); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let f = Frames::default(); + let series = + FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) }; + for fr in 0..10 { + f.cache.lock().unwrap().insert_with(FrameKey { frame: fr, ..series }, img(), Some(Arc::default()), Some(DiskSince::of(&disk, disk.drop_generation()))); + } + // Idle passes: nothing to do, nothing converted. + for _ in 0..20 { + assert!(!f.persist_missing(&src, &series, &RenderOpts::default()), "no idle passes while there is no room"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + disk.flush(); + assert!(f.persisted.lock().unwrap().is_empty()); + assert_eq!((disk.encodes(), disk.stats().entries), (0, 0)); + // Room again: the RAM frames are written. + disk.set_disk_space(Some((4096 * GB, 500 * GB))); + let on_disk = |fr: i64| { + disk.contains( + disk_cache::Kind::Frame, + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), + ) + }; + let mut written = false; + for _ in 0..1000 { + f.persist_missing(&src, &series, &RenderOpts::default()); + disk.flush(); + if (0..10).all(on_disk) && f.persisted.lock().unwrap().is_empty() { + written = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(written, "every RAM frame written once there is room"); + assert_eq!(disk.encodes(), 10); + let _ = std::fs::remove_dir_all(&dir); +} + /// Audio changes pixels only where something draws it: a clip's Audio Levels, audio effects and /// Audio switch keep every frame, in RAM and on disk, until a waveform picks the clip (on another /// layer, on itself, or through a precomp of it); then the frames of the reader render again. @@ -1681,7 +1971,7 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { fn audio_edits_keep_the_frames_until_something_draws_the_audio() { use effectcraft_engine::Session; use effectcraft_engine::project::{Footage, GroupKind, LayerId, Value, build}; - use effectcraft_engine::render::disk_cache::comp_content_key_with; + use effectcraft_engine::render::disk_cache::comp_content_key; use serde_json::json; let mut s = Session::default(); let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); @@ -1736,7 +2026,7 @@ fn audio_edits_keep_the_frames_until_something_draws_the_audio() { let mut p1 = p0.clone(); edit(Arc::make_mut(&mut p1)); let id1 = f.content_of(&p1, 2, view); - (f.cached_frames(&k(id1, 0)), comp_content_key_with(&p0, view, None) == comp_content_key_with(&p1, view, None)) + (f.cached_frames(&k(id1, 0)), comp_content_key(&p0, view) == comp_content_key(&p1, view)) }; let all: Vec = (0..120).collect(); let edits: [(&str, &dyn Fn(&mut Project)); 3] = [("levels", &levels), ("audio effect", &backwards), ("audio switch", &mute)]; @@ -1933,9 +2223,9 @@ fn disk_frames_outlive_an_edit_that_cant_change_them() { disk: Some(disk.clone()), }; // Every frame of `p0` on disk, none in RAM. - let content0 = disk_cache::comp_content_key_with(&p0, cid, None); + let content0 = disk_cache::comp_content_key(&p0, cid); for f in 0..30 { - disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255]); + disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255], &ReadFiles::default()); } let rename: fn(&mut Project, ItemId, LayerId) = |p, c, l| p.comp_mut(c).unwrap().layer_mut(l).unwrap().name = "Renamed".into(); let trim: fn(&mut Project, ItemId, LayerId) = |p, c, l| { @@ -1982,6 +2272,80 @@ fn disk_frames_outlive_an_edit_that_cant_change_them() { let _ = std::fs::remove_dir_all(&dir); } +/// With expressions the disk key covers what they can read (#3184, #1492): an edit to a comp no +/// expression names keeps every frame on disk under the same key; an edit to the comp one names +/// gives the comp a new key, and the frames outside the layer with that expression are carried to +/// it (the dirty spans of #2795). +#[test] +fn disk_frames_outlive_edits_their_expressions_cant_read() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Expression, LayerId, Value}; + use serde_json::json; + let dir = std::env::temp_dir().join(format!("ec-disk-carry-expr-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 1 << 30).unwrap(); + let mut s = Session::default(); + let comp = |s: &mut Session, name: &str| { + ItemId(s.execute("comp.new", json!({"name": name, "width": 8, "height": 8, "duration": 1, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()) + }; + let solid = |s: &mut Session, c: ItemId| LayerId(s.execute("layer.newSolid", json!({"comp": c.0, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); + let cid = comp(&mut s, "A"); + let (a, b) = (solid(&mut s, cid), solid(&mut s, cid)); + let other = comp(&mut s, "Other"); + let o = solid(&mut s, other); + let third = comp(&mut s, "Third"); + let t = solid(&mut s, third); + let fr = s.project.comp(cid).unwrap().frame_rate; + let mut p0 = (*s.project).clone(); + for (l, i, out) in [(a, 0, 15), (b, 15, 30)] { + let l = p0.comp_mut(cid).unwrap().layer_mut(l).unwrap(); + (l.start_time, l.in_point, l.out_point) = (fr.tick_of(i), fr.tick_of(i), fr.tick_of(out)); + } + p0.comp_mut(cid).unwrap().layer_mut(a).unwrap().props.prop_mut("transform/opacity").unwrap().expr = + Some(Expression { text: "comp(\"Other\").layer(1).transform.opacity".into(), enabled: true }); + let p0 = Arc::new(p0); + let opts = RenderOpts::default(); + let oh = opts_hash(&opts); + let src = |p: &Arc| RenderSource { + project: p.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let content0 = disk_cache::comp_content_key(&p0, cid); + for f in 0..30 { + disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255], &ReadFiles::default()); + } + let opacity = |p: &mut Project, c: ItemId, l: LayerId| { + p.comp_mut(c).unwrap().layer_mut(l).unwrap().props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(40.0) + }; + for (name, edited, layer, same_key, kept) in [("Third", third, t, true, (0..30).collect::>()), ("Other", other, o, false, (17..30).collect())] { + let f = Frames::default(); + let series = |p: &Arc, rev: u64| FrameKey { revision: rev, content: f.content_of(p, rev, cid), comp: cid.0, opts: oh, ..key(0, 0, 0) }; + let blue = |p: &Arc, rev: u64, want: &[i64]| { + let base = series(p, rev); + let mut got = vec![]; + for _ in 0..1000 { + got = f.disk_frames(&src(p), &base, 0..30, &opts); + if got == want { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + got + }; + assert_eq!(blue(&p0, 1, &(0..30).collect::>()), (0..30).collect::>(), "{name}: before"); + let mut p1 = p0.clone(); + opacity(Arc::make_mut(&mut p1), edited, layer); + assert_eq!(disk_cache::comp_content_key(&p1, cid) == content0, same_key, "{name}: the key"); + assert_eq!(blue(&p1, 2, &kept), kept, "{name}: after"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// A keyframe edit re-renders only around the keys it changed: two keys either side (auto-Bezier /// and continuous tangents reach the neighbouring segments), and from the layer's in point when /// the first key changes. A static value or an expression still reaches the whole layer. Every @@ -2041,7 +2405,7 @@ fn a_keyframe_edit_reaches_only_the_keys_around_it() { fn music_volume_keeps_the_frames_with_expressions_that_dont_read_audio() { use effectcraft_engine::Session; use effectcraft_engine::project::{Expression, Footage, FootageKind, LayerId, Value, build}; - use effectcraft_engine::render::disk_cache::comp_content_key_with; + use effectcraft_engine::render::disk_cache::comp_content_key; use serde_json::json; let mut s = Session::default(); let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); @@ -2069,7 +2433,7 @@ fn music_volume_keeps_the_frames_with_expressions_that_dont_read_audio() { let mut p1 = p0.clone(); Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(music_id).unwrap().props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-6.0, -6.0]); let id1 = f.content_of(&p1, 2, cid); - (f.cached_frames(&k(id1, 0)).len(), comp_content_key_with(&p0, cid, None) == comp_content_key_with(&p1, cid, None)) + (f.cached_frames(&k(id1, 0)).len(), comp_content_key(&p0, cid) == comp_content_key(&p1, cid)) }; assert_eq!(kept("wiggle(2, 20)"), (120, true), "a wiggle reads no audio"); let (frames, disk) = kept("value + thisComp.layer(\"Music\")(\"Audio\")(\"Audio Levels\")[0]"); @@ -2449,3 +2813,611 @@ fn a_keyframe_edit_beside_muted_keys_reaches_the_keys_that_play() { assert!(same(i), "kept frame {i} renders differently after the edit"); } } + +/// Disk-cache frame entries that keep the source files they were made of (#3184): a sequence +/// comp in a folder of its own, with a large unused sequence in the project. +mod frame_files { + use super::*; + use effectcraft_engine::project::{Footage, FootageKind, build}; + use effectcraft_engine::time::FrameRate; + use std::path::{Path, PathBuf}; + use std::sync::atomic::AtomicUsize; + + const RATE: FrameRate = FrameRate::FPS_30; + const FRAMES: i64 = 5; + + /// A fresh folder for test `name` (on the build drive, not the system temp folder), deleted + /// when the guard is dropped. + fn folder(name: &str) -> tempfile::TempDir { + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"); + std::fs::create_dir_all(&target).unwrap(); + tempfile::Builder::new().prefix(&format!("test-frame-files-{name}-")).tempdir_in(target).unwrap() + } + + /// A flat grey PNG of `level` at `path`. + fn png(path: &Path, level: u8) -> String { + image::RgbaImage::from_pixel(16, 8, image::Rgba([level, level, level, 255])).save(path).unwrap(); + path.to_string_lossy().to_string() + } + + /// The grey level of sequence frame `i` as first written. + fn level(i: i64) -> u8 { + 20 + i as u8 * 30 + } + + /// A project with a comp showing a sequence of [`FRAMES`] PNGs (in `dir/seq`) and, unused, a + /// sequence of 5000 frames that don't even exist. The files of the shown sequence. + fn project(dir: &Path) -> (Arc, ItemId, Vec) { + let seq_dir = dir.join("seq"); + std::fs::create_dir_all(&seq_dir).unwrap(); + let files: Vec = (0..FRAMES).map(|i| png(&seq_dir.join(format!("f_{i:04}.png")), level(i))).collect(); + let mut seq = Footage { kind: FootageKind::Sequence, sequence: files.clone(), frame_rate: RATE, ..effectcraft_media::probe(&files[0]).unwrap() }; + seq.sync_sequence_duration(); + let unused = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| dir.join("unused").join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: 16, + height: 8, + has_video: true, + ..Default::default() + }; + let mut p = Project::default(); + p.add_item("Unused", effectcraft_engine::color::Label::Aqua, None, ItemKind::Footage(unused)); + let item = p.add_item("Seq", effectcraft_engine::color::Label::Aqua, None, ItemKind::Footage(seq)); + let mut comp = Comp::new(16, 8, RATE, RATE.tick_of(FRAMES)); + comp.layers = vec![build::layer(&mut p, &comp, "Seq", LayerSource::Footage { item }, (16, 8), Some(comp.duration))].into(); + let cid = p.add_item("C", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())); + (Arc::new(p), cid, files) + } + + /// A session over the disk cache in `dir`: a new preview cache, media pool, layer cache and + /// disk cache instance (as after reopening the project), counting the calls of the hook for + /// files served from memory. + struct Session { + frames: Frames, + src: RenderSource, + disk: Arc, + hook_calls: Arc, + /// The session's media pool (`src.footage`): what it decoded and opened. + pool: Arc, + } + + fn session(p: &Arc, dir: &Path) -> Session { + let disk = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + let hook_calls = Arc::new(AtomicUsize::new(0)); + let mut frames = Frames::default(); + let calls = hook_calls.clone(); + frames.set_virtual_id(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + None + })); + let pool = Arc::new(effectcraft_media::MediaPool::new()); + let src = RenderSource { + project: p.clone(), + footage: pool.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + Session { frames, src, disk, hook_calls, pool } + } + + impl Session { + fn key(&self, cid: ItemId, frame: i64) -> FrameKey { + FrameKey { + revision: 1, + content: self.frames.content_of(&self.src.project, 1, cid), + comp: cid.0, + frame, + scale: 1000, + view: 0, + opts: opts_hash(&RenderOpts::default()), + } + } + + /// Show `frames` of `cid` (rendered or read from disk); the grey level of each, once + /// their disk writes are done. + fn show(&self, cid: ItemId, frames: &[i64]) -> Vec { + for &f in frames { + self.frames.request(&self.src, self.key(cid, f), cid, RATE.tick_of(f), RenderOpts::default()); + } + wait_for_renders(&self.frames); + // (the worker queues the write after it has released the frame) + for _ in 0..400 { + self.disk.flush(); + if frames.iter().all(|f| self.disk.contains(disk_cache::Kind::Frame, self.disk_key(cid, *f))) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + frames + .iter() + .map(|f| match self.frames.get(&self.key(cid, *f)) { + Some(FrameImage::Cpu(c)) => c.pixels[0].r(), + _ => panic!("frame {f}: an 8-bit CPU frame"), + }) + .collect() + } + + fn disk_key(&self, cid: ItemId, frame: i64) -> u128 { + frame_disk_key(&self.frames.content_keys, &self.src.project, &self.key(cid, frame), opts_hash(&RenderOpts::default())) + } + + /// (disk hits, disk misses) so far. + fn hits(&self) -> (u64, u64) { + let s = self.disk.stats(); + (s.hits, s.misses) + } + } + + fn close(a: u8, b: u8) -> bool { + a.abs_diff(b) <= 2 + } + + /// Give `path` a modification time `secs` later, contents and size unchanged. + fn touch(path: &str, secs: u64) { + let t = std::fs::metadata(path).unwrap().modified().unwrap() + std::time::Duration::from_secs(secs); + std::fs::File::options().append(true).open(path).unwrap().set_modified(t).unwrap(); + } + + /// Correct: a source file of the sequence changes (new contents and size; or only a new + /// time): exactly the frames that read it render again and their entries are replaced; the + /// others come from disk, also after reopening. + #[test] + fn a_changed_source_file_re_renders_exactly_its_frames() { + let tmp = folder("correct"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let all: Vec = (0..FRAMES).collect(); + let first = session(&p, dir); + let shown = first.show(cid, &all); + assert!(all.iter().all(|f| close(shown[*f as usize], level(*f))), "{shown:?}"); + assert_eq!(first.hits(), (0, FRAMES as u64), "rendered, nothing on disk yet"); + assert_eq!(first.disk.stats().entries, FRAMES as usize, "every frame stored"); + drop(first); + // Frame 2's file gets new pixels (and size), frame 3's only a new time. + png(Path::new(&files[2]), 250); + touch(&files[3], 30); + let second = session(&p, dir); + let shown = second.show(cid, &all); + assert!(close(shown[2], 250), "frame 2 shows its new file: {shown:?}"); + assert!([0, 1, 3, 4].iter().all(|f| close(shown[*f as usize], level(*f))), "{shown:?}"); + assert_eq!(second.hits(), (3, 2), "frames 0, 1 and 4 from disk; 2 and 3 rendered again"); + assert_eq!(second.disk.file_checks(), FRAMES as u64, "one file checked per frame read"); + assert_eq!(second.disk.stats().entries, FRAMES as usize, "the entries of 2 and 3 were replaced"); + drop(second); + // Reopened: every frame from disk, the replaced ones with their new pixels. + let third = session(&p, dir); + let shown = third.show(cid, &all); + assert_eq!(third.hits(), (FRAMES as u64, 0)); + assert!(close(shown[2], 250) && close(shown[3], level(3)), "{shown:?}"); + } + + /// Reopened (a new preview cache, media pool, layer cache and disk cache on the same folder): + /// the frame comes from disk, its one source file is checked (a `metadata` call), and no + /// source file is opened or decoded for it. + #[test] + fn a_reopened_project_serves_its_frame_from_disk_without_opening_a_source_file() { + let tmp = folder("reopen"); + let dir = tmp.path(); + let (p, cid, _) = project(dir); + let first = session(&p, dir); + let shown = first.show(cid, &[1]); + assert!(close(shown[0], level(1)), "{shown:?}"); + assert!(first.pool.stats().misses > 0, "the first session decodes the frame's file: {:?}", first.pool.stats()); + assert!(first.disk.contains(disk_cache::Kind::Frame, first.disk_key(cid, 1))); + drop(first); + let again = session(&p, dir); + let shown = again.show(cid, &[1]); + assert!(close(shown[0], level(1)), "{shown:?}"); + assert_eq!(again.hits(), (1, 0), "served from disk"); + assert_eq!(again.disk.file_checks(), 1, "its one file checked"); + assert_eq!(again.pool.stats(), effectcraft_media::PoolStats::default(), "no source file opened or decoded"); + } + + /// No scan: opening a comp of a project that also holds a large sequence, and showing a + /// frame, looks only at the files that frame reads; the comp's key looks at no file (it + /// stays the same when its files change, and never asks for the identity of a path). + #[test] + fn showing_a_frame_checks_only_the_files_it_reads() { + let tmp = folder("no-scan"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let key = disk_cache::comp_content_key(&p, cid); + png(Path::new(&files[1]), 99); + touch(&files[2], 5); + assert_eq!(disk_cache::comp_content_key(&p, cid), key, "the key looks at no file"); + let first = session(&p, dir); + first.show(cid, &[2]); + drop(first); + let again = session(&p, dir); + // (the indicator's key is computed off the UI thread first) + let mut blue = vec![]; + for _ in 0..400 { + blue = again.frames.disk_frames(&again.src, &again.key(cid, 0), 0..FRAMES, &RenderOpts::default()); + if !blue.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(blue, vec![2], "the blue bar: present entries, no file looked at"); + assert_eq!(again.disk.file_checks(), 0, "the indicator checks no file"); + let shown = again.show(cid, &[2]); + assert!(close(shown[0], level(2)), "{shown:?}"); + assert_eq!(again.hits(), (1, 0)); + assert_eq!(again.disk.file_checks(), 1, "only the file frame 2 read"); + assert_eq!(again.hook_calls.load(Ordering::Relaxed), 0, "no path of the unused sequence was asked about"); + } + + /// The frame entry files under the cache folder. + fn entries(dir: &Path) -> Vec { + let mut out = vec![]; + let mut stack = vec![dir.join("cache")]; + while let Some(d) = stack.pop() { + for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().is_some_and(|x| x == "ecc") && p.to_string_lossy().contains("frames") { + out.push(p); + } + } + } + out + } + + /// Safe: a missing source file, an unreadable one (a folder in its place) and a damaged + /// cache entry never serve the frame from before; each is rendered again and stored anew. + #[test] + fn missing_or_unreadable_files_and_damaged_entries_never_serve_a_stale_frame() { + let tmp = folder("safe"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let first = session(&p, dir); + first.show(cid, &[0]); + assert_eq!(first.disk.stats().entries, 1); + drop(first); + // Missing: not served; the frame is rendered without it. + std::fs::remove_file(&files[0]).unwrap(); + let missing = session(&p, dir); + let shown = missing.show(cid, &[0]); + assert!(!close(shown[0], level(0)), "the frame from before was served: {shown:?}"); + assert_eq!(missing.hits(), (0, 1)); + drop(missing); + // Back, with other pixels: the entry made while it was missing is not served either. + png(Path::new(&files[0]), 180); + let back = session(&p, dir); + let shown = back.show(cid, &[0]); + assert!(close(shown[0], 180), "{shown:?}"); + assert_eq!(back.hits(), (0, 1)); + drop(back); + // Unreadable (a folder in its place). + std::fs::remove_file(&files[0]).unwrap(); + std::fs::create_dir(&files[0]).unwrap(); + let unreadable = session(&p, dir); + let shown = unreadable.show(cid, &[0]); + assert!(!close(shown[0], 180), "the frame from before was served: {shown:?}"); + assert_eq!(unreadable.hits(), (0, 1)); + drop(unreadable); + std::fs::remove_dir(&files[0]).unwrap(); + png(Path::new(&files[0]), 120); + let fresh = session(&p, dir); + assert!(close(fresh.show(cid, &[0])[0], 120)); + drop(fresh); + // A damaged entry: a miss, rendered, stored again, then served. + let found = entries(dir); + assert_eq!(found.len(), 1, "{found:?}"); + let mut bytes = std::fs::read(&found[0]).unwrap(); + let n = bytes.len() / 2; + bytes[n] ^= 0x5a; + std::fs::write(&found[0], bytes).unwrap(); + let damaged = session(&p, dir); + assert!(close(damaged.show(cid, &[0])[0], 120)); + assert_eq!(damaged.hits(), (0, 1)); + drop(damaged); + let healed = session(&p, dir); + assert!(close(healed.show(cid, &[0])[0], 120)); + assert_eq!(healed.hits(), (1, 0), "the rewritten entry is used"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_renders_in_flight_stay_within_the_cap() { + use std::sync::atomic::AtomicUsize; + let slots = GpuRenderSlots::new(); + let now = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..16 { + s.spawn(|| { + let _slot = slots.acquire(2); + let n = now.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(n, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + now.fetch_sub(1, Ordering::SeqCst); + }); + } + }); + assert!(peak.load(Ordering::SeqCst) <= 2); + assert_eq!(*slots.busy.lock().unwrap_or_else(PoisonError::into_inner), 0); +} + +/// #3028: exercise the actual worker routes, not only the admission counter. The Auto CPU +/// compositor still has a live accelerator for its layer effects. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_worker_routes_admit_display_readback_and_auto_effects() { + use effectcraft_engine::render::{Accelerator, Backend}; + let Some(gpu) = Gpu::headless() else { + eprintln!("GPU worker route test skipped: no adapter"); + return; + }; + let (frames, mut src, key, _, _, _) = disk_fixture(&[]); + let mut session = effectcraft_engine::Session::default(); + session.execute("comp.new", serde_json::json!({"width": 16, "height": 16, "duration": 1})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let solid = session.execute("layer.newSolid", serde_json::json!({"color": "#ff0000"})).unwrap()["layer"].clone(); + session.execute("effect.apply", serde_json::json!({"layers": [solid], "effect": "Gaussian Blur"})).unwrap(); + src.project = session.project; + src.gpu = Some(gpu.clone()); + src.gpu_display = true; + let mut job = Job { + key, + src, + comp, + t: Tick::ZERO, + opts: RenderOpts { backend: Backend::Gpu, ..Default::default() }, + urgent: true, + background: false, + seq: 0, + cancel: Arc::default(), + }; + let worker = frames.worker(); + let admissions = || GPU_RENDER_SLOTS.entered.load(Ordering::SeqCst); + // This path is called on the UI thread by dispatch_remote: a full GPU must decline + // immediately so the frame can go to the remote worker and the window keeps painting. + let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let (tx, rx) = std::sync::mpsc::channel(); + let declined = std::thread::scope(|scope| { + scope.spawn(|| tx.send(worker.render_gpu(&job).is_none()).unwrap()); + let result = rx.recv_timeout(std::time::Duration::from_secs(2)); + drop((first, second)); + result + }); + assert_eq!(declined, Ok(true), "UI display must not wait for GPU admission"); + let before = admissions(); + assert!(worker.render_gpu(&job).is_some(), "direct display path renders"); + assert!(admissions() > before, "direct display bypassed GPU admission"); + + let before = admissions(); + assert!(worker.render_job(&job, true, &ReadLog::new()).is_gpu()); + assert!(admissions() > before, "worker display bypassed GPU admission"); + + job.src.gpu_display = false; + job.src.layer_cache.clear(); + let before = admissions(); + assert!(!worker.render_job(&job, true, &ReadLog::new()).is_gpu()); + assert!(admissions() > before, "readback bypassed GPU admission"); + + job.src.gpu_display = true; + job.src.layer_cache.clear(); + job.opts.backend = Backend::Auto; + let renderer = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + let (pick, key) = renderer.frame_auto(&gpu, comp, true).unwrap(); + pick.declined(key); // Select CPU compositing while retaining GPU-capable layer effects. + let before = admissions(); + assert!(!worker.render_job(&job, true, &ReadLog::new()).is_gpu()); + assert!(admissions() > before, "Auto CPU effects bypassed GPU admission"); + + let before = admissions(); + assert!(!worker.render_job(&job, false, &ReadLog::new()).is_gpu()); + assert_eq!(admissions(), before, "CPU retry must not hold a GPU slot"); + assert_eq!(*GPU_RENDER_SLOTS.busy.lock().unwrap(), 0); + + // Saturation in the shared Rayon pool is contention, not a GPU fault. It must finish + // through the fresh CPU route without admitting GPU effects or retiring the device. + let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let before = admissions(); + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let cpu = std::thread::scope(|scope| { + scope.spawn(|| pool.install(|| tx.send(!worker.render_job(&job, true, &ReadLog::new()).is_gpu()).unwrap())); + let result = rx.recv_timeout(std::time::Duration::from_secs(2)); + drop((first, second)); + result + }); + assert_eq!(cpu, Ok(true), "busy pool worker must complete on the CPU"); + assert_eq!(admissions(), before); + assert!(worker.gpu_ready()); + assert!(gpu.context().check_health().is_ok()); + + // The real dedicated-lane drain waits, then caches a GPU frame instead of silently + // switching a healthy preview to CPU rendering while the other lanes are busy. + frames.queue.lock().unwrap().jobs.push(Job { + key: job.key, + src: job.src.clone(), + comp, + t: job.t, + opts: RenderOpts { backend: Backend::Gpu, ..job.opts }, + urgent: false, + background: true, + seq: 1, + cancel: Arc::default(), + }); + let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, finished) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + started.send(()).unwrap(); + worker.drain(Some(0)); + tx.send(()).unwrap(); + }) + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let finished = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, finished) + }); + assert!(early.is_err(), "preview lane must wait for an available GPU permit"); + assert_eq!(finished, Ok(())); + assert!(frames.cache.lock().unwrap().map.get(&job.key).unwrap().img.is_gpu()); + + // A retirement found inside the scope releases the permit before the fresh CPU retry. + gpu.context().device_failure_notifier()("synthetic worker retirement".into()); + let before = admissions(); + assert!(!worker.render_job(&job, true, &ReadLog::new()).is_gpu()); + assert_eq!(admissions(), before + 1, "failed GPU attempt must retry without a second admission"); + assert_eq!(*GPU_RENDER_SLOTS.busy.lock().unwrap(), 0); + gpu.frame_end(); +} + +/// Early returns and unwinding release admission even before another render starts. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_render_slots_return_after_failure_and_unwind() { + let slots = GpuRenderSlots::new(); + let failed = || -> Result<(), ()> { + let _slot = slots.acquire(2); + Err(()) + }; + assert!(failed().is_err()); + assert_eq!(*slots.busy.lock().unwrap(), 0); + let result = std::panic::catch_unwind(|| { + let _slot = slots.acquire(2); + panic!("synthetic render panic"); + }); + assert!(result.is_err()); + assert_eq!(*slots.busy.lock().unwrap(), 0); +} + +/// A Rayon helper must decline a full gate rather than wait for a render that can itself be +/// waiting for that helper. Release the permits on timeout so a regression fails, not hangs. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_render_slots_never_block_rayon_helpers() { + let slots = GpuRenderSlots::new(); + let first = slots.acquire(2); + let second = slots.acquire(2); + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let declined = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + rayon::join(|| {}, || tx.send(slots.for_render(2).is_none()).unwrap()); + }); + }); + let result = rx.recv_timeout(std::time::Duration::from_secs(2)); + drop((first, second)); + result + }); + assert_eq!(declined, Ok(true), "Rayon helpers must not wait on GPU permits"); + assert_eq!(*slots.busy.lock().unwrap(), 0); +} + +/// A lane's drain thread can wait without creating the shared-pool helper dependency cycle, +/// and must keep rendering on a healthy GPU; the lane's other threads ([`LANE_WIDTH`] wide) +/// help with its frame and never wait. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { + let slots = GpuRenderSlots::new(); + let first = slots.acquire(2); + let second = slots.acquire(2); + let pool = rayon::ThreadPoolBuilder::new().num_threads(LANE_WIDTH).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, admitted, helper) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + assert!(Draining::enter(None).is_none(), "a lane cannot start a nested drain"); + assert!(DRAINING_LANE.get(), "nested rejection must not clear the lane marker"); + started.send(()).unwrap(); + tx.send(slots.for_render(2).is_some()).unwrap(); + }); + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + // Another thread of the same lane, while its drain thread waits: it declines. + let (htx, hrx) = std::sync::mpsc::channel(); + let (pool, slots) = (&pool, &slots); + scope.spawn(move || pool.install(|| htx.send(slots.for_render(2).is_none()).unwrap())); + let helper = hrx.recv_timeout(std::time::Duration::from_secs(2)); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let admitted = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, admitted, helper) + }); + assert!(early.is_err(), "busy dedicated lane must wait instead of falling back to CPU"); + assert_eq!(admitted, Ok(true)); + assert_eq!(helper, Ok(true), "a lane's helper thread must not wait on GPU permits"); + pool.install(|| { + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + let panic = std::panic::catch_unwind(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + panic!("synthetic lane failure"); + }); + assert!(panic.is_err()); + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + }); +} + +#[test] +fn effect_detection_answers_each_comp_once() { + // Sixteen levels, each comp holding eight layers of the one below and no effects: 8^16 walks + // without a memo. Answered at once. + use effectcraft_engine::project::{Comp, ItemKind, LayerSource, build}; + use effectcraft_engine::time::{FrameRate, Tick}; + let mut p = Project::default(); + let base = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + let mut below = p.add_item("C", effectcraft_engine::color::Label::Sandstone, None, ItemKind::Comp(base.clone().into())); + for _ in 0..16 { + let layers: Vec<_> = (0..8).map(|_| build::layer(&mut p, &base, "a", LayerSource::Comp { item: below }, (0, 0), None)).collect(); + let mut c = base.clone(); + c.layers = layers.into(); + below = p.add_item("C", effectcraft_engine::color::Label::Sandstone, None, ItemKind::Comp(c.into())); + } + let start = std::time::Instant::now(); + assert!(!comp_has_effects(&p, below)); + assert!(start.elapsed() < std::time::Duration::from_secs(5)); +} + +#[test] +fn an_effect_found_by_a_short_route_is_not_hidden_by_a_deep_one() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let comp = |s: &mut Session| ItemId(id(s.execute("comp.new", json!({"name": "C", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); + let nest = |s: &mut Session, outer: ItemId, inner: ItemId| { + s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0, "time": 0.0})).unwrap(); + }; + // `fx` holds a layer with an effect; seventeen comps stack above it, one level too many to be found. + let fx = comp(&mut s); + let l = id(s.execute("layer.newSolid", json!({"comp": fx.0, "color": "#ff0000"})).unwrap(), "layer"); + s.execute("effect.apply", json!({"comp": fx.0, "layers": [l], "effect": "ec.vr.converter"})).unwrap(); + let mut chain = fx; + for _ in 0..17 { + let up = comp(&mut s); + nest(&mut s, up, chain); + chain = up; + } + assert!(!comp_has_effects(&s.project, chain), "too deep to be found"); + // A root that nests the chain first and `fx` directly after it. + let root = comp(&mut s); + nest(&mut s, root, chain); + nest(&mut s, root, fx); + assert!(comp_has_effects(&s.project, root)); +} diff --git a/crates/ui-egui/src/gpu_failure.rs b/crates/ui-egui/src/gpu_failure.rs index 275112912..a0840b790 100644 --- a/crates/ui-egui/src/gpu_failure.rs +++ b/crates/ui-egui/src/gpu_failure.rs @@ -2,7 +2,9 @@ //! embedding host, never to a library borrowing its device. This bridge does no device work, //! document mutation, file I/O or renderer locking inside a notification. -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; + +pub use effectcraft_gpu::{ScopedError, ScopedErrorObserver}; type Notifier = Arc; @@ -25,11 +27,22 @@ struct State { pub struct GpuFailureBridge { state: Arc>, ctx: egui::Context, + scoped_observer: Arc>, } impl GpuFailureBridge { pub fn new(ctx: &egui::Context) -> Self { - Self { state: Arc::default(), ctx: ctx.clone() } + Self { state: Arc::default(), ctx: ctx.clone(), scoped_observer: Arc::default() } + } + + /// Register the host observer before handing this bridge to the app. Scoped errors do + /// not retire the device: they remain recoverable per-frame observations. + pub fn set_scoped_error_observer(&self, observer: impl Fn(ScopedError) + Send + Sync + 'static) -> bool { + self.scoped_observer.set(Arc::new(observer)).is_ok() + } + + pub(crate) fn scoped_error_observer(&self) -> Option { + self.scoped_observer.get().cloned() } pub fn failure(&self) -> Option { @@ -90,6 +103,18 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn scoped_observer_is_shared_without_retiring_the_device() { + let bridge = GpuFailureBridge::new(&egui::Context::default()); + let seen = Arc::new(Mutex::new(Vec::new())); + let copy = seen.clone(); + assert!(bridge.set_scoped_error_observer(move |error| copy.lock().unwrap().push(error))); + assert!(!bridge.set_scoped_error_observer(|_| panic!("must not replace observer"))); + bridge.clone().scoped_error_observer().unwrap()(ScopedError::Internal); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal]); + assert!(bridge.failure().is_none()); + } + #[test] fn a_construction_failure_is_replayed_after_notifier_registration() { let bridge = GpuFailureBridge::new(&egui::Context::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 68e58f842..bc09e358f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -506,8 +506,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "文件..."), ("file.importMultiple", "Multiple Files...", "多个文件..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 项目..."), - ("file.importAep", "After Effects Project...", "After Effects 项目..."), - ("file.exportAep", "After Effects Project...", "After Effects 项目..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects 项目..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects 项目..."), ("file.importProject", "EffectCraft Project...", "EffectCraft 项目..."), ("file.importPlaceholder", "Placeholder...", "占位符..."), ("file.importSolid", "Solid...", "纯色..."), @@ -701,7 +701,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "隐藏锁定的蒙版"), ("", "Mask and Shape Path", "蒙版和形状路径"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "已关闭"), + ("path.toggleClosed", "Closed", "已关闭"), ("path.convertToBezier", "Convert To Bezier Path", "转换为贝塞尔曲线路径"), ("path.setFirstVertex", "Set First Vertex", "设置第一个顶点"), ("path.freeTransform", "Free Transform Points", "自由变换点"), @@ -1054,7 +1054,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "基本图形"), ("window.panel", "Info", "信息"), ("help.inAppTutorials", "Learn", "学习"), - ("window.panel", "Lumetri Scopes", "Lumetri 范围"), + ("window.panel", "Video Scopes", "视频示波器"), ("window.panel", "Mask Interpolation", "蒙版插值"), ("window.panel", "Media Browser", "媒体浏览器"), ("window.panel", "Metadata", "元数据"), @@ -1146,8 +1146,8 @@ const SWEDISH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Fil..."), ("file.importMultiple", "Multiple Files...", "Flera filer..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-projekt..."), - ("file.importAep", "After Effects Project...", "After Effects-projekt..."), - ("file.exportAep", "After Effects Project...", "After Effects-projekt..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-projekt..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-projekt..."), ("file.importProject", "EffectCraft Project...", "EffectCraft-projekt..."), ("file.importPlaceholder", "Placeholder...", "Platshållare..."), ("file.importSolid", "Solid...", "Enfärgad..."), @@ -1339,7 +1339,7 @@ const SWEDISH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Dölj låsta masker"), ("", "Mask and Shape Path", "Mask- och formbana"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Sluten"), + ("path.toggleClosed", "Closed", "Sluten"), ("path.convertToBezier", "Convert To Bezier Path", "Konvertera till Bezierbana"), ("path.setFirstVertex", "Set First Vertex", "Ange första hörnpunkt"), ("path.freeTransform", "Free Transform Points", "Fri transformering av punkter"), @@ -1690,7 +1690,7 @@ const SWEDISH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Grundläggande grafik"), ("window.panel", "Info", "Info"), ("help.inAppTutorials", "Learn", "Lär dig"), - ("window.panel", "Lumetri Scopes", "Lumetri-scope"), + ("window.panel", "Video Scopes", "Videoskop"), ("window.panel", "Mask Interpolation", "Maskinterpolering"), ("window.panel", "Media Browser", "Mediebläddrare"), ("window.panel", "Metadata", "Metadata"), @@ -1788,8 +1788,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "檔案..."), ("file.importMultiple", "Multiple Files...", "多個檔案..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 專案..."), - ("file.importAep", "After Effects Project...", "After Effects 專案..."), - ("file.exportAep", "After Effects Project...", "After Effects 專案..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects 專案..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects 專案..."), ("file.importProject", "EffectCraft Project...", "EffectCraft 專案..."), ("file.importPlaceholder", "Placeholder...", "預留位置..."), ("file.importSolid", "Solid...", "純色..."), @@ -1983,7 +1983,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "隱藏鎖定的遮色片"), ("", "Mask and Shape Path", "遮色片和形狀路徑"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "封閉"), + ("path.toggleClosed", "Closed", "封閉"), ("path.convertToBezier", "Convert To Bezier Path", "轉換為貝茲路徑"), ("path.setFirstVertex", "Set First Vertex", "設定第一個頂點"), ("path.freeTransform", "Free Transform Points", "自由變換點"), @@ -2336,7 +2336,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "基本圖形"), ("window.panel", "Info", "資訊"), ("help.inAppTutorials", "Learn", "學習"), - ("window.panel", "Lumetri Scopes", "Lumetri 示波器"), + ("window.panel", "Video Scopes", "視訊示波器"), ("window.panel", "Mask Interpolation", "遮色片插補"), ("window.panel", "Media Browser", "媒體瀏覽器"), ("window.panel", "Metadata", "中繼資料"), @@ -2427,8 +2427,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "ファイル..."), ("file.importMultiple", "Multiple Files...", "複数のファイル..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Proプロジェクト..."), - ("file.importAep", "After Effects Project...", "After Effectsプロジェクト..."), - ("file.exportAep", "After Effects Project...", "After Effectsプロジェクト..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effectsプロジェクト..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effectsプロジェクト..."), ("file.importPlaceholder", "Placeholder...", "プレースホルダー..."), ("file.importSolid", "Solid...", "平面..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -2622,7 +2622,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "ロックしたマスクを非表示"), ("", "Mask and Shape Path", "マスクとシェイプのパス"), ("path.rotoBezier", "RotoBezier", "ロトベジェ"), - ("mask.setClosed", "Closed", "閉じたパス"), + ("path.toggleClosed", "Closed", "閉じたパス"), ("path.convertToBezier", "Convert To Bezier Path", "ベジェパスに変換"), ("path.setFirstVertex", "Set First Vertex", "最初の頂点を設定"), ("path.freeTransform", "Free Transform Points", "ポイントを自由変形"), @@ -2975,7 +2975,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "エッセンシャルグラフィックス"), ("window.panel", "Info", "情報"), ("help.inAppTutorials", "Learn", "学習"), - ("window.panel", "Lumetri Scopes", "Lumetriスコープ"), + ("window.panel", "Video Scopes", "ビデオスコープ"), ("window.panel", "Mask Interpolation", "マスク補間"), ("window.panel", "Media Browser", "メディアブラウザー"), ("window.panel", "Metadata", "メタデータ"), @@ -3069,8 +3069,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Файл..."), ("file.importMultiple", "Multiple Files...", "Кілька файлів..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Проєкт Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Проєкт After Effects..."), - ("file.exportAep", "After Effects Project...", "Проєкт After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Проєкт Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Проєкт Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Проєкт EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Заповнювач..."), ("file.importSolid", "Solid...", "Суцільний колір..."), @@ -3264,7 +3264,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Приховати заблоковані маски"), ("", "Mask and Shape Path", "Контур маски та фігури"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Замкнений"), + ("path.toggleClosed", "Closed", "Замкнений"), ("path.convertToBezier", "Convert To Bezier Path", "Перетворити на контур Безьє"), ("path.setFirstVertex", "Set First Vertex", "Задати першу вершину"), ("path.freeTransform", "Free Transform Points", "Вільне трансформування точок"), @@ -3617,7 +3617,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Основна графіка"), ("window.panel", "Info", "Інформація"), ("help.inAppTutorials", "Learn", "Навчання"), - ("window.panel", "Lumetri Scopes", "Вимірювачі Lumetri"), + ("window.panel", "Video Scopes", "Відеоскопи"), ("window.panel", "Mask Interpolation", "Інтерполяція маски"), ("window.panel", "Media Browser", "Браузер медіа"), ("window.panel", "Metadata", "Метадані"), @@ -3712,8 +3712,8 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Archivo..."), ("file.importMultiple", "Multiple Files...", "Varios archivos..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Proyecto de Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Proyecto de After Effects..."), - ("file.exportAep", "After Effects Project...", "Proyecto de After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Proyecto de Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Proyecto de Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Proyecto de EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Marcador de posición..."), ("file.importSolid", "Solid...", "Sólido..."), @@ -3907,7 +3907,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"), ("", "Mask and Shape Path", "Trazado de máscara y de forma"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Cerrado"), + ("path.toggleClosed", "Closed", "Cerrado"), ("path.convertToBezier", "Convert To Bezier Path", "Convertir en trazado Bézier"), ("path.setFirstVertex", "Set First Vertex", "Establecer primer vértice"), ("path.freeTransform", "Free Transform Points", "Transformar puntos libremente"), @@ -4260,7 +4260,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Gráficos esenciales"), ("window.panel", "Info", "Información"), ("help.inAppTutorials", "Learn", "Aprender"), - ("window.panel", "Lumetri Scopes", "Ámbitos de Lumetri"), + ("window.panel", "Video Scopes", "Monitores de vídeo"), ("window.panel", "Mask Interpolation", "Interpolación de máscara"), ("window.panel", "Media Browser", "Navegador de medios"), ("window.panel", "Metadata", "Metadatos"), @@ -4353,8 +4353,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Arquivo..."), ("file.importMultiple", "Multiple Files...", "Vários arquivos..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projeto do Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projeto do After Effects..."), - ("file.exportAep", "After Effects Project...", "Projeto do After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projeto do Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projeto do Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projeto do EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Espaço reservado..."), ("file.importSolid", "Solid...", "Sólido..."), @@ -4548,7 +4548,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"), ("", "Mask and Shape Path", "Caminho da máscara e da forma"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Fechado"), + ("path.toggleClosed", "Closed", "Fechado"), ("path.convertToBezier", "Convert To Bezier Path", "Converter em caminho Bézier"), ("path.setFirstVertex", "Set First Vertex", "Definir primeiro vértice"), ("path.freeTransform", "Free Transform Points", "Pontos de transformação livre"), @@ -4901,7 +4901,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Gráficos essenciais"), ("window.panel", "Info", "Informações"), ("help.inAppTutorials", "Learn", "Aprender"), - ("window.panel", "Lumetri Scopes", "Escopos Lumetri"), + ("window.panel", "Video Scopes", "Escopos de vídeo"), ("window.panel", "Mask Interpolation", "Interpolação de máscara"), ("window.panel", "Media Browser", "Navegador de mídia"), ("window.panel", "Metadata", "Metadados"), @@ -5002,8 +5002,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Файл..."), ("file.importMultiple", "Multiple Files...", "Несколько файлов..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Проект After Effects..."), - ("file.exportAep", "After Effects Project...", "Проект After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Проект Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Проект Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Проект EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Заполнитель..."), ("file.importSolid", "Solid...", "Сплошной цвет..."), @@ -5196,7 +5196,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Скрыть заблокированные маски"), ("", "Mask and Shape Path", "Контур маски и фигуры"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Замкнутый"), + ("path.toggleClosed", "Closed", "Замкнутый"), ("path.convertToBezier", "Convert To Bezier Path", "Преобразовать в кривую Безье"), ("path.setFirstVertex", "Set First Vertex", "Задать первую вершину"), ("path.freeTransform", "Free Transform Points", "Свободное трансформирование точек"), @@ -5544,7 +5544,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Основная графика"), ("window.panel", "Info", "Информация"), ("help.inAppTutorials", "Learn", "Обучение"), - ("window.panel", "Lumetri Scopes", "Измерители Lumetri"), + ("window.panel", "Video Scopes", "Видеоскопы"), ("window.panel", "Mask Interpolation", "Интерполяция маски"), ("window.panel", "Media Browser", "Обозреватель медиа"), ("window.panel", "Metadata", "Метаданные"), @@ -5643,8 +5643,8 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Fichier..."), ("file.importMultiple", "Multiple Files...", "Plusieurs fichiers..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projet Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projet After Effects..."), - ("file.exportAep", "After Effects Project...", "Projet After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projet Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projet Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projet EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Espace réservé..."), ("file.importSolid", "Solid...", "Solide..."), @@ -5837,7 +5837,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Masquer les masques verrouillés"), ("", "Mask and Shape Path", "Trajectoire de masque et de forme"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Fermé"), + ("path.toggleClosed", "Closed", "Fermé"), ("path.convertToBezier", "Convert To Bezier Path", "Convertir en trajectoire Bézier"), ("path.setFirstVertex", "Set First Vertex", "Définir le premier sommet"), ("path.freeTransform", "Free Transform Points", "Transformation libre des points"), @@ -6185,7 +6185,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Graphiques essentiels"), ("window.panel", "Info", "Informations"), ("help.inAppTutorials", "Learn", "Apprentissage"), - ("window.panel", "Lumetri Scopes", "Oscilloscopes Lumetri"), + ("window.panel", "Video Scopes", "Oscilloscopes vidéo"), ("window.panel", "Mask Interpolation", "Interpolation de masque"), ("window.panel", "Media Browser", "Navigateur multimédia"), ("window.panel", "Metadata", "Métadonnées"), @@ -6284,8 +6284,8 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Datei..."), ("file.importMultiple", "Multiple Files...", "Mehrere Dateien..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-Projekt..."), - ("file.importAep", "After Effects Project...", "After Effects-Projekt..."), - ("file.exportAep", "After Effects Project...", "After Effects-Projekt..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-Projekt..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-Projekt..."), ("file.importProject", "EffectCraft Project...", "EffectCraft-Projekt..."), ("file.importPlaceholder", "Placeholder...", "Platzhalter..."), ("file.importSolid", "Solid...", "Farbfläche..."), @@ -6478,7 +6478,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Gesperrte Masken ausblenden"), ("", "Mask and Shape Path", "Masken- und Formpfad"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Geschlossen"), + ("path.toggleClosed", "Closed", "Geschlossen"), ("path.convertToBezier", "Convert To Bezier Path", "In Bézier-Pfad konvertieren"), ("path.setFirstVertex", "Set First Vertex", "Ersten Scheitelpunkt festlegen"), ("path.freeTransform", "Free Transform Points", "Punkte frei transformieren"), @@ -6826,7 +6826,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Essential-Grafik"), ("window.panel", "Info", "Info"), ("help.inAppTutorials", "Learn", "Lernen"), - ("window.panel", "Lumetri Scopes", "Lumetri-Scopes"), + ("window.panel", "Video Scopes", "Videoskope"), ("window.panel", "Mask Interpolation", "Maskeninterpolation"), ("window.panel", "Media Browser", "Medienbrowser"), ("window.panel", "Metadata", "Metadaten"), @@ -6925,8 +6925,8 @@ const POLISH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Plik..."), ("file.importMultiple", "Multiple Files...", "Wiele plików..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projekt After Effects..."), - ("file.exportAep", "After Effects Project...", "Projekt After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Element zastępczy..."), ("file.importSolid", "Solid...", "Plansza..."), @@ -7118,7 +7118,7 @@ const POLISH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Ukryj zablokowane maski"), ("", "Mask and Shape Path", "Ścieżka maski i kształtu"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Zamknięta"), + ("path.toggleClosed", "Closed", "Zamknięta"), ("path.convertToBezier", "Convert To Bezier Path", "Konwertuj na ścieżkę Béziera"), ("path.setFirstVertex", "Set First Vertex", "Ustaw pierwszy wierzchołek"), ("path.freeTransform", "Free Transform Points", "Swobodne przekształcanie punktów"), @@ -7464,7 +7464,7 @@ const POLISH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Essential Graphics"), ("window.panel", "Info", "Informacje"), ("help.inAppTutorials", "Learn", "Nauka"), - ("window.panel", "Lumetri Scopes", "Zakresy Lumetri"), + ("window.panel", "Video Scopes", "Wskaźniki wideo"), ("window.panel", "Mask Interpolation", "Interpolacja maski"), ("window.panel", "Media Browser", "Przeglądarka multimediów"), ("window.panel", "Metadata", "Metadane"), @@ -7563,8 +7563,8 @@ const CZECH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Soubor..."), ("file.importMultiple", "Multiple Files...", "Více souborů..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), - ("file.importAep", "After Effects Project...", "Projekt After Effects..."), - ("file.exportAep", "After Effects Project...", "Projekt After Effects..."), + ("file.importAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), + ("file.exportAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."), ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Zástupný záznam..."), ("file.importSolid", "Solid...", "Plná vrstva..."), @@ -7758,7 +7758,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "Skrýt zamknuté masky"), ("", "Mask and Shape Path", "Cesta masky a tvaru"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "Uzavřená"), + ("path.toggleClosed", "Closed", "Uzavřená"), ("path.convertToBezier", "Convert To Bezier Path", "Převést na Bézierovu cestu"), ("path.setFirstVertex", "Set First Vertex", "Nastavit první vrchol"), ("path.freeTransform", "Free Transform Points", "Volně transformovat body"), @@ -8106,7 +8106,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "Základní grafika"), ("window.panel", "Info", "Informace"), ("help.inAppTutorials", "Learn", "Výuka"), - ("window.panel", "Lumetri Scopes", "Měřicí přístroje Lumetri"), + ("window.panel", "Video Scopes", "Videoskopy"), ("window.panel", "Mask Interpolation", "Interpolace masek"), ("window.panel", "Media Browser", "Prohlížeč médií"), ("window.panel", "Metadata", "Metadata"), @@ -8201,8 +8201,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "ფაილი..."), ("file.importMultiple", "Multiple Files...", "რამდენიმე ფაილი..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), - ("file.importAep", "After Effects Project...", "After Effects-ის პროექტი..."), - ("file.exportAep", "After Effects Project...", "After Effects-ის პროექტი..."), + ("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-ის პროექტი..."), + ("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-ის პროექტი..."), ("file.importProject", "EffectCraft Project...", "EffectCraft-ის პროექტი..."), ("file.importPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), ("file.importSolid", "Solid...", "სოლიდი..."), @@ -8395,7 +8395,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("layer.mask.hideLocked", "Hide Locked Masks", "ჩაკეტილი ნიღბების დამალვა"), ("", "Mask and Shape Path", "ნიღაბი და ფიგურის ტრაექტორია"), ("path.rotoBezier", "RotoBezier", "RotoBezier"), - ("mask.setClosed", "Closed", "შეკრული"), + ("path.toggleClosed", "Closed", "შეკრული"), ("path.convertToBezier", "Convert To Bezier Path", "ბეზიეს ტრაექტორიად გარდაქმნა"), ("path.setFirstVertex", "Set First Vertex", "პირველი წვეროს დაყენება"), ("path.freeTransform", "Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), @@ -8748,7 +8748,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("window.panel", "Essential Graphics", "ძირითადი გრაფიკა"), ("window.panel", "Info", "ინფორმაცია"), ("help.inAppTutorials", "Learn", "სწავლა"), - ("window.panel", "Lumetri Scopes", "Lumetri Scopes"), + ("window.panel", "Video Scopes", "ვიდეოსკოპები"), ("window.panel", "Mask Interpolation", "ნიღბის ინტერპოლაცია"), ("window.panel", "Media Browser", "მედიის ბრაუზერი"), ("window.panel", "Metadata", "მეტამონაცემები"), @@ -8799,6 +8799,21 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; + #[test] + fn renamed_menu_translations_do_not_reintroduce_vendor_names() { + for code in CATALOG_CODES { + set_current(code); + for (id, source) in [("window.panel", "Video Scopes")] { + let rendered = command_label(id, source); + let label = rendered.to_lowercase(); + for mark in ["lumetri", "mercury"] { + assert!(!label.contains(mark), "{code}: {id} -> {rendered}"); + } + } + } + set_current("en"); + } + /// The four options at the foot of the header's workspace menu say what After Effects says, /// read from its workspace button on 2026-10-31: reset, save over this one, save a copy, then /// edit the list. @@ -9193,7 +9208,8 @@ mod tests { assert!(!japanese(&app)); } - /// File ▸ Import ▸ After Effects Project… has its row in every menu catalog. + /// File ▸ Import / Export ▸ Adobe After Effects Project… has its row in every menu catalog, + /// under the label the menu draws (a row under an older label would fall back to English). #[test] fn the_aep_import_and_export_entries_are_in_every_menu_catalog() { for (code, catalog) in [ @@ -9212,7 +9228,8 @@ mod tests { ("sv", SWEDISH), ] { for id in ["file.importAep", "file.exportAep"] { - assert!(catalog.iter().any(|(command, _, _)| *command == id), "{code} has no {id} row"); + let drawn = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == id).map(|(_, e)| e.label.clone()).unwrap_or_default(); + assert!(catalog.iter().any(|(command, english, _)| *command == id && *english == drawn), "{code} has no {id} row for {drawn:?}"); } } } @@ -9291,12 +9308,16 @@ mod tests { alt_shortcuts: Vec::new(), }; let new_comp = menu_entry("comp.new", "New Composition..."); + let closed_path = menu_entry("path.toggleClosed", "Closed"); let undo = menu_entry("edit.undo", "Undo"); for code in CATALOG_CODES { app.session.execute("prefs.set", json!({"key":"general.language", "value":code})).unwrap(); let drawn = entry(&app, &new_comp, new_comp.label.clone()); assert_eq!(drawn, label(&app, "comp.new", "New Composition..."), "{code}"); assert_ne!(drawn, new_comp.label, "{code}: the menu entry is drawn in English"); + let closed_label = entry(&app, &closed_path, closed_path.label.clone()); + assert_eq!(closed_label, label(&app, "path.toggleClosed", "Closed"), "{code}"); + assert_ne!(closed_label, closed_path.label, "{code}: Closed is drawn in English"); } app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index ec952ccb0..55893115a 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -18,14 +18,19 @@ //! another effect. An effect with no row draws the registry's English name; the //! `missing_translations_report` test (run by `cargo xtask ci`) lists those per language. //! -//! A language decides for itself which names stay English: After Effects' Japanese interface -//! leaves the `CC` series in English and gives the `VR` and `OCIO` sets a Japanese tail -//! (`VR ブラー`, `OCIO CDL 変換`), while its Simplified Chinese one translates them (the `CC` -//! names come from Adobe's Chinese *documentation*, which names 68 of the 69). Repeating the -//! English name is therefore a language-level choice, not a missing row. +//! Own feature names are descriptive. The renamed effects use original translations in every +//! existing effect catalog; legacy vendor names remain search aliases in the effects crate. +//! External format tokens and stored instance names are not localized or rewritten. //! -//! The Simplified Chinese names come from the installed After Effects 2026 (26.5.0.89) -//! Simplified Chinese interface, its 21 category folder names included; `Cryptomatte`, +//! The rows of the effects this cleanup did not rename keep the provenance they had. A language +//! decides for itself which names stay English: After Effects' Japanese interface leaves the `CC` +//! series in English and gives the `VR` and `OCIO` sets a Japanese tail (`VR ブラー`, +//! `OCIO CDL 変換`), while its Simplified Chinese one translates them. Repeating the English name +//! is therefore a language-level choice, not a missing row. +//! +//! The Simplified Chinese names of unchanged effects come from the installed After Effects 2026 +//! (26.5.0.89) Simplified Chinese interface, its 21 category folder names included (the `CC` names +//! came from Adobe's Chinese *documentation*, which names 68 of the 69); `Cryptomatte`, //! `EXtractoR`, `IDentifier`, `Key Light` and `Unmult` stay in English as brand names, and //! `Face Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is //! ours. @@ -33,16 +38,15 @@ //! The Traditional Chinese names for Taiwan are written from the meaning of the English ones, with //! the terms Taiwan's creative software uses (`圖層`, `遮罩`, `筆刷`, `雜訊`, `發光`, `漸層`); they //! are not converted from the Simplified table. Brand and coined names (`Cryptomatte`, -//! `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`, `Colorama`, `Minimax`, -//! `Vegas`, `Mocha`) stay in English; the `CC` series keeps its prefix and translates the -//! descriptive tail, and the `VR` and `OCIO` sets keep their prefix in Chinese word order. +//! `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Colorama`, `Minimax`, `Vegas`) stay in +//! English, and the `VR` and `OCIO` sets keep their prefix in Chinese word order. //! //! The French names are written from the meaning of the English ones, with the vocabulary of the //! French menus; they come from no other application's interface. Brand and coined names -//! (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`, `Colorama`, -//! `Minimax`, `Tritone`, `Vibrance`, `Vegas`) and the whole `CC` series stay in English; a series -//! tag that prefixes a descriptive name (`VR`, `OCIO`, `PS`, `HDR`) stays and follows the -//! French word order (`Flou VR`, `Transformation CDL OCIO`). +//! (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Colorama`, `Minimax`, +//! `Tritone`, `Vibrance`, `Vegas`) stay in English; a series tag that prefixes a descriptive name +//! (`VR`, `OCIO`, `PS`, `HDR`) stays and follows the French word order (`Flou VR`, +//! `Transformation CDL OCIO`). use std::collections::HashMap; use std::sync::OnceLock; @@ -122,19 +126,21 @@ pub(super) fn translated_rows(language: &str) -> usize { /// The name to draw for an effect group the property tree stores as `stored`: the catalog name, /// plus the instance index the engine appends to a repeat (`Gaussian Blur 2` → `高斯模糊 2`). /// -/// A name that is neither the registry's nor the registry's plus an index was typed by the user -/// (Effect Controls ▸ Rename), so it stays verbatim — as does the whole string in a language -/// with no row. +/// Current and legacy default names (including numeric instance suffixes) draw the current +/// name. Other names were supplied by the user and stay verbatim. Stored data is never changed. pub(crate) fn group(id: &str, stored: &str) -> String { - let Some((english, translated)) = catalog(current()).and_then(|c| c.row(id)) else { return stored.to_string() }; - if stored == english { - return translated.to_string(); - } - if let Some(index) = stored.strip_prefix(english).and_then(|rest| rest.strip_prefix(' ')) - && !index.is_empty() - && index.bytes().all(|b| b.is_ascii_digit()) - { - return format!("{translated} {index}"); + let Some(spec) = effectcraft_engine::effects::find(id) else { return stored.to_string() }; + let translated = name(id, spec.name); + for source in std::iter::once(spec.name).chain(effectcraft_engine::effects::aliases(id).iter().copied()) { + if stored == source { + return translated; + } + if let Some(index) = stored.strip_prefix(source).and_then(|rest| rest.strip_prefix(' ')) + && !index.is_empty() + && index.bytes().all(|b| b.is_ascii_digit()) + { + return format!("{translated} {index}"); + } } stored.to_string() } @@ -149,6 +155,7 @@ pub(crate) fn category(source: &str) -> String { /// `query` (lowercase)? Both names match, so a translated interface can be searched either way. pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool { english.to_lowercase().contains(query) + || effectcraft_engine::effects::find(id).is_some_and(|spec| effectcraft_engine::effects::name_matches(spec, query)) || catalog(current()).and_then(|c| c.row(id)).is_some_and(|(_, translated)| translated.to_lowercase().contains(query)) } @@ -192,10 +199,10 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "双向模糊"), ("ec.blur.cameralens", "Camera Lens Blur", "摄像机镜头模糊"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "摄像机抖动去模糊"), - ("ec.blur.cccross", "CC Cross Blur", "CC 交叉模糊"), - ("ec.blur.ccradial", "CC Radial Blur", "CC 径向模糊"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC 径向快速模糊"), - ("ec.blur.ccvector", "CC Vector Blur", "CC 矢量模糊"), + ("ec.blur.cccross", "Cross Blur", "十字模糊"), + ("ec.blur.ccradial", "Variable Radial Blur", "可变径向模糊"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "快速径向模糊"), + ("ec.blur.ccvector", "Vector Blur", "矢量模糊"), ("ec.blur.channel", "Channel Blur", "通道模糊"), ("ec.blur.compound", "Compound Blur", "复合模糊"), ("ec.blur.directional", "Directional Blur", "定向模糊"), @@ -209,7 +216,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "算术"), ("ec.channel.blend", "Blend", "混合"), ("ec.channel.calculations", "Calculations", "计算"), - ("ec.channel.cccomposite", "CC Composite", "CC 合成"), + ("ec.channel.cccomposite", "Source Composite", "源图合成"), ("ec.channel.combiner", "Channel Combiner", "通道合成器"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "复合运算"), ("ec.channel.invert", "Invert", "反转"), @@ -225,10 +232,10 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "黑色和白色"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "亮度和对比度"), ("ec.color.broadcast", "Broadcast Colors", "广播颜色"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC 颜色中和器"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC 颜色偏移"), - ("ec.color.cckernel", "CC Kernel", "CC 内核"), - ("ec.color.cctoner", "CC Toner", "CC 调色器"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "色偏校正"), + ("ec.color.cccoloroffset", "Channel Offset", "通道偏移"), + ("ec.color.cckernel", "Convolution Kernel", "卷积核"), + ("ec.color.cctoner", "Multi-Tone Tint", "多色调着色"), ("ec.color.changecolor", "Change Color", "更改颜色"), ("ec.color.changetocolor", "Change to Color", "更改为颜色"), ("ec.color.channelmixer", "Channel Mixer", "通道混合器"), @@ -245,7 +252,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "保留颜色"), ("ec.color.levels", "Levels", "色阶"), ("ec.color.levelsic", "Levels (Individual Controls)", "色阶 (单独控件)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri 颜色"), + ("ec.color.lumetri", "Color Grading", "色彩分级"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 变换"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 颜色空间变换"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 显示变换"), @@ -270,20 +277,20 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "立体 3D 控制"), ("ec.distort.bezierwarp", "Bezier Warp", "贝塞尔曲线变形"), ("ec.distort.bulge", "Bulge", "凸出"), - ("ec.distort.ccbender", "CC Bender", "CC 弯曲器"), - ("ec.distort.ccbendit", "CC Bend It", "CC 弯曲"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC 融化"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC 流动运动"), - ("ec.distort.ccgriddler", "CC Griddler", "CC 网格生成器"), - ("ec.distort.cclens", "CC Lens", "CC 镜头"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC 翻页"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC 强力边角定位"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC 波纹脉冲"), - ("ec.distort.ccslant", "CC Slant", "CC 倾斜"), - ("ec.distort.ccsmear", "CC Smear", "CC 漩涡条纹"), - ("ec.distort.ccsplit", "CC Split", "CC 拆分"), - ("ec.distort.ccsplit2", "CC Split 2", "CC 拆分 2"), - ("ec.distort.cctiler", "CC Tiler", "CC 拼贴器"), + ("ec.distort.ccbender", "Flexible Bend", "柔性弯曲"), + ("ec.distort.ccbendit", "Two-Point Bend", "两点弯曲"), + ("ec.distort.ccblobbylize", "Organic Displacement", "有机置换"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "双镜头变形"), + ("ec.distort.ccgriddler", "Tile Grid", "平铺网格"), + ("ec.distort.cclens", "Lens Distortion", "镜头畸变"), + ("ec.distort.ccpageturn", "Page Curl", "页面卷曲"), + ("ec.distort.ccpowerpin", "Perspective Pin", "透视定位"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "脉冲涟漪"), + ("ec.distort.ccslant", "Slant", "倾斜"), + ("ec.distort.ccsmear", "Directional Smear", "定向涂抹"), + ("ec.distort.ccsplit", "Split Warp", "分割变形"), + ("ec.distort.ccsplit2", "Dual Split Warp", "双重分割变形"), + ("ec.distort.cctiler", "Scaled Tiles", "缩放平铺"), ("ec.distort.cornerpin", "Corner Pin", "边角定位"), ("ec.distort.displacementmap", "Displacement Map", "置换图"), ("ec.distort.liquify", "Liquify", "液化"), @@ -311,11 +318,11 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "音频频谱"), ("ec.generate.audiowaveform", "Audio Waveform", "音频波形"), ("ec.generate.beam", "Beam", "光束"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC 胶水枪"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC 光束放射 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC 光线"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC 光线扫描"), - ("ec.generate.ccthreads", "CC Threads", "CC 编织条"), + ("ec.generate.ccgluegun", "Paint Beads", "颜料珠"), + ("ec.generate.cclightburst", "Radial Light Burst", "径向光芒"), + ("ec.generate.cclightrays", "Light Rays", "光线"), + ("ec.generate.cclightsweep", "Light Sweep", "扫光"), + ("ec.generate.ccthreads", "Woven Threads", "编织线"), ("ec.generate.cellpattern", "Cell Pattern", "单元格图案"), ("ec.generate.checkerboard", "Checkerboard", "棋盘"), ("ec.generate.circle", "Circle", "圆形"), @@ -334,7 +341,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.generate.vegas", "Vegas", "勾画"), ("ec.generate.writeon", "Write-on", "写入"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "高级溢出抑制器"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC 简单丝线移除"), + ("ec.key.ccsimplewireremoval", "Line Removal", "线条移除"), ("ec.key.colordifference", "Color Difference Key", "颜色差值键"), ("ec.key.colorkey", "Color Key", "颜色键"), ("ec.key.colorrange", "Color Range", "颜色范围"), @@ -347,7 +354,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.key.screen", "Screen Key", "屏幕键"), ("ec.key.spill", "Spill Suppressor", "溢出抑制"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "屏幕色键"), ("ec.matte.mattechoker", "Matte Choker", "遮罩阻塞工具"), ("ec.matte.objectmatte", "Object Matte", "对象遮罩"), ("ec.matte.refinehard", "Refine Hard Matte", "调整实边遮罩"), @@ -371,50 +378,50 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "基本文字"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "高斯模糊 (旧版)"), ("ec.obsolete.lightning", "Lightning", "闪电"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha 形状"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "跟踪形状遮罩"), ("ec.obsolete.pathtext", "Path Text", "路径文本"), ("ec.paint.paint", "Paint", "绘画"), ("ec.perspective.3dglasses", "3D Glasses", "3D 眼镜"), ("ec.perspective.bevelalpha", "Bevel Alpha", "斜面 Alpha"), ("ec.perspective.beveledges", "Bevel Edges", "边缘斜面"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D 摄像机跟踪器"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC 圆柱体"), - ("ec.perspective.ccenvironment", "CC Environment", "CC 环境"), - ("ec.perspective.ccsphere", "CC Sphere", "CC 球体"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC 聚光"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "圆柱包裹"), + ("ec.perspective.ccenvironment", "Environment Map", "环境映射"), + ("ec.perspective.ccsphere", "Sphere Wrap", "球面包裹"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "表面聚光灯"), ("ec.perspective.dropshadow", "Drop Shadow", "投影"), ("ec.perspective.radialshadow", "Radial Shadow", "径向阴影"), ("ec.sim.carddance", "Card Dance", "卡片动画"), ("ec.sim.caustics", "Caustics", "焦散"), - ("ec.sim.ccballaction", "CC Ball Action", "CC 球动作"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC 气泡"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC 细雨波纹"), - ("ec.sim.cchair", "CC Hair", "CC 毛发"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC 水银流体"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC 粒子系统 II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC 粒子世界"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC 像素多边形"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC 降雨"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC 散射"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC 降雪"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC 星爆"), + ("ec.sim.ccballaction", "Ball Grid", "球体网格"), + ("ec.sim.ccbubbles", "Bubbles", "气泡"), + ("ec.sim.ccdrizzle", "Water Ripples", "水面涟漪"), + ("ec.sim.cchair", "Surface Fibers", "表面纤维"), + ("ec.sim.ccmrmercury", "Liquid Particles", "液体粒子"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "二维粒子发射器"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "三维粒子发射器"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "像素破碎"), + ("ec.sim.ccrainfall", "Rainfall", "降雨"), + ("ec.sim.ccscatterize", "Pixel Scatter", "像素散射"), + ("ec.sim.ccsnowfall", "Snowfall", "降雪"), + ("ec.sim.ccstarburst", "Starfield", "星空"), ("ec.sim.foam", "Foam", "泡沫"), ("ec.sim.particleplayground", "Particle Playground", "粒子运动场"), ("ec.sim.shatter", "Shatter", "碎片"), ("ec.sim.waveworld", "Wave World", "波形环境"), ("ec.stylize.brushstrokes", "Brush Strokes", "画笔描边"), ("ec.stylize.cartoon", "Cartoon", "卡通"), - ("ec.stylize.ccblockload", "CC Block Load", "CC 块加载"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC 烧毁胶片"), - ("ec.stylize.ccglass", "CC Glass", "CC 玻璃"), - ("ec.stylize.cchextile", "CC HexTile", "CC 六边形拼贴"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC 万花筒"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC 像素溶解"), - ("ec.stylize.ccplastic", "CC Plastic", "CC 塑料"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC 重复拼贴"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC 阈值"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC 阈值 RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC 暗角"), + ("ec.stylize.ccblockload", "Block Reveal", "块状显现"), + ("ec.stylize.ccburnfilm", "Film Burn", "胶片灼烧"), + ("ec.stylize.ccglass", "Glass Relief", "玻璃浮雕"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "六边形平铺"), + ("ec.stylize.cckaleida", "Kaleidoscope", "万花筒"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "调色板映射"), + ("ec.stylize.ccplastic", "Plastic Shading", "塑料着色"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "边缘平铺重复"), + ("ec.stylize.ccthreshold", "Channel Threshold", "通道阈值"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB 阈值"), + ("ec.stylize.ccvignette", "Vignette", "晕影"), ("ec.stylize.coloremboss", "Color Emboss", "彩色浮雕"), ("ec.stylize.emboss", "Emboss", "浮雕"), ("ec.stylize.findedges", "Find Edges", "查找边缘"), @@ -429,8 +436,8 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "阈值"), ("ec.text.numbers", "Numbers", "编号"), ("ec.text.timecode", "Timecode", "时间码"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC 强制运动模糊"), - ("ec.time.ccwidetime", "CC Wide Time", "CC 宽泛时间"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "时间运动模糊"), + ("ec.time.ccwidetime", "Temporal Blend", "时间混合"), ("ec.time.echo", "Echo", "残影"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "像素运动模糊"), ("ec.time.posterizetime", "Posterize Time", "抽帧"), @@ -439,23 +446,23 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "时间扭曲"), ("ec.transition.blockdissolve", "Block Dissolve", "块溶解"), ("ec.transition.cardwipe", "Card Wipe", "卡片擦除"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC 玻璃擦除"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC 网格擦除"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC 图像擦除"), - ("ec.transition.ccjaws", "CC Jaws", "CC 锯齿"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC 光线擦除"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC 线条扫掠"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC 径向缩放擦除"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC 缩放擦除"), - ("ec.transition.cctwister", "CC Twister", "CC 扭曲器"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC 自动扭曲"), + ("ec.transition.ccglasswipe", "Glass Wipe", "玻璃擦除"), + ("ec.transition.ccgridwipe", "Grid Wipe", "网格擦除"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "亮度图像擦除"), + ("ec.transition.ccjaws", "Serrated Wipe", "锯齿擦除"), + ("ec.transition.cclightwipe", "Light Wipe", "光线擦除"), + ("ec.transition.cclinesweep", "Line Sweep", "线条扫过"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "径向缩放擦除"), + ("ec.transition.ccscalewipe", "Scale Wipe", "缩放擦除"), + ("ec.transition.cctwister", "Twist Wipe", "扭转擦除"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "变形溶解"), ("ec.transition.gradientwipe", "Gradient Wipe", "渐变擦除"), ("ec.transition.iriswipe", "Iris Wipe", "光圈擦除"), ("ec.transition.linearwipe", "Linear Wipe", "线性擦除"), ("ec.transition.radialwipe", "Radial Wipe", "径向擦除"), ("ec.transition.venetian", "Venetian Blinds", "百叶窗"), ("ec.utility.applylut", "Apply Color LUT", "应用颜色 LUT"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC 高光溢出"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "超出范围显示"), ("ec.utility.cineon", "Cineon Converter", "Cineon 转换器"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "颜色配置文件转换器"), ("ec.utility.facemeasurements", "Face Measurements", "面部测量"), @@ -480,9 +487,9 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ /// [`SIMPLIFIED_CHINESE`]. Written from the meaning of the English names with the vocabulary /// Taiwan's creative software uses (圖層, 遮罩, 筆刷, 雜訊, 發光, 漸層), not converted from the /// Simplified table. Brand and coined names (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, -/// `Unmult`, `Lumetri Color`, `Colorama`, `Minimax`, `Vegas`, `Mocha`) stay in English; the `CC` -/// series keeps its prefix and translates the descriptive tail, and the `VR` and `OCIO` sets keep -/// their prefix in Chinese word order. +/// `Unmult`, `Colorama`, `Minimax`, `Vegas`) stay in English; the `VR` and `OCIO` sets keep their +/// prefix in Chinese word order. The effects this cleanup renamed are translated from their new +/// descriptive names. const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.3d.channelextract", "3D Channel Extract", "3D 通道擷取"), ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), @@ -509,10 +516,10 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "雙邊模糊"), ("ec.blur.cameralens", "Camera Lens Blur", "相機鏡頭模糊"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "相機晃動去模糊"), - ("ec.blur.cccross", "CC Cross Blur", "CC 十字模糊"), - ("ec.blur.ccradial", "CC Radial Blur", "CC 徑向模糊"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC 徑向快速模糊"), - ("ec.blur.ccvector", "CC Vector Blur", "CC 向量模糊"), + ("ec.blur.cccross", "Cross Blur", "十字模糊"), + ("ec.blur.ccradial", "Variable Radial Blur", "可變放射狀模糊"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "快速放射狀模糊"), + ("ec.blur.ccvector", "Vector Blur", "向量模糊"), ("ec.blur.channel", "Channel Blur", "通道模糊"), ("ec.blur.compound", "Compound Blur", "複合模糊"), ("ec.blur.directional", "Directional Blur", "方向模糊"), @@ -526,7 +533,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "算術"), ("ec.channel.blend", "Blend", "混合"), ("ec.channel.calculations", "Calculations", "運算"), - ("ec.channel.cccomposite", "CC Composite", "CC 合成"), + ("ec.channel.cccomposite", "Source Composite", "來源影像合成"), ("ec.channel.combiner", "Channel Combiner", "通道合成器"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "複合算術"), ("ec.channel.invert", "Invert", "反轉"), @@ -542,10 +549,10 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "黑白"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "亮度與對比"), ("ec.color.broadcast", "Broadcast Colors", "廣播色彩"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC 色彩中和器"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC 色彩偏移"), - ("ec.color.cckernel", "CC Kernel", "CC 核心"), - ("ec.color.cctoner", "CC Toner", "CC 色調"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "色偏校正"), + ("ec.color.cccoloroffset", "Channel Offset", "色版偏移"), + ("ec.color.cckernel", "Convolution Kernel", "卷積核心"), + ("ec.color.cctoner", "Multi-Tone Tint", "多色調著色"), ("ec.color.changecolor", "Change Color", "變更色彩"), ("ec.color.changetocolor", "Change to Color", "變更為色彩"), ("ec.color.channelmixer", "Channel Mixer", "通道混合器"), @@ -562,7 +569,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "保留色彩"), ("ec.color.levels", "Levels", "色階"), ("ec.color.levelsic", "Levels (Individual Controls)", "色階(個別控制項)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.lumetri", "Color Grading", "色彩分級"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 轉換"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色彩空間轉換"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 顯示轉換"), @@ -587,20 +594,20 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "立體 3D 控制"), ("ec.distort.bezierwarp", "Bezier Warp", "貝茲彎曲"), ("ec.distort.bulge", "Bulge", "凸出"), - ("ec.distort.ccbender", "CC Bender", "CC 彎曲"), - ("ec.distort.ccbendit", "CC Bend It", "CC 彎折"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC 團塊化"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC 流動"), - ("ec.distort.ccgriddler", "CC Griddler", "CC 格線"), - ("ec.distort.cclens", "CC Lens", "CC 鏡頭"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC 翻頁"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC 強力釘選"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC 漣漪脈衝"), - ("ec.distort.ccslant", "CC Slant", "CC 傾斜"), - ("ec.distort.ccsmear", "CC Smear", "CC 塗抹"), - ("ec.distort.ccsplit", "CC Split", "CC 分割"), - ("ec.distort.ccsplit2", "CC Split 2", "CC 分割 2"), - ("ec.distort.cctiler", "CC Tiler", "CC 並排"), + ("ec.distort.ccbender", "Flexible Bend", "柔性彎曲"), + ("ec.distort.ccbendit", "Two-Point Bend", "兩點彎曲"), + ("ec.distort.ccblobbylize", "Organic Displacement", "有機置換"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "雙鏡頭變形"), + ("ec.distort.ccgriddler", "Tile Grid", "拼貼網格"), + ("ec.distort.cclens", "Lens Distortion", "鏡頭畸變"), + ("ec.distort.ccpageturn", "Page Curl", "頁面捲曲"), + ("ec.distort.ccpowerpin", "Perspective Pin", "透視定位"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "脈衝漣漪"), + ("ec.distort.ccslant", "Slant", "傾斜"), + ("ec.distort.ccsmear", "Directional Smear", "定向塗抹"), + ("ec.distort.ccsplit", "Split Warp", "分割變形"), + ("ec.distort.ccsplit2", "Dual Split Warp", "雙重分割變形"), + ("ec.distort.cctiler", "Scaled Tiles", "縮放平鋪"), ("ec.distort.cornerpin", "Corner Pin", "邊角釘選"), ("ec.distort.displacementmap", "Displacement Map", "位移映射"), ("ec.distort.liquify", "Liquify", "液化"), @@ -628,11 +635,11 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "音訊頻譜"), ("ec.generate.audiowaveform", "Audio Waveform", "音訊波形"), ("ec.generate.beam", "Beam", "光束"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC 膠水槍"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC 光爆 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC 光線"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC 光線掃掠"), - ("ec.generate.ccthreads", "CC Threads", "CC 絲線"), + ("ec.generate.ccgluegun", "Paint Beads", "顏料珠"), + ("ec.generate.cclightburst", "Radial Light Burst", "放射狀光芒"), + ("ec.generate.cclightrays", "Light Rays", "光線"), + ("ec.generate.cclightsweep", "Light Sweep", "掃光"), + ("ec.generate.ccthreads", "Woven Threads", "編織線"), ("ec.generate.cellpattern", "Cell Pattern", "細胞圖樣"), ("ec.generate.checkerboard", "Checkerboard", "棋盤格"), ("ec.generate.circle", "Circle", "圓形"), @@ -651,7 +658,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.generate.vegas", "Vegas", "Vegas"), ("ec.generate.writeon", "Write-on", "書寫"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "進階溢色抑制"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC 簡易鋼絲移除"), + ("ec.key.ccsimplewireremoval", "Line Removal", "線條移除"), ("ec.key.colordifference", "Color Difference Key", "色彩差異去背"), ("ec.key.colorkey", "Color Key", "色彩去背"), ("ec.key.colorrange", "Color Range", "色彩範圍"), @@ -664,7 +671,7 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.key.screen", "Screen Key", "螢幕去背"), ("ec.key.spill", "Spill Suppressor", "溢色抑制"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "螢幕色鍵"), ("ec.matte.mattechoker", "Matte Choker", "遮罩收縮器"), ("ec.matte.objectmatte", "Object Matte", "物件遮罩"), ("ec.matte.refinehard", "Refine Hard Matte", "調整硬邊遮罩"), @@ -688,50 +695,50 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "基礎文字"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "高斯模糊(舊版)"), ("ec.obsolete.lightning", "Lightning", "閃電"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha 形狀"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "追蹤形狀遮罩"), ("ec.obsolete.pathtext", "Path Text", "路徑文字"), ("ec.paint.paint", "Paint", "繪畫"), ("ec.perspective.3dglasses", "3D Glasses", "3D 眼鏡"), ("ec.perspective.bevelalpha", "Bevel Alpha", "Alpha 斜角"), ("ec.perspective.beveledges", "Bevel Edges", "邊緣斜角"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D 攝影機追蹤"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC 圓柱"), - ("ec.perspective.ccenvironment", "CC Environment", "CC 環境"), - ("ec.perspective.ccsphere", "CC Sphere", "CC 球體"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC 聚光燈"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "圓柱包覆"), + ("ec.perspective.ccenvironment", "Environment Map", "環境貼圖"), + ("ec.perspective.ccsphere", "Sphere Wrap", "球面包覆"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "表面聚光燈"), ("ec.perspective.dropshadow", "Drop Shadow", "陰影"), ("ec.perspective.radialshadow", "Radial Shadow", "徑向陰影"), ("ec.sim.carddance", "Card Dance", "卡片舞蹈"), ("ec.sim.caustics", "Caustics", "焦散"), - ("ec.sim.ccballaction", "CC Ball Action", "CC 球狀動作"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC 泡泡"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC 細雨"), - ("ec.sim.cchair", "CC Hair", "CC 毛髮"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC 水銀先生"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC 粒子系統 II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC 粒子世界"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC 像素破碎"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC 下雨"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC 散開"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC 下雪"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC 星爆"), + ("ec.sim.ccballaction", "Ball Grid", "球體網格"), + ("ec.sim.ccbubbles", "Bubbles", "氣泡"), + ("ec.sim.ccdrizzle", "Water Ripples", "水面漣漪"), + ("ec.sim.cchair", "Surface Fibers", "表面纖維"), + ("ec.sim.ccmrmercury", "Liquid Particles", "液體粒子"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "二維粒子發射器"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "三維粒子發射器"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "像素破碎"), + ("ec.sim.ccrainfall", "Rainfall", "降雨"), + ("ec.sim.ccscatterize", "Pixel Scatter", "像素散射"), + ("ec.sim.ccsnowfall", "Snowfall", "降雪"), + ("ec.sim.ccstarburst", "Starfield", "星空"), ("ec.sim.foam", "Foam", "泡沫"), ("ec.sim.particleplayground", "Particle Playground", "粒子遊樂場"), ("ec.sim.shatter", "Shatter", "粉碎"), ("ec.sim.waveworld", "Wave World", "波浪世界"), ("ec.stylize.brushstrokes", "Brush Strokes", "筆觸"), ("ec.stylize.cartoon", "Cartoon", "卡通"), - ("ec.stylize.ccblockload", "CC Block Load", "CC 區塊載入"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC 燒灼底片"), - ("ec.stylize.ccglass", "CC Glass", "CC 玻璃"), - ("ec.stylize.cchextile", "CC HexTile", "CC 六角並排"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC 萬花筒"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC 平滑先生"), - ("ec.stylize.ccplastic", "CC Plastic", "CC 塑膠"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC 重複並排"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC 臨界值"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC 臨界值 RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC 暗角"), + ("ec.stylize.ccblockload", "Block Reveal", "區塊顯現"), + ("ec.stylize.ccburnfilm", "Film Burn", "膠片灼燒"), + ("ec.stylize.ccglass", "Glass Relief", "玻璃浮雕"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "六角形拼貼"), + ("ec.stylize.cckaleida", "Kaleidoscope", "萬花筒"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "調色盤對應"), + ("ec.stylize.ccplastic", "Plastic Shading", "塑膠著色"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "邊緣拼貼重複"), + ("ec.stylize.ccthreshold", "Channel Threshold", "色版臨界值"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB 臨界值"), + ("ec.stylize.ccvignette", "Vignette", "暗角"), ("ec.stylize.coloremboss", "Color Emboss", "彩色浮雕"), ("ec.stylize.emboss", "Emboss", "浮雕"), ("ec.stylize.findedges", "Find Edges", "尋找邊緣"), @@ -746,8 +753,8 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "臨界值"), ("ec.text.numbers", "Numbers", "數字"), ("ec.text.timecode", "Timecode", "時間碼"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC 強制動態模糊"), - ("ec.time.ccwidetime", "CC Wide Time", "CC 寬廣時間"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "時間動態模糊"), + ("ec.time.ccwidetime", "Temporal Blend", "時間混合"), ("ec.time.echo", "Echo", "回音"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "像素動態模糊"), ("ec.time.posterizetime", "Posterize Time", "色調分離時間"), @@ -756,23 +763,23 @@ const TRADITIONAL_CHINESE: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "時間扭曲"), ("ec.transition.blockdissolve", "Block Dissolve", "區塊溶解"), ("ec.transition.cardwipe", "Card Wipe", "卡片擦拭"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC 玻璃擦拭"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC 格線擦拭"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC 影像擦拭"), - ("ec.transition.ccjaws", "CC Jaws", "CC 巨顎"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC 光線擦拭"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC 線條掃掠"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC 徑向縮放擦拭"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC 縮放擦拭"), - ("ec.transition.cctwister", "CC Twister", "CC 扭轉"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC 自動彎曲"), + ("ec.transition.ccglasswipe", "Glass Wipe", "玻璃擦拭"), + ("ec.transition.ccgridwipe", "Grid Wipe", "網格擦拭"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "亮度影像擦拭"), + ("ec.transition.ccjaws", "Serrated Wipe", "鋸齒擦拭"), + ("ec.transition.cclightwipe", "Light Wipe", "光線擦拭"), + ("ec.transition.cclinesweep", "Line Sweep", "線條掃過"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "放射狀縮放擦拭"), + ("ec.transition.ccscalewipe", "Scale Wipe", "縮放擦拭"), + ("ec.transition.cctwister", "Twist Wipe", "扭轉擦拭"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "變形溶解"), ("ec.transition.gradientwipe", "Gradient Wipe", "漸層擦拭"), ("ec.transition.iriswipe", "Iris Wipe", "光圈擦拭"), ("ec.transition.linearwipe", "Linear Wipe", "線性擦拭"), ("ec.transition.radialwipe", "Radial Wipe", "徑向擦拭"), ("ec.transition.venetian", "Venetian Blinds", "百葉窗"), ("ec.utility.applylut", "Apply Color LUT", "套用色彩 LUT"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC 過亮"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "超出範圍顯示"), ("ec.utility.cineon", "Cineon Converter", "Cineon 轉換器"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "色彩設定檔轉換器"), ("ec.utility.facemeasurements", "Face Measurements", "臉部測量"), @@ -796,18 +803,18 @@ const TRADITIONAL_CHINESE: &[Row] = &[ /// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this /// one catalog instead of inventing a second mechanism. A translator fills one row per effect, -/// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the -/// `CC` series, `Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name -/// here rather than being left out; the `VR` and `OCIO` sets take a Japanese tail instead, and -/// `Unmult` keeps its English name in both catalogs. An effect with no row draws its English name -/// anyway, which is what `Twirl (Legacy)` and the two EffectCraft-only effects would do without -/// their rows. +/// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English +/// (`Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name here rather +/// than being left out; the `VR` and `OCIO` sets take a Japanese tail instead, and `Unmult` keeps +/// its English name in both catalogs. An effect with no row draws its English name anyway, which +/// is what `Twirl (Legacy)` and the two EffectCraft-only effects would do without their rows. /// -/// The rows below come from AE 2026's Japanese interface where it was read directly (its Effect menu -/// and its Effects & Presets panel) and from Adobe's own Japanese help where the panel was not to -/// hand. A published glossary is used to cross-check, never as the only source: it normalises the -/// typography (half-width parentheses where AE draws full-width) and still carries CS6-era names for -/// the `CC` series, which AE 2026 leaves in English. +/// The rows of the effects this cleanup did not rename come from AE 2026's Japanese interface +/// where it was read directly (its Effect menu and its Effects & Presets panel) and from Adobe's +/// own Japanese help where the panel was not to hand. A published glossary is used to cross-check, +/// never as the only source: it normalises the typography (half-width parentheses where AE draws +/// full-width) and still carries CS6-era names for the `CC` series. The renamed effects are +/// translated from their new descriptive names. const JAPANESE_EFFECTS: &[Row] = &[ // Effects this application adds itself have no interface to read a name from, so they follow // the Chinese catalog's choice to translate them (顔の測定, 顔のトラッキングポイント) rather than @@ -847,10 +854,10 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"), ("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "カメラぶれ除去"), - ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.cccross", "Cross Blur", "十字ぼかし"), + ("ec.blur.ccradial", "Variable Radial Blur", "可変放射状ぼかし"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "高速放射状ぼかし"), + ("ec.blur.ccvector", "Vector Blur", "ベクトルぼかし"), ("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"), ("ec.blur.compound", "Compound Blur", "ブラー(合成)"), ("ec.blur.directional", "Directional Blur", "ブラー(方向)"), @@ -864,7 +871,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "アリスマチック"), ("ec.channel.blend", "Blend", "ブレンド"), ("ec.channel.calculations", "Calculations", "計算"), - ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.cccomposite", "Source Composite", "元画像合成"), ("ec.channel.combiner", "Channel Combiner", "チャンネルコンバイナー"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "合成アリスマチック"), ("ec.channel.invert", "Invert", "反転"), @@ -879,10 +886,10 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.autolevels", "Auto Levels", "自動レベル補正"), ("ec.color.blackwhite", "Black & White", "白黒"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "輝度&コントラスト"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), - ("ec.color.cckernel", "CC Kernel", "CC Kernel"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "色かぶり補正"), + ("ec.color.cccoloroffset", "Channel Offset", "チャンネルオフセット"), + ("ec.color.cckernel", "Convolution Kernel", "畳み込みカーネル"), + ("ec.color.cctoner", "Multi-Tone Tint", "多色調ティント"), ("ec.color.changecolor", "Change Color", "色を変更"), ("ec.color.changetocolor", "Change to Color", "他のカラーへ変更"), ("ec.color.channelmixer", "Channel Mixer", "チャンネルミキサー"), @@ -899,7 +906,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "色抜き"), ("ec.color.levels", "Levels", "レベル補正"), ("ec.color.levelsic", "Levels (Individual Controls)", "レベル(個々の制御)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"), + ("ec.color.lumetri", "Color Grading", "カラーグレーディング"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 変換"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色空間変換"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 表示変換"), @@ -909,7 +916,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS不定マップ"), ("ec.color.selectivecolor", "Selective Color", "特定色域の選択"), ("ec.color.shadowhighlight", "Shadow/Highlight", "シャドウ・ハイライト"), - ("ec.color.tint", "Tint", "色かぶり補正"), + ("ec.color.tint", "Tint", "着色"), ("ec.color.tritone", "Tritone", "トライトーン"), ("ec.color.vibrance", "Vibrance", "自然な彩度"), ("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"), @@ -923,20 +930,20 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.control.slider", "Slider Control", "スライダー制御"), ("ec.distort.bezierwarp", "Bezier Warp", "ベジェワープ"), ("ec.distort.bulge", "Bulge", "バルジ"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Lens"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), - ("ec.distort.ccslant", "CC Slant", "CC Slant"), - ("ec.distort.ccsmear", "CC Smear", "CC Smear"), - ("ec.distort.ccsplit", "CC Split", "CC Split"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "柔軟ベンド"), + ("ec.distort.ccbendit", "Two-Point Bend", "2点ベンド"), + ("ec.distort.ccblobbylize", "Organic Displacement", "有機変位"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "ダブルレンズ変形"), + ("ec.distort.ccgriddler", "Tile Grid", "タイルグリッド"), + ("ec.distort.cclens", "Lens Distortion", "レンズ歪み"), + ("ec.distort.ccpageturn", "Page Curl", "ページカール"), + ("ec.distort.ccpowerpin", "Perspective Pin", "遠近ピン"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "パルス波紋"), + ("ec.distort.ccslant", "Slant", "傾斜"), + ("ec.distort.ccsmear", "Directional Smear", "方向性にじみ"), + ("ec.distort.ccsplit", "Split Warp", "分割変形"), + ("ec.distort.ccsplit2", "Dual Split Warp", "二重分割変形"), + ("ec.distort.cctiler", "Scaled Tiles", "スケールタイル"), ("ec.distort.cornerpin", "Corner Pin", "コーナーピン"), ("ec.distort.displacementmap", "Displacement Map", "ディスプレイスメントマップ"), ("ec.distort.liquify", "Liquify", "ゆがみ"), @@ -963,11 +970,11 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "オーディオスペクトラム"), ("ec.generate.audiowaveform", "Audio Waveform", "オーディオウェーブフォーム"), ("ec.generate.beam", "Beam", "レーザー"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), - ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.ccgluegun", "Paint Beads", "絵の具の粒"), + ("ec.generate.cclightburst", "Radial Light Burst", "放射状の光"), + ("ec.generate.cclightrays", "Light Rays", "光線"), + ("ec.generate.cclightsweep", "Light Sweep", "光のスイープ"), + ("ec.generate.ccthreads", "Woven Threads", "織り糸"), ("ec.generate.cellpattern", "Cell Pattern", "セルパターン"), ("ec.generate.checkerboard", "Checkerboard", "チェッカーボード"), ("ec.generate.circle", "Circle", "円"), @@ -986,7 +993,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.vegas", "Vegas", "ベガス"), ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "Advanced Spill Suppressor"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.ccsimplewireremoval", "Line Removal", "ライン除去"), ("ec.key.colordifference", "Color Difference Key", "カラー差キー"), ("ec.key.colorkey", "Color Key", "カラーキー"), ("ec.key.colorrange", "Color Range", "色範囲"), @@ -997,7 +1004,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), - ("ec.keying.keylight", "Key Light", "Keylight (1.2)"), + ("ec.keying.keylight", "Screen Color Key", "スクリーンカラーキー"), ("ec.matte.mattechoker", "Matte Choker", "マットチョーク"), ("ec.matte.refinehard", "Refine Hard Matte", "ハードマットを調整"), ("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"), @@ -1019,49 +1026,49 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "基本テキスト"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), ("ec.obsolete.lightning", "Lightning", "稲妻"), - ("ec.obsolete.mochashape", "Mocha shape", "mocha shape"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "トラッキングシェイプマット"), ("ec.obsolete.pathtext", "Path Text", "パステキスト"), ("ec.perspective.3dglasses", "3D Glasses", "3Dメガネ"), ("ec.perspective.bevelalpha", "Bevel Alpha", "ベベルアルファ"), ("ec.perspective.beveledges", "Bevel Edges", "ベベルエッジ"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D カメラトラッカー"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "円筒ラップ"), + ("ec.perspective.ccenvironment", "Environment Map", "環境マップ"), + ("ec.perspective.ccsphere", "Sphere Wrap", "球面ラップ"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "表面スポットライト"), ("ec.perspective.dropshadow", "Drop Shadow", "ドロップシャドウ"), ("ec.perspective.radialshadow", "Radial Shadow", "放射状シャドウ"), ("ec.sim.carddance", "Card Dance", "カードダンス"), ("ec.sim.caustics", "Caustics", "コースティック"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), - ("ec.sim.cchair", "CC Hair", "CC Hair"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.ccballaction", "Ball Grid", "球体グリッド"), + ("ec.sim.ccbubbles", "Bubbles", "気泡"), + ("ec.sim.ccdrizzle", "Water Ripples", "水面の波紋"), + ("ec.sim.cchair", "Surface Fibers", "表面の繊維"), + ("ec.sim.ccmrmercury", "Liquid Particles", "液体パーティクル"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "2Dパーティクルエミッター"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "3Dパーティクルエミッター"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "ピクセル破砕"), + ("ec.sim.ccrainfall", "Rainfall", "雨"), + ("ec.sim.ccscatterize", "Pixel Scatter", "ピクセル散乱"), + ("ec.sim.ccsnowfall", "Snowfall", "雪"), + ("ec.sim.ccstarburst", "Starfield", "星空"), ("ec.sim.foam", "Foam", "泡"), ("ec.sim.particleplayground", "Particle Playground", "パーティクルプレイグラウンド"), ("ec.sim.shatter", "Shatter", "シャター"), ("ec.sim.waveworld", "Wave World", "ウェーブワールド"), ("ec.stylize.brushstrokes", "Brush Strokes", "ブラシストローク"), ("ec.stylize.cartoon", "Cartoon", "カートゥーン"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glass"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.ccblockload", "Block Reveal", "ブロック表示"), + ("ec.stylize.ccburnfilm", "Film Burn", "フィルム焼け"), + ("ec.stylize.ccglass", "Glass Relief", "ガラスレリーフ"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "六角形タイル"), + ("ec.stylize.cckaleida", "Kaleidoscope", "万華鏡"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "パレットマップ"), + ("ec.stylize.ccplastic", "Plastic Shading", "プラスチックシェーディング"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "エッジタイル反復"), + ("ec.stylize.ccthreshold", "Channel Threshold", "チャンネルしきい値"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGBしきい値"), + ("ec.stylize.ccvignette", "Vignette", "ビネット"), ("ec.stylize.coloremboss", "Color Emboss", "カラーエンボス"), ("ec.stylize.emboss", "Emboss", "エンボス"), ("ec.stylize.findedges", "Find Edges", "輪郭検出"), @@ -1076,8 +1083,8 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "しきい値"), ("ec.text.numbers", "Numbers", "番号"), ("ec.text.timecode", "Timecode", "タイムコード"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "時間方向モーションブラー"), + ("ec.time.ccwidetime", "Temporal Blend", "時間ブレンド"), ("ec.time.echo", "Echo", "エコー"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), ("ec.time.posterizetime", "Posterize Time", "ポスタリゼーション時間"), @@ -1086,23 +1093,23 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "タイムワープ"), ("ec.transition.blockdissolve", "Block Dissolve", "ブロックディゾルブ"), ("ec.transition.cardwipe", "Card Wipe", "カードワイプ"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), - ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), - ("ec.transition.cctwister", "CC Twister", "CC Twister"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "ガラスワイプ"), + ("ec.transition.ccgridwipe", "Grid Wipe", "グリッドワイプ"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "輝度画像ワイプ"), + ("ec.transition.ccjaws", "Serrated Wipe", "鋸歯状ワイプ"), + ("ec.transition.cclightwipe", "Light Wipe", "ライトワイプ"), + ("ec.transition.cclinesweep", "Line Sweep", "ラインスイープ"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "放射状スケールワイプ"), + ("ec.transition.ccscalewipe", "Scale Wipe", "スケールワイプ"), + ("ec.transition.cctwister", "Twist Wipe", "ツイストワイプ"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "変形ディゾルブ"), ("ec.transition.gradientwipe", "Gradient Wipe", "グラデーションワイプ"), ("ec.transition.iriswipe", "Iris Wipe", "アイリスワイプ"), ("ec.transition.linearwipe", "Linear Wipe", "リニアワイプ"), ("ec.transition.radialwipe", "Radial Wipe", "放射状ワイプ"), ("ec.transition.venetian", "Venetian Blinds", "ブラインド"), ("ec.utility.applylut", "Apply Color LUT", "カラー LUT を適用"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "範囲外表示"), ("ec.utility.cineon", "Cineon Converter", "Cineon コンバーター"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "カラープロファイルコンバーター"), ("ec.utility.growbounds", "Grow Bounds", "範囲拡張"), @@ -1176,9 +1183,9 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ /// Georgian (`ka`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning /// of the English names, with the terms the Georgian menus and panels use (FilmCraft's Georgian -/// for the effects the two apps share). The `CC` series and the brand names (`Cryptomatte`, -/// `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`) stay in English, as -/// Adobe's Japanese interface keeps the `CC` series; `VR` and `OCIO` keep their prefix. +/// for the effects the two apps share). The brand names (`Cryptomatte`, `EXtractoR`, `IDentifier`, +/// `Key Light`, `Unmult`) stay in English; `VR` and `OCIO` keep their prefix. The renamed effects +/// are translated from their new descriptive names. const GEORGIAN: &[Row] = &[ ("ec.3d.channelextract", "3D Channel Extract", "3D არხის ამოღება"), ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), @@ -1205,10 +1212,10 @@ const GEORGIAN: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "ბილატერალური გაბუნდოვნება"), ("ec.blur.cameralens", "Camera Lens Blur", "კამერის ობიექტივის გაბუნდოვნება"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "კამერის რყევით გაბუნდოვნების მოცილება"), - ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.cccross", "Cross Blur", "ჯვარედინი დაბინდვა"), + ("ec.blur.ccradial", "Variable Radial Blur", "ცვლადი რადიალური დაბინდვა"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "სწრაფი რადიალური დაბინდვა"), + ("ec.blur.ccvector", "Vector Blur", "ვექტორული დაბინდვა"), ("ec.blur.channel", "Channel Blur", "არხის გაბუნდოვნება"), ("ec.blur.compound", "Compound Blur", "შედგენილი გაბუნდოვნება"), ("ec.blur.directional", "Directional Blur", "მიმართული გაბუნდოვნება"), @@ -1222,7 +1229,7 @@ const GEORGIAN: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "არითმეტიკა"), ("ec.channel.blend", "Blend", "შერევა"), ("ec.channel.calculations", "Calculations", "გამოთვლები"), - ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.cccomposite", "Source Composite", "წყაროს კომპოზიცია"), ("ec.channel.combiner", "Channel Combiner", "არხების გამაერთიანებელი"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "შედგენილი არითმეტიკა"), ("ec.channel.invert", "Invert", "ინვერსია"), @@ -1238,10 +1245,10 @@ const GEORGIAN: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "შავ-თეთრი"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "სიკაშკაშე და კონტრასტი"), ("ec.color.broadcast", "Broadcast Colors", "სამაუწყებლო ფერები"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), - ("ec.color.cckernel", "CC Kernel", "CC Kernel"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "ფერის ნეიტრალიზაცია"), + ("ec.color.cccoloroffset", "Channel Offset", "არხების წანაცვლება"), + ("ec.color.cckernel", "Convolution Kernel", "კონვოლუციის ბირთვი"), + ("ec.color.cctoner", "Multi-Tone Tint", "მრავალტონიანი შეფერილობა"), ("ec.color.changecolor", "Change Color", "ფერის შეცვლა"), ("ec.color.changetocolor", "Change to Color", "ფერად შეცვლა"), ("ec.color.channelmixer", "Channel Mixer", "არხების მიქშერი"), @@ -1258,7 +1265,7 @@ const GEORGIAN: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "ფერის დატოვება"), ("ec.color.levels", "Levels", "დონეები"), ("ec.color.levelsic", "Levels (Individual Controls)", "დონეები (ცალკეული მართვა)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.lumetri", "Color Grading", "ფერთა კორექცია"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL გარდაქმნა"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO ფერთა სივრცის გარდაქმნა"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO ეკრანის გარდაქმნა"), @@ -1283,20 +1290,20 @@ const GEORGIAN: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "სტერეო 3D მართვა"), ("ec.distort.bezierwarp", "Bezier Warp", "ბეზიეს დეფორმაცია"), ("ec.distort.bulge", "Bulge", "გამობერილობა"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Lens"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), - ("ec.distort.ccslant", "CC Slant", "CC Slant"), - ("ec.distort.ccsmear", "CC Smear", "CC Smear"), - ("ec.distort.ccsplit", "CC Split", "CC Split"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "მოქნილი მოღუნვა"), + ("ec.distort.ccbendit", "Two-Point Bend", "ორწერტილიანი მოღუნვა"), + ("ec.distort.ccblobbylize", "Organic Displacement", "ორგანული გადაადგილება"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "ორმაგი ლინზით დეფორმაცია"), + ("ec.distort.ccgriddler", "Tile Grid", "ფილების ბადე"), + ("ec.distort.cclens", "Lens Distortion", "ლინზის დამახინჯება"), + ("ec.distort.ccpageturn", "Page Curl", "გვერდის დახვევა"), + ("ec.distort.ccpowerpin", "Perspective Pin", "პერსპექტიული დამაგრება"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "პულსური ტალღა"), + ("ec.distort.ccslant", "Slant", "დახრა"), + ("ec.distort.ccsmear", "Directional Smear", "მიმართულებითი გაწელვა"), + ("ec.distort.ccsplit", "Split Warp", "გაყოფილი დეფორმაცია"), + ("ec.distort.ccsplit2", "Dual Split Warp", "ორმაგად გაყოფილი დეფორმაცია"), + ("ec.distort.cctiler", "Scaled Tiles", "მასშტაბირებული ფილები"), ("ec.distort.cornerpin", "Corner Pin", "კუთხეების მიმაგრება"), ("ec.distort.displacementmap", "Displacement Map", "წანაცვლების რუკა"), ("ec.distort.liquify", "Liquify", "გათხევადება"), @@ -1324,11 +1331,11 @@ const GEORGIAN: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "აუდიოს სპექტრი"), ("ec.generate.audiowaveform", "Audio Waveform", "აუდიოს ტალღის ფორმა"), ("ec.generate.beam", "Beam", "სხივი"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), - ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.ccgluegun", "Paint Beads", "საღებავის მარცვლები"), + ("ec.generate.cclightburst", "Radial Light Burst", "რადიალური ნათება"), + ("ec.generate.cclightrays", "Light Rays", "სინათლის სხივები"), + ("ec.generate.cclightsweep", "Light Sweep", "სინათლის გავლა"), + ("ec.generate.ccthreads", "Woven Threads", "ნაქსოვი ძაფები"), ("ec.generate.cellpattern", "Cell Pattern", "უჯრედების პატერნი"), ("ec.generate.checkerboard", "Checkerboard", "ჭადრაკის დაფა"), ("ec.generate.circle", "Circle", "წრე"), @@ -1347,7 +1354,7 @@ const GEORGIAN: &[Row] = &[ ("ec.generate.vegas", "Vegas", "ვეგასი"), ("ec.generate.writeon", "Write-on", "თანდათანობითი წერა"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "ფერის გადაღვრის გაფართოებული ჩამხშობი"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.ccsimplewireremoval", "Line Removal", "ხაზის მოცილება"), ("ec.key.colordifference", "Color Difference Key", "ფერთა სხვაობის გასაღები"), ("ec.key.colorkey", "Color Key", "ფერის გასაღები"), ("ec.key.colorrange", "Color Range", "ფერების დიაპაზონი"), @@ -1360,7 +1367,7 @@ const GEORGIAN: &[Row] = &[ ("ec.key.screen", "Screen Key", "ეკრანის გასაღები"), ("ec.key.spill", "Spill Suppressor", "ფერის გადაღვრის ჩამხშობი"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "ეკრანის ფერის გამოყოფა"), ("ec.matte.mattechoker", "Matte Choker", "მატის შემკუმშველი"), ("ec.matte.objectmatte", "Object Matte", "ობიექტის მატი"), ("ec.matte.refinehard", "Refine Hard Matte", "მკვეთრი მატის დახვეწა"), @@ -1384,50 +1391,50 @@ const GEORGIAN: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "საბაზო ტექსტი"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "გაუსის გაბუნდოვნება (მოძველებული)"), ("ec.obsolete.lightning", "Lightning", "ელვა"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha-ს ფიგურა"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "თვალთვალის ფორმის ნიღაბი"), ("ec.obsolete.pathtext", "Path Text", "ტექსტი ტრაექტორიაზე"), ("ec.paint.paint", "Paint", "ხატვა"), ("ec.perspective.3dglasses", "3D Glasses", "3D სათვალე"), ("ec.perspective.bevelalpha", "Bevel Alpha", "ალფას ფასკა"), ("ec.perspective.beveledges", "Bevel Edges", "კიდეების ფასკა"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D კამერის ტრეკერი"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "ცილინდრზე შემოხვევა"), + ("ec.perspective.ccenvironment", "Environment Map", "გარემოს რუკა"), + ("ec.perspective.ccsphere", "Sphere Wrap", "სფეროზე შემოხვევა"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "ზედაპირის პროჟექტორი"), ("ec.perspective.dropshadow", "Drop Shadow", "ჩრდილი"), ("ec.perspective.radialshadow", "Radial Shadow", "რადიალური ჩრდილი"), ("ec.sim.carddance", "Card Dance", "მოცეკვავე ბარათები"), ("ec.sim.caustics", "Caustics", "კაუსტიკა"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), - ("ec.sim.cchair", "CC Hair", "CC Hair"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.ccballaction", "Ball Grid", "ბურთულების ბადე"), + ("ec.sim.ccbubbles", "Bubbles", "ბუშტები"), + ("ec.sim.ccdrizzle", "Water Ripples", "წყლის ტალღები"), + ("ec.sim.cchair", "Surface Fibers", "ზედაპირის ბოჭკოები"), + ("ec.sim.ccmrmercury", "Liquid Particles", "თხევადი ნაწილაკები"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "ნაწილაკების 2D წყარო"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "ნაწილაკების 3D წყარო"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "პიქსელების დაშლა"), + ("ec.sim.ccrainfall", "Rainfall", "წვიმა"), + ("ec.sim.ccscatterize", "Pixel Scatter", "პიქსელების გაფანტვა"), + ("ec.sim.ccsnowfall", "Snowfall", "თოვა"), + ("ec.sim.ccstarburst", "Starfield", "ვარსკვლავების ველი"), ("ec.sim.foam", "Foam", "ქაფი"), ("ec.sim.particleplayground", "Particle Playground", "ნაწილაკების სიმულაცია"), ("ec.sim.shatter", "Shatter", "დამსხვრევა"), ("ec.sim.waveworld", "Wave World", "ტალღების სამყარო"), ("ec.stylize.brushstrokes", "Brush Strokes", "ფუნჯის შტრიხები"), ("ec.stylize.cartoon", "Cartoon", "მულტფილმის სტილი"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glass"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.ccblockload", "Block Reveal", "ბლოკებით გამოჩენა"), + ("ec.stylize.ccburnfilm", "Film Burn", "ფირის დაწვა"), + ("ec.stylize.ccglass", "Glass Relief", "მინის რელიეფი"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "ექვსკუთხა ფილები"), + ("ec.stylize.cckaleida", "Kaleidoscope", "კალეიდოსკოპი"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "პალიტრის რუკა"), + ("ec.stylize.ccplastic", "Plastic Shading", "პლასტმასის დაჩრდილვა"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "კიდეების განმეორება"), + ("ec.stylize.ccthreshold", "Channel Threshold", "არხის ზღვარი"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB ზღვარი"), + ("ec.stylize.ccvignette", "Vignette", "ვინიეტირება"), ("ec.stylize.coloremboss", "Color Emboss", "ფერადი რელიეფი"), ("ec.stylize.emboss", "Emboss", "რელიეფი"), ("ec.stylize.findedges", "Find Edges", "კიდეების პოვნა"), @@ -1442,8 +1449,8 @@ const GEORGIAN: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "ზღვარი"), ("ec.text.numbers", "Numbers", "რიცხვები"), ("ec.text.timecode", "Timecode", "თაიმკოდი"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "დროითი მოძრაობის დაბინდვა"), + ("ec.time.ccwidetime", "Temporal Blend", "დროითი შერევა"), ("ec.time.echo", "Echo", "ექო"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "პიქსელების მოძრაობის გაბუნდოვნება"), ("ec.time.posterizetime", "Posterize Time", "დროის პოსტერიზაცია"), @@ -1452,23 +1459,23 @@ const GEORGIAN: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "Timewarp"), ("ec.transition.blockdissolve", "Block Dissolve", "ბლოკური გადადნობა"), ("ec.transition.cardwipe", "Card Wipe", "ბარათებით წმენდა"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), - ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), - ("ec.transition.cctwister", "CC Twister", "CC Twister"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "მინის გადასვლა"), + ("ec.transition.ccgridwipe", "Grid Wipe", "ბადის გადასვლა"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "სიკაშკაშის გამოსახულებით გადასვლა"), + ("ec.transition.ccjaws", "Serrated Wipe", "დაკბილული გადასვლა"), + ("ec.transition.cclightwipe", "Light Wipe", "სინათლის გადასვლა"), + ("ec.transition.cclinesweep", "Line Sweep", "ხაზების გავლა"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "რადიალური მასშტაბის გადასვლა"), + ("ec.transition.ccscalewipe", "Scale Wipe", "მასშტაბის გადასვლა"), + ("ec.transition.cctwister", "Twist Wipe", "მოგრეხილი გადასვლა"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "დეფორმაციით გადასვლა"), ("ec.transition.gradientwipe", "Gradient Wipe", "გრადიენტული წმენდა"), ("ec.transition.iriswipe", "Iris Wipe", "დიაფრაგმული წმენდა"), ("ec.transition.linearwipe", "Linear Wipe", "წრფივი წმენდა"), ("ec.transition.radialwipe", "Radial Wipe", "რადიალური წმენდა"), ("ec.transition.venetian", "Venetian Blinds", "ჟალუზი"), ("ec.utility.applylut", "Apply Color LUT", "ფერის LUT-ის გამოყენება"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "დიაპაზონის გარეთ ჩვენება"), ("ec.utility.cineon", "Cineon Converter", "Cineon-ის გარდამქმნელი"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "ფერის პროფილის გარდამქმნელი"), ("ec.utility.facemeasurements", "Face Measurements", "სახის გაზომვები"), @@ -1517,8 +1524,8 @@ const GEORGIAN_CATEGORIES: &[(&str, &str)] = &[ /// French (`fr`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning of the /// English names, with the terms the French menus use (calque, estampe for a matte, incrustation for -/// keying). The `CC` series and the brand names stay in English, `VR`, `OCIO`, `PS` and `HDR` keep -/// their tag after the translated name. +/// keying). The brand names stay in English, `VR`, `OCIO`, `PS` and `HDR` keep their tag after the +/// translated name. const FRENCH: &[Row] = &[ ("ec.3d.channelextract", "3D Channel Extract", "Extraction de canal 3D"), ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), @@ -1544,10 +1551,10 @@ const FRENCH: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "Flou bilatéral"), ("ec.blur.cameralens", "Camera Lens Blur", "Flou de l’objectif de la caméra"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "Correction du flou de bougé de caméra"), - ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.cccross", "Cross Blur", "Flou croisé"), + ("ec.blur.ccradial", "Variable Radial Blur", "Flou radial variable"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "Flou radial rapide"), + ("ec.blur.ccvector", "Vector Blur", "Flou vectoriel"), ("ec.blur.channel", "Channel Blur", "Flou de canal"), ("ec.blur.compound", "Compound Blur", "Flou composé"), ("ec.blur.directional", "Directional Blur", "Flou directionnel"), @@ -1561,7 +1568,7 @@ const FRENCH: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "Arithmétique"), ("ec.channel.blend", "Blend", "Fusion"), ("ec.channel.calculations", "Calculations", "Calculs"), - ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.cccomposite", "Source Composite", "Composition de la source"), ("ec.channel.combiner", "Channel Combiner", "Combinateur de canaux"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "Arithmétique composée"), ("ec.channel.invert", "Invert", "Inversion"), @@ -1577,10 +1584,10 @@ const FRENCH: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "Noir et blanc"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "Luminosité et contraste"), ("ec.color.broadcast", "Broadcast Colors", "Couleurs de diffusion"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), - ("ec.color.cckernel", "CC Kernel", "CC Kernel"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "Neutralisation des couleurs"), + ("ec.color.cccoloroffset", "Channel Offset", "Décalage des canaux"), + ("ec.color.cckernel", "Convolution Kernel", "Noyau de convolution"), + ("ec.color.cctoner", "Multi-Tone Tint", "Teinte multiton"), ("ec.color.changecolor", "Change Color", "Changer la couleur"), ("ec.color.changetocolor", "Change to Color", "Changer en couleur"), ("ec.color.channelmixer", "Channel Mixer", "Mélangeur de canaux"), @@ -1597,7 +1604,7 @@ const FRENCH: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "Conserver la couleur"), ("ec.color.levels", "Levels", "Niveaux"), ("ec.color.levelsic", "Levels (Individual Controls)", "Niveaux (contrôles individuels)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.lumetri", "Color Grading", "Étalonnage des couleurs"), ("ec.color.ociocdl", "OCIO CDL Transform", "Transformation CDL OCIO"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "Transformation d’espace colorimétrique OCIO"), ("ec.color.ociodisplay", "OCIO Display Transform", "Transformation d’affichage OCIO"), @@ -1622,20 +1629,20 @@ const FRENCH: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "Contrôles stéréo 3D"), ("ec.distort.bezierwarp", "Bezier Warp", "Déformation Bézier"), ("ec.distort.bulge", "Bulge", "Renflement"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Lens"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), - ("ec.distort.ccslant", "CC Slant", "CC Slant"), - ("ec.distort.ccsmear", "CC Smear", "CC Smear"), - ("ec.distort.ccsplit", "CC Split", "CC Split"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "Courbure souple"), + ("ec.distort.ccbendit", "Two-Point Bend", "Courbure à deux points"), + ("ec.distort.ccblobbylize", "Organic Displacement", "Déplacement organique"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "Déformation à deux lentilles"), + ("ec.distort.ccgriddler", "Tile Grid", "Grille de tuiles"), + ("ec.distort.cclens", "Lens Distortion", "Distorsion de lentille"), + ("ec.distort.ccpageturn", "Page Curl", "Page enroulée"), + ("ec.distort.ccpowerpin", "Perspective Pin", "Épinglage en perspective"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "Ondulation pulsée"), + ("ec.distort.ccslant", "Slant", "Inclinaison"), + ("ec.distort.ccsmear", "Directional Smear", "Étalement directionnel"), + ("ec.distort.ccsplit", "Split Warp", "Déformation divisée"), + ("ec.distort.ccsplit2", "Dual Split Warp", "Déformation à double division"), + ("ec.distort.cctiler", "Scaled Tiles", "Tuiles mises à l’échelle"), ("ec.distort.cornerpin", "Corner Pin", "Épinglage d’angles"), ("ec.distort.displacementmap", "Displacement Map", "Carte de déplacement"), ("ec.distort.liquify", "Liquify", "Liquéfier"), @@ -1663,11 +1670,11 @@ const FRENCH: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "Spectre audio"), ("ec.generate.audiowaveform", "Audio Waveform", "Forme d’onde audio"), ("ec.generate.beam", "Beam", "Faisceau"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), - ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.ccgluegun", "Paint Beads", "Perles de peinture"), + ("ec.generate.cclightburst", "Radial Light Burst", "Éclat lumineux radial"), + ("ec.generate.cclightrays", "Light Rays", "Rayons lumineux"), + ("ec.generate.cclightsweep", "Light Sweep", "Balayage lumineux"), + ("ec.generate.ccthreads", "Woven Threads", "Fils tissés"), ("ec.generate.cellpattern", "Cell Pattern", "Motif cellulaire"), ("ec.generate.checkerboard", "Checkerboard", "Damier"), ("ec.generate.circle", "Circle", "Cercle"), @@ -1686,7 +1693,7 @@ const FRENCH: &[Row] = &[ ("ec.generate.vegas", "Vegas", "Vegas"), ("ec.generate.writeon", "Write-on", "Écriture progressive"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "Suppresseur de débordement avancé"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.ccsimplewireremoval", "Line Removal", "Suppression de ligne"), ("ec.key.colordifference", "Color Difference Key", "Incrustation par différence de couleur"), ("ec.key.colorkey", "Color Key", "Incrustation couleur"), ("ec.key.colorrange", "Color Range", "Plage de couleurs"), @@ -1699,7 +1706,7 @@ const FRENCH: &[Row] = &[ ("ec.key.screen", "Screen Key", "Incrustation d’écran"), ("ec.key.spill", "Spill Suppressor", "Suppresseur de débordement"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "Incrustation par couleur d’écran"), ("ec.matte.mattechoker", "Matte Choker", "Réducteur d’estampe"), ("ec.matte.objectmatte", "Object Matte", "Estampe d’objet"), ("ec.matte.refinehard", "Refine Hard Matte", "Affiner l’estampe nette"), @@ -1723,50 +1730,50 @@ const FRENCH: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "Texte de base"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "Flou gaussien (hérité)"), ("ec.obsolete.lightning", "Lightning", "Foudre"), - ("ec.obsolete.mochashape", "Mocha shape", "Forme Mocha"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "Cache de forme suivie"), ("ec.obsolete.pathtext", "Path Text", "Texte sur tracé"), ("ec.paint.paint", "Paint", "Peinture"), ("ec.perspective.3dglasses", "3D Glasses", "Lunettes 3D"), ("ec.perspective.bevelalpha", "Bevel Alpha", "Biseau alpha"), ("ec.perspective.beveledges", "Bevel Edges", "Biseau des bords"), ("ec.perspective.cameratracker", "3D Camera Tracker", "Suivi de caméra 3D"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "Enroulement cylindrique"), + ("ec.perspective.ccenvironment", "Environment Map", "Carte d’environnement"), + ("ec.perspective.ccsphere", "Sphere Wrap", "Enroulement sphérique"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "Projecteur de surface"), ("ec.perspective.dropshadow", "Drop Shadow", "Ombre portée"), ("ec.perspective.radialshadow", "Radial Shadow", "Ombre radiale"), ("ec.sim.carddance", "Card Dance", "Danse de cartes"), ("ec.sim.caustics", "Caustics", "Caustiques"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), - ("ec.sim.cchair", "CC Hair", "CC Hair"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.ccballaction", "Ball Grid", "Grille de sphères"), + ("ec.sim.ccbubbles", "Bubbles", "Bulles"), + ("ec.sim.ccdrizzle", "Water Ripples", "Ondulations de l’eau"), + ("ec.sim.cchair", "Surface Fibers", "Fibres de surface"), + ("ec.sim.ccmrmercury", "Liquid Particles", "Particules liquides"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "Émetteur de particules 2D"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "Émetteur de particules 3D"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "Éclatement des pixels"), + ("ec.sim.ccrainfall", "Rainfall", "Pluie"), + ("ec.sim.ccscatterize", "Pixel Scatter", "Dispersion des pixels"), + ("ec.sim.ccsnowfall", "Snowfall", "Neige"), + ("ec.sim.ccstarburst", "Starfield", "Champ d’étoiles"), ("ec.sim.foam", "Foam", "Mousse"), ("ec.sim.particleplayground", "Particle Playground", "Terrain de jeu de particules"), ("ec.sim.shatter", "Shatter", "Éclatement"), ("ec.sim.waveworld", "Wave World", "Monde des vagues"), ("ec.stylize.brushstrokes", "Brush Strokes", "Coups de pinceau"), ("ec.stylize.cartoon", "Cartoon", "Dessin animé"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glass"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.ccblockload", "Block Reveal", "Révélation par blocs"), + ("ec.stylize.ccburnfilm", "Film Burn", "Brûlure de pellicule"), + ("ec.stylize.ccglass", "Glass Relief", "Relief de verre"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "Tuiles hexagonales"), + ("ec.stylize.cckaleida", "Kaleidoscope", "Kaléidoscope"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "Palettmappning"), + ("ec.stylize.ccplastic", "Plastic Shading", "Ombrage plastique"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "Répétition des tuiles aux bords"), + ("ec.stylize.ccthreshold", "Channel Threshold", "Seuil de canal"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "Seuil RVB"), + ("ec.stylize.ccvignette", "Vignette", "Vignettage"), ("ec.stylize.coloremboss", "Color Emboss", "Estampage couleur"), ("ec.stylize.emboss", "Emboss", "Estampage"), ("ec.stylize.findedges", "Find Edges", "Détection des contours"), @@ -1781,8 +1788,8 @@ const FRENCH: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "Seuil"), ("ec.text.numbers", "Numbers", "Nombres"), ("ec.text.timecode", "Timecode", "Timecode"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "Flou de mouvement temporel"), + ("ec.time.ccwidetime", "Temporal Blend", "Fusion temporelle"), ("ec.time.echo", "Echo", "Écho"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "Flou de mouvement des pixels"), ("ec.time.posterizetime", "Posterize Time", "Postérisation temporelle"), @@ -1791,23 +1798,23 @@ const FRENCH: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "Déformation temporelle"), ("ec.transition.blockdissolve", "Block Dissolve", "Fondu par blocs"), ("ec.transition.cardwipe", "Card Wipe", "Volet de cartes"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), - ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), - ("ec.transition.cctwister", "CC Twister", "CC Twister"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "Volet de verre"), + ("ec.transition.ccgridwipe", "Grid Wipe", "Volet en grille"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "Volet par luminance d’image"), + ("ec.transition.ccjaws", "Serrated Wipe", "Volet dentelé"), + ("ec.transition.cclightwipe", "Light Wipe", "Volet lumineux"), + ("ec.transition.cclinesweep", "Line Sweep", "Balayage de lignes"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "Volet d’échelle radial"), + ("ec.transition.ccscalewipe", "Scale Wipe", "Volet d’échelle"), + ("ec.transition.cctwister", "Twist Wipe", "Volet torsadé"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "Fondu déformant"), ("ec.transition.gradientwipe", "Gradient Wipe", "Volet en dégradé"), ("ec.transition.iriswipe", "Iris Wipe", "Volet en iris"), ("ec.transition.linearwipe", "Linear Wipe", "Volet linéaire"), ("ec.transition.radialwipe", "Radial Wipe", "Volet radial"), ("ec.transition.venetian", "Venetian Blinds", "Stores vénitiens"), ("ec.utility.applylut", "Apply Color LUT", "Appliquer une LUT de couleur"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "Affichage hors plage"), ("ec.utility.cineon", "Cineon Converter", "Convertisseur Cineon"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "Convertisseur de profil colorimétrique"), ("ec.utility.facemeasurements", "Face Measurements", "Mesures du visage"), @@ -1909,10 +1916,10 @@ const SWEDISH: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "Bilateral oskärpa"), ("ec.blur.cameralens", "Camera Lens Blur", "Kameralinsoskärpa"), ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "Ta bort oskärpa från kameraskakning"), - ("ec.blur.cccross", "CC Cross Blur", "CC Korsoskärpa"), - ("ec.blur.ccradial", "CC Radial Blur", "CC Radiell oskärpa"), - ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Snabb radiell oskärpa"), - ("ec.blur.ccvector", "CC Vector Blur", "CC Vektoroskärpa"), + ("ec.blur.cccross", "Cross Blur", "Korsoskärpa"), + ("ec.blur.ccradial", "Variable Radial Blur", "Variabel radiell oskärpa"), + ("ec.blur.ccradialfast", "Fast Radial Blur", "Snabb radiell oskärpa"), + ("ec.blur.ccvector", "Vector Blur", "Vektoroskärpa"), ("ec.blur.channel", "Channel Blur", "Kanaloskärpa"), ("ec.blur.compound", "Compound Blur", "Sammansatt oskärpa"), ("ec.blur.directional", "Directional Blur", "Riktad oskärpa"), @@ -1926,7 +1933,7 @@ const SWEDISH: &[Row] = &[ ("ec.channel.arithmetic", "Arithmetic", "Aritmetik"), ("ec.channel.blend", "Blend", "Blanda"), ("ec.channel.calculations", "Calculations", "Beräkningar"), - ("ec.channel.cccomposite", "CC Composite", "CC Komposit"), + ("ec.channel.cccomposite", "Source Composite", "Källkomposition"), ("ec.channel.combiner", "Channel Combiner", "Kanalkombinerare"), ("ec.channel.compoundarithmetic", "Compound Arithmetic", "Sammansatt aritmetik"), ("ec.channel.invert", "Invert", "Invertera"), @@ -1942,10 +1949,10 @@ const SWEDISH: &[Row] = &[ ("ec.color.blackwhite", "Black & White", "Svartvitt"), ("ec.color.brightnesscontrast", "Brightness & Contrast", "Ljusstyrka och kontrast"), ("ec.color.broadcast", "Broadcast Colors", "Broadcast-färger"), - ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Färgneutraliserare"), - ("ec.color.cccoloroffset", "CC Color Offset", "CC Färgförskjutning"), - ("ec.color.cckernel", "CC Kernel", "CC Kärna"), - ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.cccolorneutralizer", "Color Neutralizer", "Färgneutralisering"), + ("ec.color.cccoloroffset", "Channel Offset", "Kanalförskjutning"), + ("ec.color.cckernel", "Convolution Kernel", "Faltningskärna"), + ("ec.color.cctoner", "Multi-Tone Tint", "Flertonsfärgning"), ("ec.color.changecolor", "Change Color", "Ändra färg"), ("ec.color.changetocolor", "Change to Color", "Ändra till färg"), ("ec.color.channelmixer", "Channel Mixer", "Kanalmixer"), @@ -1962,7 +1969,7 @@ const SWEDISH: &[Row] = &[ ("ec.color.leavecolor", "Leave Color", "Behåll färg"), ("ec.color.levels", "Levels", "Nivåer"), ("ec.color.levelsic", "Levels (Individual Controls)", "Nivåer (individuella kontroller)"), - ("ec.color.lumetri", "Lumetri Color", "Lumetri-färg"), + ("ec.color.lumetri", "Color Grading", "Färggradering"), ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL-transformering"), ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO färgrymdstransformering"), ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO visningstransformering"), @@ -1987,20 +1994,20 @@ const SWEDISH: &[Row] = &[ ("ec.control.stereo3d", "Stereo 3D Controls", "Stereo 3D-kontroller"), ("ec.distort.bezierwarp", "Bezier Warp", "Bezierförvrängning"), ("ec.distort.bulge", "Bulge", "Utbuktning"), - ("ec.distort.ccbender", "CC Bender", "CC Bender"), - ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), - ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), - ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), - ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), - ("ec.distort.cclens", "CC Lens", "CC Objektiv"), - ("ec.distort.ccpageturn", "CC Page Turn", "CC Sidvändning"), - ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), - ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Krusningspuls"), - ("ec.distort.ccslant", "CC Slant", "CC Lutning"), - ("ec.distort.ccsmear", "CC Smear", "CC Utsmetning"), - ("ec.distort.ccsplit", "CC Split", "CC Dela"), - ("ec.distort.ccsplit2", "CC Split 2", "CC Dela 2"), - ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.ccbender", "Flexible Bend", "Flexibel böjning"), + ("ec.distort.ccbendit", "Two-Point Bend", "Tvåpunktsböjning"), + ("ec.distort.ccblobbylize", "Organic Displacement", "Organisk förskjutning"), + ("ec.distort.ccflomotion", "Dual Lens Warp", "Dubbel linsförvrängning"), + ("ec.distort.ccgriddler", "Tile Grid", "Plattnät"), + ("ec.distort.cclens", "Lens Distortion", "Linsförvrängning"), + ("ec.distort.ccpageturn", "Page Curl", "Sidkrullning"), + ("ec.distort.ccpowerpin", "Perspective Pin", "Perspektivnålar"), + ("ec.distort.ccripplepulse", "Pulsed Ripple", "Pulserande krusning"), + ("ec.distort.ccslant", "Slant", "Lutning"), + ("ec.distort.ccsmear", "Directional Smear", "Riktningssmetning"), + ("ec.distort.ccsplit", "Split Warp", "Delad förvrängning"), + ("ec.distort.ccsplit2", "Dual Split Warp", "Dubbeldelad förvrängning"), + ("ec.distort.cctiler", "Scaled Tiles", "Skalade plattor"), ("ec.distort.cornerpin", "Corner Pin", "Hörnförankring"), ("ec.distort.displacementmap", "Displacement Map", "Förskjutningskarta"), ("ec.distort.liquify", "Liquify", "Gör flytande"), @@ -2028,11 +2035,11 @@ const SWEDISH: &[Row] = &[ ("ec.generate.audiospectrum", "Audio Spectrum", "Ljudspektrum"), ("ec.generate.audiowaveform", "Audio Waveform", "Ljudvågform"), ("ec.generate.beam", "Beam", "Stråle"), - ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), - ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Ljusutbrott 2.5"), - ("ec.generate.cclightrays", "CC Light Rays", "CC Ljusstrålar"), - ("ec.generate.cclightsweep", "CC Light Sweep", "CC Ljussvep"), - ("ec.generate.ccthreads", "CC Threads", "CC Trådar"), + ("ec.generate.ccgluegun", "Paint Beads", "Färgpärlor"), + ("ec.generate.cclightburst", "Radial Light Burst", "Radiellt ljusutbrott"), + ("ec.generate.cclightrays", "Light Rays", "Ljusstrålar"), + ("ec.generate.cclightsweep", "Light Sweep", "Ljussvep"), + ("ec.generate.ccthreads", "Woven Threads", "Vävda trådar"), ("ec.generate.cellpattern", "Cell Pattern", "Cellmönster"), ("ec.generate.checkerboard", "Checkerboard", "Schackbräde"), ("ec.generate.circle", "Circle", "Cirkel"), @@ -2051,7 +2058,7 @@ const SWEDISH: &[Row] = &[ ("ec.generate.vegas", "Vegas", "Vegas"), ("ec.generate.writeon", "Write-on", "Framskrivning"), ("ec.key.advancedspill", "Advanced Spill Suppressor", "Avancerad spilldämpning"), - ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Enkel trådborttagning"), + ("ec.key.ccsimplewireremoval", "Line Removal", "Linjeborttagning"), ("ec.key.colordifference", "Color Difference Key", "Färgdifferensnyckel"), ("ec.key.colorkey", "Color Key", "Färgnyckel"), ("ec.key.colorrange", "Color Range", "Färgomfång"), @@ -2064,7 +2071,7 @@ const SWEDISH: &[Row] = &[ ("ec.key.screen", "Screen Key", "Skärmnyckel"), ("ec.key.spill", "Spill Suppressor", "Spilldämpning"), ("ec.key.unmult", "Unmult", "Unmult"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Screen Color Key", "Skärmfärgsnyckling"), ("ec.matte.mattechoker", "Matte Choker", "Mattekrympning"), ("ec.matte.objectmatte", "Object Matte", "Objektmatte"), ("ec.matte.refinehard", "Refine Hard Matte", "Förfina hård matte"), @@ -2088,50 +2095,50 @@ const SWEDISH: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "Grundläggande text"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "Gaussisk oskärpa (äldre)"), ("ec.obsolete.lightning", "Lightning", "Blixt"), - ("ec.obsolete.mochashape", "Mocha shape", "Mocha-form"), + ("ec.obsolete.mochashape", "Tracked Shape Matte", "Spårad formmask"), ("ec.obsolete.pathtext", "Path Text", "Text längs bana"), ("ec.paint.paint", "Paint", "Måla"), ("ec.perspective.3dglasses", "3D Glasses", "3D-glasögon"), ("ec.perspective.bevelalpha", "Bevel Alpha", "Alfafasning"), ("ec.perspective.beveledges", "Bevel Edges", "Fasade kanter"), ("ec.perspective.cameratracker", "3D Camera Tracker", "3D-kameraspårning"), - ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), - ("ec.perspective.ccenvironment", "CC Environment", "CC Miljö"), - ("ec.perspective.ccsphere", "CC Sphere", "CC Sfär"), - ("ec.perspective.ccspotlight", "CC Spotlight", "CC Strålkastare"), + ("ec.perspective.cccylinder", "Cylinder Wrap", "Cylinderomslag"), + ("ec.perspective.ccenvironment", "Environment Map", "Miljökarta"), + ("ec.perspective.ccsphere", "Sphere Wrap", "Sfäromslag"), + ("ec.perspective.ccspotlight", "Surface Spotlight", "Ytstrålkastare"), ("ec.perspective.dropshadow", "Drop Shadow", "Slagskugga"), ("ec.perspective.radialshadow", "Radial Shadow", "Radiell skugga"), ("ec.sim.carddance", "Card Dance", "Kortdans"), ("ec.sim.caustics", "Caustics", "Kaustik"), - ("ec.sim.ccballaction", "CC Ball Action", "CC Bollåtgärd"), - ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubblor"), - ("ec.sim.ccdrizzle", "CC Drizzle", "CC Duggregn"), - ("ec.sim.cchair", "CC Hair", "CC Hår"), - ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), - ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Partikelsystem II"), - ("ec.sim.ccparticleworld", "CC Particle World", "CC Partikelvärld"), - ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), - ("ec.sim.ccrainfall", "CC Rainfall", "CC Regn"), - ("ec.sim.ccscatterize", "CC Scatterize", "CC Utspridning"), - ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snöfall"), - ("ec.sim.ccstarburst", "CC Star Burst", "CC Stjärnexplosion"), + ("ec.sim.ccballaction", "Ball Grid", "Kulnät"), + ("ec.sim.ccbubbles", "Bubbles", "Bubblor"), + ("ec.sim.ccdrizzle", "Water Ripples", "Vattenkrusningar"), + ("ec.sim.cchair", "Surface Fibers", "Ytfibrer"), + ("ec.sim.ccmrmercury", "Liquid Particles", "Vätskepartiklar"), + ("ec.sim.ccparticlesystems2", "Particle Emitter 2D", "Partikelsändare 2D"), + ("ec.sim.ccparticleworld", "Particle Emitter 3D", "Partikelsändare 3D"), + ("ec.sim.ccpixelpolly", "Pixel Shatter", "Pixelsplittring"), + ("ec.sim.ccrainfall", "Rainfall", "Regn"), + ("ec.sim.ccscatterize", "Pixel Scatter", "Pixelspridning"), + ("ec.sim.ccsnowfall", "Snowfall", "Snöfall"), + ("ec.sim.ccstarburst", "Starfield", "Stjärnfält"), ("ec.sim.foam", "Foam", "Skum"), ("ec.sim.particleplayground", "Particle Playground", "Partikellekplats"), ("ec.sim.shatter", "Shatter", "Krossa"), ("ec.sim.waveworld", "Wave World", "Vågvärld"), ("ec.stylize.brushstrokes", "Brush Strokes", "Penseldrag"), ("ec.stylize.cartoon", "Cartoon", "Tecknat"), - ("ec.stylize.ccblockload", "CC Block Load", "CC Blockladdning"), - ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Bränn film"), - ("ec.stylize.ccglass", "CC Glass", "CC Glas"), - ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), - ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), - ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), - ("ec.stylize.ccplastic", "CC Plastic", "CC Plast"), - ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), - ("ec.stylize.ccthreshold", "CC Threshold", "CC Tröskel"), - ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Tröskel RGB"), - ("ec.stylize.ccvignette", "CC Vignette", "CC Vinjett"), + ("ec.stylize.ccblockload", "Block Reveal", "Blockframträdande"), + ("ec.stylize.ccburnfilm", "Film Burn", "Filmbränning"), + ("ec.stylize.ccglass", "Glass Relief", "Glasrelief"), + ("ec.stylize.cchextile", "Hexagonal Tiles", "Sexkantiga plattor"), + ("ec.stylize.cckaleida", "Kaleidoscope", "Kalejdoskop"), + ("ec.stylize.ccmrsmoothie", "Palette Map", "Mappage de palette"), + ("ec.stylize.ccplastic", "Plastic Shading", "Plastskuggning"), + ("ec.stylize.ccrepetile", "Edge Tile Repeat", "Kantupprepning"), + ("ec.stylize.ccthreshold", "Channel Threshold", "Kanaltröskel"), + ("ec.stylize.ccthresholdrgb", "RGB Threshold", "RGB-tröskel"), + ("ec.stylize.ccvignette", "Vignette", "Vinjettering"), ("ec.stylize.coloremboss", "Color Emboss", "Färgrelief"), ("ec.stylize.emboss", "Emboss", "Relief"), ("ec.stylize.findedges", "Find Edges", "Hitta kanter"), @@ -2146,8 +2153,8 @@ const SWEDISH: &[Row] = &[ ("ec.stylize.threshold", "Threshold", "Tröskel"), ("ec.text.numbers", "Numbers", "Siffror"), ("ec.text.timecode", "Timecode", "Tidskod"), - ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Tvinga rörelseoskärpa"), - ("ec.time.ccwidetime", "CC Wide Time", "CC Bred tid"), + ("ec.time.ccforcemotionblur", "Temporal Motion Blur", "Tidsbaserad rörelseoskärpa"), + ("ec.time.ccwidetime", "Temporal Blend", "Tidsblandning"), ("ec.time.echo", "Echo", "Eko"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "Pixelrörelseoskärpa"), ("ec.time.posterizetime", "Posterize Time", "Posterisera tid"), @@ -2156,23 +2163,23 @@ const SWEDISH: &[Row] = &[ ("ec.time.timewarp", "Timewarp", "Tidsförvrängning"), ("ec.transition.blockdissolve", "Block Dissolve", "Blockövertoning"), ("ec.transition.cardwipe", "Card Wipe", "Kortridå"), - ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glasridå"), - ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Rutnätsridå"), - ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Bildridå"), - ("ec.transition.ccjaws", "CC Jaws", "CC Käftar"), - ("ec.transition.cclightwipe", "CC Light Wipe", "CC Ljusridå"), - ("ec.transition.cclinesweep", "CC Line Sweep", "CC Linjesvep"), - ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radiell skalridå"), - ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Skalridå"), - ("ec.transition.cctwister", "CC Twister", "CC Virvel"), - ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.ccglasswipe", "Glass Wipe", "Glasövergång"), + ("ec.transition.ccgridwipe", "Grid Wipe", "Rutnätsövergång"), + ("ec.transition.ccimagewipe", "Luminance Image Wipe", "Luminansbildsövergång"), + ("ec.transition.ccjaws", "Serrated Wipe", "Tandad övergång"), + ("ec.transition.cclightwipe", "Light Wipe", "Ljusövergång"), + ("ec.transition.cclinesweep", "Line Sweep", "Linjesvep"), + ("ec.transition.ccradialscalewipe", "Radial Scale Wipe", "Radiell skalövergång"), + ("ec.transition.ccscalewipe", "Scale Wipe", "Skalövergång"), + ("ec.transition.cctwister", "Twist Wipe", "Vridövergång"), + ("ec.transition.ccwarpomatic", "Warp Dissolve", "Förvrängningsupplösning"), ("ec.transition.gradientwipe", "Gradient Wipe", "Gradientridå"), ("ec.transition.iriswipe", "Iris Wipe", "Irisridå"), ("ec.transition.linearwipe", "Linear Wipe", "Linjär ridå"), ("ec.transition.radialwipe", "Radial Wipe", "Radiell ridå"), ("ec.transition.venetian", "Venetian Blinds", "Persienner"), ("ec.utility.applylut", "Apply Color LUT", "Använd färg-LUT"), - ("ec.utility.ccoverbrights", "CC Overbrights", "CC Överljust"), + ("ec.utility.ccoverbrights", "Out-of-Range Display", "Visa utanför intervallet"), ("ec.utility.cineon", "Cineon Converter", "Cineon-konverterare"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "Färgprofilkonverterare"), ("ec.utility.facemeasurements", "Face Measurements", "Ansiktsmått"), @@ -2226,6 +2233,38 @@ mod tests { use crate::i18n::set_current; use effectcraft_engine::effects; + #[test] + fn legacy_effect_names_draw_current_labels_without_changing_custom_names() { + for language in ["en", "ja", "zh-hans", "zh-hant", "fr", "sv", "ka", "cs", "de", "es", "pl", "pt-br", "ru", "uk"] { + set_current(language); + for spec in effects::registry() { + let translated = name(spec.id, spec.name); + for alias in effects::aliases(spec.id) { + assert_eq!(group(spec.id, alias), translated, "{language}: {alias}"); + assert!(matches(spec.id, alias, &spec.name.to_lowercase()), "{language}: search current name of {alias}"); + assert!(matches(spec.id, spec.name, &alias.to_lowercase()), "{language}: search legacy name {alias}"); + assert_eq!(group(spec.id, &format!("{alias} 2")), format!("{translated} 2")); + let custom = format!("{alias} custom"); + assert_eq!(group(spec.id, &custom), custom); + } + } + } + set_current("en"); + } + + #[test] + fn localized_effect_names_do_not_reintroduce_legacy_branding() { + for (language, rows) in COMPLETE.iter().chain(PARTIAL) { + for (_, _, translated) in *rows { + let label = translated.to_lowercase(); + assert!(!label.starts_with("cc "), "{language}: {translated}"); + for mark in ["lumetri", "mercury", "mocha", "keylight"] { + assert!(!label.contains(mark), "{language}: {translated}"); + } + } + } + } + /// Every row names an effect in the registry and still carries its English name, so a rename /// or a removal fails here rather than attaching a translation to a different effect. #[test] @@ -2242,6 +2281,20 @@ mod tests { } } + /// Two effects that draw one name cannot be told apart in the Effect menu, Effect Controls or + /// Effects & Presets, so every catalog names each effect it covers distinctly. + #[test] + fn display_names_are_unique_in_every_catalog() { + for (language, rows) in COMPLETE.iter().chain(PARTIAL) { + let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for (id, _, translated) in *rows { + if let Some(other) = seen.insert(translated, id) { + assert_ne!(other, *id, "{language}: {id} and {other} are both {translated:?}"); + } + } + } + } + /// Rows are keyed by id, so a repeated id would shadow a translation. #[test] fn ids_are_unique() { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 15bf774f9..b2e0b3631 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -242,9 +242,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Brush Dynamics", "ブラシのダイナミクス"), ("Caching frames", "フレームをキャッシュ中"), ("Cancel", "キャンセル"), - ("Import After Effects Project", "After Effectsプロジェクトを読み込み"), + ("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"), ("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"), ("Import Anyway", "このまま読み込み"), + ("{}: {} left out or changed", "{}:{} 件が省略または変更"), + ("… and {} more", "…ほか {} 件"), ("Casts Shadows", "シャドウを落とす"), ("Change Renderer…", "レンダラーを変更…"), ( @@ -708,8 +710,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Import Photoshop File", "Photoshopファイルを読み込み"), ("Interpret Unlabeled Alpha As", "ラベルのないアルファの解釈"), ("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"), - ("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"), - ("Mercury Software Only", "Mercuryソフトウェアのみ"), + ("GPU Acceleration ({})", "GPUアクセラレーション({})"), + ("Software Rendering", "ソフトウェアレンダリング"), ("OCIO (built-in ACES config)", "OCIO(組み込みACES設定)"), ("Off (Final Quality)", "オフ(最終品質)"), ("OpenGL (compatibility)", "OpenGL(互換)"), @@ -891,6 +893,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "フッテージフレームキャッシュ"), ("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"), ("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"), + ("GPU Frames Rendered at Once", "一度にレンダリングするGPUフレーム数"), ("Video", "ビデオ"), ("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"), ("Video Device", "ビデオデバイス"), @@ -1118,7 +1121,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Script Console", "スクリプトコンソール"), ("Essential Graphics", "エッセンシャルグラフィックス"), ("ScriptUI Panel", "ScriptUIパネル"), - ("Lumetri Scopes", "Lumetriスコープ"), + ("Video Scopes", "ビデオスコープ"), ("Media Browser", "メディアブラウザー"), ("Content-Aware Fill", "コンテンツに応じた塗りつぶし"), ("Create Nulls From Paths", "パスからヌルを作成"), @@ -1318,6 +1321,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("per second", "毎秒"), ("Waveform", "波形"), ("steps", "ステップ"), + ("GPU Acceleration (no GPU: software)", "GPUアクセラレーション(GPUなし:ソフトウェア)"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1381,9 +1385,11 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Brush Dynamics", "画笔动态"), ("Caching frames", "正在缓存帧"), ("Cancel", "取消"), - ("Import After Effects Project", "导入 After Effects 项目"), + ("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"), ("Import Anyway", "仍然导入"), + ("{}: {} left out or changed", "{}:{} 项被省略或更改"), + ("… and {} more", "…以及另外 {} 项"), ("Casts Shadows", "投射阴影"), ("Change Renderer…", "更改渲染器…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快捷键会创建自定义预设;默认预设保持不变。"), @@ -1837,8 +1843,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Import Photoshop File", "导入 Photoshop 文件"), ("Interpret Unlabeled Alpha As", "将未标记的 Alpha 解释为"), ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), - ("Mercury GPU Acceleration ({})", "Mercury GPU 加速({})"), - ("Mercury Software Only", "仅 Mercury 软件"), + ("GPU Acceleration ({})", "GPU 加速({})"), + ("Software Rendering", "软件渲染"), ("OCIO (built-in ACES config)", "OCIO(内置 ACES 配置)"), ("Off (Final Quality)", "关闭(最终质量)"), ("OpenGL (compatibility)", "OpenGL(兼容)"), @@ -2022,6 +2028,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "素材帧缓存"), ("Preview (RAM) Cache", "预览(内存)缓存"), ("Reduce Cache Size When System Is Low on Memory", "系统内存不足时减少缓存大小"), + ("GPU Frames Rendered at Once", "GPU 同时渲染的帧数"), ("Video", "视频"), ("Enable Video Preview Output", "启用视频预览输出"), ("Video Device", "视频设备"), @@ -2280,7 +2287,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Script Console", "脚本控制台"), ("Essential Graphics", "基本图形"), ("ScriptUI Panel", "ScriptUI 面板"), - ("Lumetri Scopes", "Lumetri 示波器"), + ("Video Scopes", "视频示波器"), ("Media Browser", "媒体浏览器"), ("Content-Aware Fill", "内容识别填充"), ("Create Nulls From Paths", "从路径创建空对象"), @@ -2433,6 +2440,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Blend Mode", "混合模式"), ("Show mesh", "显示网格"), ("Record Options...", "记录选项..."), + ("GPU Acceleration (no GPU: software)", "GPU 加速(无 GPU:软件)"), ]; /// Swedish (`sv`), written from the English source. @@ -2495,12 +2503,14 @@ const SWEDISH: &[(&str, &str)] = &[ ("Brush Dynamics", "Penseldynamik"), ("Caching frames", "Cachelagrar bildrutor"), ("Cancel", "Avbryt"), - ("Import After Effects Project", "Importera After Effects-projekt"), + ("Import Adobe After Effects Project", "Importera Adobe After Effects-projekt"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):", ), ("Import Anyway", "Importera ändå"), + ("{}: {} left out or changed", "{}: {} utelämnas eller ändras"), + ("… and {} more", "… och {} till"), ("Casts Shadows", "Kastar skuggor"), ("Change Renderer…", "Byt renderare…"), ( @@ -2950,8 +2960,8 @@ const SWEDISH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importera Photoshop-fil"), ("Interpret Unlabeled Alpha As", "Tolka omärkt alfa som"), ("Join the ArtCraft Discord", "Gå med i ArtCraft på Discord"), - ("Mercury GPU Acceleration ({})", "Mercury GPU-acceleration ({})"), - ("Mercury Software Only", "Endast Mercury-programvara"), + ("GPU Acceleration ({})", "GPU-acceleration ({})"), + ("Software Rendering", "Programvarurendering"), ("OCIO (built-in ACES config)", "OCIO (inbyggd ACES-konfiguration)"), ("Off (Final Quality)", "Av (slutlig kvalitet)"), ("OpenGL (compatibility)", "OpenGL (kompatibilitet)"), @@ -3133,6 +3143,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Footage Frame Cache", "Bildrutecache för material"), ("Preview (RAM) Cache", "Förhandsgranskningscache (RAM)"), ("Reduce Cache Size When System Is Low on Memory", "Minska cachestorleken när systemet har ont om minne"), + ("GPU Frames Rendered at Once", "GPU-bildrutor som renderas samtidigt"), ("Video", "Video"), ("Enable Video Preview Output", "Aktivera videoutgång för förhandsgranskning"), ("Video Device", "Videoenhet"), @@ -3183,6 +3194,14 @@ const SWEDISH: &[(&str, &str)] = &[ ("A built-in preset has this name", "En inbyggd förinställning har det här namnet"), ("Cannot import dropped files: {}", "Kan inte importera släppta filer: {}"), ("Cannot open dropped project {}: {}", "Kan inte öppna det släppta projektet {}: {}"), + ( + "Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…", + "Importerat material har en omärkt alfakanal: välj hur den ska tolkas i Arkiv ▸ Tolka material ▸ Huvud…", + ), + ( + "Skipped {} more file(s) while a dialog is open; import them again after it", + "Hoppade över {} fil(er) till medan en dialogruta är öppen; importera dem igen efteråt", + ), ("Click in the Composition panel to sample text {}", "Klicka i panelen Komposition för att hämta text {}"), ("Click to sample text {}", "Klicka för att hämta text {}"), ("Comment of {}: {}", "Kommentar till {}: {}"), @@ -3191,7 +3210,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Drop {} files to import", "Släpp {} filer för att importera"), ("Effects", "Effekter"), ("Label of {}: {}", "Etikett för {}: {}"), - ("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU-acceleration (ingen GPU: programvara)"), + ("GPU Acceleration (no GPU: software)", "GPU-acceleration (ingen GPU: programvara)"), ("Only one project can be opened per drop; skipped {} more", "Bara ett projekt kan öppnas per släpp; hoppade över {} till"), ("Open in Essential Graphics", "Öppna i Grundläggande grafik"), ("Original color: click to go back to it", "Originalfärg: klicka för att återgå till den"), @@ -3440,7 +3459,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Script Console", "Skriptkonsol"), ("Essential Graphics", "Grundläggande grafik"), ("ScriptUI Panel", "ScriptUI-panel"), - ("Lumetri Scopes", "Lumetri-scope"), + ("Video Scopes", "Videoskop"), ("Media Browser", "Mediebläddrare"), ("Content-Aware Fill", "Innehållsmedveten fyllning"), ("Create Nulls From Paths", "Skapa nullobjekt från banor"), @@ -3638,7 +3657,6 @@ const SWEDISH: &[(&str, &str)] = &[ ("Smoothing", "Utjämning"), ("Top", "Överkant"), ("Mode", "Läge"), - ("Frame", "Bildruta"), ("Orientation", "Orientering"), ("Create", "Skapa"), ("Overlap", "Överlappning"), @@ -3763,9 +3781,11 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Brush Dynamics", "筆刷動態"), ("Caching frames", "正在快取幀"), ("Cancel", "取消"), - ("Import After Effects Project", "匯入 After Effects 專案"), + ("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"), ("Import Anyway", "仍要匯入"), + ("{}: {} left out or changed", "{}:{} 項被省略或變更"), + ("… and {} more", "…以及另外 {} 項"), ("Casts Shadows", "投射陰影"), ("Change Renderer…", "更改算圖引擎…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快速鍵會創建自定義預設集;預設預設集保持不變。"), @@ -4350,6 +4370,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "素材影格快取"), ("Preview (RAM) Cache", "預覽(記憶體)快取"), ("Reduce Cache Size When System Is Low on Memory", "系統記憶體不足時減少快取大小"), + ("GPU Frames Rendered at Once", "GPU 同時算圖的影格數"), ("Video", "視訊"), ("Enable Video Preview Output", "啟用視訊預覽輸出"), ("Video Device", "視訊裝置"), @@ -4608,7 +4629,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Script Console", "指令碼主控台"), ("Essential Graphics", "基本圖形"), ("ScriptUI Panel", "ScriptUI 面板"), - ("Lumetri Scopes", "Lumetri 示波器"), + ("Video Scopes", "視訊示波器"), ("Media Browser", "媒體瀏覽器"), ("Content-Aware Fill", "內容感知填色"), ("Create Nulls From Paths", "從路徑建立空物件"), @@ -4761,6 +4782,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Blend Mode", "混合模式"), ("Show mesh", "顯示網格"), ("Record Options...", "記錄選項..."), + ("Software Rendering", "軟體算繪"), + ("GPU Acceleration ({})", "GPU 加速({})"), + ("GPU Acceleration (no GPU: software)", "GPU 加速(無 GPU:軟體)"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -4825,12 +4849,14 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Brush Dynamics", "Динаміка пензля"), ("Caching frames", "Кешування кадрів"), ("Cancel", "Скасувати"), - ("Import After Effects Project", "Імпортувати проєкт After Effects"), + ("Import Adobe After Effects Project", "Імпортувати проєкт Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):", ), ("Import Anyway", "Усе одно імпортувати"), + ("{}: {} left out or changed", "{}: {} пропущено або змінено"), + ("… and {} more", "… і ще {}"), ("Casts Shadows", "Відкидає тіні"), ("Change Renderer…", "Змінити рушій рендерингу…"), ( @@ -5301,8 +5327,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Import Photoshop File", "Імпорт файлу Photoshop"), ("Interpret Unlabeled Alpha As", "Інтерпретувати непозначену альфу як"), ("Join the ArtCraft Discord", "Приєднатися до Discord ArtCraft"), - ("Mercury GPU Acceleration ({})", "Прискорення Mercury GPU ({})"), - ("Mercury Software Only", "Лише програмний Mercury"), + ("GPU Acceleration ({})", "Прискорення GPU ({})"), + ("Software Rendering", "Програмне відтворення"), ("OCIO (built-in ACES config)", "OCIO (вбудована конфігурація ACES)"), ("Off (Final Quality)", "Вимкнено (остаточна якість)"), ("OpenGL (compatibility)", "OpenGL (сумісність)"), @@ -5348,6 +5374,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Project", "Проєкт"), ("Total Offset", "Загальний зсув"), ("Per Layer", "На шар"), + ("Video Scopes", "Відеоскопи"), + ("GPU Acceleration (no GPU: software)", "Прискорення GPU (без GPU: програмне)"), ]; // Brazilian Portuguese. @@ -5404,12 +5432,14 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Brush Dynamics", "Dinâmica do pincel"), ("Caching frames", "Armazenando quadros em cache"), ("Cancel", "Cancelar"), - ("Import After Effects Project", "Importar projeto do After Effects"), + ("Import Adobe After Effects Project", "Importar projeto do Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):", ), ("Import Anyway", "Importar mesmo assim"), + ("{}: {} left out or changed", "{}: {} omitidos ou alterados"), + ("… and {} more", "… e mais {}"), ("Casts Shadows", "Projeta sombras"), ("Change Renderer…", "Alterar renderizador…"), ( @@ -5910,6 +5940,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Recent Projects Shown", "Projetos recentes exibidos"), ("Red", "Vermelho"), ("Reduce Cache Size When System Is Low on Memory", "Reduzir o tamanho do cache quando o sistema estiver com pouca memória"), + ("GPU Frames Rendered at Once", "Quadros da GPU renderizados de uma vez"), ("Remove from favorites", "Remover dos favoritos"), ("Repair", "Reparo"), ("Report Missing Frames", "Relatar quadros ausentes"), @@ -5979,7 +6010,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Guess", "Adivinhar"), ("Import Image Sequence", "Importar sequência de imagens"), ("Import PDF / Illustrator File", "Importar arquivo PDF / Illustrator"), - ("Mercury Software Only", "Apenas software Mercury"), + ("Software Rendering", "Renderização por software"), ("OCIO (built-in ACES config)", "OCIO (config do ACES integrado)"), ("Still Duration", "Duração da imagem fixa"), ("Straight", "Direto"), @@ -6086,7 +6117,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Light", "Claro"), ("Lines", "Linhas"), ("Memory & CPU", "Memória e CPU"), - ("Mercury GPU Acceleration ({})", "Aceleração de GPU Mercury ({})"), + ("GPU Acceleration ({})", "Aceleração de GPU ({})"), ("Off", "Desativado"), ("Options", "Opções"), ("Overlay", "Sobrepor"), @@ -6111,6 +6142,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Video", "Vídeo"), ("channel", "canal"), ("files", "arquivos"), + ("Video Scopes", "Escopos de vídeo"), + ("GPU Acceleration (no GPU: software)", "Aceleração de GPU (sem GPU: software)"), ]; /// Polish (pl): original translations covering every row in the Chinese panel catalogs. @@ -6174,12 +6207,14 @@ const POLISH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dynamika pędzla"), ("Caching frames", "Buforowanie klatek"), ("Cancel", "Anuluj"), - ("Import After Effects Project", "Importuj projekt After Effects"), + ("Import Adobe After Effects Project", "Importuj projekt Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):", ), ("Import Anyway", "Importuj mimo to"), + ("{}: {} left out or changed", "{}: {} pominięto lub zmieniono"), + ("… and {} more", "… i {} więcej"), ("Casts Shadows", "Rzuca cienie"), ("Change Renderer…", "Zmień renderer…"), ( @@ -6629,8 +6664,8 @@ const POLISH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importuj plik Photoshopa"), ("Interpret Unlabeled Alpha As", "Interpretuj nieoznaczoną alfę jako"), ("Join the ArtCraft Discord", "Dołącz do Discorda ArtCraft"), - ("Mercury GPU Acceleration ({})", "Akceleracja Mercury GPU ({})"), - ("Mercury Software Only", "Tylko programowy Mercury"), + ("GPU Acceleration ({})", "Akceleracja GPU ({})"), + ("Software Rendering", "Renderowanie programowe"), ("OCIO (built-in ACES config)", "OCIO (wbudowana konfiguracja ACES)"), ("Off (Final Quality)", "Wył. (jakość końcowa)"), ("OpenGL (compatibility)", "OpenGL (zgodność)"), @@ -6868,6 +6903,8 @@ const POLISH: &[(&str, &str)] = &[ ("Resolution:", "Rozdzielczość:"), ("Guide Layer", "Warstwa pomocnicza"), ("Layer Styles", "Style warstwy"), + ("Video Scopes", "Wskaźniki wideo"), + ("GPU Acceleration (no GPU: software)", "Akceleracja GPU (bez GPU: programowa)"), ]; /// Czech (cs): original translations covering every row in the Chinese panel catalogs. @@ -6928,12 +6965,14 @@ const CZECH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dynamika štětce"), ("Caching frames", "Ukládání snímků do mezipaměti"), ("Cancel", "Zrušit"), - ("Import After Effects Project", "Importovat projekt After Effects"), + ("Import Adobe After Effects Project", "Importovat projekt Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):", ), ("Import Anyway", "Přesto importovat"), + ("{}: {} left out or changed", "{}: {} vynecháno nebo změněno"), + ("… and {} more", "… a {} dalších"), ("Casts Shadows", "Vrhat stíny"), ("Change Renderer…", "Změnit vykreslovací modul…"), ( @@ -7380,8 +7419,8 @@ const CZECH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importovat soubor Photoshop"), ("Interpret Unlabeled Alpha As", "Interpretovat neoznačenou alfu jako"), ("Join the ArtCraft Discord", "Připojit se na Discord ArtCraft"), - ("Mercury GPU Acceleration ({})", "GPU akcelerace Mercury ({})"), - ("Mercury Software Only", "Pouze softwarové zpracování Mercury"), + ("GPU Acceleration ({})", "Akcelerace GPU ({})"), + ("Software Rendering", "Softwarové vykreslování"), ("OCIO (built-in ACES config)", "OCIO (vestavěná konfigurace ACES)"), ("Off (Final Quality)", "Vypnuto (finální kvalita)"), ("OpenGL (compatibility)", "OpenGL (kompatibilita)"), @@ -7432,6 +7471,8 @@ const CZECH: &[(&str, &str)] = &[ ("Variable fonts only", "Jen proměnná písma"), ("Total Offset", "Celkový posun"), ("Per Layer", "Na vrstvu"), + ("Video Scopes", "Videoskopy"), + ("GPU Acceleration (no GPU: software)", "Akcelerace GPU (bez GPU: software)"), ]; /// Spanish: neutral, tú, sentence case; the same source strings as the Chinese catalogs, in the same @@ -7501,12 +7542,14 @@ const SPANISH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dinámica del pincel"), ("Caching frames", "Almacenando fotogramas en caché"), ("Cancel", "Cancelar"), - ("Import After Effects Project", "Importar proyecto de After Effects"), + ("Import Adobe After Effects Project", "Importar proyecto de Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):", ), ("Import Anyway", "Importar de todos modos"), + ("{}: {} left out or changed", "{}: {} omitidos o cambiados"), + ("… and {} more", "… y {} más"), ("Casts Shadows", "Proyecta sombras"), ("Change Renderer…", "Cambiar motor de procesamiento…"), ( @@ -7960,8 +8003,8 @@ const SPANISH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importar archivo de Photoshop"), ("Interpret Unlabeled Alpha As", "Interpretar alfa sin etiquetar como"), ("Join the ArtCraft Discord", "Únete al Discord de ArtCraft"), - ("Mercury GPU Acceleration ({})", "Aceleración por GPU Mercury ({})"), - ("Mercury Software Only", "Solo software Mercury"), + ("GPU Acceleration ({})", "Aceleración por GPU ({})"), + ("Software Rendering", "Renderizado por software"), ("OCIO (built-in ACES config)", "OCIO (configuración ACES integrada)"), ("Off (Final Quality)", "Desactivado (calidad final)"), ("OpenGL (compatibility)", "OpenGL (compatibilidad)"), @@ -8002,6 +8045,8 @@ const SPANISH: &[(&str, &str)] = &[ ("After Effects project", "Proyecto de After Effects"), ("Lottie animation", "Animación de Lottie"), ("Project", "Proyecto"), + ("Video Scopes", "Monitores de vídeo"), + ("GPU Acceleration (no GPU: software)", "Aceleración por GPU (sin GPU: software)"), ]; /// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step). @@ -8065,12 +8110,14 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Brush Dynamics", "ფუნჯის დინამიკა"), ("Caching frames", "კადრების ქეშირება"), ("Cancel", "გაუქმება"), - ("Import After Effects Project", "After Effects-ის პროექტის იმპორტი"), + ("Import Adobe After Effects Project", "Adobe After Effects-ის პროექტის იმპორტი"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):", ), ("Import Anyway", "მაინც იმპორტი"), + ("{}: {} left out or changed", "{}: {} გამოტოვებული ან შეცვლილი"), + ("… and {} more", "… და კიდევ {}"), ("Casts Shadows", "ჩრდილს აგდებს"), ("Change Renderer…", "რენდერერის შეცვლა…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."), @@ -8528,8 +8575,8 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Import Photoshop File", "Photoshop ფაილის იმპორტი"), ("Interpret Unlabeled Alpha As", "უცნობი ალფას ინტერპრეტაცია"), ("Join the ArtCraft Discord", "შემოგვიერთდით Discord-ზე"), - ("Mercury GPU Acceleration ({})", "GPU აჩქარება ({})"), - ("Mercury Software Only", "მხოლოდ Mercury Software"), + ("GPU Acceleration ({})", "GPU აჩქარება ({})"), + ("Software Rendering", "პროგრამული რენდერი"), ("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"), ("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"), ("OpenGL (compatibility)", "OpenGL (თავსებადობა)"), @@ -8761,6 +8808,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Total Offset", "ჯამური წანაცვლება"), ("Per Layer", "შრიდან შრემდე"), ("Track", "ტრეკინგი"), + ("Video Scopes", "ვიდეოსკოპები"), ]; /// Georgian rows for interface text no other catalog has yet: strings the panels already send @@ -8837,7 +8885,6 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Height", "სიმაღლე"), ("Shape", "ფიგურა"), ("Mode", "რეჟიმი"), - ("Frame", "კადრი"), ("Orientation", "ორიენტაცია"), ("Position (pixels)", "პოზიცია (პიქსელი)"), ("Position (percent of the composition)", "პოზიცია (კომპოზიციის პროცენტი)"), @@ -8951,7 +8998,7 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Solid Settings", "სოლიდის პარამეტრები"), ("Save as Template", "შაბლონად შენახვა"), ("My Custom RGB", "ჩემი მორგებული RGB"), - ("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU აჩქარება (GPU არ არის: პროგრამული)"), + ("GPU Acceleration (no GPU: software)", "GPU აჩქარება (GPU არ არის: პროგრამული)"), ("Sort by {}, {}", "დალაგება: {}, {}"), ("Sort by {}", "დალაგება: {}"), ("Label of {}: {}", "{}-ის იარლიყი: {}"), @@ -9037,12 +9084,14 @@ const FRENCH: &[(&str, &str)] = &[ ("Brush Dynamics", "Dynamique du pinceau"), ("Caching frames", "Mise en cache des images"), ("Cancel", "Annuler"), - ("Import After Effects Project", "Importer un projet After Effects"), + ("Import Adobe After Effects Project", "Importer un projet Adobe After Effects"), ( "Some parts of this project cannot be imported exactly (left out or changed):", "Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :", ), ("Import Anyway", "Importer quand même"), + ("{}: {} left out or changed", "{} : {} omis ou modifiés"), + ("… and {} more", "… et {} de plus"), ("Casts Shadows", "Projette des ombres"), ("Change Renderer…", "Changer de moteur de rendu…"), ( @@ -9510,8 +9559,8 @@ const FRENCH: &[(&str, &str)] = &[ ("Import Photoshop File", "Importer un fichier Photoshop"), ("Interpret Unlabeled Alpha As", "Interpréter l’alpha non étiqueté comme"), ("Join the ArtCraft Discord", "Rejoindre le Discord ArtCraft"), - ("Mercury GPU Acceleration ({})", "Accélération GPU Mercury ({})"), - ("Mercury Software Only", "Mercury, logiciel uniquement"), + ("GPU Acceleration ({})", "Accélération GPU ({})"), + ("Software Rendering", "Rendu logiciel"), ("OCIO (built-in ACES config)", "OCIO (configuration ACES intégrée)"), ("Off (Final Quality)", "Désactivé (qualité finale)"), ("OpenGL (compatibility)", "OpenGL (compatibilité)"), @@ -9780,6 +9829,14 @@ const FRENCH: &[(&str, &str)] = &[ ), ("Only one project can be opened per drop; skipped {} more", "Un seul projet peut être ouvert par dépôt ; {} de plus ignoré(s)"), ("Cannot open dropped project {}: {}", "Impossible d’ouvrir le projet déposé {} : {}"), + ( + "Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…", + "Le métrage importé a une couche alpha non étiquetée : choisissez son interprétation dans Fichier ▸ Interpréter le métrage ▸ Principal…", + ), + ( + "Skipped {} more file(s) while a dialog is open; import them again after it", + "{} fichier(s) de plus ignoré(s) pendant qu’une boîte de dialogue est ouverte ; importez-les ensuite", + ), ("Cannot import dropped files: {}", "Impossible d’importer les fichiers déposés : {}"), ("A built-in preset has this name", "Un préréglage intégré porte ce nom"), ("Replaces your preset with this name", "Remplace votre préréglage portant ce nom"), @@ -9812,7 +9869,6 @@ const FRENCH: &[(&str, &str)] = &[ ("Height", "Hauteur"), ("Shape", "Forme"), ("Mode", "Mode"), - ("Frame", "Image"), ("Orientation", "Orientation"), ("Position (pixels)", "Position (pixels)"), ("Position (percent of the composition)", "Position (pourcentage de la composition)"), @@ -9926,7 +9982,7 @@ const FRENCH: &[(&str, &str)] = &[ ("Solid Settings", "Paramètres du solide"), ("Save as Template", "Enregistrer en tant que modèle"), ("My Custom RGB", "Mon RVB personnalisé"), - ("Mercury GPU Acceleration (no GPU: software)", "Accélération GPU Mercury (aucun GPU : logiciel)"), + ("GPU Acceleration (no GPU: software)", "Accélération GPU (aucun GPU : logiciel)"), ("Sort by {}, {}", "Trier par {}, {}"), ("Sort by {}", "Trier par {}"), ("Label of {}: {}", "Étiquette de {} : {}"), @@ -10047,7 +10103,7 @@ const FRENCH: &[(&str, &str)] = &[ ("Script Console", "Console de scripts"), ("Essential Graphics", "Graphiques essentiels"), ("ScriptUI Panel", "Panneau ScriptUI"), - ("Lumetri Scopes", "Scopes Lumetri"), + ("Video Scopes", "Oscilloscopes vidéo"), ("Media Browser", "Explorateur de médias"), ("Content-Aware Fill", "Remplissage adapté au contenu"), ("Create Nulls From Paths", "Créer des objets nuls à partir de tracés"), @@ -10266,6 +10322,26 @@ const FRENCH: &[(&str, &str)] = &[ mod tests { use super::*; + /// Check rendered feature labels only. Format names, compatibility tokens and user text + /// are not subject to this guard, and a missing translation keeps its English fallback. + #[test] + fn renamed_panel_labels_do_not_reintroduce_vendor_names() { + for language in ["en", "ja", "zh-hans", "zh-hant", "fr", "sv", "ka", "cs", "de", "es", "pl", "pt-br", "ru", "uk"] { + set_current(language); + for source in ["GPU Acceleration ({})", "GPU Acceleration (no GPU: software)", "Software Rendering", "Video Scopes"] { + let rendered = tr(source); + assert!(!rendered.is_empty(), "{language}: {source}"); + let label = rendered.to_lowercase(); + for mark in ["lumetri", "mercury", "mocha", "keylight"] { + assert!(!label.contains(mark), "{language}: {source} -> {rendered}"); + } + } + // A real interchange label remains factual, not rewritten or rejected. + assert!(!tr("Export Adobe Premiere Pro Project").is_empty()); + } + set_current("en"); + } + /// The interface text that has moved into this catalog. Add a panel here when you convert it; /// `every_row_is_used_by_a_converted_panel` then looks for its rows' English sources in it. pub(super) const CONVERTED: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 795a01e1e..cd0ac4307 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -190,6 +190,9 @@ pub struct Playback { pub caching: bool, /// Resolution override while playing (pixel step), `None` = the viewer's. pub res: Option, + /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: the lower render + /// scale this play stepped down to, and when (seconds). See `step_down`. + pub adaptive: Option<(f64, f64)>, /// Include Overlays / Include Layer Controls. pub overlays: bool, pub layer_controls: bool, @@ -197,16 +200,20 @@ pub struct Playback { pub restore_max: Option>, /// Audio-only previews: the frame under the audible sample (the current time stays put). pub audio_frame: Option, - /// The preview's sound waits for frames: while the frames ahead aren't cached, every frame - /// shows as it renders, silently; the sound starts once the rest of the preview (or what - /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or - /// sooner once the silent preview keeps real time with the frames ahead cached (#208). + /// The preview's sound waits for frames: a preview whose start gave up on frames that weren't + /// cached shows every frame as it renders, silently, and starts the sound once the frames + /// ahead are cached while it keeps real time (#208), or the rest of the preview is. Once the + /// sound plays it goes on through frames that aren't cached, and the picture drops frames to + /// follow it (#2473). pub audio_held: bool, + /// The project last checked for a silent preview that became audible (#2104). + pub silent_project: Option>, /// Starting a preview with sound (#947): the playhead stays on the first frame while the /// frames ahead render (the prefetch window, at most what fits in the RAM preview), then /// sound and picture start together from it. Without this the picture played on silently /// while they rendered and the sound started at the later frame, so the opening was lost. - /// A stall later on still falls back to [`Playback::audio_held`]. + /// Once the sound plays, a stall no longer stops it: the picture drops frames to follow it + /// (#2473). pub starting: bool, /// When the preview started (seconds): [`Playback::starting`] gives up after `STARTUP_LIMIT`. pub starting_since: f64, @@ -215,6 +222,14 @@ pub struct Playback { pub first_cached: Option, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, + /// The rate the silent clock advances at (comp frames per second, at most `fps`; 0 = the + /// frame rate): below real time while frames arrive slower than that, so a first playback + /// shows them evenly instead of in bursts between freezes (see `pace_for`). + pub pace: f64, + /// Played frames the paced clock owes (fractional), `Some` while it runs below real time. + pub pace_acc: Option, + /// When the silent clock last ran (seconds), for the pace. + pub paced_at: Option, } impl Playback { @@ -300,6 +315,7 @@ pub struct EffectcraftApp { pub(crate) input_waiters: Vec>, /// Control requests waiting for an offloaded job (`wait: true` in the browser). pub(crate) job_waiters: Vec<(control::JobWaiter, Sender)>, + pub(crate) script_waiters: Vec<(u32, Sender)>, pending_screenshots: Vec, /// Pixels reserved by queued and in-flight screenshot requests. shot_bytes: usize, @@ -357,6 +373,12 @@ pub struct EffectcraftApp { footage_watcher: Option, /// The project revision and preference the watched set was last computed for. footage_watch_key: Option<(u64, String, bool)>, + /// The worker that stamps the LUT files in use ([`Self::tick_lut_files`], #3493). + lut_watcher: Option, + /// The LUT generation the frames kept were rendered at ([`effectcraft_engine::lut_reload::generation`]). + lut_generation: u64, + /// How many times the caches were purged ([`Self::purge_stamp`]). + purges: u64, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, @@ -387,8 +409,8 @@ pub struct EffectcraftApp { applied_prefs: Option<(u64, Option)>, /// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`). pub recovery: Option, - /// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one - /// under the pointer. + /// Panels drawn last frame, docked and floating: (panel, body rect). `~` maximizes the one + /// under the pointer; Effect Controls' arrow keys work only while it is on screen. pub(crate) dock_rects: Vec<(PanelKind, egui::Rect)>, /// The floating panels' OS windows that drew a panel last frame; one that is gone releases /// its IME state. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb28b0bcd..9020b8fc0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,8 +27,8 @@ pub(crate) use entries::{entry_checked, entry_enabled, entry_label, entry_shortc pub use frontend::frontend; #[cfg(test)] use frontend::zoom_step; -pub(crate) use frontend::{dialog_answered, run_engine}; -pub use invoke::{invoke, panel_command_id, reveal}; +pub(crate) use frontend::{dialog_answered, import_aep_projects, run_engine}; +pub use invoke::{invoke, panel_command_id, reveal, waits_on_dialog}; #[cfg(test)] use shortcuts::modifiers_at_start; pub use shortcuts::{Binding, bindings, chord_matches, forwardable_during_text_edit, handle_shortcuts, handle_tab_shortcut, parse_shortcut, shortcut_text}; diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index bd1294330..bf981fc67 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -502,6 +502,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu return run_engine(app, ctx, "window.assignWorkspaceShortcut", q); } "file.interpretFootage" => { + // A background import finishing while another dialog waits (the After Effects losses + // prompt of the same drop) does not replace it: that dialog's work would be lost. The + // footage keeps the default interpretation; the note says where to change it. + if app.dialog.is_some() { + app.ui.status = + crate::i18n::tr("Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…") + .to_string(); + return Ok(Value::Null); + } // Import ▸ Interpret Unlabeled Alpha As: Ask User. if let Some(i) = p.get("item").and_then(Value::as_u64) { app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)]; @@ -601,7 +610,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option> { enum Ask { Folder, - Import, + /// Footage, or a file with one of these extensions. + Import(&'static [&'static str]), OpenProject, Save(std::borrow::Cow<'static, str>), Open(&'static [&'static str]), @@ -617,7 +627,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> path.with_file_name(format!("{stem}{suffix}.{ext}")).to_string_lossy().into_owned().into() }; let (key, ask) = match id { - "file.import" | "file.importMultiple" => ("paths", Ask::Import), + // Projects too, as After Effects' File… takes an .aep: they import as projects + // (`run_with_picked`, and `file.import` for EffectCraft projects). + "file.import" | "file.importMultiple" => ("paths", Ask::Import(&["aep", "aepx", "ecproj", "ecprojx"])), "file.open" => ("path", Ask::OpenProject), "file.save" if app.session.path.is_none() => ("path", Ask::Save(project_as("", "ecproj"))), "file.saveAs" => ("path", Ask::Save(project_as("", "ecproj"))), @@ -644,7 +656,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "effect.plugins.install" if params.get("bundle").is_some() => ("folder", Ask::Folder), "effect.plugins.install" if params.get("folder").is_none() && params.get("path").is_none() => ("path", Ask::Open(&["wasm"])), "effect.helpers.load" => ("path", Ask::Folder), - "file.replaceFootage" => ("path", Ask::Import), + "file.replaceFootage" => ("path", Ask::Import(&[])), "file.relinkFootage" => ("folder", Ask::Folder), "file.collectFiles" => ("folder", Ask::Save("Collected Files".into())), // An open XML project gets ` copy` too: the copy never writes over the open file. @@ -672,6 +684,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv", ]; let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::>(); + let importable = |more: &[&'static str]| footage(MEDIA).into_iter().chain(more.iter().copied()).collect::>(); // A host whose blocking dialogs are unsafe inside this frame shows the dialog without // blocking — macOS: the nested event loop re-entered winit's handler on a drag over the // window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped, @@ -680,7 +693,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> if let Some(spawn) = app.hooks.file_dialog_async.as_ref() { let request = match ask { Ask::Folder => crate::FileDialog::Folder, - Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import(more) => crate::FileDialog::Open { exts: importable(more).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, @@ -702,9 +715,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) }; f().map(Value::from) } - Ask::Import => { + Ask::Import(more) => { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(&footage(MEDIA))) + picked_value(key, f(&importable(more))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -758,7 +771,8 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) { .map(|e| match (e.get("file").and_then(Value::as_str), e.get("error").and_then(Value::as_str)) { (Some(file), Some(error)) => format!("{file}: {error}"), (_, Some(error)) => error.to_string(), - _ => e.to_string(), + // A plain message (`file.import`'s errors) reads as itself, not as quoted JSON. + _ => e.as_str().map_or_else(|| e.to_string(), str::to_string), }) .collect(); let mut msg = parts.iter().take(3).cloned().collect::>().join("; "); @@ -770,6 +784,14 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) { /// Run command `id` with the path picked for it in parameter `key`. fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result { + if matches!(id, "file.import" | "file.importMultiple") + && let Some(picked) = v.as_array() + && picked.iter().filter_map(Value::as_str).any(effectcraft_engine::commands::is_aep_path) + { + let (aep, rest): (Vec, Vec) = + picked.iter().filter_map(Value::as_str).map(str::to_string).partition(|p| effectcraft_engine::commands::is_aep_path(p)); + return import_picked_with_aep(app, id, key, params, aep, rest); + } let mut p = params.as_object().cloned().unwrap_or_default(); p.insert(key.to_string(), v); // Picked files import in the background, with the Importing card showing progress (#270). @@ -777,10 +799,13 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value p.insert("background".into(), Value::Bool(true)); } // Photoshop files ask how to import them first; numbered stills whether to import them as an - // image sequence. + // image sequence. Not while the losses prompt of an After Effects project picked with them + // shows: one dialog at a time, so they import with the defaults. let picked = Value::Object(p.clone()); - if id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked) - || matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked) + let asking = crate::panels::aep_losses::asking(app); + if !asking + && (id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked) + || matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked)) { return Ok(json!({"dialog": id})); } @@ -804,6 +829,62 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value r } +/// Import After Effects projects dropped or picked together, as File ▸ Import ▸ Adobe After +/// Effects Project… does each: one that would lose parts asks first (the losses prompt), and the +/// ones after it wait in its queue until it is answered (`panels::aep_losses`). Returns the errors +/// of those that could not be imported; each names its file. +pub(crate) fn import_aep_projects(app: &mut EffectcraftApp, paths: Vec) -> Vec { + let mut errors = Vec::new(); + // An unanswered prompt's projects go first. + let mut paths = crate::panels::aep_losses::take_unanswered(app).into_iter().chain(paths); + while let Some(path) = paths.next() { + if crate::panels::aep_losses::asking(app) { + let queue = &mut app.dialog_state.aep_losses.queue; + queue.push(path); + queue.extend(paths); + break; + } + if let Err(e) = run_with_picked(app, "file.importAep", "path", &json!({}), json!(path)) { + errors.push(e); + } + } + errors +} + +/// File ▸ Import ▸ File… with After Effects projects picked: they import first, as projects +/// ([`import_aep_projects`]), then the other files as footage. Their errors lead the result's +/// `errors` and the status line (callers of the blocking dialog drop the result), or the message +/// when the footage import failed. +fn import_picked_with_aep(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, aep: Vec, rest: Vec) -> Result { + let errors = import_aep_projects(app, aep); + let r = if !rest.is_empty() { + run_with_picked(app, id, key, params, json!(rest)) + } else if crate::panels::aep_losses::asking(app) { + Ok(json!({"dialog": "aepLosses"})) + } else { + Ok(json!({})) + }; + if errors.is_empty() { + return r; + } + match r { + Ok(mut result) => { + if !result.is_object() { + result = json!({"result": result}); + } + let own = result.get("errors").and_then(Value::as_array).cloned().unwrap_or_default(); + result["errors"] = Value::Array(errors.into_iter().map(Value::from).chain(own).collect()); + show_result_errors(app, &result); + Ok(result) + } + Err(e) => { + let message = format!("{}; {e}", errors.join("; ")); + app.ui.status = message.clone(); + Err(message) + } + } +} + /// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so /// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is /// in points per comp pixel like the result (#882). diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 0c7573c91..4349e6b33 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -163,6 +163,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { @@ -182,8 +183,15 @@ fn only_comp(p: &Value) -> Option { m.get("comp")?.as_u64().map(effectcraft_engine::project::ItemId) } +/// Whether [`invoke`]'s result says the command waits on a dialog (a guard or a form deferred it +/// until the user answers, `{"dialog": …}`). +pub fn waits_on_dialog(result: &Value) -> bool { + result.get("dialog").is_some() +} + /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + crate::control::check_script_render(app, id)?; if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") { return frontend(app, ctx, id, params); } diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index dc4efd384..a573a2f6d 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -403,6 +403,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only if app.dialog.is_some() { continue; } + if !mods.any() && crate::panels::effect_controls::handle_disclosure_key(app, key) { + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key)); + continue; + } // Enter in the Project panel renames the selected item (handled by the panel). // In Effects & Presets and the Render Queue Enter applies or opens the row (handled by the // panel), not the layer rename. diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index b12483b0a..12aac3cd9 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -730,3 +730,126 @@ fn effect_plugin_install_dialog_router() { assert!(r.is_err(), "{r:?}"); assert!(pending.lock().unwrap().is_empty(), "no dialog for an inline folder"); } + +/// File ▸ Import ▸ File… in the non-blocking dialog (macOS, Linux) offers After Effects projects +/// next to footage, and a picked one imports as File ▸ Import ▸ Adobe After Effects Project… does: its +/// folder, its composition open. +#[test] +fn import_file_dialog_offers_and_imports_after_effects_projects() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!("ec-import-aep-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let aep = dir.join("Intro.aep").to_string_lossy().to_string(); + let mut source = effectcraft_engine::Session::default(); + source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + source.execute("file.exportAep", json!({"path": aep, "allowLosses": true})).unwrap(); + + let pending: Arc>> = Arc::default(); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + invoke(&mut app, &ctx, "file.import", json!({})).unwrap(); + let Some(crate::FileDialog::Open { exts, many }) = pending.lock().unwrap().pop() else { panic!("an Open dialog was shown") }; + assert!(many && exts.iter().any(|e| e == "aep") && exts.iter().any(|e| e == "aepx"), "{exts:?}"); + assert!(exts.iter().any(|e| e == "png"), "footage too"); + + super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: vec![aep] }); + let comp = app.session.project.item(app.session.active_comp_id().expect("a composition is open")).unwrap(); + assert_eq!(comp.name, "Intro"); + assert_eq!(app.session.project.item(comp.parent.expect("in a folder")).unwrap().name, "Intro.aep"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Footage with unlabeled alpha asks how to interpret it (Interpret Unlabeled Alpha As: Ask +/// User), but not over a dialog that already waits (the After Effects losses prompt of the same +/// drop, which a background import can finish behind): that dialog stays, and a note says where +/// to choose the interpretation. +#[test] +fn the_unlabeled_alpha_question_does_not_replace_an_open_dialog() { + use effectcraft_engine::project::{Footage, FootageKind, ItemKind}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let footage = Footage { path: "/m/matte.tga".into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut app.session.project).add_item("matte.tga", Default::default(), None, ItemKind::Footage(footage)).0; + app.dialog = Some(crate::Dialog::AepLosses); + super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap(); + assert_eq!(app.dialog, Some(crate::Dialog::AepLosses), "the waiting dialog stays"); + assert!(app.ui.status.contains("Interpret Footage"), "{}", app.ui.status); + assert!(app.session.state.project_selection.is_empty(), "the selection is left alone"); + + app.dialog = None; + super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap(); + assert_eq!(app.dialog, Some(crate::Dialog::Form), "with no dialog open it asks"); +} + +/// File ▸ Import ▸ File… and Multiple Files… offer projects next to footage; File ▸ Replace +/// Footage offers footage only. +#[test] +fn only_the_import_dialogs_offer_projects() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let pending: Arc>> = Arc::default(); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + let projects = ["aep", "aepx", "ecproj", "ecprojx"]; + for (id, offered) in [("file.import", true), ("file.importMultiple", true), ("file.replaceFootage", false)] { + assert!(super::frontend::file_dialog(&mut app, id, &json!({})).is_some(), "{id} asks for a file"); + let Some(crate::FileDialog::Open { exts, .. }) = pending.lock().unwrap().pop() else { panic!("{id}: an Open dialog") }; + assert!(exts.iter().any(|e| e == "png"), "{id} offers footage"); + assert_eq!(projects.iter().all(|p| exts.iter().any(|e| e == p)), offered, "{id}: {exts:?}"); + assert!(offered || !projects.iter().any(|p| exts.iter().any(|e| e == p)), "{id} offers no project: {exts:?}"); + } +} + +/// Picked After Effects projects that cannot be imported keep their errors next to the other +/// files': a broken `.aep` and a broken `.ecproj` are both named in the status line. +#[test] +fn picked_projects_report_every_error() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let dir = std::env::temp_dir().join(format!("ec-picked-errors-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let aep = dir.join("broken.aep"); + let ecproj = dir.join("broken.ecproj"); + std::fs::write(&aep, b"not an aep").unwrap(); + std::fs::write(&ecproj, b"not a project").unwrap(); + let paths = vec![aep.to_string_lossy().to_string(), ecproj.to_string_lossy().to_string()]; + super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: paths.clone() }); + let status = &app.ui.status; + assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}"); + assert!(!status.contains('"'), "messages, not quoted JSON: {status}"); + assert!(app.dialog.is_none() && app.session.project.items.is_empty()); + + // The blocking dialog (Windows), whose callers drop the result: the status line still has both. + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + app.hooks.pick_files = Some(Box::new(move |_: &[&str]| paths.clone())); + let _ = invoke(&mut app, &ctx, "file.import", json!({})); + let status = &app.ui.status; + assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A picked After Effects project with parts that cannot come over asks first; one picked with +/// it waits for the answer. +#[test] +fn a_picked_project_with_losses_asks_and_the_next_waits() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let dir = std::env::temp_dir().join(format!("ec-picked-losses-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let lossy = dir.join("Synthetic.aep"); + std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"), &lossy).unwrap(); + let intro = dir.join("Intro.aep").to_string_lossy().to_string(); + let mut source = effectcraft_engine::Session::default(); + source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + source.execute("file.exportAep", json!({"path": intro, "allowLosses": true})).unwrap(); + let paths = vec![lossy.to_string_lossy().to_string(), intro.clone()]; + super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths }); + assert_eq!(app.dialog, Some(crate::Dialog::AepLosses)); + assert_eq!(app.dialog_state.aep_losses.queue, [intro]); + assert!(app.session.project.items.is_empty(), "nothing imported before the answer"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 746e83b86..4674c369a 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -1,12 +1,14 @@ -//! File ▸ Import / Export ▸ After Effects Project… from the menu: what the conversion cannot +//! File ▸ Import / Export ▸ Adobe After Effects Project… from the menu: what the conversion cannot //! carry over is shown first (the command's dry run), and the user chooses to cancel or to go on //! with those losses (`allowLosses`). Cancel changes nothing; a conversion without losses runs at //! once. Agents running `file.importAep` / `file.exportAep` with `engine.execute` are never asked: -//! they pass `allowLosses` or get the refusal. +//! they pass `allowLosses` or get the refusal. After Effects projects dropped or picked together +//! (File ▸ Import ▸ File…) are asked about one at a time: the others wait in the queue until the +//! prompt is answered, and Cancel skips only the one it asks about. //! //! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`. -use crate::i18n::tr; +use crate::i18n::{tr, tr_args}; use egui::vec2; use serde_json::{Value, json}; @@ -17,10 +19,28 @@ use crate::{Dialog, EffectcraftApp}; #[derive(Clone, Debug, Default)] pub struct Pending { pub command: Option<(String, Value, Value)>, + /// After Effects projects dropped or picked with it, imported once it is answered. + pub queue: Vec, } -/// How many detail lines of a loss category the prompt shows. -const DETAILS: usize = 3; +/// Whether the prompt is showing with a conversion waiting on it. +pub fn asking(app: &EffectcraftApp) -> bool { + app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some() +} + +/// The projects of a prompt another dialog replaced (Save changes? on quitting) before it was +/// answered: its import's file, then its queue. Taken, so they are tried again instead of waiting +/// for an answer that never comes (a replaced export is dropped: it wrote nothing). +pub fn take_unanswered(app: &mut EffectcraftApp) -> Vec { + if app.dialog == Some(Dialog::AepLosses) { + return Vec::new(); + } + let losses = &mut app.dialog_state.aep_losses; + let Some((id, params, _)) = losses.command.take() else { return std::mem::take(&mut losses.queue) }; + let mut paths: Vec = params.get("path").and_then(Value::as_str).filter(|_| id == "file.importAep").map(str::to_string).into_iter().collect(); + paths.append(&mut losses.queue); + paths +} /// Whether command `id` with `params` needs the prompt first. When it does, the prompt opens /// holding the command and the caller must not run it. `Err` when the dry run fails (the file @@ -39,6 +59,9 @@ pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result String { chars.next().map(|f| f.to_uppercase().chain(chars).collect()).unwrap_or_default() } -/// The report's losses as lines: one per category with its count, and a few of its details. +/// The report's losses as lines: one per category with how many occurrences are left out or +/// changed, then every detail line the report keeps and "… and N more" for the occurrences none +/// describes (#3365). pub fn lines(report: &Value) -> Vec<(String, Vec)> { let Some(losses) = report["losses"].as_array() else { return vec![] }; losses .iter() .map(|l| { - let head = format!("{} ({})", category(l["category"].as_str().unwrap_or("other")), l["count"].as_u64().unwrap_or(0)); - let details = l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).take(DETAILS).map(str::to_string).collect()).unwrap_or_default(); + let count = l["count"].as_u64().unwrap_or(0); + let head = tr_args("{}: {} left out or changed", &[&category(l["category"].as_str().unwrap_or("other")), &count]); + let mut details: Vec = + l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + // An export report has no `unlisted`: each of its lines is one occurrence. + let listed = u64::try_from(details.len()).unwrap_or(u64::MAX); + let unlisted = l["unlisted"].as_u64().unwrap_or_else(|| count.saturating_sub(listed)); + if unlisted > 0 { + details.push(tr_args("… and {} more", &[&unlisted])); + } (head, details) }) .collect() } +/// Draw the prompt for the conversion waiting on it: its losses by category, Cancel and Import / +/// Export Anyway (Escape cancels). pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let Some((id, _, report)) = app.dialog_state.aep_losses.command.clone() else { app.dialog = None; return; }; let import = id == "file.importAep"; - let title = if import { "Import After Effects Project" } else { "Export After Effects Project" }; + let title = if import { "Import Adobe After Effects Project" } else { "Export Adobe After Effects Project" }; let mut answer: Option = None; super::dialogs::modal(ctx, title, vec2(520.0, 340.0), t, |ui| { let text = if import { @@ -114,11 +149,25 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { /// The prompt's answer: Cancel (`false`) drops the conversion, Import / Export Anyway runs it /// with `allowLosses`. fn finish(app: &mut EffectcraftApp, ctx: &egui::Context, go: bool) { - app.dialog = None; - let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else { return }; + let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else { + app.dialog = None; + return; + }; + let mut errors = Vec::new(); if go { - // An error is already in the status bar. - let _ = crate::menus::run_engine(app, ctx, &id, with_losses(¶ms)); + // The prompt stays up while the conversion runs: a question queued by a footage import + // that finished meanwhile (Interpret Footage for unlabeled alpha, drained by `run_engine`) + // leaves a note instead of opening a dialog the next queued prompt would replace. + if let Err(e) = crate::menus::run_engine(app, ctx, &id, with_losses(¶ms)) { + errors.push(e); + } + } + app.dialog = None; + // The projects dropped or picked with this one: Cancel skips only this one. + let queue = std::mem::take(&mut app.dialog_state.aep_losses.queue); + errors.extend(crate::menus::import_aep_projects(app, queue)); + if !errors.is_empty() { + app.ui.status = errors.join("; "); } } @@ -137,11 +186,72 @@ mod tests { fn the_report_reads_as_categories_with_counts_and_details() { let report = json!({"losses": [{"category": "layer_styles", "count": 4, "details": ["a", "b", "c", "d"]}, {"category": "effects", "count": 1}]}); let l = lines(&report); - assert_eq!(l[0], ("Layer styles (4)".to_string(), vec!["a".to_string(), "b".into(), "c".into()])); - assert_eq!(l[1], ("Effects (1)".to_string(), vec![])); + assert_eq!(l[0], ("Layer styles: 4 left out or changed".to_string(), vec!["a".to_string(), "b".into(), "c".into(), "d".into()])); + assert_eq!(l[1], ("Effects: 1 left out or changed".to_string(), vec!["… and 1 more".to_string()])); assert!(lines(&json!({})).is_empty()); } + /// #3365: every detail line shows, then how many occurrences have none: the report's + /// `unlisted` (an effect line can describe many instances), else the lines past the count. + #[test] + fn every_detail_shows_and_the_rest_is_counted() { + let details: Vec = (0..20).map(|i| format!("d{i}")).collect(); + let l = lines(&json!({"losses": [{"category": "masks", "count": 27, "details": details}]})); + assert_eq!(l[0].1.len(), 21); + assert_eq!(l[0].1[20], "… and 7 more"); + let l = lines(&json!({"losses": [{"category": "effects", "count": 50, "details": ["X, and 44 more like it"], "unlisted": 5}]})); + assert_eq!(l[0].1, ["X, and 44 more like it", "… and 5 more"]); + let l = lines(&json!({"losses": [{"category": "effects", "count": 45, "details": ["X, and 44 more like it"], "unlisted": 0}]})); + assert_eq!(l[0].1, ["X, and 44 more like it"]); + } + + /// Projects dropped or picked together wait only behind a prompt that shows; with none + /// showing they are tried at once (a missing file is an error naming it). + #[test] + fn projects_wait_only_behind_a_showing_prompt() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({}))); + app.dialog = Some(Dialog::AepLosses); + assert!(crate::menus::import_aep_projects(&mut app, vec!["/no/such/Two.aep".into(), "/no/such/Three.aep".into()]).is_empty()); + assert_eq!(app.dialog_state.aep_losses.queue, ["/no/such/Two.aep", "/no/such/Three.aep"]); + + // Cancel: the queued ones are tried next (both missing here, so both are reported). + let ctx = egui::Context::default(); + finish(&mut app, &ctx, false); + assert!(app.dialog_state.aep_losses.queue.is_empty()); + assert!(app.ui.status.contains("Two.aep") && app.ui.status.contains("Three.aep"), "{}", app.ui.status); + } + + /// A prompt another dialog replaced (Save changes? on quitting) holds nothing back: the next + /// import tries its project and queue first, then the new one. + #[test] + fn a_replaced_prompt_holds_nothing_back() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/no/such/One.aep"}), json!({}))); + app.dialog_state.aep_losses.queue = vec!["/no/such/Two.aep".into()]; + app.dialog = Some(Dialog::UnsavedChanges); + let errors = crate::menus::import_aep_projects(&mut app, vec!["/no/such/Three.aep".into()]); + let named = |n: &str| errors.iter().position(|e| e.contains(n)); + assert_eq!((named("One.aep"), named("Two.aep"), named("Three.aep")), (Some(0), Some(1), Some(2)), "{errors:?}"); + assert!(app.dialog_state.aep_losses.command.is_none() && app.dialog_state.aep_losses.queue.is_empty()); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges), "the other dialog stays"); + } + + /// A new prompt keeps an unanswered one's projects: they wait behind it. + #[test] + fn a_new_prompt_keeps_an_unanswered_ones_projects() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({}))); + app.dialog_state.aep_losses.queue = vec!["/a/Two.aep".into()]; + app.dialog = Some(Dialog::UnsavedChanges); + // Rec. 2020 has no After Effects counterpart: exporting asks. + std::sync::Arc::make_mut(&mut app.session.project).settings.working_space = Some(effectcraft_engine::color::ColorSpace::Rec2020); + let dir = TempDir::new(); + assert_eq!(guard(&mut app, "file.exportAep", &json!({"path": dir.0.join("out.aep").to_string_lossy()})), Ok(true)); + assert_eq!(app.dialog, Some(Dialog::AepLosses)); + assert_eq!(app.dialog_state.aep_losses.queue, ["/a/One.aep", "/a/Two.aep"]); + } + #[test] fn agents_and_consented_calls_are_not_asked() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 83f502988..4c297f9e6 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -43,6 +43,7 @@ mod viewer; pub use common::{choose_files_after_apply, reveal_applied, set_lock}; pub(crate) use common::{gesture_key, scope_lock, shown_layer_id}; +pub(crate) use panel::handle_disclosure_key; pub use panel::{show, start_rename}; pub(crate) use rows::toggle_pick; pub use viewer::viewer_hook; diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 95f810d1a..2dc005251 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -2,6 +2,39 @@ use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, prune_lock, shown use super::editors::{curves_editor, editor_height, histogram_only, levels_editor, object_matte_editor, roto_editor, warp_editor}; use super::rows::{FxScope, group_rows, toggle_closed}; use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets}; +use crate::dock::{PanelKind, WorkspaceExt as _}; + +/// Plain Right / Left Arrow expand / collapse the selected effects, as in After Effects, before +/// the arrows' layer nudge runs. Only while Effect Controls has the focus and is on screen: the +/// front tab of its frame, drawn last frame (a maximized frame hides every other frame and every +/// floating window), not behind Home. With no effect header selected the arrows still nudge. +pub(crate) fn handle_disclosure_key(app: &mut EffectcraftApp, key: egui::Key) -> bool { + let panel = PanelKind::EffectControls; + if app.ui.focused != panel + || app.ui.start_screen + || app.session.state.text_edit.is_some() + || !app.ui.dock.is_visible(panel) + || !app.dock_rects.iter().any(|(p, _)| *p == panel) + || !matches!(key, egui::Key::ArrowLeft | egui::Key::ArrowRight) + { + return false; + } + let Some(comp) = app.session.active_comp() else { return false }; + let Some(layer) = app.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) else { return false }; + let Some(effects) = layer.effects() else { return false }; + let mut handled = false; + for g in effects.groups() { + if matches!(&g.kind, GroupKind::Effect { .. }) && app.session.state.selected_props.contains(&(layer.id, g.uid)) { + if key == egui::Key::ArrowLeft { + app.ui.fx_closed.insert(g.uid); + } else { + app.ui.fx_closed.remove(&g.uid); + } + handled = true; + } + } + handled +} // --------------------------------------------------------------------------------------------- @@ -189,6 +222,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 635ef9a43..58f89e39d 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -338,11 +338,11 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &m /// The Project Settings dialog's title and fields. fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec) { let st = &s.project.settings; - // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through + // The catalogue row is `GPU Acceleration ({})`, so the value goes through // tr_args: wrapping the assembled string in tr() would look up the backend's name. let gpu_label = match &s.accel { - Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]), - None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(), + Some(a) => tr_args("GPU Acceleration ({})", &[&a.name()]), + None => tr("GPU Acceleration (no GPU: software)").to_string(), }; let depth = match st.bit_depth.label() { l if l.starts_with("16") => 1, @@ -436,7 +436,7 @@ fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec Field::choice( "renderer", "Video rendering and effects", - &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))], + &[(gpu_label.as_str(), json!("gpu")), ("Software Rendering", json!("software"))], usize::from(!st.gpu_acceleration), ), ], @@ -630,9 +630,11 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { )], ) } + // The time as the project displays it (timecode, or frames from Start Numbering Frames At, + // or feet + frames), which `time.set` reads back the same way. "time.set" if !has(p, &["time", "frame", "timecode"]) => { - let frame = comp.map(|c| c.frame_rate.frame_at(t)).unwrap_or(0); - ("Go to Time".into(), vec![Field::num("frame", "Frame", frame as f64)]) + let shown = comp.map(|c| crate::panels::timecode(s, c, t)).unwrap_or_default(); + ("Go to Time".into(), vec![Field::text("timecode", "Time", &shown)]) } "view.addGuide" if !has(p, &["position"]) => { let (w, _) = comp.map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); @@ -1050,6 +1052,18 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke mod tests { use super::*; + #[test] + fn renderer_form_keeps_descriptive_labels_and_stable_option_values() { + crate::i18n::set_current("en"); + let (_, fields) = project_settings_form(&effectcraft_engine::Session::default()); + let renderer = fields.iter().find(|field| field.key == "renderer").unwrap(); + let FieldKind::Choice { options, .. } = &renderer.kind else { panic!("renderer must be a choice") }; + assert_eq!( + options.as_slice(), + &[("GPU Acceleration (no GPU: software)".to_string(), json!("gpu")), ("Software Rendering".to_string(), json!("software"))] + ); + } + #[test] fn form_params_build_arrays_and_choices() { let f = Form { @@ -1096,6 +1110,42 @@ mod tests { assert!(app.dialog.is_none(), "one Enter confirms"); } + /// Go to Time shows the time as the project displays it, so with Frames numbered from 1 the + /// viewer's frame 00013 opens as 00013 and 00020 goes to the frame displayed 00020 (the + /// engine's frame 19). The dialog used to show and take the engine's own frame number (#3373). + #[test] + fn go_to_time_uses_the_projects_frame_numbering() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 24, "duration": 4})).unwrap(); + app.session.execute("file.projectSettings", json!({"timeDisplay": "frames", "frameStart": 1})).unwrap(); + app.session.execute("time.set", json!({"frame": 12})).unwrap(); + assert!(open_form(&mut app, "time.set", &json!({}))); + let f = &app.dialog_state.form; + assert_eq!(f.command, "time.set"); + assert!(matches!(&f.fields[0].kind, FieldKind::Text(t) if t == "00013"), "{:?}", f.fields[0]); + app.dialog_state.form.fields[0].kind = FieldKind::Text("00020".into()); + let (command, params) = (app.dialog_state.form.command.clone(), app.dialog_state.form.params()); + app.session.execute(&command, params).unwrap(); + let at = app.session.active_comp().unwrap().frame_rate.frame_at(app.session.time()); + assert_eq!(at, 19, "displayed 00020 with frames starting at 1"); + } + + /// In a comp that starts 1 s before 0, Go to Time at frame 12 opens on the negative time the + /// viewer shows, and OK without editing it stays on frame 12 (it used to jump to frame 0). + #[test] + fn go_to_time_ok_keeps_a_negative_displayed_time() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 24, "duration": 4, "startTime": -1})).unwrap(); + app.session.execute("time.set", json!({"frame": 12})).unwrap(); + assert!(open_form(&mut app, "time.set", &json!({}))); + let f = &app.dialog_state.form; + assert!(matches!(&f.fields[0].kind, FieldKind::Text(t) if t == "-0:00:00:12"), "{:?}", f.fields[0]); + let (command, params) = (f.command.clone(), f.params()); + app.session.execute(&command, params).unwrap(); + let at = app.session.active_comp().unwrap().frame_rate.frame_at(app.session.time()); + assert_eq!(at, 12, "OK on the unedited time stays put"); + } + /// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro /// XML; its fields register automation ids and OK runs `file.exportTimeline`. #[test] diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 3b735e47e..601f4f1ef 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -475,13 +475,15 @@ fn home_page( } } +/// Run the commands the Home screen's buttons and recent files asked for, and leave Home once one +/// opened something (not while a dialog it opened waits, nor when only the list changed). fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) { for (id, params) in actions { let before = (app.session.path.clone(), app.session.revision); match crate::menus::invoke(app, ctx, id, params) { Err(e) => app.ui.status = e, // Asking to save the open project first: Home stays until it is answered. - Ok(r) if r.get("dialog").is_some() => {} + Ok(r) if crate::menus::waits_on_dialog(&r) => {} // Leave Home unless only the list changed or a file dialog was cancelled. Ok(_) => { let cancelled = matches!(id, "file.open" | "file.import") && (app.session.path.clone(), app.session.revision) == before; diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 2a5579837..db3144ffd 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -262,7 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Stretch Factor:")); let before = d.percent; - drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true); + drag( + ui, + app, + "dialog.timeStretch.percent", + &mut d.percent, + DragSpec { speed: 0.5, range: -effectcraft_project::limits::MAX_STRETCH..=effectcraft_project::limits::MAX_STRETCH, suffix: " %" }, + true, + ); if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 { d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); } @@ -271,7 +278,12 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let before = d.duration; drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true); if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 { - d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + let wanted = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + // Within what Time Stretch accepts (±1 % to ±9900 %, #3247); the duration follows. + d.percent = effectcraft_project::limits::stretch(wanted); + if d.percent.to_bits() != wanted.to_bits() && d.orig_percent.abs() > 1e-9 { + d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); + } } ui.end_row(); }); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 8e4f3502f..e23e11cfb 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -113,6 +113,7 @@ fn render_tex( footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 25829be7d..4edcbb31a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -79,6 +79,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); let rect = scroll.content; diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 194bba26a..c01a8f07e 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -114,6 +114,7 @@ pub fn draw_overlay( footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 753b2884e..2e34757bf 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -329,7 +329,7 @@ fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) - if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { return cached; } - let tex = img.bytes(services).and_then(|b| image::load_from_memory(&b).ok()).map(|d| { + let tex = img.bytes(services).and_then(|b| decode_ui_image(&b)).map(|d| { let rgba = d.to_rgba8(); let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); ctx.load_texture(format!("scriptui-image-{}", img.name), ci, egui::TextureOptions::LINEAR) @@ -338,6 +338,21 @@ fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) - tex } +/// The largest side of a ScriptUI image (icons and banners are far smaller). +const MAX_UI_IMAGE_SIDE: u32 = 4096; + +/// A ScriptUI image decoded within [`MAX_UI_IMAGE_SIDE`] per side and a matching allocation +/// limit, checked from the header before the pixels (and their RGBA copy) are allocated. +fn decode_ui_image(bytes: &[u8]) -> Option { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_UI_IMAGE_SIDE); + limits.max_image_height = Some(MAX_UI_IMAGE_SIDE); + limits.max_alloc = Some(u64::from(MAX_UI_IMAGE_SIDE) * u64::from(MAX_UI_IMAGE_SIDE) * 16); + r.limits(limits); + r.decode().ok() +} + /// An image control's or icon button's image, fitted (aspect kept) and centred in `r`. fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) -> bool { let Some(tex) = image_texture(ui.ctx(), img, services) else { return false }; @@ -593,9 +608,8 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { ui.data_mut(|d| d.insert_temp(told, dock)); acts.push(Act::Resize(id, dock)); } - let content = rect; - egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { - let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); + let window = vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32); + panel_scroll(ui, ("scriptui-panel", id), rect.size(), window, |ui, r| { draw_children(app, ui, r.min, id, &w.root, &mut acts); mouse_events(ui, r.min, r.intersect(ui.clip_rect()), id, &w.root, !modal, &mut acts); }); @@ -604,6 +618,36 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { perform(app, &ctx, acts); } +/// The scroll area a docked ScriptUI panel shows its `window`-sized window in, at most `dock` +/// big, with the app's scroll style (solid bars, #269). `body` draws the window in the rect it is +/// given. +fn panel_scroll( + ui: &mut egui::Ui, + salt: impl std::hash::Hash + std::fmt::Debug, + dock: egui::Vec2, + window: egui::Vec2, + body: impl FnOnce(&mut egui::Ui, Rect) -> R, +) -> egui::scroll_area::ScrollAreaOutput { + egui::ScrollArea::both().id_salt(salt).max_width(dock.x).max_height(dock.y).scroll_bar_visibility(panel_bars(dock, window)).show(ui, |ui| { + let (r, _) = ui.allocate_exact_size(window, Sense::hover()); + body(ui, r) + }) +} + +/// Whether a docked panel's scroll bars may show: never while its window fits the dock. +/// +/// The fit is decided from the window and the dock, not from what the scroll area has left. A +/// solid bar, once shown, takes its width from the viewport, so a window that exactly fills its +/// dock was "too large" for the narrower viewport and its bars never went away (#2796). The fit +/// rounds the dock up to the whole point, as egui's scroll area does, because the script is told +/// its dock's size rounded (#2585) and its window can be a fraction of a point bigger. A window +/// that overflows only one way still gets both bars: the first one's width hides the edge of a +/// window laid out at the dock's full size, and the second one scrolls to it. +fn panel_bars(dock: egui::Vec2, window: egui::Vec2) -> egui::scroll_area::ScrollBarVisibility { + use egui::scroll_area::ScrollBarVisibility; + if window.x <= dock.x.ceil() && window.y <= dock.y.ceil() { ScrollBarVisibility::AlwaysHidden } else { ScrollBarVisibility::VisibleWhenNeeded } +} + /// Tab title of a script panel. pub fn panel_title(app: &EffectcraftApp, id: u32) -> Option { app.session.script_ui.window(id).map(|w| if w.title.is_empty() { w.script.clone() } else { w.title.clone() }) @@ -625,6 +669,70 @@ pub fn sync_panels(app: &mut EffectcraftApp) { mod tests { use super::*; + const DOCK: egui::Vec2 = egui::vec2(300.0, 200.0); + + /// Twelve frames of a ScriptUI panel docked in `dock`, with the app's solid scroll bars + /// (#269): its window is `first` big on the first frame and `then` big after that. The last + /// frame's scroll output (viewport and content size). + fn panel_frames(dock: egui::Vec2, first: egui::Vec2, then: egui::Vec2) -> (Rect, egui::Vec2) { + let ctx = egui::Context::default(); + ctx.all_styles_mut(|s| s.spacing.scroll = egui::style::ScrollStyle::solid()); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); + let mut last = (Rect::NOTHING, egui::Vec2::ZERO); + for frame in 0..12 { + let window = if frame == 0 { first } else { then }; + let input = egui::RawInput { screen_rect: Some(screen), time: Some(f64::from(frame) * 0.1), predicted_dt: 0.1, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + let mut panel = ui.new_child(UiBuilder::new().max_rect(Rect::from_min_size(egui::Pos2::ZERO, dock))); + let o = panel_scroll(&mut panel, "panel-test", dock, window, |_, _| ()); + last = (o.inner_rect, o.content_size); + }); + // Nothing paints these frames, and egui expects their texture uploads to be consumed. + out.textures_delta.clear(); + } + last + } + + /// #2796: a solid bar, once shown, took its width from the viewport, so a window that exactly + /// fills its dock stayed "too large" for what was left and kept its bars for good. A window + /// that overflowed for one frame (a dock dragged smaller before the script learns its size) + /// and then fits gets the whole dock back. + #[test] + fn a_panel_whose_window_fits_hides_its_bars() { + let (viewport, _) = panel_frames(DOCK, DOCK + vec2(40.0, 40.0), DOCK); + assert_eq!(viewport.size(), DOCK, "the bars go away once the window fits"); + } + + /// The script is told its dock's size rounded (#2585), so in a dock a fraction of a pixel + /// smaller its window is that fraction bigger. That still fits. + #[test] + fn a_panel_in_a_fractional_dock_hides_its_bars() { + let dock = vec2(299.6, 199.6); + let (viewport, _) = panel_frames(dock, dock + vec2(40.0, 40.0), DOCK); + // egui snaps rects to 1/32 of a point; a bar would take 10 points. + assert!((viewport.size() - dock).length() < 0.05, "a window within the whole pixel fits ({viewport:?})"); + } + + /// #269 holds: a window bigger than its dock keeps solid bars that take their room from the + /// panel, and scrolls. + #[test] + fn a_panel_whose_window_is_too_big_keeps_its_solid_bars() { + let big = DOCK + vec2(120.0, 80.0); + let (viewport, content) = panel_frames(DOCK, big, big); + let bar = egui::style::ScrollStyle::solid().allocated_width(); + assert_eq!(viewport.size(), DOCK - vec2(bar, bar), "solid bars on both axes"); + assert_eq!(content, big, "the whole window is scrollable"); + } + + #[test] + fn panel_bars_only_show_for_a_window_bigger_than_its_dock() { + use egui::scroll_area::ScrollBarVisibility::{AlwaysHidden, VisibleWhenNeeded}; + assert_eq!(panel_bars(DOCK, DOCK), AlwaysHidden); + assert_eq!(panel_bars(vec2(299.4, 199.4), DOCK), AlwaysHidden); + assert_eq!(panel_bars(DOCK, DOCK + vec2(1.0, 0.0)), VisibleWhenNeeded); + assert_eq!(panel_bars(DOCK, DOCK + vec2(0.0, 1.0)), VisibleWhenNeeded); + } + /// Script colours are sRGB, not linear: `#2B2B2B` drew as RGB 114 when read as linear light. #[test] fn draw_colours_are_srgb() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 72564f112..cdeb85116 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -5,7 +5,8 @@ //! //! Automation ids: `settings.page.`, `settings.` (e.g. `settings.general.undoLevels`), //! `settings.labels..name` / `.color`, `settings.button.