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 354fbd268..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: @@ -29,6 +33,7 @@ jobs: # 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 }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5bff5cdb0..f1ea2a95d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 732251f19..feec44ceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,15 +1089,24 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-audio" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" +dependencies = [ + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4)", + "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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" [[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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "serde", "serde_json", @@ -1107,12 +1116,12 @@ dependencies = [ [[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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" [[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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1132,6 +1141,14 @@ dependencies = [ "writeable", ] +[[package]] +name = "craft-media" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-media" version = "0.1.0" @@ -1143,7 +1160,7 @@ dependencies = [ [[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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "hayro-syntax", "thiserror 2.0.21", @@ -1152,7 +1169,7 @@ dependencies = [ [[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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "dispatch2", "egui", @@ -1163,7 +1180,7 @@ dependencies = [ [[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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1184,7 +1201,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "chrono", "dunce", @@ -1194,6 +1211,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.5", "semver", "serde", "serde_json", @@ -1207,6 +1225,16 @@ dependencies = [ "zip 9.1.3", ] +[[package]] +name = "craft-video" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" +dependencies = [ + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4)", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1420,8 +1448,9 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "chrono", "cpal", "craft-update", "effectcraft-engine", @@ -1439,6 +1468,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "sysinfo", "winit", "winresource", "zbus", @@ -1446,7 +1476,7 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1461,7 +1491,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1473,16 +1503,9 @@ dependencies = [ "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", @@ -1501,7 +1524,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.21.0" +version = "0.24.1" dependencies = [ "proptest", "serde", @@ -1509,7 +1532,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.21.0" +version = "0.24.1" dependencies = [ "chrono", "effectcraft-color", @@ -1527,7 +1550,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.21.0" +version = "0.24.1" dependencies = [ "chrono", "craft-docpath", @@ -1566,12 +1589,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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4)", + "craft-video", "effectcraft-color", "effectcraft-effects", - "effectcraft-hevcenc", "effectcraft-keyframe", "effectcraft-media", "effectcraft-opusenc", @@ -1579,25 +1603,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", @@ -1614,7 +1635,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.21.0" +version = "0.24.1" dependencies = [ "proptest", "serde", @@ -1622,7 +1643,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "effectcraft-color", @@ -1645,7 +1666,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1654,16 +1675,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", @@ -1690,7 +1704,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1709,7 +1723,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1721,7 +1735,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.21.0" +version = "0.24.1" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1739,9 +1753,11 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.21.0" +version = "0.24.1" dependencies = [ + "craft-audio", "craft-docpath", + "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4)", "effectcraft-color", "effectcraft-effects", "effectcraft-model", @@ -1753,12 +1769,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", @@ -1767,12 +1780,13 @@ dependencies = [ "log", "rayon", "serde_json", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "effectcraft-model" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-geom", "image", @@ -1785,14 +1799,14 @@ dependencies = [ [[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", @@ -1804,7 +1818,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.21.0" +version = "0.24.1" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1820,7 +1834,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1832,7 +1846,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.21.0" +version = "0.24.1" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1846,14 +1860,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.21.0" +version = "0.24.1" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1863,7 +1877,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1886,7 +1900,7 @@ dependencies = [ [[package]] name = "effectcraft-sam2" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "candle-core", @@ -1901,7 +1915,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.21.0" +version = "0.24.1" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1919,7 +1933,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.21.0" +version = "0.24.1" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1928,19 +1942,20 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.21.0" +version = "0.24.1" dependencies = [ "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", @@ -1952,7 +1967,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.21.0" +version = "0.24.1" dependencies = [ "proptest", "serde", @@ -1960,7 +1975,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.21.0" +version = "0.24.1" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1972,7 +1987,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.21.0" +version = "0.24.1" dependencies = [ "bytemuck", "chrono", @@ -2011,13 +2026,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", @@ -2482,7 +2493,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]] @@ -2583,16 +2594,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" @@ -2625,7 +2626,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]] @@ -2633,7 +2634,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]] @@ -3069,7 +3070,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]] @@ -3322,7 +3323,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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "hayro-ccitt", ] @@ -3336,7 +3337,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=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -3669,7 +3670,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.21", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3815,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4121,6 +4122,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" @@ -4435,6 +4445,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" @@ -4665,7 +4685,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6072,6 +6092,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" @@ -7225,16 +7259,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]] @@ -7256,6 +7312,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" @@ -7264,9 +7333,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]] @@ -7276,8 +7356,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]] @@ -7302,12 +7382,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" @@ -7315,7 +7411,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]] @@ -7324,9 +7420,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]] @@ -7338,13 +7434,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]] @@ -7353,7 +7467,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]] @@ -7389,7 +7503,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]] @@ -7423,13 +7537,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]] @@ -7712,7 +7835,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.21.0" +version = "0.24.1" dependencies = [ "chrono", "pkg-config", @@ -8038,18 +8161,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 b85c3c6b8..c812a3294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,19 +4,19 @@ 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 = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" } +craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" } +craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4" } # 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 = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } @@ -41,7 +41,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 +48,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 +79,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 = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", default-features = false, features = ["isobmff", "matroska"] } +craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", default-features = false, features = ["aac-decode", "aac-encode", "opus-decode"] } +craft-video = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", 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 +112,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 = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", 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 +132,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 +190,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 = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4", 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 +233,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 913f8aa06..57eddebdc 100644 --- a/README.md +++ b/README.md @@ -156,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 b8634178e..f52401a00 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -31,6 +31,7 @@ //! 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) @@ -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<(), 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)) { 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/Cargo.toml b/apps/effectcraft/Cargo.toml index 3030ee96d..21bd46755 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. 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..6ca42aab7 --- /dev/null +++ b/apps/effectcraft/src/crash_report.rs @@ -0,0 +1,797 @@ +//! 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 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 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 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 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 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 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..2656179ac 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(); @@ -142,6 +220,7 @@ mod tests { // 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); } @@ -217,4 +300,232 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// A fresh settings folder for one test. + fn fresh_dir(case: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-launch-guard-{case}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// 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 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 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 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 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 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() { + 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 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 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() { + 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 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 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 5350addbe..5a602adcb 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; @@ -180,6 +182,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 +224,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 +282,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 +323,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); }); } @@ -398,6 +454,7 @@ fn main() -> eframe::Result { window: window::Tracker::new(opened, saved_window, control_port.is_none()), launch, drawn: false, + reporter: desktop_reporter, })) }), ); @@ -491,6 +548,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 { @@ -525,15 +583,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/crates/aep/src/container.rs b/crates/aep/src/container.rs index 1cd0fd2b7..c71b40d9e 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -49,7 +49,7 @@ pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, Laye #[cfg(test)] pub(crate) use effects::Pard; pub use error::AepError; -pub use footage::{Footage, SequenceInfo}; +pub use footage::{Footage, PsdLayer, SequenceInfo}; pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer}; pub use index::{Node, NodeWalk, Nodes, TreeIndex}; pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey}; diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index e25456bec..5f0363001 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -38,6 +38,19 @@ pub enum Conv { Skip, } +impl Conv { + /// For a number (`Percent`, `Plain`, `Factor`): what a stored value is multiplied by to give + /// the value here. `None` for every other conversion. + pub fn number_scale(self) -> Option { + match self { + Conv::Percent { valid_max } => Some(100.0 / valid_max), + Conv::Plain { .. } => Some(1.0), + Conv::Factor { factor, .. } => Some(factor), + _ => None, + } + } +} + // After Effects' popup items as the corpus stores them (`pdnm`). /// 368. pub const RAMP_SHAPE: &[&str] = &["Linear Ramp", "Radial Ramp"]; @@ -1142,8 +1155,12 @@ pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5; /// The key a popup item is matched by between the two programs: trimmed, lower case, and /// Set Matte's "Red Channel" ... "Alpha Channel" are EffectCraft's "Red" ... "Alpha" (904 E24). +/// +/// Lower case is Unicode's, not only ASCII's: After Effects stores popup items in its UI +/// language, so an item can start with a capital outside ASCII (French "Écran"), and its key must +/// be the same as for the same word in lower case. pub fn popup_key(item: &str) -> String { - popup_stem(item).to_ascii_lowercase() + popup_stem(item).chars().flat_map(char::to_lowercase).collect() } /// [`popup_key`] before the lower-casing, borrowed: `item` trimmed, without a final ` channel` @@ -1161,7 +1178,29 @@ const SUFFIX: &[u8] = b" channel"; /// Whether two popup items have the same [`popup_key`], without building either key. pub(crate) fn popup_keys_match(a: &str, b: &str) -> bool { - popup_stem(a).eq_ignore_ascii_case(popup_stem(b)) + popup_stem(a).chars().flat_map(char::to_lowercase).eq(popup_stem(b).chars().flat_map(char::to_lowercase)) +} + +/// The English [`popup_key`] a non-English After Effects popup item name stands for, when this +/// table knows it; `None` otherwise. EffectCraft's own popup options are English only, so a +/// project saved by an After Effects set to another UI language never matches them by name and +/// the parameter falls back to its default (observed on a real "Niveaux" project, French After +/// Effects 2026 26.0.0x67: Levels' Channel and Clip to Output Black / White). Add an entry only +/// from a confirmed After Effects label, never a guess at a whole menu's translation. +pub fn popup_alias(key: &str) -> Option<&'static str> { + Some(match key { + // French: Levels' Channel popup (`ADBE Easy Levels2-0001`). "Alpha" is the same word in + // both languages, so it needs no entry. + "rvb" => "rgb", + "rouge" => "red", + "vert" => "green", + "bleu" => "blue", + // French: Levels' Clip to Output Black / White popups (`-0008`, `-0009`). + "activé" => "on", + "désactivé" => "off", + "désactivé pour la couleur 32 bpc" => "off for 32 bpc color", + _ => return None, + }) } /// The table row of an After Effects match name. @@ -1188,7 +1227,7 @@ mod tests { /// The key as it was built before the borrowed form (the reference for the differential test). fn old_key(item: &str) -> String { - let s = item.trim().to_ascii_lowercase(); + let s = item.trim().to_lowercase(); s.strip_suffix(" channel").map(str::to_string).unwrap_or(s) } @@ -1206,12 +1245,14 @@ mod tests { " channel", "x channel", "Xchannel", - "Ä channel", - "Grün Channel", - "é", - "Ünit channel", + "\u{c4} channel", + "Gr\u{fc}n Channel", + "\u{e9}", + "\u{dc}nit channel", "a channel channel", "Luminance", + "\u{c9}CRAN", + "\u{e9}cran", ]; for a in items { assert_eq!(popup_key(a), old_key(a), "{a:?}"); @@ -1220,4 +1261,13 @@ mod tests { } } } + + /// A capital outside ASCII has the same key as its lower case; ASCII keys are unchanged. + #[test] + fn popup_key_lowers_non_ascii_capitals() { + assert_eq!(popup_key("\u{c9}CRAN"), popup_key("\u{e9}cran")); + assert_eq!(popup_key(" D\u{e9}sactiv\u{e9} "), "d\u{e9}sactiv\u{e9}"); + assert_eq!(popup_key("Off for 32 bpc Color"), "off for 32 bpc color"); + assert_eq!(popup_key("Red Channel"), "red"); + } } diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index ce7c8e6c5..14aeb1116 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -1,7 +1,7 @@ //! Item ids and labels, footage items (solids and files), precomp linkage, and the name and size //! of a layer's source. -use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text}; +use crate::container::bytes::{be_f32, be_f64, be_i32, be_u32, cstr, le_u32, text, until_nul}; use crate::container::header::Rational; use crate::container::index::Node; use crate::container::typed::{Item, ItemKind, Layer, Project}; @@ -45,6 +45,59 @@ pub struct Footage { /// True when the alias points at a folder (`"target_is_folder":true`): an image sequence is ONE /// item whose alias is its folder and whose `opti` holds the first file name. pub is_sequence: bool, + /// The one layer of a Photoshop file the item shows; `None` for the merged image and for + /// any other footage. + pub psd_layer: Option, +} + +/// One layer of a Photoshop file shown by a footage item, from its `opti` (tag `8BPS`, +/// docs/aep-format.md 3.3): the layer record's index as a big-endian `i32` at byte 14 (-1 for the +/// merged image), the document's sides as little-endian `u32` at 32 and 36, its bounds top, left, +/// bottom, right as little-endian `i32` at 78, 82, 86 and 90, and its name (UTF-8, NUL +/// terminated) from byte 344. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PsdLayer { + /// The layer record's position in the file (bottom of the stack = 0). + pub index: u32, + pub name: String, + /// Top, left, bottom, right in document pixels; all 0 for a layer without pixels (an + /// adjustment layer). + pub bounds: [i32; 4], + /// The document's two sides (bytes 32 and 36; which one is the width is not sampled: the one + /// file read has a square document). 0 when not stored. + pub document: [u32; 2], +} + +/// Most bytes of a Photoshop layer name read. +const MAX_NAME: usize = 1024; + +impl PsdLayer { + /// The bounds' width and height; `None` when they are empty. + pub fn size(&self) -> Option<(u32, u32)> { + let [top, left, bottom, right] = self.bounds; + let w = u32::try_from(i64::from(right) - i64::from(left)).ok().filter(|w| *w > 0)?; + let h = u32::try_from(i64::from(bottom) - i64::from(top)).ok().filter(|h| *h > 0)?; + Some((w, h)) + } + + /// Whether the document is `size` big, its sides in either order. + pub fn document_is(&self, size: (u32, u32)) -> bool { + let [a, b] = self.document; + a > 0 && b > 0 && ((a, b) == size || (b, a) == size) + } + + /// The layer an `opti` names; `None` for another tag, the merged image or a cut record. + fn read(opti: &[u8]) -> Option { + if opti.get(..4) != Some(&b"8BPS"[..]) { + return None; + } + let index = u32::try_from(be_i32(opti, 14)?).ok()?; + let le_i32 = |at| le_u32(opti, at).map(|v| i32::from_le_bytes(v.to_le_bytes())); + // (After Effects writes 602 bytes; a longer hostile name is cut.) + let name = opti.get(344..).map(|b| text(until_nul(b.get(..MAX_NAME).unwrap_or(b)))).unwrap_or_default(); + let document = [le_u32(opti, 32).unwrap_or(0), le_u32(opti, 36).unwrap_or(0)]; + Some(Self { index, name, bounds: [le_i32(78)?, le_i32(82)?, le_i32(86)?, le_i32(90)?], document }) + } } impl Footage { @@ -307,6 +360,7 @@ impl<'f, 'a> Item<'f, 'a> { file_size: be_u32(sspc, 208).unwrap_or(0), audio_sample_rate: be_f64(sspc, 160).filter(|r| r.is_finite() && *r > 0.0), is_sequence, + psd_layer: PsdLayer::read(opti), }) } @@ -342,7 +396,46 @@ impl<'f, 'a> Project<'f, 'a> { #[cfg(test)] mod tests { - use super::json_string; + use super::{PsdLayer, json_string}; + + /// A 602-byte Photoshop `opti` naming layer `index` with `bounds` and `name`. + fn opti(index: i32, bounds: [i32; 4], name: &[u8]) -> Vec { + let mut b = vec![0u8; 602]; + b[..4].copy_from_slice(b"8BPS"); + b[14..18].copy_from_slice(&index.to_be_bytes()); + for (i, v) in bounds.iter().enumerate() { + b[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes()); + } + b[344..344 + name.len()].copy_from_slice(name); + b + } + + #[test] + fn a_photoshop_layer_reads_its_index_bounds_and_name() { + let l = PsdLayer::read(&opti(7, [749, 800, 764, 848], "tést".as_bytes())).unwrap(); + assert_eq!((l.index, l.name.as_str(), l.bounds, l.size()), (7, "tést", [749, 800, 764, 848], Some((48, 15)))); + // The merged image, another tag, a cut record: no layer. + assert_eq!(PsdLayer::read(&opti(-1, [0; 4], b"x")), None); + let mut other = opti(1, [0; 4], b"x"); + other[..4].copy_from_slice(b"Soli"); + assert_eq!(PsdLayer::read(&other), None); + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..80]), None); + // A cut name, and no name at all. + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..345]).unwrap().name, "x"); + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..300]).unwrap().name, ""); + // A layer without pixels has no size; extreme bounds do not overflow. + assert_eq!(PsdLayer::read(&opti(2, [0; 4], b"")).unwrap().size(), None); + assert_eq!(PsdLayer::read(&opti(2, [i32::MIN, i32::MIN, i32::MAX, i32::MAX], b"")).unwrap().size(), Some((u32::MAX, u32::MAX))); + assert_eq!(PsdLayer::read(&opti(2, [i32::MAX, 0, i32::MIN, 1], b"")).unwrap().size(), None); + // The document's size, its sides in either order; none when not stored. + let mut doc = opti(0, [0, 0, 1000, 800], b""); + doc[32..36].copy_from_slice(&800u32.to_le_bytes()); + doc[36..40].copy_from_slice(&1000u32.to_le_bytes()); + let l = PsdLayer::read(&doc).unwrap(); + assert_eq!(l.document, [800, 1000]); + assert!(l.document_is((800, 1000)) && l.document_is((1000, 800)) && !l.document_is((800, 800))); + assert!(!PsdLayer::read(&opti(0, [0; 4], b"")).unwrap().document_is((0, 0))); + } #[test] fn reads_a_string_with_escapes() { diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index e8464bada..338e00fc5 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -707,3 +707,99 @@ pub(crate) static FX_DEFS: &[FxDef] = &[ p(BUILT_IN, 9, &[], None, None, None, 0x00000000), ]), ]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::container::effect_map::by_match_name; + use effectcraft_project::ParamUi; + + /// Effect parameters whose hard range (`ParamUi::Slider` min..max, which `prop.set` and the + /// value editors clamp to) is still narrower than After Effects' recorded valid range, each + /// with the open issue that tracks it: (effect, parameter, issue). Remove a row when its + /// issue is fixed; the test fails on a row that is no longer needed. + #[rustfmt::skip] + const KNOWN_NARROWER: &[(&str, &str, u32)] = &[ + ("ec.blur.gaussian", "blurriness", 3460), + ("ec.blur.fastbox", "radius", 3461), + ("ec.blur.channel", "redBlurriness", 3462), + ("ec.blur.channel", "greenBlurriness", 3462), + ("ec.blur.channel", "blueBlurriness", 3462), + ("ec.blur.channel", "alphaBlurriness", 3462), + ("ec.obsolete.gaussianlegacy", "blurriness", 3463), + ("ec.perspective.dropshadow", "softness", 3464), + ("ec.blur.compound", "maximumBlur", 3465), + ("ec.blur.sharpen", "amount", 3466), + ("ec.blur.radial", "amount", 3467), + ("ec.blur.ccradial", "amount", 3468), + ("ec.distort.turbulentdisplace", "amount", 3469), + ("ec.time.pixelmotionblur", "shutterAngle", 3481), + ("ec.time.pixelmotionblur", "shutterSamples", 3482), + ("ec.noise.fractal", "subSettings/subInfluence", 3485), + ("ec.noise.fractal", "subSettings/subScaling", 3486), + ("ec.distort.ccpowerpin", "expandTop", 3492), + ("ec.distort.ccpowerpin", "expandLeft", 3492), + ("ec.distort.ccpowerpin", "expandRight", 3492), + ("ec.distort.ccpowerpin", "expandBottom", 3492), + ]; + + /// `a` is at most `b`, allowing for the `f32` the recorded bounds were read from. + fn at_most(a: f64, b: f64) -> bool { + a <= b + 1e-6 * a.abs().max(b.abs()).max(1.0) + } + + /// Every effect parameter the .aep table maps to a recorded After Effects definition accepts + /// at least After Effects' valid range, converted to EffectCraft's units the way the importer + /// converts values (`Conv::number_scale`). Only `ParamUi::Slider` limits values (`prop.set`, + /// the Effect Controls and Timeline editors); other controls take any number. A mapping with + /// a declared narrower range (`ParamMap::limit`) must match the effect's range instead. + #[test] + fn effect_hard_ranges_cover_recorded_after_effects_ranges() { + let mut problems = Vec::new(); + let mut narrower = Vec::new(); + for fx in FX_DEFS { + let Some(map) = by_match_name(fx.match_name) else { continue }; + let Some(spec) = effectcraft_effects::find(map.effect) else { + problems.push(format!("{}: no effect {}", fx.match_name, map.effect)); + continue; + }; + for pm in map.params { + let Some(scale) = pm.conv.number_scale() else { continue }; + let at = format!("{} {}", map.effect, pm.target); + let Some(ps) = spec.params.iter().find(|p| p.id == pm.target) else { + problems.push(format!("{at}: not a parameter of the effect")); + continue; + }; + // Other controls take any number. + let ParamUi::Slider { min, max, .. } = &ps.ui else { continue }; + let Some(def) = fx.params.get(pm.index) else { + problems.push(format!("{at}: no recorded definition at parT {}", pm.index)); + continue; + }; + let (Some(lo), Some(hi)) = (def.valid_min, def.valid_max) else { + problems.push(format!("{at}: no recorded valid range")); + continue; + }; + let (lo, hi) = (lo * scale, hi * scale); + if let Some(limit) = pm.limit { + if limit != (*min, *max) { + problems.push(format!("{at}: declared limit {limit:?} is not the effect's range {min}..{max}")); + } + } else if !(at_most(*min, lo) && at_most(hi, *max)) { + narrower.push((map.effect, pm.target, format!("{at}: {min}..{max} excludes After Effects' {lo}..{hi}"))); + } + } + } + for (effect, param, why) in &narrower { + if !KNOWN_NARROWER.iter().any(|(e, p, _)| e == effect && p == param) { + problems.push(why.clone()); + } + } + for (effect, param, issue) in KNOWN_NARROWER { + if !narrower.iter().any(|(e, p, _)| e == effect && p == param) { + problems.push(format!("{effect} {param}: covers After Effects' range now (or is not mapped); remove it from KNOWN_NARROWER (#{issue})")); + } + } + assert!(problems.is_empty(), "{}", problems.join("\n")); + } +} diff --git a/crates/aep/src/export/report.rs b/crates/aep/src/export/report.rs index b6094e791..22145956d 100644 --- a/crates/aep/src/export/report.rs +++ b/crates/aep/src/export/report.rs @@ -281,7 +281,7 @@ mod tests { let json = serde_json::to_string(&collected()).unwrap(); assert_eq!( json, - r#"{"exported":{"folders":0,"compositions":0,"solids":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"properties":0,"keyframes":0,"expressions":0,"masks":0,"precompLayers":0,"effects":0,"footage":0,"footageLayers":0,"textLayers":0,"markers":0,"cameras":0,"lights":0,"shapeLayers":0,"timeRemaps":0,"textAnimators":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"]},{"category":"rounded_times","count":1,"details":["time 1"]}],"limitations":["limit"]}"# + r#"{"exported":{"folders":0,"compositions":0,"solids":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"properties":0,"keyframes":0,"expressions":0,"masks":0,"precompLayers":0,"effects":0,"footage":0,"footageLayers":0,"textLayers":0,"markers":0,"cameras":0,"lights":0,"shapeLayers":0,"timeRemaps":0,"textAnimators":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"],"unlisted":0},{"category":"rounded_times","count":1,"details":["time 1"],"unlisted":0}],"limitations":["limit"]}"# ); let back: ExportReport = serde_json::from_str(&json).unwrap(); assert_eq!(back, collected()); diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index be93920ac..57dbd4f83 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -14,7 +14,8 @@ //! missing-footage placeholders). `docs/aep-import.md` has the full list. //! //! What does not arrive is **reported**, never dropped silently: [`ImportReport`] counts other -//! effects (by match name) and unimported effect parameters, unusable masks, layer style +//! effects (by match name; they arrive as placeholders that render nothing, with their parameters +//! for expressions) and unimported effect parameters, unusable masks, layer style //! features, text layer features, shape items, unsupported blend modes and transform //! properties, simplified keyframes and unreadable data per [`LossCategory`]. By default the import //! is refused when anything would be lost ([`ImportOptions::allow_losses`] lifts that); a refusal @@ -30,6 +31,7 @@ mod effects; mod lumetri; mod markers; mod masks; +mod placeholder; mod props; mod puppet; mod report; diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 1a920950b..92bd26555 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -12,7 +12,8 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::Value; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ - AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, TrackMatte, + AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, SourceLayer, + TrackMatte, }; use effectcraft_time::Tick; @@ -217,7 +218,17 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ let aep_id = item.id(); // An item without a stored name shows its file's name, a sequence `shot_[0101-0103].exr` // (126, 603-607). - let name = if name.is_empty() { default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string()) } else { name }; + // One layer of a Photoshop file is `/`, as EffectCraft's own Photoshop import + // names it (#3087). + let name = if name.is_empty() { + let file = default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string()); + match &f.psd_layer { + Some(l) if !l.name.is_empty() => format!("{}/{file}", l.name), + _ => file, + } + } else { + name + }; if f.is_solid { // A null object's placeholder solid is not an item here. if aep_id.is_some_and(|i| null_only.contains(&i)) { @@ -299,6 +310,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ missing: !present, placed_page, sequence, + layer: source_layer(&f, &name, &mut cx.col), ..Footage::default() }; // The native frame rate and the stored duration of sequences and clips (352, 603, 606). @@ -338,6 +350,34 @@ fn is_placed_page_path(path: &str) -> bool { matches!(ext.as_deref(), Some("pdf" | "ai")) } +/// The Photoshop layer a footage item shows (#3087): at its own bounds when the item is as big +/// as they are (Retain Layer Sizes), else on the document. A layer without pixels (an adjustment +/// layer) keeps the merged image and is reported. So is a layer as big as the document but not at +/// its corner (the item's size fits both framings; its bounds are used), and an item as big as +/// neither (the document is used). +fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option { + let l = f.psd_layer.as_ref()?; + let Some(size) = l.size() else { + col.lose(LossCategory::UnsupportedItems, format!("footage '{name}': a Photoshop layer without pixels (an adjustment layer), the merged image used")); + return None; + }; + let layer_size = size == (f.width, f.height); + if !layer_size && l.document.iter().all(|side| *side > 0) && !l.document_is((f.width, f.height)) { + col.lose( + LossCategory::UnsupportedItems, + format!("footage '{name}': {} x {} is neither the Photoshop layer's size nor the document's, shown on the document", f.width, f.height), + ); + } + let [top, left, ..] = l.bounds; + if layer_size && (top, left) != (0, 0) && l.document_is(size) { + col.lose( + LossCategory::UnsupportedItems, + format!("footage '{name}': a Photoshop layer as big as the document but at ({left}, {top}), shown at its own bounds (Retain Layer Sizes assumed)"), + ); + } + Some(SourceLayer { index: l.index, name: l.name.clone(), layer_size, ..SourceLayer::default() }) +} + /// The name After Effects shows for a footage item that stores none: the file's name, or for a /// sequence the name with the frame range (`shot_[0101-0103].exr`). fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option { diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 992c78cdf..a6e1fc4d8 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -1,7 +1,8 @@ //! Effects: a table maps an After Effects effect (its match name) and its parameters (by their -//! index in `parT`) to an EffectCraft effect, a parameter id and a unit conversion. Effects and -//! parameters outside the table are reported in [`LossCategory::Effects`] by match name, never -//! dropped silently. +//! index in `parT`) to an EffectCraft effect, a parameter id and a unit conversion. Parameters +//! outside the table are reported in [`LossCategory::Effects`] by match name, never dropped +//! silently. An effect outside it arrives as a placeholder holding its parameters (`placeholder.rs`) +//! and is reported too. //! //! Every conversion is backed by the corpus (`docs/aep-format.md` 3.7, cases 356-450, 511-516): //! @@ -34,7 +35,7 @@ //! import. Hue/Saturation's master values and Levels' values are arbitrary data, decoded by the //! reader (444-446, 512-516). -use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key, popup_stem}; +use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_alias, popup_key}; use crate::{ CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue, @@ -82,44 +83,37 @@ pub(crate) fn import_effects<'f, 'a>( continue; } let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); - let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else { - col.lose_with(LossCategory::Effects, || format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); - continue; - }; - let Some(spec) = effectcraft_effects::find(map.effect) else { - col.lose_with(LossCategory::Effects, || format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); - continue; - }; + let found = EFFECTS.iter().find(|m| m.match_name == fx.match_name()).and_then(|m| Some((m, effectcraft_effects::find(m.effect)?))); + let lost = || format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); if defs.source(&fx) == DefinitionSource::Missing { - col.lose_with(LossCategory::Effects, || { - format!("{}: '{}' has the effect '{shown}' ({}) without readable parameter definitions, not imported", cx.comp, cx.layer, fx.match_name()) - }); + match found { + Some(_) => col.lose_with(LossCategory::Effects, || format!("{} without readable parameter definitions, not imported", lost())), + None => col.lose_unmapped_effect(fx.match_name(), lost), + } continue; } - let size = [f64::from(cx.size.0), f64::from(cx.size.1)]; - let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size); - match fx.enabled() { - Some(on) => group.enabled = on, - None => col.lose_with(LossCategory::DamagedData, || { - format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer) - }), + if found.is_none() && col.placeholder_cost >= crate::import::placeholder::MAX_COST { + // The import's placeholder budget is spent: the definitions (shared by many instances) + // are not read again. Named like any effect without a counterpart, in a group of its + // own (with the kept instances it would read as kept). + let key = format!("{} (not kept)", fx.match_name()); + col.lose_unmapped_effect(&key, || format!("{}, not kept (past the import's placeholder parameters)", lost())); + continue; } let params = defs.params(&fx); - // Borrowed definitions (another instance's or the project copy) may not name every value - // record this instance has: such a record has nothing to import into, so it is reported. - if params.first().is_some_and(|p| p.borrowed_definition) { - let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect(); - for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) { - col.lose_with(LossCategory::Effects, || { - format!( - "{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported", - cx.comp, - cx.layer, - fx.match_name() - ) - }); - } - } + report_records_without_definition(&fx, ¶ms, cx, &shown, col); + let Some((map, spec)) = found else { + // No counterpart: kept as a placeholder that renders nothing, for expressions (#3087). + let mut group = crate::import::placeholder::placeholder(&fx, ¶ms, cx, &shown, ids, refs, col); + set_enabled(&fx, &mut group, cx, &shown, col); + col.lose_unmapped_effect(fx.match_name(), || format!("{}, kept without rendering", lost())); + col.imported.placeholder_effects = col.imported.placeholder_effects.saturating_add(1); + target.children.push(Node::Group(group)); + continue; + }; + let size = [f64::from(cx.size.0), f64::from(cx.size.1)]; + let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size); + set_enabled(&fx, &mut group, cx, &shown, col); if map.extra == Extra::Lumetri { crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); } else if map.extra == Extra::Cryptomatte { @@ -134,6 +128,30 @@ pub(crate) fn import_effects<'f, 'a>( } } +/// The effect's fx switch. +fn set_enabled(fx: &AepEffect<'_, '_>, group: &mut PropGroup, cx: &LayerCtx<'_>, shown: &str, col: &mut Collector) { + match fx.enabled() { + Some(on) => group.enabled = on, + None => { + col.lose_with(LossCategory::DamagedData, || format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)) + } + } +} + +/// Borrowed definitions (another instance's or the project copy) may not name every value record +/// this instance has: such a record has nothing to import into, so it is reported. +fn report_records_without_definition(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, col: &mut Collector) { + if !params.first().is_some_and(|p| p.borrowed_definition) { + return; + } + let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect(); + for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) { + col.lose_with(LossCategory::Effects, || { + format!("{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported", cx.comp, cx.layer, fx.match_name()) + }); + } +} + /// Parameters that never carry a value to import. pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer) @@ -241,23 +259,12 @@ pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx Conv::Checkbox => Kind::EffectBool, // Handled above. Conv::LayerRef { .. } | Conv::Skip => return, - Conv::Percent { valid_max } => { + Conv::Percent { valid_max } | Conv::Plain { valid_max } | Conv::Factor { valid_max, .. } => { if !checked(valid_max, col) { return; } - Kind::EffectNumber(100.0 / valid_max) - } - Conv::Plain { valid_max } => { - if !checked(valid_max, col) { - return; - } - Kind::EffectNumber(1.0) - } - Conv::Factor { valid_max, factor } => { - if !checked(valid_max, col) { - return; - } - Kind::EffectNumber(factor) + let Some(scale) = m.conv.number_scale() else { return }; + Kind::EffectNumber(scale) } }; // Grouped parameters are addressed by path (`master/exposure`). @@ -365,7 +372,7 @@ fn report_beyond_limit<'v>(limit: Option<(f64, f64)>, values: impl Iterator, target: &str, own: bool, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) { +pub(crate) fn import_layer_ref(p: &EffectParam<'_, '_>, target: &str, own: bool, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) { let (here, label) = names; let ParamOut { group, refs, col } = out; // After Effects hands the named layer's source to the effect, without its masks and effects @@ -416,14 +423,13 @@ pub(crate) fn resolve_layer_refs( } } -/// The EffectCraft item of each file item (by name, ignoring case and separators); `None` when -/// the parameter here is not a popup. -/// -/// The file's item count is not bounded (its `pdnm`), EffectCraft's options are: each side's key -/// is taken once and compared without a copy, so the cost is linear in the file's items. +/// The EffectCraft item of each file item (by name, ignoring case and separators, and a known +/// non-English name for it, [`popup_alias`]); `None` when the parameter here is not a popup. fn popup_mapping(file_items: &[String], ui: &ParamUi) -> Option>> { let ParamUi::Popup { options } = ui else { return None }; - let options: Vec<&str> = options.iter().map(|o| popup_stem(o)).collect(); + // The file's item count is not bounded (its `pdnm`), EffectCraft's options are: the options' + // keys are built once, and each file item's once, so the cost is linear in the file's items. + let options: Vec = options.iter().map(|o| popup_key(o)).collect(); Some( file_items .iter() @@ -431,8 +437,12 @@ fn popup_mapping(file_items: &[String], ui: &ParamUi) -> Option> if item.trim() == "(-" { return None; } - let item = popup_stem(item); - options.iter().position(|o| o.eq_ignore_ascii_case(item)).and_then(|i| u32::try_from(i).ok()) + let key = popup_key(item); + options + .iter() + .position(|o| *o == key) + .or_else(|| popup_alias(&key).and_then(|a| options.iter().position(|o| o == a))) + .and_then(|i| u32::try_from(i).ok()) }) .collect(), ) @@ -807,7 +817,7 @@ fn px(v: f64) -> String { /// A decoded value in EffectCraft's units, `None` when it does not fit the kind or is not a valid /// number. -fn convert(value: ParamValue, kind: Kind) -> Option { +pub(crate) fn convert(value: ParamValue, kind: Kind) -> Option { let raw = match (value, kind) { // The reader gives colours as ARGB fractions; `Kind` takes the stored 0..255. (ParamValue::Argb(c), Kind::EffectColor) => c.map(|x| x * 255.0).to_vec(), diff --git a/crates/aep/src/import/placeholder.rs b/crates/aep/src/import/placeholder.rs new file mode 100644 index 000000000..2fc40b71b --- /dev/null +++ b/crates/aep/src/import/placeholder.rs @@ -0,0 +1,218 @@ +//! Placeholders: an effect with no EffectCraft counterpart (a third-party plug-in, a pseudo effect +//! of expression controls) arrives as an effect group that renders nothing, as a missing plug-in +//! does. Its effect id is the After Effects match name, so Find Missing Effects lists it. It holds +//! the effect's parameters, flat and named as in After Effects, each with its value, keys and +//! expression, so expressions that read them (`effect("Settings")("Speed")`) keep working. It is +//! still reported, once per match name. +//! +//! The values keep After Effects' units, the ones its expressions read: sliders and angles as +//! stored, points in layer pixels, colours RGBA 0..1, checkboxes on / off, popups as EffectCraft's +//! 0-based items over the file's items in order (expressions read them 1-based, as After Effects +//! does). Layer parameters name their layer (their keys and expression are reported). Paths and +//! arbitrary data are not kept (reported when they hold something). +//! +//! The parameters placeholders walk, kept or not, and their popup items (by name length) are +//! bounded per import ([`MAX_COST`]): definitions can be shared by many instances, so a small file +//! could otherwise ask for very many properties. Once +//! the budget is spent, later effects without a counterpart are reported without a placeholder. + +use effectcraft_keyframe::Value; +use effectcraft_project::build::Ids; +use effectcraft_project::{GroupKind, Node, ParamUi, PropGroup, Property}; + +use crate::import::effects::{LayerCtx, ParamOut, PendingLayerRef, carries_nothing, convert, import_layer_ref, param_label}; +use crate::import::props::{Kind, import_expression, import_keys}; +use crate::import::report::{Collector, LossCategory}; +use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue}; + +/// Most placeholder parameters (kept or not) and popup items one import walks. +pub(crate) const MAX_COST: usize = 200_000; + +/// Most items read from one placeholder popup. +const MAX_POPUP_ITEMS: usize = 1024; + +/// A popup item costs one more for each of these bytes in its name, so the names kept stay +/// bounded too. +const NAME_BYTES: usize = 16; + +/// The placeholder of `fx`, with `params` (its parameter definitions) as properties. +pub(crate) fn placeholder( + fx: &AepEffect<'_, '_>, + params: &[EffectParam<'_, '_>], + cx: &LayerCtx<'_>, + shown: &str, + ids: &mut Ids<'_>, + refs: &mut Vec, + col: &mut Collector, +) -> PropGroup { + // The effect id is the match name; the group's match id is one segment of a property path + // (a pseudo effect's `Pseudo/0.25` would be cut apart), so match paths find its parameters. + let mut group = ids.group(&segment(fx.match_name()), shown); + group.kind = GroupKind::Effect { effect: fx.match_name().to_string() }; + let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); + for p in params { + // Every parameter walked counts, kept or not: a definition shared by many instances is + // otherwise walked again for each one. + if carries_nothing(p) { + col.placeholder_cost = col.placeholder_cost.saturating_add(1).min(MAX_COST); + continue; + } + if col.placeholder_cost >= MAX_COST { + col.lose(LossCategory::Effects, format!("{here}: parameters past the import's {MAX_COST} placeholder parameters not kept")); + break; + } + let label = param_label(p); + let Some((kind, ui, default)) = shape(p, cx.size) else { + col.placeholder_cost = col.placeholder_cost.saturating_add(1); + if p.stored().is_some() { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not kept (type {:?})", p.kind)); + } + continue; + }; + let (items, names) = match &ui { + ParamUi::Popup { options } => (options.len(), options.iter().map(|o| 1 + o.len() / NAME_BYTES).fold(0, usize::saturating_add)), + _ => (0, 0), + }; + if p.kind == ParamKind::Popup && popup_len(p).is_some_and(|n| n > items) { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the items past the first {MAX_POPUP_ITEMS} not kept")); + } + let cost = col.placeholder_cost.saturating_add(names).saturating_add(1); + if cost > MAX_COST { + // The budget is spent: nothing more is built, here or in later effects (definitions + // shared by many instances would otherwise still be walked item by item). + col.lose( + LossCategory::Effects, + format!("{here}: parameter {label} and the ones after it not kept (past the import's {MAX_COST} placeholder parameters)"), + ); + col.placeholder_cost = MAX_COST; + break; + } + col.placeholder_cost = cost; + let mut prop = Property::new(ids.alloc(), ¶m_id(p), p.name.trim(), default).with_ui(ui); + prop.spatial = p.kind == ParamKind::Point; + prop.hold_only = matches!(p.kind, ParamKind::Checkbox | ParamKind::Popup | ParamKind::Layer); + match kind { + Some(kind) => { + fill(p, &mut prop, kind, cx, (&here, &label), col); + group.children.push(Node::Prop(prop)); + } + // A layer parameter: the layer it names (set once every layer exists, by its uid). + None => { + let id = format!("@{}", prop.uid); + group.children.push(Node::Prop(prop)); + import_layer_ref(p, &id, true, cx, (&here, &label), ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + } + } + } + group +} + +/// How parameter `p` is held on a layer `size` big: its unit conversion (`None` for a layer +/// parameter), its control and its value until one is read. `None` for a type that is not kept. +fn shape(p: &EffectParam<'_, '_>, size: (u32, u32)) -> Option<(Option, ParamUi, Value)> { + let (kind, ui, default) = match p.kind { + ParamKind::IntSlider | ParamKind::FixedSlider | ParamKind::FloatSlider => (Kind::EffectNumber(1.0), slider_ui(p), Value::Scalar(0.0)), + ParamKind::Angle => (Kind::EffectNumber(1.0), ParamUi::Angle, Value::Scalar(0.0)), + ParamKind::Checkbox => (Kind::EffectBool, ParamUi::Checkbox, Value::Bool(false)), + ParamKind::Color => (Kind::EffectColor, ParamUi::Color, Value::Color([0.0, 0.0, 0.0, 1.0])), + ParamKind::Point => (Kind::EffectPoint(f64::from(size.0), f64::from(size.1)), ParamUi::Point, Value::Vec2([0.0, 0.0])), + ParamKind::Layer => return Some((None, ParamUi::Layer, Value::Layer(None))), + ParamKind::Popup => { + // The file's items, separators (`(-`) included: After Effects counts them. + let options: Vec = match &p.items { + Some(items) => items.iter().take(MAX_POPUP_ITEMS).cloned().collect(), + None => (1..=popup_len(p)?.min(MAX_POPUP_ITEMS)).map(|i| format!("Item {i}")).collect(), + }; + let n = u32::try_from(options.len()).ok().filter(|n| *n > 0)?; + (Kind::EffectPopup(n), ParamUi::Popup { options }, Value::Enum(0)) + } + _ => return None, + }; + Some((Some(kind), ui, default)) +} + +/// A slider's control: a float slider's valid range and drag range when they are ranges (only a +/// float slider's are decoded), else a plain number. +fn slider_ui(p: &EffectParam<'_, '_>) -> ParamUi { + match (p.valid_min(), p.valid_max(), p.slider_range()) { + (Some(min), Some(max), Some((smin, smax))) if [min, max, smin, smax].iter().all(|v| v.is_finite()) && min <= max && smin <= smax => { + ParamUi::Slider { min, max, slider_min: smin.clamp(min, max), slider_max: smax.clamp(min, max), decimals: 2 } + } + _ => ParamUi::Number, + } +} + +/// How many items a popup has: the file's names, else the count in its definition. +fn popup_len(p: &EffectParam<'_, '_>) -> Option { + p.items.as_ref().map(Vec::len).or_else(|| p.popup_item_count().map(usize::from)) +} + +/// The property id of `p`: its match name when that is one segment of a property path (a +/// third-party effect's `CC Cylinder-0003`), else `param` (a pseudo effect's +/// `Pseudo/0.25-0001`: `/` and `.` separate path segments). Expressions also find it by its name. +fn param_id(p: &EffectParam<'_, '_>) -> String { + if !p.match_name.is_empty() && p.match_name.chars().all(plain) { p.match_name.clone() } else { format!("param{}", p.index) } +} + +/// Whether `c` can stand in one segment of a property path (`/`, `.`, `#` and `@` mean more). +fn plain(c: char) -> bool { + c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') +} + +/// `name` as one segment of a property path: other characters become `_` (`Pseudo/0.25` is +/// `Pseudo_0_25`). +fn segment(name: &str) -> String { + name.chars().map(|c| if plain(c) { c } else { '_' }).collect() +} + +/// Sets `prop` from `p`: its expression, then its keys or its value (the record, else the `pard` +/// default). `names` is `(here, label)` for the report. +fn fill(p: &EffectParam<'_, '_>, prop: &mut Property, kind: Kind, cx: &LayerCtx<'_>, names: (&str, &str), col: &mut Collector) { + let (here, label) = names; + if let Some(stored) = p.stored() { + import_expression(&stored, prop, col); + } + if p.is_animated() { + let Some(stored) = p.stored() else { return }; + if kind == Kind::EffectBool { + // Keys on a checkbox are not sampled by the corpus. + col.lose(LossCategory::Effects, format!("{here}: the keyframes of parameter {label} are not kept (not decoded)")); + return; + } + let before = prop.keys.len(); + import_keys(&stored, kind, cx.layer, &p.match_name, cx.tm, prop, col); + if prop.keys.len() == before { + col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe")); + } + return; + } + let value = match (p.stored(), p.recorded_value()) { + (Some(_), Some(v)) => v, + (Some(_), None) => { + col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value")); + return; + } + // A borrowed popup's default slot holds another copy's selection (docs/aep-format.md 3.7). + (None, _) if p.kind == ParamKind::Popup && p.borrowed_definition => { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the default item is not known (shared definition), the first item used")); + return; + } + // A popup's default slot can hold 0, no item (After Effects' save of a project it opened). + (None, _) => match p.pard_default() { + Some(ParamValue::Index(0)) => { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the default item is not stored, the first item used")); + return; + } + Some(v) => v, + None => return, + }, + }; + match (convert(value, kind), value) { + (Some(v), _) => prop.value = v, + // An item of the file that is past the ones kept (reported with the cut): the first item. + (None, ParamValue::Index(i)) if usize::try_from(i).is_ok_and(|i| i > MAX_POPUP_ITEMS && popup_len(p).is_some_and(|n| i <= n)) => { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: item {i} is past the items kept, the first item used")); + } + (None, _) => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?})")), + } +} diff --git a/crates/aep/src/import/props.rs b/crates/aep/src/import/props.rs index 747f71de1..27dd2e933 100644 --- a/crates/aep/src/import/props.rs +++ b/crates/aep/src/import/props.rs @@ -231,15 +231,21 @@ pub(crate) fn import_transform( } import_expression(&node, prop, col); } - if let Some(PropNode::Other(_)) = group.get("ADBE Orientation") { + if let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") { match (layer.orientation(), target.get_mut("orientation")) { - (Some(o), Some(prop)) if !o.animated && !o.has_expression && sane(&Value::Vec3(o.degrees)) => prop.value = Value::Vec3(o.degrees), + (Some(o), Some(prop)) if o.animated || sane(&Value::Vec3(o.degrees)) => { + if o.animated { + import_orientation_keys(layer, layer_name, tm, prop, col); + } else { + prop.value = Value::Vec3(o.degrees); + } + // The expression is stored in the `otst`'s `tdbs` as on any property (#3087). + if let Some(node) = otst.child_list(b"tdbs").and_then(AepProp::from_chunk) { + import_expression(&node, prop, col); + } + } // A layer without an orientation (an ambient light) loses nothing with the default. (Some(o), None) if !o.animated && !o.has_expression && o.degrees == [0.0; 3] => {} - (Some(o), Some(prop)) if o.animated && !o.has_expression => import_orientation_keys(layer, layer_name, tm, prop, col), - (Some(o), Some(_)) if o.has_expression => { - col.lose_with(LossCategory::TransformProperties, || format!("{layer_name}: ADBE Orientation expression (not decoded)")); - } _ => col.lose_with(LossCategory::TransformProperties, || format!("{layer_name}: ADBE Orientation")), } } diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index bbf6b2724..94c0984d6 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -57,7 +57,8 @@ pub enum LossCategory { /// Layers that could not be imported for another reason (unknown layer type, missing or /// unsupported source, precomposition loop). UnsupportedLayers, - /// Project items of a kind that is not imported. + /// Project items of a kind that is not imported, and items made from a Photoshop layer + /// without pixels (they show the merged image). UnsupportedItems, /// Data that could not be read (damaged headers, unreadable keyframes). DamagedData, @@ -100,8 +101,12 @@ pub struct Loss { pub category: C, /// How many occurrences (exact). pub count: usize, - /// Up to [`MAX_DETAILS`] example lines. + /// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per + /// effect (with its number of instances). pub details: Vec, + /// How many of the `count` occurrences no line of `details` describes. + #[serde(default)] + pub unlisted: usize, } /// The loss bookkeeping the import and the export report share. @@ -109,7 +114,7 @@ impl Loss { /// Counts one loss of `category`; `detail` is only called while the category holds fewer than /// [`MAX_DETAILS`] lines. pub(crate) fn record(losses: &mut BTreeMap, category: C, detail: impl FnOnce() -> String) { - let loss = losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); + let loss = losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 }); loss.count = loss.count.saturating_add(1); if loss.details.len() < MAX_DETAILS { loss.details.push(detail()); @@ -186,6 +191,10 @@ pub struct Imported { /// in [`LossCategory::Effects`]). #[serde(default)] pub effects: usize, + /// Effects with no counterpart, kept as placeholders that render nothing (their parameters, + /// for expressions; each is also in [`LossCategory::Effects`]). + #[serde(default)] + pub placeholder_effects: usize, /// Layer styles (Drop Shadow, Stroke, ...), one per style on a layer. #[serde(default)] pub layer_styles: usize, @@ -304,8 +313,18 @@ pub(crate) struct Collector { losses: BTreeMap, pub missing_footage: Vec, pub missing_fonts: Vec, + /// The effects with no counterpart, by match name: the first instance's report line and how + /// many instances there were. They get a line each in [`Collector::finish`], so one that + /// comes after the example lines are used up is still named (#3365). + unmapped_effects: BTreeMap, + /// The properties and popup items the placeholders of this import hold so far (at most + /// `placeholder::MAX_COST`). + pub placeholder_cost: usize, } +/// How many different unmapped effects get a line of their own (a file names at most this many). +const MAX_UNMAPPED_EFFECTS: usize = 100; + impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { @@ -318,7 +337,40 @@ impl Collector { Loss::record(&mut self.losses, category, detail); } - pub fn finish(self) -> ImportReport { + /// Records one instance of an effect with no counterpart. The loss is counted now; its line is + /// made in [`Collector::finish`], one per effect with the number of instances, so that many + /// other losses cannot hide which effects were dropped. + /// `detail` is only called for an effect not seen before. + pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: impl FnOnce() -> String) { + let loss = + self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new(), unlisted: 0 }); + loss.count = loss.count.saturating_add(1); + if let Some(seen) = self.unmapped_effects.get_mut(match_name) { + seen.1 = seen.1.saturating_add(1); + } else if self.unmapped_effects.len() < MAX_UNMAPPED_EFFECTS { + self.unmapped_effects.insert(match_name.to_string(), (detail(), 1)); + } + // (Past the limit an effect is only counted: its name is not worth a line of its own.) + } + + pub fn finish(mut self) -> ImportReport { + // Every line describes one occurrence, except an unmapped effect's, which describes all + // its instances. + for loss in self.losses.values_mut() { + loss.unlisted = loss.count.saturating_sub(loss.details.len()); + } + if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) { + // The most used effects first. + let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect(); + named.sort_by_key(|n| std::cmp::Reverse(n.1)); + let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n)); + loss.unlisted = loss.unlisted.saturating_sub(described); + let lines = named.into_iter().map(|(first, n)| match n { + 1 => first, + _ => format!("{first}, and {} more like it", n - 1), + }); + loss.details.splice(0..0, lines); + } ImportReport { imported: self.imported, losses: self.losses.into_values().collect(), @@ -362,7 +414,7 @@ mod tests { let json = serde_json::to_string(&collected()).unwrap(); assert_eq!( json, - r#"{"imported":{"folders":0,"compositions":0,"solids":0,"footage":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"footageLayers":0,"precompLayers":0,"textLayers":0,"keyframes":0,"expressions":0,"masks":0,"trackMattes":0,"markers":0,"timeRemaps":0,"shapeLayers":0,"shapeItems":0,"cameras":0,"lights":0,"textAnimators":0,"effects":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"]},{"category":"masks","count":1,"details":["mask 1"]}],"missingFootage":["a.png"],"missingFonts":[],"limitations":["Frame blending, 3D material options, audio levels, guides are not decoded and not imported."],"aeVersion":{"fileVersion":"unknown","savedBy":"unknown","tested":false}}"# + r#"{"imported":{"folders":0,"compositions":0,"solids":0,"footage":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"footageLayers":0,"precompLayers":0,"textLayers":0,"keyframes":0,"expressions":0,"masks":0,"trackMattes":0,"markers":0,"timeRemaps":0,"shapeLayers":0,"shapeItems":0,"cameras":0,"lights":0,"textAnimators":0,"effects":0,"placeholderEffects":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"],"unlisted":0},{"category":"masks","count":1,"details":["mask 1"],"unlisted":0}],"missingFootage":["a.png"],"missingFonts":[],"limitations":["Frame blending, 3D material options, audio levels, guides are not decoded and not imported."],"aeVersion":{"fileVersion":"unknown","savedBy":"unknown","tested":false}}"# ); let back: ImportReport = serde_json::from_str(&json).unwrap(); assert_eq!(back, collected()); @@ -396,4 +448,61 @@ mod tests { assert_eq!(report.losses[0].details.last().map(String::as_str), Some("mask 19")); assert_eq!(built, MAX_DETAILS); } + + /// #3365: with other losses filling the 20 example lines, an effect with no counterpart still + /// gets its own line, with how many instances were lost. The category's count is unchanged. + #[test] + fn every_unmapped_effect_is_named_however_many_other_losses_there_are() { + let mut col = Collector::default(); + for i in 0..30 { + col.lose(LossCategory::Effects, format!("C: 'L{i}': parameter P not imported")); + } + for i in 0..25 { + col.lose_unmapped_effect("CC Cylinder", || format!("C: 'Cyl{i}' has the effect 'CC Cylinder' (CC Cylinder)")); + } + col.lose_unmapped_effect("Pseudo/", || "C: 'D' has the effect 'Dropdown' (Pseudo/)".to_string()); + let report = col.finish(); + let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); + assert_eq!(effects.count, 56, "30 parameters, 25 cylinders and 1 dropdown"); + let named = |what: &str| effects.details.iter().filter(|d| d.contains(what)).collect::>(); + assert_eq!(named("(CC Cylinder)").len(), 1, "{:?}", effects.details); + assert!(named("(CC Cylinder)")[0].contains("24 more"), "{:?}", named("(CC Cylinder)")); + // One instance stays the single line it was. + assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]); + assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded"); + // 20 parameter lines, the cylinders' line and the dropdown's describe 46; 10 parameters + // have no line. + assert_eq!(effects.unlisted, 10); + // The most used effect comes first. + assert!(effects.details[0].contains("(CC Cylinder)"), "{:?}", effects.details); + } + + /// #3365: each category says how many of its occurrences have no example line. + #[test] + fn a_loss_counts_the_occurrences_without_a_line() { + let mut col = Collector::default(); + for i in 0..27 { + col.lose(LossCategory::Masks, format!("mask {i}")); + } + col.lose(LossCategory::ShapeLayers, "one"); + let report = col.finish(); + let unlisted = |c| report.losses.iter().find(|l| l.category == c).map(|l| (l.details.len(), l.unlisted)); + assert_eq!(unlisted(LossCategory::Masks), Some((MAX_DETAILS, 7))); + assert_eq!(unlisted(LossCategory::ShapeLayers), Some((1, 0))); + } + + /// A file with more than [`MAX_UNMAPPED_EFFECTS`] different unmapped effects names that many + /// and counts the rest, whatever room the example lines have. + #[test] + fn at_most_the_limit_of_unmapped_effects_are_named() { + let mut col = Collector::default(); + for i in 0..=MAX_UNMAPPED_EFFECTS { + col.lose_unmapped_effect(&format!("Effect {i}"), || format!("C: 'L' has the effect 'Effect {i}' (Effect {i})")); + } + let report = col.finish(); + let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); + assert_eq!(effects.count, MAX_UNMAPPED_EFFECTS + 1, "every instance is counted"); + assert_eq!(effects.details.len(), MAX_UNMAPPED_EFFECTS, "{} lines", effects.details.len()); + assert_eq!(effects.unlisted, 1, "the effect past the limit has no line"); + } } diff --git a/crates/aep/src/import/shapes.rs b/crates/aep/src/import/shapes.rs index aa06201ac..9d0a59120 100644 --- a/crates/aep/src/import/shapes.rs +++ b/crates/aep/src/import/shapes.rs @@ -457,18 +457,22 @@ fn shift_copy_offset(p: &mut effectcraft_project::Property, cx: &mut Cx<'_, '_>) } } -/// Reports a property whose only sampled value is the default 1. +/// Reports a property whose only sampled value is the default 1. A record in the default state +/// holds After Effects' default (docs/aep-format.md 3.4, state), so it holds nothing to lose. fn not_default(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 1.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); } } -/// Reports a property whose only sampled value is 0. +/// Reports a property whose only sampled value is 0 (a record in the default state, as above, +/// holds nothing to lose). fn not_zero(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 0.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); @@ -490,3 +494,41 @@ fn unknown(g: &AepGroup<'_, '_>, known: &[&str], what: &str, cx: &mut Cx<'_, '_> } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chunk; + + /// A group holding `match_name` as a one-component property in state `tdsb` with value `v`. + fn group_with(match_name: &str, tdsb: u32, v: f64) -> Chunk<'static> { + let mut name = match_name.as_bytes().to_vec(); + name.resize(40, 0); + let mut tdb4 = vec![0u8; 124]; + tdb4[3] = 1; + let tdbs = Chunk::list( + *b"tdbs", + vec![Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec()), Chunk::data(*b"tdb4", tdb4), Chunk::data(*b"cdat", v.to_be_bytes().to_vec())], + ); + Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdmn", name), tdbs]) + } + + fn reported(match_name: &str, tdsb: u32, v: f64, check: fn(&AepGroup<'_, '_>, &str, &str, &mut Cx<'_, '_>)) -> usize { + let chunk = group_with(match_name, tdsb, v); + let g = AepGroup::from_chunk(&chunk).unwrap(); + let tm = TimeMap { timescale: 30, frame_duration: 1, rate: crate::import::time::frame_rate(30, 1).unwrap() }; + let (mut next, mut col) = (1, Collector::default()); + check(&g, match_name, "it", &mut Cx { what: "L", tm: &tm, ids: &mut Ids(&mut next), col: &mut col }); + col.finish().losses.iter().map(|l| l.count).sum() + } + + /// #3087: a guide rectangle's Shape Direction recorded in the default state (`tdsb` 3, holding + /// 2) is After Effects' default and is no loss; written (`tdsb` 1) with 2, it is one. + #[test] + fn a_default_state_record_is_no_loss() { + assert_eq!(reported("ADBE Vector Shape Direction", 3, 2.0, not_default), 0); + assert_eq!(reported("ADBE Vector Shape Direction", 1, 2.0, not_default), 1); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 3, 5.0, not_zero), 0); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 1, 5.0, not_zero), 1); + } +} diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index 4bcdbab9e..cb4b9ff60 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -20,8 +20,8 @@ pub use container::{ GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, - Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, - TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, - curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, - write, write_to, + Property, PsdLayer, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, + StyleRun, TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, + WorkingSpace, curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, + read_aepx_with_limits, style_map, write, write_to, }; diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index aa4695029..d079cdfac 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -23,6 +23,7 @@ mod lumetri; mod markers; mod masks; mod mattes; +mod placeholders; mod puppet; mod resaved_defaults; mod robustness; diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index ae61794d3..535e82483 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -10,7 +10,7 @@ use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import}; use effectcraft_aep::{Body, Chunk, ParamKind, ParamValue, Project as AepProject, parse, write}; use effectcraft_keyframe::Value; -use effectcraft_project::{Project, PropGroup}; +use effectcraft_project::{GroupKind, Project, PropGroup}; const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); @@ -274,10 +274,15 @@ pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option, ImportRes let mut project = Project::default(); let none = |_: &str| false; let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); - let g = project - .comps() - .flat_map(|(_, c)| c.layers.iter()) - .find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == effect).cloned())); + let g = project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| { + l.props.sub("effects").and_then(|fx| { + fx.children + .iter() + .filter_map(|n| n.as_group()) + .find(|g| g.match_id == effect || matches!(&g.kind, GroupKind::Effect { effect: e } if e == effect)) + .cloned() + }) + }); (g, r) } diff --git a/crates/aep/tests/it/import/placeholders.rs b/crates/aep/tests/it/import/placeholders.rs new file mode 100644 index 000000000..1a8ec697e --- /dev/null +++ b/crates/aep/tests/it/import/placeholders.rs @@ -0,0 +1,315 @@ +//! #3087: an effect with no EffectCraft counterpart (a third-party plug-in, a pseudo effect of +//! expression controls) arrives as a placeholder holding its parameters, so expressions that read +//! them keep working; it renders nothing and is still reported. Each effect is built into +//! `synthetic.aep` (`fx_inject`) in place of its Glow, on the 8 x 8 sequence layer. + +use crate::import::fx_inject::{Default, Fx, bytes_with, import_bytes, losses, other, popup, run, slider, v}; +use effectcraft_aep::import::LossCategory; +use effectcraft_aep::{Body, Chunk, ParamKind as K, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::{GroupKind, ParamUi, PropGroup}; + +/// A third-party effect with one parameter of each kept kind, inside a twirl-down group. +fn third_party(values: Vec<(usize, f64)>, keys: Vec<(usize, Vec<(i32, f64)>)>) -> Fx { + Fx { + match_name: "CC Cylinder", + name: "CC Cylinder", + defs: vec![ + other(K::GroupStart, "Light", Default::None), + slider(K::FloatSlider, "Radius (%)", 30.0, (0.0, 30000.0)), + other(K::GroupEnd, "", Default::None), + other(K::Angle, "Rotation X", Default::Number(12.5)), + other(K::Checkbox, "Wire", Default::Number(1.0)), + other(K::Color, "Light Color", Default::Color([255, 51, 102, 204])), + other(K::Point, "Position", Default::Point(0.25, 0.5)), + popup("Render", 1, &["Full", "(-", "Outside", "Inside"]), + slider(K::IntSlider, "Count", 3.0, (0.0, 50.0)), + ], + values, + data: vec![], + keys, + } +} + +fn placeholder_of(fx: &Fx) -> (PropGroup, effectcraft_aep::import::ImportResult) { + let (g, r) = run(fx, fx.match_name); + (g.expect("the placeholder"), r) +} + +#[test] +fn an_effect_without_a_counterpart_is_kept_with_its_values() { + // Radius 37.5 and Render "Outside" (3: the separator counts) stored, the rest at defaults. + let (g, r) = placeholder_of(&third_party(vec![(2, 37.5), (8, 3.0)], vec![])); + assert_eq!(g.kind, GroupKind::Effect { effect: "CC Cylinder".into() }); + assert_eq!(g.name, "CC Cylinder"); + let names: Vec<(&str, &str)> = g.props().map(|p| (p.match_id.as_str(), p.name.as_str())).collect(); + assert_eq!( + names, + [ + ("CC Cylinder-0002", "Radius (%)"), + ("CC Cylinder-0004", "Rotation X"), + ("CC Cylinder-0005", "Wire"), + ("CC Cylinder-0006", "Light Color"), + ("CC Cylinder-0007", "Position"), + ("CC Cylinder-0008", "Render"), + ("CC Cylinder-0009", "Count"), + ], + "flat, in order, without the group markers" + ); + assert_eq!(v(&g, "CC Cylinder-0002"), Value::Scalar(37.5)); + assert_eq!(v(&g, "CC Cylinder-0004"), Value::Scalar(12.5)); + assert_eq!(v(&g, "CC Cylinder-0005"), Value::Bool(true)); + let Value::Color(c) = v(&g, "CC Cylinder-0006") else { panic!("a colour") }; + assert!(c.iter().zip([0.2, 0.4, 0.8, 1.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{c:?}"); + assert_eq!(v(&g, "CC Cylinder-0007"), Value::Vec2([2.0, 4.0]), "layer pixels"); + assert_eq!(v(&g, "CC Cylinder-0008"), Value::Enum(2), "item 3 of 4, 0-based"); + assert_eq!(v(&g, "CC Cylinder-0009"), Value::Scalar(3.0)); + let render = g.prop("CC Cylinder-0008").unwrap(); + assert_eq!(render.ui, ParamUi::Popup { options: ["Full", "(-", "Outside", "Inside"].map(String::from).to_vec() }); + // Still reported, once, and counted apart from the imported effects. + let found = losses(&r, LossCategory::Effects, "CC Cylinder"); + assert!(found.len() == 1 && found[0].ends_with("has the effect 'CC Cylinder' (CC Cylinder), kept without rendering"), "{found:?}"); + assert_eq!((r.report.imported.placeholder_effects, r.report.imported.effects), (1, 1), "the placeholder and the Levels"); +} + +#[test] +fn keys_and_expressions_of_a_placeholder_are_kept() { + let fx = third_party(vec![(4, 12.5)], vec![(2, vec![(0, 10.0), (25, 20.0)])]); + let bytes = with_expression(&bytes_with(&fx), "CC Cylinder-0004", "time * 90"); + let (g, _) = import_bytes(&bytes, "CC Cylinder"); + let g = g.unwrap(); + let radius = g.prop("CC Cylinder-0002").unwrap(); + assert_eq!(radius.keys.iter().map(|k| k.value.clone()).collect::>(), [Value::Scalar(10.0), Value::Scalar(20.0)]); + let angle = g.prop("CC Cylinder-0004").unwrap(); + assert_eq!(angle.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("time * 90", true))); +} + +/// A pseudo effect's parameter match names (`Pseudo/0.25-0001`) are no property path segment: +/// its parameters are `param`, found by their names. +#[test] +fn a_pseudo_effect_is_kept_with_its_parameter_names() { + let fx = Fx { + match_name: "Pseudo/0.25", + name: "Settings", + defs: vec![slider(K::FloatSlider, "Speed", 1.0, (0.0, 100.0)), popup("Mode", 2, &["A", "B"])], + values: vec![(1, 4.0)], + data: vec![], + keys: vec![], + }; + let (g, r) = placeholder_of(&fx); + assert_eq!(g.prop("param1").map(|p| (p.name.as_str(), p.value.clone())), Some(("Speed", Value::Scalar(4.0)))); + assert_eq!(g.prop("param2").map(|p| (p.name.as_str(), p.value.clone())), Some(("Mode", Value::Enum(1)))); + assert_eq!(losses(&r, LossCategory::Effects, "Pseudo/0.25").len(), 1); + // The effect id stays the match name (Find Missing Effects, expressions); the group's match id + // is one path segment, so a match path (keyframe paste, pick whip) finds its parameters again. + assert_eq!(g.kind, GroupKind::Effect { effect: "Pseudo/0.25".into() }); + let speed = g.prop("param1").map(|p| p.uid); + let layer = PropGroup::new(0, "layer", "Layer").with(PropGroup::new(0, "effects", "Effects").with(g)); + let path = speed.and_then(|u| layer.match_path_of(u)); + assert_eq!(path.as_deref().and_then(|p| layer.prop(p)).map(|p| p.name.as_str()), Some("Speed"), "{path:?}"); +} + +/// A float slider keeps its valid range and slider range, so Effect Controls drags and clamps it +/// as After Effects does; a range that is not a range stays a plain number. +#[test] +fn a_float_slider_keeps_its_range() { + let fx = Fx { + match_name: "Pseudo/r", + name: "Rig", + defs: vec![slider(K::FloatSlider, "Speed", 1.0, (-10.0, 100.0)), slider(K::FloatSlider, "Odd", 1.0, (5.0, 1.0))], + values: vec![], + data: vec![], + keys: vec![], + }; + let (g, _) = placeholder_of(&fx); + let ui = |id: &str| g.prop(id).map(|p| p.ui.clone()); + assert_eq!(ui("param1"), Some(ParamUi::Slider { min: -10.0, max: 100.0, slider_min: -10.0, slider_max: 100.0, decimals: 2 })); + assert_eq!(ui("param2"), Some(ParamUi::Number)); +} + +/// A popup whose definition is borrowed (the project copy) and has no value record: its default +/// slot holds another copy's selection, so the first item is used and that is reported. +#[test] +fn a_borrowed_popup_without_a_record_takes_the_first_item_and_is_reported() { + let bytes = without_instance_definitions(&bytes_with(&third_party(vec![], vec![]))); + let (g, r) = import_bytes(&bytes, "CC Cylinder"); + assert_eq!(v(&g.unwrap(), "CC Cylinder-0008"), Value::Enum(0)); + let found = losses(&r, LossCategory::Effects, "CC Cylinder"); + assert!(found.iter().any(|d| d.contains("Render") && d.contains("default item is not known")), "{found:?}"); +} + +/// A popup default slot of 0 (no item; After Effects' save of a project it opened) takes the first +/// item and is reported, not read as damaged data. +#[test] +fn a_popup_default_of_no_item_takes_the_first_item() { + let fx = Fx { match_name: "Some Fx", name: "Some", defs: vec![popup("Mode", 0, &["A", "B"])], values: vec![], data: vec![], keys: vec![] }; + let (g, r) = placeholder_of(&fx); + assert_eq!(v(&g, "Some Fx-0001"), Value::Enum(0)); + let found = losses(&r, LossCategory::Effects, "Some Fx"); + assert!(found.iter().any(|d| d.contains("default item is not stored")), "{found:?}"); + assert!(losses(&r, LossCategory::DamagedData, "Some Fx").is_empty()); +} + +/// A popup past the 1024 items kept: the cut is reported, and a selection among the cut items +/// takes the first item and is reported as such, not as damaged data. +#[test] +fn a_popup_past_the_items_kept_is_reported() { + let items: &'static [&'static str] = Box::leak((0..1100).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::>().into_boxed_slice()); + let fx = Fx { match_name: "Long Fx", name: "Long", defs: vec![popup("Pick", 1, items)], values: vec![(1, 1050.0)], data: vec![], keys: vec![] }; + let (g, r) = placeholder_of(&fx); + let pick = g.prop("Long Fx-0001").unwrap(); + assert_eq!(pick.value, Value::Enum(0)); + assert!(matches!(&pick.ui, ParamUi::Popup { options } if options.len() == 1024)); + let found = losses(&r, LossCategory::Effects, "Long Fx"); + assert!(found.iter().any(|d| d.contains("past the first 1024")), "{found:?}"); + assert!(found.iter().any(|d| d.contains("item 1050 is past the items kept")), "{found:?}"); + assert!(losses(&r, LossCategory::DamagedData, "Long Fx").is_empty()); +} + +/// Many large popups stay within the import's budget: what is beyond is reported, not kept. +#[test] +fn a_hostile_placeholder_stays_bounded() { + let items: &'static [&'static str] = Box::leak((0..1000).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::>().into_boxed_slice()); + let fx = Fx { match_name: "Big Fx", name: "Big", defs: (0..300).map(|_| popup("P", 1, items)).collect(), values: vec![], data: vec![], keys: vec![] }; + let (g, r) = placeholder_of(&fx); + let kept = g.props().count(); + assert!(kept > 0 && kept < 300, "{kept}"); + assert!(g.props().map(|p| if let ParamUi::Popup { options } = &p.ui { options.len() + 1 } else { 1 }).sum::() <= 200_000); + assert!(losses(&r, LossCategory::Effects, "Big Fx").iter().any(|d| d.contains("placeholder parameters"))); +} + +/// Two instances of an effect whose parameters fill the import's budget: the first keeps what fits +/// and stops at the first parameter past it; the second is reported without a placeholder (its +/// shared definitions are not read again). +#[test] +fn an_effect_past_the_spent_budget_gets_no_placeholder() { + let items: &'static [&'static str] = Box::leak((0..1000).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::>().into_boxed_slice()); + let fx = Fx { match_name: "Big Fx", name: "Big", defs: (0..300).map(|_| popup("P", 1, items)).collect(), values: vec![], data: vec![], keys: vec![] }; + let bytes = twice(&bytes_with(&fx), "Big Fx"); + let mut project = effectcraft_project::Project::default(); + let none = |_: &str| false; + let r = effectcraft_aep::import::import( + &mut project, + &bytes, + &effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) }, + ) + .unwrap(); + let placeholders: Vec = project + .comps() + .flat_map(|(_, c)| c.layers.iter()) + .filter_map(|l| l.props.sub("effects")) + .flat_map(|fx| fx.children.iter().filter_map(|n| n.as_group()).filter(|g| g.match_id == "Big Fx").map(|g| g.props().count()).collect::>()) + .collect(); + // 199 popups of 1000 items (1001 each) fit in 200 000; the 200th stops the first instance. + assert_eq!(placeholders, [199]); + let found = losses(&r, LossCategory::Effects, "Big Fx"); + assert_eq!(found.iter().filter(|d| d.contains("and the ones after it not kept")).count(), 1, "{found:?}"); + assert!(found.iter().any(|d| d.contains("not kept (past the import's placeholder parameters)")), "{found:?}"); + assert!(found.iter().any(|d| d.contains("kept without rendering") && !d.contains("more like it")), "the kept instance alone: {found:?}"); +} + +/// Parameters a placeholder does not keep (markers, arbitrary data) still spend the budget: a +/// definition of very many of them, shared by many instances, is not walked again for each one. +#[test] +fn parameters_not_kept_still_spend_the_budget() { + let defs = (0..200_000).map(|i| other(if i % 2 == 0 { K::NoData } else { K::ArbitraryData }, "X", Default::None)).collect(); + let fx = Fx { match_name: "Odd Fx", name: "Odd", defs, values: vec![], data: vec![], keys: vec![] }; + let (_, r) = import_bytes(&twice(&bytes_with(&fx), "Odd Fx"), "Odd Fx"); + let found = losses(&r, LossCategory::Effects, "Odd Fx"); + assert!(found.iter().any(|d| d.contains("not kept (past the import's placeholder parameters)")), "{found:?}"); +} + +/// Long popup item names spend the budget by their length, so the names kept stay bounded. +#[test] +fn long_popup_item_names_spend_the_budget() { + let long = "x".repeat(1600); + let items: &'static [&'static str] = + Box::leak((0..1024).map(|i| &*Box::leak(format!("{long}{i}").into_boxed_str())).collect::>().into_boxed_slice()); + let fx = Fx { match_name: "Long Fx", name: "Long", defs: vec![popup("A", 1, items), popup("B", 1, items)], values: vec![], data: vec![], keys: vec![] }; + let (g, r) = placeholder_of(&fx); + assert_eq!(g.props().count(), 1); + assert!(losses(&r, LossCategory::Effects, "Long Fx").iter().any(|d| d.contains("placeholder parameters"))); +} + +/// `bytes` with the layer's instance of `match_name` (its `tdmn` and `sspc`) repeated after it. +fn twice(bytes: &[u8], match_name: &str) -> Vec { + let mut file = parse(bytes).unwrap(); + let mut done = 0; + edit(&mut file.chunks, false, &mut |c, in_project_copy| { + let Body::List { children, .. } = &mut c.body else { return }; + if in_project_copy { + return; + } + let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(match_name.as_bytes()))) + else { + return; + }; + let pair: Vec> = children[at..at + 2].to_vec(); + children.splice(at + 2..at + 2, pair); + done += 1; + }); + assert_eq!(done, 1, "one instance"); + write(&file).unwrap() +} + +/// `bytes` with the expression `text` on the parameter record `param` (enabled: `tdb4` byte 120 +/// 1, docs/aep-format.md 3.6). +fn with_expression(bytes: &[u8], param: &str, text: &str) -> Vec { + let mut file = parse(bytes).unwrap(); + let mut done = 0; + edit(&mut file.chunks, false, &mut |c, _| { + let Body::List { children, .. } = &mut c.body else { return }; + let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else { + return; + }; + let Some(Body::List { children: record, .. }) = children.get_mut(at + 1).map(|k| &mut k.body) else { return }; + for k in record.iter_mut().filter(|k| k.is(b"tdb4")) { + if let Body::Data(d) = &mut k.body + && let Some(b) = d.to_mut().get_mut(120) + { + *b = 1; + } + } + record.push(Chunk::data(*b"Utf8", text.as_bytes().to_vec())); + done += 1; + }); + assert!(done >= 1); + write(&file).unwrap() +} + +/// `bytes` with the layer's instance of `CC Cylinder` stripped of its own definitions (an empty +/// `parT`): it borrows the project copy's (`EfdG`). +fn without_instance_definitions(bytes: &[u8]) -> Vec { + let mut file = parse(bytes).unwrap(); + let mut done = 0; + edit(&mut file.chunks, false, &mut |c, in_project_copy| { + let Body::List { children, .. } = &mut c.body else { return }; + if in_project_copy { + return; + } + let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(&b"CC Cylinder"[..]))) + else { + return; + }; + let Some(sspc) = children.get_mut(at + 1).filter(|k| k.is_list(b"sspc")) else { return }; + for k in sspc.children_mut() { + if let Body::List { list_type, children } = &mut k.body + && list_type == b"parT" + { + children.clear(); + done += 1; + } + } + }); + assert_eq!(done, 1, "one instance"); + write(&file).unwrap() +} + +/// Calls `f` on every chunk with whether it is inside the project's effect definitions (`EfdG`). +fn edit(chunks: &mut [Chunk<'_>], in_efdg: bool, f: &mut dyn FnMut(&mut Chunk<'_>, bool)) { + for c in chunks { + let inside = in_efdg || c.is_list(b"EfdG"); + f(c, inside); + edit(c.children_mut(), inside, f); + } +} diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 8e4c28f16..d1c030401 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -842,3 +842,187 @@ fn a_default_state_colour_record_holds_the_value() { assert_eq!(colour(&default_state), colour(&written)); assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); } + +/// The project with its png item made one layer of a Photoshop file `art.psd`: the `opti` of +/// docs/aep-format.md 3.3 (tag `8BPS`, the layer index at 14, a 1000 x 1000 document at 32 and 36, +/// its bounds at 78-94, its name at 344) and the item `size` big. +fn as_psd_layer(index: i32, bounds: [i32; 4], name: &str, size: (u32, u32)) -> Vec { + let mut opti = vec![0u8; 602]; + opti[..4].copy_from_slice(b"8BPS"); + opti[14..18].copy_from_slice(&index.to_be_bytes()); + opti[32..36].copy_from_slice(&1000u32.to_le_bytes()); + opti[36..40].copy_from_slice(&1000u32.to_le_bytes()); + for (i, v) in bounds.iter().enumerate() { + opti[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes()); + } + opti[344..344 + name.len()].copy_from_slice(name.as_bytes()); + let alias = br#"{"fullpath":"C:\\x\\art.psd","platform":1,"target_is_folder":false}"#.to_vec(); + mutate(&mut |c| { + if !c.is_list(b"Pin ") { + return; + } + let Body::List { children, .. } = &mut c.body else { return }; + if !children.iter().any(|k| k.is(b"opti") && k.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..]))) { + return; + } + // No sequence naming between the alias and the `opti`. + children.retain(|k| k.is(b"sspc") || k.is(b"opti") || k.is_list(b"Als2")); + for k in children.iter_mut() { + if k.is(b"opti") { + k.body = Body::Data(opti.clone().into()); + } else if k.is(b"sspc") { + set_data(k, 30, &size.0.to_be_bytes()); + set_data(k, 34, &size.1.to_be_bytes()); + } + } + visit(children, &mut |k| { + if k.is(b"alas") { + k.body = Body::Data(alias.clone().into()); + } + }); + }) +} + +/// #3087: an item made from one layer of a Photoshop file shows that layer, named +/// `/`; it showed the whole document, named after the file. Item as big as the layer: +/// Retain Layer Sizes; as big as the document: the layer on the document. +#[test] +fn a_photoshop_layer_item_shows_its_layer() { + for (size, layer_size) in [((48, 15), true), ((1000, 1000), false)] { + let (p, r) = run(&as_psd_layer(7, [749, 800, 764, 848], "Tëst", size), true); + r.unwrap(); + let (name, f) = p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.name.clone(), f.clone())) } else { None }).unwrap(); + assert_eq!(name, "Tëst/art.psd"); + let l = f.layer.expect("the layer"); + assert_eq!((l.index, l.name.as_str(), l.layer_size, (f.width, f.height)), (7, "Tëst", layer_size, size)); + } +} + +/// A layer as big as the document but off its corner fits both framings: shown at its own bounds +/// and reported. One at the corner, or bigger than the document, reads one way and is not. An item +/// as big as neither is shown on the document and reported. +#[test] +fn a_photoshop_layer_as_big_as_the_document_off_its_corner_is_reported() { + let framing = |bounds, size| { + let (p, r) = run(&as_psd_layer(3, bounds, "Moved", size), true); + let found = losses(&r.unwrap(), LossCategory::UnsupportedItems); + let said = |what: &str| found.iter().any(|d| d.contains("'Moved/art.psd'") && d.contains(what)); + (the_footage(&p).layer.map(|l| l.layer_size), said("as big as the document"), said("neither the Photoshop layer's size")) + }; + assert_eq!(framing([5, 10, 1005, 1010], (1000, 1000)), (Some(true), true, false)); + assert_eq!(framing([0, 0, 1000, 1000], (1000, 1000)), (Some(true), false, false)); + assert_eq!(framing([9, -346, 1083, 1086], (1432, 1074)), (Some(true), false, false)); + assert_eq!(framing([749, 800, 764, 848], (1000, 1000)), (Some(false), false, false)); + // An item as big as neither the layer nor the document: shown on the document, reported. + assert_eq!(framing([5, 10, 1005, 1010], (800, 800)), (Some(false), false, true)); +} + +/// #3087: a Photoshop layer without pixels (an adjustment layer) keeps the merged image and is +/// reported; the merged image itself (index -1) is no layer. +#[test] +fn a_photoshop_layer_without_pixels_keeps_the_merged_image() { + let (p, r) = run(&as_psd_layer(4, [0; 4], "Levels 1", (1000, 1000)), true); + let found = losses(&r.unwrap(), LossCategory::UnsupportedItems); + assert!(found.iter().any(|d| d.contains("'Levels 1/art.psd'") && d.contains("without pixels")), "{found:?}"); + assert_eq!(the_footage(&p).layer, None); + let (p, _) = run(&as_psd_layer(-1, [0; 4], "", (1000, 1000)), true); + assert_eq!(the_footage(&p).layer, None); +} + +/// #3087: an expression on Orientation arrives with the stored value, as on any property; it was +/// dropped together with the value and reported as "not decoded". +#[test] +fn an_orientation_expression_is_imported_with_its_value() { + let bytes = mutate(&mut |c| { + if !c.is_list(b"otst") { + return; + } + for child in c.children_mut() { + if child.is_list(b"tdbs") { + let Body::List { children, .. } = &mut child.body else { continue }; + for k in children.iter_mut() { + if k.is(b"tdsb") { + set_data(k, 0, &1u32.to_be_bytes()); + } else if k.is(b"tdb4") { + set_data(k, 120, &[1]); + } + } + children.push(Chunk::data(*b"Utf8", b"[0, time * 90, 0]".to_vec())); + } else if child.is_list(b"otky") { + for k in child.children_mut() { + let degrees: Vec = [10.0f64, 20.0, 30.0].iter().flat_map(|d| d.to_be_bytes()).collect(); + set_data(k, 0, °rees); + } + } + } + }); + let (p, r) = run(&bytes, true); + assert_eq!(losses(&r.unwrap(), LossCategory::TransformProperties), Vec::::new()); + let mut seen = 0; + for item in p.items.values() { + let ItemKind::Comp(c) = &item.kind else { continue }; + for layer in &c.layers { + let Some(o) = layer.transform().and_then(|t| t.prop("orientation")).filter(|o| o.expr.is_some()) else { continue }; + assert_eq!(o.value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 30.0]), "{}", layer.name); + assert_eq!(o.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("[0, time * 90, 0]", true)), "{}", layer.name); + seen += 1; + } + } + assert!(seen > 0); +} + +/// `SYNTHETIC` with Levels' Channel and Clip to Output Black / White popups' item names (`pdnm`) +/// rewritten to the French labels a French-language After Effects stores there, keeping the same +/// item order so the index each popup is set to is unaffected. +fn with_french_levels_popups() -> Vec { + let (mut channel, mut clip) = (0, 0); + let bytes = mutate(&mut |c| { + if !c.is(b"Utf8") { + return; + } + if c.bytes() == Some(b"RGB|Red|Green|Blue|Alpha".as_slice()) { + *c = Chunk::data(*b"Utf8", b"RVB|Rouge|Vert|Bleu|Alpha".to_vec()); + channel += 1; + } else if c.bytes() == Some(b"On|Off|Off for 32 bpc Color".as_slice()) { + *c = Chunk::data(*b"Utf8", "Activé|Désactivé|Désactivé pour la couleur 32 bpc".as_bytes().to_vec()); + clip += 1; + } + }); + assert_eq!(channel, 2, "Levels' Channel pdnm"); + assert_eq!(clip, 4, "Levels' two Clip pdnm (Black and White), each stored twice"); + bytes +} + +/// A popup's item names in the file are matched to EffectCraft's own (English) ones by name +/// (`popup_key`): a project saved by a French After Effects stores "RVB", "Activé", "Désactivé +/// pour la couleur 32 bpc" for Levels' Channel and Clip to Output Black / White (observed in a +/// real "Niveaux" effect, After Effects 2026 26.0.0x67). Before `popup_alias`, none of these +/// matched EffectCraft's English-only options, so each came back "has no counterpart, default +/// used" and the real selection was silently lost. +#[test] +fn french_levels_popup_items_are_recognized() { + let (unchanged, r1) = run(SYNTHETIC, true); + let (french, r2) = run(&with_french_levels_popups(), true); + let (r1, r2) = (r1.unwrap(), r2.unwrap()); + let found = losses(&r2, LossCategory::Effects); + assert!(!found.iter().any(|d| d.contains("has no counterpart")), "{found:?}"); + for id in ["channel", "clipToOutputBlack", "clipToOutputWhite"] { + let want: Vec<_> = effect_values(&unchanged).into_iter().filter(|(p, _)| p == id).collect(); + let got: Vec<_> = effect_values(&french).into_iter().filter(|(p, _)| p == id).collect(); + assert_eq!(got, want, "{id}"); + } + assert_eq!(r1.report.imported.effects, r2.report.imported.effects); +} + +/// `popup_alias` must cover every item of a popup it claims to know, not only the one a first +/// report happened to show: Channel's French "Rouge", "Vert", "Bleu" ("Alpha" is the same word in +/// both languages) alongside the already-covered "RVB" (`effect_map.rs`, reviewed on #3432). +#[test] +fn french_levels_channel_aliases_cover_every_item() { + use effectcraft_aep::effect_map::popup_alias; + for (french, english) in [("rvb", "rgb"), ("rouge", "red"), ("vert", "green"), ("bleu", "blue")] { + assert_eq!(popup_alias(french), Some(english), "{french}"); + } + // "Alpha" needs no alias: the same word in both languages. + assert_eq!(popup_alias("alpha"), None); +} diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 570726932..dda0a406c 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -65,6 +65,8 @@ const REFUSED: &[&str] = &[ "file.revealInFinder", "layer.openSource", "help.revealLogFile", + // Hands the footage file to the platform's default application. + "edit.editOriginal", "file.autoSave", // The Plug-in Library downloads code, installs it to load at start-up and reads `file://` catalogs. "library.refresh", @@ -73,8 +75,15 @@ const REFUSED: &[&str] = &[ "library.uninstall", "library.loadInstalled", "library.preview", + // Watch Folder renders the projects it finds to the outputs they name and writes status + // files into the watched folder: neither is a path the agent gives, so neither is checked. + "file.watchFolder", + "file.watchFolder.poll", ]; +/// Most numbered folders `check_render_folder` inspects for one output; more are refused. +const MAX_NUMBERED_FOLDERS: usize = 4096; + /// Commands that read files named by these parameters. const READS: &[(&str, &[&str])] = &[ ("file.open", &["path"]), @@ -88,7 +97,6 @@ const READS: &[(&str, &[&str])] = &[ ("file.replaceFootage", &["path"]), ("file.relinkFootage", &["folder"]), ("file.setProxy", &["path"]), - ("file.watchFolder", &["folder"]), ("anim.applyPreset", &["path"]), ("anim.applyRecentPreset", &["path"]), ("keys.rpfCameraImport", &["path"]), @@ -102,7 +110,6 @@ const READS: &[(&str, &[&str])] = &[ // unchecked. ("essential.templateInfo", &["path"]), ("file.importProject", &["path"]), - ("file.watchFolder.poll", &["folder"]), ("footage.relink", &["path"]), ("layer.newModel", &["path"]), ("mediaBrowser.fileInfo", &["path"]), @@ -143,6 +150,8 @@ const WRITES: &[(&str, &[&str])] = &[ ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), ("file.exportTimeline", &["path"]), + ("journal.export", &["path"]), + ("help.exportRepro", &["path"]), // `path` / `output` may also be a file-name template, as for render.preRender. ("file.createProxy", &["path", "output"]), ]; @@ -200,6 +209,80 @@ impl Confinement { if self.roots(access).any(|r| real.starts_with(r)) { Ok(real) } else { Err(denied("it is outside the automation roots")) } } + /// A render output's folder holds no symbolic link that leads outside the write roots. An + /// image sequence is written to numbered names (`out_[#####].png` → `out_00001.png`, and a + /// sequence output without `#` gets `_00001` too) that the check of its template never + /// resolved, and each frame file is created through whatever link already sits at its name, + /// truncating the file it points to. A link that can't be resolved counts as outside: the + /// render would create its target. When the frame number is in a folder name, the same goes for + /// each existing numbered folder and the folder it holds the frames in. + fn check_render_folder(&self, output: &str) -> Result<()> { + let real = self.check(output, Access::Write)?; + if let Some(dir) = real.parent() { + self.check_no_outward_links(dir, output)?; + } + // The frame number may sit in a folder name (`shots_[##]/f_[#####].png`): the folders the + // frames go to are the numbered ones that exist, which the template's own parent is not. + let (before, _, after) = effectcraft_engine::project::render_queue::sequence_parts(output); + let sep = ['/', '\\']; + let Some((dir_tail, rest)) = after.split_once(sep) else { return Ok(()) }; + let (parent, prefix) = before.rsplit_once(sep).unwrap_or(("", &before)); + // A parent that doesn't exist has no numbered folders; one that can't be listed might. + let scan = if parent.is_empty() { Path::new("/") } else { Path::new(parent) }; + let entries = match std::fs::read_dir(scan) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(Error::BadArgs(format!("cannot render to `{output}`: its numbered folders in `{}` can't be checked: {e}", scan.display()))), + }; + let mut seen = 0; + for e in entries { + let e = + e.map_err(|e| Error::BadArgs(format!("cannot render to `{output}`: its numbered folders in `{}` can't be checked: {e}", scan.display())))?; + let name = e.file_name().to_string_lossy().into_owned(); + let Some(digits) = name.strip_prefix(prefix).and_then(|n| n.strip_suffix(dir_tail)) else { continue }; + if !digits.strip_prefix('-').unwrap_or(digits).bytes().all(|b| b.is_ascii_digit()) || digits.is_empty() { + continue; + } + seen += 1; + if seen > MAX_NUMBERED_FOLDERS { + return Err(Error::BadArgs(format!("cannot render to `{output}`: more than {MAX_NUMBERED_FOLDERS} numbered folders already exist to check"))); + } + // The numbered folder itself may be a link out; `check` resolves every existing part. + let frame_file = e.path().join(rest); + let frame_file = frame_file.to_string_lossy(); + self.check(&frame_file, Access::Write)?; + if let Some(dir) = Path::new(&*frame_file).parent().and_then(resolve) { + self.check_no_outward_links(&dir, output)?; + } + } + Ok(()) + } + + /// `dir` holds no symbolic link that leads outside the write roots. + fn check_no_outward_links(&self, dir: &Path, output: &str) -> Result<()> { + // A folder that does not exist yet holds no link; one that can't be listed might. + let cannot = |e: std::io::Error| Error::BadArgs(format!("cannot render to `{output}`: its folder `{}` can't be checked for links: {e}", dir.display())); + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(cannot(e)), + }; + for e in entries { + let e = e.map_err(cannot)?; + if !e.file_type().map_err(cannot)?.is_symlink() { + continue; + } + let link = e.path(); + if !link.canonicalize().is_ok_and(|r| self.write.iter().any(|w| r.starts_with(w))) { + return Err(Error::BadArgs(format!( + "cannot render to `{output}`: its folder holds `{}`, a link that leads outside the automation write roots, where a numbered frame could be written", + link.display() + ))); + } + } + Ok(()) + } + /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { @@ -219,6 +302,12 @@ impl Confinement { for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; let inner_params = step.get("params").cloned().unwrap_or(Value::Null); + // A whole `params` given as a `$N` reference becomes an earlier step's result + // only as the batch runs, so none of its file paths would have been checked + // (`essential.templateInfo` returns a manifest naming any file at all). + if !matches!(inner_params, Value::Null | Value::Object(_)) { + return Err(Error::BadArgs(format!("`{inner}` in a batch: `params` must be an object while file access is confined"))); + } let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]); // A step's `$N` reference is resolved only as the batch runs: never for a file. @@ -256,6 +345,9 @@ impl Confinement { Some(s) => { gave_a_file |= v.is_string(); self.check(s, access)?; + if QUEUES_AN_OUTPUT.contains(&id) || id.starts_with("renderQueue.") { + self.check_render_folder(s)?; + } } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), } @@ -280,6 +372,11 @@ impl Confinement { if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); } + // The default repro destination is the settings store, not an automation write root. + // Require a single explicit path so missing, null or array parameters cannot bypass it. + if id == "help.exportRepro" && params.get("path").and_then(Value::as_str).is_none() { + return Err(Error::BadArgs("`help.exportRepro`: give `path` inside an automation write root while file access is confined".into())); + } let mut lookups = vec![]; // An output the agent did not name is derived from where the project sits: the Default // Output Folder preference when that is set, beside the project file otherwise — and the @@ -314,11 +411,15 @@ impl Confinement { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { let extra = it.get("extraOutputs").and_then(Value::as_array).into_iter().flatten().map(|o| o.get("outputPath")); - for p in std::iter::once(it.get("outputPath")).chain(extra).chain([it.get("logPath")]).flatten() { + for p in std::iter::once(it.get("outputPath")).chain(extra).flatten() { if let Some(p) = p.as_str() { self.check(p, Access::Write)?; + self.check_render_folder(p)?; } } + if let Some(p) = it.get("logPath").and_then(Value::as_str) { + self.check(p, Access::Write)?; + } } Ok(()) } @@ -381,6 +482,10 @@ impl FootageSource for ConfinedFootage { fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten() } + /// The frame as 8-bit levels, or `None` when the footage lies outside the allowed roots. + fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.readable(footage).then(|| self.inner.frame8(item, footage, t, float)).flatten() + } /// The footage audio from the inner source, or `None` when the footage lies outside the allowed roots. fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() @@ -684,6 +789,16 @@ mod tests { assert!(read(&footage(&inside)).1.is_complete()); } + #[test] + fn watch_folder_and_edit_original_are_refused_while_confined() { + let dir = std::env::temp_dir(); + let c = Confinement::new(std::slice::from_ref(&dir), std::slice::from_ref(&dir)).unwrap(); + for id in ["file.watchFolder", "file.watchFolder.poll", "edit.editOriginal"] { + let e = c.check_command(id, &serde_json::json!({"folder": dir.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("not available while file access is confined"), "{id}: {e}"); + } + } + #[test] fn relative_and_outside_paths_of_newly_listed_commands_are_refused() { let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id())); @@ -746,6 +861,112 @@ mod tests { ] { assert!(c.check_command("effect.plugins.install", ¶ms).is_err(), "{params}"); } + } + + #[test] + fn a_batch_step_whose_params_are_a_reference_is_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-batch-ref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write) = (base.join("in"), base.join("out")); + for d in [&read, &write] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let info = read.join("t.ectemplate").to_string_lossy().into_owned(); + // Step 2's params are step 1's result, which can name any `path`. + let steps = serde_json::json!({"steps": [ + {"command": "essential.templateInfo", "params": {"path": info}}, + {"command": "file.saveAs", "params": "$1"}, + ]}); + let e = c.check_command("engine.batch", &steps).unwrap_err().to_string(); + assert!(e.contains("must be an object"), "{e}"); + for params in [serde_json::json!("$1.manifest"), serde_json::json!(["$1"]), serde_json::json!(7)] { + let steps = serde_json::json!({"steps": [{"command": "file.saveAs", "params": params}]}); + assert!(c.check_command("engine.batch", &steps).is_err(), "{params}"); + } + // Object params (or none) are checked as before. + let saved = write.join("p.ecproj").to_string_lossy().into_owned(); + let steps = serde_json::json!({"steps": [{"command": "file.saveAs", "params": {"path": saved}}, {"command": "edit.undo"}]}); + assert!(c.check_command("engine.batch", &steps).is_ok()); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn a_render_folder_with_a_link_out_of_the_write_roots_is_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-seq-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let template = write.join("frames_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + let queue = serde_json::json!({"items": [{"outputPath": template}]}); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + assert!(c.check_lookup(&Lookup::RenderOutputs, &queue).is_ok()); + // A link inside the write root is fine. + std::os::unix::fs::symlink(write.join("kept.png"), write.join("frames_00002.png")).unwrap(); + std::fs::write(write.join("kept.png"), b"x").unwrap(); + assert!(c.check_lookup(&Lookup::RenderOutputs, &queue).is_ok()); + // Frame 1's name leads outside: rendering would truncate that file. + std::os::unix::fs::symlink(outside.join("victim"), write.join("frames_00001.png")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_err()); + assert!(c.check_command("render.preRender", &add).is_err()); + let e = c.check_lookup(&Lookup::RenderOutputs, &queue).unwrap_err().to_string(); + assert!(e.contains("frames_00001.png"), "{e}"); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn a_render_folder_that_cannot_be_listed_is_refused() { + use std::os::unix::fs::PermissionsExt; + let base = std::env::temp_dir().join(format!("ec-confine-seq-nolist-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let write = base.join("out"); + std::fs::create_dir_all(&write).unwrap(); + let c = Confinement::new(&[], std::slice::from_ref(&write)).unwrap(); + let template = write.join("frames_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + // Writable and searchable but not listable: a link in it could not be seen. + std::fs::set_permissions(&write, std::fs::Permissions::from_mode(0o300)).unwrap(); + // A user who can list it anyway (root) sees no difference to test. + if std::fs::read_dir(&write).is_err() { + assert!(c.check_command("renderQueue.add", &add).is_err()); + } + std::fs::set_permissions(&write, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn numbered_render_folders_with_links_out_of_the_write_roots_are_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-seq-dir-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (write, outside) = (base.join("out"), base.join("elsewhere")); + for d in [&write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(&[], std::slice::from_ref(&write)).unwrap(); + let template = write.join("shot_[###]/f_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + assert!(c.check_command("renderQueue.add", &add).is_ok(), "no numbered folder exists yet"); + // A numbered folder inside the roots holding a link out is refused. + std::fs::create_dir(write.join("shot_001")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + std::os::unix::fs::symlink(outside.join("victim"), write.join("shot_001/f_00001.png")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_err()); + std::fs::remove_file(write.join("shot_001/f_00001.png")).unwrap(); + // A numbered folder that is itself a link out is refused. + std::os::unix::fs::symlink(&outside, write.join("shot_002")).unwrap(); + assert!(c.check_command("render.preRender", &add).is_err()); + // Unrelated names are left alone. + std::fs::remove_file(write.join("shot_002")).unwrap(); + std::os::unix::fs::symlink(&outside, write.join("shot_notes")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); let _ = std::fs::remove_dir_all(&base); } } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 92b62da2f..8a1839ede 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1865,3 +1865,28 @@ fn automation_roots_check_relinked_footage_again() { assert!(!(px[0] > 200 && px[3] > 200), "the link pointed outside afterwards was read: {px:?}"); let _ = std::fs::remove_dir_all(&base); } + +/// Exporting a repro must not write to the settings store outside the automation roots. +#[test] +fn confined_repro_export_requires_an_explicit_writable_path() { + use crate::confine::Confinement; + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + use std::sync::Arc; + + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(MemoryConfig::default()); + let session = Session { config: Some(store.clone()), ..Default::default() }; + let mut s = McpServer::new(Backend::headless(session)); + s.backend().confine(Confinement::new(&[], &[dir.path().to_path_buf()]).unwrap()); + for params in [json!({}), json!({"path": null}), json!({"path": []}), json!({"path": [dir.path().join("repro.jsonl")]})] { + let (content, error) = call(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": params})); + assert!(error, "{content:?}"); + assert!(store.list("Repros").is_empty(), "implicit settings-store writes must be refused"); + } + let path = dir.path().join("repro.jsonl"); + call_json(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": {"path": path}})); + assert!(path.is_file()); + let journal = call_json(&mut s, "execute_command", json!({"command": "journal.export"})); + assert!(journal["script"].is_string(), "in-memory exports need no write root"); + assert!(store.list("Repros").is_empty()); +} diff --git a/crates/av1enc/Cargo.toml b/crates/av1enc/Cargo.toml deleted file mode 100644 index 2a4c7ddfe..000000000 --- a/crates/av1enc/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "effectcraft-av1enc" -description = "Clean-room pure-Rust AV1 encoder (Main profile, key and inter frames) for MP4/WebM export, from the AV1 Bitstream & Decoding Process Specification" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[lints] -workspace = true - -[dependencies] - -[dev-dependencies] -filmcraft-av1 = { workspace = true } diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md deleted file mode 100644 index dcc343693..000000000 --- a/crates/av1enc/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# effectcraft-av1enc - -Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0, `std` only, -no `unsafe`, builds for `wasm32-unknown-unknown`). - -Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in -`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived -`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own -reconstruction, so prediction never drifts. - -## What is implemented - -- **Stream**: Main profile (`seq_profile` 0), 4:2:0, 8 or 10 bit, BT.709 colour description, - limited or full range. Sequence header with one operating point; `seq_level_idx` given or the - lowest Annex A level whose MaxPicSize / MaxH/VSize / MaxDisplayRate (and, in bitrate mode, Main - tier MaxBitrate) fit. Order hints, film grain, superres, CDEF, loop restoration, intra edge - filter, filter intra, inter-intra, masked / distance compound, warped motion and dual filter - are disabled in the sequence header. -- **Packets**: one temporal unit per frame as stored in MP4 / Matroska samples: OBUs with size - fields, no temporal delimiter; key frames start with the sequence header OBU, followed by one - `OBU_FRAME` (frame header + tile group). Every frame is shown, in input order. - `Encoder::av1c()` returns the `AV1CodecConfigurationRecord` (also the WebM `CodecPrivate`). -- **Frames**: a `KEY_FRAME` every `keyint` frames, `INTER_FRAME`s otherwise. Inter frames use - slot 0 (the previous reconstruction) for all seven references and refresh slot 0 only, so - `LAST_FRAME` is always the previous frame. CDFs adapt per symbol and are saved at the end of - every frame; inter frames load them from `LAST_FRAME` (`primary_ref_frame` = 0). One tile, - `TX_MODE_LARGEST`, `reduced_tx_set`, no segmentation, no delta q / lf. -- **Deblocking**: the normative loop filter (7.14) runs on the reconstruction; the frame level - (the same for both luma directions and both chroma planes, no deltas, sharpness 0) is searched - per frame for the lowest luma error. -- **Tiles**: the multi-symbol arithmetic encoder is the exact inverse of the spec's symbol - decoder (8.2), including the exit process padding; CDF adaptation as in 8.2.6. -- **Partitions**: 64x64 superblocks, `PARTITION_NONE` / `PARTITION_SPLIT` down to 8x8 chosen by - a rate-distortion cost (SSE + λ·estimated bits); blocks crossing the frame edge are split as - the syntax requires. 64x64 blocks are only used as inter skip blocks. -- **Intra**: DC, V, H, Paeth, smooth, smooth-V, smooth-H for luma and chroma, - ranked by SATD with a rate-distortion choice among the best three; the spec's edge preparation - and above-right / below-left availability. -- **Inter**: single reference (`LAST_FRAME`), `NEWMV` / `NEARESTMV` / `NEARMV` / `GLOBALMV` - chosen from the spec's motion vector prediction stack (7.10.2, mirrored exactly); motion search - from neighbour / co-located / parent candidates, a whole-sample diamond search and half- then - quarter-sample refinement (`allow_high_precision_mv` = 0); the normative 8-tap `EIGHTTAP` - prediction for luma and chroma; skip blocks; per-block intra / inter choice. -- **Residual**: one transform per plane and block (4x4 ... 32x32): for intra luma `DCT_DCT` or - the mode's preferred `ADST` combination (rate-distortion choice over the two best modes), the - mode-dependent type for intra chroma, `DCT_DCT` for inter blocks; dead-zone quantisation with the - spec's `Dc_Qlookup` / `Ac_Qlookup`; the normative dequantisation and inverse transforms in the - reconstruction loop; full coefficient coding (all_zero, tx type, eob_pt, eob_extra, - coeff_base_eob, coeff_base, coeff_br, dc_sign, Golomb) with the exact contexts. -- **Rate control**: `ConstantQ(base_q_idx)`; `Bitrate { kbps }` adapts `base_q_idx` per frame - towards the average target (key frames get a lower quantizer and a larger share of the budget of - their group of `keyint` frames, the inter frames the rest; the first key frame is searched in a - few passes, a later key frame that misses its budget by more than 19 % is coded again; the - deviation from the budget is paid back over 16 frames). - -## Limits - -- One tile: width and height up to 4096 and at most 2304 superblocks (4096x2304 samples). -- No CDEF or loop restoration, no compound prediction, no rectangular or 4xN blocks, no - directional intra modes other than V / H, no CfL or palette, no transform-size splits, so - compression is below mature encoders. -- 4:2:0 only (Main profile), no 12 bit. - -## Specification and provenance - -Implemented from the **AV1 Bitstream & Decoding Process Specification, version 1.0.0 with -Errata 1** (AOMediaCodec, 2019; the edition FilmCraft's decoder follows) and the **AV1 Codec -ISO Media File Format Binding** (v1.2.0) for `av1C`. - -The constant tables (default CDFs, scan orders, quantizer lookup tables, interpolation filter -taps, smooth weights, block / transform size tables, ...) and the decoder-side processes the -encoder must reproduce exactly (inverse transforms, intra and inter prediction rounding, context -derivations, motion vector prediction) are mirrored from FilmCraft's first-party, spec-derived -`filmcraft-av1` decoder (MIT OR Apache-2.0), which is also the round-trip oracle. No source of -libaom, dav1d, rav1e, SVT-AV1, ffmpeg or any other third-party encoder or decoder was consulted; -ffmpeg / libdav1d is used only as an external test oracle. diff --git a/crates/av1enc/src/bitw.rs b/crates/av1enc/src/bitw.rs deleted file mode 100644 index e4c8a1e69..000000000 --- a/crates/av1enc/src/bitw.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! MSB-first bit writer for the OBU headers (spec 4.10 `f(n)` descriptors) and `leb128()`. - -#[derive(Default)] -pub(crate) struct BitWriter { - pub buf: Vec, - nbits: usize, -} - -impl BitWriter { - pub fn new() -> Self { - BitWriter::default() - } - - /// `f(n)`: writes the low `n` bits of `v`, most significant first. - pub fn f(&mut self, n: u32, v: u32) { - for i in (0..n).rev() { - self.bit((v >> i) & 1 != 0); - } - } - - pub fn bit(&mut self, b: bool) { - if self.nbits.is_multiple_of(8) { - self.buf.push(0); - } - if b { - let last = self.buf.len() - 1; - self.buf[last] |= 0x80 >> (self.nbits % 8); - } - self.nbits += 1; - } - - /// `byte_alignment()`: zero bits up to the next byte boundary. - pub fn byte_align(&mut self) { - while !self.nbits.is_multiple_of(8) { - self.bit(false); - } - } - - /// `trailing_bits()`: a one bit followed by zero bits up to the byte boundary. - pub fn trailing_bits(&mut self) { - self.bit(true); - self.byte_align(); - } - - pub fn into_bytes(self) -> Vec { - self.buf - } -} - -/// `leb128()` encoding of an OBU size. -pub(crate) fn leb128(mut v: usize, out: &mut Vec) { - loop { - let mut b = (v & 0x7f) as u8; - v >>= 7; - if v != 0 { - b |= 0x80; - } - out.push(b); - if v == 0 { - break; - } - } -} - -/// An OBU with `obu_has_size_field` = 1 and no extension header. -pub(crate) fn obu(obu_type: u8, payload: &[u8], out: &mut Vec) { - out.push((obu_type << 3) | 0x02); - leb128(payload.len(), out); - out.extend_from_slice(payload); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bits_and_leb128() { - let mut w = BitWriter::new(); - w.f(3, 0b101); - w.f(6, 0b110011); - w.trailing_bits(); - assert_eq!(w.into_bytes(), vec![0b1011_1001, 0b1100_0000]); - let mut v = Vec::new(); - leb128(300, &mut v); - assert_eq!(v, vec![0xAC, 0x02]); - } -} diff --git a/crates/av1enc/src/cdf.rs b/crates/av1enc/src/cdf.rs deleted file mode 100644 index 777164d1a..000000000 --- a/crates/av1enc/src/cdf.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! The adaptive CDFs this encoder codes with (the subset of spec 6.8.2 init_non_coeff_cdfs / -//! init_coeff_cdfs that its syntax uses). Layout as in filmcraft-av1: each innermost array -//! holds N cumulative values (the last is 32768) plus the adaptation counter. - -use crate::tables::{ - DEFAULT_ANGLE_DELTA_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DRL_MODE_CDF, - DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF, DEFAULT_EOB_PT_256_CDF, - DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_IS_INTER_CDF, DEFAULT_MV_BIT_CDF, - DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, - DEFAULT_NEW_MV_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF, DEFAULT_PARTITION_W64_CDF, DEFAULT_REF_MV_CDF, - DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_Y_MODE_CDF, - DEFAULT_ZERO_MV_CDF, -}; - -#[derive(Clone)] -pub(crate) struct Cdfs { - pub y_mode: [[u16; 14]; 4], - pub uv_mode_cfl_not_allowed: [[u16; 14]; 13], - pub uv_mode_cfl_allowed: [[u16; 15]; 13], - pub angle_delta: [[u16; 8]; 8], - pub partition_w8: [[u16; 5]; 4], - pub partition_w16: [[u16; 11]; 4], - pub partition_w32: [[u16; 11]; 4], - pub partition_w64: [[u16; 11]; 4], - pub skip: [[u16; 3]; 3], - pub is_inter: [[u16; 3]; 4], - pub single_ref: [[[u16; 3]; 6]; 3], - pub new_mv: [[u16; 3]; 6], - pub zero_mv: [[u16; 3]; 2], - pub ref_mv: [[u16; 3]; 6], - pub drl_mode: [[u16; 3]; 3], - pub mv_joint: [u16; 5], - pub mv_class: [[u16; 12]; 2], - pub mv_class0_bit: [[u16; 3]; 2], - pub mv_fr: [[u16; 5]; 2], - pub mv_class0_fr: [[[u16; 5]; 2]; 2], - pub mv_sign: [[u16; 3]; 2], - pub mv_bit: [[[u16; 3]; 10]; 2], - pub intra_tx_type_set2: [[[u16; 6]; 13]; 3], - pub inter_tx_type_set3: [[u16; 3]; 4], - pub txb_skip: [[[u16; 3]; 13]; 5], - pub eob_pt_16: [[[u16; 6]; 2]; 2], - pub eob_pt_32: [[[u16; 7]; 2]; 2], - pub eob_pt_64: [[[u16; 8]; 2]; 2], - pub eob_pt_128: [[[u16; 9]; 2]; 2], - pub eob_pt_256: [[[u16; 10]; 2]; 2], - pub eob_pt_512: [[u16; 11]; 2], - pub eob_pt_1024: [[u16; 12]; 2], - pub eob_extra: [[[[u16; 3]; 9]; 2]; 5], - pub dc_sign: [[[u16; 3]; 3]; 2], - pub coeff_base_eob: [[[[u16; 4]; 4]; 2]; 5], - pub coeff_base: [[[[u16; 5]; 42]; 2]; 5], - pub coeff_br: [[[[u16; 5]; 21]; 2]; 5], -} - -impl Cdfs { - /// init_non_coeff_cdfs( ) followed by init_coeff_cdfs( ) for `base_q_idx`. - pub fn new(base_q_idx: u32) -> Box { - let idx = match base_q_idx { - 0..=20 => 0, - 21..=60 => 1, - 61..=120 => 2, - _ => 3, - }; - Box::new(Cdfs { - y_mode: DEFAULT_Y_MODE_CDF, - uv_mode_cfl_not_allowed: DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, - uv_mode_cfl_allowed: DEFAULT_UV_MODE_CFL_ALLOWED_CDF, - angle_delta: DEFAULT_ANGLE_DELTA_CDF, - partition_w8: DEFAULT_PARTITION_W8_CDF, - partition_w16: DEFAULT_PARTITION_W16_CDF, - partition_w32: DEFAULT_PARTITION_W32_CDF, - partition_w64: DEFAULT_PARTITION_W64_CDF, - skip: DEFAULT_SKIP_CDF, - is_inter: DEFAULT_IS_INTER_CDF, - single_ref: DEFAULT_SINGLE_REF_CDF, - new_mv: DEFAULT_NEW_MV_CDF, - zero_mv: DEFAULT_ZERO_MV_CDF, - ref_mv: DEFAULT_REF_MV_CDF, - drl_mode: DEFAULT_DRL_MODE_CDF, - mv_joint: DEFAULT_MV_JOINT_CDF, - mv_class: DEFAULT_MV_CLASS_CDF, - mv_class0_bit: [DEFAULT_MV_CLASS0_BIT_CDF; 2], - mv_fr: DEFAULT_MV_FR_CDF, - mv_class0_fr: DEFAULT_MV_CLASS0_FR_CDF, - mv_sign: [DEFAULT_MV_SIGN_CDF; 2], - mv_bit: [DEFAULT_MV_BIT_CDF; 2], - intra_tx_type_set2: DEFAULT_INTRA_TX_TYPE_SET2_CDF, - inter_tx_type_set3: DEFAULT_INTER_TX_TYPE_SET3_CDF, - txb_skip: DEFAULT_TXB_SKIP_CDF[idx], - eob_pt_16: DEFAULT_EOB_PT_16_CDF[idx], - eob_pt_32: DEFAULT_EOB_PT_32_CDF[idx], - eob_pt_64: DEFAULT_EOB_PT_64_CDF[idx], - eob_pt_128: DEFAULT_EOB_PT_128_CDF[idx], - eob_pt_256: DEFAULT_EOB_PT_256_CDF[idx], - eob_pt_512: DEFAULT_EOB_PT_512_CDF[idx], - eob_pt_1024: DEFAULT_EOB_PT_1024_CDF[idx], - eob_extra: DEFAULT_EOB_EXTRA_CDF[idx], - dc_sign: DEFAULT_DC_SIGN_CDF[idx], - coeff_base_eob: DEFAULT_COEFF_BASE_EOB_CDF[idx], - coeff_base: DEFAULT_COEFF_BASE_CDF[idx], - coeff_br: DEFAULT_COEFF_BR_CDF[idx], - }) - } -} - -impl Cdfs { - /// The counter reset of load_cdfs( ): the last entry of every CDF array is set to 0. - pub fn clear_counts(&mut self) { - macro_rules! clr { - ($($f:ident),*) => { $( self.$f.clear_count(); )* }; - } - clr!( - y_mode, - uv_mode_cfl_not_allowed, - uv_mode_cfl_allowed, - angle_delta, - partition_w8, - partition_w16, - partition_w32, - partition_w64, - skip, - is_inter, - single_ref, - new_mv, - zero_mv, - ref_mv, - drl_mode, - mv_joint, - mv_class, - mv_class0_bit, - mv_fr, - mv_class0_fr, - mv_sign, - mv_bit, - intra_tx_type_set2, - inter_tx_type_set3, - txb_skip, - eob_pt_16, - eob_pt_32, - eob_pt_64, - eob_pt_128, - eob_pt_256, - eob_pt_512, - eob_pt_1024, - eob_extra, - dc_sign, - coeff_base_eob, - coeff_base, - coeff_br - ); - } -} - -/// Zeroes the adaptation counter (last element) of every innermost CDF array. -trait ClearCount { - fn clear_count(&mut self); -} - -impl ClearCount for [u16; N] { - fn clear_count(&mut self) { - self[N - 1] = 0; - } -} - -impl ClearCount for [T; M] { - fn clear_count(&mut self) { - for e in self.iter_mut() { - e.clear_count(); - } - } -} diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs deleted file mode 100644 index b06852809..000000000 --- a/crates/av1enc/src/decide.rs +++ /dev/null @@ -1,1273 +0,0 @@ -//! Mode decision and reconstruction (not normative): partitions, intra modes, motion vectors, -//! transform coefficients. Every prediction and reconstruction step uses the normative -//! processes, so the reconstruction built here is exactly what a decoder produces; the syntax -//! is written afterwards by `writer.rs`. - -use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra}; -use crate::tables::{ - AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, - NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, - TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, -}; -use crate::transform::{forward_2d, inverse_2d}; - -/// Decisions for one coded block. -#[derive(Clone)] -pub(crate) struct Leaf { - pub bsize: usize, - pub is_inter: bool, - pub y_mode: usize, - pub uv_mode: usize, - pub tx_type_y: usize, - /// Motion vector [row, col] in 1/8 luma samples (inter blocks). - pub mv: [i32; 2], - pub skip: bool, - /// Quantised coefficients of the single transform block of each plane (raster order). - pub coefs: [Vec; 3], -} - -pub(crate) enum Node { - Out, - Leaf(Box), - Split(Box<[Node; 4]>), -} - -/// A reconstructed frame kept as a reference. -pub(crate) struct RefFrame { - pub planes: [Plane; 3], - /// Motion vectors chosen for the frame (per 4x4), used as search candidates. - pub mvs: Vec<[i32; 2]>, - /// The frame's loop filter level. - pub lf_level: u32, - /// The CDFs saved at the end of the frame (loaded by the next frame). - pub cdfs: Box, -} - -pub(crate) struct FrameGeom { - pub width: usize, - pub height: usize, - pub mi_cols: usize, - pub mi_rows: usize, - pub sb_cols: usize, - pub sb_rows: usize, -} - -impl FrameGeom { - pub fn new(width: usize, height: usize) -> FrameGeom { - let mi_cols = 2 * width.div_ceil(8); - let mi_rows = 2 * height.div_ceil(8); - FrameGeom { width, height, mi_cols, mi_rows, sb_cols: mi_cols.div_ceil(16), sb_rows: mi_rows.div_ceil(16) } - } - /// Padded plane size (whole superblocks). - pub fn padded(&self, plane: usize) -> (usize, usize) { - let s = (plane > 0) as usize; - ((self.sb_cols * 64) >> s, (self.sb_rows * 64) >> s) - } - /// Visible plane size. - pub fn visible(&self, plane: usize) -> (usize, usize) { - if plane == 0 { (self.width, self.height) } else { (self.width.div_ceil(2), self.height.div_ceil(2)) } - } -} - -pub(crate) fn tx_size_for(log2: u32) -> usize { - match log2 { - 2 => TX_4X4, - 3 => TX_8X8, - 4 => TX_16X16, - 5 => TX_32X32, - _ => TX_64X64, - } -} - -/// get_tx_set( ) for the square sizes used here with reduced_tx_set = 1. -pub(crate) fn tx_set(tx_sz: usize, is_inter: bool) -> usize { - let sqr_up = TX_SIZE_SQR_UP[tx_sz] as usize; - if sqr_up > TX_32X32 { - return TX_SET_DCTONLY; - } - if is_inter { - TX_SET_INTER_3 - } else if sqr_up == TX_32X32 { - TX_SET_DCTONLY - } else { - TX_SET_INTRA_2 - } -} - -pub(crate) fn tx_in_set(set: usize, is_inter: bool, tx_type: usize) -> bool { - if is_inter { TX_TYPE_IN_SET_INTER[set][tx_type] == 1 } else { TX_TYPE_IN_SET_INTRA[set][tx_type] == 1 } -} - -/// The chroma transform type of an intra block (compute_tx_type for plane > 0). -pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize { - let t = MODE_TO_TXFM[uv_mode] as usize; - if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT } -} - -/// Geometry and coding mode of one square transform block. -#[derive(Clone, Copy)] -struct TransformBlock { - plane: usize, - x: usize, - y: usize, - log2: u32, - tx_type: usize, - intra: bool, -} - -struct TxResult { - levels: Vec, - nonzero: bool, - dist: f64, - bits: f64, -} - -/// The output and scratch buffers of one reconstruction, each `n * n` entries long. -struct ReconBuffers<'a> { - deq: &'a mut [i32], - resid: &'a mut [i32], - recon: &'a mut [u16], -} - -/// Outcome of trying one transform block; the levels and the reconstruction stay in `TxScratch`. -#[derive(Clone, Copy)] -struct TxEval { - nonzero: bool, - dist: f64, - bits: f64, -} - -/// Buffers of the transform path, sized for the largest coded transform block (32x32). Only the -/// first `n * n` entries are used; they are reused across candidates instead of being allocated. -struct TxScratch { - res: [i32; 1024], - coef: [f32; 1024], - levels: [i32; 1024], - deq: [i32; 1024], - resid: [i32; 1024], - recon: [u16; 1024], -} - -impl Default for TxScratch { - fn default() -> Self { - TxScratch { res: [0; 1024], coef: [0.0; 1024], levels: [0; 1024], deq: [0; 1024], resid: [0; 1024], recon: [0; 1024] } - } -} - -/// Saved encoder state of a block area (reconstruction, BlockDecoded flags). -struct Snapshot { - planes: [Vec; 3], - /// BlockDecoded flags of the block area only (the only flags coding inside it can change). - decoded: [[[bool; 16]; 16]; 3], -} - -pub(crate) struct Decider<'a> { - pub g: &'a FrameGeom, - pub bd: u32, - pub intra_only: bool, - pub src: &'a [Plane; 3], - pub rec: [Plane; 3], - pub refr: Option<&'a RefFrame>, - decoded: Box<[[[bool; 35]; 35]; 3]>, - /// Decision motion vectors per 4x4 (search candidates). - pub mvs: Vec<[i32; 2]>, - dc_q: f32, - ac_q: f32, - dc_qi: i64, - ac_qi: i64, - /// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`. - dead_zone: [[f32; 2]; 2], - lambda: f64, - lambda_sad: f64, - /// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8). - half: [Vec; 3], - /// Transform buffers, taken out while a block is being tried (see `code_tx`). - scratch: Option>, -} - -const MODE_BITS_INTRA: f64 = 3.0; -/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks. -const INTER_RND: f32 = 0.30; -const INTRA_RND: f32 = 0.38; - -impl<'a> Decider<'a> { - pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self { - let bdi = ((bd - 8) >> 1) as usize; - let dc_qi = DC_QLOOKUP[bdi][qidx as usize] as i64; - let ac_qi = AC_QLOOKUP[bdi][qidx as usize] as i64; - // Lagrangian multiplier in squared-sample units of the coded bit depth. - let step = ac_qi as f64 / 8.0; - let lambda = 0.25 * step * step; - let rec = std::array::from_fn(|p| { - let (w, h) = g.padded(p); - Plane::new(w, h) - }); - Decider { - g, - bd, - intra_only, - src, - rec, - refr, - decoded: Box::new([[[false; 35]; 35]; 3]), - mvs: vec![[0, 0]; g.mi_cols * g.mi_rows], - dc_q: dc_qi as f32, - ac_q: ac_qi as f32, - dc_qi, - ac_qi, - dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))), - lambda, - lambda_sad: lambda.sqrt(), - half: match refr { - Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd), - None => Default::default(), - }, - scratch: None, - } - } - - /// Decides one 64x64 superblock (reconstruction is written to `rec`). - pub fn superblock(&mut self, r: usize, c: usize) -> Node { - // clear_block_decoded_flags( r, c, sbSize4 ) - for plane in 0..3 { - let s = (plane > 0) as usize; - let sb_w4 = (self.g.mi_cols as isize - c as isize) >> s; - let sb_h4 = (self.g.mi_rows as isize - r as isize) >> s; - let bd = &mut self.decoded[plane]; - for y in -1..=((16 >> s) as isize) { - for x in -1..=((16 >> s) as isize) { - let v = if y < 0 && x < sb_w4 { true } else { x < 0 && y < sb_h4 }; - bd[(y + 1) as usize][(x + 1) as usize] = v; - } - } - bd[(16 >> s) + 1][0] = false; - } - self.partition(r, c, BLOCK_64X64, None).0 - } - - fn decoded_at(&self, plane: usize, y: isize, x: isize) -> bool { - let (yy, xx) = (y + 1, x + 1); - if yy < 0 || xx < 0 || yy > 34 || xx > 34 { - return false; - } - self.decoded[plane][yy as usize][xx as usize] - } - - /// Marks the transform block of `plane` covering the block at (r, c) as decoded. - fn mark_decoded(&mut self, plane: usize, r: usize, c: usize, n4: usize) { - let s = (plane > 0) as usize; - let by = (r & 15) >> s; - let bx = (c & 15) >> s; - let step = (n4 >> s).max(1); - for i in 0..step { - for j in 0..step { - if by + i + 1 < 35 && bx + j + 1 < 35 { - self.decoded[plane][by + i + 1][bx + j + 1] = true; - } - } - } - } - - /// The BlockDecoded cells `mark_decoded` can set for blocks inside (r, c, n4): rows and - /// columns `by + 1 .. by + 1 + step` / `bx + 1 ..` of `decoded[plane]`. - fn decoded_region(plane: usize, r: usize, c: usize, n4: usize) -> (usize, usize, usize) { - let s = (plane > 0) as usize; - ((r & 15) >> s, (c & 15) >> s, (n4 >> s).max(1)) - } - - fn snapshot(&self, r: usize, c: usize, n4: usize) -> Snapshot { - let planes = std::array::from_fn(|p| { - let s = (p > 0) as usize; - let (x0, y0, n) = ((c * 4) >> s, (r * 4) >> s, (n4 * 4) >> s); - let pl = &self.rec[p]; - let mut v = Vec::with_capacity(n * n); - for y in y0..y0 + n { - v.extend_from_slice(&pl.data[y * pl.stride + x0..y * pl.stride + x0 + n]); - } - v - }); - let mut decoded = [[[false; 16]; 16]; 3]; - for (p, d) in decoded.iter_mut().enumerate() { - let (by, bx, step) = Self::decoded_region(p, r, c, n4); - for i in 0..step { - d[i][..step].copy_from_slice(&self.decoded[p][by + i + 1][bx + 1..bx + 1 + step]); - } - } - Snapshot { planes, decoded } - } - - fn restore(&mut self, snap: &Snapshot, r: usize, c: usize, n4: usize) { - for p in 0..3 { - let s = (p > 0) as usize; - let (x0, y0, n) = ((c * 4) >> s, (r * 4) >> s, (n4 * 4) >> s); - let pl = &mut self.rec[p]; - for (i, y) in (y0..y0 + n).enumerate() { - pl.data[y * pl.stride + x0..y * pl.stride + x0 + n].copy_from_slice(&snap.planes[p][i * n..i * n + n]); - } - } - for p in 0..3 { - let (by, bx, step) = Self::decoded_region(p, r, c, n4); - for i in 0..step { - self.decoded[p][by + i + 1][bx + 1..bx + 1 + step].copy_from_slice(&snap.decoded[p][i][..step]); - } - } - } - - fn set_mvs(&mut self, r: usize, c: usize, n4: usize, mv: [i32; 2]) { - for y in r..(r + n4).min(self.g.mi_rows) { - for x in c..(c + n4).min(self.g.mi_cols) { - self.mvs[y * self.g.mi_cols + x] = mv; - } - } - } - - fn partition(&mut self, r: usize, c: usize, bsize: usize, parent_mv: Option<[i32; 2]>) -> (Node, f64) { - let g = self.g; - if r >= g.mi_rows || c >= g.mi_cols { - return (Node::Out, 0.0); - } - let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize; - let half = n4 >> 1; - let has_rows = r + half < g.mi_rows; - let has_cols = c + half < g.mi_cols; - let can_none = has_rows && has_cols && (bsize != BLOCK_64X64 || !self.intra_only); - let can_split = bsize > BLOCK_8X8; - let sub = PARTITION_SUBSIZE[PARTITION_SPLIT][bsize] as usize; - if !can_none { - return self.split(r, c, sub, half, parent_mv); - } - let before = self.snapshot(r, c, n4); - let (leaf, cost_none) = self.leaf(r, c, bsize, parent_mv); - let cost_none = cost_none + self.lambda * 1.0; - if !can_split { - return (Node::Leaf(Box::new(leaf)), cost_none); - } - // a cheap skip block rarely gains from splitting - if leaf.skip && cost_none < self.lambda * 8.0 { - return (Node::Leaf(Box::new(leaf)), cost_none); - } - let after_none = self.snapshot(r, c, n4); - self.restore(&before, r, c, n4); - let pm = if leaf.is_inter { Some(leaf.mv) } else { parent_mv }; - let (node, cost_split) = self.split(r, c, sub, half, pm); - let cost_split = cost_split + self.lambda * 2.0; - if cost_split < cost_none { - (node, cost_split) - } else { - self.restore(&after_none, r, c, n4); - self.set_mvs(r, c, n4, if leaf.is_inter { leaf.mv } else { [0, 0] }); - (Node::Leaf(Box::new(leaf)), cost_none) - } - } - - fn split(&mut self, r: usize, c: usize, sub: usize, half: usize, pm: Option<[i32; 2]>) -> (Node, f64) { - let (n0, c0) = self.partition(r, c, sub, pm); - let (n1, c1) = self.partition(r, c + half, sub, pm); - let (n2, c2) = self.partition(r + half, c, sub, pm); - let (n3, c3) = self.partition(r + half, c + half, sub, pm); - (Node::Split(Box::new([n0, n1, n2, n3])), c0 + c1 + c2 + c3) - } - - /// Best coding of the block as a single (NONE partition) block. - fn leaf(&mut self, r: usize, c: usize, bsize: usize, parent_mv: Option<[i32; 2]>) -> (Leaf, f64) { - let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize; - if self.intra_only { - let res = self.intra_leaf(r, c, bsize); - self.set_mvs(r, c, n4, [0, 0]); - return res; - } - let before = self.snapshot(r, c, n4); - let (inter, cost_inter) = self.inter_leaf(r, c, bsize, parent_mv); - if bsize == BLOCK_64X64 || inter.skip { - self.set_mvs(r, c, n4, inter.mv); - return (inter, cost_inter); - } - let after_inter = self.snapshot(r, c, n4); - self.restore(&before, r, c, n4); - let (intra, cost_intra) = self.intra_leaf(r, c, bsize); - if cost_intra < cost_inter { - self.set_mvs(r, c, n4, [0, 0]); - (intra, cost_intra) - } else { - self.restore(&after_inter, r, c, n4); - self.set_mvs(r, c, n4, inter.mv); - (inter, cost_inter) - } - } - - fn intra_leaf(&mut self, r: usize, c: usize, bsize: usize) -> (Leaf, f64) { - let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize; - let log2 = (n4 * 4).trailing_zeros(); - let (sbr, sbc) = ((r & 15) as isize, (c & 15) as isize); - let bd = self.bd; - let mut pred = [0u16; 32 * 32]; - let mut best_pred = [0u16; 32 * 32]; - let mut best_levels = [0i32; 32 * 32]; - let mut best_recon = [0u16; 32 * 32]; - let n = 1usize << log2; - // luma - let e = IntraEdge { - x: c * 4, - y: r * 4, - have_left: c > 0, - have_above: r > 0, - have_above_right: self.decoded_at(0, sbr - 1, sbc + n4 as isize), - have_below_left: self.decoded_at(0, sbr + n4 as isize, sbc - 1), - log2, - max_x: (self.g.mi_cols * 4) as i32 - 1, - max_y: (self.g.mi_rows * 4) as i32 - 1, - bit_depth: bd, - }; - let ed = edges(&self.rec[0], &e); - // modes ranked by SATD, then a rate-distortion choice of mode and transform type among - // the best three - let mut ranked: [(f64, usize); INTRA_MODES_USED.len()] = INTRA_MODES_USED.map(|mode| { - predict_intra(&ed, &e, mode, &mut pred); - let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64; - (sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode) - }); - ranked.sort_by(|a, b| a.0.total_cmp(&b.0)); - let tx_y = tx_size_for(log2); - let mut best: Option<(f64, usize, usize, TxEval, bool)> = None; - let mut sc = self.scratch.take().unwrap_or_default(); - for &(_, mode) in ranked.iter().take(3) { - predict_intra(&ed, &e, mode, &mut pred); - let mut types = [DCT_DCT; 2]; - let mut n_types = 1; - let alt = MODE_TO_TXFM[mode] as usize; - if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) { - types[1] = alt; - n_types = 2; - } - for &t in &types[..n_types] { - let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]); - let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY { - 0.0 - } else if t == DCT_DCT { - 1.0 - } else { - 2.0 - }; - let mode_bits = if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }; - let cost = res.dist + self.lambda * (res.bits + type_bits + mode_bits); - if best.as_ref().is_none_or(|b| cost < b.0) { - best_pred[..n * n].copy_from_slice(&pred[..n * n]); - best_levels[..n * n].copy_from_slice(&sc.levels[..n * n]); - if has_recon { - best_recon[..n * n].copy_from_slice(&sc.recon[..n * n]); - } - best = Some((cost, mode, t, res, has_recon)); - } - } - } - self.scratch = Some(sc); - // Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block - // can't be coded intra (an infinite cost keeps it from being chosen). - let Some((_, y_mode, tx_type_y, ty, has_recon)) = best else { - let leaf = - Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() }; - return (leaf, f64::INFINITY); - }; - self.put(0, c * 4, r * 4, n, if has_recon { &best_recon[..n * n] } else { &best_pred[..n * n] }); - let ty = TxResult { levels: best_levels[..n * n].to_vec(), nonzero: ty.nonzero, dist: ty.dist, bits: ty.bits }; - let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT }; - self.mark_decoded(0, r, c, n4); - // chroma (one mode for both planes) - let cn = n >> 1; - let clog2 = log2 - 1; - let (csbr, csbc) = (sbr >> 1, sbc >> 1); - let ce: [IntraEdge; 2] = std::array::from_fn(|i| IntraEdge { - x: c * 2, - y: r * 2, - have_left: c > 0, - have_above: r > 0, - have_above_right: self.decoded_at(1 + i, csbr - 1, csbc + (n4 >> 1) as isize), - have_below_left: self.decoded_at(1 + i, csbr + (n4 >> 1) as isize, csbc - 1), - log2: clog2, - max_x: (self.g.mi_cols * 2) as i32 - 1, - max_y: (self.g.mi_rows * 2) as i32 - 1, - bit_depth: bd, - }); - let ced = [edges(&self.rec[1], &ce[0]), edges(&self.rec[2], &ce[1])]; - let mut best_uv = (f64::MAX, DC_PRED); - for &mode in INTRA_MODES_USED.iter() { - let mut sad = 0u64; - for i in 0..2 { - predict_intra(&ced[i], &ce[i], mode, &mut pred); - sad += self.satd(1 + i, c * 2, r * 2, cn, &pred[..cn * cn]); - } - let cost = sad as f64 + self.lambda_sad * if mode == DC_PRED { 1.0 } else { 2.5 }; - if cost < best_uv.0 { - best_uv = (cost, mode); - } - } - let uv_mode = best_uv.1; - let uv_tx = tx_size_for(clog2); - let uv_type = intra_uv_tx_type(uv_mode, uv_tx); - let mut code_uv = |i: usize| { - predict_intra(&ced[i], &ce[i], uv_mode, &mut pred); - let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]); - self.mark_decoded(1 + i, r, c, n4); - t - }; - let u = code_uv(0); - let v = code_uv(1); - let skip = !ty.nonzero && !u.nonzero && !v.nonzero; - let dist = ty.dist + u.dist + v.dist; - let bits = if skip { 1.0 } else { ty.bits + u.bits + v.bits + 1.0 } + MODE_BITS_INTRA + 2.0; - let leaf = Leaf { bsize, is_inter: false, y_mode, uv_mode, tx_type_y, mv: [0, 0], skip, coefs: [ty.levels, u.levels, v.levels] }; - (leaf, dist + self.lambda * bits) - } - - fn inter_leaf(&mut self, r: usize, c: usize, bsize: usize, parent_mv: Option<[i32; 2]>) -> (Leaf, f64) { - let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize; - let n = n4 * 4; - let log2 = n.trailing_zeros(); - // Inter blocks are only decided with a reference frame; without one, code intra. - let Some(refr) = self.refr else { return self.intra_leaf(r, c, bsize) }; - let (mv, mv_bits) = self.motion_search(r, c, n, parent_mv); - let mut pred = vec![0u16; n * n]; - let mut tx = Vec::with_capacity(3); - let mut dist = 0.0; - for p in 0..3 { - let s = (p > 0) as usize; - let (vw, vh) = self.g.visible(p); - let pn = n >> s; - let (x, y) = ((c * 4) >> s, (r * 4) >> s); - predict_inter( - &refr.planes[p], - &InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd }, - &mut pred[..pn * pn], - ); - if bsize == BLOCK_64X64 { - // coded as a skip block: the prediction is the reconstruction - dist += self.sse(p, x, y, pn, &pred[..pn * pn]); - self.put(p, x, y, pn, &pred[..pn * pn]); - } else { - let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]); - dist += t.dist; - tx.push(t); - } - self.mark_decoded(p, r, c, n4); - } - let mode_bits = if mv == [0, 0] { 1.5 } else { 2.0 + mv_bits }; - if bsize == BLOCK_64X64 { - let leaf = Leaf { bsize, is_inter: true, y_mode: NEWMV, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv, skip: true, coefs: Default::default() }; - return (leaf, dist + self.lambda * (mode_bits + 1.0)); - } - let skip = tx.iter().all(|t| !t.nonzero); - let bits = mode_bits + if skip { 1.0 } else { 1.0 + tx.iter().map(|t| t.bits).sum::() }; - let [y, u, v]: [TxResult; 3] = match tx.try_into() { - Ok(planes) => planes, - // One transform block per plane was coded above. - Err(_) => { - return ( - Leaf { bsize, is_inter: true, y_mode: NEWMV, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv, skip: true, coefs: Default::default() }, - f64::INFINITY, - ); - } - }; - let leaf = Leaf { bsize, is_inter: true, y_mode: NEWMV, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv, skip, coefs: [y.levels, u.levels, v.levels] }; - (leaf, dist + self.lambda * bits) - } - - /// Full-sample motion search: candidate vectors then a shrinking diamond search. - /// Returns the vector and an estimate of its coding cost in bits. - fn motion_search(&self, r: usize, c: usize, n: usize, parent_mv: Option<[i32; 2]>) -> ([i32; 2], f64) { - let g = self.g; - let Some(refr) = self.refr else { return ([0, 0], 0.0) }; - let mi = |rr: usize, cc: usize| self.mvs[rr * g.mi_cols + cc]; - let mut pred_mv = [0, 0]; - let mut cands: Vec<[i32; 2]> = vec![[0, 0]]; - if c > 0 { - pred_mv = mi(r, c - 1); - cands.push(pred_mv); - } - if r > 0 { - cands.push(mi(r - 1, c)); - let cr = c + n / 4; - if cr < g.mi_cols { - cands.push(mi(r - 1, cr)); - } - } - if let Some(p) = parent_mv { - cands.push(p); - } - cands.push(refr.mvs[r * g.mi_cols + c]); - let (x0, y0) = ((c * 4) as i32, (r * 4) as i32); - let (w, h) = (g.width as i32, g.height as i32); - // keep the referenced block within 64 samples of the picture - let lim_x = (-x0 - n as i32 - 64, w - x0 + 64); - let lim_y = (-y0 - n as i32 - 64, h - y0 + 64); - let valid = |m: [i32; 2]| { - let (dy, dx) = (m[0] >> 3, m[1] >> 3); - dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1 - }; - let lg = log2_table(); - let mv_bits = |m: [i32; 2]| -> f64 { - let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) }; - comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1]) - }; - // `bound` is the cost to beat: the SAD stops early once the cost cannot be below it (the - // callers only accept a strictly lower cost, so the result is unchanged) - let cost = |m: [i32; 2], bound: f64| -> f64 { - let lb = self.lambda_sad * mv_bits(m); - self.sad_ref(&refr.planes[0], [x0 + (m[1] >> 3), y0 + (m[0] >> 3)], [c * 4, r * 4], n, [lb, bound]) as f64 + lb - }; - // positions already evaluated cannot beat `best` again (costs are deterministic) - let mut seen: Vec<[i32; 2]> = Vec::with_capacity(64); - let mut best = ([0, 0], f64::MAX); - for &m in &cands { - let m = [m[0] & !7, m[1] & !7]; - if !valid(m) || seen.contains(&m) { - continue; - } - seen.push(m); - let cst = cost(m, best.1); - if cst < best.1 { - best = (m, cst); - } - } - let mut step = 8 * if n >= 32 { - 16 - } else if n == 16 { - 8 - } else { - 4 - }; - while step >= 8 { - let mut moved = true; - let mut iters = 0; - while moved && iters < 16 { - moved = false; - iters += 1; - let center = best.0; - for d in [[-step, 0], [step, 0], [0, -step], [0, step]] { - let m = [center[0] + d[0], center[1] + d[1]]; - if !valid(m) || seen.contains(&m) { - continue; - } - seen.push(m); - let cst = cost(m, best.1); - if cst < best.1 { - best = (m, cst); - moved = true; - } - } - } - step >>= 1; - } - // half- then quarter-sample refinement (plus the exact neighbour candidates) - let mut buf = vec![0u16; n * n]; - let sub_cost = |m: [i32; 2], buf: &mut Vec, bound: f64| -> f64 { - self.search_block(m, c * 4, r * 4, n, buf); - let lb = self.lambda_sad * mv_bits(m); - self.sad_bounded(0, [c * 4, r * 4], n, buf, [lb, bound]) as f64 + lb - }; - let mut seen_sub: Vec<[i32; 2]> = Vec::with_capacity(24); - for m in cands { - if m[0] & 7 != 0 || m[1] & 7 != 0 { - let m = [m[0] & !1, m[1] & !1]; - if valid(m) && !seen_sub.contains(&m) { - seen_sub.push(m); - let cst = sub_cost(m, &mut buf, best.1); - if cst < best.1 { - best = (m, cst); - } - } - } - } - for step in [4, 2] { - let center = best.0; - for d in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]] { - let m = [center[0] + d[0] * step, center[1] + d[1] * step]; - if !valid(m) || seen_sub.contains(&m) { - continue; - } - seen_sub.push(m); - let cst = sub_cost(m, &mut buf, best.1); - if cst < best.1 { - best = (m, cst); - } - } - } - (best.0, mv_bits(best.0)) - } - - /// Approximate luma prediction for motion search: exact at whole and half-sample - /// positions (precomputed planes), averaged neighbours at quarter-sample positions. - fn search_block(&self, mv: [i32; 2], x: usize, y: usize, n: usize, out: &mut [u16]) { - let Some(refr) = self.refr else { return }; - let (w, h) = (self.g.width as i32, self.g.height as i32); - // per axis: up to two (integer offset, half) taps - let axis = |v: i32| -> ([(i32, bool); 2], usize) { - let p = 2 * v; // 1/16 units - let (i, f) = (p >> 4, p & 15); - match f { - 0 => ([(i, false), (i, false)], 1), - 8 => ([(i, true), (i, true)], 1), - 4 => ([(i, false), (i, true)], 2), - _ => ([(i, true), (i + 1, false)], 2), - } - }; - let (ax, nx) = axis(mv[1]); - let (ay, ny) = axis(mv[0]); - let taps = nx.max(ny); - let fetch = |k: usize, xx: i32, yy: i32| -> u16 { - let (ox, hx) = ax[k.min(nx - 1)]; - let (oy, hy) = ay[k.min(ny - 1)]; - let sx = (xx + ox).clamp(0, w - 1) as usize; - let sy = (yy + oy).clamp(0, h - 1) as usize; - match (hx, hy) { - (false, false) => refr.planes[0].at(sx, sy), - (true, false) => self.half[0][sy * w as usize + sx], - (false, true) => self.half[1][sy * w as usize + sx], - (true, true) => self.half[2][sy * w as usize + sx], - } - }; - let vw = n.min(self.g.width.saturating_sub(x)); - let vh = n.min(self.g.height.saturating_sub(y)); - // fast path: every tap inside the picture - let (x0, y0) = (x as i32, y as i32); - let inside = (0..taps).all(|k| { - let (ox, _) = ax[k.min(nx - 1)]; - let (oy, _) = ay[k.min(ny - 1)]; - x0 + ox >= 0 && y0 + oy >= 0 && x0 + ox + vw as i32 <= w && y0 + oy + vh as i32 <= h - }); - if inside { - let src = |k: usize| -> (&[u16], usize, usize) { - let (ox, hx) = ax[k.min(nx - 1)]; - let (oy, hy) = ay[k.min(ny - 1)]; - let (sx, sy) = ((x0 + ox) as usize, (y0 + oy) as usize); - match (hx, hy) { - (false, false) => (&refr.planes[0].data, refr.planes[0].stride, sy * refr.planes[0].stride + sx), - (true, false) => (&self.half[0], w as usize, sy * w as usize + sx), - (false, true) => (&self.half[1], w as usize, sy * w as usize + sx), - (true, true) => (&self.half[2], w as usize, sy * w as usize + sx), - } - }; - let (a, sa, oa) = src(0); - if taps == 1 { - for i in 0..vh { - out[i * n..i * n + vw].copy_from_slice(&a[oa + i * sa..oa + i * sa + vw]); - } - } else { - let (b, sb, ob) = src(1); - for i in 0..vh { - let (ra, rb) = (&a[oa + i * sa..oa + i * sa + vw], &b[ob + i * sb..ob + i * sb + vw]); - for (o, (&p, &q)) in out[i * n..i * n + vw].iter_mut().zip(ra.iter().zip(rb)) { - *o = ((p as u32 + q as u32 + 1) >> 1) as u16; - } - } - } - return; - } - for i in 0..vh { - for j in 0..vw { - let (xx, yy) = (x0 + j as i32, y0 + i as i32); - out[i * n + j] = if taps == 1 { fetch(0, xx, yy) } else { ((fetch(0, xx, yy) as u32 + fetch(1, xx, yy) as u32 + 1) >> 1) as u16 }; - } - } - } - - /// SAD of the source block at (x, y) (luma) against the reference block at (rx, ry). - /// Stops after a row once `sad + lb >= bound` (the cost can no longer win). - fn sad_ref(&self, refp: &Plane, [rx, ry]: [i32; 2], [x, y]: [usize; 2], n: usize, [lb, bound]: [f64; 2]) -> u64 { - let (vw, vh) = (self.g.width, self.g.height); - let w = n.min(vw.saturating_sub(x)); - let h = n.min(vh.saturating_sub(y)); - let src = &self.src[0]; - let (lx, ly) = (vw as i32 - 1, vh as i32 - 1); - let mut sad = 0u64; - let inside = rx >= 0 && ry >= 0 && rx + w as i32 - 1 <= lx && ry + h as i32 - 1 <= ly; - for i in 0..h { - let s = &src.row(y + i)[x..x + w]; - let rr = refp.row((ry + i as i32).clamp(0, ly) as usize); - if inside { - let rrow = &rr[rx as usize..rx as usize + w]; - sad += s.iter().zip(rrow).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).sum::() as u64; - } else { - for j in 0..w { - let b = rr[(rx + j as i32).clamp(0, lx) as usize]; - sad += (s[j] as i32 - b as i32).unsigned_abs() as u64; - } - } - if sad as f64 + lb >= bound { - return sad; - } - } - sad - } - - /// SAD of a prediction against the source over the visible part of the block. Stops after a row - /// once `sad + lb >= bound` (the cost can no longer win). - fn sad_bounded(&self, p: usize, [x, y]: [usize; 2], n: usize, pred: &[u16], [lb, bound]: [f64; 2]) -> u64 { - let (vw, vh) = self.g.visible(p); - let w = n.min(vw.saturating_sub(x)); - let h = n.min(vh.saturating_sub(y)); - let src = &self.src[p]; - let mut s = 0u64; - for i in 0..h { - let row = &src.row(y + i)[x..x + w]; - s += row.iter().zip(&pred[i * n..i * n + w]).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).sum::() as u64; - if s as f64 + lb >= bound { - return s; - } - } - s - } - - /// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction - /// against the source, scaled to about the size of the SAD. - fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { - if n >= 8 { self.satd_k::<8>(p, x, y, n, pred) } else { self.satd_k::<4>(p, x, y, n, pred) } - } - - /// `satd` with K x K Hadamard sub-blocks (fixed sizes so the butterflies vectorise). - fn satd_k(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { - let src = &self.src[p]; - let mut total = 0u64; - for by in (0..n).step_by(K) { - for bx in (0..n).step_by(K) { - // d[i] holds row i of the differences - let mut d = [[0i32; K]; K]; - for (i, di) in d.iter_mut().enumerate() { - let row = &src.row(y + by + i)[x + bx..x + bx + K]; - let pr = &pred[(by + i) * n + bx..(by + i) * n + bx + K]; - for j in 0..K { - di[j] = row[j] as i32 - pr[j] as i32; - } - } - // vertical butterflies across rows (all columns at once) - let mut h = 1; - while h < K { - for i0 in (0..K).step_by(2 * h) { - for i in i0..i0 + h { - let (a, b) = (d[i], d[i + h]); - for j in 0..K { - d[i][j] = a[j] + b[j]; - d[i + h][j] = a[j] - b[j]; - } - } - } - h *= 2; - } - // horizontal butterflies within each row - let mut s = 0u64; - for di in d.iter_mut() { - hadamard(di); - s += di.iter().map(|v| v.unsigned_abs() as u64).sum::(); - } - total += s / (K as u64 / 2); - } - } - total - } - - fn sse(&self, p: usize, x: usize, y: usize, n: usize, blk: &[u16]) -> f64 { - let (vw, vh) = self.g.visible(p); - let w = n.min(vw.saturating_sub(x)); - let h = n.min(vh.saturating_sub(y)); - let src = &self.src[p]; - let mut s = 0u64; - for i in 0..h { - let row = &src.row(y + i)[x..x + w]; - s += row - .iter() - .zip(&blk[i * n..i * n + w]) - .map(|(&a, &b)| { - let d = a as i64 - b as i64; - (d * d) as u64 - }) - .sum::(); - } - s as f64 - } - - fn put(&mut self, p: usize, x: usize, y: usize, n: usize, blk: &[u16]) { - let pl = &mut self.rec[p]; - for i in 0..n { - let o = (y + i) * pl.stride + x; - pl.data[o..o + n].copy_from_slice(&blk[i * n..i * n + n]); - } - } - - /// Transform, quantise and reconstruct one square transform block of `plane` at (x, y) - /// over the prediction `pred`; the reconstruction is written to `rec`. - fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult { - let TransformBlock { plane: p, x, y, log2, .. } = block; - let mut sc = self.scratch.take().unwrap_or_default(); - let (res, has_recon) = self.try_tx(&mut sc, block, pred); - let n = 1usize << log2; - self.put(p, x, y, n, if has_recon { &sc.recon[..n * n] } else { pred }); - let levels = sc.levels[..n * n].to_vec(); - self.scratch = Some(sc); - TxResult { levels, nonzero: res.nonzero, dist: res.dist, bits: res.bits } - } - - /// Transform and quantise one block without writing it. The levels are left in `sc.levels`; - /// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction). - fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) { - let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block; - let n = 1usize << log2; - let nn = n * n; - let tx_sz = tx_size_for(log2); - let bd = self.bd; - let src = &self.src[p]; - let TxScratch { res, coef, levels, deq, resid, recon } = sc; - let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]); - for i in 0..n { - let row = &src.row(y + i)[x..x + n]; - for j in 0..n { - res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32; - } - } - forward_2d(res, coef, n, tx_type); - // dead-zone quantiser: rounding offset below one half - let rnd = if intra { INTRA_RND } else { INTER_RND }; - let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]]; - levels.fill(0); - let mut nonzero = false; - for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() { - let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) }; - // dead zone: below the exact threshold the level is zero, no division needed - if cf.abs() < thr { - continue; - } - // the operand is >= 0, so truncation equals floor - let a = (cf.abs() / q + rnd) as i32; - if a != 0 { - *l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) }; - nonzero = true; - } - } - let zero_dist = self.sse(p, x, y, n, pred); - if !nonzero { - return (TxEval { nonzero, dist: zero_dist, bits: 1.0 }, false); - } - let recon = &mut recon[..nn]; - self.reconstruct(levels, ReconBuffers { deq: &mut deq[..nn], resid: &mut resid[..nn], recon }, tx_sz, tx_type, pred, bd); - let dist = self.sse(p, x, y, n, recon); - let bits = estimate_bits(levels, tx_sz); - if dist + self.lambda * bits >= zero_dist + self.lambda { - levels.fill(0); - return (TxEval { nonzero: false, dist: zero_dist, bits: 1.0 }, false); - } - (TxEval { nonzero: true, dist, bits }, true) - } - - /// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is - /// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries. - fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) { - let ReconBuffers { deq, resid, recon } = bufs; - let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 }; - let lim = 1i64 << (7 + bd); - deq.fill(0); - for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() { - if l == 0 { - continue; - } - let q = if k == 0 { self.dc_qi } else { self.ac_qi }; - let dq = l as i64 * q; - let mag = (dq.abs() & 0xFF_FFFF) / denom; - *d = (if dq < 0 { -mag } else { mag }).clamp(-lim, lim - 1) as i32; - } - inverse_2d(deq, resid, tx_sz, tx_type, bd); - let max = (1i32 << bd) - 1; - for ((o, &p), &r) in recon.iter_mut().zip(pred).zip(resid.iter()) { - *o = (p as i32 + r).clamp(0, max) as u16; - } - } -} - -/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is -/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary. -fn zero_threshold(q: f32, rnd: f32) -> f32 { - let (mut lo, mut hi) = (0u32, f32::MAX.to_bits()); - while lo < hi { - let mid = lo + (hi - lo) / 2; - if (f32::from_bits(mid) / q + rnd) as i32 >= 1 { - hi = mid; - } else { - lo = mid + 1; - } - } - f32::from_bits(lo) -} - -const LOG2_TABLE_LEN: usize = 1025; - -/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form. -fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] { - static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new(); - TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2())) -} - -fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 { - if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() } -} - -/// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place. -fn hadamard(v: &mut [i32]) { - let n = v.len(); - let mut h = 1; - while h < n { - for i in (0..n).step_by(2 * h) { - for j in i..i + h { - let (a, b) = (v[j], v[j + h]); - v[j] = a + b; - v[j + h] = a - b; - } - } - h *= 2; - } -} - -/// Rough coefficient coding cost in bits. -fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 { - let scan = scan_for(tx_sz); - let mut eob = 0; - for (i, &pos) in scan.iter().enumerate() { - if levels[pos as usize] != 0 { - eob = i + 1; - } - } - if eob == 0 { - return 1.0; - } - let lg = log2_table(); - let mut bits = 3.0 + log2_of(lg, eob); - for &pos in &scan[..eob] { - let a = levels[pos as usize].unsigned_abs(); - bits += match a { - 0 => 0.7, - 1 => 2.5, - _ => 3.5 + 1.6 * log2_of(lg, a as usize), - }; - } - bits -} - -pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] { - match tx_sz { - TX_4X4 => &DEFAULT_SCAN_4X4, - TX_8X8 => &DEFAULT_SCAN_8X8, - TX_16X16 => &DEFAULT_SCAN_16X16, - _ => &DEFAULT_SCAN_32X32, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tables::{AC_QLOOKUP, DC_QLOOKUP}; - - /// The quantiser as it was before the dead-zone threshold and the truncation. - fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 { - (cf.abs() / q + rnd).floor() as i32 - } - - fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 { - if cf.abs() < thr { - return 0; - } - (cf.abs() / q + rnd) as i32 - } - - #[test] - fn dead_zone_threshold_is_exact() { - let mut state = 0x1234_5678_9abc_def0u64; - let mut next = move || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) { - for &qi in table.iter() { - let q = qi as f32; - for rnd in [INTER_RND, INTRA_RND] { - let thr = zero_threshold(q, rnd); - // the boundary itself and its neighbours - let b = thr.to_bits(); - for bits in b.saturating_sub(3)..=b + 3 { - let cf = f32::from_bits(bits); - assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}"); - assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr)); - } - for _ in 0..200 { - let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff); - if cf.is_nan() { - continue; - } - assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); - let cf = (next() % 100_000) as f32 / 100.0; - assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); - } - } - } - } - } - - #[test] - fn log2_table_matches_direct_log2() { - let t = log2_table(); - for v in 1..3000usize { - assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}"); - } - } - - /// The SATD as it was before the fixed-size version: runtime block size, column gather. - fn satd_ref(src: &Plane, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { - let k = n.min(8); - let mut total = 0u64; - let mut d = [0i32; 64]; - for by in (0..n).step_by(k) { - for bx in (0..n).step_by(k) { - for i in 0..k { - let row = &src.row(y + by + i)[x + bx..x + bx + k]; - for j in 0..k { - d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32; - } - } - for i in 0..k { - hadamard(&mut d[i * k..i * k + k]); - } - let mut col = [0i32; 8]; - let mut s = 0u64; - for j in 0..k { - for i in 0..k { - col[i] = d[i * k + j]; - } - hadamard(&mut col[..k]); - s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::(); - } - total += s / (k as u64 / 2); - } - } - total - } - - #[test] - fn satd_fixed_size_matches_runtime_size() { - let g = FrameGeom::new(100, 80); - let mut state = 0x9e37_79b9_7f4a_7c15u64; - let mut next = move || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - for bd in [8u32, 10] { - let max = (1u64 << bd) - 1; - let mut mk = |w: usize, h: usize| { - let mut p = Plane::new(w, h); - p.data.iter_mut().for_each(|v| *v = (next() % (max + 1)) as u16); - p - }; - let src = [mk(128, 128), mk(64, 64), mk(64, 64)]; - let dec = Decider::new(&g, bd, 100, true, &src, None); - for n in [4usize, 8, 16, 32] { - let pred: Vec = (0..n * n).map(|_| (next() % (max + 1)) as u16).collect(); - for (p, x, y) in [(0, 0, 0), (0, 32, 40), (1, 8, 4)] { - assert_eq!(dec.satd(p, x, y, n, &pred), satd_ref(&src[p], x, y, n, &pred), "n {n} plane {p}"); - } - } - } - } - - fn xorshift(seed: u64) -> impl FnMut() -> u64 { - let mut state = seed; - move || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - } - } - - /// A snapshot keeps only the BlockDecoded cells of the block area. Coding inside the block - /// marks sub-blocks of every smaller size; restoring must give back the whole 3x35x35 array - /// as it was, for every block size of a 64x64 superblock and every aligned position. - #[test] - fn snapshot_restore_brings_back_every_blockdecoded_flag() { - let g = FrameGeom::new(256, 256); - let mut next = xorshift(0x2545_f491_4f6c_dd1d); - let src = [Plane::new(256, 256), Plane::new(128, 128), Plane::new(128, 128)]; - let mut dec = Decider::new(&g, 8, 100, true, &src, None); - for random_start in [false, true] { - for n4 in [1usize, 2, 4, 8, 16] { - for sb in [0usize, 16] { - for r in (sb..sb + 16).step_by(n4) { - for c in (sb..sb + 16).step_by(n4) { - for plane in dec.decoded.iter_mut() { - for row in plane.iter_mut() { - for v in row.iter_mut() { - *v = random_start && next() & 1 == 1; - } - } - } - let before = dec.decoded.clone(); - let snap = dec.snapshot(r, c, n4); - // every sub-block of every size inside the block, as coding marks them - let mut m = n4; - loop { - for rr in (r..r + n4).step_by(m) { - for cc in (c..c + n4).step_by(m) { - for p in 0..3 { - dec.mark_decoded(p, rr, cc, m); - } - } - } - if m == 1 { - break; - } - m /= 2; - } - dec.restore(&snap, r, c, n4); - assert!(dec.decoded == before, "n4 {n4} at ({r}, {c}), random start {random_start}"); - } - } - } - } - } - } - - /// The bounded SADs return the full SAD whenever the cost can still win (`sad + lb < bound`) - /// and otherwise a partial sum that already loses, so the callers' strict `<` decides the same. - #[test] - fn bounded_sad_stops_only_once_the_cost_cannot_win() { - let g = FrameGeom::new(100, 80); - let mut next = xorshift(0x9e37_79b9_7f4a_7c15); - let mut mk = |w: usize, h: usize| { - let mut p = Plane::new(w, h); - p.data.iter_mut().for_each(|v| *v = (next() % 256) as u16); - p - }; - let src = [mk(128, 128), mk(64, 64), mk(64, 64)]; - let refp = mk(128, 128); - let mut next = xorshift(0x1234_5678_9abc_def1); - let dec = Decider::new(&g, 8, 100, false, &src, None); - for n in [4usize, 8, 16, 32] { - for (x, y, rx, ry) in [(0usize, 0usize, 0i32, 0i32), (32, 40, 29, 43), (96, 72, 90, 70), (8, 8, -5, -3)] { - let full_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [0.0, f64::INFINITY]); - let pred: Vec = (0..n * n).map(|_| (next() % 256) as u16).collect(); - let full_pred = dec.sad_bounded(0, [x, y], n, &pred, [0.0, f64::INFINITY]); - for _ in 0..50 { - let lb = (next() % 64) as f64 * 0.75; - let b_ref = (next() % (2 * full_ref + 2)) as f64; - let b_pred = (next() % (2 * full_pred + 2)) as f64; - let got_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [lb, b_ref]); - let got_pred = dec.sad_bounded(0, [x, y], n, &pred, [lb, b_pred]); - for (got, full, bound) in [(got_ref, full_ref, b_ref), (got_pred, full_pred, b_pred)] { - if full as f64 + lb < bound { - assert_eq!(got, full, "n {n} at ({x}, {y}) bound {bound}"); - } else { - assert!(got as f64 + lb >= bound && got <= full, "n {n} at ({x}, {y}) bound {bound}"); - } - } - } - } - } - } -} diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs deleted file mode 100644 index 2962833d9..000000000 --- a/crates/av1enc/src/lib.rs +++ /dev/null @@ -1,670 +0,0 @@ -//! Clean-room pure-Rust AV1 encoder (Main profile, 4:2:0, 8 and 10 bit), implemented from the -//! AV1 Bitstream & Decoding Process Specification v1.0.0 with Errata 1. -//! -//! Key frames and single-reference inter frames (LAST_FRAME = the previous frame), 64x64 -//! superblocks split down to 8x8 by rate-distortion decisions, intra modes DC / V / H / Paeth / -//! smooth, full-sample motion search with the spec's motion vector prediction, DCT / ADST -//! transforms up to 32x32, one tile per frame, in-loop filters off. See the crate README. -//! -//! The public API below is the contract `crates/export` codes against; keep it stable. - -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -mod bitw; -mod cdf; -mod decide; -mod loopfilter; -mod pred; -mod symbol; -mod tables; -mod transform; -mod writer; - -use bitw::{BitWriter, obu}; -use decide::{Decider, FrameGeom, RefFrame}; -use loopfilter::{LfInfo, loop_filter}; -use pred::Plane; -use tables::AC_QLOOKUP; -use writer::TileWriter; - -/// Rate control. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RateControl { - /// Constant quantizer index (`base_q_idx`, 1–255; lower is better). - ConstantQ(u8), - /// Average bitrate target in kbit/s (frame-level quantizer adaptation). - Bitrate { kbps: u32 }, -} - -/// Encoder settings. Main profile (seq_profile 0), 4:2:0. -#[derive(Clone, Debug, PartialEq)] -pub struct EncoderConfig { - pub width: u32, - pub height: u32, - pub fps_num: u32, - pub fps_den: u32, - /// 8 or 10. - pub bit_depth: u8, - /// `seq_level_idx` (e.g. 8 = level 4.0); `None` picks the lowest level that fits. - pub level_idx: Option, - pub rate: RateControl, - /// Frames between key frames (1 = all key frames). - pub keyint: u32, - /// BT.709 limited range is signalled unless this is set. - pub full_range: bool, - /// The colour description of `color_config` (ITU-T H.273 code points); BT.709 by default. - pub color_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, -} - -impl EncoderConfig { - pub fn new(width: u32, height: u32, fps_num: u32, fps_den: u32) -> Self { - EncoderConfig { - width, - height, - fps_num, - fps_den, - bit_depth: 8, - level_idx: None, - rate: RateControl::ConstantQ(100), - keyint: 60, - full_range: false, - color_primaries: 1, - transfer_characteristics: 1, - matrix_coefficients: 1, - } - } -} - -/// One input picture, 4:2:0, samples at the configured bit depth. -#[derive(Clone, Copy, Debug)] -pub struct Frame<'a> { - pub y: &'a [u16], - pub u: &'a [u16], - pub v: &'a [u16], - pub y_stride: usize, - pub uv_stride: usize, -} - -/// One temporal unit as stored in MP4 / WebM samples: OBUs with `obu_has_size_field` = 1 and -/// no temporal delimiter (key frames start with the sequence header OBU). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Packet { - pub data: Vec, - pub keyframe: bool, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum Error { - InvalidConfig(String), -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::InvalidConfig(s) => write!(f, "invalid AV1 encoder config: {s}"), - } - } -} - -impl std::error::Error for Error {} - -const OBU_SEQUENCE_HEADER: u8 = 1; -const OBU_FRAME: u8 = 6; - -/// Annex A level limits: (seq_level_idx, MaxPicSize, MaxHSize, MaxVSize, MaxDisplayRate, -/// MaxBitrate (Main tier, Mbit/s)). -const LEVELS: [(u8, u64, u32, u32, u64, u64); 14] = [ - (0, 147_456, 2048, 1152, 4_423_680, 1_500_000), - (1, 278_784, 2816, 1584, 8_363_520, 3_000_000), - (4, 665_856, 4352, 2448, 19_975_680, 6_000_000), - (5, 1_065_024, 5504, 3096, 31_950_720, 10_000_000), - (8, 2_359_296, 6144, 3456, 70_778_880, 12_000_000), - (9, 2_359_296, 6144, 3456, 141_557_760, 20_000_000), - (12, 8_912_896, 8192, 4352, 267_386_880, 30_000_000), - (13, 8_912_896, 8192, 4352, 534_773_760, 40_000_000), - (14, 8_912_896, 8192, 4352, 1_069_547_520, 60_000_000), - (15, 8_912_896, 8192, 4352, 1_069_547_520, 60_000_000), - (16, 35_651_584, 16384, 8704, 1_069_547_520, 60_000_000), - (17, 35_651_584, 16384, 8704, 2_139_095_040, 100_000_000), - (18, 35_651_584, 16384, 8704, 4_278_190_080, 160_000_000), - (19, 35_651_584, 16384, 8704, 4_278_190_080, 160_000_000), -]; - -/// The lowest level whose limits fit the stream (31 = no level limits when none does). -fn pick_level(cfg: &EncoderConfig) -> u8 { - let pic = cfg.width as u64 * cfg.height as u64; - let rate = pic * cfg.fps_num as u64 / cfg.fps_den.max(1) as u64; - let bitrate = match cfg.rate { - RateControl::Bitrate { kbps } => kbps as u64 * 1000, - RateControl::ConstantQ(_) => 0, - }; - for &(idx, max_pic, max_h, max_v, max_rate, max_br) in LEVELS.iter() { - if pic <= max_pic && cfg.width <= max_h && cfg.height <= max_v && rate <= max_rate && bitrate <= max_br { - return idx; - } - } - 31 -} - -/// Frame-level rate control state for `RateControl::Bitrate`. -struct RateState { - /// Bits per frame at the target rate. - frame_bits: f64, - /// Quantizer index for inter frames (continuous). - q: f64, - /// Key frame quantizer index minus the inter frame one. - key_off: f64, - /// Quantizer index steps per halving of the frame size, measured from consecutive inter - /// frames (the size/quantizer curve is much steeper than the default on noisy content). - slope: f64, - /// Quantizer index and size (in bits) of the previous inter frame. - prev_inter: Option<(f64, f64)>, - /// Accumulated (spent - budget) bits. - debt: f64, -} - -/// Quantizer index steps per halving of the frame size before any measurement. -const RC_SLOPE: f64 = 28.0; -/// Fraction of the quantizer step the size error asks for that is applied per frame. -const RC_GAIN: f64 = 0.8; -/// Frames over which an accumulated deviation from the budget is paid back. -const RC_PAYBACK: f64 = 16.0; - -impl RateState { - /// The nominal bit budget and the starting quantizer index of a key or inter frame. A key - /// frame gets a larger share, and the inter frames of the group get the rest, so that a whole - /// group of `keyint` frames adds up to `keyint * frame_bits`. - fn frame_budget(&self, keyint: u32, key: bool) -> (f64, f64) { - let keyint = keyint.max(1) as f64; - let boost = (1.0 + (keyint.clamp(1.0, 60.0) - 1.0).sqrt() * 0.75).min(0.5 * keyint + 0.5); - if key { - (self.frame_bits * boost, (self.q + self.key_off).clamp(1.0, 255.0)) - } else if keyint > 1.0 { - (self.frame_bits * (keyint - boost) / (keyint - 1.0), self.q) - } else { - (self.frame_bits, self.q) - } - } - - /// Adapts the quantizers to the size of the frame just coded at `q_used` against `target`. - fn update(&mut self, key: bool, first: bool, q_used: f64, bits: f64, target: f64) { - let err = (bits / target).log2().clamp(-1.5, 1.5); - if first && key { - // four passes ran: the key frame quantizer is known, the inter one follows from the offset - self.q = (q_used - self.key_off).clamp(1.0, 255.0); - } else if key { - self.key_off = q_used - self.q + RC_GAIN * self.slope * err; - } else { - if let Some((pq, pbits)) = self.prev_inter { - let (dq, dr) = (q_used - pq, (pbits / bits).log2()); - if dq.abs() >= 3.0 && dr.abs() >= 0.15 && dq * dr > 0.0 { - self.slope = (0.6 * self.slope + 0.4 * (dq / dr)).clamp(3.0, 45.0); - } - } - self.prev_inter = Some((q_used, bits)); - self.q = (q_used + RC_GAIN * self.slope * err).clamp(1.0, 255.0); - } - } -} - -/// Most passes on the first frame, and how far from the target (octaves, 3 %) its size may be -/// before it is accepted. -const RC_FIRST_PASSES: usize = 4; -const RC_FIRST_TOLERANCE: f64 = 0.04; -/// Quantizer index steps per halving of the size, for the first correction of the first frame -/// (the starting quantizer comes from the bits per sample; the first pass shows how far off it -/// is, and from there on the passes are placed by `next_probe_q`). -const RC_FIRST_SLOPE: f64 = 30.0; -/// The same for a key frame after the first: coded again when its size is more than 0.25 octaves -/// (19 %) off the target. -const RC_KEY_PASSES: usize = 3; -const RC_KEY_TOLERANCE: f64 = 0.25; -/// Octaves of frame size per octave of quantizer step assumed for the first correction of a frame. -const RC_PROBE_SLOPE: f64 = 1.0; -/// Largest change of the quantizer step (octaves) between two passes on one frame. -const RC_PROBE_MAX_STEP: f64 = 2.0; - -/// The quantizer index whose step size is closest to `2^x`. -fn q_for_step(bit_depth: u8, x: f64) -> u8 { - let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; - let mut best = 1; - for q in 1..=255usize { - if ((table[q] as f64).log2() - x).abs() < ((table[best] as f64).log2() - x).abs() { - best = q; - } - } - best as u8 -} - -/// The next quantizer to try for a frame, from the `(quantizer, log2(size / target))` -/// pairs coded so far. The size follows the quantizer step (not the index, which is linear in the -/// step only below about 60) in a near log-log line: false position between the closest pair of -/// passes that enclose the target, otherwise a step from the latest one with the slope of the -/// last two. `None` when no untried quantizer lies between the enclosing passes. -fn next_probe_q(bit_depth: u8, tried: &[(u8, f64)]) -> Option { - let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; - let x = |q: u8| (table[q as usize] as f64).log2(); - let over = tried.iter().filter(|t| t.1 > 0.0).max_by_key(|t| t.0); - let under = tried.iter().filter(|t| t.1 <= 0.0).min_by_key(|t| t.0); - if let (Some(&(q_lo, e_lo)), Some(&(q_hi, e_hi))) = (over, under) { - if q_hi - q_lo < 2 { - return None; - } - let next = x(q_lo) + (x(q_hi) - x(q_lo)) * e_lo / (e_lo - e_hi); - return Some(q_for_step(bit_depth, next).clamp(q_lo + 1, q_hi - 1)); - } - let &(q, err) = tried.last()?; - let mut slope = RC_PROBE_SLOPE; - if let [.., (q0, e0), (q1, e1)] = tried - && (x(*q1) - x(*q0)).abs() > 0.05 - && (e0 - e1) / (x(*q1) - x(*q0)) > 0.0 - { - slope = ((e0 - e1) / (x(*q1) - x(*q0))).clamp(0.25, 3.0); - } - Some(q_for_step(bit_depth, x(q) + (err / slope).clamp(-RC_PROBE_MAX_STEP, RC_PROBE_MAX_STEP))) -} - -/// AV1 encoder. Frames come out in input order (no hidden/reordered frames). -pub struct Encoder { - cfg: EncoderConfig, - geom: FrameGeom, - level: u8, - seq_obu: Vec, - frame_num: u64, - refr: Option, - rc: Option, -} - -impl Encoder { - pub fn new(cfg: EncoderConfig) -> Result { - if cfg.width == 0 || cfg.height == 0 { - return Err(Error::InvalidConfig("empty frame".into())); - } - if cfg.width > 4096 || cfg.height > 4096 { - return Err(Error::InvalidConfig(format!("{}x{} exceeds the single-tile limit (4096 samples per side)", cfg.width, cfg.height))); - } - if cfg.bit_depth != 8 && cfg.bit_depth != 10 { - return Err(Error::InvalidConfig(format!("bit depth {} (8 or 10 supported)", cfg.bit_depth))); - } - if cfg.fps_num == 0 || cfg.fps_den == 0 { - return Err(Error::InvalidConfig("frame rate".into())); - } - let geom = FrameGeom::new(cfg.width as usize, cfg.height as usize); - if geom.sb_cols * geom.sb_rows > 2304 { - return Err(Error::InvalidConfig(format!("{}x{} exceeds the single-tile area limit (MAX_TILE_AREA)", cfg.width, cfg.height))); - } - if let RateControl::Bitrate { kbps } = cfg.rate - && kbps == 0 - { - return Err(Error::InvalidConfig("zero bitrate".into())); - } - let level = cfg.level_idx.unwrap_or_else(|| pick_level(&cfg)); - if level > 31 { - return Err(Error::InvalidConfig(format!("seq_level_idx {level}"))); - } - let rc = match cfg.rate { - RateControl::ConstantQ(_) => None, - RateControl::Bitrate { kbps } => { - let frame_bits = kbps as f64 * 1000.0 * cfg.fps_den as f64 / cfg.fps_num as f64; - // initial guess from the bits per sample - let bpp = frame_bits / (cfg.width as f64 * cfg.height as f64); - let q = (110.0 - 40.0 * (bpp / 0.1).log2()).clamp(20.0, 230.0); - Some(RateState { frame_bits, q, key_off: -24.0, slope: RC_SLOPE, prev_inter: None, debt: 0.0 }) - } - }; - let mut enc = Encoder { cfg, geom, level, seq_obu: Vec::new(), frame_num: 0, refr: None, rc }; - let payload = enc.sequence_header_payload(); - obu(OBU_SEQUENCE_HEADER, &payload, &mut enc.seq_obu); - Ok(enc) - } - - pub fn config(&self) -> &EncoderConfig { - &self.cfg - } - - /// The sequence header OBU (with size field). - pub fn sequence_header_obu(&self) -> Vec { - self.seq_obu.clone() - } - - /// AV1CodecConfigurationRecord (`av1C` payload, also the WebM CodecPrivate). - pub fn av1c(&self) -> Vec { - let high = (self.cfg.bit_depth > 8) as u8; - let mut v = vec![ - 0x81, // marker (1), version (1) - self.level & 0x1f, // seq_profile 0, seq_level_idx_0 - (high << 6) | (1 << 3) | (1 << 2), // tier 0, high_bitdepth, twelve_bit 0, mono 0, ss_x 1, ss_y 1, csp 0 - 0, // no initial_presentation_delay - ]; - v.extend_from_slice(&self.seq_obu); - v - } - - /// sequence_header_obu( ) payload (5.5) including trailing bits. - fn sequence_header_payload(&self) -> Vec { - let c = &self.cfg; - let mut w = BitWriter::new(); - w.f(3, 0); // seq_profile - w.f(1, 0); // still_picture - w.f(1, 0); // reduced_still_picture_header - w.f(1, 0); // timing_info_present_flag - w.f(1, 0); // initial_display_delay_present_flag - w.f(5, 0); // operating_points_cnt_minus_1 - w.f(12, 0); // operating_point_idc[ 0 ] - w.f(5, self.level as u32); // seq_level_idx[ 0 ] - if self.level > 7 { - w.f(1, 0); // seq_tier[ 0 ] - } - let wbits = 32 - (c.width - 1).max(1).leading_zeros(); - let hbits = 32 - (c.height - 1).max(1).leading_zeros(); - w.f(4, wbits - 1); - w.f(4, hbits - 1); - w.f(wbits, c.width - 1); - w.f(hbits, c.height - 1); - w.f(1, 0); // frame_id_numbers_present_flag - w.f(1, 0); // use_128x128_superblock - w.f(1, 0); // enable_filter_intra - w.f(1, 0); // enable_intra_edge_filter - w.f(1, 0); // enable_interintra_compound - w.f(1, 0); // enable_masked_compound - w.f(1, 0); // enable_warped_motion - w.f(1, 0); // enable_dual_filter - w.f(1, 0); // enable_order_hint - w.f(1, 0); // seq_choose_screen_content_tools - w.f(1, 0); // seq_force_screen_content_tools - w.f(1, 0); // enable_superres - w.f(1, 0); // enable_cdef - w.f(1, 0); // enable_restoration - // color_config( ) - w.f(1, (c.bit_depth > 8) as u32); // high_bitdepth - w.f(1, 0); // mono_chrome - w.f(1, 1); // color_description_present_flag - w.f(8, c.color_primaries as u32); // color_primaries - w.f(8, c.transfer_characteristics as u32); // transfer_characteristics - w.f(8, c.matrix_coefficients as u32); // matrix_coefficients - w.f(1, c.full_range as u32); // color_range - w.f(2, 0); // chroma_sample_position: unknown - w.f(1, 0); // separate_uv_delta_q - w.f(1, 0); // film_grain_params_present - w.trailing_bits(); - w.into_bytes() - } - - /// uncompressed_header( ) (5.9) for a shown KEY_FRAME or an INTER_FRAME referencing slot 0. - fn frame_header(&self, w: &mut BitWriter, key: bool, qidx: u8, lf_level: u32) { - w.f(1, 0); // show_existing_frame - w.f(2, if key { 0 } else { 1 }); // frame_type - w.f(1, 1); // show_frame - if !key { - w.f(1, 0); // error_resilient_mode - } - w.f(1, 0); // disable_cdf_update - w.f(1, 0); // frame_size_override_flag - if !key { - w.f(3, 0); // primary_ref_frame: LAST_FRAME's saved CDFs - w.f(8, 1); // refresh_frame_flags: slot 0 becomes LAST for the next frame - for _ in 0..7 { - w.f(3, 0); // ref_frame_idx[ i ] - } - w.f(1, 0); // render_and_frame_size_different - w.f(1, 0); // allow_high_precision_mv - w.f(1, 0); // is_filter_switchable - w.f(2, 0); // interpolation_filter = EIGHTTAP - w.f(1, 0); // is_motion_mode_switchable - } else { - w.f(1, 0); // render_and_frame_size_different - } - w.f(1, 0); // disable_frame_end_update_cdf - // tile_info( ): uniform spacing, one tile - let g = &self.geom; - let tile_log2 = |blk: usize, target: usize| { - let mut k = 0; - while (blk << k) < target { - k += 1; - } - k - }; - w.f(1, 1); // uniform_tile_spacing_flag - if tile_log2(1, g.sb_cols.min(64)) > 0 { - w.f(1, 0); // increment_tile_cols_log2 - } - if tile_log2(1, g.sb_rows.min(64)) > 0 { - w.f(1, 0); // increment_tile_rows_log2 - } - // quantization_params( ) - w.f(8, qidx as u32); - w.f(1, 0); // DeltaQYDc - w.f(1, 0); // DeltaQUDc - w.f(1, 0); // DeltaQUAc - w.f(1, 0); // using_qmatrix - w.f(1, 0); // segmentation_enabled - w.f(1, 0); // delta_q_present (base_q_idx > 0) - // loop_filter_params( ): the same level for every plane and direction - w.f(6, lf_level); - w.f(6, lf_level); - if lf_level != 0 { - w.f(6, lf_level); - w.f(6, lf_level); - } - w.f(3, 0); // loop_filter_sharpness - w.f(1, 0); // loop_filter_delta_enabled - w.f(1, 0); // tx_mode_select (TX_MODE_LARGEST) - if !key { - w.f(1, 0); // reference_select - } - w.f(1, 1); // reduced_tx_set - if !key { - for _ in 0..7 { - w.f(1, 0); // is_global - } - } - } - - /// Copies a picture into superblock-padded planes (edge replication). - fn load_source(&self, frame: &Frame) -> [Plane; 3] { - let maxv = (1u16 << self.cfg.bit_depth) - 1; - std::array::from_fn(|p| { - let (vw, vh) = self.geom.visible(p); - let (pw, ph) = self.geom.padded(p); - let (data, stride) = match p { - 0 => (frame.y, frame.y_stride), - 1 => (frame.u, frame.uv_stride), - _ => (frame.v, frame.uv_stride), - }; - assert!(stride >= vw && data.len() >= stride * (vh - 1) + vw, "plane {p} is too small for {vw}x{vh}"); - let mut pl = Plane::new(pw, ph); - for y in 0..ph { - let sy = y.min(vh - 1); - let src = &data[sy * stride..sy * stride + vw]; - let row = &mut pl.data[y * pw..y * pw + pw]; - for (x, d) in row.iter_mut().enumerate() { - *d = src[x.min(vw - 1)].min(maxv); - } - } - pl - }) - } - - /// Codes one frame at `qidx`; returns the frame OBU and the reconstruction. - fn encode_frame(&self, src: &[Plane; 3], key: bool, qidx: u8) -> (Vec, RefFrame) { - let g = &self.geom; - let refr = if key { None } else { self.refr.as_ref() }; - let mut dec = Decider::new(g, self.cfg.bit_depth as u32, qidx, key, src, refr); - let mut tw = TileWriter::new(g, key, qidx, refr.map(|r| &*r.cdfs)); - for sbr in 0..g.sb_rows { - tw.start_sb_row(); - for sbc in 0..g.sb_cols { - let node = dec.superblock(sbr * 16, sbc * 16); - tw.superblock(sbr * 16, sbc * 16, &node); - } - } - let lf_level = self.pick_filter_level(&dec.rec, &tw.lf_info(), src, qidx); - loop_filter(&mut dec.rec, &tw.lf_info(), [lf_level; 4], self.cfg.bit_depth as u32, false, 1); - let (tile, cdfs) = tw.finish(); - let mut w = BitWriter::new(); - self.frame_header(&mut w, key, qidx, lf_level); - w.byte_align(); - // tile_group_obu( ): a single tile, no tile_start_and_end_present_flag - let mut payload = w.into_bytes(); - payload.extend_from_slice(&tile); - let mut out = Vec::with_capacity(payload.len() + 8); - obu(OBU_FRAME, &payload, &mut out); - let rf = RefFrame { planes: dec.rec, mvs: dec.mvs, lf_level, cdfs }; - (out, rf) - } - - /// The loop filter level minimising the luma error (searched around a guess from the - /// quantizer, on the luma of a subset of the superblock rows). - fn pick_filter_level(&self, rec: &[Plane; 3], info: &LfInfo, src: &[Plane; 3], qidx: u8) -> u32 { - let (w, h) = (self.geom.width, self.geom.height); - let sb_row_step = if self.geom.sb_rows >= 9 { 3 } else { 1 }; - let sse = |pl: &Plane| -> u64 { - let mut s = 0u64; - for y in 0..h { - if !(y >> 6).is_multiple_of(sb_row_step) { - continue; - } - for (a, b) in pl.row(y)[..w].iter().zip(&src[0].row(y)[..w]) { - let d = *a as i64 - *b as i64; - s += (d * d) as u64; - } - } - s - }; - let bd = self.cfg.bit_depth as u32; - let mut cache: Vec<(u32, u64)> = vec![(0, sse(&rec[0]))]; - // one working copy of the luma plane for all candidate levels - let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)]; - let mut eval = |lvl: u32| -> u64 { - if let Some(&(_, e)) = cache.iter().find(|(l, _)| *l == lvl) { - return e; - } - planes[0].data.copy_from_slice(&rec[0].data); - loop_filter(&mut planes, info, [lvl; 4], bd, true, sb_row_step); - let e = sse(&planes[0]); - cache.push((lvl, e)); - e - }; - let guess = ((qidx as u32 * 3) / 16).clamp(2, 50); - let mut best = (0u32, eval(0)); - let mut step = (guess / 2).max(1); - let mut center = guess; - let e = eval(center); - if e < best.1 { - best = (center, e); - } - while step >= 2 { - for lvl in [center.saturating_sub(step), (center + step).min(63)] { - let e = eval(lvl); - if e < best.1 { - best = (lvl, e); - } - } - center = best.0.max(1); - step /= 2; - } - best.0 - } - - /// Encodes one frame. - pub fn encode(&mut self, frame: &Frame) -> Packet { - let key = self.frame_num.is_multiple_of(self.cfg.keyint.max(1) as u64); - let src = self.load_source(frame); - let (frame_obu, rf) = match self.cfg.rate { - RateControl::ConstantQ(q) => self.encode_frame(&src, key, q.max(1)), - RateControl::Bitrate { .. } => match self.rc.take() { - Some(mut rc) => { - let out = self.encode_rate_controlled(&src, key, &mut rc); - self.rc = Some(rc); - out - } - // The state is created with the encoder for bitrate mode; without it, encode at a - // middle quantizer rather than fail. - None => self.encode_frame(&src, key, 128), - }, - }; - self.refr = Some(rf); - self.frame_num += 1; - let mut data = Vec::with_capacity(frame_obu.len() + self.seq_obu.len()); - if key { - data.extend_from_slice(&self.seq_obu); - } - data.extend_from_slice(&frame_obu); - Packet { data, keyframe: key } - } - - fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec, RefFrame) { - let first = self.frame_num == 0; - let (budget, q_start) = rc.frame_budget(self.cfg.keyint, key); - // pay back the accumulated deviation over the next RC_PAYBACK frames - let target = (budget - rc.debt / RC_PAYBACK).clamp(budget * 0.35, budget * 2.0); - let mut q = q_start; - let mut best: Option<(Vec, RefFrame, f64, f64)> = None; - // The first frame is coded up to four times to find its quantizer (it fixes the - // quantizers of everything after it), a later key frame up to three times (it is a large - // part of the budget of its group). A frame is coded again only while its size is far from - // the target and an untried quantizer is left to try; other frames are coded once. - let (max_passes, tolerance) = if first { - (RC_FIRST_PASSES, RC_FIRST_TOLERANCE) - } else if key { - (RC_KEY_PASSES, RC_KEY_TOLERANCE) - } else { - (1, 0.0) - }; - let mut tried: Vec<(u8, f64)> = Vec::new(); - for _ in 0..max_passes { - let qi = q.round().clamp(1.0, 255.0) as u8; - if tried.iter().any(|t| t.0 == qi) { - break; - } - let (obu, rf) = self.encode_frame(src, key, qi); - let bits = obu.len() as f64 * 8.0; - let err = (bits / target).log2(); - tried.push((qi, err)); - if best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()) { - best = Some((obu, rf, bits, qi as f64)); - } - if err.abs() <= tolerance { - break; - } - let next = if first && tried.len() == 1 { - Some((q + RC_FIRST_SLOPE * err).clamp(1.0, 255.0)) - } else { - next_probe_q(self.cfg.bit_depth, &tried).map(f64::from) - }; - match next { - Some(next) => q = next, - None => break, - } - } - // At least one pass ran, so `best` is set. - let Some((obu, rf, bits, q_used)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) }; - rc.debt += bits - budget; - rc.update(key, first, q_used, bits, target); - (obu, rf) - } - - /// The loop filter level of the last encoded frame. For tests. - #[doc(hidden)] - pub fn last_loop_filter_level(&self) -> Option { - self.refr.as_ref().map(|r| r.lf_level) - } - - /// The reconstruction of the last encoded frame (visible area, planes Y, U, V), equal to - /// what a decoder outputs. For tests. - #[doc(hidden)] - pub fn last_reconstruction(&self) -> Option<[Vec; 3]> { - let rf = self.refr.as_ref()?; - Some(std::array::from_fn(|p| { - let (vw, vh) = self.geom.visible(p); - let pl = &rf.planes[p]; - let mut v = Vec::with_capacity(vw * vh); - for y in 0..vh { - v.extend_from_slice(&pl.row(y)[..vw]); - } - v - })) - } -} diff --git a/crates/av1enc/src/loopfilter.rs b/crates/av1enc/src/loopfilter.rs deleted file mode 100644 index cf350fbd2..000000000 --- a/crates/av1enc/src/loopfilter.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! Deblocking loop filter (spec 7.14), mirrored from the decoder process so that the encoder's -//! references equal a decoder's. Segmentation and loop filter deltas are off and the sharpness -//! is 0 in the frames this encoder writes. - -use crate::pred::Plane; -use crate::tables::{MAX_LOOP_FILTER, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SUBSAMPLED_SIZE, TX_HEIGHT, TX_WIDTH}; - -/// Mode info the loop filter reads (per 4x4 luma unit, row stride `mi_cols`), and the -/// transform size per 4x4 unit of each plane (`LoopfilterTxSizes`, row stride `mi_cols + 32`). -pub(crate) struct LfInfo<'a> { - pub mi_cols: usize, - pub mi_rows: usize, - pub width: usize, - pub height: usize, - pub mi_size: &'a [u8], - pub skip: &'a [bool], - pub is_inter: &'a [bool], - pub tx: [&'a [u8]; 3], -} - -/// Applies the loop filter with `loop_filter_level[ 0..4 ]` to `planes`. For level search -/// (`luma_only`), only plane 0 and only every `sb_row_step`-th superblock row are filtered. -pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4], bit_depth: u32, luma_only: bool, sb_row_step: usize) { - if level[0] == 0 && level[1] == 0 { - return; - } - let nplanes = if luma_only { 1 } else { 3 }; - for (plane, pl) in planes.iter_mut().enumerate().take(nplanes) { - if plane > 0 && level[1 + plane] == 0 { - continue; - } - for pass in 0..2 { - let step = if plane == 0 { 1 } else { 2 }; - let mut row = 0; - while row < info.mi_rows { - if !(row >> 4).is_multiple_of(sb_row_step) { - row += step; - continue; - } - let mut col = 0; - while col < info.mi_cols { - edge(pl, info, level, bit_depth, plane, pass, (row, col)); - col += step; - } - row += step; - } - } - } -} - -fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, position: (usize, usize)) { - let (row, col) = position; - let s = (plane > 0) as usize; - let (dx, dy) = if pass == 0 { (1usize, 0usize) } else { (0, 1) }; - let x = col * 4; - let y = row * 4; - let row = row | s; - let col = col | s; - let on_screen = !(x >= info.width || y >= info.height || (pass == 0 && x == 0) || (pass == 1 && y == 0)); - if !on_screen { - return; - } - let xp = x >> s; - let yp = y >> s; - let prev_row = row - (dy << s); - let prev_col = col - (dx << s); - let i = row * info.mi_cols + col; - let mi_size = info.mi_size[i] as usize; - let tx_stride = info.mi_cols + 32; - let tx_sz = info.tx[plane][(row >> s) * tx_stride + (col >> s)] as usize; - let plane_size = SUBSAMPLED_SIZE[mi_size][s][s] as usize; - let skip = info.skip[i]; - let is_intra = !info.is_inter[i]; - let prev_tx_sz = info.tx[plane][(prev_row >> s) * tx_stride + (prev_col >> s)] as usize; - let is_block_edge = if pass == 0 { - xp.is_multiple_of(4 * NUM_4X4_BLOCKS_WIDE[plane_size] as usize) - } else { - yp.is_multiple_of(4 * NUM_4X4_BLOCKS_HIGH[plane_size] as usize) - }; - let is_tx_edge = if pass == 0 { xp.is_multiple_of(TX_WIDTH[tx_sz] as usize) } else { yp.is_multiple_of(TX_HEIGHT[tx_sz] as usize) }; - let apply_filter = is_tx_edge && (is_block_edge || !skip || is_intra); - let base_size = if pass == 0 { TX_WIDTH[prev_tx_sz].min(TX_WIDTH[tx_sz]) } else { TX_HEIGHT[prev_tx_sz].min(TX_HEIGHT[tx_sz]) } as usize; - let filter_size = if plane == 0 { 16.min(base_size) } else { 8.min(base_size) }; - // 7.14.4 with no deltas, segmentation or sharpness: the same strength for every block - let lvl = level[if plane == 0 { pass } else { plane + 1 }].min(MAX_LOOP_FILTER as u32) as i32; - if !apply_filter || lvl == 0 { - return; - } - let limit = lvl.max(1); - let blimit = 2 * (lvl + 2) + limit; - let thresh = lvl >> 4; - let strength = FilterStrength { limit, blimit, thresh }; - for k in 0..4 { - sample_filter(pl, (xp + dy * k, yp + dx * k), plane, &strength, (dx as isize, dy as isize), filter_size, bd); - } -} - -/// Thresholds derived from the loop-filter level (spec 7.14.4). -struct FilterStrength { - limit: i32, - blimit: i32, - thresh: i32, -} - -#[inline] -fn sample_filter(pl: &mut Plane, position: (usize, usize), plane: usize, strength: &FilterStrength, direction: (isize, isize), filter_size: usize, bd: u32) { - let (x, y) = position; - let (dx, dy) = direction; - let FilterStrength { limit, blimit, thresh } = *strength; - let stride = pl.stride as isize; - let step = dy * stride + dx; - let base = y as isize * stride + x as isize; - let reach: isize = if filter_size >= 16 { 7 } else { 4 }; - let mut v = [0i32; 16]; - for k in -reach..reach { - v[(k + 8) as usize] = pl.data[(base + k * step) as usize] as i32; - } - let at = |k: isize| -> i32 { v[(k + 8) as usize] }; - let (q0, q1, q2, q3) = (at(0), at(1), at(2), at(3)); - let (p0, p1, p2, p3) = (at(-1), at(-2), at(-3), at(-4)); - let d = &mut pl.data; - let sh = bd - 8; - let thresh_bd = thresh << sh; - let hev = (p1 - p0).abs() > thresh_bd || (q1 - q0).abs() > thresh_bd; - let filter_len = if filter_size == 4 { - 4 - } else if plane != 0 { - 6 - } else if filter_size == 8 { - 8 - } else { - 16 - }; - let limit_bd = limit << sh; - let blimit_bd = blimit << sh; - let mut mask = (p1 - p0).abs() > limit_bd || (q1 - q0).abs() > limit_bd || (p0 - q0).abs() * 2 + (p1 - q1).abs() / 2 > blimit_bd; - if filter_len >= 6 { - mask |= (p2 - p1).abs() > limit_bd || (q2 - q1).abs() > limit_bd; - } - if filter_len >= 8 { - mask |= (p3 - p2).abs() > limit_bd || (q3 - q2).abs() > limit_bd; - } - if mask { - return; - } - let t_bd = 1 << sh; - let flat = if filter_size >= 8 { - let mut m = (p1 - p0).abs() > t_bd || (q1 - q0).abs() > t_bd || (p2 - p0).abs() > t_bd || (q2 - q0).abs() > t_bd; - if filter_len >= 8 { - m |= (p3 - p0).abs() > t_bd || (q3 - q0).abs() > t_bd; - } - !m - } else { - false - }; - let flat2 = if filter_size >= 16 { - let (q4, q5, q6, p4, p5, p6) = (at(4), at(5), at(6), at(-5), at(-6), at(-7)); - !((p6 - p0).abs() > t_bd - || (q6 - q0).abs() > t_bd - || (p5 - p0).abs() > t_bd - || (q5 - q0).abs() > t_bd - || (p4 - p0).abs() > t_bd - || (q4 - q0).abs() > t_bd) - } else { - false - }; - if filter_size == 4 || !flat { - // narrow filter (7.14.6.3) - let half = 1i32 << (bd - 1); - let c = |v: i32| v.clamp(-half, half - 1); - let off = 0x80 << sh; - let (ps1, ps0, qs0, qs1) = (p1 - off, p0 - off, q0 - off, q1 - off); - let mut filter = if hev { c(ps1 - qs1) } else { 0 }; - filter = c(filter + 3 * (qs0 - ps0)); - let filter1 = c(filter + 4) >> 3; - let filter2 = c(filter + 3) >> 3; - d[base as usize] = (c(qs0 - filter1) + off) as u16; - d[(base - step) as usize] = (c(ps0 + filter2) + off) as u16; - if !hev { - let f = (filter1 + 1) >> 1; - d[(base + step) as usize] = (c(qs1 - f) + off) as u16; - d[(base - 2 * step) as usize] = (c(ps1 + f) + off) as u16; - } - } else { - // wide filter (7.14.6.4) - let log2 = if filter_size == 8 || !flat2 { 3 } else { 4 }; - let n: isize = if log2 == 4 { - 6 - } else if plane == 0 { - 3 - } else { - 2 - }; - let n2: isize = if log2 == 3 && plane == 0 { 0 } else { 1 }; - let mut f = [0i32; 12]; - for i in -n..n { - let mut t = 0; - for j in -n..=n { - let p = (i + j).clamp(-(n + 1), n); - let tap = if j.abs() <= n2 { 2 } else { 1 }; - t += at(p) * tap; - } - f[(i + n) as usize] = (t + (1 << (log2 - 1))) >> log2; - } - for i in -n..n { - d[(base + i * step) as usize] = f[(i + n) as usize] as u16; - } - } -} diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs deleted file mode 100644 index b899202f4..000000000 --- a/crates/av1enc/src/pred.rs +++ /dev/null @@ -1,445 +0,0 @@ -//! Prediction processes, mirrored from the spec (7.11.2 intra prediction for the non-directional -//! modes plus V_PRED / H_PRED without edge filtering, 7.11.3 block inter prediction without -//! reference scaling) so that the encoder predicts exactly like a decoder. - -use crate::tables::{ - DC_PRED, EIGHTTAP, H_PRED, PAETH_PRED, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SMOOTH_H_PRED, SMOOTH_PRED, - SMOOTH_V_PRED, SUBPEL_FILTERS, V_PRED, -}; -use crate::transform::round2; - -/// A plane buffer (padded to whole superblocks). -#[derive(Clone)] -pub(crate) struct Plane { - pub data: Vec, - pub stride: usize, -} - -impl Plane { - pub fn new(w: usize, h: usize) -> Plane { - Plane { data: vec![0; w * h], stride: w } - } - #[inline(always)] - pub fn at(&self, x: usize, y: usize) -> u16 { - self.data[y * self.stride + x] - } - #[inline(always)] - pub fn row(&self, y: usize) -> &[u16] { - &self.data[y * self.stride..(y + 1) * self.stride] - } -} - -/// The intra modes this encoder uses (values are the spec's y / uv mode numbers). -pub(crate) const INTRA_MODES_USED: [usize; 7] = [DC_PRED, V_PRED, H_PRED, PAETH_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SMOOTH_H_PRED]; - -/// Inputs of the intra prediction process. -pub(crate) struct IntraEdge { - pub x: usize, - pub y: usize, - pub have_left: bool, - pub have_above: bool, - pub have_above_right: bool, - pub have_below_left: bool, - pub log2: u32, - /// ((MiCols * MI_SIZE) >> subX) - 1 and the same for rows. - pub max_x: i32, - pub max_y: i32, - pub bit_depth: u32, -} - -/// Edge samples: `above[0..2n]`, `left[0..2n]` and the top-left corner. -pub(crate) struct Edges { - above: [i32; 64], - left: [i32; 64], - corner: i32, -} - -pub(crate) fn edges(pl: &Plane, e: &IntraEdge) -> Edges { - let w = 1usize << e.log2; - let h = w; - let bd = e.bit_depth; - let (x, y) = (e.x, e.y); - let n = w + h; - let mut above = [0i32; 64]; - let mut left = [0i32; 64]; - if !e.have_above && e.have_left { - let v = pl.at(x - 1, y) as i32; - above[..n].iter_mut().for_each(|a| *a = v); - } else if !e.have_above && !e.have_left { - let v = (1 << (bd - 1)) - 1; - above[..n].iter_mut().for_each(|a| *a = v); - } else { - let above_limit = e.max_x.min(x as i32 + if e.have_above_right { 2 * w as i32 } else { w as i32 } - 1); - let row = pl.row(y - 1); - for (i, a) in above[..n].iter_mut().enumerate() { - *a = row[above_limit.min(x as i32 + i as i32) as usize] as i32; - } - } - if !e.have_left && e.have_above { - let v = pl.at(x, y - 1) as i32; - left[..n].iter_mut().for_each(|a| *a = v); - } else if !e.have_left && !e.have_above { - let v = (1 << (bd - 1)) + 1; - left[..n].iter_mut().for_each(|a| *a = v); - } else { - let left_limit = e.max_y.min(y as i32 + if e.have_below_left { 2 * h as i32 } else { h as i32 } - 1); - for (i, l) in left[..n].iter_mut().enumerate() { - *l = pl.at(x - 1, left_limit.min(y as i32 + i as i32) as usize) as i32; - } - } - let corner = if e.have_above && e.have_left { - pl.at(x - 1, y - 1) as i32 - } else if e.have_above { - pl.at(x, y - 1) as i32 - } else if e.have_left { - pl.at(x - 1, y) as i32 - } else { - 1 << (bd - 1) - }; - Edges { above, left, corner } -} - -fn sm_weights(log2: u32) -> &'static [u8] { - match log2 { - 2 => &SM_WEIGHTS_TX_4X4, - 3 => &SM_WEIGHTS_TX_8X8, - 4 => &SM_WEIGHTS_TX_16X16, - _ => &SM_WEIGHTS_TX_32X32, - } -} - -/// Predicts a square `2^log2` block with `mode` into `pred` (row stride = block width). -pub(crate) fn predict_intra(ed: &Edges, e: &IntraEdge, mode: usize, pred: &mut [u16]) { - let log2 = e.log2; - let w = 1usize << log2; - let h = w; - let bd = e.bit_depth; - let (above, left) = (&ed.above, &ed.left); - match mode { - DC_PRED => { - let v = if e.have_left && e.have_above { - let sum: i32 = left[..h].iter().sum::() + above[..w].iter().sum::() + ((w + h) >> 1) as i32; - sum / (w + h) as i32 - } else if e.have_left { - (left[..h].iter().sum::() + (h >> 1) as i32) >> log2 - } else if e.have_above { - (above[..w].iter().sum::() + (w >> 1) as i32) >> log2 - } else { - 1 << (bd - 1) - }; - let v = v.clamp(0, (1 << bd) - 1) as u16; - pred[..w * h].iter_mut().for_each(|p| *p = v); - } - V_PRED => { - for i in 0..h { - for j in 0..w { - pred[i * w + j] = above[j] as u16; - } - } - } - H_PRED => { - for i in 0..h { - pred[i * w..i * w + w].iter_mut().for_each(|p| *p = left[i] as u16); - } - } - PAETH_PRED => { - let tl = ed.corner; - for i in 0..h { - for j in 0..w { - let a = above[j]; - let l = left[i]; - let base = a + l - tl; - let p_left = (base - l).abs(); - let p_top = (base - a).abs(); - let p_tl = (base - tl).abs(); - pred[i * w + j] = if p_left <= p_top && p_left <= p_tl { - l as u16 - } else if p_top <= p_tl { - a as u16 - } else { - tl as u16 - }; - } - } - } - _ => { - let wx = sm_weights(log2); - let wy = wx; - for i in 0..h { - for j in 0..w { - let v = if mode == SMOOTH_PRED { - let s = wy[i] as i32 * above[j] + (256 - wy[i] as i32) * left[h - 1] + wx[j] as i32 * left[i] + (256 - wx[j] as i32) * above[w - 1]; - round2(s, 9) - } else if mode == SMOOTH_V_PRED { - round2(wy[i] as i32 * above[j] + (256 - wy[i] as i32) * left[h - 1], 8) - } else { - round2(wx[j] as i32 * left[i] + (256 - wx[j] as i32) * above[w - 1], 8) - }; - pred[i * w + j] = v as u16; - } - } - } - } -} - -/// Block inter prediction (7.11.3.3 motion vector scaling without scaling, 7.11.3.4 block -/// inter prediction with the EIGHTTAP filter, single reference so InterRound1 = 11 and the -/// final rounding is the identity) of the `w x h` block at plane position (x, y) displaced by -/// `mv` (1/8 luma sample units, [row, col]). `last_x` / `last_y` are the largest sample -/// coordinates of the reference plane; the result goes to `pred` (row stride `w`). -pub(crate) struct InterBlock { - pub last_x: i32, - pub last_y: i32, - pub x: usize, - pub y: usize, - pub width: usize, - pub height: usize, - pub mv: [i32; 2], - pub subsampling: usize, - pub bit_depth: u32, -} - -pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) { - let InterBlock { last_x, last_y, x, y, width: w, height: h, mv, subsampling: ss, bit_depth } = *block; - // positions in 1/16 sample units ((startX >> 6) & 15 is the filter phase) - let px = ((x as i32) << 4) + ((2 * mv[1]) >> ss); - let py = ((y as i32) << 4) + ((2 * mv[0]) >> ss); - let (fx, fy) = ((px & 15) as usize, (py & 15) as usize); - let (ix, iy) = (px >> 4, py >> 4); - let max = (1i32 << bit_depth) - 1; - if fx == 0 && fy == 0 { - // the filters' phase-0 kernel is a unit impulse: a clamped copy - for r in 0..h { - let ry = (iy + r as i32).clamp(0, last_y) as usize; - let row = refp.row(ry); - let out = &mut pred[r * w..r * w + w]; - let x0 = ix; - if x0 >= 0 && x0 + w as i32 - 1 <= last_x { - out.copy_from_slice(&row[x0 as usize..x0 as usize + w]); - } else { - for (c, o) in out.iter_mut().enumerate() { - *o = row[(x0 + c as i32).clamp(0, last_x) as usize]; - } - } - } - return; - } - let filt_h = if w <= 4 { 4 } else { EIGHTTAP }; - let filt_v = if h <= 4 { 4 } else { EIGHTTAP }; - let kh = &SUBPEL_FILTERS[filt_h][fx]; - let kv = &SUBPEL_FILTERS[filt_v][fy]; - let bx = ix - 3; - let x_inside = bx >= 0 && bx + w as i32 + 7 <= last_x + 1; - // horizontal pass of one reference row into `out` (Round2( sum, InterRound0 = 3 )) - let hfilter = |ry: usize, out: &mut [i32]| { - let row = refp.row(ry); - if x_inside { - let seg = &row[bx as usize..bx as usize + w + 7]; - for (c, o) in out.iter_mut().enumerate() { - let mut s = 0i32; - for t in 0..8 { - s += kh[t] as i32 * seg[c + t] as i32; - } - *o = round2(s, 3); - } - } else { - for (c, o) in out.iter_mut().enumerate() { - let mut s = 0i32; - for t in 0..8 { - s += kh[t] as i32 * row[(bx + c as i32 + t as i32).clamp(0, last_x) as usize] as i32; - } - *o = round2(s, 3); - } - } - }; - let mut inter = [0i32; (64 + 7) * 64]; - if fy == 0 { - // the vertical kernel is 128 at the centre: Round2( 128 * v, 11 ) = Round2( v, 4 ) - for r in 0..h { - let ry = (iy + r as i32).clamp(0, last_y) as usize; - hfilter(ry, &mut inter[..w]); - for (p, &v) in pred[r * w..r * w + w].iter_mut().zip(&inter[..w]) { - *p = round2(v, 4).clamp(0, max) as u16; - } - } - return; - } - if fx == 0 { - // the horizontal pass is exactly 16 * sample: Round2( 16 * sum, 11 ) = Round2( sum, 7 ) - for r in 0..h { - let rows: [&[u16]; 8] = std::array::from_fn(|t| refp.row((iy + r as i32 + t as i32 - 3).clamp(0, last_y) as usize)); - for c in 0..w { - let xx = if x_inside { (ix + c as i32) as usize } else { (ix + c as i32).clamp(0, last_x) as usize }; - let mut s = 0i32; - for t in 0..8 { - s += kv[t] as i32 * rows[t][xx] as i32; - } - pred[r * w + c] = round2(s, 7).clamp(0, max) as u16; - } - } - return; - } - let inter_h = h + 7; - for r in 0..inter_h { - let ry = (iy + r as i32 - 3).clamp(0, last_y) as usize; - hfilter(ry, &mut inter[r * w..r * w + w]); - } - for r in 0..h { - for c in 0..w { - let mut s = 0i32; - for t in 0..8 { - s += kv[t] as i32 * inter[(r + t) * w + c]; - } - pred[r * w + c] = round2(s, 11).clamp(0, max) as u16; - } - } -} - -/// The visible `w x h` area of a reference plane interpolated at the sub-sample phase -/// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for -/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses. -#[cfg(test)] -pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec { - let kh = &SUBPEL_FILTERS[EIGHTTAP][fx]; - let kv = &SUBPEL_FILTERS[EIGHTTAP][fy]; - let max = (1i32 << bit_depth) - 1; - let (lx, ly) = (w as i32 - 1, h as i32 - 1); - let mut inter = vec![0i32; w * h]; - for y in 0..h { - let row = refp.row(y); - for x in 0..w { - let mut s = 0i32; - for t in 0..8 { - s += kh[t] as i32 * row[(x as i32 + t as i32 - 3).clamp(0, lx) as usize] as i32; - } - inter[y * w + x] = round2(s, 3); - } - } - let mut out = vec![0u16; w * h]; - for y in 0..h { - for x in 0..w { - let mut s = 0i32; - for t in 0..8 { - s += kv[t] as i32 * inter[(y as i32 + t as i32 - 3).clamp(0, ly) as usize * w + x]; - } - out[y * w + x] = round2(s, 11).clamp(0, max) as u16; - } - } - out -} - -/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the -/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to -/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )), -/// so the result equals three `subpel_plane` calls bit for bit. -pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec; 3] { - let k = &SUBPEL_FILTERS[EIGHTTAP][8]; - let max = (1i32 << bit_depth) - 1; - let (lx, ly) = (w as i32 - 1, h as i32 - 1); - // horizontal pass at phase 8: Round2( sum, 3 ) - let mut hp = vec![0i32; w * h]; - for y in 0..h { - let row = &refp.row(y)[..w]; - let out = &mut hp[y * w..y * w + w]; - let tap = |x: i32| -> i32 { - let mut s = 0i32; - for t in 0..8 { - s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32; - } - round2(s, 3) - }; - if w >= 8 { - for x in 0..3 { - out[x] = tap(x as i32); - } - for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) { - let seg = &row[x - 3..x + 5]; - let mut s = 0i32; - for t in 0..8 { - s += k[t] as i32 * seg[t] as i32; - } - *o = round2(s, 3); - } - for x in w - 4..w { - out[x] = tap(x as i32); - } - } else { - for (x, o) in out.iter_mut().enumerate() { - *o = tap(x as i32); - } - } - } - let p80: Vec = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect(); - let mut p08 = vec![0u16; w * h]; - let mut p88 = vec![0u16; w * h]; - for y in 0..h { - let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize); - let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]); - for x in 0..w { - let (mut a, mut b) = (0i32, 0i32); - for t in 0..8 { - a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32; - b += k[t] as i32 * hp[rows[t] * w + x]; - } - o08[x] = round2(a, 7).clamp(0, max) as u16; - o88[x] = round2(b, 11).clamp(0, max) as u16; - } - } - [p80, p08, p88] -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Whole-plane interpolation equals block inter prediction at the same phase. - #[test] - fn subpel_plane_matches_block_prediction() { - let (w, h) = (37, 29); - let mut p = Plane::new(64, 64); - for (i, v) in p.data.iter_mut().enumerate() { - *v = ((i * 7919) % 256) as u16; - } - for (fx, fy) in [(8, 0), (0, 8), (8, 8), (4, 12)] { - let plane = subpel_plane(&p, w, h, fx, fy, 8); - for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] { - let mut pred = vec![0u16; 64]; - let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2]; - predict_inter( - &p, - &InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 }, - &mut pred, - ); - for r in 0..8 { - for c in 0..8 { - let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1)); - if bx + c == x && by + r == y { - assert_eq!(pred[(r * 8 + c) as usize], plane[y as usize * w + x as usize], "phase ({fx}, {fy}) at ({}, {})", bx + c, by + r); - } - } - } - } - } - } - - #[test] - fn half_planes_match_subpel_plane() { - for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] { - let mut p = Plane::new(64, 64); - for (i, v) in p.data.iter_mut().enumerate() { - *v = ((i * 7919) % 1024) as u16; - } - let hp = half_planes(&p, w, h, 10); - for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() { - assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})"); - } - } - } - - #[test] - fn phase_zero_filters_are_impulses() { - for f in SUBPEL_FILTERS.iter() { - assert_eq!(f[0], [0, 0, 0, 128, 0, 0, 0, 0]); - } - } -} diff --git a/crates/av1enc/src/symbol.rs b/crates/av1enc/src/symbol.rs deleted file mode 100644 index 579a51e20..000000000 --- a/crates/av1enc/src/symbol.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! Multi-symbol arithmetic encoder: the exact inverse of the symbol decoder of spec 8.2. -//! -//! The decoder keeps `SymbolValue`, the bitwise complement of a 15-bit window into the tile data -//! minus the lower bounds of the symbols decoded so far, and selects symbol `k` when -//! `cur(k) <= SymbolValue < cur(k - 1)` with -//! `cur(k) = ((SymbolRange >> 8) * ((32768 - cdf[k]) >> 6) >> 1) + 4 * (N - k - 1)` and -//! `cur(-1) = SymbolRange`. The encoder therefore runs an ordinary carry-propagating range -//! coder over the complemented bit stream: coding symbol `k` adds `cur(k)` to `low` and sets the -//! range to `cur(k - 1) - cur(k)`; both are renormalised exactly like the decoder's range. -//! -//! At the end (spec 8.2.4 exit process) the decoder has consumed `S` renormalisation bits plus a -//! 15-bit window, and the bits from position `S` on must be a one followed by zeros. In the -//! complemented domain that window is `0b011_1111_1111_1111`, so the encoder picks the `S`-bit -//! prefix `D` with `D * 2^15 + 2^14 - 1` inside `[low, low + range)`, i.e. -//! `D = (low + 2^14) >> 15`, and writes `!D`, a one bit and zero padding. - -pub(crate) struct SymbolWriter { - /// Low end of the interval, holding `15 + cnt` bits not yet moved to `out` (plus carries). - low: u64, - rng: u32, - cnt: u32, - out: Vec, - pub disable_update: bool, -} - -impl SymbolWriter { - pub fn new(disable_update: bool) -> Self { - SymbolWriter { low: 0, rng: 1 << 15, cnt: 0, out: Vec::with_capacity(1 << 16), disable_update } - } - - /// Adds `k` to the bytes already output (carry propagation). - fn carry(&mut self, mut k: u64) { - let mut i = self.out.len(); - while k != 0 { - i -= 1; - let t = self.out[i] as u64 + k; - self.out[i] = t as u8; - k = t >> 8; - } - } - - #[inline(always)] - fn encode(&mut self, cdf: &[u16], n: usize, s: usize) { - let r8 = self.rng >> 8; - let cur = |k: usize| -> u32 { ((r8 * ((32768 - cdf[k] as u32) >> 6)) >> 1) + 4 * (n - k - 1) as u32 }; - let hi = if s == 0 { self.rng } else { cur(s - 1) }; - let lo = cur(s); - debug_assert!(hi > lo, "symbol {s} has an empty interval"); - self.low += lo as u64; - self.rng = hi - lo; - let d = 15 - (31 - self.rng.leading_zeros()); - if d == 0 { - return; - } - self.rng <<= d; - self.low <<= d; - self.cnt += d; - while self.cnt >= 8 { - let sh = 15 + self.cnt - 8; - let c = self.low >> sh; - self.low &= (1u64 << sh) - 1; - self.cnt -= 8; - self.carry(c >> 8); - self.out.push((c & 0xff) as u8); - } - } - - /// Codes `s` with the adaptive CDF `cdf` (N values plus the adaptation counter). - #[inline] - pub fn symbol(&mut self, s: usize, cdf: &mut [u16]) { - let n = cdf.len() - 1; - debug_assert!(s < n); - self.encode(cdf, n, s); - if !self.disable_update { - update_cdf(cdf, n, s); - } - } - - /// Codes `s` with a CDF that is not adapted. - pub fn symbol_fixed(&mut self, s: usize, cdf: &[u16]) { - let n = cdf.len() - 1; - self.encode(cdf, n, s); - } - - /// `read_bool()` counterpart. - pub fn bool(&mut self, b: bool) { - self.encode(&[1 << 14, 1 << 15, 0], 2, b as usize); - } - - /// `L(n)` counterpart (most significant bit first). - #[cfg(test)] - pub fn literal(&mut self, n: u32, v: u32) { - for i in (0..n).rev() { - self.bool((v >> i) & 1 != 0); - } - } - - /// Finishes the tile: returns its bytes (with the trailing one bit and zero padding). - pub fn finish(mut self) -> Vec { - let low = self.low + (1 << 14); - let mut tail = low >> 15; - self.carry(tail >> self.cnt); - tail &= (1u64 << self.cnt) - 1; - let mut bytes: Vec = self.out.iter().map(|b| !b).collect(); - let n = self.cnt + 1; // 1..=8 bits: !tail then the trailing one bit - let v = (((!tail) & ((1u64 << self.cnt) - 1)) << 1) | 1; - bytes.push((v << (8 - n)) as u8); - bytes - } -} - -/// CDF adaptation (spec 8.2.6, as in the decoder). -#[inline(always)] -pub(crate) fn update_cdf(cdf: &mut [u16], n: usize, symbol: usize) { - let count = cdf[n]; - let rate = 3 + (count > 15) as u32 + (count > 31) as u32 + (31 - (n as u32).leading_zeros()).min(2); - let mut tmp = 0u32; - for i in 0..n - 1 { - if i == symbol { - tmp = 1 << 15; - } - let c = cdf[i] as u32; - if tmp < c { - cdf[i] = (c - ((c - tmp) >> rate)) as u16; - } else { - cdf[i] = (c + ((tmp - c) >> rate)) as u16; - } - } - cdf[n] += (count < 32) as u16; -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The spec's symbol decoder (8.2.2 - 8.2.4), for checking the encoder. - struct Dec<'a> { - data: &'a [u8], - pos: usize, - value: u32, - range: u32, - max_bits: i64, - } - - impl<'a> Dec<'a> { - fn new(data: &'a [u8]) -> Self { - let mut d = Dec { data, pos: 0, value: 0, range: 1 << 15, max_bits: 8 * data.len() as i64 - 15 }; - let nb = (data.len() * 8).min(15) as u32; - let buf = d.bits(nb); - d.value = ((1 << 15) - 1) ^ (buf << (15 - nb)); - d - } - fn bits(&mut self, n: u32) -> u32 { - let mut x = 0; - for _ in 0..n { - let b = if self.pos < self.data.len() * 8 { (self.data[self.pos >> 3] >> (7 - (self.pos & 7))) & 1 } else { 0 }; - self.pos += 1; - x = (x << 1) | b as u32; - } - x - } - fn symbol(&mut self, cdf: &mut [u16], adapt: bool) -> usize { - let n = cdf.len() - 1; - let mut cur = self.range; - let mut s = 0; - let mut prev; - loop { - prev = cur; - let f = (1u32 << 15) - cdf[s] as u32; - cur = (((self.range >> 8) * (f >> 6)) >> 1) + 4 * (n - s - 1) as u32; - if self.value >= cur { - break; - } - s += 1; - } - self.range = prev - cur; - self.value -= cur; - let b = 15 - (31 - self.range.leading_zeros()); - if b > 0 { - self.range <<= b; - let nb = (b as i64).min(self.max_bits.max(0)) as u32; - let nd = self.bits(nb) << (b - nb); - self.value = nd ^ (((self.value + 1) << b) - 1); - self.max_bits -= b as i64; - } - if adapt { - update_cdf(cdf, n, s); - } - s - } - /// Position of the trailing one bit (8.2.4). - fn trailing_position(&self) -> i64 { - let consumed = (self.pos as i64).min(self.data.len() as i64 * 8); - consumed - (15i64).min(self.max_bits + 15) - } - } - - #[test] - fn bools_only() { - for len in 1..200 { - let bits: Vec = (0..len).map(|i| (i * 7 + len) % 3 == 0).collect(); - let mut w = SymbolWriter::new(false); - for &b in &bits { - w.bool(b); - } - let data = w.finish(); - let mut d = Dec::new(&data); - for (i, &b) in bits.iter().enumerate() { - assert_eq!(d.symbol(&mut [1 << 14, 1 << 15, 0], false) == 1, b, "len {len} bit {i} data {data:?}"); - } - } - } - - #[test] - fn round_trip_random_symbols() { - let mut seed = 0x1234_5678u32; - let mut rnd = || { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - seed - }; - for trial in 0..300 { - let count = 1 + (rnd() % 3000) as usize; - let mut cdfs_e = vec![[4096u16, 11264, 19328, 32768, 0]; 4]; - let mut cdfs_d = cdfs_e.clone(); - let mut syms = Vec::new(); - let mut w = SymbolWriter::new(false); - for _ in 0..count { - let kind = rnd() % 3; - let ctx = (rnd() % 4) as usize; - // skewed symbols so that the range also gets small - let s = if rnd() % 4 == 0 { (rnd() % 4) as usize } else { 0 }; - match kind { - 0 => w.symbol(s, &mut cdfs_e[ctx]), - 1 => w.bool(s & 1 == 1), - _ => w.literal(5, s as u32 * 7), - } - syms.push((kind, ctx, s)); - } - let data = w.finish(); - let mut d = Dec::new(&data); - for (idx, &(kind, ctx, s)) in syms.iter().enumerate() { - let got = match kind { - 0 => d.symbol(&mut cdfs_d[ctx], true), - 1 => d.symbol(&mut [1 << 14, 1 << 15, 0], false), - _ => { - let mut v = 0; - for _ in 0..5 { - v = (v << 1) | d.symbol(&mut [1 << 14, 1 << 15, 0], false); - } - v / 7 - } - }; - let want = if kind == 1 { s & 1 } else { s }; - assert_eq!(got, want, "trial {trial} idx {idx} kind {kind} of {}", syms.len()); - } - let tp = d.trailing_position(); - assert!(tp >= 0 && (tp as usize) < data.len() * 8, "trial {trial}: trailing bit position {tp}"); - let tp = tp as usize; - assert_eq!((data[tp >> 3] >> (7 - (tp & 7))) & 1, 1, "trial {trial}: trailing one bit"); - for p in tp + 1..data.len() * 8 { - assert_eq!((data[p >> 3] >> (7 - (p & 7))) & 1, 0, "trial {trial}: padding"); - } - } - } -} diff --git a/crates/av1enc/src/tables.rs b/crates/av1enc/src/tables.rs deleted file mode 100644 index 3e333a519..000000000 --- a/crates/av1enc/src/tables.rs +++ /dev/null @@ -1,4805 +0,0 @@ -// Tables mirrored from filmcraft-av1's spec_tables.rs, which extracts them from the AV1 Bitstream & -// Decoding Process Specification v1.0.0 with Errata 1. Only the tables this encoder uses are kept. -#![allow(dead_code, clippy::unreadable_literal)] - -pub const ADST_ADST: usize = 3; -pub const ADST_DCT: usize = 1; -pub const ADST_FLIPADST: usize = 7; -pub const AFFINE: usize = 3; -pub const ALTREF2_FRAME: usize = 6; -pub const ALTREF_FRAME: usize = 7; -pub const ANGLE_STEP: usize = 3; -pub const BIDIR_COMP_REFERENCE: usize = 1; -pub const BILINEAR: usize = 3; -pub const BLOCK_128X128: usize = 15; -pub const BLOCK_128X64: usize = 14; -pub const BLOCK_16X16: usize = 6; -pub const BLOCK_16X32: usize = 7; -pub const BLOCK_16X4: usize = 17; -pub const BLOCK_16X64: usize = 20; -pub const BLOCK_16X8: usize = 5; -pub const BLOCK_32X16: usize = 8; -pub const BLOCK_32X32: usize = 9; -pub const BLOCK_32X64: usize = 10; -pub const BLOCK_32X8: usize = 19; -pub const BLOCK_4X16: usize = 16; -pub const BLOCK_4X4: usize = 0; -pub const BLOCK_4X8: usize = 1; -pub const BLOCK_64X128: usize = 13; -pub const BLOCK_64X16: usize = 21; -pub const BLOCK_64X32: usize = 11; -pub const BLOCK_64X64: usize = 12; -pub const BLOCK_8X16: usize = 4; -pub const BLOCK_8X32: usize = 18; -pub const BLOCK_8X4: usize = 2; -pub const BLOCK_8X8: usize = 3; -pub const BLOCK_INVALID: usize = 22; -pub const BLOCK_SIZES: usize = 22; -pub const BLOCK_SIZE_GROUPS: usize = 4; -pub const BR_CDF_SIZE: usize = 4; -pub const BUFFER_POOL_MAX_SIZE: usize = 10; -pub const BWDREF_FRAME: usize = 5; -pub const BWD_REFS: usize = 3; -pub const CFL_ALPHABET_SIZE: usize = 16; -pub const CFL_ALPHA_CONTEXTS: usize = 6; -pub const CFL_JOINT_SIGNS: usize = 8; -pub const CFL_SIGN_NEG: usize = 1; -pub const CFL_SIGN_POS: usize = 2; -pub const CFL_SIGN_ZERO: usize = 0; -pub const CLASS0_SIZE: usize = 2; -pub const COEFF_BASE_RANGE: usize = 12; -pub const COEFF_CDF_Q_CTXS: usize = 4; -pub const COMPOUND_AVERAGE: usize = 2; -pub const COMPOUND_DIFFWTD: usize = 1; -pub const COMPOUND_DISTANCE: usize = 4; -pub const COMPOUND_IDX_CONTEXTS: usize = 6; -pub const COMPOUND_INTRA: usize = 3; -pub const COMPOUND_MODES: usize = 8; -pub const COMPOUND_MODE_CONTEXTS: usize = 8; -pub const COMPOUND_REFERENCE: usize = 1; -pub const COMPOUND_TYPES: usize = 2; -pub const COMPOUND_WEDGE: usize = 0; -pub const COMP_GROUP_IDX_CONTEXTS: usize = 6; -pub const COMP_INTER_CONTEXTS: usize = 5; -pub const COMP_NEWMV_CTXS: usize = 5; -pub const COMP_REF_TYPE_CONTEXTS: usize = 5; -pub const CP_BT_2020: usize = 9; -pub const CP_BT_470_B_G: usize = 5; -pub const CP_BT_470_M: usize = 4; -pub const CP_BT_601: usize = 6; -pub const CP_BT_709: usize = 1; -pub const CP_EBU_3213: usize = 22; -pub const CP_GENERIC_FILM: usize = 8; -pub const CP_SMPTE_240: usize = 7; -pub const CP_SMPTE_431: usize = 11; -pub const CP_SMPTE_432: usize = 12; -pub const CP_UNSPECIFIED: usize = 2; -pub const CP_XYZ: usize = 10; -pub const CSP_COLOCATED: usize = 2; -pub const CSP_RESERVED: usize = 3; -pub const CSP_UNKNOWN: usize = 0; -pub const CSP_VERTICAL: usize = 1; -pub const D113_PRED: usize = 5; -pub const D135_PRED: usize = 4; -pub const D157_PRED: usize = 6; -pub const D203_PRED: usize = 7; -pub const D45_PRED: usize = 3; -pub const D67_PRED: usize = 8; -pub const DCT_ADST: usize = 2; -pub const DCT_DCT: usize = 0; -pub const DCT_FLIPADST: usize = 5; -pub const DC_PRED: usize = 0; -pub const DC_SIGN_CONTEXTS: usize = 3; -pub const DELTA_LF_SMALL: usize = 3; -pub const DELTA_Q_SMALL: usize = 3; -pub const DIRECTIONAL_MODES: usize = 8; -pub const DIV_LUT_BITS: usize = 8; -pub const DIV_LUT_NUM: usize = 257; -pub const DIV_LUT_PREC_BITS: usize = 14; -pub const DRL_MODE_CONTEXTS: usize = 3; -pub const EC_MIN_PROB: usize = 4; -pub const EC_PROB_SHIFT: usize = 6; -pub const EIGHTTAP: usize = 0; -pub const EIGHTTAP_SHARP: usize = 2; -pub const EIGHTTAP_SMOOTH: usize = 1; -pub const EOB_COEF_CONTEXTS: usize = 9; -pub const FILTER_BITS: usize = 7; -pub const FILTER_D157_PRED: usize = 3; -pub const FILTER_DC_PRED: usize = 0; -pub const FILTER_H_PRED: usize = 2; -pub const FILTER_PAETH_PRED: usize = 4; -pub const FILTER_V_PRED: usize = 1; -pub const FLIPADST_ADST: usize = 8; -pub const FLIPADST_DCT: usize = 4; -pub const FLIPADST_FLIPADST: usize = 6; -pub const FRAME_LF_COUNT: usize = 4; -pub const FWD_REFS: usize = 4; -pub const GLOBALMV: usize = 16; -pub const GLOBAL_GLOBALMV: usize = 24; -pub const GM_ABS_ALPHA_BITS: usize = 12; -pub const GM_ABS_TRANS_BITS: usize = 12; -pub const GM_ABS_TRANS_ONLY_BITS: usize = 9; -pub const GM_ALPHA_PREC_BITS: usize = 15; -pub const GM_TRANS_ONLY_PREC_BITS: usize = 3; -pub const GM_TRANS_PREC_BITS: usize = 6; -pub const GOLDEN_FRAME: usize = 4; -pub const H_ADST: usize = 13; -pub const H_DCT: usize = 11; -pub const H_FLIPADST: usize = 15; -pub const H_PRED: usize = 2; -pub const IDENTITY: usize = 0; -pub const IDTX: usize = 9; -pub const II_DC_PRED: usize = 0; -pub const II_H_PRED: usize = 2; -pub const II_SMOOTH_PRED: usize = 3; -pub const II_V_PRED: usize = 1; -pub const INTERINTRA_MODES: usize = 4; -pub const INTERP_FILTERS: usize = 3; -pub const INTERP_FILTER_CONTEXTS: usize = 16; -pub const INTER_FRAME: usize = 1; -pub const INTRABC_DELAY_PIXELS: usize = 256; -pub const INTRABC_DELAY_SB64: usize = 4; -pub const INTRA_EDGE_KERNELS: usize = 3; -pub const INTRA_EDGE_TAPS: usize = 5; -pub const INTRA_FILTER_MODES: usize = 5; -pub const INTRA_FILTER_SCALE_BITS: usize = 4; -pub const INTRA_FRAME: usize = 0; -pub const INTRA_MODES: usize = 13; -pub const INTRA_MODE_CONTEXTS: usize = 5; -pub const INTRA_ONLY_FRAME: usize = 2; -pub const IS_INTER_CONTEXTS: usize = 4; -pub const KEY_FRAME: usize = 0; -pub const LAST2_FRAME: usize = 2; -pub const LAST3_FRAME: usize = 3; -pub const LAST_FRAME: usize = 1; -pub const LEAST_SQUARES_SAMPLES_MAX: usize = 8; -pub const LEVEL_CONTEXTS: usize = 21; -pub const LOCALWARP: usize = 2; -pub const LS_MV_MAX: usize = 256; -pub const MASK_MASTER_SIZE: usize = 64; -pub const MAX_ANGLE_DELTA: usize = 3; -pub const MAX_FRAME_DISTANCE: usize = 31; -pub const MAX_LOOP_FILTER: usize = 63; -pub const MAX_OFFSET_HEIGHT: usize = 0; -pub const MAX_OFFSET_WIDTH: usize = 8; -pub const MAX_REF_MV_STACK_SIZE: usize = 8; -pub const MAX_SB_SIZE: usize = 128; -pub const MAX_SEGMENTS: usize = 8; -pub const MAX_TILE_AREA: usize = 9437184; -pub const MAX_TILE_COLS: usize = 64; -pub const MAX_TILE_ROWS: usize = 64; -pub const MAX_TILE_WIDTH: usize = 4096; -pub const MAX_TX_DEPTH: usize = 2; -pub const MAX_VARTX_DEPTH: usize = 2; -pub const MB_MODE_COUNT: usize = 17; -pub const MC_BT_2020_CL: usize = 10; -pub const MC_BT_2020_NCL: usize = 9; -pub const MC_BT_470_B_G: usize = 5; -pub const MC_BT_601: usize = 6; -pub const MC_BT_709: usize = 1; -pub const MC_CHROMAT_CL: usize = 13; -pub const MC_CHROMAT_NCL: usize = 12; -pub const MC_FCC: usize = 4; -pub const MC_ICTCP: usize = 14; -pub const MC_IDENTITY: usize = 0; -pub const MC_RESERVED_3: usize = 3; -pub const MC_SMPTE_2085: usize = 11; -pub const MC_SMPTE_240: usize = 7; -pub const MC_SMPTE_YCGCO: usize = 8; -pub const MC_UNSPECIFIED: usize = 2; -pub const METADATA_TYPE_HDR_CLL: usize = 1; -pub const METADATA_TYPE_HDR_MDCV: usize = 2; -pub const METADATA_TYPE_ITUT_T35: usize = 4; -pub const METADATA_TYPE_SCALABILITY: usize = 3; -pub const METADATA_TYPE_TIMECODE: usize = 5; -pub const MFMV_STACK_SIZE: usize = 3; -pub const MI_SIZE: usize = 4; -pub const MI_SIZE_LOG2: usize = 2; -pub const MOTION_MODES: usize = 3; -pub const MV_BORDER: usize = 128; -pub const MV_CLASSES: usize = 11; -pub const MV_CLASS_0: usize = 0; -pub const MV_CLASS_1: usize = 1; -pub const MV_CLASS_10: usize = 10; -pub const MV_CLASS_2: usize = 2; -pub const MV_CLASS_3: usize = 3; -pub const MV_CLASS_4: usize = 4; -pub const MV_CLASS_5: usize = 5; -pub const MV_CLASS_6: usize = 6; -pub const MV_CLASS_7: usize = 7; -pub const MV_CLASS_8: usize = 8; -pub const MV_CLASS_9: usize = 9; -pub const MV_CONTEXTS: usize = 2; -pub const MV_INTRABC_CONTEXT: usize = 1; -pub const MV_JOINTS: usize = 4; -pub const MV_JOINT_HNZVNZ: usize = 3; -pub const MV_JOINT_HNZVZ: usize = 1; -pub const MV_JOINT_HZVNZ: usize = 2; -pub const MV_JOINT_ZERO: usize = 0; -pub const MV_OFFSET_BITS: usize = 10; -pub const NEARESTMV: usize = 14; -pub const NEAREST_NEARESTMV: usize = 18; -pub const NEAREST_NEWMV: usize = 20; -pub const NEARMV: usize = 15; -pub const NEAR_NEARMV: usize = 19; -pub const NEAR_NEWMV: usize = 22; -pub const NEWMV: usize = 17; -pub const NEW_MV_CONTEXTS: usize = 6; -pub const NEW_NEARESTMV: usize = 21; -pub const NEW_NEARMV: usize = 23; -pub const NEW_NEWMV: usize = 25; -pub const NUM_BASE_LEVELS: usize = 2; -pub const NUM_REF_FRAMES: usize = 8; -pub const OBMC: usize = 1; -pub const OBU_FRAME: usize = 6; -pub const OBU_FRAME_HEADER: usize = 3; -pub const OBU_METADATA: usize = 5; -pub const OBU_PADDING: usize = 15; -pub const OBU_REDUNDANT_FRAME_HEADER: usize = 7; -pub const OBU_SEQUENCE_HEADER: usize = 1; -pub const OBU_TEMPORAL_DELIMITER: usize = 2; -pub const OBU_TILE_GROUP: usize = 4; -pub const OBU_TILE_LIST: usize = 8; -pub const ONLY_4X4: usize = 0; -pub const PAETH_PRED: usize = 12; -pub const PALETTE_BLOCK_SIZE_CONTEXTS: usize = 7; -pub const PALETTE_COLORS: usize = 8; -pub const PALETTE_COLOR_CONTEXTS: usize = 5; -pub const PALETTE_MAX_COLOR_CONTEXT_HASH: usize = 8; -pub const PALETTE_NUM_NEIGHBORS: usize = 3; -pub const PALETTE_SIZES: usize = 7; -pub const PALETTE_UV_MODE_CONTEXTS: usize = 2; -pub const PALETTE_Y_MODE_CONTEXTS: usize = 3; -pub const PARTITION_CONTEXTS: usize = 4; -pub const PARTITION_HORZ: usize = 1; -pub const PARTITION_HORZ_4: usize = 8; -pub const PARTITION_HORZ_A: usize = 4; -pub const PARTITION_HORZ_B: usize = 5; -pub const PARTITION_NONE: usize = 0; -pub const PARTITION_SPLIT: usize = 3; -pub const PARTITION_VERT: usize = 2; -pub const PARTITION_VERT_4: usize = 9; -pub const PARTITION_VERT_A: usize = 6; -pub const PARTITION_VERT_B: usize = 7; -pub const PLANE_TYPES: usize = 2; -pub const PRIMARY_REF_NONE: usize = 7; -pub const QM_TOTAL_SIZE: usize = 3344; -pub const REFMVS_LIMIT: usize = 4095; -pub const REFS_PER_FRAME: usize = 7; -pub const REF_CAT_LEVEL: usize = 640; -pub const REF_CONTEXTS: usize = 3; -pub const REF_MV_CONTEXTS: usize = 6; -pub const REF_SCALE_SHIFT: usize = 14; -pub const RESTORATION_TILESIZE_MAX: usize = 256; -pub const RESTORE_NONE: usize = 0; -pub const RESTORE_SGRPROJ: usize = 2; -pub const RESTORE_SWITCHABLE: usize = 3; -pub const RESTORE_WIENER: usize = 1; -pub const ROTZOOM: usize = 2; -pub const SCALABILITY_L1T2: usize = 0; -pub const SCALABILITY_L1T3: usize = 1; -pub const SCALABILITY_L2T1: usize = 2; -pub const SCALABILITY_L2T2: usize = 3; -pub const SCALABILITY_L2T3: usize = 4; -pub const SCALABILITY_L3T1: usize = 15; -pub const SCALABILITY_L3T2: usize = 16; -pub const SCALABILITY_L3T2_KEY: usize = 21; -pub const SCALABILITY_L3T2_KEY_SHIFT: usize = 25; -pub const SCALABILITY_L3T3: usize = 17; -pub const SCALABILITY_L3T3_KEY: usize = 22; -pub const SCALABILITY_L3T3_KEY_SHIFT: usize = 26; -pub const SCALABILITY_L4T5_KEY: usize = 23; -pub const SCALABILITY_L4T5_KEY_SHIFT: usize = 27; -pub const SCALABILITY_L4T7_KEY: usize = 24; -pub const SCALABILITY_L4T7_KEY_SHIFT: usize = 28; -pub const SCALABILITY_S2T1: usize = 5; -pub const SCALABILITY_S2T2: usize = 6; -pub const SCALABILITY_S2T3: usize = 7; -pub const SCALABILITY_S3T1: usize = 18; -pub const SCALABILITY_S3T2: usize = 19; -pub const SCALABILITY_S3T3: usize = 20; -pub const SCALABILITY_SS: usize = 14; -pub const SCALE_SUBPEL_BITS: usize = 10; -pub const SEGMENT_ID_CONTEXTS: usize = 3; -pub const SEGMENT_ID_PREDICTED_CONTEXTS: usize = 3; -pub const SEG_LVL_ALT_LF_Y_V: usize = 1; -pub const SEG_LVL_ALT_Q: usize = 0; -pub const SEG_LVL_GLOBALMV: usize = 7; -pub const SEG_LVL_MAX: usize = 8; -pub const SEG_LVL_REF_FRAME: usize = 5; -pub const SEG_LVL_SKIP: usize = 6; -pub const SELECT_INTEGER_MV: usize = 2; -pub const SELECT_SCREEN_CONTENT_TOOLS: usize = 2; -pub const SGRPROJ_MTABLE_BITS: usize = 20; -pub const SGRPROJ_PARAMS_BITS: usize = 4; -pub const SGRPROJ_PRJ_BITS: usize = 7; -pub const SGRPROJ_PRJ_SUBEXP_K: usize = 4; -pub const SGRPROJ_RECIP_BITS: usize = 12; -pub const SGRPROJ_RST_BITS: usize = 4; -pub const SGRPROJ_SGR_BITS: usize = 8; -pub const SIG_COEF_CONTEXTS: usize = 42; -pub const SIG_COEF_CONTEXTS_2D: usize = 26; -pub const SIG_COEF_CONTEXTS_EOB: usize = 4; -pub const SIG_REF_DIFF_OFFSET_NUM: usize = 5; -pub const SIMPLE: usize = 0; -pub const SINGLE_REFERENCE: usize = 0; -pub const SINGLE_REFS: usize = 7; -pub const SINPI_1_9: usize = 1321; -pub const SINPI_2_9: usize = 2482; -pub const SINPI_3_9: usize = 3344; -pub const SINPI_4_9: usize = 3803; -pub const SKIP_CONTEXTS: usize = 3; -pub const SKIP_MODE_CONTEXTS: usize = 3; -pub const SMOOTH_H_PRED: usize = 11; -pub const SMOOTH_PRED: usize = 9; -pub const SMOOTH_V_PRED: usize = 10; -pub const SUBPEL_BITS: usize = 4; -pub const SUBPEL_MASK: usize = 15; -pub const SUPERRES_DENOM_BITS: usize = 3; -pub const SUPERRES_DENOM_MIN: usize = 9; -pub const SUPERRES_EXTRA_BITS: usize = 8; -pub const SUPERRES_FILTER_BITS: usize = 6; -pub const SUPERRES_FILTER_OFFSET: usize = 3; -pub const SUPERRES_FILTER_SHIFTS: usize = 64; -pub const SUPERRES_FILTER_TAPS: usize = 8; -pub const SUPERRES_NUM: usize = 8; -pub const SUPERRES_SCALE_BITS: usize = 14; -pub const SUPERRES_SCALE_MASK: usize = 16383; -pub const SWITCHABLE: usize = 4; -pub const SWITCH_FRAME: usize = 3; -pub const TC_BT_1361: usize = 12; -pub const TC_BT_2020_10_BIT: usize = 14; -pub const TC_BT_2020_12_BIT: usize = 15; -pub const TC_BT_470_B_G: usize = 5; -pub const TC_BT_470_M: usize = 4; -pub const TC_BT_601: usize = 6; -pub const TC_BT_709: usize = 1; -pub const TC_HLG: usize = 18; -pub const TC_IEC_61966: usize = 11; -pub const TC_LINEAR: usize = 8; -pub const TC_LOG_100: usize = 9; -pub const TC_LOG_100_SQRT10: usize = 10; -pub const TC_RESERVED_0: usize = 0; -pub const TC_RESERVED_3: usize = 3; -pub const TC_SMPTE_2084: usize = 16; -pub const TC_SMPTE_240: usize = 7; -pub const TC_SMPTE_428: usize = 17; -pub const TC_SRGB: usize = 13; -pub const TC_UNSPECIFIED: usize = 2; -pub const TOTAL_REFS_PER_FRAME: usize = 8; -pub const TRANSLATION: usize = 1; -pub const TXB_SKIP_CONTEXTS: usize = 13; -pub const TXFM_PARTITION_CONTEXTS: usize = 21; -pub const TX_16X16: usize = 2; -pub const TX_16X32: usize = 9; -pub const TX_16X4: usize = 14; -pub const TX_16X64: usize = 17; -pub const TX_16X8: usize = 8; -pub const TX_32X16: usize = 10; -pub const TX_32X32: usize = 3; -pub const TX_32X64: usize = 11; -pub const TX_32X8: usize = 16; -pub const TX_4X16: usize = 13; -pub const TX_4X4: usize = 0; -pub const TX_4X8: usize = 5; -pub const TX_64X16: usize = 18; -pub const TX_64X32: usize = 12; -pub const TX_64X64: usize = 4; -pub const TX_8X16: usize = 7; -pub const TX_8X32: usize = 15; -pub const TX_8X4: usize = 6; -pub const TX_8X8: usize = 1; -pub const TX_CLASS_2D: usize = 0; -pub const TX_CLASS_HORIZ: usize = 1; -pub const TX_CLASS_VERT: usize = 2; -pub const TX_MODES: usize = 3; -pub const TX_MODE_LARGEST: usize = 1; -pub const TX_MODE_SELECT: usize = 2; -pub const TX_SET_DCTONLY: usize = 0; -pub const TX_SET_INTER_1: usize = 1; -pub const TX_SET_INTER_2: usize = 2; -pub const TX_SET_INTER_3: usize = 3; -pub const TX_SET_INTRA_1: usize = 1; -pub const TX_SET_INTRA_2: usize = 2; -pub const TX_SET_TYPES_INTER: usize = 4; -pub const TX_SET_TYPES_INTRA: usize = 3; -pub const TX_SIZES: usize = 5; -pub const TX_SIZES_ALL: usize = 19; -pub const TX_SIZE_CONTEXTS: usize = 3; -pub const TX_TYPES: usize = 16; -pub const UNIDIR_COMP_REFERENCE: usize = 0; -pub const UNIDIR_COMP_REFS: usize = 4; -pub const UNIFORM_45: usize = 0; -pub const UNIFORM_45_INV: usize = 1; -pub const UV_CFL_PRED: usize = 13; -pub const UV_INTRA_MODES_CFL_ALLOWED: usize = 14; -pub const UV_INTRA_MODES_CFL_NOT_ALLOWED: usize = 13; -pub const V_ADST: usize = 12; -pub const V_DCT: usize = 10; -pub const V_FLIPADST: usize = 14; -pub const V_PRED: usize = 1; -pub const WARPEDDIFF_PREC_BITS: usize = 10; -pub const WARPEDMODEL_NONDIAGAFFINE_CLAMP: usize = 8192; -pub const WARPEDMODEL_PREC_BITS: usize = 16; -pub const WARPEDMODEL_TRANS_CLAMP: usize = 8388608; -pub const WARPEDPIXEL_PREC_SHIFTS: usize = 64; -pub const WARP_PARAM_REDUCE_BITS: usize = 6; -pub const WEDGE_HORIZONTAL: usize = 0; -pub const WEDGE_OBLIQUE117: usize = 4; -pub const WEDGE_OBLIQUE153: usize = 5; -pub const WEDGE_OBLIQUE27: usize = 2; -pub const WEDGE_OBLIQUE63: usize = 3; -pub const WEDGE_TYPES: usize = 16; -pub const WEDGE_VERTICAL: usize = 1; -pub const WIENER_COEFFS: usize = 3; -pub const ZERO_MV_CONTEXTS: usize = 2; - -pub static AC_QLOOKUP: [[u16; 256]; 3] = [ - [ - 4, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, - 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 124, 126, 128, - 130, 132, 134, 136, 138, 140, 142, 144, 146, 148, 150, 152, 155, 158, 161, 164, 167, 170, 173, 176, 179, 182, 185, 188, 191, 194, 197, 200, 203, 207, - 211, 215, 219, 223, 227, 231, 235, 239, 243, 247, 251, 255, 260, 265, 270, 275, 280, 285, 290, 295, 300, 305, 311, 317, 323, 329, 335, 341, 347, 353, - 359, 366, 373, 380, 387, 394, 401, 408, 416, 424, 432, 440, 448, 456, 465, 474, 483, 492, 501, 510, 520, 530, 540, 550, 560, 571, 582, 593, 604, 615, - 627, 639, 651, 663, 676, 689, 702, 715, 729, 743, 757, 771, 786, 801, 816, 832, 848, 864, 881, 898, 915, 933, 951, 969, 988, 1007, 1026, 1046, 1066, - 1087, 1108, 1129, 1151, 1173, 1196, 1219, 1243, 1267, 1292, 1317, 1343, 1369, 1396, 1423, 1451, 1479, 1508, 1537, 1567, 1597, 1628, 1660, 1692, 1725, - 1759, 1793, 1828, - ], - [ - 4, 9, 11, 13, 16, 18, 21, 24, 27, 30, 33, 37, 40, 44, 48, 51, 55, 59, 63, 67, 71, 75, 79, 83, 88, 92, 96, 100, 105, 109, 114, 118, 122, 127, 131, 136, - 140, 145, 149, 154, 158, 163, 168, 172, 177, 181, 186, 190, 195, 199, 204, 208, 213, 217, 222, 226, 231, 235, 240, 244, 249, 253, 258, 262, 267, 271, - 275, 280, 284, 289, 293, 297, 302, 306, 311, 315, 319, 324, 328, 332, 337, 341, 345, 349, 354, 358, 362, 367, 371, 375, 379, 384, 388, 392, 396, 401, - 409, 417, 425, 433, 441, 449, 458, 466, 474, 482, 490, 498, 506, 514, 523, 531, 539, 547, 555, 563, 571, 579, 588, 596, 604, 616, 628, 640, 652, 664, - 676, 688, 700, 713, 725, 737, 749, 761, 773, 785, 797, 809, 825, 841, 857, 873, 889, 905, 922, 938, 954, 970, 986, 1002, 1018, 1038, 1058, 1078, 1098, - 1118, 1138, 1158, 1178, 1198, 1218, 1242, 1266, 1290, 1314, 1338, 1362, 1386, 1411, 1435, 1463, 1491, 1519, 1547, 1575, 1603, 1631, 1663, 1695, 1727, - 1759, 1791, 1823, 1859, 1895, 1931, 1967, 2003, 2039, 2079, 2119, 2159, 2199, 2239, 2283, 2327, 2371, 2415, 2459, 2507, 2555, 2603, 2651, 2703, 2755, - 2807, 2859, 2915, 2971, 3027, 3083, 3143, 3203, 3263, 3327, 3391, 3455, 3523, 3591, 3659, 3731, 3803, 3876, 3952, 4028, 4104, 4184, 4264, 4348, 4432, - 4516, 4604, 4692, 4784, 4876, 4972, 5068, 5168, 5268, 5372, 5476, 5584, 5692, 5804, 5916, 6032, 6148, 6268, 6388, 6512, 6640, 6768, 6900, 7036, 7172, - 7312, - ], - [ - 4, 13, 19, 27, 35, 44, 54, 64, 75, 87, 99, 112, 126, 139, 154, 168, 183, 199, 214, 230, 247, 263, 280, 297, 314, 331, 349, 366, 384, 402, 420, 438, - 456, 475, 493, 511, 530, 548, 567, 586, 604, 623, 642, 660, 679, 698, 716, 735, 753, 772, 791, 809, 828, 846, 865, 884, 902, 920, 939, 957, 976, 994, - 1012, 1030, 1049, 1067, 1085, 1103, 1121, 1139, 1157, 1175, 1193, 1211, 1229, 1246, 1264, 1282, 1299, 1317, 1335, 1352, 1370, 1387, 1405, 1422, 1440, - 1457, 1474, 1491, 1509, 1526, 1543, 1560, 1577, 1595, 1627, 1660, 1693, 1725, 1758, 1791, 1824, 1856, 1889, 1922, 1954, 1987, 2020, 2052, 2085, 2118, - 2150, 2183, 2216, 2248, 2281, 2313, 2346, 2378, 2411, 2459, 2508, 2556, 2605, 2653, 2701, 2750, 2798, 2847, 2895, 2943, 2992, 3040, 3088, 3137, 3185, - 3234, 3298, 3362, 3426, 3491, 3555, 3619, 3684, 3748, 3812, 3876, 3941, 4005, 4069, 4149, 4230, 4310, 4390, 4470, 4550, 4631, 4711, 4791, 4871, 4967, - 5064, 5160, 5256, 5352, 5448, 5544, 5641, 5737, 5849, 5961, 6073, 6185, 6297, 6410, 6522, 6650, 6778, 6906, 7034, 7162, 7290, 7435, 7579, 7723, 7867, - 8011, 8155, 8315, 8475, 8635, 8795, 8956, 9132, 9308, 9484, 9660, 9836, 10028, 10220, 10412, 10604, 10812, 11020, 11228, 11437, 11661, 11885, 12109, - 12333, 12573, 12813, 13053, 13309, 13565, 13821, 14093, 14365, 14637, 14925, 15213, 15502, 15806, 16110, 16414, 16734, 17054, 17390, 17726, 18062, - 18414, 18766, 19134, 19502, 19886, 20270, 20670, 21070, 21486, 21902, 22334, 22766, 23214, 23662, 24126, 24590, 25070, 25551, 26047, 26559, 27071, - 27599, 28143, 28687, 29247, - ], -]; - -pub static ADJUSTED_TX_SIZE: [u8; 19] = [0, 1, 2, 3, 3, 5, 6, 7, 8, 9, 10, 3, 3, 13, 14, 15, 16, 9, 10]; - -pub static COEFF_BASE_CTX_OFFSET: [[[u8; 5]; 5]; 19] = [ - [[0, 1, 6, 6, 0], [1, 6, 6, 21, 0], [6, 6, 21, 21, 0], [6, 21, 21, 21, 0], [0, 0, 0, 0, 0]], - [[0, 1, 6, 6, 21], [1, 6, 6, 21, 21], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 1, 6, 6, 21], [1, 6, 6, 21, 21], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 1, 6, 6, 21], [1, 6, 6, 21, 21], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 1, 6, 6, 21], [1, 6, 6, 21, 21], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 11, 11, 11, 0], [11, 11, 11, 11, 0], [6, 6, 21, 21, 0], [6, 21, 21, 21, 0], [21, 21, 21, 21, 0]], - [[0, 16, 6, 6, 21], [16, 16, 6, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21], [0, 0, 0, 0, 0]], - [[0, 11, 11, 11, 11], [11, 11, 11, 11, 11], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 16, 6, 6, 21], [16, 16, 6, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21]], - [[0, 11, 11, 11, 11], [11, 11, 11, 11, 11], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 16, 6, 6, 21], [16, 16, 6, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21]], - [[0, 11, 11, 11, 11], [11, 11, 11, 11, 11], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 16, 6, 6, 21], [16, 16, 6, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21]], - [[0, 11, 11, 11, 0], [11, 11, 11, 11, 0], [6, 6, 21, 21, 0], [6, 21, 21, 21, 0], [21, 21, 21, 21, 0]], - [[0, 16, 6, 6, 21], [16, 16, 6, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21], [0, 0, 0, 0, 0]], - [[0, 11, 11, 11, 11], [11, 11, 11, 11, 11], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 16, 6, 6, 21], [16, 16, 6, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21]], - [[0, 11, 11, 11, 11], [11, 11, 11, 11, 11], [6, 6, 21, 21, 21], [6, 21, 21, 21, 21], [21, 21, 21, 21, 21]], - [[0, 16, 6, 6, 21], [16, 16, 6, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21], [16, 16, 21, 21, 21]], -]; - -pub static COEFF_BASE_POS_CTX_OFFSET: [u8; 3] = [26, 31, 36]; - -pub static COS128_LOOKUP: [u16; 65] = [ - 4096, 4095, 4091, 4085, 4076, 4065, 4052, 4036, 4017, 3996, 3973, 3948, 3920, 3889, 3857, 3822, 3784, 3745, 3703, 3659, 3612, 3564, 3513, 3461, 3406, 3349, - 3290, 3229, 3166, 3102, 3035, 2967, 2896, 2824, 2751, 2675, 2598, 2520, 2440, 2359, 2276, 2191, 2106, 2019, 1931, 1842, 1751, 1660, 1567, 1474, 1380, 1285, - 1189, 1092, 995, 897, 799, 700, 601, 501, 401, 301, 201, 101, 0, -]; - -pub static DC_QLOOKUP: [[u16; 256]; 3] = [ - [ - 4, 8, 8, 9, 10, 11, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23, 24, 25, 26, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 35, 36, 37, 38, 38, 39, - 40, 41, 42, 43, 43, 44, 45, 46, 47, 48, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 57, 58, 59, 60, 61, 62, 62, 63, 64, 65, 66, 66, 67, 68, 69, 70, 70, - 71, 72, 73, 74, 74, 75, 76, 77, 78, 78, 79, 80, 81, 81, 82, 83, 84, 85, 85, 87, 88, 90, 92, 93, 95, 96, 98, 99, 101, 102, 104, 105, 107, 108, 110, 111, - 113, 114, 116, 117, 118, 120, 121, 123, 125, 127, 129, 131, 134, 136, 138, 140, 142, 144, 146, 148, 150, 152, 154, 156, 158, 161, 164, 166, 169, 172, - 174, 177, 180, 182, 185, 187, 190, 192, 195, 199, 202, 205, 208, 211, 214, 217, 220, 223, 226, 230, 233, 237, 240, 243, 247, 250, 253, 257, 261, 265, - 269, 272, 276, 280, 284, 288, 292, 296, 300, 304, 309, 313, 317, 322, 326, 330, 335, 340, 344, 349, 354, 359, 364, 369, 374, 379, 384, 389, 395, 400, - 406, 411, 417, 423, 429, 435, 441, 447, 454, 461, 467, 475, 482, 489, 497, 505, 513, 522, 530, 539, 549, 559, 569, 579, 590, 602, 614, 626, 640, 654, - 668, 684, 700, 717, 736, 755, 775, 796, 819, 843, 869, 896, 925, 955, 988, 1022, 1058, 1098, 1139, 1184, 1232, 1282, 1336, - ], - [ - 4, 9, 10, 13, 15, 17, 20, 22, 25, 28, 31, 34, 37, 40, 43, 47, 50, 53, 57, 60, 64, 68, 71, 75, 78, 82, 86, 90, 93, 97, 101, 105, 109, 113, 116, 120, - 124, 128, 132, 136, 140, 143, 147, 151, 155, 159, 163, 166, 170, 174, 178, 182, 185, 189, 193, 197, 200, 204, 208, 212, 215, 219, 223, 226, 230, 233, - 237, 241, 244, 248, 251, 255, 259, 262, 266, 269, 273, 276, 280, 283, 287, 290, 293, 297, 300, 304, 307, 310, 314, 317, 321, 324, 327, 331, 334, 337, - 343, 350, 356, 362, 369, 375, 381, 387, 394, 400, 406, 412, 418, 424, 430, 436, 442, 448, 454, 460, 466, 472, 478, 484, 490, 499, 507, 516, 525, 533, - 542, 550, 559, 567, 576, 584, 592, 601, 609, 617, 625, 634, 644, 655, 666, 676, 687, 698, 708, 718, 729, 739, 749, 759, 770, 782, 795, 807, 819, 831, - 844, 856, 868, 880, 891, 906, 920, 933, 947, 961, 975, 988, 1001, 1015, 1030, 1045, 1061, 1076, 1090, 1105, 1120, 1137, 1153, 1170, 1186, 1202, 1218, - 1236, 1253, 1271, 1288, 1306, 1323, 1342, 1361, 1379, 1398, 1416, 1436, 1456, 1476, 1496, 1516, 1537, 1559, 1580, 1601, 1624, 1647, 1670, 1692, 1717, - 1741, 1766, 1791, 1817, 1844, 1871, 1900, 1929, 1958, 1990, 2021, 2054, 2088, 2123, 2159, 2197, 2236, 2276, 2319, 2363, 2410, 2458, 2508, 2561, 2616, - 2675, 2737, 2802, 2871, 2944, 3020, 3102, 3188, 3280, 3375, 3478, 3586, 3702, 3823, 3953, 4089, 4236, 4394, 4559, 4737, 4929, 5130, 5347, - ], - [ - 4, 12, 18, 25, 33, 41, 50, 60, 70, 80, 91, 103, 115, 127, 140, 153, 166, 180, 194, 208, 222, 237, 251, 266, 281, 296, 312, 327, 343, 358, 374, 390, - 405, 421, 437, 453, 469, 484, 500, 516, 532, 548, 564, 580, 596, 611, 627, 643, 659, 674, 690, 706, 721, 737, 752, 768, 783, 798, 814, 829, 844, 859, - 874, 889, 904, 919, 934, 949, 964, 978, 993, 1008, 1022, 1037, 1051, 1065, 1080, 1094, 1108, 1122, 1136, 1151, 1165, 1179, 1192, 1206, 1220, 1234, - 1248, 1261, 1275, 1288, 1302, 1315, 1329, 1342, 1368, 1393, 1419, 1444, 1469, 1494, 1519, 1544, 1569, 1594, 1618, 1643, 1668, 1692, 1717, 1741, 1765, - 1789, 1814, 1838, 1862, 1885, 1909, 1933, 1957, 1992, 2027, 2061, 2096, 2130, 2165, 2199, 2233, 2267, 2300, 2334, 2367, 2400, 2434, 2467, 2499, 2532, - 2575, 2618, 2661, 2704, 2746, 2788, 2830, 2872, 2913, 2954, 2995, 3036, 3076, 3127, 3177, 3226, 3275, 3324, 3373, 3421, 3469, 3517, 3565, 3621, 3677, - 3733, 3788, 3843, 3897, 3951, 4005, 4058, 4119, 4181, 4241, 4301, 4361, 4420, 4479, 4546, 4612, 4677, 4742, 4807, 4871, 4942, 5013, 5083, 5153, 5222, - 5291, 5367, 5442, 5517, 5591, 5665, 5745, 5825, 5905, 5984, 6063, 6149, 6234, 6319, 6404, 6495, 6587, 6678, 6769, 6867, 6966, 7064, 7163, 7269, 7376, - 7483, 7599, 7715, 7832, 7958, 8085, 8214, 8352, 8492, 8635, 8788, 8945, 9104, 9275, 9450, 9639, 9832, 10031, 10245, 10465, 10702, 10946, 11210, 11482, - 11776, 12081, 12409, 12750, 13118, 13501, 13913, 14343, 14807, 15290, 15812, 16356, 16943, 17575, 18237, 18949, 19718, 20521, 21387, - ], -]; - -pub static DEFAULT_ANGLE_DELTA_CDF: [[u16; 8]; 8] = [ - [2180, 5032, 7567, 22776, 26989, 30217, 32768, 0], - [2301, 5608, 8801, 23487, 26974, 30330, 32768, 0], - [3780, 11018, 13699, 19354, 23083, 31286, 32768, 0], - [4581, 11226, 15147, 17138, 21834, 28397, 32768, 0], - [1737, 10927, 14509, 19588, 22745, 28823, 32768, 0], - [2664, 10176, 12485, 17650, 21600, 30495, 32768, 0], - [2240, 11096, 15453, 20341, 22561, 28917, 32768, 0], - [3605, 10428, 12459, 17676, 21244, 30655, 32768, 0], -]; - -pub static DEFAULT_COEFF_BASE_CDF: [[[[[u16; 5]; 42]; 2]; 5]; 4] = [ - [ - [ - [ - [4034, 8930, 12727, 32768, 0], - [18082, 29741, 31877, 32768, 0], - [12596, 26124, 30493, 32768, 0], - [9446, 21118, 27005, 32768, 0], - [6308, 15141, 21279, 32768, 0], - [2463, 6357, 9783, 32768, 0], - [20667, 30546, 31929, 32768, 0], - [13043, 26123, 30134, 32768, 0], - [8151, 18757, 24778, 32768, 0], - [5255, 12839, 18632, 32768, 0], - [2820, 7206, 11161, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [15736, 27553, 30604, 32768, 0], - [11210, 23794, 28787, 32768, 0], - [5947, 13874, 19701, 32768, 0], - [4215, 9323, 13891, 32768, 0], - [2833, 6462, 10059, 32768, 0], - [19605, 30393, 31582, 32768, 0], - [13523, 26252, 30248, 32768, 0], - [8446, 18622, 24512, 32768, 0], - [3818, 10343, 15974, 32768, 0], - [1481, 4117, 6796, 32768, 0], - [22649, 31302, 32190, 32768, 0], - [14829, 27127, 30449, 32768, 0], - [8313, 17702, 23304, 32768, 0], - [3022, 8301, 12786, 32768, 0], - [1536, 4412, 7184, 32768, 0], - [22354, 29774, 31372, 32768, 0], - [14723, 25472, 29214, 32768, 0], - [6673, 13745, 18662, 32768, 0], - [2068, 5766, 9322, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [6302, 16444, 21761, 32768, 0], - [23040, 31538, 32475, 32768, 0], - [15196, 28452, 31496, 32768, 0], - [10020, 22946, 28514, 32768, 0], - [6533, 16862, 23501, 32768, 0], - [3538, 9816, 15076, 32768, 0], - [24444, 31875, 32525, 32768, 0], - [15881, 28924, 31635, 32768, 0], - [9922, 22873, 28466, 32768, 0], - [6527, 16966, 23691, 32768, 0], - [4114, 11303, 17220, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [20201, 30770, 32209, 32768, 0], - [14754, 28071, 31258, 32768, 0], - [8378, 20186, 26517, 32768, 0], - [5916, 15299, 21978, 32768, 0], - [4268, 11583, 17901, 32768, 0], - [24361, 32025, 32581, 32768, 0], - [18673, 30105, 31943, 32768, 0], - [10196, 22244, 27576, 32768, 0], - [5495, 14349, 20417, 32768, 0], - [2676, 7415, 11498, 32768, 0], - [24678, 31958, 32585, 32768, 0], - [18629, 29906, 31831, 32768, 0], - [9364, 20724, 26315, 32768, 0], - [4641, 12318, 18094, 32768, 0], - [2758, 7387, 11579, 32768, 0], - [25433, 31842, 32469, 32768, 0], - [18795, 29289, 31411, 32768, 0], - [7644, 17584, 23592, 32768, 0], - [3408, 9014, 15047, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [4536, 10072, 14001, 32768, 0], - [25459, 31416, 32206, 32768, 0], - [16605, 28048, 30818, 32768, 0], - [11008, 22857, 27719, 32768, 0], - [6915, 16268, 22315, 32768, 0], - [2625, 6812, 10537, 32768, 0], - [24257, 31788, 32499, 32768, 0], - [16880, 29454, 31879, 32768, 0], - [11958, 25054, 29778, 32768, 0], - [7916, 18718, 25084, 32768, 0], - [3383, 8777, 13446, 32768, 0], - [22720, 31603, 32393, 32768, 0], - [14960, 28125, 31335, 32768, 0], - [9731, 22210, 27928, 32768, 0], - [6304, 15832, 22277, 32768, 0], - [2910, 7818, 12166, 32768, 0], - [20375, 30627, 32131, 32768, 0], - [13904, 27284, 30887, 32768, 0], - [9368, 21558, 27144, 32768, 0], - [5937, 14966, 21119, 32768, 0], - [2667, 7225, 11319, 32768, 0], - [23970, 31470, 32378, 32768, 0], - [17173, 29734, 32018, 32768, 0], - [12795, 25441, 29965, 32768, 0], - [8981, 19680, 25893, 32768, 0], - [4728, 11372, 16902, 32768, 0], - [24287, 31797, 32439, 32768, 0], - [16703, 29145, 31696, 32768, 0], - [10833, 23554, 28725, 32768, 0], - [6468, 16566, 23057, 32768, 0], - [2415, 6562, 10278, 32768, 0], - [26610, 32395, 32659, 32768, 0], - [18590, 30498, 32117, 32768, 0], - [12420, 25756, 29950, 32768, 0], - [7639, 18746, 24710, 32768, 0], - [3001, 8086, 12347, 32768, 0], - [25076, 32064, 32580, 32768, 0], - [17946, 30128, 32028, 32768, 0], - [12024, 24985, 29378, 32768, 0], - [7517, 18390, 24304, 32768, 0], - [3243, 8781, 13331, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [6037, 16771, 21957, 32768, 0], - [24774, 31704, 32426, 32768, 0], - [16830, 28589, 31056, 32768, 0], - [10602, 22828, 27760, 32768, 0], - [6733, 16829, 23071, 32768, 0], - [3250, 8914, 13556, 32768, 0], - [25582, 32220, 32668, 32768, 0], - [18659, 30342, 32223, 32768, 0], - [12546, 26149, 30515, 32768, 0], - [8420, 20451, 26801, 32768, 0], - [4636, 12420, 18344, 32768, 0], - [27581, 32362, 32639, 32768, 0], - [18987, 30083, 31978, 32768, 0], - [11327, 24248, 29084, 32768, 0], - [7264, 17719, 24120, 32768, 0], - [3995, 10768, 16169, 32768, 0], - [25893, 31831, 32487, 32768, 0], - [16577, 28587, 31379, 32768, 0], - [10189, 22748, 28182, 32768, 0], - [6832, 17094, 23556, 32768, 0], - [3708, 10110, 15334, 32768, 0], - [25904, 32282, 32656, 32768, 0], - [19721, 30792, 32276, 32768, 0], - [12819, 26243, 30411, 32768, 0], - [8572, 20614, 26891, 32768, 0], - [5364, 14059, 20467, 32768, 0], - [26580, 32438, 32677, 32768, 0], - [20852, 31225, 32340, 32768, 0], - [12435, 25700, 29967, 32768, 0], - [8691, 20825, 26976, 32768, 0], - [4446, 12209, 17269, 32768, 0], - [27350, 32429, 32696, 32768, 0], - [21372, 30977, 32272, 32768, 0], - [12673, 25270, 29853, 32768, 0], - [9208, 20925, 26640, 32768, 0], - [5018, 13351, 18732, 32768, 0], - [27351, 32479, 32713, 32768, 0], - [21398, 31209, 32387, 32768, 0], - [12162, 25047, 29842, 32768, 0], - [7896, 18691, 25319, 32768, 0], - [4670, 12882, 18881, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [5487, 10460, 13708, 32768, 0], - [21597, 28303, 30674, 32768, 0], - [11037, 21953, 26476, 32768, 0], - [8147, 17962, 22952, 32768, 0], - [5242, 13061, 18532, 32768, 0], - [1889, 5208, 8182, 32768, 0], - [26774, 32133, 32590, 32768, 0], - [17844, 29564, 31767, 32768, 0], - [11690, 24438, 29171, 32768, 0], - [7542, 18215, 24459, 32768, 0], - [2993, 8050, 12319, 32768, 0], - [28023, 32328, 32591, 32768, 0], - [18651, 30126, 31954, 32768, 0], - [12164, 25146, 29589, 32768, 0], - [7762, 18530, 24771, 32768, 0], - [3492, 9183, 13920, 32768, 0], - [27591, 32008, 32491, 32768, 0], - [17149, 28853, 31510, 32768, 0], - [11485, 24003, 28860, 32768, 0], - [7697, 18086, 24210, 32768, 0], - [3075, 7999, 12218, 32768, 0], - [28268, 32482, 32654, 32768, 0], - [19631, 31051, 32404, 32768, 0], - [13860, 27260, 31020, 32768, 0], - [9605, 21613, 27594, 32768, 0], - [4876, 12162, 17908, 32768, 0], - [27248, 32316, 32576, 32768, 0], - [18955, 30457, 32075, 32768, 0], - [11824, 23997, 28795, 32768, 0], - [7346, 18196, 24647, 32768, 0], - [3403, 9247, 14111, 32768, 0], - [29711, 32655, 32735, 32768, 0], - [21169, 31394, 32417, 32768, 0], - [13487, 27198, 30957, 32768, 0], - [8828, 21683, 27614, 32768, 0], - [4270, 11451, 17038, 32768, 0], - [28708, 32578, 32731, 32768, 0], - [20120, 31241, 32482, 32768, 0], - [13692, 27550, 31321, 32768, 0], - [9418, 22514, 28439, 32768, 0], - [4999, 13283, 19462, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [5673, 14302, 19711, 32768, 0], - [26251, 30701, 31834, 32768, 0], - [12782, 23783, 27803, 32768, 0], - [9127, 20657, 25808, 32768, 0], - [6368, 16208, 21462, 32768, 0], - [2465, 7177, 10822, 32768, 0], - [29961, 32563, 32719, 32768, 0], - [18318, 29891, 31949, 32768, 0], - [11361, 24514, 29357, 32768, 0], - [7900, 19603, 25607, 32768, 0], - [4002, 10590, 15546, 32768, 0], - [29637, 32310, 32595, 32768, 0], - [18296, 29913, 31809, 32768, 0], - [10144, 21515, 26871, 32768, 0], - [5358, 14322, 20394, 32768, 0], - [3067, 8362, 13346, 32768, 0], - [28652, 32470, 32676, 32768, 0], - [17538, 30771, 32209, 32768, 0], - [13924, 26882, 30494, 32768, 0], - [10496, 22837, 27869, 32768, 0], - [7236, 16396, 21621, 32768, 0], - [30743, 32687, 32746, 32768, 0], - [23006, 31676, 32489, 32768, 0], - [14494, 27828, 31120, 32768, 0], - [10174, 22801, 28352, 32768, 0], - [6242, 15281, 21043, 32768, 0], - [25817, 32243, 32720, 32768, 0], - [18618, 31367, 32325, 32768, 0], - [13997, 28318, 31878, 32768, 0], - [12255, 26534, 31383, 32768, 0], - [9561, 21588, 28450, 32768, 0], - [28188, 32635, 32724, 32768, 0], - [22060, 32365, 32728, 32768, 0], - [18102, 30690, 32528, 32768, 0], - [14196, 28864, 31999, 32768, 0], - [12262, 25792, 30865, 32768, 0], - [24176, 32109, 32628, 32768, 0], - [18280, 29681, 31963, 32768, 0], - [10205, 23703, 29664, 32768, 0], - [7889, 20025, 27676, 32768, 0], - [6060, 16743, 23970, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [5141, 7096, 8260, 32768, 0], - [27186, 29022, 29789, 32768, 0], - [6668, 12568, 15682, 32768, 0], - [2172, 6181, 8638, 32768, 0], - [1126, 3379, 4531, 32768, 0], - [443, 1361, 2254, 32768, 0], - [26083, 31153, 32436, 32768, 0], - [13486, 24603, 28483, 32768, 0], - [6508, 14840, 19910, 32768, 0], - [3386, 8800, 13286, 32768, 0], - [1530, 4322, 7054, 32768, 0], - [29639, 32080, 32548, 32768, 0], - [15897, 27552, 30290, 32768, 0], - [8588, 20047, 25383, 32768, 0], - [4889, 13339, 19269, 32768, 0], - [2240, 6871, 10498, 32768, 0], - [28165, 32197, 32517, 32768, 0], - [20735, 30427, 31568, 32768, 0], - [14325, 24671, 27692, 32768, 0], - [5119, 12554, 17805, 32768, 0], - [1810, 5441, 8261, 32768, 0], - [31212, 32724, 32748, 32768, 0], - [23352, 31766, 32545, 32768, 0], - [14669, 27570, 31059, 32768, 0], - [8492, 20894, 27272, 32768, 0], - [3644, 10194, 15204, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [2461, 7013, 9371, 32768, 0], - [24749, 29600, 30986, 32768, 0], - [9466, 19037, 22417, 32768, 0], - [3584, 9280, 14400, 32768, 0], - [1505, 3929, 5433, 32768, 0], - [677, 1500, 2736, 32768, 0], - [23987, 30702, 32117, 32768, 0], - [13554, 24571, 29263, 32768, 0], - [6211, 14556, 21155, 32768, 0], - [3135, 10972, 15625, 32768, 0], - [2435, 7127, 11427, 32768, 0], - [31300, 32532, 32550, 32768, 0], - [14757, 30365, 31954, 32768, 0], - [4405, 11612, 18553, 32768, 0], - [580, 4132, 7322, 32768, 0], - [1695, 10169, 14124, 32768, 0], - [30008, 32282, 32591, 32768, 0], - [19244, 30108, 31748, 32768, 0], - [11180, 24158, 29555, 32768, 0], - [5650, 14972, 19209, 32768, 0], - [2114, 5109, 8456, 32768, 0], - [31856, 32716, 32748, 32768, 0], - [23012, 31664, 32572, 32768, 0], - [13694, 26656, 30636, 32768, 0], - [8142, 19508, 26093, 32768, 0], - [4253, 10955, 16724, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [601, 983, 1311, 32768, 0], - [18725, 23406, 28087, 32768, 0], - [5461, 8192, 10923, 32768, 0], - [3781, 15124, 21425, 32768, 0], - [2587, 7761, 12072, 32768, 0], - [106, 458, 810, 32768, 0], - [22282, 29710, 31894, 32768, 0], - [8508, 20926, 25984, 32768, 0], - [3726, 12713, 18083, 32768, 0], - [1620, 7112, 10893, 32768, 0], - [729, 2236, 3495, 32768, 0], - [30163, 32474, 32684, 32768, 0], - [18304, 30464, 32000, 32768, 0], - [11443, 26526, 29647, 32768, 0], - [6007, 15292, 21299, 32768, 0], - [2234, 6703, 8937, 32768, 0], - [30954, 32177, 32571, 32768, 0], - [17363, 29562, 31076, 32768, 0], - [9686, 22464, 27410, 32768, 0], - [8192, 16384, 21390, 32768, 0], - [1755, 8046, 11264, 32768, 0], - [31168, 32734, 32748, 32768, 0], - [22486, 31441, 32471, 32768, 0], - [12833, 25627, 29738, 32768, 0], - [6980, 17379, 23122, 32768, 0], - [3111, 8887, 13479, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], - [ - [ - [ - [6041, 11854, 15927, 32768, 0], - [20326, 30905, 32251, 32768, 0], - [14164, 26831, 30725, 32768, 0], - [9760, 20647, 26585, 32768, 0], - [6416, 14953, 21219, 32768, 0], - [2966, 7151, 10891, 32768, 0], - [23567, 31374, 32254, 32768, 0], - [14978, 27416, 30946, 32768, 0], - [9434, 20225, 26254, 32768, 0], - [6658, 14558, 20535, 32768, 0], - [3916, 8677, 12989, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [18088, 29545, 31587, 32768, 0], - [13062, 25843, 30073, 32768, 0], - [8940, 16827, 22251, 32768, 0], - [7654, 13220, 17973, 32768, 0], - [5733, 10316, 14456, 32768, 0], - [22879, 31388, 32114, 32768, 0], - [15215, 27993, 30955, 32768, 0], - [9397, 19445, 24978, 32768, 0], - [3442, 9813, 15344, 32768, 0], - [1368, 3936, 6532, 32768, 0], - [25494, 32033, 32406, 32768, 0], - [16772, 27963, 30718, 32768, 0], - [9419, 18165, 23260, 32768, 0], - [2677, 7501, 11797, 32768, 0], - [1516, 4344, 7170, 32768, 0], - [26556, 31454, 32101, 32768, 0], - [17128, 27035, 30108, 32768, 0], - [8324, 15344, 20249, 32768, 0], - [1903, 5696, 9469, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8455, 19003, 24368, 32768, 0], - [23563, 32021, 32604, 32768, 0], - [16237, 29446, 31935, 32768, 0], - [10724, 23999, 29358, 32768, 0], - [6725, 17528, 24416, 32768, 0], - [3927, 10927, 16825, 32768, 0], - [26313, 32288, 32634, 32768, 0], - [17430, 30095, 32095, 32768, 0], - [11116, 24606, 29679, 32768, 0], - [7195, 18384, 25269, 32768, 0], - [4726, 12852, 19315, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [22822, 31648, 32483, 32768, 0], - [16724, 29633, 31929, 32768, 0], - [10261, 23033, 28725, 32768, 0], - [7029, 17840, 24528, 32768, 0], - [4867, 13886, 21502, 32768, 0], - [25298, 31892, 32491, 32768, 0], - [17809, 29330, 31512, 32768, 0], - [9668, 21329, 26579, 32768, 0], - [4774, 12956, 18976, 32768, 0], - [2322, 7030, 11540, 32768, 0], - [25472, 31920, 32543, 32768, 0], - [17957, 29387, 31632, 32768, 0], - [9196, 20593, 26400, 32768, 0], - [4680, 12705, 19202, 32768, 0], - [2917, 8456, 13436, 32768, 0], - [26471, 32059, 32574, 32768, 0], - [18458, 29783, 31909, 32768, 0], - [8400, 19464, 25956, 32768, 0], - [3812, 10973, 17206, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [6779, 13743, 17678, 32768, 0], - [24806, 31797, 32457, 32768, 0], - [17616, 29047, 31372, 32768, 0], - [11063, 23175, 28003, 32768, 0], - [6521, 16110, 22324, 32768, 0], - [2764, 7504, 11654, 32768, 0], - [25266, 32367, 32637, 32768, 0], - [19054, 30553, 32175, 32768, 0], - [12139, 25212, 29807, 32768, 0], - [7311, 18162, 24704, 32768, 0], - [3397, 9164, 14074, 32768, 0], - [25988, 32208, 32522, 32768, 0], - [16253, 28912, 31526, 32768, 0], - [9151, 21387, 27372, 32768, 0], - [5688, 14915, 21496, 32768, 0], - [2717, 7627, 12004, 32768, 0], - [23144, 31855, 32443, 32768, 0], - [16070, 28491, 31325, 32768, 0], - [8702, 20467, 26517, 32768, 0], - [5243, 13956, 20367, 32768, 0], - [2621, 7335, 11567, 32768, 0], - [26636, 32340, 32630, 32768, 0], - [19990, 31050, 32341, 32768, 0], - [13243, 26105, 30315, 32768, 0], - [8588, 19521, 25918, 32768, 0], - [4717, 11585, 17304, 32768, 0], - [25844, 32292, 32582, 32768, 0], - [19090, 30635, 32097, 32768, 0], - [11963, 24546, 28939, 32768, 0], - [6218, 16087, 22354, 32768, 0], - [2340, 6608, 10426, 32768, 0], - [28046, 32576, 32694, 32768, 0], - [21178, 31313, 32296, 32768, 0], - [13486, 26184, 29870, 32768, 0], - [7149, 17871, 23723, 32768, 0], - [2833, 7958, 12259, 32768, 0], - [27710, 32528, 32686, 32768, 0], - [20674, 31076, 32268, 32768, 0], - [12413, 24955, 29243, 32768, 0], - [6676, 16927, 23097, 32768, 0], - [2966, 8333, 12919, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8639, 19339, 24429, 32768, 0], - [24404, 31837, 32525, 32768, 0], - [16997, 29425, 31784, 32768, 0], - [11253, 24234, 29149, 32768, 0], - [6751, 17394, 24028, 32768, 0], - [3490, 9830, 15191, 32768, 0], - [26283, 32471, 32714, 32768, 0], - [19599, 31168, 32442, 32768, 0], - [13146, 26954, 30893, 32768, 0], - [8214, 20588, 26890, 32768, 0], - [4699, 13081, 19300, 32768, 0], - [28212, 32458, 32669, 32768, 0], - [18594, 30316, 32100, 32768, 0], - [11219, 24408, 29234, 32768, 0], - [6865, 17656, 24149, 32768, 0], - [3678, 10362, 16006, 32768, 0], - [25825, 32136, 32616, 32768, 0], - [17313, 29853, 32021, 32768, 0], - [11197, 24471, 29472, 32768, 0], - [6947, 17781, 24405, 32768, 0], - [3768, 10660, 16261, 32768, 0], - [27352, 32500, 32706, 32768, 0], - [20850, 31468, 32469, 32768, 0], - [14021, 27707, 31133, 32768, 0], - [8964, 21748, 27838, 32768, 0], - [5437, 14665, 21187, 32768, 0], - [26304, 32492, 32698, 32768, 0], - [20409, 31380, 32385, 32768, 0], - [13682, 27222, 30632, 32768, 0], - [8974, 21236, 26685, 32768, 0], - [4234, 11665, 16934, 32768, 0], - [26273, 32357, 32711, 32768, 0], - [20672, 31242, 32441, 32768, 0], - [14172, 27254, 30902, 32768, 0], - [9870, 21898, 27275, 32768, 0], - [5164, 13506, 19270, 32768, 0], - [26725, 32459, 32728, 32768, 0], - [20991, 31442, 32527, 32768, 0], - [13071, 26434, 30811, 32768, 0], - [8184, 20090, 26742, 32768, 0], - [4803, 13255, 19895, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [7555, 14942, 18501, 32768, 0], - [24410, 31178, 32287, 32768, 0], - [14394, 26738, 30253, 32768, 0], - [8413, 19554, 25195, 32768, 0], - [4766, 12924, 18785, 32768, 0], - [2029, 5806, 9207, 32768, 0], - [26776, 32364, 32663, 32768, 0], - [18732, 29967, 31931, 32768, 0], - [11005, 23786, 28852, 32768, 0], - [6466, 16909, 23510, 32768, 0], - [3044, 8638, 13419, 32768, 0], - [29208, 32582, 32704, 32768, 0], - [20068, 30857, 32208, 32768, 0], - [12003, 25085, 29595, 32768, 0], - [6947, 17750, 24189, 32768, 0], - [3245, 9103, 14007, 32768, 0], - [27359, 32465, 32669, 32768, 0], - [19421, 30614, 32174, 32768, 0], - [11915, 25010, 29579, 32768, 0], - [6950, 17676, 24074, 32768, 0], - [3007, 8473, 13096, 32768, 0], - [29002, 32676, 32735, 32768, 0], - [22102, 31849, 32576, 32768, 0], - [14408, 28009, 31405, 32768, 0], - [9027, 21679, 27931, 32768, 0], - [4694, 12678, 18748, 32768, 0], - [28216, 32528, 32682, 32768, 0], - [20849, 31264, 32318, 32768, 0], - [12756, 25815, 29751, 32768, 0], - [7565, 18801, 24923, 32768, 0], - [3509, 9533, 14477, 32768, 0], - [30133, 32687, 32739, 32768, 0], - [23063, 31910, 32515, 32768, 0], - [14588, 28051, 31132, 32768, 0], - [9085, 21649, 27457, 32768, 0], - [4261, 11654, 17264, 32768, 0], - [29518, 32691, 32748, 32768, 0], - [22451, 31959, 32613, 32768, 0], - [14864, 28722, 31700, 32768, 0], - [9695, 22964, 28716, 32768, 0], - [4932, 13358, 19502, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [6465, 16958, 21688, 32768, 0], - [25199, 31514, 32360, 32768, 0], - [14774, 27149, 30607, 32768, 0], - [9257, 21438, 26972, 32768, 0], - [5723, 15183, 21882, 32768, 0], - [3150, 8879, 13731, 32768, 0], - [26989, 32262, 32682, 32768, 0], - [17396, 29937, 32085, 32768, 0], - [11387, 24901, 29784, 32768, 0], - [7289, 18821, 25548, 32768, 0], - [3734, 10577, 16086, 32768, 0], - [29728, 32501, 32695, 32768, 0], - [17431, 29701, 31903, 32768, 0], - [9921, 22826, 28300, 32768, 0], - [5896, 15434, 22068, 32768, 0], - [3430, 9646, 14757, 32768, 0], - [28614, 32511, 32705, 32768, 0], - [19364, 30638, 32263, 32768, 0], - [13129, 26254, 30402, 32768, 0], - [8754, 20484, 26440, 32768, 0], - [4378, 11607, 17110, 32768, 0], - [30292, 32671, 32744, 32768, 0], - [21780, 31603, 32501, 32768, 0], - [14314, 27829, 31291, 32768, 0], - [9611, 22327, 28263, 32768, 0], - [4890, 13087, 19065, 32768, 0], - [25862, 32567, 32733, 32768, 0], - [20794, 32050, 32567, 32768, 0], - [17243, 30625, 32254, 32768, 0], - [13283, 27628, 31474, 32768, 0], - [9669, 22532, 28918, 32768, 0], - [27435, 32697, 32748, 32768, 0], - [24922, 32390, 32714, 32768, 0], - [21449, 31504, 32536, 32768, 0], - [16392, 29729, 31832, 32768, 0], - [11692, 24884, 29076, 32768, 0], - [24193, 32290, 32735, 32768, 0], - [18909, 31104, 32563, 32768, 0], - [12236, 26841, 31403, 32768, 0], - [8171, 21840, 29082, 32768, 0], - [7224, 17280, 25275, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [3078, 6839, 9890, 32768, 0], - [13837, 20450, 24479, 32768, 0], - [5914, 14222, 19328, 32768, 0], - [3866, 10267, 14762, 32768, 0], - [2612, 7208, 11042, 32768, 0], - [1067, 2991, 4776, 32768, 0], - [25817, 31646, 32529, 32768, 0], - [13708, 26338, 30385, 32768, 0], - [7328, 18585, 24870, 32768, 0], - [4691, 13080, 19276, 32768, 0], - [1825, 5253, 8352, 32768, 0], - [29386, 32315, 32624, 32768, 0], - [17160, 29001, 31360, 32768, 0], - [9602, 21862, 27396, 32768, 0], - [5915, 15772, 22148, 32768, 0], - [2786, 7779, 12047, 32768, 0], - [29246, 32450, 32663, 32768, 0], - [18696, 29929, 31818, 32768, 0], - [10510, 23369, 28560, 32768, 0], - [6229, 16499, 23125, 32768, 0], - [2608, 7448, 11705, 32768, 0], - [30753, 32710, 32748, 32768, 0], - [21638, 31487, 32503, 32768, 0], - [12937, 26854, 30870, 32768, 0], - [8182, 20596, 26970, 32768, 0], - [3637, 10269, 15497, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [5244, 12150, 16906, 32768, 0], - [20486, 26858, 29701, 32768, 0], - [7756, 18317, 23735, 32768, 0], - [3452, 9256, 13146, 32768, 0], - [2020, 5206, 8229, 32768, 0], - [1801, 4993, 7903, 32768, 0], - [27051, 31858, 32531, 32768, 0], - [15988, 27531, 30619, 32768, 0], - [9188, 21484, 26719, 32768, 0], - [6273, 17186, 23800, 32768, 0], - [3108, 9355, 14764, 32768, 0], - [31076, 32520, 32680, 32768, 0], - [18119, 30037, 31850, 32768, 0], - [10244, 22969, 27472, 32768, 0], - [4692, 14077, 19273, 32768, 0], - [3694, 11677, 17556, 32768, 0], - [30060, 32581, 32720, 32768, 0], - [21011, 30775, 32120, 32768, 0], - [11931, 24820, 29289, 32768, 0], - [7119, 17662, 24356, 32768, 0], - [3833, 10706, 16304, 32768, 0], - [31954, 32731, 32748, 32768, 0], - [23913, 31724, 32489, 32768, 0], - [15520, 28060, 31286, 32768, 0], - [11517, 23008, 28571, 32768, 0], - [6193, 14508, 20629, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [1035, 2807, 4156, 32768, 0], - [13162, 18138, 20939, 32768, 0], - [2696, 6633, 8755, 32768, 0], - [1373, 4161, 6853, 32768, 0], - [1099, 2746, 4716, 32768, 0], - [340, 1021, 1599, 32768, 0], - [22826, 30419, 32135, 32768, 0], - [10395, 21762, 26942, 32768, 0], - [4726, 12407, 17361, 32768, 0], - [2447, 7080, 10593, 32768, 0], - [1227, 3717, 6011, 32768, 0], - [28156, 31424, 31934, 32768, 0], - [16915, 27754, 30373, 32768, 0], - [9148, 20990, 26431, 32768, 0], - [5950, 15515, 21148, 32768, 0], - [2492, 7327, 11526, 32768, 0], - [30602, 32477, 32670, 32768, 0], - [20026, 29955, 31568, 32768, 0], - [11220, 23628, 28105, 32768, 0], - [6652, 17019, 22973, 32768, 0], - [3064, 8536, 13043, 32768, 0], - [31769, 32724, 32748, 32768, 0], - [22230, 30887, 32373, 32768, 0], - [12234, 25079, 29731, 32768, 0], - [7326, 18816, 25353, 32768, 0], - [3933, 10907, 16616, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], - [ - [ - [ - [8896, 16227, 20630, 32768, 0], - [23629, 31782, 32527, 32768, 0], - [15173, 27755, 31321, 32768, 0], - [10158, 21233, 27382, 32768, 0], - [6420, 14857, 21558, 32768, 0], - [3269, 8155, 12646, 32768, 0], - [24835, 32009, 32496, 32768, 0], - [16509, 28421, 31579, 32768, 0], - [10957, 21514, 27418, 32768, 0], - [7881, 15930, 22096, 32768, 0], - [5388, 10960, 15918, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [20745, 30773, 32093, 32768, 0], - [15200, 27221, 30861, 32768, 0], - [13032, 20873, 25667, 32768, 0], - [12285, 18663, 23494, 32768, 0], - [11563, 17481, 21489, 32768, 0], - [26260, 31982, 32320, 32768, 0], - [15397, 28083, 31100, 32768, 0], - [9742, 19217, 24824, 32768, 0], - [3261, 9629, 15362, 32768, 0], - [1480, 4322, 7499, 32768, 0], - [27599, 32256, 32460, 32768, 0], - [16857, 27659, 30774, 32768, 0], - [9551, 18290, 23748, 32768, 0], - [3052, 8933, 14103, 32768, 0], - [2021, 5910, 9787, 32768, 0], - [29005, 32015, 32392, 32768, 0], - [17677, 27694, 30863, 32768, 0], - [9204, 17356, 23219, 32768, 0], - [2403, 7516, 12814, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [10808, 22056, 26896, 32768, 0], - [25739, 32313, 32676, 32768, 0], - [17288, 30203, 32221, 32768, 0], - [11359, 24878, 29896, 32768, 0], - [6949, 17767, 24893, 32768, 0], - [4287, 11796, 18071, 32768, 0], - [27880, 32521, 32705, 32768, 0], - [19038, 31004, 32414, 32768, 0], - [12564, 26345, 30768, 32768, 0], - [8269, 19947, 26779, 32768, 0], - [5674, 14657, 21674, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [25742, 32319, 32671, 32768, 0], - [19557, 31164, 32454, 32768, 0], - [13381, 26381, 30755, 32768, 0], - [10101, 21466, 26722, 32768, 0], - [9209, 19650, 26825, 32768, 0], - [27107, 31917, 32432, 32768, 0], - [18056, 28893, 31203, 32768, 0], - [10200, 21434, 26764, 32768, 0], - [4660, 12913, 19502, 32768, 0], - [2368, 6930, 12504, 32768, 0], - [26960, 32158, 32613, 32768, 0], - [18628, 30005, 32031, 32768, 0], - [10233, 22442, 28232, 32768, 0], - [5471, 14630, 21516, 32768, 0], - [3235, 10767, 17109, 32768, 0], - [27696, 32440, 32692, 32768, 0], - [20032, 31167, 32438, 32768, 0], - [8700, 21341, 28442, 32768, 0], - [5662, 14831, 21795, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [9704, 17294, 21132, 32768, 0], - [26762, 32278, 32633, 32768, 0], - [18382, 29620, 31819, 32768, 0], - [10891, 23475, 28723, 32768, 0], - [6358, 16583, 23309, 32768, 0], - [3248, 9118, 14141, 32768, 0], - [27204, 32573, 32699, 32768, 0], - [19818, 30824, 32329, 32768, 0], - [11772, 25120, 30041, 32768, 0], - [6995, 18033, 25039, 32768, 0], - [3752, 10442, 16098, 32768, 0], - [27222, 32256, 32559, 32768, 0], - [15356, 28399, 31475, 32768, 0], - [8821, 20635, 27057, 32768, 0], - [5511, 14404, 21239, 32768, 0], - [2935, 8222, 13051, 32768, 0], - [24875, 32120, 32529, 32768, 0], - [15233, 28265, 31445, 32768, 0], - [8605, 20570, 26932, 32768, 0], - [5431, 14413, 21196, 32768, 0], - [2994, 8341, 13223, 32768, 0], - [28201, 32604, 32700, 32768, 0], - [21041, 31446, 32456, 32768, 0], - [13221, 26213, 30475, 32768, 0], - [8255, 19385, 26037, 32768, 0], - [4930, 12585, 18830, 32768, 0], - [28768, 32448, 32627, 32768, 0], - [19705, 30561, 32021, 32768, 0], - [11572, 23589, 28220, 32768, 0], - [5532, 15034, 21446, 32768, 0], - [2460, 7150, 11456, 32768, 0], - [29874, 32619, 32699, 32768, 0], - [21621, 31071, 32201, 32768, 0], - [12511, 24747, 28992, 32768, 0], - [6281, 16395, 22748, 32768, 0], - [3246, 9278, 14497, 32768, 0], - [29715, 32625, 32712, 32768, 0], - [20958, 31011, 32283, 32768, 0], - [11233, 23671, 28806, 32768, 0], - [6012, 16128, 22868, 32768, 0], - [3427, 9851, 15414, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [11016, 22111, 26794, 32768, 0], - [25946, 32357, 32677, 32768, 0], - [17890, 30452, 32252, 32768, 0], - [11678, 25142, 29816, 32768, 0], - [6720, 17534, 24584, 32768, 0], - [4230, 11665, 17820, 32768, 0], - [28400, 32623, 32747, 32768, 0], - [21164, 31668, 32575, 32768, 0], - [13572, 27388, 31182, 32768, 0], - [8234, 20750, 27358, 32768, 0], - [5065, 14055, 20897, 32768, 0], - [28981, 32547, 32705, 32768, 0], - [18681, 30543, 32239, 32768, 0], - [10919, 24075, 29286, 32768, 0], - [6431, 17199, 24077, 32768, 0], - [3819, 10464, 16618, 32768, 0], - [26870, 32467, 32693, 32768, 0], - [19041, 30831, 32347, 32768, 0], - [11794, 25211, 30016, 32768, 0], - [6888, 18019, 24970, 32768, 0], - [4370, 12363, 18992, 32768, 0], - [29578, 32670, 32744, 32768, 0], - [23159, 32007, 32613, 32768, 0], - [15315, 28669, 31676, 32768, 0], - [9298, 22607, 28782, 32768, 0], - [6144, 15913, 22968, 32768, 0], - [28110, 32499, 32669, 32768, 0], - [21574, 30937, 32015, 32768, 0], - [12759, 24818, 28727, 32768, 0], - [6545, 16761, 23042, 32768, 0], - [3649, 10597, 16833, 32768, 0], - [28163, 32552, 32728, 32768, 0], - [22101, 31469, 32464, 32768, 0], - [13160, 25472, 30143, 32768, 0], - [7303, 18684, 25468, 32768, 0], - [5241, 13975, 20955, 32768, 0], - [28400, 32631, 32744, 32768, 0], - [22104, 31793, 32603, 32768, 0], - [13557, 26571, 30846, 32768, 0], - [7749, 19861, 26675, 32768, 0], - [4873, 14030, 21234, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [9800, 17635, 21073, 32768, 0], - [26153, 31885, 32527, 32768, 0], - [15038, 27852, 31006, 32768, 0], - [8718, 20564, 26486, 32768, 0], - [5128, 14076, 20514, 32768, 0], - [2636, 7566, 11925, 32768, 0], - [27551, 32504, 32701, 32768, 0], - [18310, 30054, 32100, 32768, 0], - [10211, 23420, 29082, 32768, 0], - [6222, 16876, 23916, 32768, 0], - [3462, 9954, 15498, 32768, 0], - [29991, 32633, 32721, 32768, 0], - [19883, 30751, 32201, 32768, 0], - [11141, 24184, 29285, 32768, 0], - [6420, 16940, 23774, 32768, 0], - [3392, 9753, 15118, 32768, 0], - [28465, 32616, 32712, 32768, 0], - [19850, 30702, 32244, 32768, 0], - [10983, 24024, 29223, 32768, 0], - [6294, 16770, 23582, 32768, 0], - [3244, 9283, 14509, 32768, 0], - [30023, 32717, 32748, 32768, 0], - [22940, 32032, 32626, 32768, 0], - [14282, 27928, 31473, 32768, 0], - [8562, 21327, 27914, 32768, 0], - [4846, 13393, 19919, 32768, 0], - [29981, 32590, 32695, 32768, 0], - [20465, 30963, 32166, 32768, 0], - [11479, 23579, 28195, 32768, 0], - [5916, 15648, 22073, 32768, 0], - [3031, 8605, 13398, 32768, 0], - [31146, 32691, 32739, 32768, 0], - [23106, 31724, 32444, 32768, 0], - [13783, 26738, 30439, 32768, 0], - [7852, 19468, 25807, 32768, 0], - [3860, 11124, 16853, 32768, 0], - [31014, 32724, 32748, 32768, 0], - [23629, 32109, 32628, 32768, 0], - [14747, 28115, 31403, 32768, 0], - [8545, 21242, 27478, 32768, 0], - [4574, 12781, 19067, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [9185, 19694, 24688, 32768, 0], - [26081, 31985, 32621, 32768, 0], - [16015, 29000, 31787, 32768, 0], - [10542, 23690, 29206, 32768, 0], - [6732, 17945, 24677, 32768, 0], - [3916, 11039, 16722, 32768, 0], - [28224, 32566, 32744, 32768, 0], - [19100, 31138, 32485, 32768, 0], - [12528, 26620, 30879, 32768, 0], - [7741, 20277, 26885, 32768, 0], - [4566, 12845, 18990, 32768, 0], - [29933, 32593, 32718, 32768, 0], - [17670, 30333, 32155, 32768, 0], - [10385, 23600, 28909, 32768, 0], - [6243, 16236, 22407, 32768, 0], - [3976, 10389, 16017, 32768, 0], - [28377, 32561, 32738, 32768, 0], - [19366, 31175, 32482, 32768, 0], - [13327, 27175, 31094, 32768, 0], - [8258, 20769, 27143, 32768, 0], - [4703, 13198, 19527, 32768, 0], - [31086, 32706, 32748, 32768, 0], - [22853, 31902, 32583, 32768, 0], - [14759, 28186, 31419, 32768, 0], - [9284, 22382, 28348, 32768, 0], - [5585, 15192, 21868, 32768, 0], - [28291, 32652, 32746, 32768, 0], - [19849, 32107, 32571, 32768, 0], - [14834, 26818, 29214, 32768, 0], - [10306, 22594, 28672, 32768, 0], - [6615, 17384, 23384, 32768, 0], - [28947, 32604, 32745, 32768, 0], - [25625, 32289, 32646, 32768, 0], - [18758, 28672, 31403, 32768, 0], - [10017, 23430, 28523, 32768, 0], - [6862, 15269, 22131, 32768, 0], - [23933, 32509, 32739, 32768, 0], - [19927, 31495, 32631, 32768, 0], - [11903, 26023, 30621, 32768, 0], - [7026, 20094, 27252, 32768, 0], - [5998, 18106, 24437, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [4456, 11274, 15533, 32768, 0], - [21219, 29079, 31616, 32768, 0], - [11173, 23774, 28567, 32768, 0], - [7282, 18293, 24263, 32768, 0], - [4890, 13286, 19115, 32768, 0], - [1890, 5508, 8659, 32768, 0], - [26651, 32136, 32647, 32768, 0], - [14630, 28254, 31455, 32768, 0], - [8716, 21287, 27395, 32768, 0], - [5615, 15331, 22008, 32768, 0], - [2675, 7700, 12150, 32768, 0], - [29954, 32526, 32690, 32768, 0], - [16126, 28982, 31633, 32768, 0], - [9030, 21361, 27352, 32768, 0], - [5411, 14793, 21271, 32768, 0], - [2943, 8422, 13163, 32768, 0], - [29539, 32601, 32730, 32768, 0], - [18125, 30385, 32201, 32768, 0], - [10422, 24090, 29468, 32768, 0], - [6468, 17487, 24438, 32768, 0], - [2970, 8653, 13531, 32768, 0], - [30912, 32715, 32748, 32768, 0], - [20666, 31373, 32497, 32768, 0], - [12509, 26640, 30917, 32768, 0], - [8058, 20629, 27290, 32768, 0], - [4231, 12006, 18052, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [10202, 20633, 25484, 32768, 0], - [27336, 31445, 32352, 32768, 0], - [12420, 24384, 28552, 32768, 0], - [7648, 18115, 23856, 32768, 0], - [5662, 14341, 19902, 32768, 0], - [3611, 10328, 15390, 32768, 0], - [30945, 32616, 32736, 32768, 0], - [18682, 30505, 32253, 32768, 0], - [11513, 25336, 30203, 32768, 0], - [7449, 19452, 26148, 32768, 0], - [4482, 13051, 18886, 32768, 0], - [32022, 32690, 32747, 32768, 0], - [18578, 30501, 32146, 32768, 0], - [11249, 23368, 28631, 32768, 0], - [5645, 16958, 22158, 32768, 0], - [5009, 11444, 16637, 32768, 0], - [31357, 32710, 32748, 32768, 0], - [21552, 31494, 32504, 32768, 0], - [13891, 27677, 31340, 32768, 0], - [9051, 22098, 28172, 32768, 0], - [5190, 13377, 19486, 32768, 0], - [32364, 32740, 32748, 32768, 0], - [24839, 31907, 32551, 32768, 0], - [17160, 28779, 31696, 32768, 0], - [12452, 24137, 29602, 32768, 0], - [6165, 15389, 22477, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [2575, 7281, 11077, 32768, 0], - [14002, 20866, 25402, 32768, 0], - [6343, 15056, 19658, 32768, 0], - [4474, 11858, 17041, 32768, 0], - [2865, 8299, 12534, 32768, 0], - [1344, 3949, 6391, 32768, 0], - [24720, 31239, 32459, 32768, 0], - [12585, 25356, 29968, 32768, 0], - [7181, 18246, 24444, 32768, 0], - [5025, 13667, 19885, 32768, 0], - [2521, 7304, 11605, 32768, 0], - [29908, 32252, 32584, 32768, 0], - [17421, 29156, 31575, 32768, 0], - [9889, 22188, 27782, 32768, 0], - [5878, 15647, 22123, 32768, 0], - [2814, 8665, 13323, 32768, 0], - [30183, 32568, 32713, 32768, 0], - [18528, 30195, 32049, 32768, 0], - [10982, 24606, 29657, 32768, 0], - [6957, 18165, 25231, 32768, 0], - [3508, 10118, 15468, 32768, 0], - [31761, 32736, 32748, 32768, 0], - [21041, 31328, 32546, 32768, 0], - [12568, 26732, 31166, 32768, 0], - [8052, 20720, 27733, 32768, 0], - [4336, 12192, 18396, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], - [ - [ - [ - [7062, 16472, 22319, 32768, 0], - [24538, 32261, 32674, 32768, 0], - [13675, 28041, 31779, 32768, 0], - [8590, 20674, 27631, 32768, 0], - [5685, 14675, 22013, 32768, 0], - [3655, 9898, 15731, 32768, 0], - [26493, 32418, 32658, 32768, 0], - [16376, 29342, 32090, 32768, 0], - [10594, 22649, 28970, 32768, 0], - [8176, 17170, 24303, 32768, 0], - [5605, 12694, 19139, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [23888, 31902, 32542, 32768, 0], - [18612, 29687, 31987, 32768, 0], - [16245, 24852, 29249, 32768, 0], - [15765, 22608, 27559, 32768, 0], - [19895, 24699, 27510, 32768, 0], - [28401, 32212, 32457, 32768, 0], - [15274, 27825, 30980, 32768, 0], - [9364, 18128, 24332, 32768, 0], - [2283, 8193, 15082, 32768, 0], - [1228, 3972, 7881, 32768, 0], - [29455, 32469, 32620, 32768, 0], - [17981, 28245, 31388, 32768, 0], - [10921, 20098, 26240, 32768, 0], - [3743, 11829, 18657, 32768, 0], - [2374, 9593, 15715, 32768, 0], - [31068, 32466, 32635, 32768, 0], - [20321, 29572, 31971, 32768, 0], - [10771, 20255, 27119, 32768, 0], - [2795, 10410, 17361, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [9320, 22102, 27840, 32768, 0], - [27057, 32464, 32724, 32768, 0], - [16331, 30268, 32309, 32768, 0], - [10319, 23935, 29720, 32768, 0], - [6189, 16448, 24106, 32768, 0], - [3589, 10884, 18808, 32768, 0], - [29026, 32624, 32748, 32768, 0], - [19226, 31507, 32587, 32768, 0], - [12692, 26921, 31203, 32768, 0], - [7049, 19532, 27635, 32768, 0], - [7727, 15669, 23252, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [28056, 32625, 32748, 32768, 0], - [22383, 32075, 32669, 32768, 0], - [15417, 27098, 31749, 32768, 0], - [18127, 26493, 27190, 32768, 0], - [5461, 16384, 21845, 32768, 0], - [27982, 32091, 32584, 32768, 0], - [19045, 29868, 31972, 32768, 0], - [10397, 22266, 27932, 32768, 0], - [5990, 13697, 21500, 32768, 0], - [1792, 6912, 15104, 32768, 0], - [28198, 32501, 32718, 32768, 0], - [21534, 31521, 32569, 32768, 0], - [11109, 25217, 30017, 32768, 0], - [5671, 15124, 26151, 32768, 0], - [4681, 14043, 18725, 32768, 0], - [28688, 32580, 32741, 32768, 0], - [22576, 32079, 32661, 32768, 0], - [10627, 22141, 28340, 32768, 0], - [9362, 14043, 28087, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [7754, 16948, 22142, 32768, 0], - [25670, 32330, 32691, 32768, 0], - [15663, 29225, 31994, 32768, 0], - [9878, 23288, 29158, 32768, 0], - [6419, 17088, 24336, 32768, 0], - [3859, 11003, 17039, 32768, 0], - [27562, 32595, 32725, 32768, 0], - [17575, 30588, 32399, 32768, 0], - [10819, 24838, 30309, 32768, 0], - [7124, 18686, 25916, 32768, 0], - [4479, 12688, 19340, 32768, 0], - [28385, 32476, 32673, 32768, 0], - [15306, 29005, 31938, 32768, 0], - [8937, 21615, 28322, 32768, 0], - [5982, 15603, 22786, 32768, 0], - [3620, 10267, 16136, 32768, 0], - [27280, 32464, 32667, 32768, 0], - [15607, 29160, 32004, 32768, 0], - [9091, 22135, 28740, 32768, 0], - [6232, 16632, 24020, 32768, 0], - [4047, 11377, 17672, 32768, 0], - [29220, 32630, 32718, 32768, 0], - [19650, 31220, 32462, 32768, 0], - [13050, 26312, 30827, 32768, 0], - [9228, 20870, 27468, 32768, 0], - [6146, 15149, 21971, 32768, 0], - [30169, 32481, 32623, 32768, 0], - [17212, 29311, 31554, 32768, 0], - [9911, 21311, 26882, 32768, 0], - [4487, 13314, 20372, 32768, 0], - [2570, 7772, 12889, 32768, 0], - [30924, 32613, 32708, 32768, 0], - [19490, 30206, 32107, 32768, 0], - [11232, 23998, 29276, 32768, 0], - [6769, 17955, 25035, 32768, 0], - [4398, 12623, 19214, 32768, 0], - [30609, 32627, 32722, 32768, 0], - [19370, 30582, 32287, 32768, 0], - [10457, 23619, 29409, 32768, 0], - [6443, 17637, 24834, 32768, 0], - [4645, 13236, 20106, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8626, 20271, 26216, 32768, 0], - [26707, 32406, 32711, 32768, 0], - [16999, 30329, 32286, 32768, 0], - [11445, 25123, 30286, 32768, 0], - [6411, 18828, 25601, 32768, 0], - [6801, 12458, 20248, 32768, 0], - [29918, 32682, 32748, 32768, 0], - [20649, 31739, 32618, 32768, 0], - [12879, 27773, 31581, 32768, 0], - [7896, 21751, 28244, 32768, 0], - [5260, 14870, 23698, 32768, 0], - [29252, 32593, 32731, 32768, 0], - [17072, 30460, 32294, 32768, 0], - [10653, 24143, 29365, 32768, 0], - [6536, 17490, 23983, 32768, 0], - [4929, 13170, 20085, 32768, 0], - [28137, 32518, 32715, 32768, 0], - [18171, 30784, 32407, 32768, 0], - [11437, 25436, 30459, 32768, 0], - [7252, 18534, 26176, 32768, 0], - [4126, 13353, 20978, 32768, 0], - [31162, 32726, 32748, 32768, 0], - [23017, 32222, 32701, 32768, 0], - [15629, 29233, 32046, 32768, 0], - [9387, 22621, 29480, 32768, 0], - [6922, 17616, 25010, 32768, 0], - [28838, 32265, 32614, 32768, 0], - [19701, 30206, 31920, 32768, 0], - [11214, 22410, 27933, 32768, 0], - [5320, 14177, 23034, 32768, 0], - [5049, 12881, 17827, 32768, 0], - [27484, 32471, 32734, 32768, 0], - [21076, 31526, 32561, 32768, 0], - [12707, 26303, 31211, 32768, 0], - [8169, 21722, 28219, 32768, 0], - [6045, 19406, 27042, 32768, 0], - [27753, 32572, 32745, 32768, 0], - [20832, 31878, 32653, 32768, 0], - [13250, 27356, 31674, 32768, 0], - [7718, 21508, 29858, 32768, 0], - [7209, 18350, 25559, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [7876, 16901, 21741, 32768, 0], - [24001, 31898, 32625, 32768, 0], - [14529, 27959, 31451, 32768, 0], - [8273, 20818, 27258, 32768, 0], - [5278, 14673, 21510, 32768, 0], - [2983, 8843, 14039, 32768, 0], - [28016, 32574, 32732, 32768, 0], - [17471, 30306, 32301, 32768, 0], - [10224, 24063, 29728, 32768, 0], - [6602, 17954, 25052, 32768, 0], - [4002, 11585, 17759, 32768, 0], - [30190, 32634, 32739, 32768, 0], - [17497, 30282, 32270, 32768, 0], - [10229, 23729, 29538, 32768, 0], - [6344, 17211, 24440, 32768, 0], - [3849, 11189, 17108, 32768, 0], - [28570, 32583, 32726, 32768, 0], - [17521, 30161, 32238, 32768, 0], - [10153, 23565, 29378, 32768, 0], - [6455, 17341, 24443, 32768, 0], - [3907, 11042, 17024, 32768, 0], - [30689, 32715, 32748, 32768, 0], - [21546, 31840, 32610, 32768, 0], - [13547, 27581, 31459, 32768, 0], - [8912, 21757, 28309, 32768, 0], - [5548, 15080, 22046, 32768, 0], - [30783, 32540, 32685, 32768, 0], - [17540, 29528, 31668, 32768, 0], - [10160, 21468, 26783, 32768, 0], - [4724, 13393, 20054, 32768, 0], - [2702, 8174, 13102, 32768, 0], - [31648, 32686, 32742, 32768, 0], - [20954, 31094, 32337, 32768, 0], - [12420, 25698, 30179, 32768, 0], - [7304, 19320, 26248, 32768, 0], - [4366, 12261, 18864, 32768, 0], - [31581, 32723, 32748, 32768, 0], - [21373, 31586, 32525, 32768, 0], - [12744, 26625, 30885, 32768, 0], - [7431, 20322, 26950, 32768, 0], - [4692, 13323, 20111, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [7833, 18369, 24095, 32768, 0], - [26650, 32273, 32702, 32768, 0], - [16371, 29961, 32191, 32768, 0], - [11055, 24082, 29629, 32768, 0], - [6892, 18644, 25400, 32768, 0], - [5006, 13057, 19240, 32768, 0], - [29834, 32666, 32748, 32768, 0], - [19577, 31335, 32570, 32768, 0], - [12253, 26509, 31122, 32768, 0], - [7991, 20772, 27711, 32768, 0], - [5677, 15910, 23059, 32768, 0], - [30109, 32532, 32720, 32768, 0], - [16747, 30166, 32252, 32768, 0], - [10134, 23542, 29184, 32768, 0], - [5791, 16176, 23556, 32768, 0], - [4362, 10414, 17284, 32768, 0], - [29492, 32626, 32748, 32768, 0], - [19894, 31402, 32525, 32768, 0], - [12942, 27071, 30869, 32768, 0], - [8346, 21216, 27405, 32768, 0], - [6572, 17087, 23859, 32768, 0], - [32035, 32735, 32748, 32768, 0], - [22957, 31838, 32618, 32768, 0], - [14724, 28572, 31772, 32768, 0], - [10364, 23999, 29553, 32768, 0], - [7004, 18433, 25655, 32768, 0], - [27528, 32277, 32681, 32768, 0], - [16959, 31171, 32096, 32768, 0], - [10486, 23593, 27962, 32768, 0], - [8192, 16384, 23211, 32768, 0], - [8937, 17873, 20852, 32768, 0], - [27715, 32002, 32615, 32768, 0], - [15073, 29491, 31676, 32768, 0], - [11264, 24576, 28672, 32768, 0], - [2341, 18725, 23406, 32768, 0], - [7282, 18204, 25486, 32768, 0], - [28547, 32213, 32657, 32768, 0], - [20788, 29773, 32239, 32768, 0], - [6780, 21469, 30508, 32768, 0], - [5958, 14895, 23831, 32768, 0], - [16384, 21845, 27307, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [5992, 14304, 19765, 32768, 0], - [22612, 31238, 32456, 32768, 0], - [13456, 27162, 31087, 32768, 0], - [8001, 20062, 26504, 32768, 0], - [5168, 14105, 20764, 32768, 0], - [2632, 7771, 12385, 32768, 0], - [27034, 32344, 32709, 32768, 0], - [15850, 29415, 31997, 32768, 0], - [9494, 22776, 28841, 32768, 0], - [6151, 16830, 23969, 32768, 0], - [3461, 10039, 15722, 32768, 0], - [30134, 32569, 32731, 32768, 0], - [15638, 29422, 31945, 32768, 0], - [9150, 21865, 28218, 32768, 0], - [5647, 15719, 22676, 32768, 0], - [3402, 9772, 15477, 32768, 0], - [28530, 32586, 32735, 32768, 0], - [17139, 30298, 32292, 32768, 0], - [10200, 24039, 29685, 32768, 0], - [6419, 17674, 24786, 32768, 0], - [3544, 10225, 15824, 32768, 0], - [31333, 32726, 32748, 32768, 0], - [20618, 31487, 32544, 32768, 0], - [12901, 27217, 31232, 32768, 0], - [8624, 21734, 28171, 32768, 0], - [5104, 14191, 20748, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [11206, 21090, 26561, 32768, 0], - [28759, 32279, 32671, 32768, 0], - [14171, 27952, 31569, 32768, 0], - [9743, 22907, 29141, 32768, 0], - [6871, 17886, 24868, 32768, 0], - [4960, 13152, 19315, 32768, 0], - [31077, 32661, 32748, 32768, 0], - [19400, 31195, 32515, 32768, 0], - [12752, 26858, 31040, 32768, 0], - [8370, 22098, 28591, 32768, 0], - [5457, 15373, 22298, 32768, 0], - [31697, 32706, 32748, 32768, 0], - [17860, 30657, 32333, 32768, 0], - [12510, 24812, 29261, 32768, 0], - [6180, 19124, 24722, 32768, 0], - [5041, 13548, 17959, 32768, 0], - [31552, 32716, 32748, 32768, 0], - [21908, 31769, 32623, 32768, 0], - [14470, 28201, 31565, 32768, 0], - [9493, 22982, 28608, 32768, 0], - [6858, 17240, 24137, 32768, 0], - [32543, 32752, 32756, 32768, 0], - [24286, 32097, 32666, 32768, 0], - [15958, 29217, 32024, 32768, 0], - [10207, 24234, 29958, 32768, 0], - [6929, 18305, 25652, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - [ - [ - [4137, 10847, 15682, 32768, 0], - [17824, 27001, 30058, 32768, 0], - [10204, 22796, 28291, 32768, 0], - [6076, 15935, 22125, 32768, 0], - [3852, 10937, 16816, 32768, 0], - [2252, 6324, 10131, 32768, 0], - [25840, 32016, 32662, 32768, 0], - [15109, 28268, 31531, 32768, 0], - [9385, 22231, 28340, 32768, 0], - [6082, 16672, 23479, 32768, 0], - [3318, 9427, 14681, 32768, 0], - [30594, 32574, 32718, 32768, 0], - [16836, 29552, 31859, 32768, 0], - [9556, 22542, 28356, 32768, 0], - [6305, 16725, 23540, 32768, 0], - [3376, 9895, 15184, 32768, 0], - [29383, 32617, 32745, 32768, 0], - [18891, 30809, 32401, 32768, 0], - [11688, 25942, 30687, 32768, 0], - [7468, 19469, 26651, 32768, 0], - [3909, 11358, 17012, 32768, 0], - [31564, 32736, 32748, 32768, 0], - [20906, 31611, 32600, 32768, 0], - [13191, 27621, 31537, 32768, 0], - [8768, 22029, 28676, 32768, 0], - [5079, 14109, 20906, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], -]; - -pub static DEFAULT_COEFF_BASE_EOB_CDF: [[[[[u16; 4]; 4]; 2]; 5]; 4] = [ - [ - [ - [[17837, 29055, 32768, 0], [29600, 31446, 32768, 0], [30844, 31878, 32768, 0], [24926, 28948, 32768, 0]], - [[21365, 30026, 32768, 0], [30512, 32423, 32768, 0], [31658, 32621, 32768, 0], [29630, 31881, 32768, 0]], - ], - [ - [[5717, 26477, 32768, 0], [30491, 31703, 32768, 0], [31550, 32158, 32768, 0], [29648, 31491, 32768, 0]], - [[12608, 27820, 32768, 0], [30680, 32225, 32768, 0], [30809, 32335, 32768, 0], [31299, 32423, 32768, 0]], - ], - [ - [[1786, 12612, 32768, 0], [30663, 31625, 32768, 0], [32339, 32468, 32768, 0], [31148, 31833, 32768, 0]], - [[18857, 23865, 32768, 0], [31428, 32428, 32768, 0], [31744, 32373, 32768, 0], [31775, 32526, 32768, 0]], - ], - [ - [[1787, 2532, 32768, 0], [30832, 31662, 32768, 0], [31824, 32682, 32768, 0], [32133, 32569, 32768, 0]], - [[13751, 22235, 32768, 0], [32089, 32409, 32768, 0], [27084, 27920, 32768, 0], [29291, 32594, 32768, 0]], - ], - [ - [[1725, 3449, 32768, 0], [31102, 31935, 32768, 0], [32457, 32613, 32768, 0], [32412, 32649, 32768, 0]], - [[10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0]], - ], - ], - [ - [ - [[17560, 29888, 32768, 0], [29671, 31549, 32768, 0], [31007, 32056, 32768, 0], [27286, 30006, 32768, 0]], - [[26594, 31212, 32768, 0], [31208, 32582, 32768, 0], [31835, 32637, 32768, 0], [30595, 32206, 32768, 0]], - ], - [ - [[15239, 29932, 32768, 0], [31315, 32095, 32768, 0], [32130, 32434, 32768, 0], [30864, 31996, 32768, 0]], - [[26279, 30968, 32768, 0], [31142, 32495, 32768, 0], [31713, 32540, 32768, 0], [31929, 32594, 32768, 0]], - ], - [ - [[2644, 25198, 32768, 0], [32038, 32451, 32768, 0], [32639, 32695, 32768, 0], [32166, 32518, 32768, 0]], - [[17187, 27668, 32768, 0], [31714, 32550, 32768, 0], [32283, 32678, 32768, 0], [31930, 32563, 32768, 0]], - ], - [ - [[1044, 2257, 32768, 0], [30755, 31923, 32768, 0], [32208, 32693, 32768, 0], [32244, 32615, 32768, 0]], - [[21317, 26207, 32768, 0], [29133, 30868, 32768, 0], [29311, 31231, 32768, 0], [29657, 31087, 32768, 0]], - ], - [ - [[478, 1834, 32768, 0], [31005, 31987, 32768, 0], [32317, 32724, 32768, 0], [30865, 32648, 32768, 0]], - [[10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0]], - ], - ], - [ - [ - [[20092, 30774, 32768, 0], [30695, 32020, 32768, 0], [31131, 32103, 32768, 0], [28666, 30870, 32768, 0]], - [[27258, 31095, 32768, 0], [31804, 32623, 32768, 0], [31763, 32528, 32768, 0], [31438, 32506, 32768, 0]], - ], - [ - [[18049, 30489, 32768, 0], [31706, 32286, 32768, 0], [32163, 32473, 32768, 0], [31550, 32184, 32768, 0]], - [[27116, 30842, 32768, 0], [31971, 32598, 32768, 0], [32088, 32576, 32768, 0], [32067, 32664, 32768, 0]], - ], - [ - [[12854, 29093, 32768, 0], [32272, 32558, 32768, 0], [32667, 32729, 32768, 0], [32306, 32585, 32768, 0]], - [[25476, 30366, 32768, 0], [32169, 32687, 32768, 0], [32479, 32689, 32768, 0], [31673, 32634, 32768, 0]], - ], - [ - [[2809, 19301, 32768, 0], [32205, 32622, 32768, 0], [32338, 32730, 32768, 0], [31786, 32616, 32768, 0]], - [[22737, 29105, 32768, 0], [30810, 32362, 32768, 0], [30014, 32627, 32768, 0], [30528, 32574, 32768, 0]], - ], - [ - [[935, 3382, 32768, 0], [30789, 31909, 32768, 0], [32466, 32756, 32768, 0], [30860, 32513, 32768, 0]], - [[10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0]], - ], - ], - [ - [ - [[22497, 31198, 32768, 0], [31715, 32495, 32768, 0], [31606, 32337, 32768, 0], [30388, 31990, 32768, 0]], - [[27877, 31584, 32768, 0], [32170, 32728, 32768, 0], [32155, 32688, 32768, 0], [32219, 32702, 32768, 0]], - ], - [ - [[21457, 31043, 32768, 0], [31951, 32483, 32768, 0], [32153, 32562, 32768, 0], [31473, 32215, 32768, 0]], - [[27558, 31151, 32768, 0], [32020, 32640, 32768, 0], [32097, 32575, 32768, 0], [32242, 32719, 32768, 0]], - ], - [ - [[19980, 30591, 32768, 0], [32219, 32597, 32768, 0], [32581, 32706, 32768, 0], [31803, 32287, 32768, 0]], - [[26473, 30507, 32768, 0], [32431, 32723, 32768, 0], [32196, 32611, 32768, 0], [31588, 32528, 32768, 0]], - ], - [ - [[24647, 30463, 32768, 0], [32412, 32695, 32768, 0], [32468, 32720, 32768, 0], [31269, 32523, 32768, 0]], - [[28482, 31505, 32768, 0], [32152, 32701, 32768, 0], [31732, 32598, 32768, 0], [31767, 32712, 32768, 0]], - ], - [ - [[12358, 24977, 32768, 0], [31331, 32385, 32768, 0], [32634, 32756, 32768, 0], [30411, 32548, 32768, 0]], - [[10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0], [10923, 21845, 32768, 0]], - ], - ], -]; - -pub static DEFAULT_COEFF_BR_CDF: [[[[[u16; 5]; 21]; 2]; 5]; 4] = [ - [ - [ - [ - [14298, 20718, 24174, 32768, 0], - [12536, 19601, 23789, 32768, 0], - [8712, 15051, 19503, 32768, 0], - [6170, 11327, 15434, 32768, 0], - [4742, 8926, 12538, 32768, 0], - [3803, 7317, 10546, 32768, 0], - [1696, 3317, 4871, 32768, 0], - [14392, 19951, 22756, 32768, 0], - [15978, 23218, 26818, 32768, 0], - [12187, 19474, 23889, 32768, 0], - [9176, 15640, 20259, 32768, 0], - [7068, 12655, 17028, 32768, 0], - [5656, 10442, 14472, 32768, 0], - [2580, 4992, 7244, 32768, 0], - [12136, 18049, 21426, 32768, 0], - [13784, 20721, 24481, 32768, 0], - [10836, 17621, 21900, 32768, 0], - [8372, 14444, 18847, 32768, 0], - [6523, 11779, 16000, 32768, 0], - [5337, 9898, 13760, 32768, 0], - [3034, 5860, 8462, 32768, 0], - ], - [ - [15967, 22905, 26286, 32768, 0], - [13534, 20654, 24579, 32768, 0], - [9504, 16092, 20535, 32768, 0], - [6975, 12568, 16903, 32768, 0], - [5364, 10091, 14020, 32768, 0], - [4357, 8370, 11857, 32768, 0], - [2506, 4934, 7218, 32768, 0], - [23032, 28815, 30936, 32768, 0], - [19540, 26704, 29719, 32768, 0], - [15158, 22969, 27097, 32768, 0], - [11408, 18865, 23650, 32768, 0], - [8885, 15448, 20250, 32768, 0], - [7108, 12853, 17416, 32768, 0], - [4231, 8041, 11480, 32768, 0], - [19823, 26490, 29156, 32768, 0], - [18890, 25929, 28932, 32768, 0], - [15660, 23491, 27433, 32768, 0], - [12147, 19776, 24488, 32768, 0], - [9728, 16774, 21649, 32768, 0], - [7919, 14277, 19066, 32768, 0], - [5440, 10170, 14185, 32768, 0], - ], - ], - [ - [ - [14406, 20862, 24414, 32768, 0], - [11824, 18907, 23109, 32768, 0], - [8257, 14393, 18803, 32768, 0], - [5860, 10747, 14778, 32768, 0], - [4475, 8486, 11984, 32768, 0], - [3606, 6954, 10043, 32768, 0], - [1736, 3410, 5048, 32768, 0], - [14430, 20046, 22882, 32768, 0], - [15593, 22899, 26709, 32768, 0], - [12102, 19368, 23811, 32768, 0], - [9059, 15584, 20262, 32768, 0], - [6999, 12603, 17048, 32768, 0], - [5684, 10497, 14553, 32768, 0], - [2822, 5438, 7862, 32768, 0], - [15785, 21585, 24359, 32768, 0], - [18347, 25229, 28266, 32768, 0], - [14974, 22487, 26389, 32768, 0], - [11423, 18681, 23271, 32768, 0], - [8863, 15350, 20008, 32768, 0], - [7153, 12852, 17278, 32768, 0], - [3707, 7036, 9982, 32768, 0], - ], - [ - [15460, 21696, 25469, 32768, 0], - [12170, 19249, 23191, 32768, 0], - [8723, 15027, 19332, 32768, 0], - [6428, 11704, 15874, 32768, 0], - [4922, 9292, 13052, 32768, 0], - [4139, 7695, 11010, 32768, 0], - [2291, 4508, 6598, 32768, 0], - [19856, 26920, 29828, 32768, 0], - [17923, 25289, 28792, 32768, 0], - [14278, 21968, 26297, 32768, 0], - [10910, 18136, 22950, 32768, 0], - [8423, 14815, 19627, 32768, 0], - [6771, 12283, 16774, 32768, 0], - [4074, 7750, 11081, 32768, 0], - [19852, 26074, 28672, 32768, 0], - [19371, 26110, 28989, 32768, 0], - [16265, 23873, 27663, 32768, 0], - [12758, 20378, 24952, 32768, 0], - [10095, 17098, 21961, 32768, 0], - [8250, 14628, 19451, 32768, 0], - [5205, 9745, 13622, 32768, 0], - ], - ], - [ - [ - [10563, 16233, 19763, 32768, 0], - [9794, 16022, 19804, 32768, 0], - [6750, 11945, 15759, 32768, 0], - [4963, 9186, 12752, 32768, 0], - [3845, 7435, 10627, 32768, 0], - [3051, 6085, 8834, 32768, 0], - [1311, 2596, 3830, 32768, 0], - [11246, 16404, 19689, 32768, 0], - [12315, 18911, 22731, 32768, 0], - [10557, 17095, 21289, 32768, 0], - [8136, 14006, 18249, 32768, 0], - [6348, 11474, 15565, 32768, 0], - [5196, 9655, 13400, 32768, 0], - [2349, 4526, 6587, 32768, 0], - [13337, 18730, 21569, 32768, 0], - [19306, 26071, 28882, 32768, 0], - [15952, 23540, 27254, 32768, 0], - [12409, 19934, 24430, 32768, 0], - [9760, 16706, 21389, 32768, 0], - [8004, 14220, 18818, 32768, 0], - [4138, 7794, 10961, 32768, 0], - ], - [ - [10870, 16684, 20949, 32768, 0], - [9664, 15230, 18680, 32768, 0], - [6886, 12109, 15408, 32768, 0], - [4825, 8900, 12305, 32768, 0], - [3630, 7162, 10314, 32768, 0], - [3036, 6429, 9387, 32768, 0], - [1671, 3296, 4940, 32768, 0], - [13819, 19159, 23026, 32768, 0], - [11984, 19108, 23120, 32768, 0], - [10690, 17210, 21663, 32768, 0], - [7984, 14154, 18333, 32768, 0], - [6868, 12294, 16124, 32768, 0], - [5274, 8994, 12868, 32768, 0], - [2988, 5771, 8424, 32768, 0], - [19736, 26647, 29141, 32768, 0], - [18933, 26070, 28984, 32768, 0], - [15779, 23048, 27200, 32768, 0], - [12638, 20061, 24532, 32768, 0], - [10692, 17545, 22220, 32768, 0], - [9217, 15251, 20054, 32768, 0], - [5078, 9284, 12594, 32768, 0], - ], - ], - [ - [ - [2331, 3662, 5244, 32768, 0], - [2891, 4771, 6145, 32768, 0], - [4598, 7623, 9729, 32768, 0], - [3520, 6845, 9199, 32768, 0], - [3417, 6119, 9324, 32768, 0], - [2601, 5412, 7385, 32768, 0], - [600, 1173, 1744, 32768, 0], - [7672, 13286, 17469, 32768, 0], - [4232, 7792, 10793, 32768, 0], - [2915, 5317, 7397, 32768, 0], - [2318, 4356, 6152, 32768, 0], - [2127, 4000, 5554, 32768, 0], - [1850, 3478, 5275, 32768, 0], - [977, 1933, 2843, 32768, 0], - [18280, 24387, 27989, 32768, 0], - [15852, 22671, 26185, 32768, 0], - [13845, 20951, 24789, 32768, 0], - [11055, 17966, 22129, 32768, 0], - [9138, 15422, 19801, 32768, 0], - [7454, 13145, 17456, 32768, 0], - [3370, 6393, 9013, 32768, 0], - ], - [ - [5842, 9229, 10838, 32768, 0], - [2313, 3491, 4276, 32768, 0], - [2998, 6104, 7496, 32768, 0], - [2420, 7447, 9868, 32768, 0], - [3034, 8495, 10923, 32768, 0], - [4076, 8937, 10975, 32768, 0], - [1086, 2370, 3299, 32768, 0], - [9714, 17254, 20444, 32768, 0], - [8543, 13698, 17123, 32768, 0], - [4918, 9007, 11910, 32768, 0], - [4129, 7532, 10553, 32768, 0], - [2364, 5533, 8058, 32768, 0], - [1834, 3546, 5563, 32768, 0], - [1473, 2908, 4133, 32768, 0], - [15405, 21193, 25619, 32768, 0], - [15691, 21952, 26561, 32768, 0], - [12962, 19194, 24165, 32768, 0], - [10272, 17855, 22129, 32768, 0], - [8588, 15270, 20718, 32768, 0], - [8682, 14669, 19500, 32768, 0], - [4870, 9636, 13205, 32768, 0], - ], - ], - [ - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], - [ - [ - [ - [14995, 21341, 24749, 32768, 0], - [13158, 20289, 24601, 32768, 0], - [8941, 15326, 19876, 32768, 0], - [6297, 11541, 15807, 32768, 0], - [4817, 9029, 12776, 32768, 0], - [3731, 7273, 10627, 32768, 0], - [1847, 3617, 5354, 32768, 0], - [14472, 19659, 22343, 32768, 0], - [16806, 24162, 27533, 32768, 0], - [12900, 20404, 24713, 32768, 0], - [9411, 16112, 20797, 32768, 0], - [7056, 12697, 17148, 32768, 0], - [5544, 10339, 14460, 32768, 0], - [2954, 5704, 8319, 32768, 0], - [12464, 18071, 21354, 32768, 0], - [15482, 22528, 26034, 32768, 0], - [12070, 19269, 23624, 32768, 0], - [8953, 15406, 20106, 32768, 0], - [7027, 12730, 17220, 32768, 0], - [5887, 10913, 15140, 32768, 0], - [3793, 7278, 10447, 32768, 0], - ], - [ - [15571, 22232, 25749, 32768, 0], - [14506, 21575, 25374, 32768, 0], - [10189, 17089, 21569, 32768, 0], - [7316, 13301, 17915, 32768, 0], - [5783, 10912, 15190, 32768, 0], - [4760, 9155, 13088, 32768, 0], - [2993, 5966, 8774, 32768, 0], - [23424, 28903, 30778, 32768, 0], - [20775, 27666, 30290, 32768, 0], - [16474, 24410, 28299, 32768, 0], - [12471, 20180, 24987, 32768, 0], - [9410, 16487, 21439, 32768, 0], - [7536, 13614, 18529, 32768, 0], - [5048, 9586, 13549, 32768, 0], - [21090, 27290, 29756, 32768, 0], - [20796, 27402, 30026, 32768, 0], - [17819, 25485, 28969, 32768, 0], - [13860, 21909, 26462, 32768, 0], - [11002, 18494, 23529, 32768, 0], - [8953, 15929, 20897, 32768, 0], - [6448, 11918, 16454, 32768, 0], - ], - ], - [ - [ - [15999, 22208, 25449, 32768, 0], - [13050, 19988, 24122, 32768, 0], - [8594, 14864, 19378, 32768, 0], - [6033, 11079, 15238, 32768, 0], - [4554, 8683, 12347, 32768, 0], - [3672, 7139, 10337, 32768, 0], - [1900, 3771, 5576, 32768, 0], - [15788, 21340, 23949, 32768, 0], - [16825, 24235, 27758, 32768, 0], - [12873, 20402, 24810, 32768, 0], - [9590, 16363, 21094, 32768, 0], - [7352, 13209, 17733, 32768, 0], - [5960, 10989, 15184, 32768, 0], - [3232, 6234, 9007, 32768, 0], - [15761, 20716, 23224, 32768, 0], - [19318, 25989, 28759, 32768, 0], - [15529, 23094, 26929, 32768, 0], - [11662, 18989, 23641, 32768, 0], - [8955, 15568, 20366, 32768, 0], - [7281, 13106, 17708, 32768, 0], - [4248, 8059, 11440, 32768, 0], - ], - [ - [14899, 21217, 24503, 32768, 0], - [13519, 20283, 24047, 32768, 0], - [9429, 15966, 20365, 32768, 0], - [6700, 12355, 16652, 32768, 0], - [5088, 9704, 13716, 32768, 0], - [4243, 8154, 11731, 32768, 0], - [2702, 5364, 7861, 32768, 0], - [22745, 28388, 30454, 32768, 0], - [20235, 27146, 29922, 32768, 0], - [15896, 23715, 27637, 32768, 0], - [11840, 19350, 24131, 32768, 0], - [9122, 15932, 20880, 32768, 0], - [7488, 13581, 18362, 32768, 0], - [5114, 9568, 13370, 32768, 0], - [20845, 26553, 28932, 32768, 0], - [20981, 27372, 29884, 32768, 0], - [17781, 25335, 28785, 32768, 0], - [13760, 21708, 26297, 32768, 0], - [10975, 18415, 23365, 32768, 0], - [9045, 15789, 20686, 32768, 0], - [6130, 11199, 15423, 32768, 0], - ], - ], - [ - [ - [13549, 19724, 23158, 32768, 0], - [11844, 18382, 22246, 32768, 0], - [7919, 13619, 17773, 32768, 0], - [5486, 10143, 13946, 32768, 0], - [4166, 7983, 11324, 32768, 0], - [3364, 6506, 9427, 32768, 0], - [1598, 3160, 4674, 32768, 0], - [15281, 20979, 23781, 32768, 0], - [14939, 22119, 25952, 32768, 0], - [11363, 18407, 22812, 32768, 0], - [8609, 14857, 19370, 32768, 0], - [6737, 12184, 16480, 32768, 0], - [5506, 10263, 14262, 32768, 0], - [2990, 5786, 8380, 32768, 0], - [20249, 25253, 27417, 32768, 0], - [21070, 27518, 30001, 32768, 0], - [16854, 24469, 28074, 32768, 0], - [12864, 20486, 25000, 32768, 0], - [9962, 16978, 21778, 32768, 0], - [8074, 14338, 19048, 32768, 0], - [4494, 8479, 11906, 32768, 0], - ], - [ - [13960, 19617, 22829, 32768, 0], - [11150, 17341, 21228, 32768, 0], - [7150, 12964, 17190, 32768, 0], - [5331, 10002, 13867, 32768, 0], - [4167, 7744, 11057, 32768, 0], - [3480, 6629, 9646, 32768, 0], - [1883, 3784, 5686, 32768, 0], - [18752, 25660, 28912, 32768, 0], - [16968, 24586, 28030, 32768, 0], - [13520, 21055, 25313, 32768, 0], - [10453, 17626, 22280, 32768, 0], - [8386, 14505, 19116, 32768, 0], - [6742, 12595, 17008, 32768, 0], - [4273, 8140, 11499, 32768, 0], - [22120, 27827, 30233, 32768, 0], - [20563, 27358, 29895, 32768, 0], - [17076, 24644, 28153, 32768, 0], - [13362, 20942, 25309, 32768, 0], - [10794, 17965, 22695, 32768, 0], - [9014, 15652, 20319, 32768, 0], - [5708, 10512, 14497, 32768, 0], - ], - ], - [ - [ - [5705, 10930, 15725, 32768, 0], - [7946, 12765, 16115, 32768, 0], - [6801, 12123, 16226, 32768, 0], - [5462, 10135, 14200, 32768, 0], - [4189, 8011, 11507, 32768, 0], - [3191, 6229, 9408, 32768, 0], - [1057, 2137, 3212, 32768, 0], - [10018, 17067, 21491, 32768, 0], - [7380, 12582, 16453, 32768, 0], - [6068, 10845, 14339, 32768, 0], - [5098, 9198, 12555, 32768, 0], - [4312, 8010, 11119, 32768, 0], - [3700, 6966, 9781, 32768, 0], - [1693, 3326, 4887, 32768, 0], - [18757, 24930, 27774, 32768, 0], - [17648, 24596, 27817, 32768, 0], - [14707, 22052, 26026, 32768, 0], - [11720, 18852, 23292, 32768, 0], - [9357, 15952, 20525, 32768, 0], - [7810, 13753, 18210, 32768, 0], - [3879, 7333, 10328, 32768, 0], - ], - [ - [8278, 13242, 15922, 32768, 0], - [10547, 15867, 18919, 32768, 0], - [9106, 15842, 20609, 32768, 0], - [6833, 13007, 17218, 32768, 0], - [4811, 9712, 13923, 32768, 0], - [3985, 7352, 11128, 32768, 0], - [1688, 3458, 5262, 32768, 0], - [12951, 21861, 26510, 32768, 0], - [9788, 16044, 20276, 32768, 0], - [6309, 11244, 14870, 32768, 0], - [5183, 9349, 12566, 32768, 0], - [4389, 8229, 11492, 32768, 0], - [3633, 6945, 10620, 32768, 0], - [3600, 6847, 9907, 32768, 0], - [21748, 28137, 30255, 32768, 0], - [19436, 26581, 29560, 32768, 0], - [16359, 24201, 27953, 32768, 0], - [13961, 21693, 25871, 32768, 0], - [11544, 18686, 23322, 32768, 0], - [9372, 16462, 20952, 32768, 0], - [6138, 11210, 15390, 32768, 0], - ], - ], - [ - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], - [ - [ - [ - [16138, 22223, 25509, 32768, 0], - [15347, 22430, 26332, 32768, 0], - [9614, 16736, 21332, 32768, 0], - [6600, 12275, 16907, 32768, 0], - [4811, 9424, 13547, 32768, 0], - [3748, 7809, 11420, 32768, 0], - [2254, 4587, 6890, 32768, 0], - [15196, 20284, 23177, 32768, 0], - [18317, 25469, 28451, 32768, 0], - [13918, 21651, 25842, 32768, 0], - [10052, 17150, 21995, 32768, 0], - [7499, 13630, 18587, 32768, 0], - [6158, 11417, 16003, 32768, 0], - [4014, 7785, 11252, 32768, 0], - [15048, 21067, 24384, 32768, 0], - [18202, 25346, 28553, 32768, 0], - [14302, 22019, 26356, 32768, 0], - [10839, 18139, 23166, 32768, 0], - [8715, 15744, 20806, 32768, 0], - [7536, 13576, 18544, 32768, 0], - [5413, 10335, 14498, 32768, 0], - ], - [ - [17394, 24501, 27895, 32768, 0], - [15889, 23420, 27185, 32768, 0], - [11561, 19133, 23870, 32768, 0], - [8285, 14812, 19844, 32768, 0], - [6496, 12043, 16550, 32768, 0], - [4771, 9574, 13677, 32768, 0], - [3603, 6830, 10144, 32768, 0], - [21656, 27704, 30200, 32768, 0], - [21324, 27915, 30511, 32768, 0], - [17327, 25336, 28997, 32768, 0], - [13417, 21381, 26033, 32768, 0], - [10132, 17425, 22338, 32768, 0], - [8580, 15016, 19633, 32768, 0], - [5694, 11477, 16411, 32768, 0], - [24116, 29780, 31450, 32768, 0], - [23853, 29695, 31591, 32768, 0], - [20085, 27614, 30428, 32768, 0], - [15326, 24335, 28575, 32768, 0], - [11814, 19472, 24810, 32768, 0], - [10221, 18611, 24767, 32768, 0], - [7689, 14558, 20321, 32768, 0], - ], - ], - [ - [ - [16214, 22380, 25770, 32768, 0], - [14213, 21304, 25295, 32768, 0], - [9213, 15823, 20455, 32768, 0], - [6395, 11758, 16139, 32768, 0], - [4779, 9187, 13066, 32768, 0], - [3821, 7501, 10953, 32768, 0], - [2293, 4567, 6795, 32768, 0], - [15859, 21283, 23820, 32768, 0], - [18404, 25602, 28726, 32768, 0], - [14325, 21980, 26206, 32768, 0], - [10669, 17937, 22720, 32768, 0], - [8297, 14642, 19447, 32768, 0], - [6746, 12389, 16893, 32768, 0], - [4324, 8251, 11770, 32768, 0], - [16532, 21631, 24475, 32768, 0], - [20667, 27150, 29668, 32768, 0], - [16728, 24510, 28175, 32768, 0], - [12861, 20645, 25332, 32768, 0], - [10076, 17361, 22417, 32768, 0], - [8395, 14940, 19963, 32768, 0], - [5731, 10683, 14912, 32768, 0], - ], - [ - [14433, 21155, 24938, 32768, 0], - [14658, 21716, 25545, 32768, 0], - [9923, 16824, 21557, 32768, 0], - [6982, 13052, 17721, 32768, 0], - [5419, 10503, 15050, 32768, 0], - [4852, 9162, 13014, 32768, 0], - [3271, 6395, 9630, 32768, 0], - [22210, 27833, 30109, 32768, 0], - [20750, 27368, 29821, 32768, 0], - [16894, 24828, 28573, 32768, 0], - [13247, 21276, 25757, 32768, 0], - [10038, 17265, 22563, 32768, 0], - [8587, 14947, 20327, 32768, 0], - [5645, 11371, 15252, 32768, 0], - [22027, 27526, 29714, 32768, 0], - [23098, 29146, 31221, 32768, 0], - [19886, 27341, 30272, 32768, 0], - [15609, 23747, 28046, 32768, 0], - [11993, 20065, 24939, 32768, 0], - [9637, 18267, 23671, 32768, 0], - [7625, 13801, 19144, 32768, 0], - ], - ], - [ - [ - [14438, 20798, 24089, 32768, 0], - [12621, 19203, 23097, 32768, 0], - [8177, 14125, 18402, 32768, 0], - [5674, 10501, 14456, 32768, 0], - [4236, 8239, 11733, 32768, 0], - [3447, 6750, 9806, 32768, 0], - [1986, 3950, 5864, 32768, 0], - [16208, 22099, 24930, 32768, 0], - [16537, 24025, 27585, 32768, 0], - [12780, 20381, 24867, 32768, 0], - [9767, 16612, 21416, 32768, 0], - [7686, 13738, 18398, 32768, 0], - [6333, 11614, 15964, 32768, 0], - [3941, 7571, 10836, 32768, 0], - [22819, 27422, 29202, 32768, 0], - [22224, 28514, 30721, 32768, 0], - [17660, 25433, 28913, 32768, 0], - [13574, 21482, 26002, 32768, 0], - [10629, 17977, 22938, 32768, 0], - [8612, 15298, 20265, 32768, 0], - [5607, 10491, 14596, 32768, 0], - ], - [ - [13569, 19800, 23206, 32768, 0], - [13128, 19924, 23869, 32768, 0], - [8329, 14841, 19403, 32768, 0], - [6130, 10976, 15057, 32768, 0], - [4682, 8839, 12518, 32768, 0], - [3656, 7409, 10588, 32768, 0], - [2577, 5099, 7412, 32768, 0], - [22427, 28684, 30585, 32768, 0], - [20913, 27750, 30139, 32768, 0], - [15840, 24109, 27834, 32768, 0], - [12308, 20029, 24569, 32768, 0], - [10216, 16785, 21458, 32768, 0], - [8309, 14203, 19113, 32768, 0], - [6043, 11168, 15307, 32768, 0], - [23166, 28901, 30998, 32768, 0], - [21899, 28405, 30751, 32768, 0], - [18413, 26091, 29443, 32768, 0], - [15233, 23114, 27352, 32768, 0], - [12683, 20472, 25288, 32768, 0], - [10702, 18259, 23409, 32768, 0], - [8125, 14464, 19226, 32768, 0], - ], - ], - [ - [ - [9040, 14786, 18360, 32768, 0], - [9979, 15718, 19415, 32768, 0], - [7913, 13918, 18311, 32768, 0], - [5859, 10889, 15184, 32768, 0], - [4593, 8677, 12510, 32768, 0], - [3820, 7396, 10791, 32768, 0], - [1730, 3471, 5192, 32768, 0], - [11803, 18365, 22709, 32768, 0], - [11419, 18058, 22225, 32768, 0], - [9418, 15774, 20243, 32768, 0], - [7539, 13325, 17657, 32768, 0], - [6233, 11317, 15384, 32768, 0], - [5137, 9656, 13545, 32768, 0], - [2977, 5774, 8349, 32768, 0], - [21207, 27246, 29640, 32768, 0], - [19547, 26578, 29497, 32768, 0], - [16169, 23871, 27690, 32768, 0], - [12820, 20458, 25018, 32768, 0], - [10224, 17332, 22214, 32768, 0], - [8526, 15048, 19884, 32768, 0], - [5037, 9410, 13118, 32768, 0], - ], - [ - [12339, 17329, 20140, 32768, 0], - [13505, 19895, 23225, 32768, 0], - [9847, 16944, 21564, 32768, 0], - [7280, 13256, 18348, 32768, 0], - [4712, 10009, 14454, 32768, 0], - [4361, 7914, 12477, 32768, 0], - [2870, 5628, 7995, 32768, 0], - [20061, 25504, 28526, 32768, 0], - [15235, 22878, 26145, 32768, 0], - [12985, 19958, 24155, 32768, 0], - [9782, 16641, 21403, 32768, 0], - [9456, 16360, 20760, 32768, 0], - [6855, 12940, 18557, 32768, 0], - [5661, 10564, 15002, 32768, 0], - [25656, 30602, 31894, 32768, 0], - [22570, 29107, 31092, 32768, 0], - [18917, 26423, 29541, 32768, 0], - [15940, 23649, 27754, 32768, 0], - [12803, 20581, 25219, 32768, 0], - [11082, 18695, 23376, 32768, 0], - [7939, 14373, 19005, 32768, 0], - ], - ], - [ - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], - [ - [ - [ - [18315, 24289, 27551, 32768, 0], - [16854, 24068, 27835, 32768, 0], - [10140, 17927, 23173, 32768, 0], - [6722, 12982, 18267, 32768, 0], - [4661, 9826, 14706, 32768, 0], - [3832, 8165, 12294, 32768, 0], - [2795, 6098, 9245, 32768, 0], - [17145, 23326, 26672, 32768, 0], - [20733, 27680, 30308, 32768, 0], - [16032, 24461, 28546, 32768, 0], - [11653, 20093, 25081, 32768, 0], - [9290, 16429, 22086, 32768, 0], - [7796, 14598, 19982, 32768, 0], - [6502, 12378, 17441, 32768, 0], - [21681, 27732, 30320, 32768, 0], - [22389, 29044, 31261, 32768, 0], - [19027, 26731, 30087, 32768, 0], - [14739, 23755, 28624, 32768, 0], - [11358, 20778, 25511, 32768, 0], - [10995, 18073, 24190, 32768, 0], - [9162, 14990, 20617, 32768, 0], - ], - [ - [21425, 27952, 30388, 32768, 0], - [18062, 25838, 29034, 32768, 0], - [11956, 19881, 24808, 32768, 0], - [7718, 15000, 20980, 32768, 0], - [5702, 11254, 16143, 32768, 0], - [4898, 9088, 16864, 32768, 0], - [3679, 6776, 11907, 32768, 0], - [23294, 30160, 31663, 32768, 0], - [24397, 29896, 31836, 32768, 0], - [19245, 27128, 30593, 32768, 0], - [13202, 19825, 26404, 32768, 0], - [11578, 19297, 23957, 32768, 0], - [8073, 13297, 21370, 32768, 0], - [5461, 10923, 19745, 32768, 0], - [27367, 30521, 31934, 32768, 0], - [24904, 30671, 31940, 32768, 0], - [23075, 28460, 31299, 32768, 0], - [14400, 23658, 30417, 32768, 0], - [13885, 23882, 28325, 32768, 0], - [14746, 22938, 27853, 32768, 0], - [5461, 16384, 27307, 32768, 0], - ], - ], - [ - [ - [18274, 24813, 27890, 32768, 0], - [15537, 23149, 27003, 32768, 0], - [9449, 16740, 21827, 32768, 0], - [6700, 12498, 17261, 32768, 0], - [4988, 9866, 14198, 32768, 0], - [4236, 8147, 11902, 32768, 0], - [2867, 5860, 8654, 32768, 0], - [17124, 23171, 26101, 32768, 0], - [20396, 27477, 30148, 32768, 0], - [16573, 24629, 28492, 32768, 0], - [12749, 20846, 25674, 32768, 0], - [10233, 17878, 22818, 32768, 0], - [8525, 15332, 20363, 32768, 0], - [6283, 11632, 16255, 32768, 0], - [20466, 26511, 29286, 32768, 0], - [23059, 29174, 31191, 32768, 0], - [19481, 27263, 30241, 32768, 0], - [15458, 23631, 28137, 32768, 0], - [12416, 20608, 25693, 32768, 0], - [10261, 18011, 23261, 32768, 0], - [8016, 14655, 19666, 32768, 0], - ], - [ - [17616, 24586, 28112, 32768, 0], - [15809, 23299, 27155, 32768, 0], - [10767, 18890, 23793, 32768, 0], - [7727, 14255, 18865, 32768, 0], - [6129, 11926, 16882, 32768, 0], - [4482, 9704, 14861, 32768, 0], - [3277, 7452, 11522, 32768, 0], - [22956, 28551, 30730, 32768, 0], - [22724, 28937, 30961, 32768, 0], - [18467, 26324, 29580, 32768, 0], - [13234, 20713, 25649, 32768, 0], - [11181, 17592, 22481, 32768, 0], - [8291, 18358, 24576, 32768, 0], - [7568, 11881, 14984, 32768, 0], - [24948, 29001, 31147, 32768, 0], - [25674, 30619, 32151, 32768, 0], - [20841, 26793, 29603, 32768, 0], - [14669, 24356, 28666, 32768, 0], - [11334, 23593, 28219, 32768, 0], - [8922, 14762, 22873, 32768, 0], - [8301, 13544, 20535, 32768, 0], - ], - ], - [ - [ - [17113, 23733, 27081, 32768, 0], - [14139, 21406, 25452, 32768, 0], - [8552, 15002, 19776, 32768, 0], - [5871, 11120, 15378, 32768, 0], - [4455, 8616, 12253, 32768, 0], - [3469, 6910, 10386, 32768, 0], - [2255, 4553, 6782, 32768, 0], - [18224, 24376, 27053, 32768, 0], - [19290, 26710, 29614, 32768, 0], - [14936, 22991, 27184, 32768, 0], - [11238, 18951, 23762, 32768, 0], - [8786, 15617, 20588, 32768, 0], - [7317, 13228, 18003, 32768, 0], - [5101, 9512, 13493, 32768, 0], - [22639, 28222, 30210, 32768, 0], - [23216, 29331, 31307, 32768, 0], - [19075, 26762, 29895, 32768, 0], - [15014, 23113, 27457, 32768, 0], - [11938, 19857, 24752, 32768, 0], - [9942, 17280, 22282, 32768, 0], - [7167, 13144, 17752, 32768, 0], - ], - [ - [15820, 22738, 26488, 32768, 0], - [13530, 20885, 25216, 32768, 0], - [8395, 15530, 20452, 32768, 0], - [6574, 12321, 16380, 32768, 0], - [5353, 10419, 14568, 32768, 0], - [4613, 8446, 12381, 32768, 0], - [3440, 7158, 9903, 32768, 0], - [24247, 29051, 31224, 32768, 0], - [22118, 28058, 30369, 32768, 0], - [16498, 24768, 28389, 32768, 0], - [12920, 21175, 26137, 32768, 0], - [10730, 18619, 25352, 32768, 0], - [10187, 16279, 22791, 32768, 0], - [9310, 14631, 22127, 32768, 0], - [24970, 30558, 32057, 32768, 0], - [24801, 29942, 31698, 32768, 0], - [22432, 28453, 30855, 32768, 0], - [19054, 25680, 29580, 32768, 0], - [14392, 23036, 28109, 32768, 0], - [12495, 20947, 26650, 32768, 0], - [12442, 20326, 26214, 32768, 0], - ], - ], - [ - [ - [12162, 18785, 22648, 32768, 0], - [12749, 19697, 23806, 32768, 0], - [8580, 15297, 20346, 32768, 0], - [6169, 11749, 16543, 32768, 0], - [4836, 9391, 13448, 32768, 0], - [3821, 7711, 11613, 32768, 0], - [2228, 4601, 7070, 32768, 0], - [16319, 24725, 28280, 32768, 0], - [15698, 23277, 27168, 32768, 0], - [12726, 20368, 25047, 32768, 0], - [9912, 17015, 21976, 32768, 0], - [7888, 14220, 19179, 32768, 0], - [6777, 12284, 17018, 32768, 0], - [4492, 8590, 12252, 32768, 0], - [23249, 28904, 30947, 32768, 0], - [21050, 27908, 30512, 32768, 0], - [17440, 25340, 28949, 32768, 0], - [14059, 22018, 26541, 32768, 0], - [11288, 18903, 23898, 32768, 0], - [9411, 16342, 21428, 32768, 0], - [6278, 11588, 15944, 32768, 0], - ], - [ - [13981, 20067, 23226, 32768, 0], - [16922, 23580, 26783, 32768, 0], - [11005, 19039, 24487, 32768, 0], - [7389, 14218, 19798, 32768, 0], - [5598, 11505, 17206, 32768, 0], - [6090, 11213, 15659, 32768, 0], - [3820, 7371, 10119, 32768, 0], - [21082, 26925, 29675, 32768, 0], - [21262, 28627, 31128, 32768, 0], - [18392, 26454, 30437, 32768, 0], - [14870, 22910, 27096, 32768, 0], - [12620, 19484, 24908, 32768, 0], - [9290, 16553, 22802, 32768, 0], - [6668, 14288, 20004, 32768, 0], - [27704, 31055, 31949, 32768, 0], - [24709, 29978, 31788, 32768, 0], - [21668, 29264, 31657, 32768, 0], - [18295, 26968, 30074, 32768, 0], - [16399, 24422, 29313, 32768, 0], - [14347, 23026, 28104, 32768, 0], - [12370, 19806, 24477, 32768, 0], - ], - ], - [ - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - [ - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - [8192, 16384, 24576, 32768, 0], - ], - ], - ], -]; - -pub static DEFAULT_DC_SIGN_CDF: [[[[u16; 3]; 3]; 2]; 4] = [ - [[[16000, 32768, 0], [13056, 32768, 0], [18816, 32768, 0]], [[15232, 32768, 0], [12928, 32768, 0], [17280, 32768, 0]]], - [[[16000, 32768, 0], [13056, 32768, 0], [18816, 32768, 0]], [[15232, 32768, 0], [12928, 32768, 0], [17280, 32768, 0]]], - [[[16000, 32768, 0], [13056, 32768, 0], [18816, 32768, 0]], [[15232, 32768, 0], [12928, 32768, 0], [17280, 32768, 0]]], - [[[16000, 32768, 0], [13056, 32768, 0], [18816, 32768, 0]], [[15232, 32768, 0], [12928, 32768, 0], [17280, 32768, 0]]], -]; - -pub static DEFAULT_DRL_MODE_CDF: [[u16; 3]; 3] = [[13104, 32768, 0], [24560, 32768, 0], [18945, 32768, 0]]; - -pub static DEFAULT_EOB_EXTRA_CDF: [[[[[u16; 3]; 9]; 2]; 5]; 4] = [ - [ - [ - [ - [16961, 32768, 0], - [17223, 32768, 0], - [7621, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [19069, 32768, 0], - [22525, 32768, 0], - [13377, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [20401, 32768, 0], - [17025, 32768, 0], - [12845, 32768, 0], - [12873, 32768, 0], - [14094, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [20681, 32768, 0], - [20701, 32768, 0], - [15250, 32768, 0], - [15017, 32768, 0], - [14928, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [23905, 32768, 0], - [17194, 32768, 0], - [16170, 32768, 0], - [17695, 32768, 0], - [13826, 32768, 0], - [15810, 32768, 0], - [12036, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [23959, 32768, 0], - [20799, 32768, 0], - [19021, 32768, 0], - [16203, 32768, 0], - [17886, 32768, 0], - [14144, 32768, 0], - [12010, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [27399, 32768, 0], - [16327, 32768, 0], - [18071, 32768, 0], - [19584, 32768, 0], - [20721, 32768, 0], - [18432, 32768, 0], - [19560, 32768, 0], - [10150, 32768, 0], - [8805, 32768, 0], - ], - [ - [24932, 32768, 0], - [20833, 32768, 0], - [12027, 32768, 0], - [16670, 32768, 0], - [19914, 32768, 0], - [15106, 32768, 0], - [17662, 32768, 0], - [13783, 32768, 0], - [28756, 32768, 0], - ], - ], - [ - [ - [23406, 32768, 0], - [21845, 32768, 0], - [18432, 32768, 0], - [16384, 32768, 0], - [17096, 32768, 0], - [12561, 32768, 0], - [17320, 32768, 0], - [22395, 32768, 0], - [21370, 32768, 0], - ], - [ - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - ], - [ - [ - [ - [17471, 32768, 0], - [20223, 32768, 0], - [11357, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [20335, 32768, 0], - [21667, 32768, 0], - [14818, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [20430, 32768, 0], - [20662, 32768, 0], - [15367, 32768, 0], - [16970, 32768, 0], - [14657, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [22117, 32768, 0], - [22028, 32768, 0], - [18650, 32768, 0], - [16042, 32768, 0], - [15885, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [22409, 32768, 0], - [21012, 32768, 0], - [15650, 32768, 0], - [17395, 32768, 0], - [15469, 32768, 0], - [20205, 32768, 0], - [19511, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [24220, 32768, 0], - [22480, 32768, 0], - [17737, 32768, 0], - [18916, 32768, 0], - [19268, 32768, 0], - [18412, 32768, 0], - [18844, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [25991, 32768, 0], - [20314, 32768, 0], - [17731, 32768, 0], - [19678, 32768, 0], - [18649, 32768, 0], - [17307, 32768, 0], - [21798, 32768, 0], - [17549, 32768, 0], - [15630, 32768, 0], - ], - [ - [26585, 32768, 0], - [21469, 32768, 0], - [20432, 32768, 0], - [17735, 32768, 0], - [19280, 32768, 0], - [15235, 32768, 0], - [20297, 32768, 0], - [22471, 32768, 0], - [28997, 32768, 0], - ], - ], - [ - [ - [26605, 32768, 0], - [11304, 32768, 0], - [16726, 32768, 0], - [16560, 32768, 0], - [20866, 32768, 0], - [23524, 32768, 0], - [19878, 32768, 0], - [13469, 32768, 0], - [23084, 32768, 0], - ], - [ - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - ], - [ - [ - [ - [18983, 32768, 0], - [20512, 32768, 0], - [14885, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [20090, 32768, 0], - [19444, 32768, 0], - [17286, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [19139, 32768, 0], - [21487, 32768, 0], - [18959, 32768, 0], - [20910, 32768, 0], - [19089, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [20536, 32768, 0], - [20664, 32768, 0], - [20625, 32768, 0], - [19123, 32768, 0], - [14862, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [19833, 32768, 0], - [21502, 32768, 0], - [17485, 32768, 0], - [20267, 32768, 0], - [18353, 32768, 0], - [23329, 32768, 0], - [21478, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [22041, 32768, 0], - [23434, 32768, 0], - [20001, 32768, 0], - [20554, 32768, 0], - [20951, 32768, 0], - [20145, 32768, 0], - [15562, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [23312, 32768, 0], - [21607, 32768, 0], - [16526, 32768, 0], - [18957, 32768, 0], - [18034, 32768, 0], - [18934, 32768, 0], - [24247, 32768, 0], - [16921, 32768, 0], - [17080, 32768, 0], - ], - [ - [26579, 32768, 0], - [24910, 32768, 0], - [18637, 32768, 0], - [19800, 32768, 0], - [20388, 32768, 0], - [9887, 32768, 0], - [15642, 32768, 0], - [30198, 32768, 0], - [24721, 32768, 0], - ], - ], - [ - [ - [26998, 32768, 0], - [16737, 32768, 0], - [17838, 32768, 0], - [18922, 32768, 0], - [19515, 32768, 0], - [18636, 32768, 0], - [17333, 32768, 0], - [15776, 32768, 0], - [22658, 32768, 0], - ], - [ - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - ], - [ - [ - [ - [20177, 32768, 0], - [20789, 32768, 0], - [20262, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [21416, 32768, 0], - [20855, 32768, 0], - [23410, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [20238, 32768, 0], - [21057, 32768, 0], - [19159, 32768, 0], - [22337, 32768, 0], - [20159, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [20125, 32768, 0], - [20559, 32768, 0], - [21707, 32768, 0], - [22296, 32768, 0], - [17333, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [19941, 32768, 0], - [20527, 32768, 0], - [21470, 32768, 0], - [22487, 32768, 0], - [19558, 32768, 0], - [22354, 32768, 0], - [20331, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [22752, 32768, 0], - [25006, 32768, 0], - [22075, 32768, 0], - [21576, 32768, 0], - [17740, 32768, 0], - [21690, 32768, 0], - [19211, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [21442, 32768, 0], - [22358, 32768, 0], - [18503, 32768, 0], - [20291, 32768, 0], - [19945, 32768, 0], - [21294, 32768, 0], - [21178, 32768, 0], - [19400, 32768, 0], - [10556, 32768, 0], - ], - [ - [24648, 32768, 0], - [24949, 32768, 0], - [20708, 32768, 0], - [23905, 32768, 0], - [20501, 32768, 0], - [9558, 32768, 0], - [9423, 32768, 0], - [30365, 32768, 0], - [19253, 32768, 0], - ], - ], - [ - [ - [26064, 32768, 0], - [22098, 32768, 0], - [19613, 32768, 0], - [20525, 32768, 0], - [17595, 32768, 0], - [16618, 32768, 0], - [20497, 32768, 0], - [18989, 32768, 0], - [15513, 32768, 0], - ], - [ - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - ], -]; - -pub static DEFAULT_EOB_PT_1024_CDF: [[[u16; 12]; 2]; 4] = [ - [[393, 421, 751, 1623, 3160, 6352, 13345, 18047, 22571, 25830, 32768, 0], [1865, 1988, 2930, 4242, 10533, 16538, 21354, 27255, 28546, 31784, 32768, 0]], - [[696, 948, 3145, 5702, 9706, 13217, 17851, 21856, 25692, 28034, 32768, 0], [2672, 3591, 9330, 17084, 22725, 24284, 26527, 28027, 28377, 30876, 32768, 0]], - [ - [2784, 3831, 7041, 10521, 14847, 18844, 23155, 26682, 29229, 31045, 32768, 0], - [9577, 12466, 17739, 20750, 22061, 23215, 24601, 25483, 25843, 32056, 32768, 0], - ], - [ - [6698, 8334, 11961, 15762, 20186, 23862, 27434, 29326, 31082, 32050, 32768, 0], - [20569, 22426, 25569, 26859, 28053, 28913, 29486, 29724, 29807, 32570, 32768, 0], - ], -]; - -pub static DEFAULT_EOB_PT_128_CDF: [[[[u16; 9]; 2]; 2]; 4] = [ - [ - [[219, 482, 1140, 2091, 3680, 6028, 12586, 32768, 0], [371, 699, 1254, 4830, 9479, 12562, 17497, 32768, 0]], - [[5245, 7456, 12880, 15852, 20033, 23932, 27608, 32768, 0], [2054, 3472, 5869, 14232, 18242, 20590, 26752, 32768, 0]], - ], - [ - [[685, 933, 1488, 2714, 4766, 8562, 19254, 32768, 0], [217, 352, 618, 2303, 5261, 9969, 17472, 32768, 0]], - [[8045, 11200, 15497, 19595, 23948, 27408, 30938, 32768, 0], [2310, 4160, 7471, 14997, 17931, 20768, 30240, 32768, 0]], - ], - [ - [[1366, 1738, 2527, 5016, 9355, 15797, 24643, 32768, 0], [354, 558, 944, 2760, 7287, 14037, 21779, 32768, 0]], - [[13627, 16246, 20173, 24429, 27948, 30415, 31863, 32768, 0], [6275, 9889, 14769, 23164, 27988, 30493, 32272, 32768, 0]], - ], - [ - [[3472, 4885, 7489, 12481, 18517, 24536, 29635, 32768, 0], [886, 1731, 3271, 8469, 15569, 22126, 28383, 32768, 0]], - [[24313, 26062, 28385, 30107, 31217, 31898, 32345, 32768, 0], [9165, 13282, 21150, 30286, 31894, 32571, 32712, 32768, 0]], - ], -]; - -pub static DEFAULT_EOB_PT_16_CDF: [[[[u16; 6]; 2]; 2]; 4] = [ - [[[840, 1039, 1980, 4895, 32768, 0], [370, 671, 1883, 4471, 32768, 0]], [[3247, 4950, 9688, 14563, 32768, 0], [1904, 3354, 7763, 14647, 32768, 0]]], - [[[2125, 2551, 5165, 8946, 32768, 0], [513, 765, 1859, 6339, 32768, 0]], [[7637, 9498, 14259, 19108, 32768, 0], [2497, 4096, 8866, 16993, 32768, 0]]], - [[[4016, 4897, 8881, 14968, 32768, 0], [716, 1105, 2646, 10056, 32768, 0]], [[11139, 13270, 18241, 23566, 32768, 0], [3192, 5032, 10297, 19755, 32768, 0]]], - [ - [[6708, 8958, 14746, 22133, 32768, 0], [1222, 2074, 4783, 15410, 32768, 0]], - [[19575, 21766, 26044, 29709, 32768, 0], [7297, 10767, 19273, 28194, 32768, 0]], - ], -]; - -pub static DEFAULT_EOB_PT_256_CDF: [[[[u16; 10]; 2]; 2]; 4] = [ - [ - [[310, 584, 1887, 3589, 6168, 8611, 11352, 15652, 32768, 0], [998, 1850, 2998, 5604, 17341, 19888, 22899, 25583, 32768, 0]], - [[2520, 3240, 5952, 8870, 12577, 17558, 19954, 24168, 32768, 0], [2203, 4130, 7435, 10739, 20652, 23681, 25609, 27261, 32768, 0]], - ], - [ - [[1448, 2109, 4151, 6263, 9329, 13260, 17944, 23300, 32768, 0], [399, 1019, 1749, 3038, 10444, 15546, 22739, 27294, 32768, 0]], - [[6402, 8148, 12623, 15072, 18728, 22847, 26447, 29377, 32768, 0], [1674, 3252, 5734, 10159, 22397, 23802, 24821, 30940, 32768, 0]], - ], - [ - [[3089, 3920, 6038, 9460, 14266, 19881, 25766, 29176, 32768, 0], [1084, 2358, 3488, 5122, 11483, 18103, 26023, 29799, 32768, 0]], - [[11514, 13794, 17480, 20754, 24361, 27378, 29492, 31277, 32768, 0], [6571, 9610, 15516, 21826, 29092, 30829, 31842, 32708, 32768, 0]], - ], - [ - [[5348, 7113, 11820, 15924, 22106, 26777, 30334, 31757, 32768, 0], [2453, 4474, 6307, 8777, 16474, 22975, 29000, 31547, 32768, 0]], - [[23110, 24597, 27140, 28894, 30167, 30927, 31392, 32094, 32768, 0], [9998, 17661, 25178, 28097, 31308, 32038, 32403, 32695, 32768, 0]], - ], -]; - -pub static DEFAULT_EOB_PT_32_CDF: [[[[u16; 7]; 2]; 2]; 4] = [ - [ - [[400, 520, 977, 2102, 6542, 32768, 0], [210, 405, 1315, 3326, 7537, 32768, 0]], - [[2636, 4273, 7588, 11794, 20401, 32768, 0], [1786, 3179, 6902, 11357, 19054, 32768, 0]], - ], - [ - [[989, 1249, 2019, 4151, 10785, 32768, 0], [313, 441, 1099, 2917, 8562, 32768, 0]], - [[8394, 10352, 13932, 18855, 26014, 32768, 0], [2578, 4124, 8181, 13670, 24234, 32768, 0]], - ], - [ - [[2515, 3003, 4452, 8162, 16041, 32768, 0], [574, 821, 1836, 5089, 13128, 32768, 0]], - [[13468, 16303, 20361, 25105, 29281, 32768, 0], [3542, 5502, 10415, 16760, 25644, 32768, 0]], - ], - [ - [[4617, 5709, 8446, 13584, 23135, 32768, 0], [1156, 1702, 3675, 9274, 20539, 32768, 0]], - [[22086, 24282, 27010, 29770, 31743, 32768, 0], [7699, 10897, 20891, 26926, 31628, 32768, 0]], - ], -]; - -pub static DEFAULT_EOB_PT_512_CDF: [[[u16; 11]; 2]; 4] = [ - [[641, 983, 3707, 5430, 10234, 14958, 18788, 23412, 26061, 32768, 0], [5095, 6446, 9996, 13354, 16017, 17986, 20919, 26129, 29140, 32768, 0]], - [[1230, 2278, 5035, 7776, 11871, 15346, 19590, 24584, 28749, 32768, 0], [7265, 9979, 15819, 19250, 21780, 23846, 26478, 28396, 31811, 32768, 0]], - [[2624, 3936, 6480, 9686, 13979, 17726, 23267, 28410, 31078, 32768, 0], [12015, 14769, 19588, 22052, 24222, 25812, 27300, 29219, 32114, 32768, 0]], - [[5927, 7809, 10923, 14597, 19439, 24135, 28456, 31142, 32060, 32768, 0], [21093, 23043, 25742, 27658, 29097, 29716, 30073, 30820, 31956, 32768, 0]], -]; - -pub static DEFAULT_EOB_PT_64_CDF: [[[[u16; 8]; 2]; 2]; 4] = [ - [ - [[329, 498, 1101, 1784, 3265, 7758, 32768, 0], [335, 730, 1459, 5494, 8755, 12997, 32768, 0]], - [[3505, 5304, 10086, 13814, 17684, 23370, 32768, 0], [1563, 2700, 4876, 10911, 14706, 22480, 32768, 0]], - ], - [ - [[1260, 1446, 2253, 3712, 6652, 13369, 32768, 0], [401, 605, 1029, 2563, 5845, 12626, 32768, 0]], - [[8609, 10612, 14624, 18714, 22614, 29024, 32768, 0], [1923, 3127, 5867, 9703, 14277, 27100, 32768, 0]], - ], - [ - [[2374, 2772, 4583, 7276, 12288, 19706, 32768, 0], [497, 810, 1315, 3000, 7004, 15641, 32768, 0]], - [[15050, 17126, 21410, 24886, 28156, 30726, 32768, 0], [4034, 6290, 10235, 14982, 21214, 28491, 32768, 0]], - ], - [ - [[6307, 7541, 12060, 16358, 22553, 27865, 32768, 0], [1289, 2320, 3971, 7926, 14153, 24291, 32768, 0]], - [[24212, 25708, 28268, 30035, 31307, 32049, 32768, 0], [8726, 12378, 19409, 26450, 30038, 32462, 32768, 0]], - ], -]; - -pub static DEFAULT_INTER_TX_TYPE_SET3_CDF: [[u16; 3]; 4] = [[16384, 32768, 0], [4167, 32768, 0], [1998, 32768, 0], [748, 32768, 0]]; - -pub static DEFAULT_INTRA_FRAME_Y_MODE_CDF: [[[u16; 14]; 5]; 5] = [ - [ - [15588, 17027, 19338, 20218, 20682, 21110, 21825, 23244, 24189, 28165, 29093, 30466, 32768, 0], - [12016, 18066, 19516, 20303, 20719, 21444, 21888, 23032, 24434, 28658, 30172, 31409, 32768, 0], - [10052, 10771, 22296, 22788, 23055, 23239, 24133, 25620, 26160, 29336, 29929, 31567, 32768, 0], - [14091, 15406, 16442, 18808, 19136, 19546, 19998, 22096, 24746, 29585, 30958, 32462, 32768, 0], - [12122, 13265, 15603, 16501, 18609, 20033, 22391, 25583, 26437, 30261, 31073, 32475, 32768, 0], - ], - [ - [10023, 19585, 20848, 21440, 21832, 22760, 23089, 24023, 25381, 29014, 30482, 31436, 32768, 0], - [5983, 24099, 24560, 24886, 25066, 25795, 25913, 26423, 27610, 29905, 31276, 31794, 32768, 0], - [7444, 12781, 20177, 20728, 21077, 21607, 22170, 23405, 24469, 27915, 29090, 30492, 32768, 0], - [8537, 14689, 15432, 17087, 17408, 18172, 18408, 19825, 24649, 29153, 31096, 32210, 32768, 0], - [7543, 14231, 15496, 16195, 17905, 20717, 21984, 24516, 26001, 29675, 30981, 31994, 32768, 0], - ], - [ - [12613, 13591, 21383, 22004, 22312, 22577, 23401, 25055, 25729, 29538, 30305, 32077, 32768, 0], - [9687, 13470, 18506, 19230, 19604, 20147, 20695, 22062, 23219, 27743, 29211, 30907, 32768, 0], - [6183, 6505, 26024, 26252, 26366, 26434, 27082, 28354, 28555, 30467, 30794, 32086, 32768, 0], - [10718, 11734, 14954, 17224, 17565, 17924, 18561, 21523, 23878, 28975, 30287, 32252, 32768, 0], - [9194, 9858, 16501, 17263, 18424, 19171, 21563, 25961, 26561, 30072, 30737, 32463, 32768, 0], - ], - [ - [12602, 14399, 15488, 18381, 18778, 19315, 19724, 21419, 25060, 29696, 30917, 32409, 32768, 0], - [8203, 13821, 14524, 17105, 17439, 18131, 18404, 19468, 25225, 29485, 31158, 32342, 32768, 0], - [8451, 9731, 15004, 17643, 18012, 18425, 19070, 21538, 24605, 29118, 30078, 32018, 32768, 0], - [7714, 9048, 9516, 16667, 16817, 16994, 17153, 18767, 26743, 30389, 31536, 32528, 32768, 0], - [8843, 10280, 11496, 15317, 16652, 17943, 19108, 22718, 25769, 29953, 30983, 32485, 32768, 0], - ], - [ - [12578, 13671, 15979, 16834, 19075, 20913, 22989, 25449, 26219, 30214, 31150, 32477, 32768, 0], - [9563, 13626, 15080, 15892, 17756, 20863, 22207, 24236, 25380, 29653, 31143, 32277, 32768, 0], - [8356, 8901, 17616, 18256, 19350, 20106, 22598, 25947, 26466, 29900, 30523, 32261, 32768, 0], - [10835, 11815, 13124, 16042, 17018, 18039, 18947, 22753, 24615, 29489, 30883, 32482, 32768, 0], - [7618, 8288, 9859, 10509, 15386, 18657, 22903, 28776, 29180, 31355, 31802, 32593, 32768, 0], - ], -]; - -pub static DEFAULT_INTRA_TX_TYPE_SET2_CDF: [[[u16; 6]; 13]; 3] = [ - [ - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - ], - [ - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - [6554, 13107, 19661, 26214, 32768, 0], - ], - [ - [1127, 12814, 22772, 27483, 32768, 0], - [145, 6761, 11980, 26667, 32768, 0], - [362, 5887, 11678, 16725, 32768, 0], - [385, 15213, 18587, 30693, 32768, 0], - [25, 2914, 23134, 27903, 32768, 0], - [60, 4470, 11749, 23991, 32768, 0], - [37, 3332, 14511, 21448, 32768, 0], - [157, 6320, 13036, 17439, 32768, 0], - [119, 6719, 12906, 29396, 32768, 0], - [47, 5537, 12576, 21499, 32768, 0], - [269, 6076, 11258, 23115, 32768, 0], - [83, 5615, 12001, 17228, 32768, 0], - [1968, 5556, 12023, 18547, 32768, 0], - ], -]; - -pub static DEFAULT_IS_INTER_CDF: [[u16; 3]; 4] = [[806, 32768, 0], [16662, 32768, 0], [20186, 32768, 0], [26538, 32768, 0]]; - -pub static DEFAULT_MV_BIT_CDF: [[u16; 3]; 10] = [ - [17408, 32768, 0], - [17920, 32768, 0], - [18944, 32768, 0], - [20480, 32768, 0], - [22528, 32768, 0], - [24576, 32768, 0], - [28672, 32768, 0], - [29952, 32768, 0], - [29952, 32768, 0], - [30720, 32768, 0], -]; - -pub static DEFAULT_MV_CLASS0_BIT_CDF: [u16; 3] = [27648, 32768, 0]; - -pub static DEFAULT_MV_CLASS0_FR_CDF: [[[u16; 5]; 2]; 2] = - [[[16384, 24576, 26624, 32768, 0], [12288, 21248, 24128, 32768, 0]], [[16384, 24576, 26624, 32768, 0], [12288, 21248, 24128, 32768, 0]]]; - -pub static DEFAULT_MV_CLASS0_HP_CDF: [u16; 3] = [20480, 32768, 0]; - -pub static DEFAULT_MV_CLASS_CDF: [[u16; 12]; 2] = [ - [28672, 30976, 31858, 32320, 32551, 32656, 32740, 32757, 32762, 32767, 32768, 0], - [28672, 30976, 31858, 32320, 32551, 32656, 32740, 32757, 32762, 32767, 32768, 0], -]; - -pub static DEFAULT_MV_FR_CDF: [[u16; 5]; 2] = [[8192, 17408, 21248, 32768, 0], [8192, 17408, 21248, 32768, 0]]; - -pub static DEFAULT_MV_HP_CDF: [u16; 3] = [16384, 32768, 0]; - -pub static DEFAULT_MV_JOINT_CDF: [u16; 5] = [4096, 11264, 19328, 32768, 0]; - -pub static DEFAULT_MV_SIGN_CDF: [u16; 3] = [16384, 32768, 0]; - -pub static DEFAULT_NEW_MV_CDF: [[u16; 3]; 6] = [[24035, 32768, 0], [16630, 32768, 0], [15339, 32768, 0], [8386, 32768, 0], [12222, 32768, 0], [4676, 32768, 0]]; - -pub static DEFAULT_PARTITION_W16_CDF: [[u16; 11]; 4] = [ - [15597, 20929, 24571, 26706, 27664, 28821, 29601, 30571, 31902, 32768, 0], - [7925, 11043, 16785, 22470, 23971, 25043, 26651, 28701, 29834, 32768, 0], - [5414, 13269, 15111, 20488, 22360, 24500, 25537, 26336, 32117, 32768, 0], - [2662, 6362, 8614, 20860, 23053, 24778, 26436, 27829, 31171, 32768, 0], -]; - -pub static DEFAULT_PARTITION_W32_CDF: [[u16; 11]; 4] = [ - [18462, 20920, 23124, 27647, 28227, 29049, 29519, 30178, 31544, 32768, 0], - [7689, 9060, 12056, 24992, 25660, 26182, 26951, 28041, 29052, 32768, 0], - [6015, 9009, 10062, 24544, 25409, 26545, 27071, 27526, 32047, 32768, 0], - [1394, 2208, 2796, 28614, 29061, 29466, 29840, 30185, 31899, 32768, 0], -]; - -pub static DEFAULT_PARTITION_W64_CDF: [[u16; 11]; 4] = [ - [20137, 21547, 23078, 29566, 29837, 30261, 30524, 30892, 31724, 32768, 0], - [6732, 7490, 9497, 27944, 28250, 28515, 28969, 29630, 30104, 32768, 0], - [5945, 7663, 8348, 28683, 29117, 29749, 30064, 30298, 32238, 32768, 0], - [870, 1212, 1487, 31198, 31394, 31574, 31743, 31881, 32332, 32768, 0], -]; - -pub static DEFAULT_PARTITION_W8_CDF: [[u16; 5]; 4] = - [[19132, 25510, 30392, 32768, 0], [13928, 19855, 28540, 32768, 0], [12522, 23679, 28629, 32768, 0], [9896, 18783, 25853, 32768, 0]]; - -pub static DEFAULT_REF_MV_CDF: [[u16; 3]; 6] = - [[23974, 32768, 0], [24188, 32768, 0], [17848, 32768, 0], [28622, 32768, 0], [24312, 32768, 0], [19923, 32768, 0]]; - -pub static DEFAULT_SCAN_16X16: [u16; 256] = [ - 0, 1, 16, 32, 17, 2, 3, 18, 33, 48, 64, 49, 34, 19, 4, 5, 20, 35, 50, 65, 80, 96, 81, 66, 51, 36, 21, 6, 7, 22, 37, 52, 67, 82, 97, 112, 128, 113, 98, 83, - 68, 53, 38, 23, 8, 9, 24, 39, 54, 69, 84, 99, 114, 129, 144, 160, 145, 130, 115, 100, 85, 70, 55, 40, 25, 10, 11, 26, 41, 56, 71, 86, 101, 116, 131, 146, - 161, 176, 192, 177, 162, 147, 132, 117, 102, 87, 72, 57, 42, 27, 12, 13, 28, 43, 58, 73, 88, 103, 118, 133, 148, 163, 178, 193, 208, 224, 209, 194, 179, - 164, 149, 134, 119, 104, 89, 74, 59, 44, 29, 14, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 241, 226, 211, 196, 181, 166, - 151, 136, 121, 106, 91, 76, 61, 46, 31, 47, 62, 77, 92, 107, 122, 137, 152, 167, 182, 197, 212, 227, 242, 243, 228, 213, 198, 183, 168, 153, 138, 123, 108, - 93, 78, 63, 79, 94, 109, 124, 139, 154, 169, 184, 199, 214, 229, 244, 245, 230, 215, 200, 185, 170, 155, 140, 125, 110, 95, 111, 126, 141, 156, 171, 186, - 201, 216, 231, 246, 247, 232, 217, 202, 187, 172, 157, 142, 127, 143, 158, 173, 188, 203, 218, 233, 248, 249, 234, 219, 204, 189, 174, 159, 175, 190, 205, - 220, 235, 250, 251, 236, 221, 206, 191, 207, 222, 237, 252, 253, 238, 223, 239, 254, 255, -]; - -pub static DEFAULT_SCAN_32X32: [u16; 1024] = [ - 0, 1, 32, 64, 33, 2, 3, 34, 65, 96, 128, 97, 66, 35, 4, 5, 36, 67, 98, 129, 160, 192, 161, 130, 99, 68, 37, 6, 7, 38, 69, 100, 131, 162, 193, 224, 256, - 225, 194, 163, 132, 101, 70, 39, 8, 9, 40, 71, 102, 133, 164, 195, 226, 257, 288, 320, 289, 258, 227, 196, 165, 134, 103, 72, 41, 10, 11, 42, 73, 104, 135, - 166, 197, 228, 259, 290, 321, 352, 384, 353, 322, 291, 260, 229, 198, 167, 136, 105, 74, 43, 12, 13, 44, 75, 106, 137, 168, 199, 230, 261, 292, 323, 354, - 385, 416, 448, 417, 386, 355, 324, 293, 262, 231, 200, 169, 138, 107, 76, 45, 14, 15, 46, 77, 108, 139, 170, 201, 232, 263, 294, 325, 356, 387, 418, 449, - 480, 512, 481, 450, 419, 388, 357, 326, 295, 264, 233, 202, 171, 140, 109, 78, 47, 16, 17, 48, 79, 110, 141, 172, 203, 234, 265, 296, 327, 358, 389, 420, - 451, 482, 513, 544, 576, 545, 514, 483, 452, 421, 390, 359, 328, 297, 266, 235, 204, 173, 142, 111, 80, 49, 18, 19, 50, 81, 112, 143, 174, 205, 236, 267, - 298, 329, 360, 391, 422, 453, 484, 515, 546, 577, 608, 640, 609, 578, 547, 516, 485, 454, 423, 392, 361, 330, 299, 268, 237, 206, 175, 144, 113, 82, 51, - 20, 21, 52, 83, 114, 145, 176, 207, 238, 269, 300, 331, 362, 393, 424, 455, 486, 517, 548, 579, 610, 641, 672, 704, 673, 642, 611, 580, 549, 518, 487, 456, - 425, 394, 363, 332, 301, 270, 239, 208, 177, 146, 115, 84, 53, 22, 23, 54, 85, 116, 147, 178, 209, 240, 271, 302, 333, 364, 395, 426, 457, 488, 519, 550, - 581, 612, 643, 674, 705, 736, 768, 737, 706, 675, 644, 613, 582, 551, 520, 489, 458, 427, 396, 365, 334, 303, 272, 241, 210, 179, 148, 117, 86, 55, 24, 25, - 56, 87, 118, 149, 180, 211, 242, 273, 304, 335, 366, 397, 428, 459, 490, 521, 552, 583, 614, 645, 676, 707, 738, 769, 800, 832, 801, 770, 739, 708, 677, - 646, 615, 584, 553, 522, 491, 460, 429, 398, 367, 336, 305, 274, 243, 212, 181, 150, 119, 88, 57, 26, 27, 58, 89, 120, 151, 182, 213, 244, 275, 306, 337, - 368, 399, 430, 461, 492, 523, 554, 585, 616, 647, 678, 709, 740, 771, 802, 833, 864, 896, 865, 834, 803, 772, 741, 710, 679, 648, 617, 586, 555, 524, 493, - 462, 431, 400, 369, 338, 307, 276, 245, 214, 183, 152, 121, 90, 59, 28, 29, 60, 91, 122, 153, 184, 215, 246, 277, 308, 339, 370, 401, 432, 463, 494, 525, - 556, 587, 618, 649, 680, 711, 742, 773, 804, 835, 866, 897, 928, 960, 929, 898, 867, 836, 805, 774, 743, 712, 681, 650, 619, 588, 557, 526, 495, 464, 433, - 402, 371, 340, 309, 278, 247, 216, 185, 154, 123, 92, 61, 30, 31, 62, 93, 124, 155, 186, 217, 248, 279, 310, 341, 372, 403, 434, 465, 496, 527, 558, 589, - 620, 651, 682, 713, 744, 775, 806, 837, 868, 899, 930, 961, 992, 993, 962, 931, 900, 869, 838, 807, 776, 745, 714, 683, 652, 621, 590, 559, 528, 497, 466, - 435, 404, 373, 342, 311, 280, 249, 218, 187, 156, 125, 94, 63, 95, 126, 157, 188, 219, 250, 281, 312, 343, 374, 405, 436, 467, 498, 529, 560, 591, 622, - 653, 684, 715, 746, 777, 808, 839, 870, 901, 932, 963, 994, 995, 964, 933, 902, 871, 840, 809, 778, 747, 716, 685, 654, 623, 592, 561, 530, 499, 468, 437, - 406, 375, 344, 313, 282, 251, 220, 189, 158, 127, 159, 190, 221, 252, 283, 314, 345, 376, 407, 438, 469, 500, 531, 562, 593, 624, 655, 686, 717, 748, 779, - 810, 841, 872, 903, 934, 965, 996, 997, 966, 935, 904, 873, 842, 811, 780, 749, 718, 687, 656, 625, 594, 563, 532, 501, 470, 439, 408, 377, 346, 315, 284, - 253, 222, 191, 223, 254, 285, 316, 347, 378, 409, 440, 471, 502, 533, 564, 595, 626, 657, 688, 719, 750, 781, 812, 843, 874, 905, 936, 967, 998, 999, 968, - 937, 906, 875, 844, 813, 782, 751, 720, 689, 658, 627, 596, 565, 534, 503, 472, 441, 410, 379, 348, 317, 286, 255, 287, 318, 349, 380, 411, 442, 473, 504, - 535, 566, 597, 628, 659, 690, 721, 752, 783, 814, 845, 876, 907, 938, 969, 1000, 1001, 970, 939, 908, 877, 846, 815, 784, 753, 722, 691, 660, 629, 598, - 567, 536, 505, 474, 443, 412, 381, 350, 319, 351, 382, 413, 444, 475, 506, 537, 568, 599, 630, 661, 692, 723, 754, 785, 816, 847, 878, 909, 940, 971, 1002, - 1003, 972, 941, 910, 879, 848, 817, 786, 755, 724, 693, 662, 631, 600, 569, 538, 507, 476, 445, 414, 383, 415, 446, 477, 508, 539, 570, 601, 632, 663, 694, - 725, 756, 787, 818, 849, 880, 911, 942, 973, 1004, 1005, 974, 943, 912, 881, 850, 819, 788, 757, 726, 695, 664, 633, 602, 571, 540, 509, 478, 447, 479, - 510, 541, 572, 603, 634, 665, 696, 727, 758, 789, 820, 851, 882, 913, 944, 975, 1006, 1007, 976, 945, 914, 883, 852, 821, 790, 759, 728, 697, 666, 635, - 604, 573, 542, 511, 543, 574, 605, 636, 667, 698, 729, 760, 791, 822, 853, 884, 915, 946, 977, 1008, 1009, 978, 947, 916, 885, 854, 823, 792, 761, 730, - 699, 668, 637, 606, 575, 607, 638, 669, 700, 731, 762, 793, 824, 855, 886, 917, 948, 979, 1010, 1011, 980, 949, 918, 887, 856, 825, 794, 763, 732, 701, - 670, 639, 671, 702, 733, 764, 795, 826, 857, 888, 919, 950, 981, 1012, 1013, 982, 951, 920, 889, 858, 827, 796, 765, 734, 703, 735, 766, 797, 828, 859, - 890, 921, 952, 983, 1014, 1015, 984, 953, 922, 891, 860, 829, 798, 767, 799, 830, 861, 892, 923, 954, 985, 1016, 1017, 986, 955, 924, 893, 862, 831, 863, - 894, 925, 956, 987, 1018, 1019, 988, 957, 926, 895, 927, 958, 989, 1020, 1021, 990, 959, 991, 1022, 1023, -]; - -pub static DEFAULT_SCAN_4X4: [u16; 16] = [0, 1, 4, 8, 5, 2, 3, 6, 9, 12, 13, 10, 7, 11, 14, 15]; - -pub static DEFAULT_SCAN_8X8: [u16; 64] = [ - 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, - 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, -]; - -pub static DEFAULT_SINGLE_REF_CDF: [[[u16; 3]; 6]; 3] = [ - [[4897, 32768, 0], [1555, 32768, 0], [4236, 32768, 0], [8650, 32768, 0], [904, 32768, 0], [1444, 32768, 0]], - [[16973, 32768, 0], [16751, 32768, 0], [19647, 32768, 0], [24773, 32768, 0], [11014, 32768, 0], [15087, 32768, 0]], - [[29744, 32768, 0], [30279, 32768, 0], [31194, 32768, 0], [31895, 32768, 0], [26875, 32768, 0], [30304, 32768, 0]], -]; - -pub static DEFAULT_SKIP_CDF: [[u16; 3]; 3] = [[31671, 32768, 0], [16515, 32768, 0], [4576, 32768, 0]]; - -pub static DEFAULT_TXB_SKIP_CDF: [[[[u16; 3]; 13]; 5]; 4] = [ - [ - [ - [31849, 32768, 0], - [5892, 32768, 0], - [12112, 32768, 0], - [21935, 32768, 0], - [20289, 32768, 0], - [27473, 32768, 0], - [32487, 32768, 0], - [7654, 32768, 0], - [19473, 32768, 0], - [29984, 32768, 0], - [9961, 32768, 0], - [30242, 32768, 0], - [32117, 32768, 0], - ], - [ - [31548, 32768, 0], - [1549, 32768, 0], - [10130, 32768, 0], - [16656, 32768, 0], - [18591, 32768, 0], - [26308, 32768, 0], - [32537, 32768, 0], - [5403, 32768, 0], - [18096, 32768, 0], - [30003, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [29957, 32768, 0], - [5391, 32768, 0], - [18039, 32768, 0], - [23566, 32768, 0], - [22431, 32768, 0], - [25822, 32768, 0], - [32197, 32768, 0], - [3778, 32768, 0], - [15336, 32768, 0], - [28981, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [17920, 32768, 0], - [1818, 32768, 0], - [7282, 32768, 0], - [25273, 32768, 0], - [10923, 32768, 0], - [31554, 32768, 0], - [32624, 32768, 0], - [1366, 32768, 0], - [15628, 32768, 0], - [30462, 32768, 0], - [146, 32768, 0], - [5132, 32768, 0], - [31657, 32768, 0], - ], - [ - [6308, 32768, 0], - [117, 32768, 0], - [1638, 32768, 0], - [2161, 32768, 0], - [16384, 32768, 0], - [10923, 32768, 0], - [30247, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [30371, 32768, 0], - [7570, 32768, 0], - [13155, 32768, 0], - [20751, 32768, 0], - [20969, 32768, 0], - [27067, 32768, 0], - [32013, 32768, 0], - [5495, 32768, 0], - [17942, 32768, 0], - [28280, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [31782, 32768, 0], - [1836, 32768, 0], - [10689, 32768, 0], - [17604, 32768, 0], - [21622, 32768, 0], - [27518, 32768, 0], - [32399, 32768, 0], - [4419, 32768, 0], - [16294, 32768, 0], - [28345, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [31901, 32768, 0], - [10311, 32768, 0], - [18047, 32768, 0], - [24806, 32768, 0], - [23288, 32768, 0], - [27914, 32768, 0], - [32296, 32768, 0], - [4215, 32768, 0], - [15756, 32768, 0], - [28341, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [26726, 32768, 0], - [1045, 32768, 0], - [11703, 32768, 0], - [20590, 32768, 0], - [18554, 32768, 0], - [25970, 32768, 0], - [31938, 32768, 0], - [5583, 32768, 0], - [21313, 32768, 0], - [29390, 32768, 0], - [641, 32768, 0], - [22265, 32768, 0], - [31452, 32768, 0], - ], - [ - [26584, 32768, 0], - [188, 32768, 0], - [8847, 32768, 0], - [24519, 32768, 0], - [22938, 32768, 0], - [30583, 32768, 0], - [32608, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [29614, 32768, 0], - [9068, 32768, 0], - [12924, 32768, 0], - [19538, 32768, 0], - [17737, 32768, 0], - [24619, 32768, 0], - [30642, 32768, 0], - [4119, 32768, 0], - [16026, 32768, 0], - [25657, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [31957, 32768, 0], - [3230, 32768, 0], - [11153, 32768, 0], - [18123, 32768, 0], - [20143, 32768, 0], - [26536, 32768, 0], - [31986, 32768, 0], - [3050, 32768, 0], - [14603, 32768, 0], - [25155, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [32363, 32768, 0], - [10692, 32768, 0], - [19090, 32768, 0], - [24357, 32768, 0], - [24442, 32768, 0], - [28312, 32768, 0], - [32169, 32768, 0], - [3648, 32768, 0], - [15690, 32768, 0], - [26815, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [30669, 32768, 0], - [3832, 32768, 0], - [11663, 32768, 0], - [18889, 32768, 0], - [19782, 32768, 0], - [23313, 32768, 0], - [31330, 32768, 0], - [5124, 32768, 0], - [18719, 32768, 0], - [28468, 32768, 0], - [3082, 32768, 0], - [20982, 32768, 0], - [29443, 32768, 0], - ], - [ - [28573, 32768, 0], - [3183, 32768, 0], - [17802, 32768, 0], - [25977, 32768, 0], - [26677, 32768, 0], - [27832, 32768, 0], - [32387, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], - [ - [ - [26887, 32768, 0], - [6729, 32768, 0], - [10361, 32768, 0], - [17442, 32768, 0], - [15045, 32768, 0], - [22478, 32768, 0], - [29072, 32768, 0], - [2713, 32768, 0], - [11861, 32768, 0], - [20773, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [31903, 32768, 0], - [2044, 32768, 0], - [7528, 32768, 0], - [14618, 32768, 0], - [16182, 32768, 0], - [24168, 32768, 0], - [31037, 32768, 0], - [2786, 32768, 0], - [11194, 32768, 0], - [20155, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [32510, 32768, 0], - [8430, 32768, 0], - [17318, 32768, 0], - [24154, 32768, 0], - [23674, 32768, 0], - [28789, 32768, 0], - [32139, 32768, 0], - [3440, 32768, 0], - [13117, 32768, 0], - [22702, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - [ - [31671, 32768, 0], - [2056, 32768, 0], - [11746, 32768, 0], - [16852, 32768, 0], - [18635, 32768, 0], - [24715, 32768, 0], - [31484, 32768, 0], - [4656, 32768, 0], - [16074, 32768, 0], - [24704, 32768, 0], - [1806, 32768, 0], - [14645, 32768, 0], - [25336, 32768, 0], - ], - [ - [31539, 32768, 0], - [8433, 32768, 0], - [20576, 32768, 0], - [27904, 32768, 0], - [27852, 32768, 0], - [30026, 32768, 0], - [32441, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - [16384, 32768, 0], - ], - ], -]; - -pub static DEFAULT_UV_MODE_CFL_ALLOWED_CDF: [[u16; 15]; 13] = [ - [10407, 11208, 12900, 13181, 13823, 14175, 14899, 15656, 15986, 20086, 20995, 22455, 24212, 32768, 0], - [4532, 19780, 20057, 20215, 20428, 21071, 21199, 21451, 22099, 24228, 24693, 27032, 29472, 32768, 0], - [5273, 5379, 20177, 20270, 20385, 20439, 20949, 21695, 21774, 23138, 24256, 24703, 26679, 32768, 0], - [6740, 7167, 7662, 14152, 14536, 14785, 15034, 16741, 18371, 21520, 22206, 23389, 24182, 32768, 0], - [4987, 5368, 5928, 6068, 19114, 20315, 21857, 22253, 22411, 24911, 25380, 26027, 26376, 32768, 0], - [5370, 6889, 7247, 7393, 9498, 21114, 21402, 21753, 21981, 24780, 25386, 26517, 27176, 32768, 0], - [4816, 4961, 7204, 7326, 8765, 8930, 20169, 20682, 20803, 23188, 23763, 24455, 24940, 32768, 0], - [6608, 6740, 8529, 9049, 9257, 9356, 9735, 18827, 19059, 22336, 23204, 23964, 24793, 32768, 0], - [5998, 7419, 7781, 8933, 9255, 9549, 9753, 10417, 18898, 22494, 23139, 24764, 25989, 32768, 0], - [10660, 11298, 12550, 12957, 13322, 13624, 14040, 15004, 15534, 20714, 21789, 23443, 24861, 32768, 0], - [10522, 11530, 12552, 12963, 13378, 13779, 14245, 15235, 15902, 20102, 22696, 23774, 25838, 32768, 0], - [10099, 10691, 12639, 13049, 13386, 13665, 14125, 15163, 15636, 19676, 20474, 23519, 25208, 32768, 0], - [3144, 5087, 7382, 7504, 7593, 7690, 7801, 8064, 8232, 9248, 9875, 10521, 29048, 32768, 0], -]; - -pub static DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF: [[u16; 14]; 13] = [ - [22631, 24152, 25378, 25661, 25986, 26520, 27055, 27923, 28244, 30059, 30941, 31961, 32768, 0], - [9513, 26881, 26973, 27046, 27118, 27664, 27739, 27824, 28359, 29505, 29800, 31796, 32768, 0], - [9845, 9915, 28663, 28704, 28757, 28780, 29198, 29822, 29854, 30764, 31777, 32029, 32768, 0], - [13639, 13897, 14171, 25331, 25606, 25727, 25953, 27148, 28577, 30612, 31355, 32493, 32768, 0], - [9764, 9835, 9930, 9954, 25386, 27053, 27958, 28148, 28243, 31101, 31744, 32363, 32768, 0], - [11825, 13589, 13677, 13720, 15048, 29213, 29301, 29458, 29711, 31161, 31441, 32550, 32768, 0], - [14175, 14399, 16608, 16821, 17718, 17775, 28551, 30200, 30245, 31837, 32342, 32667, 32768, 0], - [12885, 13038, 14978, 15590, 15673, 15748, 16176, 29128, 29267, 30643, 31961, 32461, 32768, 0], - [12026, 13661, 13874, 15305, 15490, 15726, 15995, 16273, 28443, 30388, 30767, 32416, 32768, 0], - [19052, 19840, 20579, 20916, 21150, 21467, 21885, 22719, 23174, 28861, 30379, 32175, 32768, 0], - [18627, 19649, 20974, 21219, 21492, 21816, 22199, 23119, 23527, 27053, 31397, 32148, 32768, 0], - [17026, 19004, 19997, 20339, 20586, 21103, 21349, 21907, 22482, 25896, 26541, 31819, 32768, 0], - [12124, 13759, 14959, 14992, 15007, 15051, 15078, 15166, 15255, 15753, 16039, 16606, 32768, 0], -]; - -pub static DEFAULT_Y_MODE_CDF: [[u16; 14]; 4] = [ - [22801, 23489, 24293, 24756, 25601, 26123, 26606, 27418, 27945, 29228, 29685, 30349, 32768, 0], - [18673, 19845, 22631, 23318, 23950, 24649, 25527, 27364, 28152, 29701, 29984, 30852, 32768, 0], - [19770, 20979, 23396, 23939, 24241, 24654, 25136, 27073, 27830, 29360, 29730, 30659, 32768, 0], - [20155, 21301, 22838, 23178, 23261, 23533, 23703, 24804, 25352, 26575, 27016, 28049, 32768, 0], -]; - -pub static DEFAULT_ZERO_MV_CDF: [[u16; 3]; 2] = [[2175, 32768, 0], [1054, 32768, 0]]; - -pub static INTRA_MODE_CONTEXT: [u8; 13] = [0, 1, 2, 3, 4, 4, 4, 4, 3, 0, 1, 2, 0]; - -pub static MAG_REF_OFFSET_WITH_TX_CLASS: [[[u8; 2]; 3]; 3] = [[[0, 1], [1, 0], [1, 1]], [[0, 1], [1, 0], [0, 2]], [[0, 1], [1, 0], [2, 0]]]; - -pub static MAX_TX_SIZE_RECT: [u8; 22] = [0, 5, 6, 1, 7, 8, 2, 9, 10, 3, 11, 12, 4, 4, 4, 4, 13, 14, 15, 16, 17, 18]; - -pub static MI_HEIGHT_LOG2: [u8; 22] = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4, 5, 4, 5, 2, 0, 3, 1, 4, 2]; - -pub static MI_WIDTH_LOG2: [u8; 22] = [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 0, 2, 1, 3, 2, 4]; - -pub static MODE_TO_TXFM: [u8; 14] = [0, 1, 2, 0, 3, 1, 2, 2, 1, 3, 1, 2, 3, 0]; - -pub static NUM_4X4_BLOCKS_HIGH: [u8; 22] = [1, 2, 1, 2, 4, 2, 4, 8, 4, 8, 16, 8, 16, 32, 16, 32, 4, 1, 8, 2, 16, 4]; - -pub static NUM_4X4_BLOCKS_WIDE: [u8; 22] = [1, 1, 2, 2, 2, 4, 4, 4, 8, 8, 8, 16, 16, 16, 32, 32, 1, 4, 2, 8, 4, 16]; - -pub static PARTITION_SUBSIZE: [[u8; 22]; 10] = [ - [0, 22, 22, 3, 22, 22, 6, 22, 22, 9, 22, 22, 12, 22, 22, 15, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 2, 22, 22, 5, 22, 22, 8, 22, 22, 11, 22, 22, 14, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 1, 22, 22, 4, 22, 22, 7, 22, 22, 10, 22, 22, 13, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 0, 22, 22, 3, 22, 22, 6, 22, 22, 9, 22, 22, 12, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 2, 22, 22, 5, 22, 22, 8, 22, 22, 11, 22, 22, 14, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 2, 22, 22, 5, 22, 22, 8, 22, 22, 11, 22, 22, 14, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 1, 22, 22, 4, 22, 22, 7, 22, 22, 10, 22, 22, 13, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 1, 22, 22, 4, 22, 22, 7, 22, 22, 10, 22, 22, 13, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 22, 22, 22, 17, 22, 22, 19, 22, 22, 21, 22, 22, 22, 22, 22, 22, 22, 22, 22], - [22, 22, 22, 22, 22, 22, 16, 22, 22, 18, 22, 22, 20, 22, 22, 22, 22, 22, 22, 22, 22, 22], -]; - -pub static SIG_REF_DIFF_OFFSET: [[[u8; 2]; 5]; 3] = - [[[0, 1], [1, 0], [1, 1], [0, 2], [2, 0]], [[0, 1], [1, 0], [0, 2], [0, 3], [0, 4]], [[0, 1], [1, 0], [2, 0], [3, 0], [4, 0]]]; - -pub static SIZE_GROUP: [u8; 22] = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 0, 0, 1, 1, 2, 2]; - -pub static SM_WEIGHTS_TX_16X16: [u8; 16] = [255, 225, 196, 170, 145, 123, 102, 84, 68, 54, 43, 33, 26, 20, 17, 16]; - -pub static SM_WEIGHTS_TX_32X32: [u8; 32] = - [255, 240, 225, 210, 196, 182, 169, 157, 145, 133, 122, 111, 101, 92, 83, 74, 66, 59, 52, 45, 39, 34, 29, 25, 21, 17, 14, 12, 10, 9, 8, 8]; - -pub static SM_WEIGHTS_TX_4X4: [u8; 4] = [255, 149, 85, 64]; - -pub static SM_WEIGHTS_TX_8X8: [u8; 8] = [255, 197, 146, 105, 73, 50, 37, 32]; - -pub static SUBPEL_FILTERS: [[[i16; 8]; 16]; 6] = [ - [ - [0, 0, 0, 128, 0, 0, 0, 0], - [0, 2, -6, 126, 8, -2, 0, 0], - [0, 2, -10, 122, 18, -4, 0, 0], - [0, 2, -12, 116, 28, -8, 2, 0], - [0, 2, -14, 110, 38, -10, 2, 0], - [0, 2, -14, 102, 48, -12, 2, 0], - [0, 2, -16, 94, 58, -12, 2, 0], - [0, 2, -14, 84, 66, -12, 2, 0], - [0, 2, -14, 76, 76, -14, 2, 0], - [0, 2, -12, 66, 84, -14, 2, 0], - [0, 2, -12, 58, 94, -16, 2, 0], - [0, 2, -12, 48, 102, -14, 2, 0], - [0, 2, -10, 38, 110, -14, 2, 0], - [0, 2, -8, 28, 116, -12, 2, 0], - [0, 0, -4, 18, 122, -10, 2, 0], - [0, 0, -2, 8, 126, -6, 2, 0], - ], - [ - [0, 0, 0, 128, 0, 0, 0, 0], - [0, 2, 28, 62, 34, 2, 0, 0], - [0, 0, 26, 62, 36, 4, 0, 0], - [0, 0, 22, 62, 40, 4, 0, 0], - [0, 0, 20, 60, 42, 6, 0, 0], - [0, 0, 18, 58, 44, 8, 0, 0], - [0, 0, 16, 56, 46, 10, 0, 0], - [0, -2, 16, 54, 48, 12, 0, 0], - [0, -2, 14, 52, 52, 14, -2, 0], - [0, 0, 12, 48, 54, 16, -2, 0], - [0, 0, 10, 46, 56, 16, 0, 0], - [0, 0, 8, 44, 58, 18, 0, 0], - [0, 0, 6, 42, 60, 20, 0, 0], - [0, 0, 4, 40, 62, 22, 0, 0], - [0, 0, 4, 36, 62, 26, 0, 0], - [0, 0, 2, 34, 62, 28, 2, 0], - ], - [ - [0, 0, 0, 128, 0, 0, 0, 0], - [-2, 2, -6, 126, 8, -2, 2, 0], - [-2, 6, -12, 124, 16, -6, 4, -2], - [-2, 8, -18, 120, 26, -10, 6, -2], - [-4, 10, -22, 116, 38, -14, 6, -2], - [-4, 10, -22, 108, 48, -18, 8, -2], - [-4, 10, -24, 100, 60, -20, 8, -2], - [-4, 10, -24, 90, 70, -22, 10, -2], - [-4, 12, -24, 80, 80, -24, 12, -4], - [-2, 10, -22, 70, 90, -24, 10, -4], - [-2, 8, -20, 60, 100, -24, 10, -4], - [-2, 8, -18, 48, 108, -22, 10, -4], - [-2, 6, -14, 38, 116, -22, 10, -4], - [-2, 6, -10, 26, 120, -18, 8, -2], - [-2, 4, -6, 16, 124, -12, 6, -2], - [0, 2, -2, 8, 126, -6, 2, -2], - ], - [ - [0, 0, 0, 128, 0, 0, 0, 0], - [0, 0, 0, 120, 8, 0, 0, 0], - [0, 0, 0, 112, 16, 0, 0, 0], - [0, 0, 0, 104, 24, 0, 0, 0], - [0, 0, 0, 96, 32, 0, 0, 0], - [0, 0, 0, 88, 40, 0, 0, 0], - [0, 0, 0, 80, 48, 0, 0, 0], - [0, 0, 0, 72, 56, 0, 0, 0], - [0, 0, 0, 64, 64, 0, 0, 0], - [0, 0, 0, 56, 72, 0, 0, 0], - [0, 0, 0, 48, 80, 0, 0, 0], - [0, 0, 0, 40, 88, 0, 0, 0], - [0, 0, 0, 32, 96, 0, 0, 0], - [0, 0, 0, 24, 104, 0, 0, 0], - [0, 0, 0, 16, 112, 0, 0, 0], - [0, 0, 0, 8, 120, 0, 0, 0], - ], - [ - [0, 0, 0, 128, 0, 0, 0, 0], - [0, 0, -4, 126, 8, -2, 0, 0], - [0, 0, -8, 122, 18, -4, 0, 0], - [0, 0, -10, 116, 28, -6, 0, 0], - [0, 0, -12, 110, 38, -8, 0, 0], - [0, 0, -12, 102, 48, -10, 0, 0], - [0, 0, -14, 94, 58, -10, 0, 0], - [0, 0, -12, 84, 66, -10, 0, 0], - [0, 0, -12, 76, 76, -12, 0, 0], - [0, 0, -10, 66, 84, -12, 0, 0], - [0, 0, -10, 58, 94, -14, 0, 0], - [0, 0, -10, 48, 102, -12, 0, 0], - [0, 0, -8, 38, 110, -12, 0, 0], - [0, 0, -6, 28, 116, -10, 0, 0], - [0, 0, -4, 18, 122, -8, 0, 0], - [0, 0, -2, 8, 126, -4, 0, 0], - ], - [ - [0, 0, 0, 128, 0, 0, 0, 0], - [0, 0, 30, 62, 34, 2, 0, 0], - [0, 0, 26, 62, 36, 4, 0, 0], - [0, 0, 22, 62, 40, 4, 0, 0], - [0, 0, 20, 60, 42, 6, 0, 0], - [0, 0, 18, 58, 44, 8, 0, 0], - [0, 0, 16, 56, 46, 10, 0, 0], - [0, 0, 14, 54, 48, 12, 0, 0], - [0, 0, 12, 52, 52, 12, 0, 0], - [0, 0, 12, 48, 54, 14, 0, 0], - [0, 0, 10, 46, 56, 16, 0, 0], - [0, 0, 8, 44, 58, 18, 0, 0], - [0, 0, 6, 42, 60, 20, 0, 0], - [0, 0, 4, 40, 62, 22, 0, 0], - [0, 0, 4, 36, 62, 26, 0, 0], - [0, 0, 2, 34, 62, 30, 0, 0], - ], -]; - -pub static SUBSAMPLED_SIZE: [[[u8; 2]; 2]; 22] = [ - [[0, 0], [0, 0]], - [[1, 0], [22, 0]], - [[2, 22], [0, 0]], - [[3, 2], [1, 0]], - [[4, 3], [22, 1]], - [[5, 22], [3, 2]], - [[6, 5], [4, 3]], - [[7, 6], [22, 4]], - [[8, 22], [6, 5]], - [[9, 8], [7, 6]], - [[10, 9], [22, 7]], - [[11, 22], [9, 8]], - [[12, 11], [10, 9]], - [[13, 12], [22, 10]], - [[14, 22], [12, 11]], - [[15, 14], [13, 12]], - [[16, 1], [22, 1]], - [[17, 22], [2, 2]], - [[18, 4], [22, 16]], - [[19, 22], [5, 17]], - [[20, 7], [22, 18]], - [[21, 22], [8, 19]], -]; - -pub static TRANSFORM_ROW_SHIFT: [u8; 19] = [0, 1, 2, 2, 2, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2]; - -pub static TX_HEIGHT: [u8; 19] = [4, 8, 16, 32, 64, 8, 4, 16, 8, 32, 16, 64, 32, 16, 4, 32, 8, 64, 16]; - -pub static TX_HEIGHT_LOG2: [u8; 19] = [2, 3, 4, 5, 6, 3, 2, 4, 3, 5, 4, 6, 5, 4, 2, 5, 3, 6, 4]; - -pub static TX_SIZE_SQR: [u8; 19] = [0, 1, 2, 3, 4, 0, 0, 1, 1, 2, 2, 3, 3, 0, 0, 1, 1, 2, 2]; - -pub static TX_SIZE_SQR_UP: [u8; 19] = [0, 1, 2, 3, 4, 1, 1, 2, 2, 3, 3, 4, 4, 2, 2, 3, 3, 4, 4]; - -pub static TX_TYPE_IN_SET_INTER: [[u8; 16]; 4] = [ - [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], -]; - -pub static TX_TYPE_IN_SET_INTRA: [[u8; 16]; 3] = - [[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0], [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]]; - -pub static TX_TYPE_INTER_INV_SET3: [u8; 2] = [9, 0]; - -pub static TX_TYPE_INTRA_INV_SET2: [u8; 5] = [9, 0, 3, 1, 2]; - -pub static TX_WIDTH: [u8; 19] = [4, 8, 16, 32, 64, 4, 8, 8, 16, 16, 32, 32, 64, 4, 16, 8, 32, 16, 64]; - -pub static TX_WIDTH_LOG2: [u8; 19] = [2, 3, 4, 5, 6, 2, 3, 3, 4, 4, 5, 5, 6, 2, 4, 3, 5, 4, 6]; diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs deleted file mode 100644 index 9d81ead0b..000000000 --- a/crates/av1enc/src/transform.rs +++ /dev/null @@ -1,575 +0,0 @@ -//! Transforms. The inverse transforms are the normative ones of spec 7.13 (mirrored from -//! filmcraft-av1, so that the encoder reconstructs exactly like a decoder); the forward -//! transforms are floating-point matrices scaled to match them (not normative). - -use std::sync::OnceLock; - -use crate::tables::{ADST_ADST, ADST_DCT, COS128_LOOKUP, DCT_ADST, DCT_DCT, TRANSFORM_ROW_SHIFT, TX_WIDTH_LOG2}; - -#[inline(always)] -fn round2_64(x: i64, n: u32) -> i32 { - if n == 0 { x as i32 } else { ((x + (1i64 << (n - 1))) >> n) as i32 } -} - -#[inline(always)] -pub(crate) fn round2(x: i32, n: u32) -> i32 { - if n == 0 { x } else { (x + (1 << (n - 1))) >> n } -} - -#[inline(always)] -fn cos128(angle: i32) -> i64 { - let a = (angle & 255) as usize; - (match a { - 0..=64 => COS128_LOOKUP[a] as i32, - 65..=128 => -(COS128_LOOKUP[128 - a] as i32), - 129..=192 => -(COS128_LOOKUP[a - 128] as i32), - _ => COS128_LOOKUP[256 - a] as i32, - }) as i64 -} - -#[inline(always)] -fn sin128(angle: i32) -> i64 { - cos128(angle - 64) -} - -#[inline(always)] -fn brev(num_bits: u32, x: usize) -> usize { - let mut t = 0; - for i in 0..num_bits { - let bit = (x >> i) & 1; - t += bit << (num_bits - 1 - i); - } - t -} - -#[inline(always)] -fn bf(t: &mut [i32], a: usize, b: usize, angle: i32, flip: bool) { - let x = t[a] as i64 * cos128(angle) - t[b] as i64 * sin128(angle); - let y = t[a] as i64 * sin128(angle) + t[b] as i64 * cos128(angle); - t[a] = round2_64(x, 12); - t[b] = round2_64(y, 12); - if flip { - t.swap(a, b); - } -} - -#[inline(always)] -fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) { - let (a, b) = if flip { (b, a) } else { (a, b) }; - let x = t[a]; - let y = t[b]; - let lo = -(1i32 << (r - 1)); - let hi = (1i32 << (r - 1)) - 1; - t[a] = (x + y).clamp(lo, hi); - t[b] = (x - y).clamp(lo, hi); -} - -/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the -/// bit-reversal permutations and loop bounds fold at compile time. -#[inline(always)] -fn inverse_dct_n(t: &mut [i32], r: u32) { - let n = N; - let len = 1usize << n; - let mut copy = [0i32; 32]; - copy[..len].copy_from_slice(&t[..len]); - for i in 0..len { - t[i] = copy[brev(n, i)]; - } - inverse_dct_initial_stages(t, n, r); - inverse_dct_final_stages(t, n, r); -} - -/// Rotate the high-frequency branches before the four-point core. -#[inline(always)] -fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { - if n >= 5 { - for i in 0..8 { - bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false); - } - } - if n >= 4 { - for i in 0..4 { - bf(t, 8 + i, 15 - i, 12 + ((brev(2, 3 - i) as i32) << 4), false); - } - } - if n >= 5 { - for i in 0..8 { - hd(t, 16 + 2 * i, 17 + 2 * i, i & 1 == 1, r); - } - } - if n >= 3 { - for i in 0..2 { - bf(t, 4 + i, 7 - i, 56 - 32 * i as i32, false); - } - } - if n >= 4 { - for i in 0..4 { - hd(t, 8 + 2 * i, 9 + 2 * i, i & 1 == 1, r); - } - } - if n >= 5 { - for i in 0..2 { - for j in 0..2 { - bf(t, 30 - 4 * i - j, 17 + 4 * i + j, 24 + ((j as i32) << 6) + (((1 - i) as i32) << 5), true); - } - } - } -} - -/// Evaluate the four-point core and merge progressively larger branches. -#[inline(always)] -fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) { - for i in 0..2 { - bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0); - } - if n >= 3 { - for i in 0..2 { - hd(t, 4 + 2 * i, 5 + 2 * i, i == 1, r); - } - } - if n >= 4 { - for i in 0..2 { - bf(t, 14 - i, 9 + i, 48 + 64 * i as i32, true); - } - } - if n >= 5 { - for i in 0..4 { - for j in 0..2 { - hd(t, 16 + 4 * i + j, 19 + 4 * i - j, i & 1 == 1, r); - } - } - } - for i in 0..2 { - hd(t, i, 3 - i, false, r); - } - if n >= 3 { - bf(t, 6, 5, 32, true); - } - if n >= 4 { - for i in 0..2 { - for j in 0..2 { - hd(t, 8 + 4 * i + j, 11 + 4 * i - j, i == 1, r); - } - } - } - if n >= 5 { - for i in 0..4 { - bf(t, 29 - i, 18 + i, 48 + (i as i32 >> 1) * 64, true); - } - } - if n >= 3 { - for i in 0..4 { - hd(t, i, 7 - i, false, r); - } - } - if n >= 4 { - for i in 0..2 { - bf(t, 13 - i, 10 + i, 32, true); - } - } - if n >= 5 { - for i in 0..2 { - for j in 0..4 { - hd(t, 16 + i * 8 + j, 23 + i * 8 - j, i == 1, r); - } - } - } - if n >= 4 { - for i in 0..8 { - hd(t, i, 15 - i, false, r); - } - } - if n >= 5 { - for i in 0..4 { - bf(t, 27 - i, 20 + i, 32, true); - } - } - if n >= 5 { - for i in 0..16 { - hd(t, i, 31 - i, false, r); - } - } -} - -const SINPI_1_9: i64 = 1321; -const SINPI_2_9: i64 = 2482; -const SINPI_3_9: i64 = 3344; -const SINPI_4_9: i64 = 3803; - -#[inline(always)] -fn inverse_adst4(t: &mut [i32]) { - let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64); - let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3]; - let a7 = t0 - t2; - let b7 = a7 + t3; - s[0] += s[3]; - s[1] -= s[4]; - s[3] = s[2]; - s[2] = SINPI_3_9 * b7; - s[0] += s[5]; - s[1] -= s[6]; - let x = [s[0] + s[3], s[1] + s[3], s[2], s[0] + s[1] - s[3]]; - for i in 0..4 { - t[i] = round2_64(x[i], 12); - } -} - -#[inline(always)] -fn adst_in_permute(t: &mut [i32]) { - let n = N; - let n0 = 1usize << n; - let mut copy = [0i32; 16]; - copy[..n0].copy_from_slice(&t[..n0]); - for i in 0..n0 { - let idx = if i & 1 == 1 { i - 1 } else { n0 - i - 1 }; - t[i] = copy[idx]; - } -} - -#[inline(always)] -fn adst_out_permute(t: &mut [i32]) { - let n = N; - let n0 = 1usize << n; - let mut copy = [0i32; 16]; - copy[..n0].copy_from_slice(&t[..n0]); - for i in 0..n0 { - let a = (i >> 3) & 1; - let b = ((i >> 2) & 1) ^ ((i >> 3) & 1); - let c = ((i >> 1) & 1) ^ ((i >> 2) & 1); - let d = (i & 1) ^ ((i >> 1) & 1); - let idx = ((d << 3) | (c << 2) | (b << 1) | a) >> (4 - n); - t[i] = if i & 1 == 1 { -copy[idx] } else { copy[idx] }; - } -} - -#[inline(always)] -fn inverse_adst8(t: &mut [i32], r: u32) { - adst_in_permute::<3>(t); - for i in 0..4 { - bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true); - } - for i in 0..4 { - hd(t, i, 4 + i, false, r); - } - for i in 0..2 { - bf(t, 4 + 3 * i, 5 + i, 48 - 32 * i as i32, true); - } - for i in 0..2 { - for j in 0..2 { - hd(t, 4 * j + i, 2 + 4 * j + i, false, r); - } - } - for i in 0..2 { - bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); - } - adst_out_permute::<3>(t); -} - -#[inline(always)] -fn inverse_adst16(t: &mut [i32], r: u32) { - adst_in_permute::<4>(t); - for i in 0..8 { - bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true); - } - for i in 0..8 { - hd(t, i, 8 + i, false, r); - } - for i in 0..2 { - bf(t, 8 + 2 * i, 9 + 2 * i, 56 - 32 * i as i32, true); - bf(t, 13 + 2 * i, 12 + 2 * i, 8 + 32 * i as i32, true); - } - for i in 0..4 { - for j in 0..2 { - hd(t, 8 * j + i, 4 + 8 * j + i, false, r); - } - } - for i in 0..2 { - for j in 0..2 { - bf(t, 4 + 8 * j + 3 * i, 5 + 8 * j + i, 48 - 32 * i as i32, true); - } - } - for i in 0..2 { - for j in 0..4 { - hd(t, 4 * j + i, 2 + 4 * j + i, false, r); - } - } - for i in 0..4 { - bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); - } - adst_out_permute::<4>(t); -} - -/// 1-D transform kind. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum Kind { - Dct, - Adst, -} - -/// (vertical / column kind, horizontal / row kind) of a transform type. -pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) { - match tx_type { - DCT_DCT => (Kind::Dct, Kind::Dct), - ADST_DCT => (Kind::Adst, Kind::Dct), - DCT_ADST => (Kind::Dct, Kind::Adst), - ADST_ADST => (Kind::Adst, Kind::Adst), - // The encoder only chooses the four types above. - _ => { - debug_assert!(false, "unsupported transform type {tx_type}"); - (Kind::Dct, Kind::Dct) - } - } -} - -#[inline(always)] -fn inverse_dct(t: &mut [i32], n: u32, r: u32) { - match n { - 2 => inverse_dct_n::<2>(t, r), - 3 => inverse_dct_n::<3>(t, r), - 4 => inverse_dct_n::<4>(t, r), - _ => inverse_dct_n::<5>(t, r), - } -} - -#[inline(always)] -fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) { - match kind { - Kind::Dct => inverse_dct(t, n, r), - Kind::Adst => match n { - 2 => inverse_adst4(t), - 3 => inverse_adst8(t, r), - _ => inverse_adst16(t, r), - }, - } -} - -/// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised -/// coefficients (row stride `n`) into `residual` (row stride `n`). -pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { - match TX_WIDTH_LOG2[tx_sz] { - 2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth), - 3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth), - 4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth), - _ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth), - } -} - -/// `inverse_2d` for a block of width `1 << LOG2`. -fn inverse_2d_n(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { - let log2 = LOG2; - let n = 1usize << log2; - let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32; - let row_clamp = bit_depth + 8; - let col_clamp = (bit_depth + 6).max(16); - let (ck, rk) = kinds(tx_type); - let lo = -(1i32 << (col_clamp - 1)); - let hi = (1i32 << (col_clamp - 1)) - 1; - let mut t = [0i32; 32]; - for i in 0..n { - let row = &dequant[i * n..i * n + n]; - if row.iter().all(|&v| v == 0) { - residual[i * n..i * n + n].iter_mut().for_each(|v| *v = 0); - continue; - } - t[..n].copy_from_slice(row); - inverse_1d(&mut t, rk, log2, row_clamp); - for j in 0..n { - residual[i * n + j] = round2(t[j], row_shift).clamp(lo, hi); - } - } - for j in 0..n { - for i in 0..n { - t[i] = residual[i * n + j]; - } - inverse_1d(&mut t, ck, log2, col_clamp); - for i in 0..n { - residual[i * n + j] = round2(t[i], 4); - } - } -} - -/// Orthonormal forward basis matrices `[k][i]` (row k = basis function k), sizes 4..=32. -struct Bases { - dct: [Vec; 4], - adst: [Vec; 3], - /// The same matrices transposed (`[i][k]`). - dct_t: [Vec; 4], - adst_t: [Vec; 3], -} - -fn transpose(m: &[f32]) -> Vec { - let n = (m.len() as f64).sqrt() as usize; - let mut t = vec![0f32; n * n]; - for k in 0..n { - for i in 0..n { - t[i * n + k] = m[k * n + i]; - } - } - t -} - -fn bases() -> &'static Bases { - static B: OnceLock = OnceLock::new(); - B.get_or_init(|| { - let pi = std::f64::consts::PI; - let dct = std::array::from_fn(|l| { - let n = 4usize << l; - let mut m = vec![0f32; n * n]; - for k in 0..n { - let c = if k == 0 { std::f64::consts::FRAC_1_SQRT_2 } else { 1.0 }; - for i in 0..n { - m[k * n + i] = (c * (2.0 / n as f64).sqrt() * (pi * (2 * i + 1) as f64 * k as f64 / (2 * n) as f64).cos()) as f32; - } - } - m - }); - let adst = std::array::from_fn(|l| { - let n = 4usize << l; - let mut m = vec![0f32; n * n]; - for k in 0..n { - for i in 0..n { - m[k * n + i] = if n == 4 { - // the AV1 4-point ADST (sin(pi (i + 1)(2k + 1) / 9) basis) - (2.0 / 3.0 * (pi * ((i + 1) * (2 * k + 1)) as f64 / 9.0).sin()) as f32 - } else { - ((2.0 / n as f64).sqrt() * (pi * ((2 * i + 1) * (2 * k + 1)) as f64 / (4 * n) as f64).sin()) as f32 - }; - } - } - m - }); - let dct_t = std::array::from_fn(|l| transpose(&dct[l])); - let adst_t = std::array::from_fn(|l| transpose(&adst[l])); - Bases { dct, adst, dct_t, adst_t } - }) -} - -fn basis_t(kind: Kind, n: usize) -> &'static [f32] { - let l = n.trailing_zeros() as usize - 2; - match kind { - Kind::Dct => &bases().dct_t[l], - Kind::Adst => &bases().adst_t[l], - } -} - -fn basis(kind: Kind, n: usize) -> &'static [f32] { - let l = n.trailing_zeros() as usize - 2; - match kind { - Kind::Dct => &bases().dct[l], - Kind::Adst => &bases().adst[l], - } -} - -/// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain -/// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser). -pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { - match n { - 4 => forward_2d_n::<4, 16>(residual, out, tx_type), - 8 => forward_2d_n::<8, 64>(residual, out, tx_type), - 16 => forward_2d_n::<16, 256>(residual, out, tx_type), - _ => forward_2d_dyn(residual, out, n, tx_type), - } -} - -/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing). -#[inline(always)] -fn forward_2d_n(residual: &[i32], out: &mut [f32], tx_type: usize) { - let n = N; - let (ck, rk) = kinds(tx_type); - let bc = &basis(ck, n)[..NN]; - let brt = &basis_t(rk, n)[..NN]; - let residual = &residual[..NN]; - let out = &mut out[..NN]; - let mut tmp = [0f32; NN]; - for i in 0..n { - let t = &mut tmp[i * n..i * n + n]; - for j in 0..n { - let r = residual[i * n + j]; - if r == 0 { - continue; - } - let r = r as f32; - for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) { - *o += r * b; - } - } - } - out.iter_mut().for_each(|v| *v = 0.0); - for k in 0..n { - let o = &mut out[k * n..k * n + n]; - for i in 0..n { - let b = 8.0 * bc[k * n + i]; - for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) { - *v += b * t; - } - } - } -} - -/// 32x32 (and any other size): a const-generic 32 version measured slower. -fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { - let (ck, rk) = kinds(tx_type); - let bc = basis(ck, n); - let brt = basis_t(rk, n); - // tmp[i][k] = sum_j residual[i][j] * br[k][j] (horizontal; written as row updates so that - // the inner loops vectorise) - let mut tmp = [0f32; 32 * 32]; - for i in 0..n { - let t = &mut tmp[i * n..i * n + n]; - for j in 0..n { - let r = residual[i * n + j]; - if r == 0 { - continue; - } - let r = r as f32; - for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) { - *o += r * b; - } - } - } - // out[k][j] = 8 * sum_i bc[k][i] * tmp[i][j] (vertical) - out[..n * n].iter_mut().for_each(|v| *v = 0.0); - for k in 0..n { - let o = &mut out[k * n..k * n + n]; - for i in 0..n { - let b = 8.0 * bc[k * n + i]; - for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) { - *v += b * t; - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32}; - - /// forward (float) then the normative inverse reproduces the residual: the forward basis - /// and scale match the decoder's inverse transforms for every supported size and type. - #[test] - fn forward_matches_inverse() { - let mut seed = 99u32; - let mut rnd = || { - seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); - ((seed >> 16) % 201) as i32 - 100 - }; - for (tx, n) in [(TX_4X4, 4), (TX_8X8, 8), (TX_16X16, 16), (TX_32X32, 32)] { - let denom = if tx == TX_32X32 { 2.0 } else { 1.0 }; - for tx_type in [DCT_DCT, ADST_DCT, DCT_ADST, ADST_ADST] { - if n == 32 && tx_type != DCT_DCT { - continue; - } - let res: Vec = (0..n * n).map(|_| rnd()).collect(); - let mut coef = vec![0f32; n * n]; - forward_2d(&res, &mut coef, n, tx_type); - // q = 8 / denom keeps full precision: dequant = level * q / denom = coef - let deq: Vec = coef.iter().map(|&c| (c / denom).round() as i32).collect(); - let mut out = vec![0i32; n * n]; - inverse_2d(&deq, &mut out, tx, tx_type, 8); - let err: i32 = res.iter().zip(&out).map(|(a, b)| (a - b).abs()).max().unwrap(); - assert!(err <= 1, "tx {tx} type {tx_type}: max error {err}"); - } - } - } -} diff --git a/crates/av1enc/src/writer.rs b/crates/av1enc/src/writer.rs deleted file mode 100644 index a10bd2e9a..000000000 --- a/crates/av1enc/src/writer.rs +++ /dev/null @@ -1,1040 +0,0 @@ -//! Tile syntax writer (spec 5.11 with the 8.3 CDF selection). It keeps the same per-4x4 mode -//! info, coefficient contexts and adaptive CDFs as a decoder and writes the decisions taken by -//! `decide.rs` as symbols. The context derivations and the motion vector prediction process -//! (7.10.2) mirror the decoder exactly, since symbols and coded motion vector differences -//! depend on them. - -use crate::cdf::Cdfs; -use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for}; -use crate::loopfilter::LfInfo; -use crate::symbol::SymbolWriter; -use crate::tables::{ - ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE, - D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME, - LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV, - NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, - PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP, - SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, - TX_WIDTH_LOG2, V_PRED, -}; - -type MvP = [i32; 2]; - -/// Mode info per 4x4 (MiSizes, YModes, RefFrames, Mvs, ...). -struct Mi { - cols: usize, - mi_size: Vec, - y_mode: Vec, - ref_frame: Vec<[i8; 2]>, - mv: Vec, - skip: Vec, - is_inter: Vec, - tx_type: Vec, - written: Vec, -} - -impl Mi { - #[inline(always)] - fn idx(&self, r: usize, c: usize) -> usize { - r * self.cols + c - } -} - -/// Output of find_mv_stack. -#[derive(Default)] -struct MvStack { - ref_stack_mv: [MvP; MAX_REF_MV_STACK_SIZE + 1], - weight_stack: [u32; MAX_REF_MV_STACK_SIZE + 1], - num_mv_found: usize, - new_mv_count: usize, - found_match: bool, - close_matches: usize, - total_matches: usize, - new_mv_context: usize, - ref_mv_context: usize, - zero_mv_context: usize, - drl_ctx_stack: [usize; MAX_REF_MV_STACK_SIZE + 1], -} - -pub(crate) struct TileWriter<'a> { - g: &'a FrameGeom, - intra_only: bool, - qidx: u8, - sw: SymbolWriter, - cdf: Box, - kf_y_mode: [[[u16; 14]; 5]; 5], - mi: Mi, - above_level: [Vec; 3], - above_dc: [Vec; 3], - left_level: [Vec; 3], - left_dc: [Vec; 3], - /// LoopfilterTxSizes per plane (row stride mi_cols + 32). - lf_tx: [Vec; 3], - /// Scratch for the levels coded so far in `coeffs`. - quant: Vec, - // current block - r: usize, - c: usize, - bsize: usize, - avail_u: bool, - avail_l: bool, - mvs: MvStack, -} - -fn block_size_px(bs: usize) -> usize { - 4 * NUM_4X4_BLOCKS_WIDE[bs] as usize -} - -fn is_directional(mode: usize) -> bool { - (V_PRED..=D67_PRED).contains(&mode) -} - -impl<'a> TileWriter<'a> { - /// `init`: the CDFs saved by the primary reference frame (load_cdfs), or None for the - /// defaults (init_non_coeff_cdfs / init_coeff_cdfs). - pub fn new(g: &'a FrameGeom, intra_only: bool, qidx: u8, init: Option<&Cdfs>) -> Self { - let n = g.mi_cols * g.mi_rows; - TileWriter { - g, - intra_only, - qidx, - sw: SymbolWriter::new(false), - cdf: match init { - Some(c) => { - let mut c = Box::new(c.clone()); - c.clear_counts(); - c - } - None => Cdfs::new(qidx as u32), - }, - kf_y_mode: DEFAULT_INTRA_FRAME_Y_MODE_CDF, - mi: Mi { - cols: g.mi_cols, - mi_size: vec![0; n], - y_mode: vec![0; n], - ref_frame: vec![[0, -1]; n], - mv: vec![[0, 0]; n], - skip: vec![false; n], - is_inter: vec![false; n], - tx_type: vec![0; n], - written: vec![false; n], - }, - above_level: std::array::from_fn(|_| vec![0; g.mi_cols + 64]), - above_dc: std::array::from_fn(|_| vec![0; g.mi_cols + 64]), - left_level: std::array::from_fn(|_| vec![0; g.mi_rows + 64]), - left_dc: std::array::from_fn(|_| vec![0; g.mi_rows + 64]), - lf_tx: std::array::from_fn(|_| vec![0; (g.mi_cols + 32) * (g.mi_rows + 32)]), - quant: Vec::new(), - r: 0, - c: 0, - bsize: 0, - avail_u: false, - avail_l: false, - mvs: MvStack::default(), - } - } - - /// The tile data and the final CDFs (saved for the frame-end CDF update). - pub fn finish(self) -> (Vec, Box) { - (self.sw.finish(), self.cdf) - } - - /// The mode info the loop filter needs. - pub fn lf_info(&self) -> LfInfo<'_> { - LfInfo { - mi_cols: self.g.mi_cols, - mi_rows: self.g.mi_rows, - width: self.g.width, - height: self.g.height, - mi_size: &self.mi.mi_size, - skip: &self.mi.skip, - is_inter: &self.mi.is_inter, - tx: [&self.lf_tx[0], &self.lf_tx[1], &self.lf_tx[2]], - } - } - - /// Start of a superblock row (clear_left_context). - pub fn start_sb_row(&mut self) { - for p in 0..3 { - self.left_level[p].iter_mut().for_each(|v| *v = 0); - self.left_dc[p].iter_mut().for_each(|v| *v = 0); - } - } - - pub fn superblock(&mut self, r: usize, c: usize, node: &Node) { - self.partition(r, c, BLOCK_64X64, node); - } - - fn partition(&mut self, r: usize, c: usize, bsize: usize, node: &Node) { - let (mi_rows, mi_cols) = (self.g.mi_rows, self.g.mi_cols); - if r >= mi_rows || c >= mi_cols { - return; - } - let avail_u = r > 0; - let avail_l = c > 0; - let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize; - let half = n4 >> 1; - let has_rows = r + half < mi_rows; - let has_cols = c + half < mi_cols; - let split = matches!(node, Node::Split(_)); - let bsl = MI_WIDTH_LOG2[bsize] as usize; - let above = avail_u && (MI_WIDTH_LOG2[self.mi.mi_size[self.mi.idx(r - 1, c)] as usize] as usize) < bsl; - let left = avail_l && (MI_HEIGHT_LOG2[self.mi.mi_size[self.mi.idx(r, c - 1)] as usize] as usize) < bsl; - let ctx = left as usize * 2 + above as usize; - let partition = if split { PARTITION_SPLIT } else { PARTITION_NONE }; - if has_rows && has_cols { - let cdf: &mut [u16] = match bsl { - 1 => &mut self.cdf.partition_w8[ctx], - 2 => &mut self.cdf.partition_w16[ctx], - 3 => &mut self.cdf.partition_w32[ctx], - _ => &mut self.cdf.partition_w64[ctx], - }; - self.sw.symbol(partition, cdf); - } else if has_cols || has_rows { - assert!(split, "blocks crossing the frame edge are split"); - let pcdf: &[u16] = match bsl { - 2 => &self.cdf.partition_w16[ctx], - 3 => &self.cdf.partition_w32[ctx], - _ => &self.cdf.partition_w64[ctx], - }; - let pr = |k: usize| pcdf[k] as i32 - if k == 0 { 0 } else { pcdf[k - 1] as i32 }; - let psum = if has_cols { - // split_or_horz - pr(PARTITION_VERT) + pr(PARTITION_SPLIT) + pr(PARTITION_HORZ_A) + pr(PARTITION_VERT_A) + pr(PARTITION_VERT_B) + pr(PARTITION_VERT_4) - } else { - // split_or_vert - pr(PARTITION_HORZ) + pr(PARTITION_SPLIT) + pr(PARTITION_HORZ_A) + pr(PARTITION_HORZ_B) + pr(PARTITION_VERT_A) + pr(PARTITION_HORZ_4) - }; - let cdf = [((1i32 << 15) - psum) as u16, 1 << 15, 0]; - self.sw.symbol_fixed(1, &cdf); - } else { - assert!(split); - } - match node { - Node::Split(ch) => { - let sub = PARTITION_SUBSIZE[PARTITION_SPLIT][bsize] as usize; - self.partition(r, c, sub, &ch[0]); - self.partition(r, c + half, sub, &ch[1]); - self.partition(r + half, c, sub, &ch[2]); - self.partition(r + half, c + half, sub, &ch[3]); - } - Node::Leaf(leaf) => { - debug_assert_eq!(leaf.bsize, bsize); - self.block(r, c, leaf); - } - // Blocks inside the frame are always decided; nothing is coded outside it. - Node::Out => debug_assert!(false, "undecided block inside the frame"), - } - } - - fn block(&mut self, r: usize, c: usize, b: &Leaf) { - self.r = r; - self.c = c; - self.bsize = b.bsize; - self.avail_u = r > 0; - self.avail_l = c > 0; - let bsize = b.bsize; - let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize; - // skip - let mut ctx = 0; - if self.avail_u { - ctx += self.mi.skip[self.mi.idx(r - 1, c)] as usize; - } - if self.avail_l { - ctx += self.mi.skip[self.mi.idx(r, c - 1)] as usize; - } - let mut y_mode = b.y_mode; - if self.intra_only { - self.sw.symbol(b.skip as usize, &mut self.cdf.skip[ctx]); - let above = if self.avail_u { self.mi.y_mode[self.mi.idx(r - 1, c)] as usize } else { DC_PRED }; - let left = if self.avail_l { self.mi.y_mode[self.mi.idx(r, c - 1)] as usize } else { DC_PRED }; - let actx = INTRA_MODE_CONTEXT[above] as usize; - let lctx = INTRA_MODE_CONTEXT[left] as usize; - self.sw.symbol(b.y_mode, &mut self.kf_y_mode[actx][lctx]); - self.intra_angle_and_uv(b); - } else { - self.sw.symbol(b.skip as usize, &mut self.cdf.skip[ctx]); - // is_inter - let left = if self.avail_l { self.mi.ref_frame[self.mi.idx(r, c - 1)] } else { [INTRA_FRAME as i8, -1] }; - let above = if self.avail_u { self.mi.ref_frame[self.mi.idx(r - 1, c)] } else { [INTRA_FRAME as i8, -1] }; - let left_intra = left[0] <= INTRA_FRAME as i8; - let above_intra = above[0] <= INTRA_FRAME as i8; - let ctx = if self.avail_u && self.avail_l { - if left_intra && above_intra { 3 } else { (left_intra || above_intra) as usize } - } else if self.avail_u || self.avail_l { - 2 * (if self.avail_u { above_intra } else { left_intra }) as usize - } else { - 0 - }; - self.sw.symbol(b.is_inter as usize, &mut self.cdf.is_inter[ctx]); - if b.is_inter { - y_mode = self.inter_block_mode_info(b, left, above); - } else { - let ctx = SIZE_GROUP[bsize] as usize; - self.sw.symbol(b.y_mode, &mut self.cdf.y_mode[ctx]); - self.intra_angle_and_uv(b); - } - } - // mode info stored before the residual - let (rows, cols) = (n4.min(self.g.mi_rows - r), n4.min(self.g.mi_cols - c)); - for y in 0..rows { - for x in 0..cols { - let i = self.mi.idx(r + y, c + x); - self.mi.y_mode[i] = y_mode as u8; - self.mi.ref_frame[i] = if b.is_inter { [LAST_FRAME as i8, -1] } else { [INTRA_FRAME as i8, -1] }; - self.mi.written[i] = true; - if b.is_inter { - self.mi.mv[i] = b.mv; - } - } - } - if b.skip { - // reset_block_context( ) - for plane in 0..3 { - let s = (plane > 0) as usize; - for i in (c >> s)..((c + n4) >> s) { - self.above_level[plane][i] = 0; - self.above_dc[plane][i] = 0; - } - for i in (r >> s)..((r + n4) >> s) { - self.left_level[plane][i] = 0; - self.left_dc[plane][i] = 0; - } - } - } else { - let log2 = (n4 * 4).trailing_zeros(); - assert!(log2 <= 5, "64x64 blocks are coded as skip blocks"); - for plane in 0..3 { - let s = (plane > 0) as u32; - self.coeffs(plane, (c * 4) >> s, (r * 4) >> s, tx_size_for(log2 - s), b, &b.coefs[plane]); - } - } - for y in 0..rows { - for x in 0..cols { - let i = self.mi.idx(r + y, c + x); - self.mi.is_inter[i] = b.is_inter; - self.mi.skip[i] = b.skip; - self.mi.mi_size[i] = bsize as u8; - } - } - // LoopfilterTxSizes: one transform block per plane - let log2 = (n4 * 4).trailing_zeros(); - let stride = self.g.mi_cols + 32; - for plane in 0..3 { - let s = (plane > 0) as usize; - let tx = tx_size_for(log2 - s as u32) as u8; - let (r0, c0, m) = (r >> s, c >> s, n4 >> s); - for y in r0..r0 + m { - self.lf_tx[plane][y * stride + c0..y * stride + c0 + m].iter_mut().for_each(|v| *v = tx); - } - } - } - - fn intra_angle_and_uv(&mut self, b: &Leaf) { - if self.bsize >= BLOCK_8X8 && is_directional(b.y_mode) { - self.sw.symbol(MAX_ANGLE_DELTA, &mut self.cdf.angle_delta[b.y_mode - V_PRED]); - } - let bs = block_size_px(self.bsize); - if bs <= 32 { - self.sw.symbol(b.uv_mode, &mut self.cdf.uv_mode_cfl_allowed[b.y_mode]); - } else { - self.sw.symbol(b.uv_mode, &mut self.cdf.uv_mode_cfl_not_allowed[b.y_mode]); - } - if self.bsize >= BLOCK_8X8 && is_directional(b.uv_mode) { - self.sw.symbol(MAX_ANGLE_DELTA, &mut self.cdf.angle_delta[b.uv_mode - V_PRED]); - } - } - - /// read_ref_frames (LAST_FRAME), find_mv_stack, the inter mode, DRL and motion vector. - /// Returns the inter mode (YMode). - fn inter_block_mode_info(&mut self, b: &Leaf, left: [i8; 2], above: [i8; 2]) -> usize { - let count = |ft: i8| -> usize { - let mut n = 0; - if self.avail_u { - n += (above[0] == ft) as usize + (above[1] == ft) as usize; - } - if self.avail_l { - n += (left[0] == ft) as usize + (left[1] == ft) as usize; - } - n - }; - let cmp = |a: usize, b: usize| match a.cmp(&b) { - std::cmp::Ordering::Less => 0, - std::cmp::Ordering::Equal => 1, - std::cmp::Ordering::Greater => 2, - }; - let (last, last2, last3, gold) = (count(LAST_FRAME as i8), count(LAST2_FRAME as i8), count(LAST3_FRAME as i8), count(GOLDEN_FRAME as i8)); - let (bwd, alt2, alt) = (count(BWDREF_FRAME as i8), count(ALTREF2_FRAME as i8), count(ALTREF_FRAME as i8)); - let ctx_p1 = cmp(last + last2 + last3 + gold, bwd + alt2 + alt); - let ctx_comp_ref = cmp(last + last2, last3 + gold); - let ctx_comp_ref_p1 = cmp(last, last2); - self.sw.symbol(0, &mut self.cdf.single_ref[ctx_p1][0]); - self.sw.symbol(0, &mut self.cdf.single_ref[ctx_comp_ref][2]); - self.sw.symbol(0, &mut self.cdf.single_ref[ctx_comp_ref_p1][3]); - self.find_mv_stack(); - let mv = b.mv; - let s = &self.mvs; - let mode = if mv == [0, 0] { - GLOBALMV - } else if mv == s.ref_stack_mv[0] { - NEARESTMV - } else if mv == s.ref_stack_mv[1] { - NEARMV - } else { - NEWMV - }; - let (nctx, zctx, rctx) = (s.new_mv_context, s.zero_mv_context, s.ref_mv_context); - self.sw.symbol((mode != NEWMV) as usize, &mut self.cdf.new_mv[nctx]); - if mode != NEWMV { - self.sw.symbol((mode != GLOBALMV) as usize, &mut self.cdf.zero_mv[zctx]); - if mode != GLOBALMV { - self.sw.symbol((mode == NEARMV) as usize, &mut self.cdf.ref_mv[rctx]); - } - } - // DRL: ref_mv_idx 0 for NEWMV, 1 for NEARMV - if mode == NEWMV { - if self.mvs.num_mv_found > 1 { - let ctx = self.mvs.drl_ctx_stack[0]; - self.sw.symbol(0, &mut self.cdf.drl_mode[ctx]); - } - } else if mode == NEARMV && self.mvs.num_mv_found > 2 { - let ctx = self.mvs.drl_ctx_stack[1]; - self.sw.symbol(0, &mut self.cdf.drl_mode[ctx]); - } - if mode == NEWMV { - let pred = self.mvs.ref_stack_mv[0]; - self.write_mv([mv[0] - pred[0], mv[1] - pred[1]]); - } - mode - } - - fn write_mv(&mut self, diff: MvP) { - let joint = ((diff[0] != 0) as usize) << 1 | (diff[1] != 0) as usize; - // MV_JOINT_ZERO, MV_JOINT_HNZVZ (col only), MV_JOINT_HZVNZ (row only), MV_JOINT_HNZVNZ - self.sw.symbol(joint, &mut self.cdf.mv_joint); - for (comp, &d) in diff.iter().enumerate() { - if d != 0 { - self.write_mv_component(comp, d); - } - } - } - - fn write_mv_component(&mut self, comp: usize, v: i32) { - debug_assert!(v % 2 == 0, "quarter-sample precision"); - self.sw.symbol((v < 0) as usize, &mut self.cdf.mv_sign[comp]); - let m = v.unsigned_abs() - 1; // odd: the implied hp bit is 1 - let fr = ((m >> 1) & 3) as usize; - let int = m >> 3; - if int < CLASS0_SIZE as u32 { - self.sw.symbol(MV_CLASS_0, &mut self.cdf.mv_class[comp]); - self.sw.symbol(int as usize, &mut self.cdf.mv_class0_bit[comp]); - self.sw.symbol(fr, &mut self.cdf.mv_class0_fr[comp][int as usize]); - } else { - let class = 31 - int.leading_zeros(); - let d = int - (1 << class); - self.sw.symbol(class as usize, &mut self.cdf.mv_class[comp]); - for i in 0..class as usize { - self.sw.symbol(((d >> i) & 1) as usize, &mut self.cdf.mv_bit[comp][i]); - } - self.sw.symbol(fr, &mut self.cdf.mv_fr[comp]); - } - } - - // ---- coefficients ---------------------------------------------------------------------- - - fn coeffs(&mut self, plane: usize, start_x: usize, start_y: usize, tx_sz: usize, b: &Leaf, levels: &[i32]) { - let x4 = start_x >> 2; - let y4 = start_y >> 2; - let w4 = TX_WIDTH[tx_sz] as usize >> 2; - let h4 = TX_HEIGHT[tx_sz] as usize >> 2; - let tx_sz_ctx = (TX_SIZE_SQR[tx_sz] as usize + TX_SIZE_SQR_UP[tx_sz] as usize + 1) >> 1; - let ptype = (plane > 0) as usize; - let scan = scan_for(tx_sz); - let mut eob = 0; - for (i, &pos) in scan.iter().enumerate() { - if levels[pos as usize] != 0 { - eob = i + 1; - } - } - let ctx = self.all_zero_ctx(plane, tx_sz, x4, y4, w4, h4); - self.sw.symbol((eob == 0) as usize, &mut self.cdf.txb_skip[tx_sz_ctx][ctx]); - let mut cul_level = 0u32; - let mut dc_category = 0u8; - if eob == 0 { - if plane == 0 { - self.set_tx_types(x4, y4, tx_sz, DCT_DCT); - } - } else { - if plane == 0 { - self.write_transform_type(x4, y4, tx_sz, b); - } - let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b); - debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST)); - let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4; - let eob_pt = if eob < 2 { eob } else { (32 - ((eob - 1) as u32).leading_zeros()) as usize + 1 }; - let ectx = 0; // TX_CLASS_2D - let s = eob_pt - 1; - match eob_multisize { - 0 => self.sw.symbol(s, &mut self.cdf.eob_pt_16[ptype][ectx]), - 1 => self.sw.symbol(s, &mut self.cdf.eob_pt_32[ptype][ectx]), - 2 => self.sw.symbol(s, &mut self.cdf.eob_pt_64[ptype][ectx]), - 3 => self.sw.symbol(s, &mut self.cdf.eob_pt_128[ptype][ectx]), - 4 => self.sw.symbol(s, &mut self.cdf.eob_pt_256[ptype][ectx]), - 5 => self.sw.symbol(s, &mut self.cdf.eob_pt_512[ptype]), - _ => self.sw.symbol(s, &mut self.cdf.eob_pt_1024[ptype]), - } - if eob_pt >= 3 { - let eob_shift = eob_pt - 3; - let offset = eob - ((1 << (eob_pt - 2)) + 1); - self.sw.symbol((offset >> eob_shift) & 1, &mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]); - for i in 1..eob_pt - 2 { - let sh = eob_pt - 2 - 1 - i; - self.sw.bool((offset >> sh) & 1 == 1); - } - } - let bwl = TX_WIDTH_LOG2[tx_sz] as usize; - let height = TX_HEIGHT[tx_sz] as usize; - let width = 1usize << bwl; - // Quant[] as the decoder sees it while parsing: the levels coded so far (capped) - let mut quant = std::mem::take(&mut self.quant); - quant.clear(); - quant.resize(width * height, 0); - for c in (0..eob).rev() { - let pos = scan[c] as usize; - let a = levels[pos].unsigned_abs().min(15) as i32; - if c == eob - 1 { - let ctx = coeff_base_eob_ctx(c, bwl, height); - self.sw.symbol((a.min(3) - 1) as usize, &mut self.cdf.coeff_base_eob[tx_sz_ctx][ptype][ctx]); - } else { - let ctx = coeff_base_ctx(&quant, tx_sz, bwl, width, height, pos); - self.sw.symbol(a.min(3) as usize, &mut self.cdf.coeff_base[tx_sz_ctx][ptype][ctx]); - } - if a > NUM_BASE_LEVELS as i32 { - let ctx = coeff_br_ctx(&quant, bwl, height, pos); - let mut rem = a - 3; - for _ in 0..(COEFF_BASE_RANGE / (BR_CDF_SIZE - 1)) { - let br = rem.min(3); - self.sw.symbol(br as usize, &mut self.cdf.coeff_br[tx_sz_ctx.min(TX_32X32)][ptype][ctx]); - rem -= br; - if br < 3 { - break; - } - } - } - quant[pos] = a; - } - self.quant = quant; - for c in 0..eob { - let pos = scan[c] as usize; - let v = levels[pos]; - if v != 0 { - if c == 0 { - let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4); - self.sw.symbol((v < 0) as usize, &mut self.cdf.dc_sign[ptype][ctx]); - } else { - self.sw.bool(v < 0); - } - } - let a = v.unsigned_abs(); - if a > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as u32 { - // Golomb code of a - 15 - let x = a - 15 + 1; - let length = 32 - x.leading_zeros(); - for _ in 0..length - 1 { - self.sw.bool(false); - } - self.sw.bool(true); - for i in (0..length - 1).rev() { - self.sw.bool((x >> i) & 1 == 1); - } - } - if pos == 0 && a > 0 { - dc_category = if v < 0 { 1 } else { 2 }; - } - cul_level += a & 0xFFFFF; - } - cul_level = cul_level.min(63); - } - for i in 0..w4 { - if x4 + i < self.above_level[plane].len() { - self.above_level[plane][x4 + i] = cul_level as u8; - self.above_dc[plane][x4 + i] = dc_category; - } - } - for i in 0..h4 { - if y4 + i < self.left_level[plane].len() { - self.left_level[plane][y4 + i] = cul_level as u8; - self.left_dc[plane][y4 + i] = dc_category; - } - } - } - - fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) { - // transform_type( ) - let set = tx_set(tx_sz, b.is_inter); - let t = b.tx_type_y; - if set > 0 && self.qidx > 0 { - let sqr = TX_SIZE_SQR[tx_sz] as usize; - if b.is_inter { - // The decision only picks types of this set (DCT_DCT otherwise). - let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]); - } else { - let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]); - } - } else { - assert_eq!(t, DCT_DCT); - } - self.set_tx_types(x4, y4, tx_sz, t); - } - - fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) { - for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) { - for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) { - if y4 + j < self.g.mi_rows && x4 + i < self.g.mi_cols { - let k = self.mi.idx(y4 + j, x4 + i); - self.mi.tx_type[k] = t as u8; - } - } - } - } - - fn compute_tx_type(&self, plane: usize, tx_sz: usize, x4: usize, y4: usize, b: &Leaf) -> usize { - if TX_SIZE_SQR_UP[tx_sz] as usize > TX_32X32 { - return DCT_DCT; - } - if plane == 0 { - return self.mi.tx_type[self.mi.idx(y4, x4)] as usize; - } - if b.is_inter { - let set = tx_set(tx_sz, true); - let xx = self.c.max(x4 << 1); - let yy = self.r.max(y4 << 1); - let t = self.mi.tx_type[self.mi.idx(yy, xx)] as usize; - return if tx_in_set(set, true, t) { t } else { DCT_DCT }; - } - intra_uv_tx_type(b.uv_mode, tx_sz) - } - - fn all_zero_ctx(&self, plane: usize, tx_sz: usize, x4: usize, y4: usize, w4: usize, h4: usize) -> usize { - let s = (plane > 0) as usize; - let max_x4 = self.g.mi_cols >> s; - let max_y4 = self.g.mi_rows >> s; - let w = TX_WIDTH[tx_sz] as usize; - let h = TX_HEIGHT[tx_sz] as usize; - let bsize = SUBSAMPLED_SIZE[self.bsize][s][s] as usize; - let bw = block_size_px(bsize); - let bh = 4 * NUM_4X4_BLOCKS_HIGH[bsize] as usize; - let al = &self.above_level[plane]; - let ll = &self.left_level[plane]; - if plane == 0 { - let mut top = 0u32; - let mut left = 0u32; - for k in 0..w4 { - if x4 + k < max_x4 { - top = top.max(al[x4 + k] as u32); - } - } - for k in 0..h4 { - if y4 + k < max_y4 { - left = left.max(ll[y4 + k] as u32); - } - } - top = top.min(255); - left = left.min(255); - if bw == w && bh == h { - 0 - } else if top == 0 && left == 0 { - 1 - } else if top == 0 || left == 0 { - 2 + (top.max(left) > 3) as usize - } else if top.max(left) <= 3 { - 4 - } else if top.min(left) <= 3 { - 5 - } else { - 6 - } - } else { - let ad = &self.above_dc[plane]; - let ld = &self.left_dc[plane]; - let mut above = 0u8; - let mut left = 0u8; - for i in 0..w4 { - if x4 + i < max_x4 { - above |= al[x4 + i] | ad[x4 + i]; - } - } - for i in 0..h4 { - if y4 + i < max_y4 { - left |= ll[y4 + i] | ld[y4 + i]; - } - } - let mut ctx = (above != 0) as usize + (left != 0) as usize + 7; - if bw * bh > w * h { - ctx += 3; - } - ctx - } - } - - fn dc_sign_ctx(&self, plane: usize, x4: usize, y4: usize, w4: usize, h4: usize) -> usize { - let s = (plane > 0) as usize; - let max_x4 = self.g.mi_cols >> s; - let max_y4 = self.g.mi_rows >> s; - let mut dc_sign = 0i32; - for k in 0..w4 { - if x4 + k < max_x4 { - match self.above_dc[plane][x4 + k] { - 1 => dc_sign -= 1, - 2 => dc_sign += 1, - _ => {} - } - } - } - for k in 0..h4 { - if y4 + k < max_y4 { - match self.left_dc[plane][y4 + k] { - 1 => dc_sign -= 1, - 2 => dc_sign += 1, - _ => {} - } - } - } - match dc_sign.signum() { - -1 => 1, - 1 => 2, - _ => 0, - } - } - - // ---- motion vector prediction (7.10.2), single reference LAST_FRAME -------------------- - - fn inside(&self, r: isize, c: isize) -> bool { - c >= 0 && c < self.g.mi_cols as isize && r >= 0 && r < self.g.mi_rows as isize - } - - fn find_mv_stack(&mut self) { - let n4 = NUM_4X4_BLOCKS_WIDE[self.bsize] as isize; - let (bw4, bh4) = (n4, n4); - self.mvs = MvStack::default(); - self.scan_row(-1); - let mut found_above = self.mvs.found_match; - self.mvs.found_match = false; - self.scan_col(-1); - let mut found_left = self.mvs.found_match; - self.mvs.found_match = false; - if bw4.max(bh4) <= 16 { - self.scan_point(-1, bw4); - } - if self.mvs.found_match { - found_above = true; - } - self.mvs.close_matches = found_above as usize + found_left as usize; - let num_nearest = self.mvs.num_mv_found; - let num_new = self.mvs.new_mv_count; - for idx in 0..num_nearest { - self.mvs.weight_stack[idx] += REF_CAT_LEVEL as u32; - } - self.mvs.zero_mv_context = 0; - // use_ref_frame_mvs = 0: no temporal candidates - self.scan_point(-1, -1); - if self.mvs.found_match { - found_above = true; - } - self.mvs.found_match = false; - self.scan_row(-3); - if self.mvs.found_match { - found_above = true; - } - self.mvs.found_match = false; - self.scan_col(-3); - if self.mvs.found_match { - found_left = true; - } - self.mvs.found_match = false; - if bh4 > 1 { - self.scan_row(-5); - } - if self.mvs.found_match { - found_above = true; - } - self.mvs.found_match = false; - if bw4 > 1 { - self.scan_col(-5); - } - if self.mvs.found_match { - found_left = true; - } - self.mvs.total_matches = found_above as usize + found_left as usize; - let n = self.mvs.num_mv_found; - self.sort_stack(0, num_nearest); - self.sort_stack(num_nearest, n); - if self.mvs.num_mv_found < 2 { - self.extra_search(); - } - self.context_and_clamping(num_new); - } - - fn scan_row(&mut self, delta_row: isize) { - let bw4 = NUM_4X4_BLOCKS_WIDE[self.bsize] as isize; - let (mi_row, mi_col) = (self.r as isize, self.c as isize); - let end4 = bw4.min(self.g.mi_cols as isize - mi_col).min(16); - let mut delta_col = 0; - let use_step16 = bw4 >= 16; - let mut delta_row = delta_row; - if delta_row.abs() > 1 { - delta_row += mi_row & 1; - delta_col = 1 - (mi_col & 1); - } - let mut i = 0; - while i < end4 { - let mv_row = mi_row + delta_row; - let mv_col = mi_col + delta_col + i; - if !self.inside(mv_row, mv_col) { - break; - } - let k = self.mi.idx(mv_row as usize, mv_col as usize); - let mut len = bw4.min(NUM_4X4_BLOCKS_WIDE[self.mi.mi_size[k] as usize] as isize); - if delta_row.abs() > 1 { - len = len.max(2); - } - if use_step16 { - len = len.max(4); - } - self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, len as u32 * 2); - i += len; - } - } - - fn scan_col(&mut self, delta_col: isize) { - let bh4 = NUM_4X4_BLOCKS_HIGH[self.bsize] as isize; - let (mi_row, mi_col) = (self.r as isize, self.c as isize); - let end4 = bh4.min(self.g.mi_rows as isize - mi_row).min(16); - let mut delta_row = 0; - let use_step16 = bh4 >= 16; - let mut delta_col = delta_col; - if delta_col.abs() > 1 { - delta_row = 1 - (mi_row & 1); - delta_col += mi_col & 1; - } - let mut i = 0; - while i < end4 { - let mv_row = mi_row + delta_row + i; - let mv_col = mi_col + delta_col; - if !self.inside(mv_row, mv_col) { - break; - } - let k = self.mi.idx(mv_row as usize, mv_col as usize); - let mut len = bh4.min(NUM_4X4_BLOCKS_HIGH[self.mi.mi_size[k] as usize] as isize); - if delta_col.abs() > 1 { - len = len.max(2); - } - if use_step16 { - len = len.max(4); - } - self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, len as u32 * 2); - i += len; - } - } - - fn scan_point(&mut self, delta_row: isize, delta_col: isize) { - let mv_row = self.r as isize + delta_row; - let mv_col = self.c as isize + delta_col; - if self.inside(mv_row, mv_col) && self.mi.written[self.mi.idx(mv_row as usize, mv_col as usize)] { - self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, 4); - } - } - - fn add_ref_mv_candidate(&mut self, mv_row: usize, mv_col: usize, weight: u32) { - let i = self.mi.idx(mv_row, mv_col); - if !self.mi.is_inter[i] { - return; - } - let rf = self.mi.ref_frame[i]; - for cand_list in 0..2 { - if rf[cand_list] == LAST_FRAME as i8 { - self.search_stack(i, cand_list, weight); - } - } - } - - fn search_stack(&mut self, i: usize, cand_list: usize, weight: u32) { - debug_assert_eq!(cand_list, 0); - let cand_mode = self.mi.y_mode[i] as usize; - // gm_type is IDENTITY: the stored motion vector is used - let mut cand = self.mi.mv[i]; - lower_mv_precision(&mut cand); - if cand_mode == NEWMV { - self.mvs.new_mv_count += 1; - } - self.mvs.found_match = true; - let n = self.mvs.num_mv_found; - if let Some(idx) = (0..n).find(|&k| self.mvs.ref_stack_mv[k] == cand) { - self.mvs.weight_stack[idx] += weight; - } else if n < MAX_REF_MV_STACK_SIZE { - self.mvs.ref_stack_mv[n] = cand; - self.mvs.weight_stack[n] = weight; - self.mvs.num_mv_found += 1; - } - } - - fn sort_stack(&mut self, start: usize, end: usize) { - let mut end = end; - let s = &mut self.mvs; - while end > start { - let mut new_end = start; - for idx in start + 1..end { - if s.weight_stack[idx - 1] < s.weight_stack[idx] { - s.weight_stack.swap(idx - 1, idx); - s.ref_stack_mv.swap(idx - 1, idx); - new_end = idx; - } - } - end = new_end; - } - } - - fn extra_search(&mut self) { - let (mi_row, mi_col) = (self.r as isize, self.c as isize); - let n4 = NUM_4X4_BLOCKS_WIDE[self.bsize] as isize; - let w4 = n4.min(16).min(self.g.mi_cols as isize - mi_col); - let h4 = n4.min(16).min(self.g.mi_rows as isize - mi_row); - let num4x4 = w4.min(h4); - for pass in 0..2 { - let mut idx = 0; - while idx < num4x4 && self.mvs.num_mv_found < 2 { - let (mv_row, mv_col) = if pass == 0 { (mi_row - 1, mi_col + idx) } else { (mi_row + idx, mi_col - 1) }; - if !self.inside(mv_row, mv_col) { - break; - } - let k = self.mi.idx(mv_row as usize, mv_col as usize); - // add_extra_mv_candidate( ) (no sign bias: a single forward reference) - let rfs = self.mi.ref_frame[k]; - for &cand_ref in rfs.iter().take(2) { - if cand_ref > INTRA_FRAME as i8 { - let cand = self.mi.mv[k]; - let n = self.mvs.num_mv_found; - if !(0..n).any(|j| self.mvs.ref_stack_mv[j] == cand) { - self.mvs.ref_stack_mv[n] = cand; - self.mvs.weight_stack[n] = 2; - self.mvs.num_mv_found += 1; - } - } - } - let sz = self.mi.mi_size[k] as usize; - idx += if pass == 0 { NUM_4X4_BLOCKS_WIDE[sz] } else { NUM_4X4_BLOCKS_HIGH[sz] } as isize; - } - } - for idx in self.mvs.num_mv_found..2 { - self.mvs.ref_stack_mv[idx] = [0, 0]; // GlobalMvs[ 0 ] - } - } - - fn context_and_clamping(&mut self, num_new: usize) { - let bs = block_size_px(self.bsize) as i32; - let n = self.mvs.num_mv_found; - for idx in 0..n { - let mut z = 0; - if idx + 1 < n { - let w0 = self.mvs.weight_stack[idx]; - let w1 = self.mvs.weight_stack[idx + 1]; - if w0 >= REF_CAT_LEVEL as u32 { - if w1 < REF_CAT_LEVEL as u32 { - z = 1; - } - } else { - z = 2; - } - } - self.mvs.drl_ctx_stack[idx] = z; - } - let n4 = NUM_4X4_BLOCKS_WIDE[self.bsize] as i32; - for idx in 0..n { - let mut mv = self.mvs.ref_stack_mv[idx]; - let border_r = MV_BORDER as i32 + bs * 8; - let to_top = -((self.r as i32 * 4) * 8); - let to_bottom = ((self.g.mi_rows as i32 - n4 - self.r as i32) * 4) * 8; - mv[0] = mv[0].clamp(to_top - border_r, to_bottom + border_r); - let to_left = -((self.c as i32 * 4) * 8); - let to_right = ((self.g.mi_cols as i32 - n4 - self.c as i32) * 4) * 8; - mv[1] = mv[1].clamp(to_left - border_r, to_right + border_r); - self.mvs.ref_stack_mv[idx] = mv; - } - let (close, total) = (self.mvs.close_matches, self.mvs.total_matches); - if close == 0 { - self.mvs.new_mv_context = total.min(1); - self.mvs.ref_mv_context = total; - } else if close == 1 { - self.mvs.new_mv_context = 3 - num_new.min(1); - self.mvs.ref_mv_context = 2 + total; - } else { - self.mvs.new_mv_context = 5 - num_new.min(1); - self.mvs.ref_mv_context = 5; - } - } -} - -/// lower_mv_precision( ) with allow_high_precision_mv = 0 and force_integer_mv = 0. -fn lower_mv_precision(mv: &mut MvP) { - for c in mv.iter_mut() { - if *c & 1 != 0 { - *c += if *c > 0 { -1 } else { 1 }; - } - } -} - -fn coeff_base_eob_ctx(c: usize, bwl: usize, height: usize) -> usize { - if c == 0 { - 0 - } else if c <= (height << bwl) / 8 { - 1 - } else if c <= (height << bwl) / 4 { - 2 - } else { - 3 - } -} - -/// Context of coeff_base for TX_CLASS_2D (8.3.2). -fn coeff_base_ctx(quant: &[i32], tx_sz: usize, bwl: usize, width: usize, height: usize, pos: usize) -> usize { - let row = pos >> bwl; - let col = pos - (row << bwl); - let mut mag = 0i32; - for off in SIG_REF_DIFF_OFFSET[TX_CLASS_2D].iter() { - let ref_row = row + off[0] as usize; - let ref_col = col + off[1] as usize; - if ref_row < height && ref_col < width { - mag += quant[(ref_row << bwl) + ref_col].abs().min(3); - } - } - let ctx = ((mag + 1) >> 1).min(4) as usize; - if row == 0 && col == 0 { - return 0; - } - ctx + COEFF_BASE_CTX_OFFSET[tx_sz][row.min(4)][col.min(4)] as usize -} - -/// Context of coeff_br for TX_CLASS_2D. -fn coeff_br_ctx(quant: &[i32], bwl: usize, txh: usize, pos: usize) -> usize { - let txw = 1usize << bwl; - let row = pos >> bwl; - let col = pos - (row << bwl); - let mut mag = 0i32; - for off in MAG_REF_OFFSET_WITH_TX_CLASS[TX_CLASS_2D].iter() { - let ref_row = row + off[0] as usize; - let ref_col = col + off[1] as usize; - if ref_row < txh && ref_col < txw { - mag += quant[ref_row * txw + ref_col].min((COEFF_BASE_RANGE + NUM_BASE_LEVELS + 1) as i32); - } - } - let mag = ((mag + 1) >> 1).min(6) as usize; - if pos == 0 { - mag - } else if row < 2 && col < 2 { - mag + 7 - } else { - mag + 14 - } -} diff --git a/crates/av1enc/tests/it/main.rs b/crates/av1enc/tests/it/main.rs deleted file mode 100644 index c66bacde5..000000000 --- a/crates/av1enc/tests/it/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! The crate's integration tests, one binary (each module is a former `tests/.rs`). - -mod bench; -mod common; -mod ffmpeg_oracle; -mod roundtrip; diff --git a/crates/color/src/icc.rs b/crates/color/src/icc.rs index 184c94c28..e35cbafc7 100644 --- a/crates/color/src/icc.rs +++ b/crates/color/src/icc.rs @@ -325,6 +325,9 @@ pub struct Clut { pub data: Vec, } +/// Most interpolating inputs [`Clut::eval`] combines (ICC allows 15). +pub const MAX_CLUT_INPUTS: usize = 16; + impl Clut { /// Multilinear interpolation at `x` (0..1 per input). pub fn eval(&self, x: &[f64]) -> Vec { @@ -334,26 +337,40 @@ impl Clut { let mut s = self.outputs; for i in (0..n).rev() { stride[i] = s; - s *= self.grid[i].max(1); + // Saturating: a table with enough axes to overflow cannot hold its data anyway, and a + // saturated offset reads nothing (zero) instead of panicking. + s = s.saturating_mul(self.grid[i].max(1)); } let mut frac = vec![0.0; n]; let mut step = vec![0usize; n]; for i in 0..n { let g = self.grid[i].max(1); let f = x.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0) * (g - 1) as f64; - let k = (f.floor() as usize).min(g.saturating_sub(2)); + let mut k = (f.floor() as usize).min(g.saturating_sub(2)); frac[i] = if g > 1 { f - k as f64 } else { 0.0 }; step[i] = if g > 1 { stride[i] } else { 0 }; - base += k * stride[i]; + // The last grid point is a corner of its own, not an interpolation toward the next. + if frac[i] >= 1.0 { + k += 1; + frac[i] = 0.0; + } + base = base.saturating_add(k.saturating_mul(stride[i])); } let mut out = vec![0.0; self.outputs]; - for corner in 0..(1usize << n) { + // Only the axes that interpolate have two corners: a singleton axis, or one sampled on a + // grid point, contributes its lower corner alone. ICC tables have at most 15 inputs; a + // table built with more interpolating axes than that evaluates to zero. + let active: Vec = (0..n).filter(|&i| step[i] != 0 && frac[i] != 0.0).collect(); + if active.len() > MAX_CLUT_INPUTS { + return out; + } + for corner in 0..(1usize << active.len()) { let mut w = 1.0; let mut off = base; - for i in 0..n { - if corner >> i & 1 == 1 { + for (bit, &i) in active.iter().enumerate() { + if corner >> bit & 1 == 1 { w *= frac[i]; - off += step[i]; + off = off.saturating_add(step[i]); } else { w *= 1.0 - frac[i]; } @@ -362,7 +379,7 @@ impl Clut { continue; } for (o, v) in out.iter_mut().enumerate() { - *v += w * self.data.get(off + o).copied().unwrap_or(0.0) as f64; + *v += w * self.data.get(off.saturating_add(o)).copied().unwrap_or(0.0) as f64; } } out @@ -1052,6 +1069,31 @@ mod tests { assert!(matches!(parse_profile(&write_matrix_profile("m", P709, D65, 2.2)).unwrap(), Profile::Matrix(_))); } + #[test] + fn clut_corners_count_only_interpolating_axes() { + // 64 singleton axes and one interpolating one: two corners, not 2^65. + let mut grid = vec![1; 64]; + grid.push(2); + let c = Clut { grid, outputs: 1, data: vec![0.0, 1.0] }; + let mut x = vec![0.5; 64]; + x.push(0.25); + assert!((c.eval(&x)[0] - 0.25).abs() < 1e-9); + // On grid points nothing interpolates. + let c = Clut { grid: vec![2; 40], outputs: 1, data: vec![] }; + assert_eq!(c.eval(&[1.0; 40]), vec![0.0]); + // More interpolating axes than ICC allows: zero, at once. + assert_eq!(c.eval(&[0.5; 40]), vec![0.0]); + // The last grid point of every axis is that table entry, however many axes there are. + let mut data = vec![0.0; 1 << 17]; + data[(1 << 17) - 1] = 0.75; + let c = Clut { grid: vec![2; 17], outputs: 1, data }; + assert!((c.eval(&[1.0; 17])[0] - 0.75).abs() < 1e-9); + // Strides that overflow usize (64 two-point axes) read nothing instead of panicking. + let c = Clut { grid: vec![2; 64], outputs: 1, data: vec![] }; + assert_eq!(c.eval(&[1.0; 64]), vec![0.0]); + assert_eq!(c.eval(&[0.5; 64]), vec![0.0]); + } + #[test] fn clut_and_lut3d_interpolate() { // A 2-point identity CLUT on 3 inputs is exact for every point. diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 880ad16c5..31ef8be42 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -228,8 +228,21 @@ fn compound_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { // --------------------------------------------------------------------------------------------- // Camera Lens Blur -/// Half-open horizontal spans (dy, x_left, x_right) of a polygonal / round iris of radius `r`. +/// Largest Camera Lens Blur radius in buffer pixels. Blur Radius goes to 500 in the UI; a project +/// or expression value far beyond it would otherwise build an iris table of one row per pixel of +/// radius (and sum that many rows per output pixel) before any image size check. +pub const CAMERA_LENS_MAX_RADIUS: f64 = 2000.0; + +/// Camera Lens Blur's iris radius in buffer pixels: Blur Radius at the buffer's `scale`, capped +/// at [`CAMERA_LENS_MAX_RADIUS`]. Shared by the CPU and GPU paths. +pub fn camera_lens_radius(ctx: &EffectCtx, scale: f64) -> f64 { + (ctx.params.f("blurRadius").max(0.0) * scale).clamp(0.0, CAMERA_LENS_MAX_RADIUS) +} + +/// Half-open horizontal spans (dy, x_left, x_right) of a polygonal / round iris of radius `r` +/// (capped at [`CAMERA_LENS_MAX_RADIUS`]). fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64, i64, i64)> { + let r = if r.is_nan() { 0.0 } else { r.clamp(0.0, CAMERA_LENS_MAX_RADIUS) }; let ri = r.ceil() as i64; let rot = rotation_deg.to_radians(); let verts: Vec<(f64, f64)> = (0..sides) @@ -292,7 +305,8 @@ pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) s.2 = (s.2 as f64 * aspect).round() as i64; } } - (spans, r * aspect.max(1.0)) + // Same cap as iris_spans, so the reach matches the spans for unbounded radii. + (spans, r.clamp(0.0, CAMERA_LENS_MAX_RADIUS) * aspect.max(1.0)) } /// Whether Camera Lens Blur needs more than plain iris spans (Diffraction Fringe or a Blur @@ -302,7 +316,7 @@ pub fn camera_lens_plain(ctx: &EffectCtx) -> bool { } fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + let r = camera_lens_radius(ctx, b.scale); if r < 0.5 { return b; } @@ -910,6 +924,19 @@ mod tests { assert!(out.img.get(15 + o, 10 + d)[0] < 0.9, "bright map blurs"); } + #[test] + fn iris_spans_cap_a_huge_radius() { + // A project's Blur Radius far beyond the UI's 500 builds a bounded table. + let max_rows = 2 * CAMERA_LENS_MAX_RADIUS as usize + 1; + assert!(iris_spans(1e12, 6, 0.0, 0.0).len() <= max_rows); + assert!(iris_spans(f64::INFINITY, 0, 0.0, 0.0).len() <= max_rows); + assert_eq!(iris_spans(f64::NAN, 0, 0.0, 0.0), vec![(0, 0, 0)]); + let params = Params { values: [("blurRadius".into(), num(1e15))].into_iter().collect() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [4.0, 4.0], seed: 1, adjustment: false, env: Default::default() }; + assert_eq!(camera_lens_radius(&ctx, 1.0), CAMERA_LENS_MAX_RADIUS); + assert!(camera_lens_spans(&ctx, camera_lens_radius(&ctx, 1.0)).0.len() <= max_rows); + } + #[test] fn iris_spans_hexagon_is_symmetric() { let s = iris_spans(10.0, 6, 0.0, 0.0); diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 6639e649e..9df44d76f 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -252,10 +252,18 @@ impl Lv { if clip_w { t = t.min(1.0); } - self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01)) + let curve = crate::color_fx::levels_gamma(t, self.g.max(0.01)).clamp(-LEVELS_IC_CURVE_LIMIT, LEVELS_IC_CURVE_LIMIT); + self.ob + (self.ow - self.ob) * curve } } +/// How far Levels (Individual Controls)' curve may reach: with gamma down to 0 (rendered as 0.01, +/// an exponent of 100) an HDR value a few times Input White overflows `f32`, and an infinite curve +/// times an empty output range is NaN. 1e30 times the widest output range (2e4) is still finite +/// (`f32::MAX` is 3.4e38), and every curve that was finite before stays as it was below it. The +/// GPU (`fxt_lv`) uses the same. +const LEVELS_IC_CURVE_LIMIT: f32 = 1e30; + fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { let lv = LV_IDS.map(|ids| Lv { ib: ctx.params.f(ids[0]) as f32, @@ -978,11 +986,14 @@ fn cc_kernel(ctx: &EffectCtx, mut b: Buf) -> Buf { pub fn specs() -> Vec { let mut lv_params = Vec::new(); for (ids, names) in LV_IDS.iter().zip(LV_NAMES.iter()) { - lv_params.push(p(ids[0], names[0], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); - lv_params.push(p(ids[1], names[1], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); - lv_params.push(p(ids[2], names[2], num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2))); - lv_params.push(p(ids[3], names[3], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); - lv_params.push(p(ids[4], names[4], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); + // After Effects' valid ranges (#3487-#3491): levels -10000..10000 and gamma from 0 (the + // renderer floors it at 0.01); gamma keeps its wider maximum. The sliders stay 0..1. + let level = || slider(-10000.0, 10000.0, 0.0, 1.0, 3); + lv_params.push(p(ids[0], names[0], num(0.0), level())); + lv_params.push(p(ids[1], names[1], num(1.0), level())); + lv_params.push(p(ids[2], names[2], num(1.0), slider(0.0, 10.0, 0.1, 3.0, 2))); + lv_params.push(p(ids[3], names[3], num(0.0), level())); + lv_params.push(p(ids[4], names[4], num(1.0), level())); } lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); diff --git a/crates/effects/src/color2/tests.rs b/crates/effects/src/color2/tests.rs index 5f57692a0..76338dd98 100644 --- a/crates/effects/src/color2/tests.rs +++ b/crates/effects/src/color2/tests.rs @@ -270,6 +270,39 @@ fn levels_ic_clip_popups() { assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5); } +/// #3487-#3491: over After Effects' valid ranges (levels ±10000, gamma from 0) the output stays +/// finite: gamma 0 renders as 0.01, whose curve on an HDR value overflows `f32` unbounded. +#[test] +fn levels_ic_stays_finite_over_the_whole_range() { + let img = Image::filled(2, 2, [3.0, 0.5, -2.0, 1.0]); + for (ib, iw, ob, ow) in [ + (0.0, 1.0, 0.0, 1.0), + (0.0, 1.0, 1.0, 1.0), + (-10000.0, 10000.0, -10000.0, 10000.0), + (10000.0, -10000.0, 10000.0, -10000.0), + (0.0, 1e-3, -10000.0, 10000.0), + ] { + let lv = [ + ("rgb/rgbInBlack", num(ib)), + ("rgb/rgbInWhite", num(iw)), + ("rgb/rgbGamma", num(0.0)), + ("rgb/rgbOutBlack", num(ob)), + ("rgb/rgbOutWhite", num(ow)), + ("clipToOutputBlack", Value::Enum(0)), + ("clipToOutputWhite", Value::Enum(0)), + ]; + let out = run("ec.color.levelsic", &img, &lv); + assert!(out.data.iter().flatten().all(|v| v.is_finite()), "{ib} {iw} {ob} {ow}: {:?}", out.data[0]); + } + // The bound only stops the overflow: an HDR value that was finite before keeps its level + // (gamma 0.1 at 4 x Input White: 4^10). + let bright = run("ec.color.levelsic", &Image::filled(1, 1, [4.0, 4.0, 4.0, 1.0]), &[("rgb/rgbGamma", num(0.1)), ("clipToOutputWhite", Value::Enum(0))]); + assert_eq!(bright.data[0][0], 4.0f32.powf(10.0)); + // Gamma 0 is the limit of a small gamma: below Input White the curve goes to 0. + let dark = run("ec.color.levelsic", &Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]), &[("rgb/rgbGamma", num(0.0))]); + assert!(dark.data[0][0] < 1e-6, "{:?}", dark.data[0]); +} + /// A host whose other layers and other frames are a fixed image. struct ImgHost(Image); impl crate::EffectHost for ImgHost { diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index d0d398100..6548fdbdb 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -809,7 +809,7 @@ pub fn specs() -> Vec { "Change Color", vec![ p("view", "View", Value::Enum(0), popup(&["Corrected Layer", "Color Correction Mask"])), - p("hueTransform", "Hue Transform", num(0.0), slider(-360.0, 360.0, -180.0, 180.0, 1)), + p("hueTransform", "Hue Transform", num(0.0), slider(-1800.0, 1800.0, -180.0, 180.0, 1)), p("lightnessTransform", "Lightness Transform", num(0.0), bipolar()), p("saturationTransform", "Saturation Transform", num(0.0), bipolar()), p("colorToChange", "Color To Change", col(0.8, 0.2, 0.2), ParamUi::Color), diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 610f73f4c..59d4aad01 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -506,8 +506,8 @@ pub fn specs() -> Vec { p("anchor", "Anchor Point", pt(0.5, 0.5), ParamUi::Point), p("position", "Position", pt(0.5, 0.5), ParamUi::Point), p("uniform", "Uniform Scale", Value::Bool(true), ParamUi::Checkbox), - p("scaleHeight", "Scale Height", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)), - p("scaleWidth", "Scale Width", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)), + p("scaleHeight", "Scale Height", num(100.0), slider(-30000.0, 30000.0, 0.0, 600.0, 1)), + p("scaleWidth", "Scale Width", num(100.0), slider(-30000.0, 30000.0, 0.0, 600.0, 1)), p("skew", "Skew", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)), p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle), p("rotation", "Rotation", num(0.0), ParamUi::Angle), diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index bbd8738a8..611cb6dcf 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -743,7 +743,7 @@ pub fn specs() -> Vec { p("evolution", "Evolution", num(0.0), ParamUi::Angle), p("evolutionOptions/cycleEvolution", "Cycle Evolution", Value::Bool(false), ParamUi::Checkbox), p("evolutionOptions/cycle", "Cycle (in Revolutions)", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), - p("evolutionOptions/randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)), + p("evolutionOptions/randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), p("pinning", "Pinning", Value::Enum(1), popup(&TURBULENT_PINNING)), p("resizeLayer", "Resize Layer", Value::Bool(false), ParamUi::Checkbox), ], diff --git a/crates/effects/src/file_cache.rs b/crates/effects/src/file_cache.rs new file mode 100644 index 000000000..88fb75bbd --- /dev/null +++ b/crates/effects/src/file_cache.rs @@ -0,0 +1,436 @@ +//! Caches of what effects read from files named by a parameter (a LUT, an OCIO transform file, an +//! OCIO config), keyed by the parameter text and checked against the files each result was read +//! from (#3493, #3538). +//! +//! A cached result remembers every file its load read, with the file's size and modification time +//! (its stamp) taken before reading, including the files a nested cached load read (a config's +//! `FileTransform` LUTs) and files looked for but missing. Each lookup stamps those files again +//! (one `metadata` call each) and loads again once one moved, so a file edited, created, deleted or +//! repaired on disk is followed. Inline text reads no file and is its own content. An edit that keeps +//! both the size and the modification time can't be told apart. +//! +//! Every file read is also followed by [`check_lut_files`], and [`lut_generation`] moves when one is +//! found changed: the viewers and frame caches drop the frames made before. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError}; +use std::time::SystemTime; + +/// A file's size and modification time when it was read (`None`: missing, or a file that could +/// not be described). A file whose stamp moved is read again. +pub(crate) type FileStamp = Option<(u64, Option)>; + +/// The files a load read and their stamps. +type Files = Vec<(String, FileStamp)>; + +/// At most this many files are followed by [`check_lut_files`]; the least recently read go first. +const MAX_FOLLOWED_FILES: usize = 1024; + +/// One cached result and what it was read from. +struct Entry { + files: Files, + value: V, + used: u64, + /// The result's size estimate and the parameter text, counted against the cache's byte bound. + bytes: usize, +} + +/// Results by parameter text, at most `max_entries` and `max_bytes` of them, the least recently +/// used going first. +pub(crate) struct SourceCache { + map: HashMap>, + clock: u64, + bytes: usize, + max_entries: usize, + max_bytes: usize, +} + +impl SourceCache { + pub(crate) fn new(max_entries: usize, max_bytes: usize) -> Self { + SourceCache { map: HashMap::new(), clock: 0, bytes: 0, max_entries, max_bytes } + } + + fn remove(&mut self, src: &str) { + if let Some(e) = self.map.remove(src) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + + /// Forget the results read from `path`. + fn forget_file(&mut self, path: &str) { + let stale: Vec = self.map.iter().filter(|(_, e)| e.files.iter().any(|(p, _)| p == path)).map(|(k, _)| k.clone()).collect(); + for src in stale { + self.remove(&src); + } + } + + /// Keep within the bounds, always the result for `keep`. + fn evict(&mut self, keep: &str) { + while self.map.len() > self.max_entries || (self.bytes > self.max_bytes && self.map.len() > 1) { + let Some(oldest) = self.map.iter().filter(|(k, _)| k.as_str() != keep).min_by_key(|(_, e)| e.used).map(|(k, _)| k.clone()) else { break }; + self.remove(&oldest); + } + } + + /// The files the result cached for `src` was read from. + pub(crate) fn files(&self, src: &str) -> Option> { + self.map.get(src).map(|e| e.files.iter().map(|(p, _)| p.clone()).collect()) + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.map.len() + } + + #[cfg(test)] + pub(crate) fn contains(&self, src: &str) -> bool { + self.map.contains_key(src) + } + + #[cfg(test)] + pub(crate) fn bytes(&self) -> (usize, usize, usize) { + (self.bytes, self.map.values().map(|e| e.bytes).sum(), self.max_bytes) + } +} + +/// Lock a cache; a panic elsewhere while it was held leaves a usable map. +pub(crate) fn lock(cache: &Mutex>) -> MutexGuard<'_, SourceCache> { + cache.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// A cache that registers with [`check_lut_files`] on first use. +pub(crate) struct Registered { + cache: OnceLock>>, + max_entries: usize, + max_bytes: usize, +} + +impl Registered { + pub(crate) const fn new(max_entries: usize, max_bytes: usize) -> Self { + Registered { cache: OnceLock::new(), max_entries, max_bytes } + } + + pub(crate) fn get(&'static self) -> &'static Mutex> { + self.cache.get_or_init(|| { + registry().push(self); + Mutex::new(SourceCache::new(self.max_entries, self.max_bytes)) + }) + } +} + +/// A cache [`check_lut_files`] tells to forget the results read from a changed file. +trait ForgetFile: Sync { + fn forget_file(&self, path: &str); + /// Add the files the cached results were read from to `out`. + fn held_files(&self, out: &mut HashSet); +} + +impl ForgetFile for Registered { + fn forget_file(&self, path: &str) { + if let Some(c) = self.cache.get() { + lock(c).forget_file(path); + } + } + + fn held_files(&self, out: &mut HashSet) { + if let Some(c) = self.cache.get() { + out.extend(lock(c).map.values().flat_map(|e| e.files.iter().map(|(p, _)| p.clone()))); + } + } +} + +fn registry() -> MutexGuard<'static, Vec<&'static dyn ForgetFile>> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner) +} + +thread_local! { + /// The loads running on this thread, innermost last: the files each read, and the bytes of + /// the cached results it holds (a config holds the LUTs its `FileTransform`s loaded). + static READING: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Note that the running load read (or looked for) `path`, stamped `stamp`. +pub(crate) fn note_file(path: &str, stamp: FileStamp) { + READING.with(|r| { + if let Ok(mut r) = r.try_borrow_mut() + && let Some((top, _)) = r.last_mut() + && !top.iter().any(|(p, _)| p == path) + { + top.push((path.to_string(), stamp)); + } + }); +} + +/// Note that the running load holds a cached result of `bytes`. +fn note_bytes(bytes: usize) { + READING.with(|r| { + if let Ok(mut r) = r.try_borrow_mut() + && let Some((_, held)) = r.last_mut() + { + *held = held.saturating_add(bytes); + } + }); +} + +/// Pops the files of a load when it ends, even by a panic. +struct Reading { + popped: bool, +} + +impl Reading { + fn start() -> Reading { + READING.with(|r| { + if let Ok(mut r) = r.try_borrow_mut() { + r.push((Vec::new(), 0)); + } + }); + Reading { popped: false } + } + + fn pop() -> Option<(Files, usize)> { + READING.with(|r| r.try_borrow_mut().ok().and_then(|mut r| r.pop())) + } + + /// The files the load read, and the bytes of the cached results it holds. + fn finish(mut self) -> (Files, usize) { + self.popped = true; + Self::pop().unwrap_or_default() + } +} + +impl Drop for Reading { + fn drop(&mut self) { + if !self.popped { + let _ = Self::pop(); + } + } +} + +/// The stamp of the file at `path` now: one `metadata` call. +pub(crate) fn file_stamp(path: &str) -> FileStamp { + std::fs::metadata(path).ok().map(|md| (md.len(), md.modified().ok())) +} + +/// Read the text file at `path` for the running load, stamped before reading (a file written +/// after it was stamped has a newer stamp, and is read again next time). +pub(crate) fn read(path: &str) -> std::io::Result { + note_file(path, file_stamp(path)); + std::fs::read_to_string(path) +} + +/// Note that the running load looked for `path` and did not find it: it loads again once the file +/// appears. +pub(crate) fn note_missing(path: &str) { + note_file(path, None); +} + +/// The result cached for `src`, or `load`'s (and its size estimate in bytes), cached. `load` reads +/// files with [`read`] (and notes the ones it looked for with [`note_missing`]); no lock is held +/// while it runs or while the files are stamped. +pub(crate) fn cached(cache: &Mutex>, src: &str, load: impl FnOnce() -> (V, usize)) -> V { + let files = lock(cache).map.get(src).map(|e| e.files.clone()); + let stale = files.is_some(); + if let Some(files) = files + && files.iter().all(|(p, s)| file_stamp(p) == *s) + { + let mut c = lock(cache); + c.clock += 1; + let now = c.clock; + if let Some(e) = c.map.get_mut(src) + && e.files == files + { + e.used = now; + let (value, bytes) = (e.value.clone(), e.bytes); + drop(c); + // (an enclosing load depends on these files too, and holds this result) + for (p, s) in &files { + note_file(p, *s); + } + note_bytes(bytes); + return value; + } + } + let reading = Reading::start(); + let (value, size) = load(); + let (files, held) = reading.finish(); + follow(&files, stale); + for (p, s) in &files { + note_file(p, *s); + } + // (what it holds counts against this cache's bound too: another cache letting go of it + // frees nothing while this result keeps it) + let bytes = size.saturating_add(held).saturating_add(src.len()); + note_bytes(bytes); + let mut c = lock(cache); + c.clock += 1; + let used = c.clock; + c.remove(src); + c.map.insert(src.to_string(), Entry { files, value: value.clone(), used, bytes }); + c.bytes = c.bytes.saturating_add(bytes); + c.evict(src); + value +} + +/// How often a file a cached load read was found changed on disk ([`lut_generation`]). +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// The files followed by [`check_lut_files`]: the stamp each was last read with, and when. +#[derive(Default)] +struct Followed { + files: HashMap, + clock: u64, +} + +fn followed() -> MutexGuard<'static, Followed> { + static FOLLOWED: OnceLock> = OnceLock::new(); + FOLLOWED.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Followed { + /// Note that `files` were read with these stamps; true when one has a different stamp than + /// its last read's: a file changed on disk. + fn read(&mut self, files: &Files) -> bool { + let mut moved = false; + for (path, stamp) in files { + self.clock += 1; + let now = self.clock; + if self.files.insert(path.clone(), (*stamp, now)).is_some_and(|(old, _)| old != *stamp) { + moved = true; + } + } + moved + } + + /// Keep at most [`MAX_FOLLOWED_FILES`], letting go of the least recently read first but never + /// of a file in `held` (a cached result was read from it) or in `files` (just read). + fn trim(&mut self, held: &HashSet, files: &Files) { + while self.files.len() > MAX_FOLLOWED_FILES { + let Some(oldest) = self + .files + .iter() + .filter(|(p, _)| !held.contains(p.as_str()) && !files.iter().any(|(f, _)| f == *p)) + .min_by_key(|(_, (_, used))| *used) + .map(|(p, _)| p.clone()) + else { + break; + }; + self.files.remove(&oldest); + } + } +} + +/// The files the registered caches' results were read from. +fn held_files() -> HashSet { + let caches: Vec<&'static dyn ForgetFile> = registry().clone(); + let mut out = HashSet::new(); + for c in caches { + c.held_files(&mut out); + } + out +} + +/// Note that `files` were read with these stamps. A file read with a different stamp than last +/// time, or a load replacing a result whose files moved (`stale`), means a file changed on disk, +/// and frames made before may show the old contents. +fn follow(files: &Files, stale: bool) { + let (moved, over) = { + let mut f = followed(); + (f.read(files), f.files.len() > MAX_FOLLOWED_FILES) + }; + if over { + // (gathered without the followed files' lock: no two of these locks are ever nested) + let held = held_files(); + followed().trim(&held, files); + } + if moved || stale { + GENERATION.fetch_add(1, Ordering::AcqRel); + } +} + +/// Changes each time a file a cached load read is found changed on disk (by a load itself or +/// [`check_lut_files`]): frames made before then may show the old contents. +pub fn lut_generation() -> u64 { + GENERATION.load(Ordering::Acquire) +} + +/// Look at every file read (one `metadata` call each) and forget the results read from the ones +/// that changed on disk since, so the next load reads them again. Returns [`lut_generation`]. +pub fn check_lut_files() -> u64 { + let files: Vec<(String, FileStamp)> = followed().files.iter().map(|(p, (s, _))| (p.clone(), *s)).collect(); + // (stamped without the lock: a file on a slow share must not hold up renders) + let changed: Vec<(String, FileStamp, FileStamp)> = + files.into_iter().filter_map(|(path, old)| Some(file_stamp(&path)).filter(|now| *now != old).map(|now| (path, old, now))).collect(); + let caches: Vec<&'static dyn ForgetFile> = registry().clone(); + for (path, old, now) in changed { + { + let mut f = followed(); + // (a render that read the file meanwhile has noted its new stamp already) + match f.files.get_mut(&path) { + Some((s, _)) if *s == old => *s = now, + _ => continue, + } + } + for c in &caches { + c.forget_file(&path); + } + GENERATION.fetch_add(1, Ordering::AcqRel); + } + lut_generation() +} + +#[cfg(test)] +pub(crate) fn followed_stamp(path: &str) -> Option { + followed().files.get(path).map(|(s, _)| *s) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Past [`MAX_FOLLOWED_FILES`], the file just read is still followed (a list of its own: the + /// shared one's files belong to the other tests too). + #[test] + fn the_file_just_read_stays_followed_past_the_limit() { + let mut f = Followed::default(); + let held: HashSet = ["/followed/0.cube".to_string()].into(); + for i in 0..MAX_FOLLOWED_FILES + 8 { + let files = vec![(format!("/followed/{i}.cube"), Some((1, None)))]; + assert!(!f.read(&files)); + f.trim(&held, &files); + assert!(f.files.contains_key(&files[0].0), "{i}: the file just read"); + assert!(f.files.len() <= MAX_FOLLOWED_FILES); + } + assert!(f.files.contains_key("/followed/0.cube"), "a file a cached result was read from"); + assert!(!f.files.contains_key("/followed/1.cube"), "the least recently read other"); + assert!(f.read(&vec![("/followed/1030.cube".into(), Some((2, None)))]), "a moved stamp"); + } + + /// A nested cached load's files count for the enclosing load. + #[test] + fn an_enclosing_load_depends_on_a_nested_loads_files() { + static INNER: Registered = Registered::new(8, 1 << 20); + static OUTER: Registered = Registered::new(8, 1 << 20); + let v = cached(OUTER.get(), "outer", || { + note_missing("/nested/outer-own.cube"); + let inner = cached(INNER.get(), "inner", || { + note_missing("/nested/inner.cube"); + (7, 100) + }); + (inner, 0) + }); + assert_eq!(v, 7); + let files = lock(OUTER.get()).files("outer").unwrap(); + let held = |src: &str| lock(OUTER.get()).map.get(src).map(|e| e.bytes); + // ("inner" is 5 bytes, its result 100: "outer" holds it, on a load and on a hit alike) + assert_eq!(held("outer"), Some(100 + 5 + "outer".len()), "the result an enclosing load holds counts"); + assert_eq!(files, vec!["/nested/outer-own.cube".to_string(), "/nested/inner.cube".to_string()]); + // A hit on the inner result still reports its files to a new enclosing load. + let w = cached(OUTER.get(), "outer 2", || (cached(INNER.get(), "inner", || (0, 0)), 0)); + assert_eq!(w, 7); + assert_eq!(lock(OUTER.get()).files("outer 2").unwrap(), vec!["/nested/inner.cube".to_string()]); + assert_eq!(held("outer 2"), Some(100 + 5 + "outer 2".len())); + } +} diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index ae0d61861..d8d47a7c7 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -121,11 +121,17 @@ pub fn four_color_softening(blend: f64, scale: f64) -> f64 { (b * b).max(1e-6) } +/// 4-Color Gradient's Jitter (percent, up to After Effects' 500 %) as the dither's strength: 1 at +/// 100 %, a few code values of noise per unit. +pub fn four_color_jitter(jitter: f64) -> f32 { + (jitter / 100.0).clamp(0.0, 5.0) as f32 +} + fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect(); let k = four_color_softening(ctx.params.f("blend"), b.scale); - let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; + let jitter = four_color_jitter(ctx.params.f("jitter")); let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); let seed = ctx.seed; @@ -349,8 +355,9 @@ pub fn specs() -> Vec { p("positionsColors/color3", "Color 3", col(1.0, 0.0, 1.0), ParamUi::Color), p("positionsColors/point4", "Point 4", pt(0.9, 0.9), ParamUi::Point), p("positionsColors/color4", "Color 4", col(0.0, 0.0, 1.0), ParamUi::Color), - p("blend", "Blend", num(100.0), slider(1.0, 1000.0, 1.0, 1000.0, 1)), - p("jitter", "Jitter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + // After Effects' valid ranges (#3478, #3479); Blend keeps its lower minimum (1, not 5). + p("blend", "Blend", num(100.0), slider(1.0, 10000.0, 1.0, 1000.0, 1)), + p("jitter", "Jitter", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 1)), p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES[..18])), ], @@ -384,7 +391,7 @@ pub fn specs() -> Vec { p("corner", "Corner", pt(0.1, 0.1), ParamUi::Point), p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)), p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)), - p("border", "Border", num(2.0), slider(0.0, 400.0, 0.0, 40.0, 1)), + p("border", "Border", num(2.0), slider(0.0, 2000.0, 0.0, 40.0, 1)), p("feather/featherWidth", "Width", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)), p("feather/featherHeight", "Height", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)), p("invertGrid", "Invert Grid", Value::Bool(false), ParamUi::Checkbox), @@ -465,6 +472,21 @@ mod generator_tests { } } + /// #3479: Jitter goes up to After Effects' 500 %, and more jitter dithers more (the dither + /// stopped growing at 100 %). + #[test] + fn four_color_jitter_grows_up_to_five_hundred_percent() { + let img = Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0]); + let spread = |jitter: f64| { + let o = run_fx("ec.generate.fourcolor", &[("jitter", num(jitter))], img.clone(), 0.0, EffectEnv::default()).img; + let flat = run_fx("ec.generate.fourcolor", &[], img.clone(), 0.0, EffectEnv::default()).img; + o.data.iter().zip(&flat.data).map(|(a, b)| (a[0] - b[0]).abs()).fold(0.0f32, f32::max) + }; + let (at100, at500) = (spread(100.0), spread(500.0)); + assert!(at100 > 0.0 && at500 > 4.0 * at100, "{at100} {at500}"); + assert!(at500 < 0.2, "a few code values: {at500}"); + } + /// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 % /// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10 /// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5). diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index ad80cb9cd..6bf5443fc 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -1111,8 +1111,8 @@ pub fn specs() -> Vec { p("endPoint", "Ending Point", pt(0.75, 0.5), ParamUi::Point), p("length", "Length", num(25.0), pct()), p("time", "Time", num(0.0), pct()), - p("startThickness", "Starting Thickness", num(8.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("endThickness", "Ending Thickness", num(8.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("startThickness", "Starting Thickness", num(8.0), slider(0.0, 4000.0, 0.0, 100.0, 1)), + p("endThickness", "Ending Thickness", num(8.0), slider(0.0, 4000.0, 0.0, 100.0, 1)), p("softness", "Softness", num(20.0), pct()), p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("outsideColor", "Outside Color", col(0.4, 0.4, 1.0), ParamUi::Color), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 019596f80..f9d8f97e8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -39,6 +39,7 @@ mod keying2; pub mod keylight; mod matte; +mod file_cache; pub mod migrate; mod misc; pub mod mocha_shape; @@ -98,14 +99,14 @@ pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). -pub use blur2::{camera_lens_plain, camera_lens_spans, radial_fast_pad}; -pub use generate::{four_color_softening, gen_mode}; +pub use blur2::{CAMERA_LENS_MAX_RADIUS, camera_lens_plain, camera_lens_radius, camera_lens_spans, radial_fast_pad}; +pub use generate::{four_color_jitter, four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ Coverage, MarksPlan, PaintPlan, Seg, eyedropper_color, marks_plan, paint_bucket_region, paint_plan, plan_reads_pixels, poly_segs, scribble_style, }; -pub use noise::GrainLook; +pub use noise::{GrainLook, MEDIAN_MAX_RADIUS}; pub use textfx::{BoltPlan, TextLook, TextPass, bolt_plan, text_passes}; pub use transition::{GridWipe, place_layer}; // effectcraft-gpu fx_noise. @@ -123,13 +124,14 @@ pub use channel3d::{ crypto_layer_name, crypto_layer_named, extractor_aux_channel, extractor_own_channel, id_color, is_extractor_copy, matte_only_px, reads_aux, selection_hashes, }; +pub use file_cache::{check_lut_files, lut_generation}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan, wave_world_plan, }; -pub use utility::{Lut, load_lut}; +pub use utility::{Lut, load_lut, lut_file, lut_files, lut_params}; pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation}; /// Effect categories in Effects & Presets order. diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 65951ad97..9c7131657 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -919,7 +919,7 @@ pub fn specs() -> Vec { p("startAngle", "Start Angle", num(0.0), ParamUi::Angle), p("center", "Wipe Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), p("wipe", "Wipe", Value::Enum(0), popup(&["Clockwise", "Counterclockwise", "Both"])), - p("feather", "Feather", num(0.0), slider(0.0, 360.0, 0.0, 50.0, 1)), + p("feather", "Feather", num(0.0), slider(0.0, 32000.0, 0.0, 50.0, 1)), ], radial_wipe, ), @@ -930,8 +930,8 @@ pub fn specs() -> Vec { vec![ p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), p("direction", "Direction", num(90.0), ParamUi::Angle), - p("width", "Width", num(30.0), slider(2.0, 1000.0, 2.0, 200.0, 0)), - p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("width", "Width", num(30.0), slider(1.0, 32000.0, 2.0, 200.0, 0)), + p("feather", "Feather", num(0.0), slider(0.0, 32000.0, 0.0, 100.0, 1)), ], venetian, ), diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index b6f76a268..fde24b537 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -370,9 +370,16 @@ fn quant(v: f32) -> u16 { (v.clamp(0.0, 1.0) * (NB - 1) as f32).round() as u16 } +/// Largest median window radius (buffer pixels) for Median, Median (Legacy), Dust & Scratches +/// and the other median users. Radius goes to 255 in the UI; filling one (2r + 1)² window per +/// row costs the square of the radius, so a project value far beyond it is held here (also +/// keeping the window's pixel count well inside `u32` / `i32` on the GPU). +pub const MEDIAN_MAX_RADIUS: usize = 512; + /// Per-channel median over a (2r+1)² window of the premultiplied channels (values quantised to /// 512 levels in 0..1, edges repeated). pub(crate) fn median_image(img: &Image, r: usize) -> Image { + let r = r.min(MEDIAN_MAX_RADIUS); let (w, h) = (img.width as usize, img.height as usize); if r == 0 || w == 0 || h == 0 { return img.clone(); @@ -759,10 +766,12 @@ pub fn specs() -> Vec { let hls_common = || { vec![ p("noise", "Noise", Value::Enum(0), popup(&["Uniform", "Squared", "Grain"])), - p("hue", "Hue", num(0.0), pct()), - p("lightness", "Lightness", num(0.0), pct()), - p("saturation", "Saturation", num(0.0), pct()), - p("grainSize", "Grain Size", num(1.0), slider(0.1, 100.0, 0.5, 10.0, 2)), + // After Effects' valid ranges (#3483, #3484): the amounts go to 1000 %, Grain Size + // down to 0 (the renderer floors it at 0.1 px) and keeps its wider maximum. + p("hue", "Hue", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("lightness", "Lightness", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("saturation", "Saturation", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("grainSize", "Grain Size", num(1.0), slider(0.0, 100.0, 0.5, 10.0, 2)), ] }; let mut hls = hls_common(); @@ -871,6 +880,17 @@ mod tests { } } + #[test] + fn median_radius_is_capped() { + // A project's Radius far beyond the UI's 255 takes the capped window, not a + // (2r + 1)² fill per row (or an overflow) on a tiny image. + let mut img = Image::filled(4, 3, [0.2, 0.3, 0.4, 1.0]); + img.set(1, 1, [1.0, 1.0, 1.0, 1.0]); + let capped = median_image(&img, MEDIAN_MAX_RADIUS); + assert_eq!(median_image(&img, usize::MAX).data, capped.data); + assert_eq!(median_image(&img, MEDIAN_MAX_RADIUS + 1000).data, capped.data); + } + #[test] fn median_matches_brute_force() { let (w, h) = (9u32, 7u32); @@ -949,6 +969,32 @@ mod tests { assert_eq!(still.img, img); } + /// #3483, #3484: amounts above 100 % keep adding noise, and Grain Size 0 renders (floored at + /// 0.1 px) with finite output, for both effects. + #[test] + fn noise_hls_takes_after_effects_ranges() { + let mut img = Image::new(16, 16); + for (i, p) in img.data.iter_mut().enumerate() { + *p = premul([0.6, 0.3, (i % 7) as f32 / 7.0], 1.0); + } + for id in ["ec.noise.noisehls", "ec.noise.noisehlsauto"] { + for (param, lo, hi) in [("hue", 0.0, 1000.0), ("lightness", 0.0, 1000.0), ("saturation", 0.0, 1000.0), ("grainSize", 0.0, 100.0)] { + let ui = &crate::find(id).unwrap().params.iter().find(|p| p.id == param).unwrap().ui; + assert!(matches!(ui, ParamUi::Slider { min, max, .. } if *min == lo && *max == hi), "{id} {param}: {ui:?}"); + } + let at = |amount: f64, grain: f64| { + let vals = + [("noise", Value::Enum(2)), ("hue", num(amount)), ("lightness", num(amount)), ("saturation", num(amount)), ("grainSize", num(grain))]; + run(id, &vals, img.clone(), 0.0).img + }; + let (low, high) = (at(100.0, 1.0), at(1000.0, 1.0)); + assert_ne!(low, high, "{id}"); + for out in [high, at(1000.0, 0.0)] { + assert!(out.data.iter().flatten().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{id}"); + } + } + } + #[test] fn add_grain_keeps_flat_mean() { let out = run("ec.noise.addgrain", &[("tweaking/intensity", num(2.0)), ("application/blendingMode", Value::Enum(2))], gray(64, 64, 0.5), 0.0); diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 8a9750282..86534a178 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -301,7 +301,8 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: let fr = fc.frames.get(&f)?; let [fw, fh] = fc.size; let expected = fw.checked_mul(fh)?; - if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { + // Only the layer's own full-resolution grid (see Roto Brush's frozen mattes). + if fc.size != roto::frozen_grid(layer_size) || fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { return None; } let a = rle::decode_bounded(&fr.a, expected)?; @@ -719,6 +720,12 @@ mod tests { p.values.insert("objectPoint".into(), Value::Vec2([40.0, 40.0])); p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); + // A compact plane claiming a size other than the layer's grid is not decoded. + let mut fc3 = fc.clone(); + fc3.size = [4000, 4000]; + fc3.frames.insert(2, roto::freeze_frame(&vec![1.0; 4000 * 4000], &[])); + p.values.insert(FROZEN.into(), Value::Str(fc3.to_json())); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } /// Mattes frozen with a model stay usable in a session where that model is not loaded (a diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index 2daa9857d..febbc163c 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -24,8 +24,7 @@ //! * Configuration ▸ Custom reads a `.ocio` file instead ([`crate::ocio_config`]); what it can't //! apply is reported by [`warning`]. -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; use effectcraft_color::ColorSpace; use effectcraft_color::space::{invert, mul, mul_vec, rgb_to_xyz}; @@ -51,6 +50,7 @@ pub use effects::parse_looks; pub use effects::{color_stabilizer_maps, warning}; #[cfg(test)] use effects::{tonemap, tonemap_inv}; +pub(crate) use lut::cached_files; pub use lut::{FileXform, load_file}; #[cfg(test)] pub use lut::{parse_file, parse_spi1d, parse_spi3d, parse_spimtx}; diff --git a/crates/effects/src/ocio/lut.rs b/crates/effects/src/ocio/lut.rs index de931a411..daa584278 100644 --- a/crates/effects/src/ocio/lut.rs +++ b/crates/effects/src/ocio/lut.rs @@ -1,5 +1,6 @@ use super::cdl::{Cdl, parse_cdl_xml}; -use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube}; +use super::{Arc, Lut, parse_cube}; +use crate::file_cache::{self, Registered}; // ---------------------------------------------------------------- LUT files @@ -264,33 +265,38 @@ pub fn parse_file(text: &str, ext: &str) -> Option { parse_3dl(text).map(|lut| FileXform::Lut { lut, shaper: None }) } -type FileCache = Mutex>>>; +/// [`load_file`]'s cache, by parameter text (bounded like [`crate::load_lut`]'s). +static FILES: Registered>> = Registered::new(64, 512 << 20); -/// Load (and cache) a transform from a file path or inline content. +/// The files the transform cached for `src` was read from. +pub(crate) fn cached_files(src: &str) -> Option> { + file_cache::lock(FILES.get()).files(src) +} + +/// Load (and cache) a transform from a file path or inline content. A file is stamped (one +/// `metadata` call) on every load and read again once its size or modification time moved (see +/// [`crate::file_cache`]); inline content is its own content. pub fn load_file(src: &str) -> Option> { let src_t = src.trim(); if src_t.is_empty() { return None; } - static CACHE: OnceLock = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Ok(m) = cache.lock() - && let Some(v) = m.get(src) - { - return v.clone(); - } - let inline = src.contains('\n'); - let parsed = if inline { - parse_file(src, "") - } else { - let ext = std::path::Path::new(src_t).extension().and_then(std::ffi::OsStr::to_str).unwrap_or(""); - std::fs::read_to_string(src_t).ok().and_then(|t| parse_file(&t, ext)) - } - .map(Arc::new); - if let Ok(mut m) = cache.lock() { - m.insert(src.to_string(), parsed.clone()); - } - parsed + file_cache::cached(FILES.get(), src, || { + let parsed = if src.contains('\n') { + parse_file(src, "") + } else { + let ext = std::path::Path::new(src_t).extension().and_then(std::ffi::OsStr::to_str).unwrap_or(""); + file_cache::read(src_t).ok().and_then(|t| parse_file(&t, ext)) + } + .map(Arc::new); + let bytes = match parsed.as_deref() { + Some(FileXform::Lut { lut, shaper }) => crate::utility::lut_bytes(lut) + .saturating_add(shaper.as_ref().map_or(0, |s| s.iter().map(Vec::len).sum::().saturating_mul(std::mem::size_of::<(f32, f32)>()))), + Some(FileXform::Cdl(c)) => c.len().saturating_mul(std::mem::size_of::<(String, Cdl)>()), + Some(FileXform::Matrix { .. }) | None => 0, + }; + (parsed, bytes) + }) } impl FileXform { diff --git a/crates/effects/src/ocio/tests.rs b/crates/effects/src/ocio/tests.rs index fe93dd25a..e17fd782a 100644 --- a/crates/effects/src/ocio/tests.rs +++ b/crates/effects/src/ocio/tests.rs @@ -396,3 +396,43 @@ fn color_stabilizer_samples_the_reference_at_the_points_reference_positions() { assert!((o(x) - (a + b * v(x))).abs() < 1e-3, "x {x}"); } } + +/// Writes `text` to `dir/name` with modification time `secs` (so an edit within the file system's +/// time resolution still moves the stamp), returning the path. +fn write_dated(dir: &std::path::Path, name: &str, text: &str, secs: u64) -> String { + let path = dir.join(name); + std::fs::write(&path, text).unwrap(); + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap(); + path.to_string_lossy().into_owned() +} + +/// #3538: OCIO File Transform read a file once per process; an edited, created, repaired or +/// deleted file was never followed. +#[test] +fn an_ocio_transform_file_is_read_again_once_it_changes() { + const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n"; + const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n"; + let dir = std::env::temp_dir().join(format!("ec-fx-3538-file-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let at = |src: &str| load_file(src).map(|x| x.apply([0.5; 3], 1, false, "")); + let edited = write_dated(dir.as_path(), "edited.cube", BLACK, 1_000); + assert_eq!(at(&edited), Some([0.0; 3])); + write_dated(dir.as_path(), "edited.cube", WHITE, 2_000); + assert_eq!(at(&edited), Some([1.0; 3]), "an edited file, same size"); + let created = dir.as_path().join("created.cube").to_string_lossy().into_owned(); + assert_eq!(at(&created), None); + write_dated(dir.as_path(), "created.cube", WHITE, 1_000); + assert_eq!(at(&created), Some([1.0; 3]), "a file created after a failed load"); + let repaired = write_dated(dir.as_path(), "repaired.cube", "not a lut", 1_000); + assert_eq!(at(&repaired), None); + write_dated(dir.as_path(), "repaired.cube", WHITE, 2_000); + assert_eq!(at(&repaired), Some([1.0; 3]), "a repaired file"); + std::fs::remove_file(&repaired).unwrap(); + assert_eq!(at(&repaired), None, "a deleted file"); + // Inline text is its own content: the same text is the same transform. + let inline = load_file(BLACK).unwrap(); + assert!(Arc::ptr_eq(&inline, &load_file(BLACK).unwrap())); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/effects/src/ocio_config.rs b/crates/effects/src/ocio_config.rs index e31281ede..17e61d4b1 100644 --- a/crates/effects/src/ocio_config.rs +++ b/crates/effects/src/ocio_config.rs @@ -42,9 +42,10 @@ //! resolving named transforms [`MAX_NAMED_DEPTH`] levels down or past [`MAX_OPS`] steps //! (a config that refers to itself in a loop), so a hostile config can't exhaust the stack. -use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; + +use crate::file_cache::{self, Registered}; use super::ocio::{ACES_CG, ACES2065_1, Cdl, D65, FileXform, P3, REC709, REC2020, Space, Tf, load_file, matrix, sp}; @@ -882,13 +883,21 @@ fn resolve(src: &str, dir: &Path, search: &[String]) -> Option { if src.trim().is_empty() { return None; } + // (a candidate not there is noted: the config is read again once it appears) + let found = |c: PathBuf| { + let there = c.exists(); + if !there { + file_cache::note_missing(&c.to_string_lossy()); + } + there.then_some(c) + }; let p = Path::new(src); if p.is_absolute() { - return p.exists().then(|| p.to_path_buf()); + return found(p.to_path_buf()); } let mut dirs: Vec = search.iter().map(|s| dir.join(s)).collect(); dirs.push(dir.to_path_buf()); - dirs.into_iter().map(|d| d.join(src)).find(|c| c.exists()) + dirs.into_iter().map(|d| d.join(src)).find_map(found) } // ---------------------------------------------------------------- the config @@ -1338,33 +1347,34 @@ impl<'c> Build<'c> { } } -type ConfigCache = Mutex, String>>>; +/// [`load_config`]'s cache, by parameter text. +static CONFIGS: Registered, String>> = Registered::new(16, 64 << 20); + +/// The files the config cached for `src` was read from: the `.ocio` file, the files its +/// `FileTransform`s read and the places it looked for missing ones. +pub(crate) fn cached_files(src: &str) -> Option> { + file_cache::lock(CONFIGS.get()).files(src) +} /// Load (and cache) a config from a `.ocio` path or the config text itself; why not, when it -/// can't be read. +/// can't be read. The config file and the files its `FileTransform`s read are stamped (one +/// `metadata` call each) on every load, and the config is read again once one of them moved or a +/// missing one appeared (see [`crate::file_cache`]). pub fn load_config(src: &str) -> Result, String> { let t = src.trim(); if t.is_empty() { return Err("no config file chosen".into()); } - static CACHE: OnceLock = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Ok(m) = cache.lock() - && let Some(v) = m.get(src) - { - return v.clone(); - } - let parsed = if src.contains('\n') { - Config::parse(src, Path::new(".")) - } else { + file_cache::cached(CONFIGS.get(), src, || { + if src.contains('\n') { + return (Config::parse(src, Path::new(".")).map(Arc::new), 0); + } let p = Path::new(t); - std::fs::read_to_string(p).map_err(|e| format!("{t}: {e}")).and_then(|text| Config::parse(&text, p.parent().unwrap_or(Path::new(".")))) - } - .map(Arc::new); - if let Ok(mut m) = cache.lock() { - m.insert(src.to_string(), parsed.clone()); - } - parsed + match file_cache::read(t) { + Ok(text) => (Config::parse(&text, p.parent().unwrap_or(Path::new("."))).map(Arc::new), text.len()), + Err(e) => (Err(format!("{t}: {e}")), 0), + } + }) } #[cfg(test)] diff --git a/crates/effects/src/ocio_config/tests.rs b/crates/effects/src/ocio_config/tests.rs index b616e6263..31cac56c2 100644 --- a/crates/effects/src/ocio_config/tests.rs +++ b/crates/effects/src/ocio_config/tests.rs @@ -410,3 +410,69 @@ fn builtin_styles() { fn rgb_white_xyz() -> [f64; 3] { [D65[0] / D65[1], 1.0, (1.0 - D65[0] - D65[1]) / D65[1]] } + +/// #3538: a custom `.ocio` config, and the LUTs it reads, were read once per process. +#[test] +fn an_ocio_config_and_its_luts_are_read_again_once_they_change() { + const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n"; + const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n"; + let dir = std::env::temp_dir().join(format!("ec-fx-3538-config-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(dir.as_path().join("luts")).unwrap(); + let write = |name: &str, text: &str, secs: u64| { + let path = dir.as_path().join(name); + std::fs::write(&path, text).unwrap(); + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap(); + path.to_string_lossy().into_owned() + }; + let config = |space: &str| { + format!( + "ocio_profile_version: 2\nsearch_path: luts\nroles:\n scene_linear: lin\ncolorspaces:\n - !\n name: lin\n - !\n name: {space}\n to_reference: ! {{src: grade.cube, interpolation: linear}}\n - !\n name: later\n to_reference: ! {{src: later.cube, interpolation: linear}}\n" + ) + }; + let src = dir.as_path().join("config.ocio").to_string_lossy().into_owned(); + assert!(load_config(&src).is_err(), "no config yet"); + write("config.ocio", &config("graded"), 1_000); + write("luts/grade.cube", BLACK, 1_000); + let to_lin = |space: &str| { + let c = load_config(&src).unwrap(); + c.convert(c.space(space).unwrap(), c.space("lin").unwrap(), [0.5; 3]) + }; + assert_eq!(to_lin("graded"), [0.0; 3], "a config created after a failed load"); + write("luts/grade.cube", WHITE, 2_000); + assert_eq!(to_lin("graded"), [1.0; 3], "a LUT the config reads, edited"); + assert_eq!(to_lin("later"), [0.5; 3], "a LUT not there yet passes through"); + write("luts/later.cube", BLACK, 1_000); + assert_eq!(to_lin("later"), [0.0; 3], "a LUT the config reads, created"); + write("config.ocio", &config("regraded"), 2_000); + assert!(load_config(&src).unwrap().space("regraded").is_some(), "an edited config"); + std::fs::remove_file(&src).unwrap(); + assert!(load_config(&src).is_err(), "a deleted config"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +/// A LUT a cached config read that changes is noticed without a render loading the config (the +/// interface's watcher), and the config's files name it (frame and layer cache keys hold them). +#[test] +fn check_lut_files_notices_a_configs_lut() { + let dir = std::env::temp_dir().join(format!("ec-fx-3538-check-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let lut = dir.join("watched.cube"); + std::fs::write(&lut, "LUT_1D_SIZE 2\n0 0 0\n1 1 1\n").unwrap(); + let config = dir.join("config.ocio"); + std::fs::write(&config, "ocio_profile_version: 2\ncolorspaces:\n - !\n name: lin\n - !\n name: graded\n to_reference: ! {src: watched.cube}\n").unwrap(); + let src = config.to_string_lossy().into_owned(); + // A config not read yet is read: its files name its LUT from the first render on. + let files = crate::lut_files(&src); + assert!(files.contains(&lut.to_string_lossy().into_owned()), "{files:?}"); + assert!(load_config(&src).is_ok()); + let before = crate::check_lut_files(); + assert!(cached_files(&src).is_some(), "an unchanged config stays"); + std::fs::write(&lut, "LUT_1D_SIZE 3\n0 0 0\n0.5 0.5 0.5\n1 1 1\n").unwrap(); + assert!(crate::check_lut_files() > before); + assert!(cached_files(&src).is_none(), "a config whose LUT changed is read again"); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 69c63caf9..8f55bc408 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -394,7 +394,7 @@ pub fn specs() -> Vec { }; let render = || popup(&["Full", "Outside", "Inside"]); let mut cyl = vec![ - p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("radius", "Radius", num(100.0), slider(0.0, 30000.0, 0.0, 200.0, 1)), p("position", "Position", pt(0.5, 0.5), ParamUi::Point), p("rotation", "Rotation", num(0.0), ParamUi::Angle), p("rotationX", "Rotation X", num(0.0), ParamUi::Angle), @@ -565,6 +565,23 @@ mod tests { assert_eq!(out.get(50 + 18, 10)[3], 0.0); } + /// #3368: Radius takes the range After Effects' CC Cylinder has, up to 30000 %, and a cylinder + /// that large still renders (nearly flat, fully opaque) instead of clamping to 400 %. + #[test] + fn cylinder_radius_goes_up_to_thirty_thousand_percent() { + let spec = find("ec.perspective.cccylinder").unwrap(); + let radius = spec.params.iter().find(|p| p.id == "radius").unwrap(); + assert!(matches!(radius.ui, ParamUi::Slider { min, max, .. } if min == 0.0 && max == 30000.0), "{:?}", radius.ui); + // The cylinder's axis on the left edge: at 400 % its radius is 400 / 100 * 100 / 2π ≈ 63.7 px, + // so a pixel 70 px along is past it there and inside the 30000 % one. + let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]); + let at_edge = |radius: f64| run("ec.perspective.cccylinder", &img, &[("radius", num(radius)), ("position", Value::Vec2([0.0, 10.0]))]).img; + let clamped = at_edge(400.0).get(70, 10); + assert_eq!(clamped[3], 0.0, "beyond a 400 % cylinder: {clamped:?}"); + let px = at_edge(30000.0).get(70, 10); + assert!(px.iter().all(|v| v.is_finite()) && px[3] > 0.99, "{px:?}"); + } + #[test] fn cylinder_axis_rotations_affect_geometry_and_texture() { let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]); diff --git a/crates/effects/src/puppet/mesh.rs b/crates/effects/src/puppet/mesh.rs index f01ee8f78..d5fd66dae 100644 --- a/crates/effects/src/puppet/mesh.rs +++ b/crates/effects/src/puppet/mesh.rs @@ -614,11 +614,72 @@ pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh { Mesh { verts, tris, outline: poly } } +/// The Expansion range the outline honours, in layer pixels (the property's slider range). A +/// project file can store any number; outside this range it is clamped, so the padded grid +/// below stays a few hundred pixels larger than the buffer. +pub const EXPANSION_RANGE: (f64, f64) = (-100.0, 200.0); +/// Most buffer pixels of padding the outline grid takes on each side (Expansion × buffer scale). +const MAX_PAD: f64 = 4096.0; +/// Most cells the padded outline grid may have (it is expanded with two `f64` distance buffers). +const MAX_GRID_CELLS: usize = 1 << 25; +/// Most the alpha is shrunk by, per axis, to make the padded grid fit; a layer needing more has no +/// outline. +const MAX_DOWNSAMPLE: usize = 8; + /// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and -/// `offset`), expanded by `expansion` layer pixels. +/// `offset`), expanded by `expansion` layer pixels (clamped to [`EXPANSION_RANGE`]). When the +/// padded grid would pass [`MAX_PAD`] or [`MAX_GRID_CELLS`] (a high-scale layer with a large +/// Expansion), the alpha is traced at a coarser resolution instead of being skipped. pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> { - // Pad so expansion can grow past the buffer edge. - let pad = (expansion.max(0.0) * scale).ceil() as usize + 2; + let expansion = if expansion.is_finite() { expansion.clamp(EXPANSION_RANGE.0, EXPANSION_RANGE.1) } else { 0.0 }; + if alpha.len() < w.saturating_mul(h) { + return vec![]; + } + let want = (expansion.max(0.0) * scale).ceil(); + if !want.is_finite() { + return vec![]; + } + for k in 1..=MAX_DOWNSAMPLE { + let pad = (want / k as f64).ceil(); + let (kw, kh) = (w.div_ceil(k), h.div_ceil(k)); + let fits = pad <= MAX_PAD && { + let pad = pad as usize + 2; + kw.checked_add(2 * pad).zip(kh.checked_add(2 * pad)).and_then(|(pw, ph)| pw.checked_mul(ph)).is_some_and(|c| c <= MAX_GRID_CELLS) + }; + if !fits { + continue; + } + if k == 1 { + return outline_padded(alpha, [w, h], scale, offset, seed, expansion, pad as usize + 2); + } + let small = shrink(alpha, w, h, k); + let kf = k as f64; + return outline_padded(&small, [kw, kh], scale / kf, [offset[0] / kf, offset[1] / kf], seed, expansion, pad as usize + 2); + } + vec![] +} + +/// `alpha` (`w × h`) with every `k × k` block averaged into one pixel. +fn shrink(alpha: &[f32], w: usize, h: usize, k: usize) -> Vec { + let (kw, kh) = (w.div_ceil(k), h.div_ceil(k)); + let mut out = vec![0.0f32; kw * kh]; + for y in 0..kh { + for x in 0..kw { + let (x1, y1) = (((x + 1) * k).min(w), ((y + 1) * k).min(h)); + let (mut sum, mut n) = (0.0f32, 0u32); + for yy in y * k..y1 { + for xx in x * k..x1 { + sum += alpha[yy * w + xx]; + n += 1; + } + } + out[y * kw + x] = sum / n.max(1) as f32; + } + } + out +} + +fn outline_padded(alpha: &[f32], [w, h]: [usize; 2], scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64, pad: usize) -> Vec<[f64; 2]> { let (pw, ph) = (w + 2 * pad, h + 2 * pad); let mut on = vec![false; pw * ph]; for y in 0..h { diff --git a/crates/effects/src/puppet/tests.rs b/crates/effects/src/puppet/tests.rs index 10426d46c..c2db999e1 100644 --- a/crates/effects/src/puppet/tests.rs +++ b/crates/effects/src/puppet/tests.rs @@ -106,6 +106,42 @@ fn expansion_grows_the_outline() { assert!((am - pi * 225.0).abs() < 60.0, "{am}"); } +/// An imported Expansion far past the property's range is clamped instead of padding the grid +/// by that many pixels (a 1e12 expansion would ask for terabytes). +#[test] +fn hostile_expansion_is_clamped() { + let (w, h) = (40, 40); + let alpha = disc_alpha(w, h, [20.0, 20.0], 10.0); + let max = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], EXPANSION_RANGE.1)).abs(); + // A non-finite Expansion is taken as zero (the unexpanded outline), not clamped to the maximum. + let zero = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], 0.0)).abs(); + for e in [1e12, f64::MAX, f64::INFINITY, f64::NAN] { + let a = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], e)).abs(); + let want = if e.is_finite() { max } else { zero }; + assert!((a - want).abs() < 1.0, "{e}: {a} vs {want}"); + } + assert!(outline(&alpha, w, h, 1e9, [0.0; 2], [20.0, 20.0], 100.0).is_empty()); + let _ = outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], -1e12); +} + +/// A high-scale layer with a large Expansion is traced at a coarser resolution, not skipped, and a +/// tiny buffer with a huge padded grid stays within the grid budget. +#[test] +fn high_scale_expansion_is_traced_coarser_instead_of_skipped() { + let (w, h) = (40, 40); + let alpha = disc_alpha(w, h, [20.0, 20.0], 10.0); + // Scale 32, Expansion 130: 4160 px of padding, past MAX_PAD, so it is shrunk by 2. + let scale = 32.0; + let o = outline(&alpha, w, h, scale, [0.0; 2], [20.0, 20.0], 130.0); + let area = signed_area(&o).abs(); + // The disc has radius 10 / 32 layer px; expanded by 130. + let r = 10.0 / scale + 130.0; + assert!((area - std::f64::consts::PI * r * r).abs() < 0.03 * std::f64::consts::PI * r * r, "{area}"); + // One pixel at scale 20.48 and Expansion 1e12 (clamped): its 8197 px square grid is over budget. + let o = outline(&[1.0], 1, 1, 20.48, [0.0; 2], [0.5, 0.5], 1e12); + assert!(o.len() >= 3); +} + fn rect_mesh(w: f64, h: f64, tris: f64) -> Mesh { triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &MeshOpts { triangles: tris, density: 50.0, expansion: 0.0 }) } diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index f9c26f417..733535b21 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -378,10 +378,22 @@ pub fn purge() { c.bytes = 0; } +/// The pixel size of the layer-space grid at `scale` (`round(size · scale)`, at least 1). +fn grid_size(layer_size: [f64; 2], scale: f64) -> (u32, u32) { + (((layer_size[0] * scale).round() as u32).max(1), ((layer_size[1] * scale).round() as u32).max(1)) +} + +/// The full-resolution layer grid frozen mattes are stored at. A frozen cache claiming any +/// other size did not come from freezing this layer and is not decoded. +pub fn frozen_grid(layer_size: [f64; 2]) -> [usize; 2] { + let (w, h) = grid_size(layer_size, 1.0); + [w as usize, h as usize] +} + /// The layer-space grid (`round(size · scale)` pixels) of a buffer. pub fn layer_grid(buf: &Buf, layer_size: [f64; 2]) -> Image { let s = if buf.scale > 0.0 { buf.scale } else { 1.0 }; - let (w, h) = (((layer_size[0] * s).round() as u32).max(1), ((layer_size[1] * s).round() as u32).max(1)); + let (w, h) = grid_size(layer_size, s); let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64); let mut img = Image::new(w, h); img.rows_mut().for_each(|(y, row)| { @@ -1212,8 +1224,11 @@ pub fn freeze_frame(alpha: &[f32], band: &[u8]) -> FrozenFrame { fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(Vec, Vec, f64)> { let (w, h) = (img.width as usize, img.height as usize); // Frozen mattes. + // Only the layer's own full-resolution grid: a project cannot make a tiny layer decode (and + // convert to floats) planes as large as the RLE allows. Any other size falls through to a computed matte. if let Some(fc) = frozen(ctx.params) && frozen_applies(&fc, ctx.params, ctx.layer_size, ctx.fps()) + && fc.size == frozen_grid(ctx.layer_size) && let Some(fr) = fc.frames.get(&f) { let [fw, fh] = fc.size; @@ -1409,6 +1424,33 @@ mod tests { params } + #[test] + fn mismatched_frozen_size_falls_back_to_a_computed_matte() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 20, [0, 24]); + let mut params = params_with(&d); + let fc = + FrozenCache { key: frozen_key(¶ms, [100.0, 80.0], 25.0), size: [10, 8], frames: BTreeMap::from([(5, freeze_frame(&[1.0; 80], &[0; 80]))]) }; + let host = Seq; + let render = |params: &Params| { + let ctx = EffectCtx { + params, + time: 5.0 / 25.0 + 0.001, + layer_size: [100.0, 80.0], + seed: 1, + adjustment: false, + env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() }, + }; + crate::apply(&specs()[0], &ctx, Buf { img: frame(5.0 / 25.0 + 0.001), offset: [0.0; 2], scale: 1.0 }).img + }; + let computed = render(¶ms); + params.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + let out = render(¶ms); + assert!(out.data[(40 * 100 + 50) as usize][3] > 0.5, "the disk is missing from the output"); + // The cache's all-opaque matte would leave the background opaque too; the fallback must match a render with no cache. + assert!(out.data == computed.data, "the mismatched frozen cache was used"); + } + #[test] fn renders_the_segmented_disk_on_demand() { let mut d = RotoData::default(); @@ -1980,6 +2022,18 @@ mod tests { } } + /// Frozen mattes are only decoded at the size the freeze pass stores: the layer grid at + /// full resolution. + #[test] + fn frozen_grid_is_the_full_resolution_layer_grid() { + for size in [[100.0, 80.0], [99.6, 0.2], [1.0, 1.0]] { + let buf = Buf { img: Image::new(3, 3), offset: [0.0; 2], scale: 1.0 }; + let g = layer_grid(&buf, size); + assert_eq!(frozen_grid(size), [g.width as usize, g.height as usize]); + } + assert_eq!(frozen_grid([100.0, 80.0]), [100, 80]); + } + #[test] fn saved_tracking_decoders_validate_dimensions_and_plane_lengths() { let valid = rle::encode(&[255, 0, 255, 0]); diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index beda8c318..8d9914e31 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -8,9 +8,11 @@ use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, /// Steps per second of the stepped particle simulations. pub(crate) const SPS: f64 = 60.0; -/// Number of whole steps reached at layer time `t`. +/// Number of whole steps reached at layer time `t` (at most [`crate::util::MAX_SIM_STEPS`]: the +/// per-step histories built from it, such as [`rate_history`], stay bounded however late a +/// project places the layer's time). pub(crate) fn steps_at(t: f64) -> u64 { - if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 } + if t <= 0.0 { 0 } else { ((t * SPS + 1e-6).floor() as u64).min(crate::util::MAX_SIM_STEPS) } } #[derive(Clone, Debug)] diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 247854352..a2de2c35e 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -612,7 +612,7 @@ pub fn specs() -> Vec { "ec.stylize.scatter", "Scatter", vec![ - p("amount", "Scatter Amount", num(0.0), slider(0.0, 127.0, 0.0, 127.0, 1)), + p("amount", "Scatter Amount", num(0.0), slider(0.0, 1000.0, 0.0, 127.0, 1)), p("grain", "Grain", Value::Enum(0), popup(&["Both", "Horizontal", "Vertical"])), p("randomizeEveryFrame", "Scatter Randomness", Value::Bool(false), ParamUi::Checkbox), ], diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index b9d20e2a7..74b392db6 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -565,7 +565,8 @@ impl PixelMotion { let px = |id: &str| (g(id, 0.0).max(0.0) * scale).round() as u32; PixelMotion { tile, - window: ((g("tuning/blockSize", 8.0).max(4.0)) * scale).round().max(4.0) as u32, + // Block Size is declared 4..64; a project can store any number. + window: ((g("tuning/blockSize", 8.0).clamp(4.0, 64.0)) * scale).round().max(4.0) as u32, global_smoothness: g("tuning/smoothing/globalSmoothness", 0.5).clamp(0.0, 1.0) as f32, local_smoothness: g("tuning/smoothing/localSmoothness", 0.5).clamp(0.0, 1.0) as f32, iterations: g("tuning/smoothing/smoothingIterations", 20.0).clamp(0.0, 200.0) as u32, diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9c81dab10..390750c6e 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -115,18 +115,34 @@ fn box_row(src: &[f32], dst: &mut [f32], r: usize) { dst.copy_from_slice(src); return; } - let get = |i: isize| src[i.clamp(0, n as isize - 1) as usize]; - let norm = 1.0 / (2 * r + 1) as f32; - let mut acc = 0.0f64; - for i in -(r as isize)..=(r as isize) { - acc += get(i) as f64; + // Past the row the window only adds more copies of the edge pixels; the radius is clamped + // so the index arithmetic below cannot overflow (the means are unchanged in practice). + let r = r.min(MAX_BOX_RADIUS); + let (last, ri) = (n - 1, r as i64); + let get = |i: i64| src[i.clamp(0, last as i64) as usize]; + let norm = 1.0 / (2.0 * r as f64 + 1.0); + // The first window, -r..=r: the edge pixels it repeats are counted, not visited, so the + // work depends on the row length and not on the radius. + let inside = r.min(last); + let mut acc = (r as f64 + 1.0) * src[0] as f64 + src[1..=inside].iter().map(|v| *v as f64).sum::(); + // Only when the window actually repeats the last pixel: 0 * inf would be NaN otherwise. + if r > inside { + acc += (r - inside) as f64 * src[last] as f64; } for x in 0..n { - dst[x] = acc as f32 * norm; - acc += get(x as isize + r as isize + 1) as f64 - get(x as isize - r as isize) as f64; + dst[x] = (acc * norm) as f32; + let xi = x as i64; + acc += get(xi + ri + 1) as f64 - get(xi - ri) as f64; } } +/// Largest box radius [`box_row`] uses (larger radii come from absurd blur amounts in a +/// project, such as a Mocha shape's feather). +const MAX_BOX_RADIUS: usize = 1 << 30; + +/// Largest sigma [`box_radii`] spreads into boxes (see [`MAX_BOX_RADIUS`]). +const MAX_BOX_SIGMA: f64 = 1e8; + /// Separable box mean with radii `rx`, `ry` (edges repeated). pub fn box_plane(p: &Plane, rx: usize, ry: usize) -> Plane { let mut cur = if rx > 0 { p.map_rows(|s, d| box_row(s, d, rx)) } else { p.clone() }; @@ -138,9 +154,10 @@ pub fn box_plane(p: &Plane, rx: usize, ry: usize) -> Plane { /// Box radii for `n` passes approximating a Gaussian of `sigma`. pub fn box_radii(sigma: f64, n: usize) -> Vec { - if sigma <= 0.0 { + if sigma.is_nan() || sigma <= 0.0 || n == 0 { return vec![0; n]; } + let sigma = sigma.min(MAX_BOX_SIGMA); let w_ideal = (12.0 * sigma * sigma / n as f64 + 1.0).sqrt(); let mut wl = w_ideal.floor() as i64; if wl % 2 == 0 { @@ -545,13 +562,22 @@ struct SimEntry { /// Checkpoint every this many steps. pub const SIM_CHECKPOINT: u64 = 15; +/// Most steps a simulation runs: three hours of layer time at 60 steps per second. The layer +/// time comes from the project (start time, stretch), so without a limit a tiny file could ask +/// for years of steps; past this the state stays where it is. +pub const MAX_SIM_STEPS: u64 = 60 * 60 * 60 * 3; + +/// Most checkpoints kept per simulation (and made by one run, which spaces them further apart +/// as it goes). +const MAX_SIM_CHECKPOINTS: usize = 256; + impl SimCache { pub const fn new(cap: usize) -> SimCache { SimCache { inner: std::sync::Mutex::new(Vec::new()), cap } } - /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step - /// `i` to `i + 1`. + /// State after `target` steps (at most [`MAX_SIM_STEPS`]). `init` builds step 0; + /// `step(state, i)` advances from step `i` to `i + 1`. pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { self.run_where(key, target, init, step, |_, _| true) } @@ -569,6 +595,7 @@ impl SimCache { valid: impl Fn(u64, &S) -> bool, ) -> std::sync::Arc { use std::sync::Arc; + let target = target.min(MAX_SIM_STEPS); let start = { let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; @@ -587,11 +614,17 @@ impl SimCache { if !had_start { fresh.push((0, Arc::new(st.clone()))); } + let mut every = SIM_CHECKPOINT; while i < target { step(&mut st, i); i += 1; - if i % SIM_CHECKPOINT == 0 && i != target { + if i % every == 0 && i != target { fresh.push((i, Arc::new(st.clone()))); + // A long run keeps a bounded number of states: spread them twice as far apart. + if fresh.len() > MAX_SIM_CHECKPOINTS { + every = every.saturating_mul(2); + fresh.retain(|(s, _)| s % every == 0); + } } } let out = Arc::new(st); @@ -611,19 +644,23 @@ impl SimCache { }; let e = &mut g[idx]; e.used = tick; - for (s, st) in fresh { - e.checkpoints.retain(|(c, _)| *c != s); - e.checkpoints.push((s, st)); - } - // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. - if e.checkpoints.len() > 256 { - e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0); + // Fresh checkpoints replace those at the same step (one pass, not one per checkpoint). + let steps: std::collections::HashSet = fresh.iter().map(|(s, _)| *s).collect(); + e.checkpoints.retain(|(c, _)| !steps.contains(c)); + e.checkpoints.extend(fresh); + // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. The + // state just asked for keeps its slot, so asking for it again is answered from the cache. + if e.checkpoints.len() > MAX_SIM_CHECKPOINTS { + e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0 && *s != target); e.checkpoints.sort_by_key(|(s, _)| *s); - let mut k = 0; + // Drop only the excess, evenly spaced, so the rest keep their seek distance. + let (len, excess) = (e.checkpoints.len(), (e.checkpoints.len() + 1).saturating_sub(MAX_SIM_CHECKPOINTS)); + let mut i = 0; e.checkpoints.retain(|_| { - k += 1; - k % 2 == 1 + i += 1; + (i * excess) / len == ((i - 1) * excess) / len }); + e.checkpoints.push((target, out.clone())); } out } @@ -801,6 +838,45 @@ mod tests { assert_eq!(d, e); } + /// Layer time from a project cannot run a simulation without end, and a long run keeps a + /// bounded number of checkpoints that still give the same answer when seeking. + #[test] + fn sim_cache_bounds_steps_and_checkpoints() { + let c: SimCache = SimCache::new(2); + let count = |s: &mut u64, _: u64| *s += 1; + assert_eq!(*c.run(1, u64::MAX, || 0, count), MAX_SIM_STEPS); + let n = c.inner.lock().map(|g| g.iter().map(|e| e.checkpoints.len()).max().unwrap_or(0)).unwrap_or(0); + assert!(n <= MAX_SIM_CHECKPOINTS + 1, "{n} checkpoints"); + let fresh: SimCache = SimCache::new(2); + let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i); + let far = *fresh.run(1, 100_000, || 7, step); + let mid = *fresh.run(1, 54_321, || 7, step); + let again: SimCache = SimCache::new(2); + assert_eq!(*again.run(1, 54_321, || 7, step), mid); + assert_eq!(*again.run(1, 100_000, || 7, step), far); + } + + /// A request that leaves the cache over its checkpoint cap still finds its own state there. + #[test] + fn sim_cache_keeps_the_requested_state_when_trimming() { + let c: SimCache = SimCache::new(2); + let steps = std::cell::Cell::new(0u64); + let count = |s: &mut u64, _: u64| { + steps.set(steps.get() + 1); + *s += 1; + }; + let target = SIM_CHECKPOINT * (MAX_SIM_CHECKPOINTS as u64 - 1) + SIM_CHECKPOINT - 1; + assert_eq!(*c.run(1, target, || 0, count), target); + let n = c.inner.lock().map(|g| g.iter().map(|e| e.checkpoints.len()).max().unwrap_or(0)).unwrap_or(0); + assert!(n <= MAX_SIM_CHECKPOINTS, "{n} checkpoints"); + steps.set(0); + assert_eq!(*c.run(1, target, || 0, count), target); + assert_eq!(steps.get(), 0, "the second request is answered from the cache"); + // Only the excess is dropped: the other aligned checkpoints stay. + let kept = c.inner.lock().map(|g| g[0].checkpoints.len()).unwrap_or(0); + assert_eq!(kept, MAX_SIM_CHECKPOINTS, "trimmed to the cap, not halved"); + } + #[test] fn poly_helpers() { let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; @@ -841,4 +917,37 @@ mod tests { let o = guided_filter(&g, &p, 2, 1e-3); assert!(o.data.iter().all(|v| (v - 0.7).abs() < 1e-3)); } + + /// The box mean counts repeated edge pixels instead of visiting them: the same means as a + /// direct sum, and a huge radius (a Mocha feather of 1e300) costs a row's worth of work. + #[test] + fn box_row_matches_a_direct_sum_and_bounds_the_radius() { + let src = [1.0f32, 2.0, 3.0, 5.0, 8.0]; + for r in [0usize, 1, 2, 4, 5, 9, 40] { + let mut dst = [0.0f32; 5]; + box_row(&src, &mut dst, r); + for (x, d) in dst.iter().enumerate() { + let ri = r as i64; + let sum: f64 = (x as i64 - ri..=x as i64 + ri).map(|i| src[i.clamp(0, 4) as usize] as f64).sum(); + let want = (sum / (2 * r + 1) as f64) as f32; + assert!((d - want).abs() < 1e-5, "r={r} x={x}: {d} vs {want}"); + } + } + // An infinite last pixel inside the window still yields a finite first mean. + let mut inf = [0.0f32; 3]; + box_row(&[1.0, 2.0, f32::INFINITY], &mut inf, 1); + assert!((inf[0] - 4.0 / 3.0).abs() < 1e-6, "{inf:?}"); + let mut one = [0.0f32]; + box_row(&[0.25], &mut one, usize::MAX); + assert!((one[0] - 0.25).abs() < 1e-6); + let mut dst = [0.0f32; 5]; + box_row(&src, &mut dst, usize::MAX); + assert!(dst.iter().all(|v| v.is_finite() && (1.0..=8.0).contains(v)), "{dst:?}"); + for sigma in [1e300, f64::MAX, f64::INFINITY, f64::NAN, -1.0] { + assert_eq!(box_radii(sigma, 3).len(), 3); + } + let p = Plane { w: 4, h: 3, data: vec![0.5; 12] }; + let g = gauss_plane(&p, 1e300, 1e300); + assert!(g.data.iter().all(|v| (v - 0.5).abs() < 1e-4), "{:?}", g.data); + } } diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index fca9d607e..afb430e1d 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -1,9 +1,9 @@ //! Utility effects: bounds growth, HDR inspection/compression, Cineon log conversion and colour //! lookup tables (.cube). -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; +use crate::file_cache::{self, Registered}; use effectcraft_color::luminance; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; @@ -330,26 +330,71 @@ impl Lut { } } -type LutCache = Mutex>>>; +/// At most this many parsed LUTs (files and inline texts) are kept, and at most +/// [`MAX_CACHED_BYTES`] of them; the least recently used go first. +const MAX_CACHED_LUTS: usize = 64; +const MAX_CACHED_BYTES: usize = 512 << 20; -/// Load (and cache) a LUT from inline `.cube` text or a file path. +/// [`load_lut`]'s cache, by parameter text. +static LUTS: Registered>> = Registered::new(MAX_CACHED_LUTS, MAX_CACHED_BYTES); + +/// The parameters (paths in the effect's group) of `effect` that name a file an effect reads +/// through a cache that follows it ([`load_lut`], the OCIO file transforms and configs). +pub fn lut_params(effect: &str) -> &'static [&'static str] { + match effect { + "ec.utility.applylut" => &["lut"], + "ec.color.lumetri" => &["basicCorrection/inputLutFile", "creative/lookFile"], + "ec.color.ociofile" => &["file"], + "ec.color.ociocolorspace" | "ec.color.ociodisplay" => &["configFile"], + _ => &[], + } +} + +/// The file a LUT parameter names; `None` for inline `.cube` text or an empty parameter. +pub fn lut_file(src: &str) -> Option<&str> { + // `.cube` text always spans lines (a size header, then data rows); a path never does, even one + // whose file name contains `LUT_` (#3454). Trimmed, so a trailing newline is still a path. + let path = src.trim(); + (!path.is_empty() && !path.contains('\n')).then_some(path) +} + +/// The files a [`lut_params`] parameter set to `src` reads: the ones its cached result was read +/// from (an OCIO config's also the LUTs it names, and the places it looked for missing ones; an +/// `.ocio` config not read yet is read), else the file it names. +pub fn lut_files(src: &str) -> Vec { + // A config not read yet is read now: the LUTs it names are its files from the first render. + if crate::ocio_config::cached_files(src).is_none() + && lut_file(src).is_some_and(|p| std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("ocio"))) + { + let _ = crate::ocio_config::load_config(src); + } + crate::ocio_config::cached_files(src) + .or_else(|| crate::ocio::cached_files(src)) + .or_else(|| file_cache::lock(LUTS.get()).files(src)) + .unwrap_or_else(|| lut_file(src).map(str::to_string).into_iter().collect()) +} + +/// Load (and cache) a LUT from inline `.cube` text or a file path. A file is stamped (one +/// `metadata` call) on every load and read again once its size or modification time moved (see +/// [`crate::file_cache`]); inline text is its own content. pub fn load_lut(src: &str) -> Option> { if src.trim().is_empty() { return None; } - static CACHE: OnceLock = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Ok(m) = cache.lock() - && let Some(v) = m.get(src) - { - return v.clone(); - } - let inline = src.contains('\n') || src.contains("LUT_"); - let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new); - if let Ok(mut m) = cache.lock() { - m.insert(src.to_string(), parsed.clone()); - } - parsed + file_cache::cached(LUTS.get(), src, || { + let lut = match lut_file(src) { + Some(path) => file_cache::read(path).ok().and_then(|t| parse_cube(&t)), + None => parse_cube(src), + } + .map(Arc::new); + let bytes = lut.as_ref().map_or(0, |l| lut_bytes(l)); + (lut, bytes) + }) +} + +/// The size of a LUT's tables. +pub(crate) fn lut_bytes(l: &Lut) -> usize { + l.d1.len().saturating_add(l.d3.len()).saturating_mul(std::mem::size_of::<[f32; 3]>()) } fn apply_lut(ctx: &EffectCtx, mut b: Buf) -> Buf { @@ -484,6 +529,114 @@ mod tests { assert_eq!(out.img, img); } + /// #3454: a path containing `LUT_` was taken for `.cube` text and the file never loaded. + #[test] + fn a_path_whose_name_contains_lut_loads_the_file() { + let dir = std::env::temp_dir().join(format!("ec-fx-3454-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("LUT_x.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let path = path.to_string_lossy().into_owned(); + assert!(load_lut(&path).is_some(), "{path}"); + assert!(load_lut(&format!("{path}\r\n")).is_some(), "{path} with a trailing newline"); + assert!(load_lut(&identity_cube(2)).is_some(), "inline text"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// #3493: a LUT file was read once per process; an edited or newly created file never loaded. + #[test] + fn a_lut_file_is_read_again_once_it_changes() { + const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n"; + const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n"; + let dir = std::env::temp_dir().join(format!("ec-fx-3493-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let write = |name: &str, text: &str, secs: u64| { + let path = dir.join(name); + std::fs::write(&path, text).unwrap(); + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap(); + path.to_string_lossy().into_owned() + }; + let at = |src: &str| load_lut(src).map(|l| l.apply([0.5; 3])); + let edited = write("edited.cube", BLACK, 1_000); + assert_eq!(at(&edited), Some([0.0; 3])); + write("edited.cube", WHITE, 2_000); + assert_eq!(at(&edited), Some([1.0; 3]), "an edited file, same size"); + let created = dir.join("created.cube").to_string_lossy().into_owned(); + assert_eq!(at(&created), None); + write("created.cube", WHITE, 1_000); + assert_eq!(at(&created), Some([1.0; 3]), "a file created after a failed load"); + let repaired = write("repaired.cube", "not a lut", 1_000); + assert_eq!(at(&repaired), None); + write("repaired.cube", WHITE, 2_000); + assert_eq!(at(&repaired), Some([1.0; 3]), "a repaired file"); + std::fs::remove_file(&repaired).unwrap(); + assert_eq!(at(&repaired), None, "a deleted file"); + // Inline text is its own content: the same text is the same LUT. + let inline = load_lut(BLACK).unwrap(); + assert!(Arc::ptr_eq(&inline, &load_lut(BLACK).unwrap())); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// A file that changed is noticed without a render loading it, and the generation that tells + /// frames made before it apart moves. + #[test] + fn check_lut_files_forgets_a_changed_file() { + let dir = std::env::temp_dir().join(format!("ec-fx-3493-check-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("grade.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let src = path.to_string_lossy().into_owned(); + assert!(load_lut(&src).is_some()); + let before = crate::check_lut_files(); + assert!(file_cache::lock(LUTS.get()).contains(&src), "an unchanged file stays"); + std::fs::write(&path, identity_cube(3)).unwrap(); + assert!(crate::check_lut_files() > before); + assert!(!file_cache::lock(LUTS.get()).contains(&src), "a changed file is read again"); + assert_eq!(load_lut(&src).map(|l| l.n3), Some(3)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// The cache keeps at most [`MAX_CACHED_LUTS`] LUTs, the least recently used going first. + #[test] + fn the_lut_cache_is_bounded() { + // (text no other test loads: a load elsewhere would make it recently used) + let oldest = format!("TITLE \"bounded first\"\n{}", identity_cube(2)); + let first = load_lut(&oldest).unwrap(); + for i in 0..MAX_CACHED_LUTS + 8 { + let text = format!("TITLE \"bounded {i}\"\n{}", identity_cube(2)); + assert!(load_lut(&text).is_some()); + assert!(file_cache::lock(LUTS.get()).len() <= MAX_CACHED_LUTS); + } + assert!(!Arc::ptr_eq(&first, &load_lut(&oldest).unwrap()), "the oldest was let go"); + let (bytes, sum, max) = file_cache::lock(LUTS.get()).bytes(); + assert_eq!(bytes, sum, "the bytes kept add up"); + assert!(bytes <= max); + } + + /// A LUT file whose LUT left the cache is still followed: its frames are dropped when it + /// changes, though no render loads it again first. + #[test] + fn a_file_whose_lut_left_the_cache_is_still_followed() { + let dir = std::env::temp_dir().join(format!("ec-fx-3493-evicted-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("old.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let src = path.to_string_lossy().into_owned(); + assert!(load_lut(&src).is_some()); + for i in 0..MAX_CACHED_LUTS + 8 { + assert!(load_lut(&format!("TITLE \"evict {i}\"\n{}", identity_cube(2))).is_some()); + } + assert!(!file_cache::lock(LUTS.get()).contains(&src), "its LUT left the cache"); + std::fs::write(&path, identity_cube(3)).unwrap(); + crate::check_lut_files(); + assert_eq!(file_cache::followed_stamp(&src), Some(file_cache::file_stamp(&src)), "the change was noticed"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn one_d_lut_with_domain() { let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap(); diff --git a/crates/effects/tests/it/gen_golden.rs b/crates/effects/tests/it/gen_golden.rs index 0f5fced8b..45092841a 100644 --- a/crates/effects/tests/it/gen_golden.rs +++ b/crates/effects/tests/it/gen_golden.rs @@ -413,46 +413,46 @@ fn plan_effects_golden_hashes() { const GOLDEN: &[(&str, u64)] = &[ ("ec.obsolete.lightning/0/8bpc/t0.4", 0xd829bb3a79ec2d61), ("ec.obsolete.lightning/0/8bpc/t1.3", 0xb2221364c0defe9b), - ("ec.obsolete.lightning/0/32bpc/t0.4", 0x828cc4e53d579af7), - ("ec.obsolete.lightning/0/32bpc/t1.3", 0x15770238cde9e766), - ("ec.obsolete.lightning/0/32bpc/half/t0.4", 0xf64f2b87fa9a09d3), - ("ec.obsolete.lightning/0/32bpc/half/t1.3", 0x625a00bd52fde5aa), + ("ec.obsolete.lightning/0/32bpc/t0.4", 0x38c8b3ca5d03eb81), + ("ec.obsolete.lightning/0/32bpc/t1.3", 0x552241c6035fa660), + ("ec.obsolete.lightning/0/32bpc/half/t0.4", 0x6158941311ce44d9), + ("ec.obsolete.lightning/0/32bpc/half/t1.3", 0xc7c2a804de6140a3), ("ec.obsolete.lightning/1/8bpc/t0.4", 0xa7c8cb94beebca84), ("ec.obsolete.lightning/1/8bpc/t1.3", 0x6e583b1d1f455099), - ("ec.obsolete.lightning/1/32bpc/t0.4", 0x2ffd973cc7de98f7), - ("ec.obsolete.lightning/1/32bpc/t1.3", 0x8f35e6018cac57d7), - ("ec.obsolete.lightning/1/32bpc/half/t0.4", 0x96c14e911783f8d6), - ("ec.obsolete.lightning/1/32bpc/half/t1.3", 0x620ee605f56c2068), + ("ec.obsolete.lightning/1/32bpc/t0.4", 0xe34f30bf669e94d5), + ("ec.obsolete.lightning/1/32bpc/t1.3", 0xb1ec8fad75bbbc32), + ("ec.obsolete.lightning/1/32bpc/half/t0.4", 0x9e2934559f25d0d9), + ("ec.obsolete.lightning/1/32bpc/half/t1.3", 0x8d2f6967b73bbf54), ("ec.obsolete.lightning/2/8bpc/t0.4", 0x119e5cedac21cd9b), ("ec.obsolete.lightning/2/8bpc/t1.3", 0x04b775fba837ddce), - ("ec.obsolete.lightning/2/32bpc/t0.4", 0xfee767091adac4ce), - ("ec.obsolete.lightning/2/32bpc/t1.3", 0x2ae26f9670c93751), - ("ec.obsolete.lightning/2/32bpc/half/t0.4", 0x99111f77bf26704f), - ("ec.obsolete.lightning/2/32bpc/half/t1.3", 0x8eb781efc14183c3), + ("ec.obsolete.lightning/2/32bpc/t0.4", 0x551415572abb270a), + ("ec.obsolete.lightning/2/32bpc/t1.3", 0x3e1b68b5b9cf2e8c), + ("ec.obsolete.lightning/2/32bpc/half/t0.4", 0x69b347a75e8ac280), + ("ec.obsolete.lightning/2/32bpc/half/t1.3", 0xe89cc144b611a083), ("ec.generate.advancedlightning/0/8bpc/t0.4", 0x5a53bf1e0236225d), ("ec.generate.advancedlightning/0/8bpc/t1.3", 0x5a53bf1e0236225d), - ("ec.generate.advancedlightning/0/32bpc/t0.4", 0x6817bf5b3583e0a0), - ("ec.generate.advancedlightning/0/32bpc/t1.3", 0x6817bf5b3583e0a0), - ("ec.generate.advancedlightning/0/32bpc/half/t0.4", 0x5dcc055f6cca9922), - ("ec.generate.advancedlightning/0/32bpc/half/t1.3", 0x5dcc055f6cca9922), + ("ec.generate.advancedlightning/0/32bpc/t0.4", 0x8ae323866971eaf4), + ("ec.generate.advancedlightning/0/32bpc/t1.3", 0x8ae323866971eaf4), + ("ec.generate.advancedlightning/0/32bpc/half/t0.4", 0x74b4ba806ab8e78b), + ("ec.generate.advancedlightning/0/32bpc/half/t1.3", 0x74b4ba806ab8e78b), ("ec.generate.advancedlightning/1/8bpc/t0.4", 0xf07f11457fa683d0), ("ec.generate.advancedlightning/1/8bpc/t1.3", 0xf07f11457fa683d0), - ("ec.generate.advancedlightning/1/32bpc/t0.4", 0x1b21531df9187431), - ("ec.generate.advancedlightning/1/32bpc/t1.3", 0x1b21531df9187431), - ("ec.generate.advancedlightning/1/32bpc/half/t0.4", 0xf1032a0a9345edf1), - ("ec.generate.advancedlightning/1/32bpc/half/t1.3", 0xf1032a0a9345edf1), + ("ec.generate.advancedlightning/1/32bpc/t0.4", 0x1474a59cbdbcb6aa), + ("ec.generate.advancedlightning/1/32bpc/t1.3", 0x1474a59cbdbcb6aa), + ("ec.generate.advancedlightning/1/32bpc/half/t0.4", 0x4808561ce9d8a4a6), + ("ec.generate.advancedlightning/1/32bpc/half/t1.3", 0x4808561ce9d8a4a6), ("ec.generate.advancedlightning/2/8bpc/t0.4", 0x62b5d179f05c6a4c), ("ec.generate.advancedlightning/2/8bpc/t1.3", 0x62b5d179f05c6a4c), - ("ec.generate.advancedlightning/2/32bpc/t0.4", 0x192256fe81f73975), - ("ec.generate.advancedlightning/2/32bpc/t1.3", 0x192256fe81f73975), - ("ec.generate.advancedlightning/2/32bpc/half/t0.4", 0x953dd51008c3ce31), - ("ec.generate.advancedlightning/2/32bpc/half/t1.3", 0x953dd51008c3ce31), + ("ec.generate.advancedlightning/2/32bpc/t0.4", 0x73c4b44db599a5c4), + ("ec.generate.advancedlightning/2/32bpc/t1.3", 0x73c4b44db599a5c4), + ("ec.generate.advancedlightning/2/32bpc/half/t0.4", 0x3085cf1b5f6e8311), + ("ec.generate.advancedlightning/2/32bpc/half/t1.3", 0x3085cf1b5f6e8311), ("ec.generate.advancedlightning/3/8bpc/t0.4", 0x57b6ad61bd3d89e0), ("ec.generate.advancedlightning/3/8bpc/t1.3", 0x57b6ad61bd3d89e0), - ("ec.generate.advancedlightning/3/32bpc/t0.4", 0x7e7c9d20e94c8b6c), - ("ec.generate.advancedlightning/3/32bpc/t1.3", 0x7e7c9d20e94c8b6c), - ("ec.generate.advancedlightning/3/32bpc/half/t0.4", 0xa37940f5a490a0fe), - ("ec.generate.advancedlightning/3/32bpc/half/t1.3", 0xa37940f5a490a0fe), + ("ec.generate.advancedlightning/3/32bpc/t0.4", 0x86eee3d36bfd41f2), + ("ec.generate.advancedlightning/3/32bpc/t1.3", 0x86eee3d36bfd41f2), + ("ec.generate.advancedlightning/3/32bpc/half/t0.4", 0xb24444985859d820), + ("ec.generate.advancedlightning/3/32bpc/half/t1.3", 0xb24444985859d820), ("ec.generate.radiowaves/0/8bpc/t0.4", 0xbc3643b8af12c44b), ("ec.generate.radiowaves/0/8bpc/t1.3", 0x703e948dc562e132), ("ec.generate.radiowaves/0/32bpc/t0.4", 0x2b0db208ee7517c1), @@ -479,8 +479,8 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.radiowaves/3/32bpc/half/t1.3", 0xcf62c191e2aa8cc4), ("ec.generate.vegas/0/8bpc/t0.4", 0x5e58d44254e9101b), ("ec.generate.vegas/0/8bpc/t1.3", 0x5e58d44254e9101b), - ("ec.generate.vegas/0/32bpc/t0.4", 0xe18712686e402759), - ("ec.generate.vegas/0/32bpc/t1.3", 0xe18712686e402759), + ("ec.generate.vegas/0/32bpc/t0.4", 0x2c6093dc655c6dbe), + ("ec.generate.vegas/0/32bpc/t1.3", 0x2c6093dc655c6dbe), ("ec.generate.vegas/0/32bpc/half/t0.4", 0xb037c97234bbd936), ("ec.generate.vegas/0/32bpc/half/t1.3", 0xb037c97234bbd936), ("ec.generate.vegas/1/8bpc/t0.4", 0x68566eb5dc411cd3), @@ -491,8 +491,8 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.vegas/1/32bpc/half/t1.3", 0xe2f8f5f4a12628d6), ("ec.generate.vegas/2/8bpc/t0.4", 0x002c48a91aea2dcf), ("ec.generate.vegas/2/8bpc/t1.3", 0x002c48a91aea2dcf), - ("ec.generate.vegas/2/32bpc/t0.4", 0x4f550bbbf8b8316b), - ("ec.generate.vegas/2/32bpc/t1.3", 0x4f550bbbf8b8316b), + ("ec.generate.vegas/2/32bpc/t0.4", 0xd703631844687600), + ("ec.generate.vegas/2/32bpc/t1.3", 0xd703631844687600), ("ec.generate.vegas/2/32bpc/half/t0.4", 0x30036df3e56a01ed), ("ec.generate.vegas/2/32bpc/half/t1.3", 0x30036df3e56a01ed), ("ec.generate.stroke/0/8bpc/t0.4", 0xa580fbfb806d655c), @@ -557,10 +557,10 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.paintbucket/1/32bpc/half/t1.3", 0xf5ea3b0a7ac224ed), ("ec.generate.paintbucket/2/8bpc/t0.4", 0x530d0a248dc22c72), ("ec.generate.paintbucket/2/8bpc/t1.3", 0x530d0a248dc22c72), - ("ec.generate.paintbucket/2/32bpc/t0.4", 0xe1b655f8815e1357), - ("ec.generate.paintbucket/2/32bpc/t1.3", 0xe1b655f8815e1357), - ("ec.generate.paintbucket/2/32bpc/half/t0.4", 0xb95b3b205255cd7e), - ("ec.generate.paintbucket/2/32bpc/half/t1.3", 0xb95b3b205255cd7e), + ("ec.generate.paintbucket/2/32bpc/t0.4", 0xf05e50b4261103a3), + ("ec.generate.paintbucket/2/32bpc/t1.3", 0xf05e50b4261103a3), + ("ec.generate.paintbucket/2/32bpc/half/t0.4", 0x7e74e09fcc83615c), + ("ec.generate.paintbucket/2/32bpc/half/t1.3", 0x7e74e09fcc83615c), ("ec.generate.paintbucket/3/8bpc/t0.4", 0xa7485b7277e03e66), ("ec.generate.paintbucket/3/8bpc/t1.3", 0xa7485b7277e03e66), ("ec.generate.paintbucket/3/32bpc/t0.4", 0xc8e06c6eed30448b), @@ -666,16 +666,16 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.key.innerouter/0/32bpc/half/t1.3", 0x003941eb2cd18339), ("ec.key.innerouter/1/8bpc/t0.4", 0xd7bfea3d9550db56), ("ec.key.innerouter/1/8bpc/t1.3", 0xd7bfea3d9550db56), - ("ec.key.innerouter/1/32bpc/t0.4", 0x62f7705e43cc6a71), - ("ec.key.innerouter/1/32bpc/t1.3", 0x62f7705e43cc6a71), - ("ec.key.innerouter/1/32bpc/half/t0.4", 0x22f15f770f22f072), - ("ec.key.innerouter/1/32bpc/half/t1.3", 0x22f15f770f22f072), + ("ec.key.innerouter/1/32bpc/t0.4", 0x36661c78c393b52a), + ("ec.key.innerouter/1/32bpc/t1.3", 0x36661c78c393b52a), + ("ec.key.innerouter/1/32bpc/half/t0.4", 0x73f19ba1aea7e496), + ("ec.key.innerouter/1/32bpc/half/t1.3", 0x73f19ba1aea7e496), ("ec.key.innerouter/2/8bpc/t0.4", 0x7bdece44f841bb11), ("ec.key.innerouter/2/8bpc/t1.3", 0x7bdece44f841bb11), - ("ec.key.innerouter/2/32bpc/t0.4", 0x5130a1fc09c42120), - ("ec.key.innerouter/2/32bpc/t1.3", 0x5130a1fc09c42120), - ("ec.key.innerouter/2/32bpc/half/t0.4", 0x46c0880853618fc8), - ("ec.key.innerouter/2/32bpc/half/t1.3", 0x46c0880853618fc8), + ("ec.key.innerouter/2/32bpc/t0.4", 0x093157401c50396e), + ("ec.key.innerouter/2/32bpc/t1.3", 0x093157401c50396e), + ("ec.key.innerouter/2/32bpc/half/t0.4", 0xbde58c0668a48529), + ("ec.key.innerouter/2/32bpc/half/t1.3", 0xbde58c0668a48529), ("ec.color.colorlink/0/8bpc/t0.4", 0x539d1da6b2ed744a), ("ec.color.colorlink/0/8bpc/t1.3", 0x539d1da6b2ed744a), ("ec.color.colorlink/0/32bpc/t0.4", 0x67ed14da67ce1a97), diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 388790901..4a0f3858b 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -537,18 +537,18 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.caustics/1/8bpc/t1.5", 0x282968317c36ccdf), ("ec.sim.caustics/1/8bpc/t0.8", 0x282968317c36ccdf), ("ec.sim.caustics/1/8bpc/t2.2", 0x282968317c36ccdf), - ("ec.sim.caustics/1/32bpc/t0.5", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t1.5", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t0.8", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t2.2", 0xc12df8561f396d45), + ("ec.sim.caustics/1/32bpc/t0.5", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t1.5", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t0.8", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t2.2", 0xaa63d15038f75de2), ("ec.sim.caustics/2/8bpc/t0.5", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t1.5", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t0.8", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t2.2", 0x52ab258ae56e9b15), - ("ec.sim.caustics/2/32bpc/t0.5", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t1.5", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t0.8", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t2.2", 0x9a820d0e7c6d11ea), + ("ec.sim.caustics/2/32bpc/t0.5", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t1.5", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t0.8", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t2.2", 0x51d4304b505b5462), ("ec.sim.waveworld/0/8bpc/t0.5", 0x425d5d639d2a6495), ("ec.sim.waveworld/0/8bpc/t1.5", 0x233d7370e580804a), ("ec.sim.waveworld/0/8bpc/t0.8", 0x866da86ce5e78eb2), diff --git a/crates/engine/src/analysis.rs b/crates/engine/src/analysis.rs index fa2b8b4ec..a1adb6dac 100644 --- a/crates/engine/src/analysis.rs +++ b/crates/engine/src/analysis.rs @@ -1,4 +1,5 @@ pub mod camera_track; +pub(crate) mod frames_key; pub mod mask_track; pub mod models; pub mod roto; diff --git a/crates/engine/src/analysis/camera_track.rs b/crates/engine/src/analysis/camera_track.rs index 29961f533..8209af543 100644 --- a/crates/engine/src/analysis/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -26,9 +26,10 @@ use effectcraft_track::camtrack::linalg::{self, Similarity, V3}; use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer}; use serde::Serialize; +use crate::analysis::frames_key; use crate::offload::JobKind; use crate::tracking::lock; -use crate::warp::{analysis_times, signature}; +use crate::warp::analysis_times; use crate::{Event, Session}; /// Live analysis progress (serde for agents / the control channel). @@ -81,7 +82,7 @@ pub struct CameraJob { pub comp: ItemId, pub layer: LayerId, pub effect: Uid, - /// The layer signature the tracks are made from, and the solve settings key. + /// What the tracks are made from (`tracks_key`), and the solve settings key. key: String, solve_key: String, } @@ -122,10 +123,21 @@ fn str_of(g: &PropGroup, m: &str) -> String { } } -/// What a tracker's tracks are made from: the layer's frames and Detailed Analysis. +/// What a tracker's tracks are made from: its input (the layer's frames under its masks and the +/// effects above it, `frames_key::analysis_key`) and Detailed Analysis. fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String { + tracks_key_of(frames_key::analysis_key(project, cid, l, g.uid), g) +} + +fn tracks_key_of(input: String, g: &PropGroup) -> String { let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool); - format!("{}{}", signature(project, cid, l), if detailed { "+detailed" } else { "" }) + format!("{input}{}", if detailed { "+detailed" } else { "" }) +} + +/// Whether the tracker's saved tracks are made from its current input and settings, after an +/// edit from `before` to `project` ([`frames_key::analysis_key_matches`]). +fn tracks_match(before: &Project, project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> bool { + frames_key::analysis_key_matches(before, project, cid, l, g.uid, &str_of(g, TRACKS_KEY), |k| tracks_key_of(k, g)) } /// Parameters of an instance from static values (the tracker's settings are not animated). @@ -165,7 +177,7 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id)); for g in fx.groups().filter(|g| is_tracker(g)) { let tracked = !str_of(g, TRACKS).is_empty(); - if tracked && str_of(g, TRACKS_KEY) != tracks_key(after, *cid, l, g) { + if tracked && !tracks_match(before, after, *cid, l, g) { clears.push((*cid, l.id, g.uid, true)); todo.push((*cid, l.id, g.uid)); } else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) { @@ -325,7 +337,7 @@ impl Session { let key = tracks_key(&self.project, comp, l, g); let settings = ct::settings(¶ms); let solve_key = ct::solve_key(&settings); - let tracks = ct::tracks(¶ms).filter(|_| str_of(g, TRACKS_KEY) == key); + let tracks = ct::tracks(¶ms).filter(|_| tracks_match(&self.project, &self.project, comp, l, g)); let n = times.len(); let work = CamWork { project: self.project.clone(), diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs new file mode 100644 index 000000000..abbafde67 --- /dev/null +++ b/crates/engine/src/analysis/frames_key.rs @@ -0,0 +1,777 @@ +//! What an analysis of a layer is made from. One writer keys the Warp Stabilizer and the 3D +//! Camera Tracker ([`analysis_key`]: frames, masks, effects above, a precomp's contents) and Roto +//! Brush / Object Matte ([`input_key`]: frames, masks, effects above). +//! +//! The key hashes a text this module writes field by field. The source item (footage and its +//! settings, solid), the Time Remap property, the masks and effect groups (their properties, +//! keyframes and values) and a precomp's comp and layers are destructured or matched in full, and +//! each variant is written as a fixed word (or, for an enum the project file names, its name +//! there): a new field or variant doesn't compile until it is decided here, written if it changes +//! the layer's frames, left out (with a reason) if it doesn't. Renaming a field or variant doesn't +//! change keys. Of the analysed layer itself, only the fields named in `layer_frames` and +//! [`analysis_key`] are read. The text leaves out observations (whether footage is available) and +//! data that never changes pixels (colour labels, UI hints, markers but a comp's timing markers; +//! only expressions read the others), so they never drop saved +//! analyses (#3534, #3536). +//! +//! Keys saved by 0.22–0.24 hashed derived `Debug` text instead. [`legacy`] still recognises +//! them, and a matching saved key is kept rather than rewritten: frozen Roto Brush mattes and +//! segmentations hash the saved key itself. + +use std::collections::HashSet; +use std::fmt::Write as _; + +use effectcraft_color::ColorSpace; +use effectcraft_keyframe::{Ease, FeatherPoint, Gradient, Interp, Keyframe, ShapePath, Value}; +use effectcraft_project::{ + AlphaMode, CineonSettings, CineonUnits, Comp, Expression, FeatherFalloff, FieldOrder, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, + LayerSource, LightKind, Marker, MaskMode, MaskMotionBlur, Node, PrimitiveKind, Project, PropGroup, Property, Solid, SourceLayer, Switches, TrackMatte, Uid, + essential, styles::GlobalLight, +}; +use effectcraft_time::{FrameRate, Tick}; + +pub(crate) mod legacy; + +/// The version of the text, its first word: a later change to what it writes bumps it. +const VERSION: &str = "frames/1"; + +/// Property groups nest a few levels (effect ▸ group ▸ property); deeper trees are cut here. +const MAX_DEPTH: usize = 32; + +/// Precomps nest deeper than the renderer draws them (16) only in broken projects; deeper +/// contents are cut here. +const MAX_NESTING: usize = 32; + +fn fnv(s: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + h +} + +fn hex(s: &str) -> String { + format!("{:016x}", fnv(s)) +} + +/// Which signature a key is made from: today's, or one an earlier release saved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Era { + Current, + Legacy, +} + +/// The signature of `layer`'s frames: its source (and the source item), In/Out, start time, +/// stretch, Time Remap and the comp frame rate. Roto Brush Input Keys and [`analysis_key`] are +/// made from it. +pub(crate) fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { + hex(&signature_text(p, comp, layer)) +} + +/// The text [`signature`] hashes. +pub(crate) fn signature_text(p: &Project, comp: ItemId, layer: &Layer) -> String { + let mut t = Text::new(); + t.word(VERSION); + t.layer_frames(p, comp, layer); + t.0 +} + +/// A Roto Brush's (or Object Matte's) Input Key: the layer's frames, its masks and the effects +/// above the instance `uid`. +pub(crate) fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { + input_key_of(signature(p, comp, layer), layer, uid) +} + +/// An Input Key from a [`signature`]. +pub(crate) fn input_key_of(signature: String, layer: &Layer, uid: Uid) -> String { + let mut t = Text::new(); + t.word(VERSION); + t.word(&signature); + t.inputs_above(layer, uid, true); + hex(&t.0) +} + +/// The first word of an [`analysis_key`]'s text: a later change to what it writes bumps it. +const ANALYSIS_VERSION: &str = "analysis/1"; + +/// What a Warp Stabilizer or 3D Camera Tracker, the instance `uid` of `layer`, analyses: its +/// input, the layer's frames ([`signature`]) under its masks and the effects above it, and a +/// precomp's contents (#3535). The instance's own settings and the effects below it are not part +/// of it. +pub(crate) fn analysis_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { + let mut t = Text::new(); + t.word(ANALYSIS_VERSION); + t.word(&signature(p, comp, layer)); + // The Effects switch turns the effects above on or off (it has none when it is the first). + if layer.effects().and_then(|fx| fx.groups().next()).is_some_and(|g| g.uid != uid) { + t.field("effectsSwitch", layer.switches.effects); + } + // While it is off the renderer skips the whole effect stack: the effects above draw nothing. + t.inputs_above(layer, uid, layer.switches.effects); + t.precomp(p, comp, layer); + hex(&t.0) +} + +/// Whether `saved`, the key saved with the analysis of instance `uid` of `layer` in `after`, is +/// for its current input ([`analysis_key`]). `key` makes the saved form from an input key or a +/// frames signature (the tracker adds its Detailed Analysis). +/// +/// Keys saved before #3535 hashed only the layer's frames ([`signature`], or a [`legacy`] one). +/// Such a key is taken to be for the input the layer had when it was opened, and stays valid +/// while each edit (from `before` to `after`) keeps the input: the first edit that changes it +/// clears the analysis. A layer or instance new in `after` (pasted, opened) keeps such a key +/// while its frames match. +pub(crate) fn analysis_key_matches( + before: &Project, + after: &Project, + comp: ItemId, + layer: &Layer, + uid: Uid, + saved: &str, + key: impl Fn(String) -> String, +) -> bool { + let now = analysis_key(after, comp, layer, uid); + if key(now.clone()) == saved { + return true; + } + let old = before.comp(comp).and_then(|c| c.layer(layer.id)).filter(|l| l.props.find_group(uid).is_some()); + match old { + Some(old) => key_matches(before, comp, old, saved, |s, _| key(s)) && analysis_key(before, comp, old, uid) == now, + None => key_matches(after, comp, layer, saved, |s, _| key(s)), + } +} + +/// Whether `saved`, a key saved with an analysis of `layer`, is for the layer's current frames. +/// `key` makes such a key from a signature of the given era. A key an earlier release saved for +/// the same frames matches too: keep it rather than rewrite it (analysing writes today's). +pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String, Era) -> String) -> bool { + key(signature(p, comp, layer), Era::Current) == saved || legacy::signatures(p, comp, layer).into_iter().any(|s| key(s, Era::Legacy) == saved) +} + +/// The text a key is hashed from. Fields are `name=value;`; strings are quoted and escaped, so +/// no value can end its field early. +struct Text(String); + +impl Text { + fn new() -> Text { + Text(String::new()) + } + + fn word(&mut self, w: &str) { + self.0.push_str(w); + self.0.push(';'); + } + + fn field(&mut self, name: &str, value: impl std::fmt::Display) { + let _ = write!(self.0, "{name}={value};"); + } + + fn str(&mut self, name: &str, s: &str) { + let _ = write!(self.0, "{name}={s:?};"); + } + + fn num(&mut self, name: &str, v: f64) { + let _ = write!(self.0, "{name}={v:?};"); + } + + fn nums(&mut self, name: &str, v: &[f64]) { + let _ = write!(self.0, "{name}={v:?};"); + } + + fn floats(&mut self, name: &str, v: &[f32]) { + let _ = write!(self.0, "{name}={v:?};"); + } + + fn opt(&mut self, name: &str, v: Option<&T>, write: impl FnOnce(&mut Text, &T)) { + match v { + Some(v) => { + let _ = write!(self.0, "{name}{{"); + write(self, v); + self.0.push_str("};"); + } + None => self.word(&format!("{name}=none")), + } + } + + fn tick(&mut self, name: &str, t: Tick) { + self.field(name, t.0); + } + + fn rate(&mut self, name: &str, r: FrameRate) { + let FrameRate { num, den } = r; + let _ = write!(self.0, "{name}={num}/{den};"); + } + + /// What a layer's frames are made of, before its masks and effects. + fn layer_frames(&mut self, p: &Project, comp: ItemId, layer: &Layer) { + self.layer_source(&layer.source); + if let Some(item) = layer.source.item() { + match p.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => self.footage(f), + Some(ItemKind::Solid(s)) => self.solid(s), + // Its contents are in an [`analysis_key`], not here: Roto Brush keys are made from this. + Some(ItemKind::Comp(c)) => { + self.word("comp"); + self.field("w", c.width); + self.field("h", c.height); + self.rate("rate", c.frame_rate); + self.tick("duration", c.duration); + } + Some(ItemKind::Folder) | None => self.word("item=none"), + } + } + self.tick("in", layer.in_point); + self.tick("out", layer.out_point); + self.tick("start", layer.start_time); + self.num("stretch", layer.stretch); + self.opt("timeRemap", layer.props.get("timeRemap"), |t, pr| t.property(pr)); + self.opt("compRate", p.comp(comp).map(|c| &c.frame_rate), |t, r| t.rate("rate", *r)); + } + + fn layer_source(&mut self, s: &LayerSource) { + match s { + LayerSource::Footage { item } => self.field("footage", item.0), + LayerSource::Comp { item } => self.field("comp", item.0), + LayerSource::Solid { item } => self.field("solid", item.0), + LayerSource::Model { item } => self.field("model", item.0), + LayerSource::Text => self.word("text"), + LayerSource::Shape => self.word("shape"), + LayerSource::Null => self.word("null"), + LayerSource::Camera => self.word("camera"), + LayerSource::Light { kind } => self.field( + "light", + match kind { + LightKind::Parallel => "parallel", + LightKind::Spot => "spot", + LightKind::Point => "point", + LightKind::Ambient => "ambient", + LightKind::Environment => "environment", + }, + ), + LayerSource::Primitive { kind } => self.field( + "primitive", + match kind { + PrimitiveKind::Cube => "cube", + PrimitiveKind::Sphere => "sphere", + PrimitiveKind::Plane => "plane", + PrimitiveKind::Torus => "torus", + PrimitiveKind::Cone => "cone", + PrimitiveKind::Cylinder => "cylinder", + }, + ), + } + } + + fn solid(&mut self, s: &Solid) { + let Solid { color, width, height, pixel_aspect } = s; + self.word("solid"); + self.floats("color", color); + self.field("w", width); + self.field("h", height); + self.num("par", *pixel_aspect); + } + + fn footage(&mut self, f: &Footage) { + let Footage { + path, + kind, + width, + height, + pixel_aspect, + frame_rate, + native_rate, + duration, + has_video, + has_audio, + alpha, + premul_color, + loop_count, + codec, + // Whether the file is there is an observation (`footage.check`), not an edit: an + // offline file is the same footage, and its analyses stay valid (#3534). + missing: _, + sequence, + color_profile, + layer, + fields, + invert_alpha, + linear_light, + preserve_rgb, + cineon, + data, + page, + alphabetical, + start_timecode, + file_timecode, + // Markers annotate the footage; they never change its frames. + markers: _, + placed_page, + } = f; + self.word("footage"); + self.str("path", path); + self.field( + "kind", + match kind { + FootageKind::Video => "video", + FootageKind::Still => "still", + FootageKind::Sequence => "sequence", + FootageKind::Audio => "audio", + FootageKind::Model => "model", + FootageKind::Data => "data", + }, + ); + self.field("w", width); + self.field("h", height); + self.num("par", *pixel_aspect); + self.rate("rate", *frame_rate); + self.opt("nativeRate", native_rate.as_ref(), |t, r| t.rate("rate", *r)); + self.tick("duration", *duration); + self.field("video", has_video); + self.field("audio", has_audio); + self.field( + "alpha", + match alpha { + AlphaMode::Straight => "straight", + AlphaMode::Premultiplied => "premultiplied", + AlphaMode::Ignore => "ignore", + }, + ); + self.floats("matte", premul_color); + self.field("loop", loop_count); + self.str("codec", codec); + self.field("frames", sequence.len()); + for s in sequence { + self.str("file", s); + } + self.opt("profile", color_profile.as_ref(), |t, c| t.word(color_space(*c))); + self.opt("layer", layer.as_ref(), |t, l| t.source_layer(l)); + self.field( + "fields", + match fields { + FieldOrder::Off => "off", + FieldOrder::UpperFirst => "upper", + FieldOrder::LowerFirst => "lower", + }, + ); + self.field("invertAlpha", invert_alpha); + self.field("linearLight", linear_light); + self.field("preserveRgb", preserve_rgb); + self.cineon(cineon); + self.opt("data", data.as_ref(), |t, d| t.str("text", d)); + self.field("page", page); + self.field("alphabetical", alphabetical); + self.opt("startTimecode", start_timecode.as_ref(), |t, v| t.field("frame", v)); + self.opt("fileTimecode", file_timecode.as_ref(), |t, v| t.field("frame", v)); + self.field("placedPage", placed_page); + } + + fn source_layer(&mut self, l: &SourceLayer) { + let SourceLayer { index, name, layer_size, embedded, placed } = l; + self.field("index", index); + // The layer is found by its index; the name only labels it. + let _ = name; + self.field("layerSize", layer_size); + self.opt("embedded", embedded.as_ref(), |t, e| t.str("id", e)); + self.field("placed", placed); + } + + fn cineon(&mut self, c: &CineonSettings) { + let CineonSettings { log_conversion, converted_black, converted_white, black, white, gamma, rolloff, units } = c; + self.word("cineon"); + self.field("log", log_conversion); + self.floats("points", &[*converted_black, *converted_white, *black, *white, *gamma, *rolloff]); + self.field( + "units", + match units { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + }, + ); + } + + /// The layer's masks and the effects above the instance `uid`: what the effect's input is + /// made of besides the layer's frames. + /// `effects`: whether to write the effects (a Roto Brush Input Key always does: frozen mattes + /// hash the saved key, so it keeps its text). + fn inputs_above(&mut self, layer: &Layer, uid: Uid, effects: bool) { + if let Some(m) = layer.masks() { + self.word("masks"); + self.group(m, 0); + } + if let Some(fx) = layer.effects() { + self.word("effects"); + for g in fx.groups().take_while(|g| effects && g.uid != uid) { + self.group(g, 0); + } + } + } + + /// A precomp layer's contents: its Essential Properties (they set values inside it) and what + /// its comp draws, nested precomps included. + fn precomp(&mut self, p: &Project, comp: ItemId, layer: &Layer) { + let LayerSource::Comp { item } = layer.source else { return }; + if let Some(e) = layer.props.group(essential::GROUP) { + self.word("essential"); + self.group(e, 0); + } + // A precomp never draws the comp it is in. + let mut seen = HashSet::from([comp]); + self.comp_contents(p, item, 0, &mut seen); + } + + /// What comp `id` draws. Each comp is written once per key, where it is first reached; a + /// later reference (a comp used twice, or a cycle) writes only its id, so the work is bounded + /// by the size of the project. + fn comp_contents(&mut self, p: &Project, id: ItemId, depth: usize, seen: &mut HashSet) { + self.field("contents", id.0); + // Before `seen`: a comp first reached too deep is written in full where it is shallower. + if depth >= MAX_NESTING { + self.word("tooDeep"); + return; + } + if !seen.insert(id) { + self.word("seen"); + return; + } + let Some(ItemKind::Comp(c)) = p.item(id).map(|i| &i.kind) else { + self.word("none"); + return; + }; + let Comp { + width, + height, + pixel_aspect, + frame_rate, + duration, + display_start, + background, + // The work area, Hide Shy, the viewer's Resolution and guides, and the poster frame + // only show the comp or say what to preview; they never change its frames. + work_area: _, + layers, + markers, + shutter_angle, + shutter_phase, + motion_blur_samples, + motion_blur_adaptive_limit, + renderer, + hide_shy: _, + enable_motion_blur, + enable_frame_blending, + draft_3d, + preserve_frame_rate, + preserve_resolution, + poster_time: _, + global_light, + guides: _, + essential, + resolution: _, + } = &**c; + self.field("w", width); + self.field("h", height); + self.num("par", *pixel_aspect); + self.rate("rate", *frame_rate); + self.tick("duration", *duration); + self.tick("displayStart", *display_start); + self.floats("background", background); + self.timing_markers(markers); + self.nums("shutter", &[*shutter_angle, *shutter_phase]); + self.field("samples", format_args!("{motion_blur_samples}/{motion_blur_adaptive_limit}")); + self.form("renderer", renderer); + self.field("motionBlur", enable_motion_blur); + self.field("frameBlending", enable_frame_blending); + self.field("draft3d", draft_3d); + self.field("preserveRate", preserve_frame_rate); + self.field("preserveResolution", preserve_resolution); + let GlobalLight { angle, altitude } = global_light; + self.property(angle); + self.property(altitude); + self.opt("essentialGraphics", essential.as_ref(), |t, e| t.form("definition", e)); + for l in layers.iter() { + self.nested_layer(p, l, depth, seen); + } + self.word("end"); + } + + /// A layer of a precomp. + fn nested_layer(&mut self, p: &Project, l: &Layer, depth: usize, seen: &mut HashSet) { + let Layer { + id, + name, + // Whether the name follows the Source Text changes nothing until the name changes. + name_follows_text: _, + source, + // Labels and comments only show the layer. + label: _, + comment: _, + start_time, + in_point, + out_point, + stretch, + switches, + blend_mode, + preserve_transparency, + track_matte, + parent, + // Layer markers annotate it (only expressions read them, as for the analysed layer); + // Lock Markers protects them. + markers: _, + markers_locked: _, + auto_orient, + environment, + environment_background, + props, + } = l; + self.field("layer", id.0); + // Expressions find layers by name (`thisComp.layer("Clip")`), as they find effects. + self.str("name", name); + self.layer_source(source); + if let Some(item) = source.item() { + match p.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => self.footage(f), + Some(ItemKind::Solid(s)) => self.solid(s), + Some(ItemKind::Comp(_)) => self.comp_contents(p, item, depth.saturating_add(1), seen), + Some(ItemKind::Folder) | None => self.word("item=none"), + } + } + self.tick("in", *in_point); + self.tick("out", *out_point); + self.tick("start", *start_time); + self.num("stretch", *stretch); + self.switches(switches); + self.form("blend", blend_mode); + self.field("preserveTransparency", preserve_transparency); + self.opt("matte", track_matte.as_ref(), |t, m| { + let TrackMatte { layer, kind } = m; + t.field("layer", layer.0); + t.form("kind", kind); + }); + self.opt("parent", parent.as_ref(), |t, id| t.field("id", id.0)); + self.form("autoOrient", auto_orient); + self.field("environment", format_args!("{environment}/{environment_background}")); + self.group(props, 0); + } + + fn switches(&mut self, s: &Switches) { + // Lock and Shy only protect and hide the layer in the Timeline. Audio is written (as are + // the Audio group and audio effects, in the property tree): a waveform effect can draw it. + let Switches { video, audio, solo, locked: _, shy: _, collapse, quality, sampling, effects, frame_blend, motion_blur, adjustment, three_d, guide } = s; + self.field("switches", format_args!("{video}/{audio}/{solo}/{collapse}/{effects}/{motion_blur}/{adjustment}/{three_d}/{guide}")); + self.form("quality", quality); + self.form("sampling", sampling); + self.form("frameBlend", frame_blend); + } + + /// A comp's protected markers with a duration: they set its timing (Responsive Design). + /// Other markers annotate it; only expressions read them. + fn timing_markers(&mut self, markers: &[Marker]) { + for m in markers.iter().filter(|m| m.protected && m.duration > Tick::ZERO) { + self.tick("timingMarker", m.time); + self.tick("for", m.duration); + } + } + + /// A value in its project-file form: enums the project file names, and the Essential Graphics + /// definition. + fn form(&mut self, name: &str, v: &T) { + self.str(name, &serde_json::to_string(v).unwrap_or_default()); + } + + fn group(&mut self, g: &PropGroup, depth: usize) { + // Expressions find effects and groups by name (`effect("Blur")`, `propertyGroup(1).name`). + // The uid and colour label never change pixels. + let PropGroup { uid: _, match_id, name, kind, enabled, label: _, children } = g; + if depth >= MAX_DEPTH { + self.word("tooDeep"); + return; + } + self.str("group", match_id); + self.str("name", name); + self.group_kind(kind); + self.field("enabled", enabled); + for c in children { + match c { + Node::Prop(p) => self.property(p), + Node::Group(g) => self.group(g, depth + 1), + } + } + self.word("end"); + } + + fn group_kind(&mut self, k: &GroupKind) { + match k { + GroupKind::Plain => self.word("plain"), + GroupKind::Indexed => self.word("indexed"), + // A mask's colour and lock only show it in the UI. + GroupKind::Mask { mode, inverted, color: _, locked: _, motion_blur, feather_falloff, roto_bezier } => { + self.field( + "mask", + match mode { + MaskMode::None => "none", + MaskMode::Add => "add", + MaskMode::Subtract => "subtract", + MaskMode::Intersect => "intersect", + MaskMode::Lighten => "lighten", + MaskMode::Darken => "darken", + MaskMode::Difference => "difference", + }, + ); + self.field("inverted", inverted); + self.field( + "motionBlur", + match motion_blur { + MaskMotionBlur::SameAsLayer => "layer", + MaskMotionBlur::On => "on", + MaskMotionBlur::Off => "off", + }, + ); + self.field( + "falloff", + match feather_falloff { + FeatherFalloff::Smooth => "smooth", + FeatherFalloff::Linear => "linear", + }, + ); + self.field("rotoBezier", roto_bezier); + } + GroupKind::Effect { effect } => self.str("effect", effect), + // A motion tracker's settings say how to track, not how to draw the layer. + GroupKind::Tracker { settings: _ } => self.word("tracker"), + GroupKind::Essential { overridden } => { + self.word("essential"); + for u in overridden { + self.field("overridden", u); + } + } + } + } + + fn property(&mut self, pr: &Property) { + // Expressions read the name (`thisProperty.name`) and seed `random`/`wiggle` with the + // uid: the uid is written for a property with an expression. UI hints and the 2D/3D-only + // and hold-only flags say where and how it can be edited, not what it evaluates to. + let Property { uid, match_id, name, value, keys, expr, ui: _, spatial, hold_only: _, three_d_only: _, two_d_only: _, shown_dims: _, static_only: _ } = + pr; + self.str("prop", match_id); + self.str("name", name); + if expr.is_some() { + self.field("uid", uid); + } + self.value(value); + self.field("spatial", spatial); + for k in keys { + self.keyframe(k); + } + self.opt("expr", expr.as_ref(), |t, e| { + let Expression { text, enabled } = e; + t.str("text", text); + t.field("enabled", enabled); + }); + } + + fn keyframe(&mut self, k: &Keyframe) { + // A key's colour label only shows it in the UI. + let Keyframe { + time, + value, + in_interp, + out_interp, + in_ease, + out_ease, + continuous, + auto_bezier, + spatial_in, + spatial_out, + spatial_auto, + spatial_continuous, + roving, + label: _, + muted, + } = k; + self.tick("key", *time); + self.value(value); + self.field("interp", format_args!("{}/{}", interp(*in_interp), interp(*out_interp))); + for (side, eases) in [("inEase", in_ease), ("outEase", out_ease)] { + for e in eases { + let Ease { speed, influence } = e; + self.nums(side, &[*speed, *influence]); + } + } + self.field("continuous", continuous); + self.field("autoBezier", auto_bezier); + self.nums("spatialIn", spatial_in); + self.nums("spatialOut", spatial_out); + self.field("spatialAuto", spatial_auto); + self.field("spatialContinuous", spatial_continuous); + self.field("roving", roving); + self.field("muted", muted); + } + + fn value(&mut self, v: &Value) { + match v { + Value::Scalar(x) => self.num("scalar", *x), + Value::Vec2(x) => self.nums("vec2", x), + Value::Vec3(x) => self.nums("vec3", x), + Value::Color(x) => self.nums("color", x), + Value::Bool(b) => self.field("bool", b), + Value::Enum(i) => self.field("enum", i), + Value::Path(p) => self.shape_path(p), + // A text document has dozens of fields and no place in what a key covers today (a + // text layer's Source Text is not under its masks or effects): its project-file form. + Value::Text(d) => self.str("text", &serde_json::to_string(d).unwrap_or_default()), + Value::Layer(l) => self.opt("layer", l.as_ref(), |t, id| t.field("id", id)), + Value::Gradient(g) => { + let Gradient { colors, opacities } = g; + self.word("gradient"); + for (at, c) in colors { + self.num("stop", *at); + self.floats("rgba", c); + } + for (at, o) in opacities { + self.num("stop", *at); + self.floats("opacity", &[*o]); + } + } + Value::Str(s) => self.str("str", s), + } + } + + fn shape_path(&mut self, p: &ShapePath) { + let ShapePath { vertices, in_tangents, out_tangents, closed, feather } = p; + self.word("path"); + for (name, points) in [("v", vertices), ("in", in_tangents), ("out", out_tangents)] { + for pt in points { + self.nums(name, pt); + } + } + self.field("closed", closed); + for f in feather { + let FeatherPoint { segment, t, radius, tension } = f; + self.field("featherSegment", segment); + self.nums("feather", &[*t, *radius, *tension]); + } + } +} + +fn interp(i: Interp) -> &'static str { + match i { + Interp::Linear => "linear", + Interp::Bezier => "bezier", + Interp::Hold => "hold", + } +} + +fn color_space(c: ColorSpace) -> &'static str { + match c { + ColorSpace::Srgb => "srgb", + ColorSpace::Rec709 => "rec709", + ColorSpace::Rec2020 => "rec2020", + ColorSpace::DisplayP3 => "p3", + ColorSpace::AcesCg => "acescg", + ColorSpace::Aces2065 => "aces2065", + ColorSpace::Rec2100Pq => "rec2100pq", + ColorSpace::Rec2100Hlg => "rec2100hlg", + } +} diff --git a/crates/engine/src/analysis/frames_key/legacy.rs b/crates/engine/src/analysis/frames_key/legacy.rs new file mode 100644 index 000000000..882c82d80 --- /dev/null +++ b/crates/engine/src/analysis/frames_key/legacy.rs @@ -0,0 +1,147 @@ +//! Signatures as 0.22–0.24 wrote them, to recognise the keys those releases saved. Nothing new is +//! keyed with them. They hash derived `Debug` text, so `tests::analysis_signature` pins keys +//! those releases saved: if a type change alters this text, write that part out here. + +use effectcraft_project::{Footage, ItemId, ItemKind, Layer, LayerSource, Project, Uid}; + +use super::fnv; + +/// The signatures a saved key of `layer` may have been made from. The footage's availability is +/// tried both ways: those releases hashed it, though it isn't part of the footage (#3534). Before +/// `Footage::placed_page` was added in 0.23, the footage text ended without that field. +pub(crate) fn signatures(p: &Project, comp: ItemId, layer: &Layer) -> Vec { + let src = match &layer.source { + LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => { + let mut out = Vec::with_capacity(4); + for missing in [false, true] { + out.push(signature_with_source(p, comp, layer, &footage_text(f, missing, true))); + if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page { + out.push(signature_with_source(p, comp, layer, &footage_text(f, missing, false))); + } + } + return out; + } + Some(ItemKind::Solid(s)) => format!("{s:?}"), + Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration), + _ => String::new(), + }, + _ => String::new(), + }; + vec![signature_with_source(p, comp, layer, &src)] +} + +/// A Roto Brush Input Key as 0.22–0.24 made it from a [`signatures`] entry. +pub(crate) fn input_key_of(mut s: String, layer: &Layer, uid: Uid) -> String { + if let Some(m) = layer.masks() { + s.push_str(&format!("{m:?}")); + } + if let Some(fx) = layer.effects() { + for g in fx.groups() { + if g.uid == uid { + break; + } + s.push_str(&format!("{g:?}")); + } + } + format!("{:016x}", fnv(&s)) +} + +/// A footage item as 0.23–0.24 wrote it into a signature (without `placed_page` before 0.23), +/// with `missing` in place of the item's. +pub(crate) fn footage_text(f: &Footage, missing: bool, placed_page: bool) -> String { + format!("{:?}", FootageText { f, missing, placed_page }) +} + +struct FootageText<'a> { + f: &'a Footage, + missing: bool, + placed_page: bool, +} + +impl std::fmt::Debug for FootageText<'_> { + fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Every field is named so that a new one doesn't compile until it is decided here: a new + // field was never in these keys, so it stays out. + let Footage { + path, + kind, + width, + height, + pixel_aspect, + frame_rate, + native_rate, + duration, + has_video, + has_audio, + alpha, + premul_color, + loop_count, + codec, + missing: _, + sequence, + color_profile, + layer, + fields, + invert_alpha, + linear_light, + preserve_rgb, + cineon, + data, + page, + alphabetical, + start_timecode, + file_timecode, + markers, + placed_page, + } = self.f; + let mut d = out.debug_struct("Footage"); + d.field("path", path) + .field("kind", kind) + .field("width", width) + .field("height", height) + .field("pixel_aspect", pixel_aspect) + .field("frame_rate", frame_rate) + .field("native_rate", native_rate) + .field("duration", duration) + .field("has_video", has_video) + .field("has_audio", has_audio) + .field("alpha", alpha) + .field("premul_color", premul_color) + .field("loop_count", loop_count) + .field("codec", codec) + .field("missing", &self.missing) + .field("sequence", sequence) + .field("color_profile", color_profile) + .field("layer", layer) + .field("fields", fields) + .field("invert_alpha", invert_alpha) + .field("linear_light", linear_light) + .field("preserve_rgb", preserve_rgb) + .field("cineon", cineon) + .field("data", data) + .field("page", page) + .field("alphabetical", alphabetical) + .field("start_timecode", start_timecode) + .field("file_timecode", file_timecode) + .field("markers", markers); + if self.placed_page { + d.field("placed_page", placed_page); + } + d.finish() + } +} + +fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String { + let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default(); + let s = format!( + "{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}", + layer.source, + layer.in_point, + layer.out_point, + layer.start_time, + layer.stretch, + layer.props.get("timeRemap") + ); + format!("{:016x}", fnv(&s)) +} diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 3fe049e20..cb67005f8 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -8,8 +8,8 @@ //! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same //! pass and stores the final mattes in the effect (one undo step). `sync`, run after every //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made -//! of (the layer's source and timing, its masks and the effects above it) and drops frozen -//! mattes made from other frames. +//! of (the layer's source and timing, its masks and the effects above it: `analysis::frames_key`), +//! and drops frozen mattes made from other frames. Footage going offline keeps them. //! //! Object Matte instances run through the same jobs (`objectMatte.propagate` / `.freeze`): the //! frames are [`om::job_frames`] around the reference frame, each segmented through @@ -31,6 +31,7 @@ use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpt use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; +use crate::analysis::frames_key::{self, Era}; use crate::tracking::lock; use crate::{Event, Session}; @@ -207,31 +208,10 @@ fn set_str(g: &mut PropGroup, m: &str, v: String) { } } -fn fnv(s: &str) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in s.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - /// What the effect's input frames are made of: the layer's source and timing, its masks and the /// effects above the Roto Brush. pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { - let mut s = crate::warp::signature(p, comp, layer); - if let Some(m) = layer.masks() { - s.push_str(&format!("{m:?}")); - } - if let Some(fx) = layer.effects() { - for g in fx.groups() { - if g.uid == uid { - break; - } - s.push_str(&format!("{g:?}")); - } - } - format!("{:016x}", fnv(&s)) + frames_key::input_key(p, comp, layer, uid) } /// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from @@ -247,7 +227,13 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) { let key = input_key(after, *cid, l, g.uid); let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); - if str_of(g, INPUT_KEY) != key { + let saved_key = str_of(g, INPUT_KEY); + // Keep a matching old key: the frozen cache itself also hashes that key. + let key_of = |s, era| match era { + Era::Current => frames_key::input_key_of(s, l, g.uid), + Era::Legacy => frames_key::legacy::input_key_of(s, l, g.uid), + }; + if !frames_key::key_matches(after, *cid, l, &saved_key, key_of) { set.push((*cid, l.id, g.uid, key)); todo.push((*cid, l.id, g.uid)); } else if old.is_none_or(|o| guard_of(o) != guard_of(g)) { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 71ee45ee6..45af7f172 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -6,10 +6,11 @@ //! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the //! stabilization plan once to validate and cache the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of -//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that -//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect -//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses -//! automatically too). +//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap, its masks, the +//! effects above the Warp Stabilizer and a precomp's contents: `analysis::frames_key::analysis_key`, +//! shared with the 3D Camera Tracker). Edits that change those clear the analysis (`invalidate`, run after every edit) and queue the effect for +//! re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses +//! automatically too). Footage going offline is not such a change. use crate::offload::{JobKind, rate}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -17,13 +18,14 @@ use std::sync::{Arc, Mutex}; use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY}; use effectcraft_keyframe::Value; -use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid}; +use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid}; use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; +use crate::analysis::frames_key; use crate::tracking::lock; use crate::{Event, Session}; @@ -69,7 +71,7 @@ pub struct WarpJob { pub comp: ItemId, pub layer: LayerId, pub effect: Uid, - /// The layer signature the analysis is made from. + /// What the analysis is made from (`frames_key::analysis_key`). key: String, } @@ -98,40 +100,6 @@ pub(crate) struct WarpWork { pub times: Vec, } -fn fnv(s: &str) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in s.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - -/// What an analysis of `layer` depends on (its frames): source (and the footage item's -/// interpretation), In/Out, start time, stretch, Time Remap and the comp frame rate. -pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { - let src = match &layer.source { - LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => format!("{f:?}"), - Some(ItemKind::Solid(s)) => format!("{s:?}"), - Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration), - _ => String::new(), - }, - _ => String::new(), - }; - let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default(); - let s = format!( - "{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}", - layer.source, - layer.in_point, - layer.out_point, - layer.start_time, - layer.stretch, - layer.props.get("timeRemap") - ); - format!("{:016x}", fnv(&s)) -} - fn is_warp(g: &PropGroup) -> bool { matches!(&g.kind, GroupKind::Effect { effect } if effect == warp_stab::ID) } @@ -155,11 +123,10 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, if !fx.groups().any(is_warp) { continue; } - let sig = signature(after, *cid, l); let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id)); for g in fx.groups().filter(|g| is_warp(g)) { let analysed = !str_of(g, ANALYSIS).is_empty(); - if analysed && str_of(g, ANALYSIS_KEY) != sig { + if analysed && !frames_key::analysis_key_matches(before, after, *cid, l, g.uid, &str_of(g, ANALYSIS_KEY), |k| k) { clear.push((*cid, l.id, g.uid)); todo.push((*cid, l.id, g.uid)); } else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() { @@ -338,7 +305,7 @@ impl Session { detailed, times, }; - let key = signature(&self.project, comp, l); + let key = frames_key::analysis_key(&self.project, comp, l, effect); if !wait && self.offloads() { drop(work); self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?; diff --git a/crates/engine/src/background/remote.rs b/crates/engine/src/background/remote.rs index aea210ec5..72b4598a2 100644 --- a/crates/engine/src/background/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -430,7 +430,10 @@ impl FrameServer { } } // A frame still waiting for readbacks is replaced: end it, so every `frame_begin` has its `frame_end`. - if self.pending.take().is_some() { + if let Some(old) = self.pending.take() { + if let (Some((key, chose)), Some(pick)) = (old.auto, a.auto_pick()) { + pick.abandoned(key, chose); + } a.frame_end(); } a.frame_begin(); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 076be70ec..fefe01950 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -31,6 +31,7 @@ pub(crate) mod frame_export; mod frontend; mod help; mod import_project; +mod journal; mod key_labels; mod key_transform; mod keys_more; @@ -48,7 +49,7 @@ mod nav_more; pub mod paint; pub mod panels_cmds; pub(crate) mod path_nulls; -mod paths; +pub(crate) mod paths; mod project_items; pub(crate) mod prop; mod prop_groups; @@ -161,6 +162,7 @@ mod params; mod schema; use super::commands; +pub use aep::is_aep_path; pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; @@ -201,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(nav_more::specs()); v.extend(render_queue::specs()); v.extend(help::specs()); + v.extend(journal::specs()); v.extend(query::specs()); v.extend(batch::specs()); v.extend(layer_menu::specs()); diff --git a/crates/engine/src/cmd/aep.rs b/crates/engine/src/cmd/aep.rs index 575171f89..eab218100 100644 --- a/crates/engine/src/cmd/aep.rs +++ b/crates/engine/src/cmd/aep.rs @@ -1,5 +1,5 @@ -//! File ▸ Import ▸ After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸ After Effects -//! Project… (`effectcraft_aep::export`). +//! File ▸ Import ▸ Adobe After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸ +//! Adobe After Effects Project… (`effectcraft_aep::export`). //! //! Adds the items of an `.aep` (or its XML form, `.aepx`) to the project in a folder named after //! the file (`docs/aep-import.md` lists what arrives). What the importer cannot bring over (effects @@ -16,6 +16,14 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, str_p}; use crate::{EngineError, Result, Session, cmd}; +/// Whether `path` is an After Effects project (`.aep`, or its XML form `.aepx`): imported as a +/// project (`file.importAep`), not as footage. +pub fn is_aep_path(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("aep") || e.eq_ignore_ascii_case("aepx")) +} + +/// `file.importAep`: the `.aep` / `.aepx` at `path` into the open project, in a folder named after +/// the file; refused ([`EngineError::Refused`]) when parts would be lost and `allowLosses` is not set. fn import_aep(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.importAep", "missing `path`"))?.to_string(); let allow_losses = b_p(p, "allowLosses").unwrap_or(false); @@ -40,11 +48,12 @@ fn import_aep(s: &mut Session, p: &Value) -> Result { return Ok(json!({"dryRun": true, "report": report})); } let importer = s.importer.clone(); - let res = s.edit("Import After Effects Project", None, |proj, st| { + let res = s.edit("Import Adobe After Effects Project", None, |proj, st| { let r = effectcraft_aep::import::import(proj, &bytes, &options).map_err(|e| match e { - AepImportError::Refused(_) => { - EngineError::Other(format!("{e} (nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report)")) - } + AepImportError::Refused(_) => EngineError::Refused { + losses: e.to_string(), + advice: "nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report", + }, other => EngineError::Other(format!("cannot import {path}: {other}")), })?; if apply { @@ -90,6 +99,8 @@ fn import_aep(s: &mut Session, p: &Value) -> Result { })) } +/// `file.exportAep`: the open project as an `.aep` at `path`; refused ([`EngineError::Refused`]) +/// when parts would be lost and `allowLosses` is not set. fn export_aep(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.exportAep", "missing `path`"))?.to_string(); let allow_losses = b_p(p, "allowLosses").unwrap_or(false); @@ -112,7 +123,7 @@ fn export_aep(s: &mut Session, p: &Value) -> Result { }; let res = effectcraft_aep::export::export_with(&s.project, &ExportOptions { allow_losses }, &folder_time).map_err(|e| match e { AepExportError::Refused(_) => { - EngineError::Other(format!("{e} (nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report)")) + EngineError::Refused { losses: e.to_string(), advice: "nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report" } } other => EngineError::Other(format!("cannot export {path}: {other}")), })?; @@ -126,11 +137,12 @@ fn export_aep(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "bytes": res.bytes.len(), "report": res.report})) } +/// File ▸ Import / Export ▸ Adobe After Effects Project…. pub fn specs() -> Vec { vec![ cmd!( "file.importAep", - "After Effects Project...", + "Adobe After Effects Project...", ["File", "Import"], None, "{path (.aep or .aepx), allowLosses?: bool (import although parts cannot be imported), dryRun?: bool (only return the report), applySettings?: bool (take over the file's project settings; default: when the project has no compositions)}", @@ -139,7 +151,7 @@ pub fn specs() -> Vec { ), cmd!( "file.exportAep", - "After Effects Project...", + "Adobe After Effects Project...", ["File", "Export"], None, "{path (.aep), allowLosses?: bool (export although parts cannot be exported), dryRun?: bool (only return the report)}", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8129759c5..d0158a286 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -319,15 +319,19 @@ impl Prober { return f.map(|footage| Probed { name, footage, ask_alpha: false, warning: None }).map_err(fail); } let importer = self.importer.as_ref().ok_or_else(|| fail("media import is not available in this build".into()))?; - let mut f = crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?; - let mut warning = None; + // What the file's own import left out (a model's textures over the limits) is a warning too. + let (mut f, mut warning) = match src { + Source::File(_) => importer.probe_noting(path).map_err(fail)?, + _ => (crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?, None), + }; // Settings ▸ Import ▸ Report Missing Frames (numbered sequences). if f.kind == FootageKind::Sequence && !f.alphabetical && self.report_missing_frames && let Some(gaps) = crate::sequence::missing_report(&f.sequence) { - warning = Some(format!("{name}: {gaps} (the previous frame is held)")); + let gaps = format!("{name}: {gaps} (the previous frame is held)"); + warning = Some(warning.map_or(gaps.clone(), |w| format!("{w}; {gaps}"))); } // Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its // own alpha, and audio and data have none). @@ -404,8 +408,11 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { let mut comps = vec![]; let mut items = vec![]; let mut errors = vec![]; - // EffectCraft projects import as projects, as After Effects imports an .aep. - let (project_paths, mut paths): (Vec, Vec) = paths.into_iter().partition(|x| super::import_project::is_project_path(x)); + // EffectCraft projects import as projects, as After Effects imports an .aep; so do After + // Effects projects, refused when parts of them cannot come over. The app asks about those + // before it gets here (`panels::aep_losses`); a script or agent gets the refusal. + let (project_paths, paths): (Vec, Vec) = paths.into_iter().partition(|x| super::import_project::is_project_path(x)); + let (aep_paths, mut paths): (Vec, Vec) = paths.into_iter().partition(|x| super::is_aep_path(x)); let mut projects = vec![]; for path in project_paths { match s.execute("file.importProject", json!({"path": path})) { @@ -413,6 +420,17 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { Err(e) => errors.push(format!("{path}: {e}")), } } + for path in aep_paths { + match s.execute("file.importAep", json!({"path": path})) { + Ok(r) => projects.push(r), + // `file.import` has no `allowLosses`: point at the entry that lists the losses, and at + // the command that takes the consent. + Err(EngineError::Refused { losses, .. }) => errors.push(format!( + "{path}: {losses}; File ▸ Import ▸ Adobe After Effects Project… lists them and can import the rest (`file.importAep` with `allowLosses`)" + )), + Err(e) => errors.push(format!("{path}: {e}")), + } + } if let Some(retain) = retain { let mut rest = vec![]; for path in paths { diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 168529c47..3088e8828 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -395,6 +395,9 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { // reads (other footage, a proxy, the project file itself), and then nothing is written. let protected = s.protected_files(); let mut used_names = BTreeSet::new(); + // Where each file name's numbering resumes: a name repeated many times is numbered from its + // own file name, not by prefixing the previous candidate again. + let mut next_k: BTreeMap = BTreeMap::new(); let mut plan: Vec<(ItemId, Vec<(String, String)>)> = vec![]; for it in s.project.items.values() { let ItemKind::Footage(f) = &it.kind else { continue }; @@ -404,11 +407,17 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { let files: Vec<&String> = if f.sequence.is_empty() { vec![&f.path] } else { f.sequence.iter().collect() }; let mut pairs = vec![]; for src in files { - let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); - let mut k = 2; - while !used_names.insert(name.clone()) { - name = format!("{k}_{name}"); - k += 1; + let file = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); + let mut name = file.clone(); + if !used_names.insert(name.clone()) { + let k = next_k.entry(file.clone()).or_insert(2); + loop { + name = format!("{k}_{file}"); + *k += 1; + if used_names.insert(name.clone()) { + break; + } + } } let dst = format!("{folder}/(Footage)/{name}"); // A file that is already where its copy goes stays (it is not copied onto itself). diff --git a/crates/engine/src/cmd/frame_export.rs b/crates/engine/src/cmd/frame_export.rs index d6f491fb0..61e1ac82d 100644 --- a/crates/engine/src/cmd/frame_export.rs +++ b/crates/engine/src/cmd/frame_export.rs @@ -9,6 +9,8 @@ //! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `.R/G/B/A` channel //! set per layer (the layer-prefixed channel convention multi-layer EXR readers use). +use std::collections::{HashMap, HashSet}; + use effectcraft_color::BlendMode; use effectcraft_project::{ItemId, LayerId, Project}; use effectcraft_raster::Image; @@ -141,17 +143,22 @@ pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec /// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only: /// the `exr` crate panics on characters it cannot store in a channel name (#1702). -fn exr_name(name: &str, used: &mut Vec) -> String { +/// `used` holds the names given so far, `next` the suffix each base's search resumes at: many +/// layers of one name take a name each without retrying every suffix before theirs. +fn exr_name(name: &str, used: &mut HashSet, next: &mut HashMap) -> String { let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect(); let base = if base.trim().is_empty() { "Layer".to_string() } else { base }; - let mut n = base.clone(); - let mut k = 2; - while used.contains(&n) { - n = format!("{base} {k}"); - k += 1; + if used.insert(base.clone()) { + return base; + } + let k = next.entry(base.clone()).or_insert(2); + loop { + let n = format!("{base} {k}"); + *k += 1; + if used.insert(n.clone()) { + return n; + } } - used.push(n.clone()); - n } /// The multi-layer EXR bytes of a frame and the channel names. @@ -186,9 +193,9 @@ pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec } }; push("", &full); - let mut used = vec![]; + let (mut used, mut next) = (HashSet::new(), HashMap::new()); for l in &layers { - let n = exr_name(&l.name, &mut used); + let n = exr_name(&l.name, &mut used, &mut next); push(&n, &l.image); } let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into())); @@ -248,3 +255,23 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use super::exr_name; + + #[test] + fn many_layers_of_one_name_take_a_name_each_in_order() { + let (mut used, mut next) = (HashSet::new(), HashMap::new()); + // `A 3` is taken by a layer of its own before the repeats reach it. + let names: Vec = ["A", "A", "A 3", "A", "A.b"].iter().map(|n| exr_name(n, &mut used, &mut next)).collect(); + assert_eq!(names, ["A", "A 2", "A 3", "A 4", "A_b"]); + // 20 000 layers of one name: each found without retrying the suffixes before it. + for _ in 0..20_000 { + exr_name("Same", &mut used, &mut next); + } + assert_eq!(next["Same"], 20_001); + } +} diff --git a/crates/engine/src/cmd/journal.rs b/crates/engine/src/cmd/journal.rs new file mode 100644 index 000000000..533cc498c --- /dev/null +++ b/crates/engine/src/cmd/journal.rs @@ -0,0 +1,53 @@ +//! The command journal as a replay script (see [`crate::replay`]): `journal.export` for agents +//! and `help.exportRepro` for bug reports. Neither is journaled. + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, str_p}; +use crate::{Result, Session, replay}; + +/// `journal.export {path?}`: the script itself, or written to `path`. +fn export(s: &mut Session, p: &Value) -> Result { + if let Some(path) = str_p(p, "path") { + let (path, steps) = replay::save_repro(s, Some(path))?; + return Ok(json!({"path": path, "steps": steps})); + } + let script = replay::export(s, None); + Ok(json!({"steps": script.lines().count().saturating_sub(1), "script": script})) +} + +/// `help.exportRepro {path?}`: save the steps so far for a bug report (default: the settings +/// folder's `Repros`). +fn export_repro(s: &mut Session, p: &Value) -> Result { + let (path, steps) = replay::save_repro(s, str_p(p, "path"))?; + Ok(json!({"path": path, "steps": steps})) +} + +const fn spec(id: &'static str, label: &'static str, params: &'static str, run: super::Run) -> CommandSpec { + CommandSpec { id, label, menu: &[], shortcut: None, alt_shortcuts: &[], params, enabled: always, run, journal: false } +} + +pub fn specs() -> Vec { + #[allow(unused_mut)] + let mut v = vec![ + spec( + "journal.export", + "Export Command Journal", + "{path? (write the script there)} → {script, steps} or {path, steps}: the commands run since the project was last opened, as a replay script (docs/agents.md, Repro scripts)", + export, + ), + spec( + "help.exportRepro", + "Save Bug Repro...", + "{path? (default: Repros in the settings folder)} → {path, steps}: the steps since the project was opened, for a bug report (commands and parameters only)", + export_repro, + ), + ]; + // A command with a bug, for the crash-repro tests. + #[cfg(test)] + v.push(spec("test.panic", "Panic (test)", "{why?}", |_, p| { + let why = str_p(p, "why").unwrap_or("test").to_string(); + std::panic::panic_any(why) + })); + v +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f48fa26f8..de43454f4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -24,7 +24,7 @@ mod transform; use contents::{add_mask, add_shape_item, mask_props, set_text}; use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text}; -pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, position_p}; +pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p, show_span}; pub(crate) use parent::parent_fixes; use parent::set_parent; use replace::{has_replace_source, replace_source}; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 0196b5aee..c46222e7e 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -14,6 +14,39 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { } } +/// Settings ▸ General ▸ Fit New Layers to Work Area: the `(start, end)` that new text, solid, +/// shape, null and adjustment layers in `comp` take, or `None` for the whole composition. +pub(crate) fn new_layer_span(s: &Session, comp: &effectcraft_project::Comp) -> Option<(Tick, Tick)> { + let (start, end) = (comp.work_area.0, comp.work_area.1.min(comp.duration)); + (s.prefs.general.fit_new_layers_to_work_area && start >= Tick::ZERO && start < end).then_some((start, end)) +} + +/// Start a new layer at `span`'s start and end it at `span`'s end, when there is a span. +pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { + if let Some((start, end)) = span { + l.start_time = start; + l.in_point = start; + l.out_point = end; + } +} + +/// Tools draw on the layer they create: when it was fitted to a span the playhead is outside, +/// move `comp`'s playhead to the first frame in the span so the new layer shows. The layer's timing +/// is kept. The work area need not sit on frames (a typed start, a frame-rate change), so the +/// frame is rounded up from the start, not to the nearest; a span holding no frame moves nothing. +pub(crate) fn show_span(s: &mut Session, comp: ItemId, span: Option<(Tick, Tick)>) { + let Some(fr) = s.project.comp(comp).map(|c| c.frame_rate) else { return }; + if let Some((start, end)) = span + && !(start..end).contains(&s.time_of(comp)) + { + let first = fr.snap(start); + let first = if first < start { fr.tick_of(fr.frame_at(start).saturating_add(1)) } else { first }; + if first < end { + s.set_time_of(comp, first); + } + } +} + /// Insert a new layer above the selection (or at the top) and select it. pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { insert_layer_at(proj, st, cid, layer, None) @@ -89,10 +122,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); + let span = new_layer_span(s, &comp); let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { let folder = solids_folder(proj); let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect })); let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); + fit_span(&mut l, span); if adjustment { l.switches.adjustment = true; l.label = Label::Purple; @@ -186,9 +221,11 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } } let edit = b_p(p, "edit").unwrap_or(false); + let span = new_layer_span(s, &comp); let id = s.edit("New Text Layer", None, |proj, st| { let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text)); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + fit_span(&mut l, span); // Named after its text, the name follows the text until renamed; a given name does not. l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty)); if let Some(pr) = l.props.prop_mut("text/sourceText") { @@ -213,6 +250,9 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } Ok(id) })?; + if edit { + show_span(s, cid, span); + } Ok(json!({"layer": id.0})) } @@ -232,8 +272,9 @@ pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result { .and_then(|v| v.as_array()) .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); + let span = new_layer_span(s, &comp); let id = s.edit("New Shape Layer", None, |proj, st| { - let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; + let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos, span)?; let mut next = proj.next_id; let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); proj.next_id = next; @@ -249,8 +290,10 @@ fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: & let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").unwrap_or(name).to_string(); + let span = new_layer_span(s, &comp); let id = s.edit(label, None, |proj, st| { - let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + let mut l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + fit_span(&mut l, span); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) diff --git a/crates/engine/src/cmd/mask.rs b/crates/engine/src/cmd/mask.rs index 3e4a8a9e4..bfb4ea403 100644 --- a/crates/engine/src/cmd/mask.rs +++ b/crates/engine/src/cmd/mask.rs @@ -762,7 +762,7 @@ pub fn specs() -> Vec { cmd!("mask.new", "New Mask from Points", [], None, "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, mode?}", has_layers, new_mask), cmd!("mask.addVertex", "Add Mask Vertex", [], None, "{layer?, mask, point: [x,y], in?, out?, index?}", has_layers, add_vertex), cmd!("mask.setVertex", "Set Mask Vertex", [], None, "{layer?, mask, index, point?, in?, out?, merge?}", has_layers, set_vertex), - cmd!("mask.setClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{layer?, mask, closed?}", has_layers, set_closed), + cmd!("mask.setClosed", "Closed", [], None, "{layer?, mask, closed?}", has_layers, set_closed), cmd!("mask.setRoundness", "Set Mask Roundness", [], None, "{layer?, mask, roundness (corner radius in px; 0 = square)}", has_layers, set_roundness), cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices), cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices), diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 8364f67ec..2f1e1051b 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -12,6 +12,9 @@ use super::layer_menu::target_masks; use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p}; use crate::{EngineError, Result, Session, VertexRef, cmd}; +mod closed; +pub(crate) use closed::selected_paths_closed; + // ---------------------------------------------------------------- RotoBezier /// Automatic (RotoBezier) tangents: each vertex's tangents follow the line through its @@ -547,6 +550,7 @@ fn ungroup_shapes(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + cmd!("path.toggleClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{}", closed::has_paths, closed::toggle_closed), cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier), cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier), cmd!("path.groupShapes", "Group Shapes", ["Layer"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes), diff --git a/crates/engine/src/cmd/paths/closed.rs b/crates/engine/src/cmd/paths/closed.rs new file mode 100644 index 000000000..90ff7e737 --- /dev/null +++ b/crates/engine/src/cmd/paths/closed.rs @@ -0,0 +1,130 @@ +//! The selection-aware Closed menu action. Explicit edits retain `mask.setClosed`'s API. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{GroupKind, Layer, LayerId, Node, PropGroup, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::super::{bad, layer_mut}; +use crate::{EngineError, Result, Session, VertexRef}; + +const MAX_DEPTH: usize = 128; + +struct Target { + layer: LayerId, + route: Vec, + closed: bool, + editable: bool, +} + +struct Selection<'a> { + vertices: &'a [VertexRef], + props: &'a [(LayerId, Uid)], + time: Tick, +} + +/// A route uses checked child access and never recurses through an input-controlled tree. +fn group_at_mut<'a>(mut group: &'a mut PropGroup, route: &[usize]) -> Option<&'a mut PropGroup> { + for &index in route { + group = group.children.get_mut(index)?.as_group_mut()?; + } + Some(group) +} + +/// The selected paths on the selected layers, else every path of those layers (as the other +/// Mask and Shape Path commands do). Locked layers and masks count as matches, so selecting one +/// never falls back to its siblings, and are then left out. +fn targets(s: &Session) -> Result> { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let selection = Selection { vertices: &s.state.selected_vertices, props: &s.state.selected_props, time: s.time() }; + let mut targets = Vec::new(); + for whole_layers in [false, true] { + // Only selected layers: navigation and insertion can retain point/property selections + // on other layers, and every match below is per layer. + for layer in comp.layers.iter().filter(|l| s.state.selected_layers.contains(&l.id)) { + collect(&selection, layer, &layer.props, &mut Vec::new(), whole_layers, layer.switches.locked, &mut targets)?; + } + if !targets.is_empty() { + break; + } + } + targets.retain(|target| target.editable); + if targets.is_empty() { + return Err(bad("path.toggleClosed", "select an unlocked mask or Bezier shape path")); + } + Ok(targets) +} + +fn collect( + selection: &Selection, + layer: &Layer, + group: &PropGroup, + route: &mut Vec, + inherited: bool, + locked: bool, + targets: &mut Vec, +) -> Result<()> { + if route.len() > MAX_DEPTH { + return Err(bad("path.toggleClosed", "path group nesting exceeds 128 levels")); + } + let locked = locked || matches!(group.kind, GroupKind::Mask { locked: true, .. }); + let selected = inherited || selection.props.contains(&(layer.id, group.uid)); + if super::super::mask::is_path_group(group) + && let Some(prop) = group.get("path") + && let KV::Path(path) = prop.value_at(layer.layer_time(selection.time)) + { + // Selecting any of a path group's properties (Mask Path, Feather, Opacity…) or its points + // selects the path. + let selected = selected + || group.children.iter().any(|c| matches!(c, Node::Prop(p) if selection.props.contains(&(layer.id, p.uid)))) + || selection.vertices.iter().any(|v| v.layer == layer.id && v.mask == group.uid && v.index < path.vertices.len()); + if selected { + targets.push(Target { layer: layer.id, route: route.clone(), closed: path.closed, editable: !locked }); + } + return Ok(()); + } + for (index, child) in group.children.iter().enumerate() { + let Some(child) = child.as_group() else { continue }; + route.push(index); + collect(selection, layer, child, route, selected, locked, targets)?; + route.pop(); + } + Ok(()) +} + +pub(crate) fn selected_paths_closed(s: &Session) -> Option { + targets(s).ok().map(|targets| targets.iter().all(|target| target.closed)) +} + +pub(super) fn has_paths(s: &Session) -> std::result::Result<(), String> { + targets(s).map(|_| ()).map_err(|error| error.to_string()) +} + +pub(super) fn toggle_closed(s: &mut Session, _: &Value) -> Result { + let targets = targets(s)?; + let closed = !targets.iter().all(|target| target.closed); + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let time = s.time(); + s.edit("Closed", None, |project, _| { + for target in &targets { + let layer = layer_mut(project, cid, target.layer)?; + let time = layer.layer_time(time); + let group = group_at_mut(&mut layer.props, &target.route).ok_or_else(|| bad("path.toggleClosed", "the selected path no longer exists"))?; + let roto = super::is_roto(group); + let prop = group.get_mut("path").ok_or_else(|| bad("path.toggleClosed", "the selected group has no path"))?; + let KV::Path(mut path) = prop.value_at(time) else { return Err(bad("path.toggleClosed", "the selected value is not a path")) }; + if path.closed == closed { + continue; + } + // As `mask.setClosed`: a path read from a file may have fewer tangents than vertices (#1250). + path.fit_tangents(); + path.closed = closed; + if roto { + super::roto_smooth(&mut path); + } + prop.set_value_at(time, KV::Path(path)); + } + Ok(()) + })?; + Ok(json!({"paths": targets.len(), "closed": closed})) +} diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index b23e73575..761a295b2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1370,6 +1370,13 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { if sel.is_empty() { return Err(bad(CMD, "no `path` given and no selected property has an expression")); } + // Every layer is checked before the first is converted: a refusal leaves them all as they were. + let step = comp.frame_duration().0; + for (lid, _) in &sel { + if let Some(l) = comp.layer(*lid) { + check_convertible(step, l)?; + } + } let mut n = 0; for (lid, uid) in sel { n += convert_one_expr(s, cid, lid, uid)?.as_u64().unwrap_or(0); @@ -1380,14 +1387,36 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { convert_one_expr(s, cid, lid, uid) } +/// Most keyframes Convert Expression to Keyframes makes: 2^20, 4.8 hours at 60 fps (After Effects +/// compositions run to 3 hours, 648,000 frames at 60 fps). +const MAX_CONVERTED_KEYS: u64 = 1 << 20; +const _: () = assert!(MAX_CONVERTED_KEYS >= 3 * 3600 * 60); + +/// One evaluation and one key per frame of the layer: its In and Out points and the composition's +/// frame duration are saved data, so the step must advance and the count (a partial last frame +/// rounded up, as the loop steps, in 128 bits) is checked before any key is made. +fn check_convertible(step: i64, layer: &Layer) -> Result<()> { + const CMD: &str = "prop.convertExpressionToKeyframes"; + if step <= 0 { + return Err(bad(CMD, "the composition's frame duration is not positive")); + } + let (step, span) = (i128::from(step), i128::from(layer.out_point.0) - i128::from(layer.in_point.0)); + let frames = if span > 0 { (span + step - 1) / step } else { 0 }; + if frames > i128::from(MAX_CONVERTED_KEYS) { + return Err(bad(CMD, format!("the layer spans {frames} frames; at most {MAX_CONVERTED_KEYS} are converted"))); + } + Ok(()) +} + /// Replaces the expression on one property with keyframes sampled from its evaluated values. /// Returns the number of properties converted. fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); let pr = layer.props.find(uid).ok_or_else(|| bad("prop.convertExpressionToKeyframes", "no property"))?.clone(); - let mut keys = vec![]; let fd = comp.frame_duration(); + check_convertible(fd.0, &layer)?; + let mut keys = vec![]; let mut t = layer.in_point; while t < layer.out_point { let ctx = effectcraft_render::EvalCtx { diff --git a/crates/engine/src/cmd/relink.rs b/crates/engine/src/cmd/relink.rs index d899decbe..e74e4bf97 100644 --- a/crates/engine/src/cmd/relink.rs +++ b/crates/engine/src/cmd/relink.rs @@ -1,6 +1,6 @@ //! Recover moved media without replacing interpretation, sequence ranges or layer timing. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind}; @@ -11,6 +11,8 @@ use crate::{Result, Session, cmd}; const COMMAND: &str = "file.relinkFootage"; const ENTRY_LIMIT: usize = 100_000; +/// Candidates listed for one missing item (more than one already makes it ambiguous). +const MAX_CANDIDATES: usize = 32; fn enabled(_: &Session) -> std::result::Result<(), String> { if cfg!(target_arch = "wasm32") { Err("folder relinking requires the desktop filesystem".into()) } else { Ok(()) } @@ -95,6 +97,8 @@ fn run(s: &mut Session, p: &Value) -> Result { let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); s.spawn_task("relink", "Relinking missing footage", wait, move |ctl| { let index = files(&folder, ctl)?; + // Each candidate file is probed once, however many missing items share its name. + let mut probes: HashMap> = HashMap::new(); let mut rows = vec![]; let mut updates = vec![]; for (n, (id, old)) in missing.iter().enumerate() { @@ -104,19 +108,21 @@ fn run(s: &mut Session, p: &Value) -> Result { let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio) && old.layer.is_none() && old.page == 0; let name = Path::new(&old.path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); - let paths = index.get(name).cloned().unwrap_or_default(); + let paths = index.get(name).map_or(&[][..], Vec::as_slice); let mut candidates = vec![]; let mut accepted = vec![]; - for path in paths { + // Past a few candidates the item is ambiguous anyway: list no more of them. + for path in paths.iter().take(MAX_CANDIDATES) { if ctl.cancelled() { return Err(crate::render_queue::CANCELLED.into()); } let mut reason = "source type requires manual replacement".to_string(); let mut confidence = "low"; if supported { - match importer.probe(&path) { - Ok(f) if compatible(old, &f) => { - if old.kind != FootageKind::Sequence || (!old.sequence.is_empty() && sequence_at(old, &path).is_some()) { + let probed = probes.entry(path.clone()).or_insert_with(|| importer.probe(path)); + match probed { + Ok(f) if compatible(old, f) => { + if old.kind != FootageKind::Sequence || (!old.sequence.is_empty() && sequence_at(old, path).is_some()) { confidence = "high"; reason = "exact filename and compatible source metadata".into(); accepted.push(path.clone()); @@ -284,6 +290,19 @@ mod tests { assert_eq!(get(&s, id), expected); } + #[test] + fn many_same_named_candidates_list_at_most_the_cap() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + for i in 0..MAX_CANDIDATES + 8 { + dir.file(&format!("new/{i}/plate.png"), b"valid"); + } + let r = search(&mut s, &dir, false); + assert_eq!(r["items"][0]["status"], "ambiguous"); + assert_eq!(r["items"][0]["candidates"].as_array().unwrap().len(), MAX_CANDIDATES); + assert_eq!(get(&s, id), old); + } + #[test] fn duplicates_and_invalid_media_are_reported_without_edits() { let dir = Fixture::new(); diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index 57b22ba82..8b9cc28f2 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -8,6 +8,7 @@ use effectcraft_geom::vec2; use effectcraft_keyframe::{Gradient, ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup}; +use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -263,7 +264,8 @@ pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Res } /// A new empty shape layer ("Shape Layer n" unless `name`) with its Position at `position` (comp -/// pixels; else the comp centre), above the selected layer and selected. +/// pixels; else the comp centre), above the selected layer and selected. `span` is the +/// [`new_layer_span`](super::layer::new_layer_span) the layer takes, if any. pub(crate) fn new_shape_layer( proj: &mut Project, st: &mut EditorState, @@ -271,11 +273,13 @@ pub(crate) fn new_shape_layer( cid: ItemId, name: Option<&str>, position: Option<[f64; 2]>, + span: Option<(Tick, Tick)>, ) -> Result { let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); let name = name.map(str::to_string).unwrap_or_else(|| format!("Shape Layer {}", count + 1)); let mut l = build::layer(proj, comp, &name, LayerSource::Shape, (comp.width, comp.height), None); place(&mut l, position); + super::layer::fit_span(&mut l, span); insert_layer(proj, st, cid, l) } @@ -346,10 +350,11 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { _ => "Rectangle Tool", }; let name = str_p(p, "name"); + let span = super::layer::new_layer_span(s, &comp); let (lid, group) = s.edit(label, None, |proj, st| { let lid = match target { Some(l) => l, - None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?, + None => new_shape_layer(proj, st, &comp, cid, name, Some(pos), span)?, }; let mut next = proj.next_id; let g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?; @@ -358,6 +363,9 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { st.selected_layers = vec![lid]; Ok((lid, group)) })?; + if target.is_none() { + super::layer::show_span(s, cid, span); + } Ok(json!({"layer": lid.0, "group": group})) } diff --git a/crates/engine/src/cmd/time.rs b/crates/engine/src/cmd/time.rs index 556c02bcb..49a3caede 100644 --- a/crates/engine/src/cmd/time.rs +++ b/crates/engine/src/cmd/time.rs @@ -43,7 +43,9 @@ pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String { } /// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame -/// `current`) into a comp frame. +/// `current`) into a comp frame. In Timecode style, in a comp whose Start Timecode is negative, a +/// `-` before a timecode with separators (`-0:00:00:12`, `-1.00`) is the sign of a displayed time, +/// not a step back; a bare `-n` still steps back. pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { let fr = comp.frame_rate; let st = &s.project.settings; @@ -53,6 +55,20 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } None => { + // A comp whose Start Timecode is negative shows the times before 0 with a `-` + // (`-0:00:00:12`), so there a `-` before a timecode with separators is the sign of a + // displayed time and typing what the display shows lands on the frame it shows. A bare + // `-n` still moves n frames back. + if st.time_display == TimeDisplayStyle::Timecode + && comp.display_start < Tick::ZERO + && let Some(body) = text.trim().strip_prefix('-').map(str::trim_start) + && body.contains([':', ';', '.', ',']) + && !body.starts_with(['+', '-']) + { + let drop_frame = fr.supports_drop_frame() && body.contains(';'); + let shown = effectcraft_time::parse_timecode(body, fr, drop_frame, current).map_err(|e| e.0)?; + return Ok(shown.saturating_neg().saturating_sub(fr.frame_at(comp.display_start))); + } // The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop // too; only explicit `;` fields mean drop-frame (#890). let drop_frame = fr.supports_drop_frame() && text.contains(';'); diff --git a/crates/engine/src/cmd/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs index 698a46f02..dfa6ce3e7 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -282,10 +282,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result { path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])])); let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; let name = str_p(p, "name").map(str::to_string); + let span = super::layer::new_layer_span(s, &comp); let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { let lid = match target { Some(l) => l, - None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?, + None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None, span)?, }; let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -300,6 +301,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result { st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }]; Ok((lid, group, path_uid)) })?; + if target.is_none() { + super::layer::show_span(s, cid, span); + } Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) } diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 75e9e4467..e1f5bfa3c 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -15,6 +15,10 @@ pub enum EngineError { Project(#[from] effectcraft_project::ProjectError), #[error("{0}")] Other(String), + /// Refused because parts would be lost in a conversion; nothing changed. `losses` names them, + /// `advice` how to go on (`file.importAep` / `file.exportAep` without `allowLosses`). + #[error("{losses} ({advice})")] + Refused { losses: String, advice: &'static str }, } pub type Result = std::result::Result; diff --git a/crates/engine/src/footage.rs b/crates/engine/src/footage.rs index b921dd128..d64afcaf7 100644 --- a/crates/engine/src/footage.rs +++ b/crates/engine/src/footage.rs @@ -1,6 +1,7 @@ pub mod doc_path; pub mod footage_check; pub mod footage_reload; +pub mod lut_reload; pub mod media_browser; pub mod media_cache; pub mod sequence; diff --git a/crates/engine/src/footage/lut_reload.rs b/crates/engine/src/footage/lut_reload.rs new file mode 100644 index 000000000..54211eac0 --- /dev/null +++ b/crates/engine/src/footage/lut_reload.rs @@ -0,0 +1,90 @@ +//! LUT files that change on disk (#3493). +//! +//! A render follows a LUT file by itself: [`effectcraft_effects::load_lut`] stamps the file and +//! reads it again once it changed, and a layer's cache key holds the stamp. What the desktop app +//! keeps of earlier renders, the frames of its viewer and RAM preview, is dropped when +//! [`effectcraft_effects::lut_generation`] moves. The [`Watcher`] looks at the LUT files in use +//! every two seconds, so an edit shows without anything rendering first, and wakes the interface +//! only when one changed. + +use std::sync::mpsc::{RecvTimeoutError, SyncSender}; +use std::time::Duration; + +/// Moves each time a LUT file in use was found changed on disk: frames rendered before may show +/// the old LUT ([`effectcraft_effects::lut_generation`]). +pub fn generation() -> u64 { + effectcraft_effects::lut_generation() +} + +/// Stamp the LUT files in use now ([`effectcraft_effects::check_lut_files`]): the ones that +/// changed are read again by the next render. Returns [`generation`]. +pub fn check() -> u64 { + effectcraft_effects::check_lut_files() +} + +/// How often the LUT files in use are stamped. +pub const INTERVAL: Duration = Duration::from_secs(2); + +/// A worker thread that stamps the LUT files in use ([`effectcraft_effects::check_lut_files`]) +/// and runs `wake` when one changed. Dropping the watcher ends the thread. +pub struct Watcher { + _stop: SyncSender<()>, +} + +impl Watcher { + /// Start the worker. `None` where there are no threads (the web app, whose LUTs don't come + /// from files) or one couldn't be started. + pub fn start(wake: impl Fn() + Send + 'static) -> Option { + Self::start_with(wake, INTERVAL) + } + + /// [`Watcher::start`] stamping every `every` (tests). + pub fn start_with(wake: impl Fn() + Send + 'static, every: Duration) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1); + std::thread::Builder::new() + .name("lut-watch".into()) + .spawn(move || { + let mut seen = effectcraft_effects::lut_generation(); + // (ends once the watcher, the only sender, is dropped) + while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(every) { + let now = check(); + if now != seen { + seen = now; + wake(); + } + } + }) + .ok()?; + Some(Watcher { _stop: tx }) + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + /// A LUT file edited on disk wakes the interface without a render loading it. + #[test] + fn an_edited_lut_file_wakes_the_interface() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("watched.cube"); + std::fs::write(&path, "LUT_1D_SIZE 2\n0 0 0\n1 1 1\n").unwrap(); + let src = path.to_string_lossy().into_owned(); + assert!(effectcraft_effects::load_lut(&src).is_some()); + let before = effectcraft_effects::lut_generation(); + let (tx, rx) = std::sync::mpsc::channel(); + let _watcher = Watcher::start_with( + move || { + let _ = tx.send(()); + }, + Duration::from_millis(10), + ) + .unwrap(); + std::fs::write(&path, "LUT_1D_SIZE 3\n0 0 0\n0.5 0.5 0.5\n1 1 1\n").unwrap(); + rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(effectcraft_effects::lut_generation() > before); + } +} diff --git a/crates/engine/src/footage/sequence.rs b/crates/engine/src/footage/sequence.rs index fc2dab2b0..891ebdbe1 100644 --- a/crates/engine/src/footage/sequence.rs +++ b/crates/engine/src/footage/sequence.rs @@ -11,6 +11,8 @@ //! The browser has no folders: its file table lists the files picked or dropped so far, so //! picking every frame of a sequence imports it there too. +use std::collections::HashMap; + use effectcraft_project::sequence::{frame_number, missing_frame_ranges, sequence_name, split_frame_number}; use effectcraft_project::{Footage, FootageKind}; use effectcraft_time::FrameRate; @@ -126,10 +128,22 @@ fn sort_run(files: &mut Vec, alphabetical: bool) { files.dedup(); } -/// The files of the run `path` belongs to, from its folder. -fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec { +type RunKey = (String, String, String); + +/// The files of the run `path` belongs to, from its folder. `folders` keeps each folder's files +/// by run, listed once for all the files picked from it. +fn run_files(services: &dyn Services, path: &str, alphabetical: bool, folders: &mut HashMap>>) -> Vec { let Some(key) = run_key(path, alphabetical) else { return vec![] }; - let mut files: Vec = services.list_dir(&key.0).into_iter().filter(|p| run_key(p, alphabetical).as_ref() == Some(&key)).collect(); + let runs = folders.entry(key.0.clone()).or_insert_with(|| { + let mut runs: HashMap> = HashMap::new(); + for p in services.list_dir(&key.0) { + if let Some(k) = run_key(&p, alphabetical) { + runs.entry(k).or_default().push(p); + } + } + runs + }); + let mut files = runs.get(&key).cloned().unwrap_or_default(); sort_run(&mut files, alphabetical); files } @@ -138,27 +152,41 @@ fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec Vec { + // Each folder's runs, listed once for everything that needs them (a picked folder's listing + // is kept for the run lookup of its lone files). + let mut folders: HashMap>> = HashMap::new(); // Each file, and whether it may join a sequence. let mut files: Vec<(String, bool)> = vec![]; for p in paths { if services.is_dir(p) { - let mut inside: Vec = - services.list_dir(p).into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect(); + let listing = services.list_dir(p); + let mut runs: HashMap> = HashMap::new(); + for f in &listing { + if let Some(k) = run_key(f, opts.alphabetical) { + runs.entry(k).or_default().push(f.clone()); + } + } + if let Some(dir) = runs.keys().next().map(|k| k.0.clone()) { + folders.entry(dir).or_insert(runs); + } + let mut inside: Vec = listing.into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect(); inside.sort_by_key(|f| file_name(f).to_lowercase()); files.extend(inside.into_iter().map(|f| (f, opts.folders))); } else { files.push((p.clone(), opts.sequence)); } } - // Runs in the order their first file was picked. - let mut runs: Vec<((String, String, String), Vec)> = vec![]; + // Runs in the order their first file was picked (found by key, not by a scan of the runs). + let mut runs: Vec<(RunKey, Vec)> = vec![]; + let mut run_of: HashMap = HashMap::new(); let mut out: Vec> = vec![]; let mut slot: Vec = vec![]; for (f, joins) in files { match run_key(&f, opts.alphabetical).filter(|_| joins) { - Some(key) => match runs.iter_mut().find(|(k, _)| *k == key) { - Some((_, run)) => run.push(f), + Some(key) => match run_of.get(&key) { + Some(&i) => runs[i].1.push(f), None => { + run_of.insert(key.clone(), runs.len()); runs.push((key, vec![f])); slot.push(out.len()); out.push(None); @@ -173,7 +201,7 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) - if let [one] = picked.as_slice() && opts.sequence { - let all = run_files(services, one, opts.alphabetical); + let all = run_files(services, one, opts.alphabetical, &mut folders); if all.len() >= 2 { picked = all; } @@ -315,6 +343,59 @@ mod tests { assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1); } + /// Thousands of files picked one by one from one folder: the folder is listed once, not + /// once per file, and each finds its run by key. + #[test] + fn picked_files_of_one_folder_list_it_once() { + struct Counting(Table, std::sync::atomic::AtomicUsize); + impl Services for Counting { + fn read_file(&self, path: &str) -> std::io::Result> { + self.0.read_file(path) + } + fn write_file(&self, p: &str, b: &[u8]) -> std::io::Result<()> { + self.0.write_file(p, b) + } + fn list_dir(&self, dir: &str) -> Vec { + self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.0.list_dir(dir) + } + fn is_dir(&self, path: &str) -> bool { + self.0.is_dir(path) + } + } + let names: Vec<&'static str> = (0..2000).map(|i| &*Box::leak(format!("/s/x{i}_1.png").into_boxed_str())).collect(); + let t = Counting(Table(names.clone()), Default::default()); + let picked: Vec = names.iter().map(|s| s.to_string()).collect(); + let g = group(&t, &picked, SequenceOptions::default()); + assert_eq!(g.len(), 2000); + assert_eq!(t.1.load(std::sync::atomic::Ordering::Relaxed), 1); + } + + /// A picked folder's listing serves the run lookup of its lone files: one listing in all. + #[test] + fn a_picked_folder_is_listed_once() { + struct Counting(Table, std::sync::atomic::AtomicUsize); + impl Services for Counting { + fn read_file(&self, path: &str) -> std::io::Result> { + self.0.read_file(path) + } + fn write_file(&self, p: &str, b: &[u8]) -> std::io::Result<()> { + self.0.write_file(p, b) + } + fn list_dir(&self, dir: &str) -> Vec { + self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.0.list_dir(dir) + } + fn is_dir(&self, path: &str) -> bool { + self.0.is_dir(path) + } + } + let t = Counting(Table(vec!["/s/a_0001.png", "/s/b_0001.png", "/s/b_0002.png"]), Default::default()); + let g = group(&t, &strs(&["/s"]), SequenceOptions { sequence: true, folders: true, alphabetical: false }); + assert_eq!(g.len(), 2, "{g:?}"); + assert_eq!(t.1.load(std::sync::atomic::Ordering::Relaxed), 1); + } + /// #431: `file.import` brings picked or dropped numbered stills in as stills; only a folder or /// an explicit `sequence: true` makes a sequence, as After Effects' Sequence checkbox does. #[test] diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index 0b15baaf5..f411fa5bd 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -751,6 +751,9 @@ fn footage_dir(s: &Session, name: &str, footage_dir: Option<&str>, project_path: fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec)], dir: &str) -> Result> { let mut written = std::collections::HashMap::new(); let mut used = std::collections::HashSet::new(); + // Where each (item, file) pair's numbering resumes, so repeated names don't retry every + // number before theirs. + let mut next = std::collections::HashMap::new(); for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) { // `media//` → one folder; a name another item took gets the item's id. let file = safe_file_name(&base_name(k)); @@ -763,10 +766,10 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec) // case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing // dot or space, so `a.png.` is `a.png` there. The name written stays as it is. let mut name = file.clone(); - let mut n = 0usize; + let n = next.entry((item, same_file_key(&file))).or_insert(0usize); while !used.insert(same_file_key(&name)) { - n += 1; - name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; + *n += 1; + name = if *n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; } let out = format!("{dir}/{name}"); s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?; diff --git a/crates/engine/src/interchange/keyframe_text_effects.rs b/crates/engine/src/interchange/keyframe_text_effects.rs index 83f5b6c20..7b0658f61 100644 --- a/crates/engine/src/interchange/keyframe_text_effects.rs +++ b/crates/engine/src/interchange/keyframe_text_effects.rs @@ -273,6 +273,7 @@ pub(crate) const UNITS: &[(&str, &str, &str)] = &[ ("ec.distort.wavewarp", "width", "percent"), ("ec.distort.wavewarp", "speed", "pixels"), ("ec.generate.fourcolor", "blend", "percent"), + ("ec.generate.fourcolor", "jitter", "percent"), ("ec.generate.advancedlightning", "coreSettings/coreRadius", ""), ("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"), ("ec.generate.advancedlightning", "alphaObstacle", "pixels"), @@ -317,6 +318,15 @@ pub(crate) const UNITS: &[(&str, &str, &str)] = &[ ("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"), ("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""), ("ec.matte.simplechoker", "chokeMatte", "pixels"), + // Their ranges were 0..100 (#3483, #3484). + ("ec.noise.noisehls", "hue", "percent"), + ("ec.noise.noisehls", "lightness", "percent"), + ("ec.noise.noisehls", "saturation", "percent"), + ("ec.noise.noisehls", "grainSize", ""), + ("ec.noise.noisehlsauto", "hue", "percent"), + ("ec.noise.noisehlsauto", "lightness", "percent"), + ("ec.noise.noisehlsauto", "saturation", "percent"), + ("ec.noise.noisehlsauto", "grainSize", ""), ("ec.noise.fractal", "transform/scale", "percent"), ("ec.noise.fractal", "transform/scaleWidth", "percent"), ("ec.noise.fractal", "transform/scaleHeight", "percent"), diff --git a/crates/engine/src/interchange/vector.rs b/crates/engine/src/interchange/vector.rs index b52517cb3..64ef00253 100644 --- a/crates/engine/src/interchange/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -8,6 +8,7 @@ use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, P use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint}; use kurbo::{BezPath, Point}; +/// Convert a kurbo path into shape path property groups. fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec { effectcraft_path::from_kurbo(path) .into_iter() @@ -20,6 +21,7 @@ fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec { .collect() } +/// Uniform scale factor of a transform (square root of its determinant magnitude). fn det_scale(m: Affine) -> f64 { let c = m.as_coeffs(); (c[0] * c[3] - c[1] * c[2]).abs().sqrt() @@ -31,6 +33,7 @@ fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> { (c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5])) } +/// Convert an SVG gradient into the engine's gradient value. fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient { Gradient { colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(), @@ -52,6 +55,7 @@ fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect) } } +/// Convert one SVG shape under transform `m` into shape-layer items. fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec { use kurbo::Shape as _; let mut items = vec![]; @@ -72,48 +76,60 @@ fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width), + let paint = match &st.paint { + Paint::Color(c) => Some(build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width)), Paint::Gradient(gr) => { let (radial, a, b) = gradient_points(gr, m, bbox); - build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width) + Some(build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width)) } + // Editable shape layers do not yet have a pattern paint operator. Keep the geometry + // and any retained fallback as solid paint; SVG footage retains the rasterised pattern. + Paint::Pattern(pattern) => pattern.fallback.map(|c| build::shape_stroke(ids, [c[0], c[1], c[2], c[3]], width)), }; - set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0)); - set(&mut g, "cap", Value::Enum(st.cap as u32)); - set(&mut g, "join", Value::Enum(st.join as u32)); - set(&mut g, "miter", Value::Scalar(st.miter)); - if let Some((d, off)) = &st.dash - && let Some(dg) = g.sub_mut("dashes") - { - let k = det_scale(m); - set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k)); - set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k)); - set(dg, "offset", Value::Scalar(off * k)); + if let Some(mut g) = paint { + set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0)); + set(&mut g, "cap", Value::Enum(st.cap as u32)); + set(&mut g, "join", Value::Enum(st.join as u32)); + set(&mut g, "miter", Value::Scalar(st.miter)); + if let Some((d, off)) = &st.dash + && let Some(dg) = g.sub_mut("dashes") + { + let k = det_scale(m); + set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k)); + set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k)); + set(dg, "offset", Value::Scalar(off * k)); + } + items.push(g); } - items.push(g); } if let Some(f) = &s.fill { - let mut g = match f { - Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]), + let paint = match f { + Paint::Color(c) => Some(build::shape_fill(ids, [c[0], c[1], c[2], 1.0])), Paint::Gradient(gr) => { let (radial, a, b) = gradient_points(gr, m, bbox); - build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr)) + Some(build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr))) } + // Pattern artwork is preserved in rasterised SVG footage; editable shapes keep only + // any retained fallback as solid paint. + Paint::Pattern(pattern) => pattern.fallback.map(|c| build::shape_fill(ids, [c[0], c[1], c[2], c[3]])), }; - set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0)); - set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd))); - items.push(g); + if let Some(mut g) = paint { + set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0)); + set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd))); + items.push(g); + } } items } +/// Set a child property's value if present. fn set(g: &mut PropGroup, k: &str, v: Value) { if let Some(p) = g.get_mut(k) { p.value = v; } } +/// Wrap items in a named shape group with the given opacity (0..1). fn group(ids: &mut Ids, name: &str, items: Vec, opacity: f64) -> PropGroup { let mut g = build::shape_group(ids, name, items); if let Some(p) = g.prop_mut("transform/opacity") { @@ -165,6 +181,7 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[ /// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see /// [`split_at_images`]); soft masks are not converted. pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { + /// Convert nodes to property groups, applying transform `m` and clip paths. fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec { let mut out = vec![]; for n in nodes.iter().rev() { @@ -406,6 +423,7 @@ fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) { } } +/// Copy timing and parenting from `src` to `dst`. fn like_source(dst: &mut Layer, src: &Layer) { dst.start_time = src.start_time; dst.in_point = src.in_point; @@ -418,13 +436,17 @@ fn like_source(dst: &mut Layer, src: &Layer) { /// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage /// layer: shape layers have no images). pub struct VectorImage { + /// Image name. pub name: String, + /// Pixel width. pub width: u32, + /// Pixel height. pub height: u32, /// Straight RGBA8. pub rgba: std::sync::Arc>, /// Image pixels → document pixels. pub transform: Affine, + /// Opacity, 0..1. pub opacity: f64, /// Inside a clipping group or under a soft mask (not carried over). pub clipped: bool, @@ -432,6 +454,7 @@ pub struct VectorImage { /// The document's images, bottom first, and how many soft-masked groups it has. pub fn vector_images(doc: &Doc) -> (Vec, usize) { + /// Collect images from a group tree, counting soft-masked groups. fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec, masks: &mut usize) { let m = m * g.transform; let opacity = opacity * g.opacity; @@ -467,6 +490,7 @@ pub fn vector_images(doc: &Doc) -> (Vec, usize) { /// layers and image footage layers can stack in the document's order. One more segment than /// images. pub fn split_at_images(doc: &Doc) -> Vec { + /// Count the images in a group tree. fn count(g: &effectcraft_svg::Group) -> usize { g.children .iter() @@ -477,35 +501,51 @@ pub fn split_at_images(doc: &Doc) -> Vec { }) .sum() } - /// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed. - fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group { - let mut out = g.clone(); - out.children = vec![]; + /// `g` without its children, holding `children` instead (nothing below `g` is copied). + fn shell(g: &effectcraft_svg::Group, children: Vec) -> effectcraft_svg::Group { + effectcraft_svg::Group { + name: g.name.clone(), + transform: g.transform, + opacity: g.opacity, + children, + clip: g.clip.clone(), + blend: g.blend, + mask: g.mask.clone(), + isolated: g.isolated, + knockout: g.knockout, + } + } + /// One traversal for every segment: the pieces of `g` as (segment, group), by ascending + /// segment; `seen` counts images passed. Each shape is copied once, into its own segment. + fn split(g: &effectcraft_svg::Group, seen: &mut usize) -> Vec<(usize, effectcraft_svg::Group)> { + let mut by: Vec<(usize, Vec)> = vec![]; + let mut put = |seg: usize, node: Node| match by.last_mut() { + Some(last) if last.0 == seg => last.1.push(node), + _ => by.push((seg, vec![node])), + }; for c in &g.children { match c { Node::Image(_) => *seen += 1, - Node::Shape(_) => { - if *seen == seg { - out.children.push(c.clone()); - } - } + Node::Shape(_) => put(*seen, c.clone()), Node::Group(s) => { - let p = prune(s, seg, seen); - if !p.children.is_empty() { - out.children.push(Node::Group(p)); + for (seg, part) in split(s, seen) { + put(seg, Node::Group(part)); } } } } - out + by.into_iter().map(|(seg, children)| (seg, shell(g, children))).collect() } let n = count(&doc.root); - (0..=n) - .map(|seg| { - let mut d = doc.clone(); - d.root = prune(&doc.root, seg, &mut 0); - d - }) + let mut roots: Vec> = vec![None; n + 1]; + for (seg, part) in split(&doc.root, &mut 0) { + if let Some(slot) = roots.get_mut(seg) { + *slot = Some(part); + } + } + roots + .into_iter() + .map(|root| Doc { width: doc.width, height: doc.height, root: root.unwrap_or_else(|| shell(&doc.root, vec![])), skipped: doc.skipped.clone() }) .collect() } @@ -655,3 +695,73 @@ pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec Node { + let geom = Geom::Rect { x: 0.0, y: 0.0, w: 1.0, h: 1.0, rx: 0.0, ry: 0.0 }; + Node::Shape(Shape { + name: name.into(), + geom, + transform: Affine::IDENTITY, + fill: None, + fill_opacity: 1.0, + fill_rule: effectcraft_svg::FillRule::NonZero, + stroke: None, + opacity: 1.0, + }) + } + fn image() -> Node { + Node::Image(Image { name: "i".into(), transform: Affine::IDENTITY, width: 1, height: 1, rgba: std::sync::Arc::new(vec![0; 4]), opacity: 1.0 }) + } + fn group(name: &str, children: Vec) -> Node { + let mut g = Group::new(name); + g.children = children; + Node::Group(g) + } + fn names(g: &Group, out: &mut Vec) { + for c in &g.children { + match c { + Node::Shape(s) => out.push(s.name.clone()), + Node::Group(s) => { + out.push(format!("<{}", s.name)); + names(s, out); + out.push(format!("{}>", s.name)); + } + Node::Image(_) => out.push("IMG".into()), + } + } + } + + /// Segment `k` keeps the shapes between image `k - 1` and `k`, with the groups that hold them. + #[test] + fn split_keeps_each_segments_shapes_and_their_groups() { + let mut root = Group::new("root"); + root.children = vec![ + shape("a"), + image(), + group("g1", vec![shape("b"), image(), shape("c"), group("g2", vec![shape("d"), image(), shape("e")])]), + image(), + group("empty", vec![image()]), + shape("f"), + ]; + let doc = Doc { width: 10.0, height: 10.0, root, skipped: vec![] }; + let segs = split_at_images(&doc); + assert_eq!(segs.len(), 6); + let got: Vec> = segs + .iter() + .map(|d| { + let mut v = vec![]; + names(&d.root, &mut v); + v + }) + .collect(); + let want: Vec> = + vec![vec!["a"], vec![""], vec!["", "g1>"], vec!["", "g1>"], vec![], vec!["f"]]; + assert_eq!(got, want); + assert!(segs.iter().all(|d| d.root.name == "root" && d.width == 10.0)); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 599aba1b7..0b7608756 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod output_guard; pub mod preview; pub mod render_queue; +pub mod replay; pub mod scriptui; mod settings; mod system; @@ -60,6 +61,7 @@ pub use effectcraft_track as track; pub use footage::doc_path; pub use footage::footage_check; pub use footage::footage_reload; +pub use footage::lut_reload; pub use footage::media_browser; pub use footage::media_cache; pub use footage::sequence; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 70acbd1fb..1301940e6 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -23,6 +23,12 @@ //! Libraries, Bridge, Media Encoder, Behance, Creative Cloud…) are intentionally absent; the //! Essential Graphics workspace uses the Properties panel and the Essential Graphics panel, whose //! templates use EffectCraft's own open `.ectemplate` format. +//! +//! Where EffectCraft has entries After Effects lacks, After Effects' entries keep their order and +//! the others get a fixed place. File ▸ Import: the project imports sit together where After +//! Effects has Adobe Premiere Pro Project…, in alphabetical order and named in full (Adobe After +//! Effects Project…, Adobe Premiere Pro Project…, EffectCraft Project…); the formats only +//! EffectCraft imports follow a separator at the end, in alphabetical order (#3103). use std::sync::OnceLock; @@ -314,6 +320,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { let layer = s.state.selected_layers.first().and_then(|l| comp.layer(*l)); let pstr = |k: &str| params.get(k).and_then(Value::as_str); match command { + "path.toggleClosed" => crate::cmd::paths::selected_paths_closed(s), "layer.setBlendMode" => { let want = effectcraft_color::BlendMode::from_name(pstr("mode")?)?; Some(layer?.blend_mode == want) @@ -577,14 +584,15 @@ File Import File... | file.import Multiple Files... | file.importMultiple + Adobe After Effects Project... | file.importAep Adobe Premiere Pro Project... | file.importTimeline EffectCraft Project... | file.importProject + Vanishing Point (.vpe)... | file.importVanishingPoint Placeholder... | file.importPlaceholder Solid... | file.importSolid - After Effects Project... | file.importAep - Lottie... | file.importLottie - Vanishing Point (.vpe)... | file.importVanishingPoint + --- Essential Graphics Template... | essential.importTemplate + Lottie... | file.importLottie Import Recent Footage @dynamic:recentFootage --- @@ -592,7 +600,7 @@ File Export Add to Render Queue | renderQueue.add Adobe Premiere Pro Project... | file.exportTimeline - After Effects Project... | file.exportAep + Adobe After Effects Project... | file.exportAep Lottie JSON... | file.exportLottie Essential Graphics Template... | essential.exportTemplate --- @@ -822,7 +830,7 @@ Layer Hide Locked Masks | layer.mask.hideLocked Mask and Shape Path RotoBezier | path.rotoBezier - Closed | mask.setClosed + Closed | path.toggleClosed Convert To Bezier Path | path.convertToBezier Set First Vertex | path.setFirstVertex Free Transform Points | path.freeTransform diff --git a/crates/engine/src/menus/tests.rs b/crates/engine/src/menus/tests.rs index 8de3cc6dc..740610971 100644 --- a/crates/engine/src/menus/tests.rs +++ b/crates/engine/src/menus/tests.rs @@ -159,6 +159,44 @@ fn checked_reflects_layer_state() { assert_eq!(checked(&s, "file.save", &serde_json::json!({})), None); } +#[test] +fn closed_menu_opens_and_recloses_a_manual_shape() { + use serde_json::json; + let mut s = crate::Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let r = s + .execute( + "shape.newPath", + json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true, + "inTangents": [[0, -12], [-8, 0], [0, -6]], "outTangents": [[12, 0], [0, 8], [-6, 0]]}), + ) + .unwrap(); + let lid = effectcraft_project::LayerId(r["layer"].as_u64().unwrap()); + let uid = r["path"].as_u64().unwrap(); + let path = + |s: &crate::Session| s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + let original = path(&s); + for mac in [true, false] { + let (_, entry) = + platform_entries(mac).into_iter().find(|(p, e)| p.iter().map(String::as_str).eq(["Layer", "Mask and Shape Path"]) && e.label == "Closed").unwrap(); + assert!(s.is_enabled(&entry.command)); + assert_eq!(checked(&s, &entry.command, &entry.params), Some(true)); + s.execute(&entry.command, entry.params.clone()).unwrap(); + let mut opened = original.clone(); + opened.closed = false; + assert_eq!(path(&s), opened); + assert!(!effectcraft_path::to_kurbo(&path(&s)).elements().contains(&effectcraft_path::PathEl::ClosePath)); + assert_eq!(checked(&s, &entry.command, &entry.params), Some(false)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(path(&s), original); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(path(&s), opened); + s.execute(&entry.command, entry.params).unwrap(); + assert_eq!(path(&s), original); + assert!(effectcraft_path::to_kurbo(&path(&s)).elements().contains(&effectcraft_path::PathEl::ClosePath)); + } +} + /// `cargo test -p effectcraft-engine dump_specs -- --ignored --nocapture`: every command as TSV. #[test] #[ignore] diff --git a/crates/engine/src/output_guard.rs b/crates/engine/src/output_guard.rs index 46c4356ed..73d938a32 100644 --- a/crates/engine/src/output_guard.rs +++ b/crates/engine/src/output_guard.rs @@ -509,12 +509,27 @@ impl Session { /// is not for an export to replace. For callers that write the file themselves; call it /// before the work, not after it. pub fn refuse_export(&self, path: &str) -> crate::Result<()> { + self.refuse_script_write(path)?; match self.blocking(path) { Some(source) => Err(crate::EngineError::Other(source.refusal(path, Use::Export))), None => Ok(()), } } + /// `Err` when a script issued the running command ([`Session::running_for_script`]) and + /// Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files is off. The scripting + /// wrappers check this too, but a script can run any command directly (`app.run`, `__exec`), + /// so the commands that write a path they are given check here, through + /// [`Session::refuse_export`] and [`Session::write_output`]. + pub fn refuse_script_write(&self, path: &str) -> crate::Result<()> { + if self.running_for_script() && !self.prefs.scripting.allow_scripts_write_files { + return Err(crate::EngineError::Other(format!( + "Unable to write {path}: scripts can't write files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on" + ))); + } + Ok(()) + } + /// Write `bytes` to `path` through the host's services, unless the project reads the file /// there ([`Session::refuse_export`]). Every command that writes to a path it is given /// writes through this; Save and Save As (the project's own file) do not. @@ -525,6 +540,7 @@ impl Session { /// [`Session::write_output`] against files already found ([`Session::protected_files`]), /// for a command that writes many. pub(crate) fn write_output_with(&self, protected: &Protected, path: &str, bytes: &[u8]) -> crate::Result<()> { + self.refuse_script_write(path)?; if let Some(source) = protected.find(Path::new(path)) { return Err(crate::EngineError::Other(source.refusal(path, Use::Export))); } diff --git a/crates/engine/src/preview.rs b/crates/engine/src/preview.rs index 76d21a806..7c0ee02cd 100644 --- a/crates/engine/src/preview.rs +++ b/crates/engine/src/preview.rs @@ -159,7 +159,8 @@ pub struct PreviewPreset { pub pre_roll: f64, pub post_roll: f64, pub play_from: PlayFrom, - /// Frames per second; `None` = Auto (the comp's rate). + /// Composition frames played per second; `None` = Auto (the comp's rate). + /// An override changes preview speed, including the audio's speed and pitch. pub frame_rate: Option, /// Frames skipped between the frames played (0 = every frame). pub skip: u32, diff --git a/crates/engine/src/replay.rs b/crates/engine/src/replay.rs new file mode 100644 index 000000000..5cca36918 --- /dev/null +++ b/crates/engine/src/replay.rs @@ -0,0 +1,748 @@ +//! Replay scripts: a bug or crash as a list of commands an agent (or a test) can run again. +//! +//! The session's command journal ([`Session::journal`]) becomes a JSON-lines script +//! (`journal.export`, `help.exportRepro`, and automatically when the last-resort guard catches a +//! panic, see [`after_command`]); `effectcraft-cli replay script.jsonl` runs one headless and +//! `crates/host/tests/it/repros.rs` runs every `tests/repros/*.jsonl` as a regression test. +//! +//! Format (one JSON object per line; blank lines and lines starting with `//` or `#` are +//! comments): +//! +//! ```text +//! {"start": "new" | "demo" | {"project": "path.ecproj"}, "allow_errors"?: bool} optional, first +//! {"command": "comp.new", "params": {...}, "expect_error"?: true | "substring"} +//! {"expect": {"query": "prop.get", "params": {...}, "pointer"?: "/value", "equals"?: ...}} +//! {"expect_no_error": true} +//! ``` +//! +//! A command must succeed unless it says `expect_error` (then it must fail, with that text in +//! the message when it is a string), or the start line says `allow_errors` (errors are reported +//! but tolerated; crash repros say so, since after the fix the crashing step may fail cleanly). +//! A panic always fails. `expect` runs a command (normally a query) and compares the value at +//! the JSON `pointer` of its result with `equals` (numbers within 1e-6); `expect_no_error` +//! checks that the previous command succeeded. Other keys (`version`, `app`, `note`, `crash`…) +//! are information for people. Relative `project` paths are relative to the script. +//! +//! Privacy: a repro holds command ids and their parameters only, never project or media file +//! contents. Parameters can include file paths, layer names and text typed into text layers. + +use std::cell::Cell; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::{Map, Value, json}; + +use crate::{EngineError, Result, Session, guard}; + +/// The format version written into exported headers. +pub const VERSION: u64 = 1; +/// Largest script [`run`] accepts (bytes). +pub const MAX_SCRIPT_BYTES: usize = 64 << 20; +/// Most lines [`run`] reads. +pub const MAX_LINES: usize = 100_000; +/// Exported scripts stay below this size: the oldest steps are dropped (and counted). +const MAX_EXPORT_BYTES: usize = 32 << 20; +/// Folder of saved repros in the settings store (next to `Logs`). +pub const REPRO_DIR: &str = "Repros"; +/// Automatic crash repros kept (the oldest are removed). +const KEEP_CRASH_REPROS: usize = 20; +/// Commands that replace the whole project: an export starts at the last one. +const PROJECT_STARTS: &[&str] = &["file.newProject", "file.open", "file.openDemoProject"]; +/// Error and value texts in reports are cut to this many characters. +const MAX_TEXT: usize = 600; + +// ---------- nesting and crash capture (Session::execute) ---------- + +thread_local! { + /// Commands running on this thread: (all, journaled ones). + static DEPTH: Cell<(u32, u32)> = const { Cell::new((0, 0)) }; + /// A replay is running on this thread (its panics are reported, not saved as repros). + static REPLAYING: Cell = const { Cell::new(false) }; +} + +/// One running command (see [`Session::execute`]): commands it runs itself (a script's steps) +/// aren't journaled again, and only the outermost command saves a crash repro. +pub struct Nesting { + outer: (u32, u32), +} + +impl Nesting { + /// Record nesting so commands dispatched by a script are not exported twice. + pub fn enter(journaled: bool) -> Nesting { + let outer = DEPTH.with(Cell::get); + DEPTH.with(|d| d.set((outer.0.saturating_add(1), outer.1.saturating_add(u32::from(journaled))))); + Nesting { outer } + } + /// Run by a journaled command (which replays it). + pub fn inside_journaled(&self) -> bool { + self.outer.1 > 0 + } + /// Whether this command is responsible for capturing a nested panic. + pub fn outermost(&self) -> bool { + self.outer.0 == 0 + } +} + +impl Drop for Nesting { + fn drop(&mut self) { + DEPTH.with(|d| d.set(self.outer)); + } +} + +/// After the outermost command: when the guard caught a panic while it ran (`caught` is +/// [`guard::caught`] from before), save the journal plus this command as a crash repro in the +/// settings store and name it in the error. +pub fn after_command(s: &Session, id: &str, params: &Value, nesting: &Nesting, caught: u64, r: Result) -> Result { + if !nesting.outermost() || guard::caught() == caught || REPLAYING.with(Cell::get) { + return r; + } + let error = match &r { + Err(e) => e.to_string(), + Ok(_) => "a step it ran failed with an internal error".to_string(), + }; + let saved = save_crash_repro(s, id, params, &error); + match (r, saved) { + (Err(EngineError::Other(m)), Some(path)) => Err(EngineError::Other(format!("{m} (the steps that led here were saved to {path})"))), + (r, _) => r, + } +} + +/// Wall-clock seconds used only to label exported repros. +fn unix_secs() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// `Repros/--.jsonl`. +fn repro_name(prefix: &str, id: &str) -> String { + let id: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' }).take(60).collect(); + format!("{REPRO_DIR}/{prefix}-{:012}-{id}.jsonl", unix_secs()) +} + +/// Where a store entry is on disk (or its name in stores without a folder). +fn store_path(store: &dyn crate::config::ConfigStore, name: &str) -> String { + store.dir().map(|d| d.join(name).display().to_string()).unwrap_or_else(|| name.to_string()) +} + +/// Persist the failing command with its preceding journal and prune older crash repros. +fn save_crash_repro(s: &Session, id: &str, params: &Value, error: &str) -> Option { + let store = s.config.clone()?; + let mut text = export(s, Some(json!({"command": id, "error": cut(error)}))); + text.push_str(&command_line(id, params)); + text.push('\n'); + let name = repro_name("crash", id); + if let Err(e) = store.write(&name, &text) { + log::error!("cannot save the crash repro {name}: {e}"); + return None; + } + // Keep the newest few (names sort by time). + let mut old: Vec = store.list(REPRO_DIR).into_iter().filter(|n| n.starts_with("crash-")).collect(); + old.sort(); + let extra = old.len().saturating_sub(KEEP_CRASH_REPROS); + for n in old.into_iter().take(extra) { + let _ = store.remove(&format!("{REPRO_DIR}/{n}")); + } + let path = store_path(store.as_ref(), &name); + log::error!("crash repro saved to {path}"); + Some(path) +} + +/// Save the journal as a repro: at `path` (through the session's file services), else in the +/// settings store's `Repros` folder. Returns where it went and how many steps it holds. +pub fn save_repro(s: &Session, path: Option<&str>) -> Result<(String, usize)> { + let text = export(s, None); + let steps = text.lines().count().saturating_sub(1); + if let Some(path) = path { + s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + return Ok((path.to_string(), steps)); + } + let store = s.config.clone().ok_or_else(|| EngineError::Other("there is no settings folder to save the repro in; pass `path`".into()))?; + let name = repro_name("repro", "session"); + store.write(&name, &text).map_err(|e| EngineError::Other(format!("cannot save {name}: {e}")))?; + Ok((store_path(store.as_ref(), &name), steps)) +} + +// ---------- export ---------- + +/// Serialize one command without including an empty parameter object. +fn command_line(id: &str, params: &Value) -> String { + let mut o = Map::new(); + o.insert("command".into(), json!(id)); + if !(params.is_null() || params.as_object().is_some_and(Map::is_empty)) { + o.insert("params".into(), params.clone()); + } + Value::Object(o).to_string() +} + +/// Saving writes the user's files and doesn't change the project: left out of repros. +fn skipped_in_export(id: &str) -> bool { + id.starts_with("file.save") || id.starts_with("file.increment") +} + +/// The journal as a replay script: a start line, then the steps since the project was last +/// replaced (`file.newProject` / `file.open` / `file.openDemoProject`; successful Revert records a resolved `file.open`). `crash` (the failing +/// command and its error) marks a crash repro, which tolerates errors on replay. +pub fn export(s: &Session, crash: Option) -> String { + let from = s.journal.iter().rposition(|(id, _)| PROJECT_STARTS.contains(&id.as_str())).unwrap_or(0); + let mut lines: Vec = + s.journal.get(from..).unwrap_or_default().iter().filter(|(id, _)| !skipped_in_export(id)).map(|(id, p)| command_line(id, p)).collect(); + let mut size: usize = lines.iter().map(|l| l.len().saturating_add(1)).sum(); + let mut dropped = 0usize; + while size > MAX_EXPORT_BYTES && !lines.is_empty() { + size = size.saturating_sub(lines.remove(0).len().saturating_add(1)); + dropped += 1; + } + let mut head = Map::new(); + head.insert("start".into(), json!("new")); + head.insert("version".into(), json!(VERSION)); + head.insert("app".into(), json!(format!("EffectCraft {}", env!("CARGO_PKG_VERSION")))); + head.insert("os".into(), json!(std::env::consts::OS)); + head.insert("exported".into(), json!(unix_secs())); + if let Some(c) = crash { + head.insert("allow_errors".into(), json!(true)); + head.insert("crash".into(), c); + } + if dropped > 0 { + head.insert("dropped".into(), json!(dropped)); + } + head.insert("note".into(), json!("commands and parameters only (no file contents); parameters can include file paths and text")); + let mut out = Value::Object(head).to_string(); + out.push('\n'); + for l in lines { + out.push_str(&l); + out.push('\n'); + } + out +} + +// ---------- parsing ---------- + +#[derive(Clone, Debug, PartialEq)] +enum Start { + New, + Demo, + Project(String), +} + +#[derive(Clone, Debug)] +enum Step { + Start(Start), + Command { + id: String, + params: Value, + expect_error: Option>, + }, + Expect { + query: String, + params: Value, + pointer: Option, + equals: Option, + }, + ExpectNoError, + /// A line that isn't a valid step (reported as a failure). + Invalid(String), +} + +fn params_of(v: Option<&Value>) -> std::result::Result { + match v { + None | Some(Value::Null) => Ok(json!({})), + Some(p @ Value::Object(_)) => Ok(p.clone()), + Some(_) => Err("`params` must be an object".into()), + } +} + +/// Decode the supported initial project states. +fn parse_start(v: &Value) -> std::result::Result { + match v { + Value::String(s) if s == "new" => Ok(Start::New), + Value::String(s) if s == "demo" => Ok(Start::Demo), + Value::Object(o) => match o.get("project").and_then(Value::as_str) { + Some(p) if !p.trim().is_empty() => Ok(Start::Project(p.to_string())), + _ => Err("`start` object needs a `project` path".into()), + }, + _ => Err("`start` must be \"new\", \"demo\" or {\"project\": path}".into()), + } +} + +/// One line → a step (`None`: a blank or comment line). +fn parse_line(text: &str, first: bool, allow_errors: &mut bool) -> Option { + let t = text.trim(); + if t.is_empty() || t.starts_with("//") || t.starts_with('#') { + return None; + } + let v: Value = match serde_json::from_str(t) { + Ok(v) => v, + Err(e) => return Some(Step::Invalid(format!("not JSON: {e}"))), + }; + let Some(o) = v.as_object() else { return Some(Step::Invalid("a line must be a JSON object".into())) }; + let step = if let Some(st) = o.get("start") { + if !first { + return Some(Step::Invalid("the `start` line must come first".into())); + } + *allow_errors = o.get("allow_errors").and_then(Value::as_bool).unwrap_or(false); + parse_start(st).map(Step::Start) + } else if let Some(c) = o.get("command") { + parse_command(c, o) + } else if let Some(e) = o.get("expect") { + parse_expect(e) + } else if o.get("expect_no_error").and_then(Value::as_bool) == Some(true) { + Ok(Step::ExpectNoError) + } else if o.len() == 1 && o.contains_key("comment") { + return None; + } else { + Err("expected a `start`, `command`, `expect` or `expect_no_error` line".to_string()) + }; + Some(step.unwrap_or_else(Step::Invalid)) +} + +/// Parse one command and its optional expected error without executing it. +fn parse_command(c: &Value, o: &Map) -> std::result::Result { + let id = c.as_str().filter(|s| !s.is_empty()).ok_or("`command` must be a command id")?; + let expect_error = match o.get("expect_error") { + None | Some(Value::Null | Value::Bool(false)) => None, + Some(Value::Bool(true)) => Some(None), + Some(Value::String(s)) => Some(Some(s.clone())), + Some(_) => return Err("`expect_error` must be true or a text the error contains".into()), + }; + Ok(Step::Command { id: id.to_string(), params: params_of(o.get("params"))?, expect_error }) +} + +/// Parse a query assertion, retaining the distinction between no expected value and JSON null. +fn parse_expect(e: &Value) -> std::result::Result { + let query = e.get("query").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("`expect` needs a `query` command id")?; + let pointer = e.get("pointer").map(|p| p.as_str().map(str::to_string).ok_or("`pointer` must be a JSON pointer such as /value")).transpose()?; + Ok(Step::Expect { query: query.to_string(), params: params_of(e.get("params"))?, pointer, equals: e.get("equals").cloned() }) +} + +// ---------- running ---------- + +/// How to run a script. +#[derive(Clone, Debug, Default)] +pub struct Options { + /// Keep running after a failed step (default: stop at the first failure). + pub keep_going: bool, + /// Folder relative `start.project` paths are resolved against (the script's). + pub base_dir: Option, +} + +/// What happened to one line. +#[derive(Clone, Debug, Serialize)] +pub struct StepReport { + /// 1-based line number (0: the implicit start). + pub line: usize, + /// `start`, `command`, `expect`, `expect_no_error` or `invalid`. + pub kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub panicked: bool, +} + +/// The outcome of [`run`]. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Report { + pub ok: bool, + /// A step panicked (caught by the last-resort guard): always a failure. + pub panicked: bool, + pub steps: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_failure: Option, + /// Steps not run after the first failure. + pub skipped: usize, +} + +/// Cut a message to [`MAX_TEXT`] characters. +fn cut(s: &str) -> String { + match s.char_indices().nth(MAX_TEXT) { + Some((i, _)) => format!("{}…", s.get(..i).unwrap_or(s)), + None => s.to_string(), + } +} + +/// Run one command the way the app does, noting whether it panicked. +fn exec(s: &mut Session, id: &str, params: Value) -> (Result, bool) { + let before = guard::caught(); + let r = guard::guarded(&format!("replay of `{id}`"), || s.execute(id, params)); + s.events.clear(); + (r, guard::caught() != before) +} + +/// Numbers within 1e-6 (relative above 1), everything else exactly. +fn approx_eq(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0), + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| approx_eq(a, b)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, a)| y.get(k).is_some_and(|b| approx_eq(a, b))), + _ => a == b, + } +} + +/// Marks this thread as replaying while alive. +struct Replaying(bool); + +impl Replaying { + /// Suppress automatic crash exports for the duration of a replay. + fn enter() -> Replaying { + Replaying(REPLAYING.with(|r| r.replace(true))) + } +} + +impl Drop for Replaying { + fn drop(&mut self) { + REPLAYING.with(|r| r.set(self.0)); + } +} + +/// Create or open the initial project and retain whether its command panicked. +fn start_step(s: &mut Session, start: &Start, base: Option<&Path>) -> (String, Result, bool) { + match start { + Start::New => { + let (r, p) = exec(s, "file.newProject", json!({})); + ("file.newProject".into(), r, p) + } + Start::Demo => { + let (r, p) = exec(s, "file.openDemoProject", json!({})); + ("file.openDemoProject".into(), r, p) + } + Start::Project(path) => { + let full = match base { + Some(b) if Path::new(path).is_relative() => b.join(path).display().to_string(), + _ => path.clone(), + }; + let (r, p) = exec(s, "file.open", json!({"path": full})); + ("file.open".into(), r, p) + } + } +} + +/// Compare a query result and produce a bounded explanation when it fails. +fn expectation_error(r: Result, panicked: bool, pointer: Option<&str>, equals: Option<&Value>) -> Option { + let value = match r { + Ok(v) => v, + Err(e) => return Some(cut(&format!("{}{e}", if panicked { "panicked: " } else { "" }))), + }; + let got = match pointer { + Some(p) => value.pointer(p), + None => Some(&value), + }; + match (got, equals) { + (None, _) => Some(cut(&format!("no value at {} in {value}", pointer.unwrap_or("")))), + (Some(got), Some(want)) if !approx_eq(got, want) => Some(cut(&format!("expected {want}, got {got}"))), + _ => None, + } +} + +/// Execute one parsed line and remember whether the last command failed for `expect_no_error`. +fn run_step(s: &mut Session, line: usize, step: Step, opts: &Options, allow_errors: bool, last_command_failed: &mut Option) -> StepReport { + let mut sr = StepReport { line, kind: "command", command: None, ok: true, error: None, panicked: false }; + match step { + Step::Invalid(m) => { + sr.kind = "invalid"; + sr.ok = false; + sr.error = Some(m); + } + Step::Start(start) => { + sr.kind = "start"; + let (id, r, panicked) = start_step(s, &start, opts.base_dir.as_deref()); + sr.command = Some(id); + sr.panicked = panicked; + sr.ok = !panicked; + if let Err(e) = r { + sr.ok = false; + sr.error = Some(cut(&e.to_string())); + } + } + Step::Command { id, params, expect_error } => { + let (r, panicked) = exec(s, &id, params); + sr.command = Some(id); + sr.panicked = panicked; + *last_command_failed = Some(r.is_err()); + let err = r.err().map(|e| e.to_string()); + let (ok, error) = match (err, expect_error) { + (e, _) if panicked => (false, Some(format!("panicked: {}", e.unwrap_or_else(|| "a step failed with an internal error".into())))), + (None, None) => (true, None), + (None, Some(_)) => (false, Some("expected an error, but the command succeeded".into())), + (Some(e), Some(Some(want))) if !e.contains(want.as_str()) => (false, Some(format!("expected an error containing {want:?}, got: {e}"))), + (Some(_), Some(_)) => (true, None), + (Some(e), None) => (allow_errors, Some(e)), + }; + sr.ok = ok; + sr.error = error.as_deref().map(cut); + } + Step::Expect { query, params, pointer, equals } => { + sr.kind = "expect"; + let (r, panicked) = exec(s, &query, params); + sr.command = Some(query); + sr.panicked = panicked; + sr.error = expectation_error(r, panicked, pointer.as_deref(), equals.as_ref()); + sr.ok = sr.error.is_none() && !panicked; + } + Step::ExpectNoError => { + sr.kind = "expect_no_error"; + match last_command_failed { + Some(false) => {} + Some(true) => { + sr.ok = false; + sr.error = Some("the previous command failed".into()); + } + None => { + sr.ok = false; + sr.error = Some("no command before this line".into()); + } + } + } + } + sr +} + +/// Run a replay script against `s`. Never panics: malformed lines are failed steps. +pub fn run(s: &mut Session, script: &str, opts: &Options) -> Report { + let _replaying = Replaying::enter(); + let mut report = Report { ok: true, ..Default::default() }; + let fail = |report: &mut Report, sr: StepReport| { + report.ok = false; + report.panicked |= sr.panicked; + if report.first_failure.is_none() { + report.first_failure = Some(sr.clone()); + } + report.steps.push(sr); + }; + if script.len() > MAX_SCRIPT_BYTES { + let msg = format!("the script is larger than {} MB", MAX_SCRIPT_BYTES >> 20); + fail(&mut report, StepReport { line: 0, kind: "invalid", command: None, ok: false, error: Some(msg), panicked: false }); + return report; + } + // Hand-edited UTF-8 scripts may start with a BOM, especially on Windows. + let script = script.strip_prefix('\u{feff}').unwrap_or(script); + let mut allow_errors = false; + let mut steps: Vec<(usize, Step)> = vec![]; + for (i, line) in script.lines().enumerate().take(MAX_LINES) { + if let Some(step) = parse_line(line, steps.is_empty(), &mut allow_errors) { + steps.push((i + 1, step)); + } + } + if script.lines().nth(MAX_LINES).is_some() { + steps.push((MAX_LINES + 1, Step::Invalid(format!("the script has more than {MAX_LINES} lines; the rest was not read")))); + } + if !matches!(steps.first(), Some((_, Step::Start(_)) | (_, Step::Invalid(_)))) { + steps.insert(0, (0, Step::Start(Start::New))); + } + let mut last_command_failed: Option = None; + let mut stopped = false; + for (line, step) in steps { + if stopped { + report.skipped += 1; + continue; + } + let sr = run_step(s, line, step, opts, allow_errors, &mut last_command_failed); + if sr.ok { + report.steps.push(sr); + } else { + fail(&mut report, sr); + stopped = !opts.keep_going; + } + } + report +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::{Options, run}; + use crate::Session; + use crate::config::{ConfigStore, MemoryConfig}; + + fn replay(script: &str) -> super::Report { + run(&mut Session::new(), script, &Options::default()) + } + + #[test] + fn steps_expectations_and_failures_are_reported() { + let r = replay( + r#"{"start": "new"} +// a comment +{"command": "comp.new", "params": {"name": "A", "width": 320, "height": 180, "frameRate": 30, "duration": 2}} +{"expect_no_error": true} +{"command": "comp.new", "params": {"frameRate": 0}, "expect_error": "frame"} +{"expect": {"query": "comp.info", "pointer": "/width", "equals": 320}} +"#, + ); + assert!(r.ok, "{r:?}"); + assert_eq!(r.steps.len(), 5); + let r = replay( + "{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\n{\"command\": \"comp.new\"}", + ); + assert!(!r.ok && !r.panicked && r.skipped == 1, "{r:?}"); + assert_eq!(r.first_failure.as_ref().map(|f| f.line), Some(2)); + // An unexpected success, an unknown command. + assert!(!replay(r#"{"command": "comp.new", "expect_error": true}"#).ok); + assert!(!replay(r#"{"command": "no.such.command"}"#).ok); + // Tolerated errors. + let r = replay("{\"start\": \"new\", \"allow_errors\": true}\n{\"command\": \"no.such.command\"}\n{\"expect_no_error\": true}"); + assert!(!r.ok && r.first_failure.as_ref().map(|f| f.kind) == Some("expect_no_error"), "{r:?}"); + } + + /// A UTF-8 BOM belongs to the text file, not the first JSON step. + #[test] + fn utf8_bom_preserves_explicit_and_implicit_start_lines() { + for body in ["{\"start\":\"new\"}\n{\"command\":\"comp.new\"}", "{\"command\":\"comp.new\"}"] { + let script = format!("\u{feff}{body}"); + let report = replay(&script); + assert!(report.ok, "{report:?}"); + assert_eq!(report.steps.last().unwrap().line, body.lines().count()); + } + } + + #[test] + fn malformed_lines_are_failures_not_crashes() { + for bad in [ + "not json", + "[1, 2]", + "{\"command\": 5}", + "{\"command\": \"comp.new\", \"params\": [1]}", + "{\"command\": \"comp.new\", \"expect_error\": 3}", + "{\"expect\": {}}", + "{\"expect\": {\"query\": \"comp.info\", \"pointer\": 4}}", + "{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/nope\"}}", + "{\"start\": 7}", + "{\"start\": {\"project\": \"\"}}", + "{\"command\": \"comp.new\"}\n{\"start\": \"new\"}", + "{\"what\": 1}", + "{\"expect_no_error\": true}", + "{\"start\": {\"project\": \"/no/such/file.ecproj\"}}", + ] { + let r = replay(bad); + assert!(!r.ok && !r.panicked, "{bad}: {r:?}"); + } + let r = replay(&format!("{{\"command\": \"{}\"}}", "\u{1F600}".repeat(1000))); + assert!(!r.ok && r.first_failure.and_then(|f| f.error).is_some_and(|e| e.ends_with('…'))); + assert!(replay("").ok, "an empty script is a new project"); + } + + #[test] + fn the_journal_exports_and_replays() { + let mut s = Session::new(); + s.execute("comp.new", json!({"name": "Old"})).unwrap(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000", "name": "Red"})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#00ff00", "name": "Green"})).unwrap(); + // A query isn't journaled; neither is saving. + s.execute("comp.info", json!({})).unwrap(); + let text = super::export(&s, None); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 4, "{text}"); + assert!(lines[0].contains("\"start\":\"new\"") && lines[1].contains("file.openDemoProject"), "starts at the last project open: {text}"); + let mut again = Session::new(); + let r = run(&mut again, &text, &Options::default()); + assert!(r.ok, "{r:?}"); + let layers = |s: &Session| s.active_comp().map(|c| c.layers.len()); + assert_eq!(layers(&again), layers(&s)); + // journal.export returns it; with `path` it writes the file. + let v = s.execute("journal.export", json!({})).unwrap(); + assert_eq!(v["steps"], 3); + assert_eq!(v["script"].as_str().unwrap().lines().skip(1).collect::>(), lines[1..]); + let dir = std::env::temp_dir().join(format!("ec-replay-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("j.jsonl"); + s.execute("journal.export", json!({"path": path.to_str().unwrap()})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("layer.newSolid")); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(s.journal.iter().filter(|(id, _)| id.starts_with("journal.")).count(), 0); + } + + #[test] + fn help_export_repro_saves_into_the_settings_store() { + let mut s = Session::new(); + assert!(s.execute("help.exportRepro", json!({})).is_err(), "no settings folder and no path"); + let store = Arc::new(MemoryConfig::default()); + s.config = Some(store.clone()); + s.execute("comp.new", json!({})).unwrap(); + let v = s.execute("help.exportRepro", json!({})).unwrap(); + assert_eq!(v["steps"], 1); + let saved = store.list(super::REPRO_DIR); + assert_eq!(saved.len(), 1, "{saved:?}"); + assert!(store.read(v["path"].as_str().unwrap()).unwrap().contains("comp.new")); + } + + #[test] + fn a_panicking_command_saves_a_crash_repro_and_replays_as_a_failure() { + let mut s = Session::new(); + let store = Arc::new(MemoryConfig::default()); + s.config = Some(store.clone()); + s.execute("comp.new", json!({"name": "C"})).unwrap(); + // `test.panic` stands in for a command with a bug (see `commands::journal`). + let e = s.execute("test.panic", json!({"why": "boom"})).unwrap_err().to_string(); + assert!(e.contains("boom") && e.contains("Repros/crash-"), "{e}"); + let names = store.list(super::REPRO_DIR); + assert_eq!(names.len(), 1); + let text = store.read(&format!("{}/{}", super::REPRO_DIR, names[0])).unwrap(); + assert!(text.lines().next().unwrap().contains("\"crash\""), "{text}"); + assert!(text.lines().last().unwrap().contains("test.panic"), "{text}"); + // The replay fails on the panic (and doesn't save another repro). + let r = run(&mut Session::new(), &text, &Options::default()); + assert!(!r.ok && r.panicked, "{r:?}"); + assert_eq!(r.first_failure.as_ref().and_then(|f| f.command.as_deref()), Some("test.panic")); + // A failing replay inside a session with a store saves nothing more. + let mut t = Session::new(); + t.config = Some(store.clone()); + assert!(!run(&mut t, &text, &Options::default()).ok); + assert_eq!(store.list(super::REPRO_DIR).len(), 1); + // Only the newest crash repros are kept. + for i in 0..30 { + store.write(&format!("{}/crash-{i:012}-x.jsonl", super::REPRO_DIR), "{}").unwrap(); + } + let _ = s.execute("test.panic", json!({})); + assert_eq!(store.list(super::REPRO_DIR).len(), super::KEEP_CRASH_REPROS); + } + + #[test] + fn commands_run_by_a_journaled_command_are_not_journaled_twice() { + let mut s = Session::new(); + s.execute("comp.new", json!({})).unwrap(); + // engine.batch isn't journaled: its steps are. + s.execute("engine.batch", json!({"steps": [{"command": "layer.newNull"}, {"command": "layer.newNull"}]})).unwrap(); + assert_eq!(s.journal.iter().filter(|(id, _)| id == "layer.newNull").count(), 2); + // A journaled command's own steps replay with it. + s.execute("file.runScript", json!({"steps": [{"command": "layer.newNull"}]})).unwrap(); + let ids: Vec<&str> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["comp.new", "layer.newNull", "layer.newNull", "file.runScript"]); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod revert_tests { + use super::{Options, export, run}; + use crate::Session; + use serde_json::json; + + /// Saving is omitted, so a successful Revert must keep the file it read as an open boundary. + #[test] + fn save_as_then_revert_exports_a_replayable_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("saved.ecproj"); + let mut s = Session::new(); + s.execute("comp.new", json!({"name": "Saved"})).unwrap(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + s.execute("layer.newNull", json!({})).unwrap(); + s.execute("file.revert", json!({})).unwrap(); + s.execute("layer.newNull", json!({"name": "After Revert"})).unwrap(); + let script = export(&s, None); + assert!(!script.contains("file.save") && !script.contains("file.revert"), "{script}"); + assert!(script.lines().nth(1).unwrap().contains("file.open"), "{script}"); + let saved = std::fs::read(&path).unwrap(); + let mut again = Session::new(); + let report = run(&mut again, &script, &Options::default()); + assert!(report.ok, "{report:?}"); + assert_eq!(again.path, s.path); + assert_eq!(again.active_comp().unwrap().layers.len(), s.active_comp().unwrap().layers.len()); + assert_eq!(saved, std::fs::read(path).unwrap(), "replay must not rewrite the saved project"); + } +} diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 6d36a2e7e..69e572ec2 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -195,6 +195,15 @@ impl ImageRef { } } +/// Most edges a ScriptUI `fillPath` is filled with (a path past it fills its first edges). +pub const MAX_FILL_EDGES: usize = 65_536; +/// Most horizontal bands a ScriptUI fill is cut into (vertices and edge crossings); the topmost are filled. +pub const MAX_FILL_BANDS: usize = 262_144; +/// Most triangles a ScriptUI fill produces. +pub const MAX_FILL_TRIANGLES: usize = 1 << 22; +/// Most band-by-edge steps a fill takes while cutting bands. +pub const MAX_FILL_WORK: usize = 1 << 31; + impl PathSeg { /// The subpaths of `path` as polylines (ellipses flattened to `n` segments), each with /// whether it is closed. @@ -248,8 +257,16 @@ impl PathSeg { /// self-intersecting and holed paths fill correctly (frontends' polygon fills are often /// convex-only). Exact trapezoid decomposition: the plane is cut into horizontal bands at /// every vertex and edge crossing; inside a band no edges cross, so each inside span is a - /// trapezoid (two triangles). + /// trapezoid (two triangles). Edges are compared pairwise and every band looks at every edge, + /// on the UI thread, for geometry a script chooses, so a path past [`MAX_FILL_EDGES`] edges, + /// [`MAX_FILL_BANDS`] bands, [`MAX_FILL_TRIANGLES`] triangles or [`MAX_FILL_WORK`] steps (edge + /// pairs compared, then edges looked at per band) is filled only as far as the limit allows + /// (topmost part first) and a warning is logged. pub fn fill_triangles(path: &[PathSeg], n: usize, even_odd: bool) -> Vec<[[f64; 2]; 3]> { + Self::fill_triangles_within(path, n, even_odd, [MAX_FILL_EDGES, MAX_FILL_BANDS, MAX_FILL_TRIANGLES, MAX_FILL_WORK]) + } + + fn fill_triangles_within(path: &[PathSeg], n: usize, even_odd: bool, [max_edges, max_bands, max_triangles, max_work]: [usize; 4]) -> Vec<[[f64; 2]; 3]> { // Non-horizontal edges, top to bottom, with their winding direction. let mut edges: Vec<([f64; 2], [f64; 2], i32)> = vec![]; for (pts, _) in PathSeg::polylines(path, n) { @@ -261,15 +278,37 @@ impl PathSeg { edges.push(if a[1] < b[1] { (a, b, 1) } else { (b, a, -1) }); } } + if edges.len() > max_edges { + log::warn!("fillPath: {} edges, filling only the first {max_edges}", edges.len()); + edges.truncate(max_edges); + } if edges.len() < 2 { return vec![]; } let x_at = |e: &([f64; 2], [f64; 2], i32), y: f64| e.0[0] + (e.1[0] - e.0[0]) * (y - e.0[1]) / (e.1[1] - e.0[1]); let mut ys: Vec = edges.iter().flat_map(|e| [e.0[1], e.1[1]]).collect(); - // Where two edges cross, a band boundary. - for i in 0..edges.len() { - for j in i + 1..edges.len() { + // Where two edges cross, a band boundary. Edges by top, so each is compared only with the + // ones that start above its bottom (the pairs that share some height); the pairs and the + // boundaries found are counted against the budgets, and past one the rest are not looked + // for. A band that holds a crossing that was not looked for is not filled (its edges + // change order inside it, which the band loop below tells), so what is drawn is never + // outside the path. + let mut truncated = false; + let mut order: Vec = (0..edges.len()).collect(); + order.sort_by(|&a, &b| edges[a].0[1].total_cmp(&edges[b].0[1])); + let mut pairs = 0usize; + 'cross: for (k, &i) in order.iter().enumerate() { + for &j in &order[k + 1..] { let (a, b) = (&edges[i], &edges[j]); + if b.0[1] >= a.1[1] { + break; + } + pairs += 1; + if pairs > max_work || ys.len() > max_bands { + log::warn!("fillPath: over its crossing budget; bands with crossings that were not cut are left unfilled"); + truncated = true; + break 'cross; + } let (lo, hi) = (a.0[1].max(b.0[1]), a.1[1].min(b.1[1])); if hi <= lo { continue; @@ -282,6 +321,11 @@ impl PathSeg { } ys.sort_by(f64::total_cmp); ys.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + if ys.len() > max_bands { + log::warn!("fillPath: {} bands, filling only the topmost {max_bands}", ys.len()); + ys.truncate(max_bands); + } + let mut work = 0usize; let mut out = vec![]; let mut row: Vec<(f64, f64, f64, i32)> = vec![]; for w in ys.windows(2) { @@ -290,8 +334,14 @@ impl PathSeg { row.clear(); row.extend(edges.iter().filter(|e| e.0[1] < ym && ym < e.1[1]).map(|e| (x_at(e, ym), x_at(e, ya), x_at(e, yb), e.2))); row.sort_by(|a, b| a.0.total_cmp(&b.0)); + // With crossings missing, edges that swap places inside the band make its trapezoids + // wrong: it is left out. + let swapped = truncated && !row.windows(2).all(|w| w[0].1 <= w[1].1 + 1e-9 && w[0].2 <= w[1].2 + 1e-9); let mut wind = 0; for k in 0..row.len().saturating_sub(1) { + if swapped { + break; + } wind += row[k].3; let inside = if even_odd { wind % 2 != 0 } else { wind != 0 }; if inside { @@ -300,6 +350,13 @@ impl PathSeg { out.push([[l.1, ya], [r.2, yb], [l.2, yb]]); } } + // (Every band's edge scan counts, a band left out included.) + work = work.saturating_add(edges.len()); + if out.len() > max_triangles || work > max_work { + log::warn!("fillPath: over its triangle or work budget, filled down to y = {yb}"); + out.truncate(max_triangles); + break; + } } out } @@ -951,4 +1008,50 @@ mod tests { let open = vec![PathSeg::M { x: 0.0, y: 0.0 }, PathSeg::L { x: 4.0, y: 0.0 }, PathSeg::L { x: 0.0, y: 4.0 }]; assert!((area(&PathSeg::fill_triangles(&open, 48, false)) - 8.0).abs() < 1e-9); } + + /// A script's path past the fill budgets is filled as far as they allow, never emptied. + #[test] + fn a_fill_past_its_budgets_draws_what_fits() { + let area = |t: &[[[f64; 2]; 3]]| { + t.iter().map(|t| ((t[1][0] - t[0][0]) * (t[2][1] - t[0][1]) - (t[2][0] - t[0][0]) * (t[1][1] - t[0][1])).abs() / 2.0).sum::() + }; + let rect = |x: f64, y: f64, w: f64, h: f64| PathSeg::R { x, y, w, h }; + let rects: Vec = (0..8).map(|i| rect(2.0 * i as f64, 0.0, 1.0, 10.0)).collect(); + let full = PathSeg::fill_triangles(&rects, 48, false); + assert!((area(&full) - 80.0).abs() < 1e-9); + // Edges over the limit: the first edges still fill (4 edges = 2 rectangles). + let t = PathSeg::fill_triangles_within(&rects, 48, false, [4, usize::MAX, usize::MAX, usize::MAX]); + assert!((area(&t) - 20.0).abs() < 1e-9, "{}", area(&t)); + // Triangles over the limit: truncated, not empty. + let t = PathSeg::fill_triangles_within(&rects, 48, false, [usize::MAX, usize::MAX, 6, usize::MAX]); + assert_eq!(t.len(), 6); + // Bands over the limit: the topmost band fills. + let stair: Vec = (0..4).map(|i| rect(0.0, i as f64, 1.0, 1.0)).collect(); + let t = PathSeg::fill_triangles_within(&stair, 48, false, [usize::MAX, 2, usize::MAX, usize::MAX]); + assert!(!t.is_empty() && area(&t) < 4.0); + // Work over the limit stops the cut. + let t = PathSeg::fill_triangles_within(&stair, 48, false, [usize::MAX, usize::MAX, usize::MAX, 1]); + assert!(!t.is_empty() && area(&t) < 4.0); + // Many crossing edges (every pair of 120 diagonals meets): the crossing scan is held to the + // band and work budgets too. + let fan: Vec = (0..60) + .flat_map(|i| { + let x = i as f64; + [PathSeg::M { x, y: 0.0 }, PathSeg::L { x: 60.0 - x, y: 100.0 }, PathSeg::L { x: x + 0.5, y: 100.0 }] + }) + .collect(); + for limits in [[usize::MAX, 50, usize::MAX, usize::MAX], [usize::MAX, usize::MAX, usize::MAX, 500]] { + let t = PathSeg::fill_triangles_within(&fan, 48, false, limits); + assert!(area(&t) <= area(&PathSeg::fill_triangles(&fan, 48, false)) + 1e-6, "{limits:?}"); + } + // Bands whose crossings were not looked for are not filled: a bow tie (area 50, crossing at + // y = 5) under a crossing budget of one boundary gives no overlapping triangles. + let bow = vec![PathSeg::M { x: 0.0, y: 0.0 }, PathSeg::L { x: 10.0, y: 10.0 }, PathSeg::L { x: 0.0, y: 10.0 }, PathSeg::L { x: 10.0, y: 0.0 }]; + assert!((area(&PathSeg::fill_triangles(&bow, 48, false)) - 50.0).abs() < 1e-9); + let t = PathSeg::fill_triangles_within(&bow, 48, false, [usize::MAX, 2, usize::MAX, usize::MAX]); + assert!(area(&t) <= 50.0 + 1e-9, "{}", area(&t)); + // Bands left out still count against the work budget (it ends the scan after the first). + let t = PathSeg::fill_triangles_within(&bow, 48, false, [usize::MAX, 2, usize::MAX, 1]); + assert!(area(&t) <= 50.0 + 1e-9, "{}", area(&t)); + } } diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index 5658df107..fe95f5300 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -106,6 +106,11 @@ pub trait Importer: Send + Sync { /// Probe one file and return footage metadata. Image sequences are the engine's /// ([`sequence`]): it groups numbered stills and probes their first file here. fn probe(&self, path: &str) -> std::result::Result; + /// [`probe`](Self::probe), and what the import left out of the file (a 3D model's textures + /// over the limits), which the import shows the user. + fn probe_noting(&self, path: &str) -> std::result::Result<(effectcraft_project::Footage, Option), String> { + self.probe(path).map(|f| (f, None)) + } /// A footage file's bytes are now at `path` (extracted from a template): hosts whose media /// layer reads from memory (the browser) register them; file-based ones need nothing. fn register(&self, _path: &str, _data: &[u8]) {} diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 51d87e018..6ea79f0d9 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -1,8 +1,11 @@ use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands}; -/// `t` as a current time of `comp`: a frame within its duration. +/// `t` as a current time of `comp`: a frame within its duration. The last frame is the last one +/// that starts before the end, so a final partial frame (a duration off the frame grid after a +/// frame-rate change) stays reachable; on the grid it is `duration - frame_duration`. fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { - comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) + let last = comp.frame_rate.snap(comp.duration - Tick(1)).max(Tick::ZERO); + comp.frame_rate.snap_nearest(t.max(Tick::ZERO)).min(last) } impl Session { @@ -114,18 +117,23 @@ impl Session { /// editor is open, so playback with no text editor does no text copying. pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { - let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); - let edited = commands::text_edit::active_editor_snapshot(self); - self.state.times.insert(c, t); - if self.prefs.general.sync_time_related_items { - // Direct call: this `set_time` owns the editor snapshot for both time changes, - // so a synced move does not take a second snapshot or start a second epoch. - self.sync_related_times_inner(c, t); - } - commands::text_edit::after_external_change(self, edited.as_ref()); + self.set_time_of(c, t); } } + /// Move `comp`'s current time, as [`Session::set_time`] does for the active comp. + pub fn set_time_of(&mut self, c: ItemId, t: Tick) { + let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); + let edited = commands::text_edit::active_editor_snapshot(self); + self.state.times.insert(c, t); + if self.prefs.general.sync_time_related_items { + // Direct call: this `set_time_of` owns the editor snapshot for both time changes, + // so a synced move does not take a second snapshot or start a second epoch. + self.sync_related_times_inner(c, t); + } + commands::text_edit::after_external_change(self, edited.as_ref()); + } + /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of /// `comp` moves the current time of the comps nested in it (through their precomp layers' /// timing) and of the comps it is nested in, so every related viewer shows the same moment. @@ -139,8 +147,8 @@ impl Session { /// Sets the time of every comp related to `comp` through precomp layers: down into the comps /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps - /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to - /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. + /// cycles and diamonds finite; each found time goes through [`comp_frame_at`] (as a direct move + /// does) and is stored in `state.times`. The caller owns the text-editor boundary. fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { let mut seen = std::collections::BTreeSet::from([comp]); let mut todo = vec![(comp, t)]; @@ -170,7 +178,7 @@ impl Session { } for (cid, ct) in found { if let Some(c) = self.project.comp(cid) { - let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); + let ct = comp_frame_at(c, ct); self.state.times.insert(cid, ct); } } diff --git a/crates/engine/src/session/dispatch.rs b/crates/engine/src/session/dispatch.rs index aa83ac09f..a3f3f05d5 100644 --- a/crates/engine/src/session/dispatch.rs +++ b/crates/engine/src/session/dispatch.rs @@ -22,10 +22,20 @@ impl Session { } } // Last-resort guard: a panicking command reports an error instead of crashing the app. - let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms))?; + let nesting = crate::replay::Nesting::enter(spec.journal); + let caught = guard::caught(); + let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms)); + let r = crate::replay::after_command(self, id, ¶ms, &nesting, caught, r)?; self.learn_observe(id, ¶ms); - if spec.journal { - self.journal.push((id.to_string(), params)); + if spec.journal && !nesting.inside_journaled() { + // Revert and Open Recent resolve a file the parameters may not name. Store + // the successful read as an explicit open boundary while its resolved path is known. + let entry = if matches!(id, "file.revert" | "file.openRecent") { + self.path.as_ref().map(|path| ("file.open".to_string(), serde_json::json!({"path": path}))) + } else { + None + }; + self.journal.push(entry.unwrap_or_else(|| (id.to_string(), params))); if self.journal.len() > 10_000 { self.journal.drain(..1000); } diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 2dd8a3e7f..c3740196c 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -103,15 +103,22 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); - // Point-sample down to at most `max_side` on the long side. - let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; - let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + // Vector footage is drawn at the thumbnail's size: its declared size can be a + // 16384² canvas, gigabytes of pixels to point-sample down to a few dozen. + let scale = (max_side.max(1) as f64 / f.width.max(f.height).max(1) as f64).min(1.0); + let src = footage.vector_frame(item, &f, scale).or_else(|| footage.frame(item, &f, Tick(0)))?; + let img = effectcraft_render::cineon_frame(src, &f); + // Point-sample down to at most `max_side` on the long side (a drawn size one + // pixel over it, from rounding, is shrunk by that pixel, not halved). + let (long, max) = (u64::from(img.width.max(img.height)), u64::from(max_side.max(1))); + let fit = |side: u32| if long <= max { side } else { ((u64::from(side) * max + long / 2) / long).max(1) as u32 }; + let (w, h) = (fit(img.width), fit(img.height)); let full = img.to_rgba8(); let mut px = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { - let i = (((y * step) * img.width + x * step) * 4) as usize; + let (sx, sy) = (u64::from(x) * u64::from(img.width) / u64::from(w), u64::from(y) * u64::from(img.height) / u64::from(h)); + let i = ((sy * u64::from(img.width) + sx) * 4) as usize; px.extend_from_slice(&full[i..i + 4]); } } diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index 1e79cdd46..d7c50cdce 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -92,6 +92,7 @@ pub fn pages() -> Vec { s("general.showToolTips", "Show Tool Tips", B, true), s("general.viewerWheelScrubbing", "Scrub Composition Frames with Mouse Wheel", B, true), s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true), + s("general.fitNewLayersToWorkArea", "Fit New Layers to Work Area", B, true), s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true), s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true), s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true), @@ -170,6 +171,7 @@ pub fn pages() -> Vec { title: "Previews", items: vec![ s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true), + s("previews.adaptivePlayback", "Lower Resolution When Playback Can't Keep Up", B, true), s("previews.showInternalWireframes", "Show Internal Wireframes", B, true), s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true), s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 328f93bf4..46d654ab0 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -36,6 +36,9 @@ page!(General { viewer_wheel_scrubbing: bool = false, /// Create New Layers at Composition Start Time (off: at the current time). create_layers_at_comp_start: bool = true, + /// Fit New Layers to Work Area: new text, solid, shape, null and adjustment layers span the + /// work area instead of the whole composition (off, as in After Effects). + fit_new_layers_to_work_area: bool = false, switches_affect_nested_comps: bool = true, /// Default Spatial Interpolation to Linear. default_spatial_linear: bool = false, @@ -92,8 +95,14 @@ page!(CompositionPrefs { page!(Previews { /// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`. adaptive_resolution_limit: String = "1/8".into(), + /// A preview at Resolution Auto that renders slower than real time steps down a resolution + /// (½, then ¼, no further than the Adaptive Resolution Limit) for the rest of that play. Off + /// by default: it never steps back up during a play, so a looping preview kept playing at ¼ + /// after its first loop, where without it the first loop caches Full and the next ones play + /// in real time at Full, as in After Effects. + adaptive_playback: bool = false, show_internal_wireframes: bool = false, - cache_frames_when_idle: bool = false, + cache_frames_when_idle: bool = true, fast_previews: bool = false, /// `faster` or `moreAccurate`. zoom_quality: String = "moreAccurate".into(), diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs index 44e2261a2..2d3996d57 100644 --- a/crates/engine/src/settings/shortcuts.rs +++ b/crates/engine/src/settings/shortcuts.rs @@ -550,6 +550,10 @@ impl Keymaps { } } +/// Most conflicts [`ShortcutTable::all_conflicts`] lists: a preset binding every command to one +/// key would otherwise list every pair of them. +pub const MAX_CONFLICTS: usize = 10_000; + /// The active preset resolved against every bindable command. #[derive(Clone, Debug, Default)] pub struct ShortcutTable { @@ -584,8 +588,15 @@ impl ShortcutTable { } /// Every active binding: (keys, bindable). + /// Each bindable's keys once: an imported preset can repeat a binding any number of times. pub fn bindings(&self) -> Vec<(&str, &Bindable)> { - self.bindables.iter().flat_map(|b| self.keys.get(&b.key).into_iter().flatten().map(move |k| (k.as_str(), b))).collect() + self.bindables + .iter() + .flat_map(|b| { + let mut seen = std::collections::HashSet::new(); + self.keys.get(&b.key).into_iter().flatten().filter(move |k| seen.insert(k.as_str())).map(move |k| (k.as_str(), b)) + }) + .collect() } /// Other bindables using `keys` in an overlapping scope. @@ -607,6 +618,7 @@ impl ShortcutTable { /// Every pair of commands sharing keys in the same scope, or in two contexts (which can be /// active at once): only one of the two can run, or neither ([`pick`]). (Other pairs in /// different scopes are intended, see [`Conflict::overrides`].) + /// At most [`MAX_CONFLICTS`] are listed. pub fn all_conflicts(&self) -> Vec<(String, Conflict)> { let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new(); for (k, b) in self.bindings() { @@ -618,6 +630,9 @@ impl ShortcutTable { for (k, bs) in by_keys { for (i, a) in bs.iter().enumerate() { for b in &bs[i + 1..] { + if out.len() >= MAX_CONFLICTS { + return out; + } if a.scope == b.scope || (is_context(&a.scope) && is_context(&b.scope)) { out.push(( a.key.clone(), diff --git a/crates/engine/src/system/guard.rs b/crates/engine/src/system/guard.rs index 699e6474d..ec32e2545 100644 --- a/crates/engine/src/system/guard.rs +++ b/crates/engine/src/system/guard.rs @@ -4,14 +4,28 @@ //! panics leaves the project as it was. This is a safety net, not a substitute for returning //! errors (AGENTS.md, "Never crash"). +use std::cell::Cell; + use crate::{EngineError, Result}; +thread_local! { + /// Panics [`guarded`] caught on this thread (see [`caught`]). + static CAUGHT: Cell = const { Cell::new(0) }; +} + +/// How many panics [`guarded`] has caught on this thread so far: compare two readings to learn +/// whether a call panicked (crash repros, `effectcraft-cli replay`). +pub fn caught() -> u64 { + CAUGHT.with(Cell::get) +} + /// Run `f`; a panic inside it becomes an error naming `what`. The panic itself is logged by /// the panic hook ([`crate::logging::install_panic_hook`]). pub fn guarded(what: &str, f: impl FnOnce() -> Result) -> Result { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { Ok(r) => r, Err(payload) => { + CAUGHT.with(|c| c.set(c.get().saturating_add(1))); let msg = payload .downcast_ref::<&str>() .map(|s| s.to_string()) diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 857a3e90d..da9d0f4f6 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -125,7 +125,13 @@ fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { /// One log line (without the newline): ` [] : `. fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { - let message = message.strip_suffix('\n').unwrap_or(message); + // CRLF becomes LF first, so a message ending in "\r\n" loses its final break like one ending + // in "\n" (and its carriage return is not taken for a lone one). + let message = message.replace("\r\n", "\n"); + let message = message.strip_suffix('\n').unwrap_or(&message); + // A message's own line breaks (a path or name from a project or a script) continue it on + // indented lines, so they can't pass for records of their own. + let message = message.replace('\r', "\\r").replace('\n', "\n\t"); format!("{ts} {level:<5} [{thread}] {target}: {message}") } @@ -214,16 +220,31 @@ fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { } } let path = numbered(dir, 0); - let file = File::create(&path)?; + let file = create_session_file(&path)?; Ok((path, file)) } +// Another process may create the current log after our rotation. Never truncate its +// file: its writer and crash reporter may already hold the same inode open. +fn create_session_file(path: &Path) -> std::io::Result { + std::fs::OpenOptions::new().write(true).create_new(true).open(path) +} + /// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to /// a fresh `effectcraft.log` there, first the ones logged so far; returns its path. pub fn attach_session_log(dir: &Path) -> Result { + attach_session_log_with_header(dir, "") +} + +/// Open a session log with a bounded, unfiltered identity header before buffered records. +/// A crash reporter can then recognize this run even with logging disabled or verbose startup. +pub fn attach_session_log_with_header(dir: &Path, header: &str) -> Result { + if header.len() > 1024 { + return Err("session log header exceeds 1024 bytes".into()); + } let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner); - sink.attach(file); + sink.attach_with_header(file, header); sink.path = Some(path.clone()); Ok(path) } @@ -264,8 +285,14 @@ impl Sink { } /// Start writing to `file`, first the lines kept so far. + #[cfg(test)] fn attach(&mut self, file: File) { + self.attach_with_header(file, ""); + } + + fn attach_with_header(&mut self, file: File, header: &str) { self.file = Some(file); + self.append(header); for line in std::mem::take(&mut self.pending) { self.append(&line); } @@ -333,6 +360,22 @@ mod tests { use super::*; use std::sync::atomic::AtomicUsize; + #[test] + fn identity_header_precedes_buffered_records_independent_of_log_filter() { + let dir = temp_dir("identity-header"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write(&"earlier startup record".repeat(1000)); + sink.attach_with_header(File::create(&path).unwrap(), "crash-report session 1-2\n"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("crash-report session 1-2\n")); + assert!(text.contains("earlier startup record")); + assert!(attach_session_log_with_header(&dir, &"x".repeat(1025)).is_err()); + drop(sink); + std::fs::remove_dir_all(dir).unwrap(); + } + /// `RUST_LOG` picks what start-up prints to stderr (#234). #[test] fn rust_log_filters_by_level_and_target() { @@ -397,6 +440,20 @@ mod tests { assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z"); } + #[test] + fn concurrent_session_creation_does_not_truncate_the_winner() { + let dir = temp_dir("concurrent-create"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(SESSION_LOG); + let mut winner = create_session_file(&path).unwrap(); + winner.write_all(b"crash-report session 1-2\nwinner content\n").unwrap(); + assert_eq!(create_session_file(&path).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + winner.write_all(b"still the same session\n").unwrap(); + assert_eq!(read(&path), "crash-report session 1-2\nwinner content\nstill the same session\n"); + drop(winner); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Each launch keeps the previous runs' logs, so the log of a run that crashed survives. #[test] fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { @@ -472,4 +529,22 @@ mod tests { "2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full" ); } + + #[test] + fn line_breaks_in_a_message_continue_it_on_indented_lines() { + let forged = "cache at /tmp/x\n2026-10-08T07:59:18.000Z ERROR [main] effectcraft_engine: forged\rend"; + let line = format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "t", forged); + assert_eq!(line.lines().count(), 2); + assert!(line.lines().nth(1).unwrap().starts_with('\t'), "{line}"); + assert!(!line.contains('\r'), "{line}"); + } + + #[test] + fn a_trailing_crlf_is_one_line_break_not_a_lone_carriage_return() { + let line = |m| format_line("t", log::Level::Info, "main", "t", m); + assert_eq!(line("cache\r\n"), line("cache")); + assert_eq!(line("a\r\nb\r\n"), line("a\nb")); + // A lone one, even at the end, is still escaped. + assert!(line("cache\r").ends_with("cache\\r")); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 19953053e..df0853398 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,8 @@ mod add_item_options; mod aep_export; mod aep_import; +mod analysis_inputs; +mod analysis_signature; mod anim_presets; mod anim_tools; mod aux_footage; @@ -12,6 +14,7 @@ mod crypto_pick; mod delete_keys; mod disk_cache; mod display_pass; +mod effect_ranges; mod effects; mod essential; mod essential_more; @@ -27,8 +30,10 @@ mod key_metadata_order; mod key_time; mod keyframe_text; mod keylight; +mod legacy_analysis_keys; mod limits; mod lottie; +mod lut_files; mod m137; mod m145; mod markers; diff --git a/crates/engine/src/tests/aep_export.rs b/crates/engine/src/tests/aep_export.rs index 41c2ebfee..fc5ea29bc 100644 --- a/crates/engine/src/tests/aep_export.rs +++ b/crates/engine/src/tests/aep_export.rs @@ -1,4 +1,4 @@ -//! File ▸ Export ▸ After Effects Project… through the command registry. +//! File ▸ Export ▸ Adobe After Effects Project… through the command registry. use serde_json::json; @@ -73,11 +73,12 @@ fn bad_input_is_an_error_not_a_crash() { assert!(empty.execute_checked("file.exportAep", json!({"path": path})).is_ok()); } +/// File ▸ Export ▸ Adobe After Effects Project… is in the menu under that label. #[test] fn aep_export_menu_entry_exists() { let entries = crate::menus::entries(); let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone())); - assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "After Effects Project...".into()))); + assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "Adobe After Effects Project...".into()))); } #[test] diff --git a/crates/engine/src/tests/aep_import.rs b/crates/engine/src/tests/aep_import.rs index 8f09f1c0b..c62987ddf 100644 --- a/crates/engine/src/tests/aep_import.rs +++ b/crates/engine/src/tests/aep_import.rs @@ -1,4 +1,4 @@ -//! File ▸ Import ▸ After Effects Project… through the command registry, with the synthetic +//! File ▸ Import ▸ Adobe After Effects Project… through the command registry, with the synthetic //! fixtures of `effectcraft-aep`. use serde_json::json; @@ -147,11 +147,96 @@ fn bad_input_is_an_error_not_a_crash() { assert!(s.project.items.is_empty()); } +/// File ▸ Import: After Effects' own entries in its order, the project imports together where +/// After Effects has Adobe Premiere Pro Project… (alphabetical, named in full), then a separator +/// and the formats only EffectCraft imports, alphabetical (#3103). +#[test] +fn the_import_menu_groups_the_project_imports() { + use crate::menus::MenuNode; + let file = crate::menus::menu_bar().iter().find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "File" => Some(children), + _ => None, + }); + let import = file.into_iter().flatten().find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Import" => Some(children), + _ => None, + }); + let labels: Vec<&str> = import + .into_iter() + .flatten() + .map(|n| match n { + MenuNode::Item(e) => e.label.as_str(), + MenuNode::Separator => "---", + MenuNode::Submenu { label, .. } => label.as_str(), + MenuNode::Dynamic { .. } => "@dynamic", + }) + .collect(); + assert_eq!( + labels, + [ + "File...", + "Multiple Files...", + "Adobe After Effects Project...", + "Adobe Premiere Pro Project...", + "EffectCraft Project...", + "Vanishing Point (.vpe)...", + "Placeholder...", + "Solid...", + "---", + "Essential Graphics Template...", + "Lottie...", + ] + ); +} + +/// `file.import` takes After Effects projects as After Effects' File… does, for scripts and +/// agents: one without losses imports as `file.importAep` does; one with losses is refused and +/// changes nothing, naming the menu entry that lists the losses; a file that is not one says why. +#[test] +fn file_import_takes_after_effects_projects() { + assert!(crate::cmd::is_aep_path("/a/Intro.aep") && crate::cmd::is_aep_path("/a/Intro.AEPX")); + assert!(!crate::cmd::is_aep_path("/a/Intro.ecproj") && !crate::cmd::is_aep_path("/a/aep") && !crate::cmd::is_aep_path("/a/clip.aep.mp4")); + let dir = std::env::temp_dir().join(format!("ec-file-import-aep-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let message = |r: crate::Result| match r { + Ok(v) => v["errors"].to_string(), + Err(e) => e.to_string(), + }; + + let intro = dir.join("Intro.aep").to_string_lossy().to_string(); + let mut source = 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 mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [intro]})).unwrap(); + assert_eq!(r["projects"].as_array().map(Vec::len), Some(1), "{r}"); + let folder = s.project.items.values().find(|i| i.name == "Intro.aep").map(|i| i.id); + assert!(folder.is_some(), "the project's folder"); + assert!(s.project.items.values().any(|i| i.name == "Intro" && i.parent == folder), "its composition in the folder"); + + let lossy = concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"); + let mut s = Session::default(); + let refused = message(s.execute("file.import", json!({"paths": [lossy]}))); + assert!(refused.contains("synthetic.aep") && refused.contains("File ▸ Import ▸ Adobe After Effects Project… lists them"), "{refused}"); + assert!(refused.contains("`file.importAep` with `allowLosses`"), "and the command that takes the consent: {refused}"); + assert!(s.project.items.is_empty(), "nothing imported"); + + let junk = dir.join("Junk.AEPX"); + std::fs::write(&junk, b"not a project").unwrap(); + let mut s = Session::default(); + let error = message(s.execute("file.import", json!({"paths": [junk.to_string_lossy()]}))); + assert!(error.contains("Junk.AEPX"), "{error}"); + assert!(s.project.items.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Import ▸ Adobe After Effects Project… is in the menu under that label. #[test] fn aep_menu_entry_exists() { let entries = crate::menus::entries(); let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone())); - assert_eq!(find("file.importAep"), Some(("File > Import".into(), "After Effects Project...".into()))); + assert_eq!(find("file.importAep"), Some(("File > Import".into(), "Adobe After Effects Project...".into()))); } #[test] diff --git a/crates/engine/src/tests/analysis_inputs.rs b/crates/engine/src/tests/analysis_inputs.rs new file mode 100644 index 000000000..b45b38eda --- /dev/null +++ b/crates/engine/src/tests/analysis_inputs.rs @@ -0,0 +1,211 @@ +//! Warp Stabilizer and 3D Camera Tracker analyses follow their input: the layer's frames, its +//! masks, the effects above them and a precomp's contents (#3535). Edits that leave the input +//! as it was (effects below, names, labels) keep them. + +use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY}; +use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY}; +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemId, LayerId, Project}; +use serde_json::{Value as Json, json}; + +use crate::Session; + +const COMP: ItemId = ItemId(2); +const CLIP: LayerId = LayerId(3); +const WARP: u64 = 26; +const TRACKER: u64 = 54; + +/// `analysed-0.24.ecproj`: the clip of `tracked-0.22.ecproj` with a mask and, in order, a Warp Stabilizer, a 3D Camera +/// Tracker, a Gaussian Blur and a Roto Brush. +const ANALYSED: &str = include_str!("fixtures/analysed-0.24.ecproj"); + +fn open(json: &str) -> Session { + let mut s = Session::default(); + s.replace_project(Project::from_json(json).unwrap(), None); + s.open_comp(COMP); + s +} + +/// Which keys an analysis saves. +#[derive(Clone, Copy, Debug)] +enum Saved { + /// Today's: the input to the instance. + Input, + /// Before #3535: the layer's frames only (still recognised). + Frames, +} + +/// The key an analysis of instance `uid` (no Detailed Analysis) saves. +fn key(p: &Project, uid: u64, saved: Saved) -> String { + let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap(); + match saved { + Saved::Input => crate::analysis::frames_key::analysis_key(p, COMP, layer, uid), + Saved::Frames => crate::analysis::frames_key::signature(p, COMP, layer), + } +} + +/// Give the Warp Stabilizer and the tracker an analysis of the clip as it is now, saved with +/// `saved` keys, and reopen the project. +fn analyse(s: &mut Session, saved: Saved) { + let mut p = (*s.project).clone(); + let set = |p: &mut Project, uid: u64, data: &str, id: &str, key: &str| { + let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(uid).unwrap(); + g.prop_mut(data).unwrap().value = Value::Str(format!("saved {data}")); + g.prop_mut(id).unwrap().value = Value::Str(key.into()); + }; + // The Warp Stabilizer is above the tracker: its analysis is part of the tracker's input. + let k = key(&p, WARP, saved); + set(&mut p, WARP, ANALYSIS, ANALYSIS_KEY, &k); + let k = key(&p, TRACKER, saved); + set(&mut p, TRACKER, TRACKS, TRACKS_KEY, &k); + *s = open(&p.to_json()); + run(s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})); + assert_eq!(analysed(s), [true, true], "{saved:?}"); +} + +/// Whether the Warp Stabilizer and the tracker still have their analysis. +fn analysed(s: &Session) -> [bool; 2] { + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let has = |uid: u64, id: &str| matches!(&layer.props.find_group(uid).unwrap().prop(id).unwrap().value, Value::Str(s) if !s.is_empty()); + [has(WARP, ANALYSIS), has(TRACKER, TRACKS)] +} + +fn run(s: &mut Session, cmd: &str, params: Json) -> Json { + s.execute_checked(cmd, params).unwrap() +} + +fn undo(s: &mut Session) { + run(s, "edit.undo", json!({})); +} + +/// An edit, its effect on the two analyses, and that undo brings them back. +fn check(s: &mut Session, cmd: &str, params: Json, kept: [bool; 2]) { + assert_eq!(analysed(s), [true, true], "before {cmd} {params}"); + run(s, cmd, params.clone()); + assert_eq!(analysed(s), kept, "{cmd} {params}"); + undo(s); + assert_eq!(analysed(s), [true, true], "undo of {cmd} {params}"); +} + +#[test] +fn masks_and_effects_above_are_part_of_the_input() { + for saved in [Saved::Input, Saved::Frames] { + masks_and_effects_above(saved); + } +} + +fn masks_and_effects_above(saved: Saved) { + let mut s = open(ANALYSED); + analyse(&mut s, saved); + // The layer's mask is under every effect. + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]); + // The Warp Stabilizer is above the tracker: its settings change the tracker's input only. + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/stabilization/smoothness", "value": 20}), [true, false]); + // The Gaussian Blur and the Roto Brush are below both. + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4}), [true, true]); + run(&mut s, "layer.rename", json!({"layer": CLIP.0, "name": "Renamed"})); + assert_eq!(analysed(&s), [true, true]); + // The Effects switch turns the Warp Stabilizer (above the tracker) off. + check(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false}), [true, false]); + // While it is off, the effects above draw nothing: editing them keeps the analyses. + run(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false})); + analyse(&mut s, saved); + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/stabilization/smoothness", "value": 20}), [true, true]); + run(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": true})); + assert_eq!(analysed(&s), [true, false]); + analyse(&mut s, saved); + + // An effect moved above the Warp Stabilizer is part of both inputs, and its settings too. + s.edit("blur first", None, |p, _| { + let fx = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.sub_mut("effects").unwrap(); + let blur = fx.children.remove(2); + fx.children.insert(0, blur); + Ok(()) + }) + .unwrap(); + assert_eq!(analysed(&s), [false, false]); + analyse(&mut s, saved); + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/blurriness", "value": 4}), [false, false]); + + // Saved and reopened, they still match, and a mask edit still clears them. + let mut s = open(&s.project.to_json()); + run(&mut s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})); + assert_eq!(analysed(&s), [true, true]); + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]); +} +#[test] +fn a_precomps_contents_are_part_of_the_input() { + for saved in [Saved::Input, Saved::Frames] { + precomp_contents(saved); + } +} + +fn precomp_contents(saved: Saved) { + let mut s = open(ANALYSED); + // Pre-compose the clip, leaving its masks and effects on the precomp layer. + let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"})); + let inner = ItemId(r["comp"].as_u64().unwrap()); + let inner_layer = |s: &Session, comp: ItemId| s.project.comp(comp).unwrap().layers[0].id.0; + let clip = inner_layer(&s, inner); + analyse(&mut s, saved); + // Edits inside the precomp change the frames. + check(&mut s, "prop.set", json!({"comp": inner.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]); + check(&mut s, "effect.apply", json!({"comp": inner.0, "layers": [clip], "effect": "ec.blur.gaussian"}), [false, false]); + check(&mut s, "layer.timing", json!({"comp": inner.0, "layers": [clip], "out": 0.25}), [false, false]); + // A label or comment inside it doesn't. A name does: expressions find layers by name. + s.edit("label", None, |p, _| { + let l = p.comp_mut(inner).unwrap().layer_mut(LayerId(clip)).unwrap(); + l.label = effectcraft_color::Label::Pink; + l.comment = "a note".into(); + Ok(()) + }) + .unwrap(); + assert_eq!(analysed(&s), [true, true]); + check(&mut s, "layer.rename", json!({"comp": inner.0, "layer": clip, "name": "Inner"}), [false, false]); + + // A precomp inside the precomp: its contents too. + let r = run(&mut s, "layer.precompose", json!({"comp": inner.0, "layers": [clip], "mode": "leave"})); + let deeper = ItemId(r["comp"].as_u64().unwrap()); + assert_eq!(analysed(&s), [false, false], "pre-composing inside the precomp changes its contents"); + analyse(&mut s, saved); + let clip = inner_layer(&s, deeper); + check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]); + + // Saved and reopened, they still match, and an edit inside still clears them. + let mut s = open(&s.project.to_json()); + run(&mut s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})); + assert_eq!(analysed(&s), [true, true]); + check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]); +} + +/// Precomps used many times, nested deeply or (in a broken project) in a cycle are keyed in +/// bounded time, and an edit deep inside still changes the key. +#[test] +fn shared_deep_and_cyclic_precomps_are_keyed_in_bounded_time() { + use effectcraft_color::Label; + use effectcraft_project::{ItemKind, LayerSource}; + let mut s = open(ANALYSED); + let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"})); + let inner = ItemId(r["comp"].as_u64().unwrap()); + let mut p = (*s.project).clone(); + let template = p.comp(inner).unwrap().clone(); + // 40 comps, each drawing the next one twice (2^40 paths if each were walked), the last + // drawing the first. + let ids: Vec = (0..40).map(|i| p.add_item(&format!("Nest {i}"), Label::default(), None, ItemKind::Comp(template.clone().into()))).collect(); + for (i, id) in ids.iter().enumerate() { + let next = ids[(i + 1) % ids.len()]; + let mut layer = template.layers[0].clone(); + layer.source = LayerSource::Comp { item: next }; + let c = p.comp_mut(*id).unwrap(); + c.layers.push(layer.clone()); + layer.id = LayerId(layer.id.0 + 1000); + c.layers.push(layer); + } + p.comp_mut(inner).unwrap().layers[0].source = LayerSource::Comp { item: ids[0] }; + let key = |p: &Project| crate::analysis::frames_key::analysis_key(p, COMP, p.comp(COMP).unwrap().layer(CLIP).unwrap(), WARP); + let start = std::time::Instant::now(); + let before = key(&p); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); + p.comp_mut(ids[10]).unwrap().background = [1.0, 0.0, 0.0]; + assert_ne!(key(&p), before); +} diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs new file mode 100644 index 000000000..045e279de --- /dev/null +++ b/crates/engine/src/tests/analysis_signature.rs @@ -0,0 +1,315 @@ +//! Warp Stabilizer, 3D Camera Tracker and Roto Brush keys saved by 0.24 keep matching their +//! layer's frames after edits that don't change them, and a real change still clears them. + +use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY}; +use effectcraft_effects::roto::{FROZEN, INPUT_KEY}; +use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY}; +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemId, LayerId, Project}; +use serde_json::json; + +use crate::Session; + +const COMP: ItemId = ItemId(2); +const CLIP: LayerId = LayerId(3); +const WARP: u64 = 26; +const TRACKER: u64 = 54; +const ROTO: u64 = 86; + +/// Saved by 0.24.1 (before #3534/#3536): the clip of `tracked-0.22.ecproj` with Time Remap, a +/// mask, a Gaussian Blur and a Roto Brush, its analyses replaced by short stand-ins. The keys +/// are the ones that version computed for it. +const ANALYSED_0_24: &str = include_str!("fixtures/analysed-0.24.ecproj"); +const SIGNATURE_0_24: &str = "03efb7c4b0f27b65"; +const ROTO_INPUT_0_24: &str = "7b319ce93027617c"; + +fn open(json: &str) -> Session { + let mut s = Session::default(); + s.replace_project(Project::from_json(json).unwrap(), None); + s.open_comp(COMP); + s +} + +/// The (data, key) of the Warp Stabilizer, the 3D Camera Tracker and the Roto Brush. +fn saved(s: &Session) -> [(String, String); 3] { + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let get = |uid: u64, data: &str, key: &str| { + let params = crate::camera_track::static_params(layer.props.find_group(uid).unwrap()); + let text = |id: &str| match params.get(id) { + Some(Value::Str(s)) => s.clone(), + other => panic!("{id}: {other:?}"), + }; + (text(data), text(key)) + }; + [get(WARP, ANALYSIS, ANALYSIS_KEY), get(TRACKER, TRACKS, TRACKS_KEY), get(ROTO, FROZEN, INPUT_KEY)] +} + +fn add_to_render_queue(s: &mut Session) { + s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap(); +} + +#[test] +fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() { + let mut s = open(ANALYSED_0_24); + let before = saved(&s); + assert_eq!(before[0], ("saved analysis".into(), SIGNATURE_0_24.into())); + assert_eq!(before[1], ("saved tracks".into(), SIGNATURE_0_24.into())); + assert_eq!(before[2], ("saved frozen".into(), ROTO_INPUT_0_24.into())); + + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + + // Saved and reopened, the keys still match. + let mut s = open(&s.project.to_json()); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + + // Other frames: all three are cleared (the Roto Brush gets a new key), and undo brings them back. + s.execute_checked("layer.timing", json!({"layer": CLIP.0, "out": 0.25})).unwrap(); + let after = saved(&s); + assert_eq!(after[0], (String::new(), String::new())); + assert_eq!(after[1], (String::new(), String::new())); + assert_eq!(after[2].0, ""); + assert_ne!(after[2].1, ROTO_INPUT_0_24); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + + // Time Remap is part of the frames. + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "timeRemap", "value": 0.3})).unwrap(); + assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); + s.execute_checked("edit.undo", json!({})).unwrap(); + + // The Roto Brush's input also has the layer's masks and the effects above it. + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4})).unwrap(); + let after = saved(&s); + assert_eq!(after[2].0, ""); + assert_ne!(after[2].1, ROTO_INPUT_0_24); + s.execute_checked("edit.undo", json!({})).unwrap(); + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3})).unwrap(); + assert_eq!(saved(&s)[2].0, ""); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); +} + +/// The text keys are made from today, written field by field (#3536). It changes only when this +/// test is changed with it: a new field of a type it writes doesn't compile until it is decided +/// in `analysis::frames_key`, and renaming a field or variant doesn't change it. +#[test] +fn signature_text_is_pinned() { + let s = open(ANALYSED_0_24); + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";name="Time Remap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#; + assert_eq!(crate::analysis::frames_key::signature_text(&s.project, COMP, layer), key); +} + +/// Keys made today (after analysing again) match through edits, undo and save/reopen too, and +/// renaming or relabelling never drops them. +#[test] +fn todays_keys_survive_edits_that_keep_their_frames() { + let mut s = open(ANALYSED_0_24); + // Analysing again writes today's keys. + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let signature = crate::analysis::frames_key::signature(&s.project, COMP, layer); + let input = crate::roto::input_key(&s.project, COMP, layer, ROTO); + assert_ne!(signature, SIGNATURE_0_24); + s.edit("analyse", None, |p, _| { + let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); + for (uid, id, key) in [(WARP, ANALYSIS_KEY, &signature), (TRACKER, TRACKS_KEY, &signature), (ROTO, INPUT_KEY, &input)] { + l.props.find_group_mut(uid).unwrap().get_mut(id).unwrap().value = Value::Str(key.clone()); + } + Ok(()) + }) + .unwrap(); + // The Roto Brush's key covers the effects above it: the Warp Stabilizer's new key changed it. + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let input = crate::roto::input_key(&s.project, COMP, layer, ROTO); + s.edit("roto key", None, |p, _| { + let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(ROTO).unwrap(); + g.get_mut(INPUT_KEY).unwrap().value = Value::Str(input.clone()); + g.get_mut(FROZEN).unwrap().value = Value::Str("saved frozen".into()); + Ok(()) + }) + .unwrap(); + let before = saved(&s); + assert_eq!(before[0].1, signature); + assert_eq!(before[2], ("saved frozen".into(), input)); + + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + let mut s = open(&s.project.to_json()); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + // Offline and back online (#3534). + for missing in [true, false] { + if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = missing; + } + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + } + // Colour labels and footage markers only show things. + s.edit("label", None, |p, _| { + let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); + l.props.find_group_mut(82).unwrap().label = Some(effectcraft_color::Label::Yellow); + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.markers.push(effectcraft_project::Marker::default()); + } + Ok(()) + }) + .unwrap(); + assert_eq!(saved(&s), before); + // Expressions find effects by name: renaming the effect above the Roto Brush is a change for it. + s.edit("rename", None, |p, _| { + p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(82).unwrap().name = "Soft".into(); + Ok(()) + }) + .unwrap(); + assert_eq!(saved(&s)[..2], before[..2]); + assert_eq!(saved(&s)[2].0, ""); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + + // A real change still clears them. + s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap(); + assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); +} + +/// Footage going offline (or coming back) is an observation, not an edit: the next edit keeps +/// every analysis (#3534). +#[test] +fn offline_footage_keeps_analyses() { + let mut s = open(ANALYSED_0_24); + let before = saved(&s); + let missing = |s: &Session| matches!(&s.project.item(ItemId(1)).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.missing); + // The fixture's footage path doesn't exist here: the check marks it missing. + let r = s.execute_checked("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"], 1, "{r}"); + assert!(missing(&s)); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + + // Saved and reopened while offline, then back online. + let mut s = open(&s.project.to_json()); + assert!(missing(&s)); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = false; + } + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + + // A real change while offline still clears them. + s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap(); + assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); +} + +/// A key 0.24.1 saved while the footage was offline (it hashed the availability) matches too, +/// online or offline. 0.24.1 gave the Roto Brush this Input Key when the fixture's footage went +/// offline and an edit cleared the other two analyses. +#[test] +fn keys_saved_by_0_24_while_offline_still_match() { + const ROTO_INPUT_0_24_OFFLINE: &str = "6183fdd46b514259"; + let mut s = open(ANALYSED_0_24); + s.edit("offline", None, |p, _| { + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = true; + } + let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); + // What 0.24.1's edit cleared then. + use effectcraft_effects::camera_tracker::{AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY}; + for (uid, id) in + [(WARP, ANALYSIS), (WARP, ANALYSIS_KEY), (TRACKER, SOLVE), (TRACKER, SOLVE_KEY), (TRACKER, METHOD_USED), (TRACKER, TRACKS), (TRACKER, TRACKS_KEY)] + { + l.props.find_group_mut(uid).unwrap().prop_mut(id).unwrap().value = Value::Str(String::new()); + } + l.props.find_group_mut(TRACKER).unwrap().prop_mut(AVERAGE_ERROR).unwrap().value = Value::Scalar(0.0); + let g = l.props.find_group_mut(ROTO).unwrap(); + g.get_mut(INPUT_KEY).unwrap().value = Value::Str(ROTO_INPUT_0_24_OFFLINE.into()); + Ok(()) + }) + .unwrap(); + let roto = |s: &Session| saved(s)[2].clone(); + assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into())); + for missing in [true, false] { + if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = missing; + } + add_to_render_queue(&mut s); + assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into())); + } +} + +/// Expressions seed `random` and `wiggle` with the property's uid: a Time Remap with an expression +/// keys its uid (a duplicated layer gets new uids), one without doesn't. +#[test] +fn an_expression_keys_its_property_uid() { + let s = open(ANALYSED_0_24); + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap().clone(); + let text = |l: &effectcraft_project::Layer| crate::analysis::frames_key::signature_text(&s.project, COMP, l); + let renumbered = |l: &effectcraft_project::Layer| { + let mut l = l.clone(); + l.props.get_mut("timeRemap").unwrap().uid += 1000; + l + }; + assert_eq!(text(&renumbered(&layer)), text(&layer)); + let mut with_expr = layer.clone(); + with_expr.props.get_mut("timeRemap").unwrap().expr = + Some(effectcraft_project::Expression { text: "seedRandom(0, true); random(0, 1)".into(), enabled: true }); + assert_ne!(text(&renumbered(&with_expr)), text(&with_expr)); +} + +/// Non-default values of the types the key writes are written as their fixed words (#3536). +#[test] +fn nested_values_are_written_as_fixed_words() { + use effectcraft_project::{ + AlphaMode, CineonUnits, FieldOrder, FootageKind, GroupKind, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Solid, SourceLayer, + }; + let mut s = open(ANALYSED_0_24); + let p = std::sync::Arc::make_mut(&mut s.project); + if let Some(ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.kind = FootageKind::Sequence; + f.sequence = vec!["a_0001.png".into(), "a_0002.png".into()]; + f.alpha = AlphaMode::Premultiplied; + f.premul_color = [0.5, 0.25, 1.0]; + f.fields = FieldOrder::UpperFirst; + f.cineon.units = CineonUnits::Float; + f.cineon.log_conversion = true; + f.color_profile = Some(effectcraft_color::ColorSpace::DisplayP3); + f.layer = Some(SourceLayer { index: 3, name: "Sky".into(), layer_size: true, embedded: Some("id-7".into()), placed: true }); + f.native_rate = Some(effectcraft_time::FrameRate { num: 30000, den: 1001 }); + f.start_timecode = Some(42); + f.data = Some("{\"k\":1}".into()); + } + let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap().clone(); + let text = crate::analysis::frames_key::signature_text(p, COMP, &layer); + let footage = text.split(";in=").next().unwrap(); + assert_eq!( + footage, + r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=sequence;w=160;h=120;par=1.0;rate=12/1;nativeRate{rate=30000/1001;};duration=254016000000;video=true;audio=false;alpha=premultiplied;matte=[0.5, 0.25, 1.0];loop=1;codec="H.264 (High)";frames=2;file="a_0001.png";file="a_0002.png";profile{p3;};layer{index=3;layerSize=true;embedded{id="id-7";};placed=true;};fields=upper;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=true;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=float;data{text="{\"k\":1}";};page=0;alphabetical=false;startTimecode{frame=42;};fileTimecode=none;placedPage=false"# + ); + + let solid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 64, height: 48, pixel_aspect: 2.0 })); + let mut on_solid = layer.clone(); + on_solid.source = LayerSource::Solid { item: solid }; + let text = crate::analysis::frames_key::signature_text(p, COMP, &on_solid); + assert_eq!(text.split(";in=").next().unwrap(), format!("frames/1;solid={};solid;color=[1.0, 0.5, 0.0];w=64;h=48;par=2.0", solid.0)); + + // Every mask mode and motion blur setting gives its own Input Key. + let mask = layer.masks().unwrap().groups().next().unwrap().uid; + let mut keys = std::collections::BTreeSet::new(); + for mode in MaskMode::ALL { + for blur in [MaskMotionBlur::SameAsLayer, MaskMotionBlur::On, MaskMotionBlur::Off] { + let mut l = layer.clone(); + if let GroupKind::Mask { mode: m, motion_blur, .. } = &mut l.props.find_group_mut(mask).unwrap().kind { + (*m, *motion_blur) = (mode, blur); + } + keys.insert(crate::analysis::frames_key::input_key(p, COMP, &l, ROTO)); + } + } + assert_eq!(keys.len(), MaskMode::ALL.len() * 3); +} diff --git a/crates/engine/src/tests/camera_track.rs b/crates/engine/src/tests/camera_track.rs index 3ca8578a9..77829e7f9 100644 --- a/crates/engine/src/tests/camera_track.rs +++ b/crates/engine/src/tests/camera_track.rs @@ -252,6 +252,15 @@ fn invalidation_resolve_cancel_and_undo() { assert_eq!(status(&mut s)["analyzed"], false); assert!(s.redo()); assert_eq!(status(&mut s)["solved"], true); + // A mask is part of the tracked input (#3535): drawing one clears the tracks, undo keeps them. + s.execute_checked("mask.new", json!({"layer": clip.0, "vertices": [[10, 10], [200, 10], [200, 150]]})).unwrap(); + assert_eq!(status(&mut s)["analyzed"], false); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(status(&mut s)["solved"], true); + // An effect below the tracker isn't. + s.execute_checked("effect.apply", json!({"layers": [clip.0], "effect": "ec.blur.gaussian"})).unwrap(); + assert_eq!(status(&mut s)["solved"], true); + s.execute_checked("edit.undo", json!({})).unwrap(); // Changing the Solve Method keeps the tracks and only re-solves. s.execute_checked("prop.set", json!({"layer": clip.0, "path": "effects/#1/advanced/solveMethod", "value": 1})).unwrap(); diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs new file mode 100644 index 000000000..7deaaa911 --- /dev/null +++ b/crates/engine/src/tests/effect_ranges.rs @@ -0,0 +1,170 @@ +//! Effect parameters take the whole valid range After Effects records for them (fx_defs in the +//! aep crate), end to end: `prop.set` keeps both ends, undo and redo restore them, and a saved +//! project reopens with them. The aep crate's +//! `effect_hard_ranges_cover_recorded_after_effects_ranges` checks the ranges themselves +//! against that table. + +use effectcraft_project::{LayerId, Node, ParamUi, PropGroup}; +use serde_json::{Value, json}; + +use crate::Session; + +/// (issue, effect, parameter path, lowest, highest): the ends of the range the effect now accepts, +/// in EffectCraft's units: After Effects' valid range, or wider where EffectCraft already was. +/// At least one end of each is outside the range accepted before the issue. +#[rustfmt::skip] +const ROWS: &[(u32, &str, &str, f64, f64)] = &[ + (3487, "ec.color.levelsic", "rgb/rgbInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "rgb/rgbInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "rgb/rgbOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "rgb/rgbOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "rgb/rgbGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "red/redInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "red/redInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "red/redOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "red/redOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "red/redGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "green/greenInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "green/greenInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "green/greenOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "green/greenOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "green/greenGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "blue/blueInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "blue/blueInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "blue/blueOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "blue/blueOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "blue/blueGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "alpha/alphaInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "alpha/alphaInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "alpha/alphaOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "alpha/alphaOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "alpha/alphaGamma", 0.0, 10.0), + (3478, "ec.generate.fourcolor", "blend", 1.0, 10000.0), + (3479, "ec.generate.fourcolor", "jitter", 0.0, 500.0), + (3483, "ec.noise.noisehls", "hue", 0.0, 1000.0), + (3483, "ec.noise.noisehls", "lightness", 0.0, 1000.0), + (3483, "ec.noise.noisehls", "saturation", 0.0, 1000.0), + (3484, "ec.noise.noisehls", "grainSize", 0.0, 100.0), + (3483, "ec.noise.noisehlsauto", "hue", 0.0, 1000.0), + (3483, "ec.noise.noisehlsauto", "lightness", 0.0, 1000.0), + (3483, "ec.noise.noisehlsauto", "saturation", 0.0, 1000.0), + (3484, "ec.noise.noisehlsauto", "grainSize", 0.0, 100.0), + (3480, "ec.color.changecolor", "hueTransform", -1800.0, 1800.0), + (3476, "ec.generate.grid", "border", 0.0, 2000.0), + (3472, "ec.transition.venetian", "width", 1.0, 32000.0), + (3473, "ec.transition.venetian", "feather", 0.0, 32000.0), + (3474, "ec.transition.radialwipe", "feather", 0.0, 32000.0), + (3477, "ec.stylize.scatter", "amount", 0.0, 1000.0), + (3475, "ec.generate.beam", "startThickness", 0.0, 4000.0), + (3475, "ec.generate.beam", "endThickness", 0.0, 4000.0), + (3471, "ec.distort.transform", "scaleHeight", -30000.0, 30000.0), + (3471, "ec.distort.transform", "scaleWidth", -30000.0, 30000.0), + (3470, "ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 0.0, 100000.0), +]; + +fn path(effect: &str, param: &str) -> String { + format!("effects/{effect}/{param}") +} + +fn read(s: &mut Session, layer: u64, effect: &str, param: &str) -> f64 { + let got = s.execute("prop.get", json!({"layer": layer, "path": path(effect, param)})).unwrap(); + got["value"].as_f64().unwrap_or_else(|| panic!("{effect} {param}: {got}")) +} + +fn set(s: &mut Session, layer: u64, effect: &str, param: &str, v: f64) -> Value { + s.execute("prop.set", json!({"layer": layer, "path": path(effect, param), "value": v})).unwrap() +} + +/// A composition with two solids, each with one instance of every effect in [`ROWS`]. +fn session() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap(); + let mut layers = [0; 2]; + for l in &mut layers { + *l = s.execute("layer.newSolid", json!({"color": "#808080", "width": 64, "height": 36})).unwrap()["layer"].as_u64().unwrap(); + let mut effects: Vec<&str> = ROWS.iter().map(|r| r.1).collect(); + effects.dedup(); + for e in effects { + s.execute("effect.apply", json!({"layers": [*l], "effect": e})).unwrap(); + } + } + (s, layers[0], layers[1]) +} + +/// Both ends of every row survive `prop.set`, undo / redo, and a save and reopen. +#[test] +fn effect_parameters_keep_both_ends_of_the_after_effects_range() { + let (mut s, low, high) = session(); + for &(issue, e, p, lo, hi) in ROWS { + assert_eq!(set(&mut s, high, e, p, lo), json!(lo), "#{issue} {e} {p}: prop.set reply"); + assert_eq!(read(&mut s, high, e, p), lo, "#{issue} {e} {p}"); + set(&mut s, high, e, p, hi); + assert_eq!(read(&mut s, high, e, p), hi, "#{issue} {e} {p}"); + assert!(s.undo()); + assert_eq!(read(&mut s, high, e, p), lo, "#{issue} {e} {p}: undo"); + assert!(s.redo()); + assert_eq!(read(&mut s, high, e, p), hi, "#{issue} {e} {p}: redo"); + set(&mut s, low, e, p, lo); + } + let dir = std::env::temp_dir().join(format!("ec-effect-ranges-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ranges.ecproj").to_string_lossy().to_string(); + s.execute("file.save", json!({"path": file})).unwrap(); + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": file})).unwrap(); + let _ = std::fs::remove_dir_all(dir); + for &(issue, e, p, lo, hi) in ROWS { + assert_eq!(read(&mut s2, low, e, p), lo, "#{issue} {e} {p}: reopened"); + assert_eq!(read(&mut s2, high, e, p), hi, "#{issue} {e} {p}: reopened"); + } +} + +/// A project saved while these ranges were narrower stored that range with each instance +/// (`ParamUi`) and values clamped to it: opening it takes the effect's current range and keeps +/// the stored values, so the new ends can be set. +#[test] +fn projects_saved_with_the_narrower_ranges_open_with_the_new_ones() { + let (mut s, low, _) = session(); + let cid = s.active_comp_id().unwrap(); + let mut old = (*s.project).clone(); + let stored: Vec = ROWS.iter().map(|&(_, e, p, ..)| read(&mut s, low, e, p)).collect(); + { + let effectcraft_project::ItemKind::Comp(comp) = &mut old.items.get_mut(&cid).unwrap().kind else { panic!("not a composition") }; + let l = std::sync::Arc::make_mut(comp).layers.find_mut(|l| l.id == LayerId(low)).unwrap(); + for (&(_, e, p, ..), v) in ROWS.iter().zip(&stored) { + let g = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n { + Node::Group(g) if g.match_id == e => Some(g), + _ => None, + }); + let pr = prop_mut(g.unwrap(), p).unwrap_or_else(|| panic!("{e} {p}")); + // The narrowest stored range that still holds the stored value. + pr.ui = ParamUi::Slider { min: *v, max: *v, slider_min: *v, slider_max: *v, decimals: 1 }; + } + } + let dir = std::env::temp_dir().join(format!("ec-effect-ranges-old-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("old.ecproj").to_string_lossy().to_string(); + std::fs::write(&file, old.to_json()).unwrap(); + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": file})).unwrap(); + let _ = std::fs::remove_dir_all(dir); + for (&(issue, e, p, lo, hi), v) in ROWS.iter().zip(&stored) { + assert_eq!(read(&mut s2, low, e, p), *v, "#{issue} {e} {p}: the stored value is kept"); + let spec = effectcraft_effects::find(e).unwrap().params.iter().find(|q| q.id == p).unwrap(); + let uid = s2.execute("prop.get", json!({"layer": low, "path": path(e, p)})).unwrap()["uid"].as_u64().unwrap(); + let layer = s2.project.comp(cid).unwrap().layer(LayerId(low)).unwrap(); + assert_eq!(layer.props.find(uid).unwrap().ui, spec.ui, "#{issue} {e} {p}: the range is refreshed"); + set(&mut s2, low, e, p, lo); + assert_eq!(read(&mut s2, low, e, p), lo, "#{issue} {e} {p}"); + set(&mut s2, low, e, p, hi); + assert_eq!(read(&mut s2, low, e, p), hi, "#{issue} {e} {p}"); + } +} + +/// The property at `path` (`group/param`) in an effect instance. +fn prop_mut<'a>(g: &'a mut PropGroup, path: &str) -> Option<&'a mut effectcraft_project::Property> { + match path.split_once('/') { + None => g.get_mut(path), + Some((first, rest)) => prop_mut(g.sub_mut(first)?, rest), + } +} diff --git a/crates/engine/src/tests/export_overwrite.rs b/crates/engine/src/tests/export_overwrite.rs index 5cc65b1d5..507a9be4b 100644 --- a/crates/engine/src/tests/export_overwrite.rs +++ b/crates/engine/src/tests/export_overwrite.rs @@ -331,3 +331,17 @@ fn background_pre_render_movies_are_checked_again_before_publication() { assert!(listing(&f.dir).iter().all(|name| !name.starts_with(".effectcraft-prerender-"))); } } + +/// Footage files sharing a name are numbered from that name (`3_a.mov`, not `3_2_a.mov`). +#[test] +fn collect_files_numbers_repeated_names_from_the_name() { + let mut f = collecting("collect-repeated", "a.mov"); + for d in ["in2", "in3"] { + std::fs::create_dir_all(f.path(d)).unwrap(); + f.footage(&format!("{d}/a.mov")); + } + collect(&mut f).unwrap(); + let mut names = listing(&f.path("out/(Footage)")); + names.sort(); + assert_eq!(names, ["2_a.mov", "3_a.mov", "a.mov"]); +} diff --git a/crates/engine/src/tests/fixtures/analysed-0.24.ecproj b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj new file mode 100644 index 000000000..e99b4acc0 --- /dev/null +++ b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj @@ -0,0 +1 @@ +{"schema":2,"settings":{"bit_depth":"Bpc8","linearize":false,"working_space":null,"blend_linear":false,"time_display":"Timecode","frame_start":0,"audio_sample_rate":48000,"gpu_acceleration":true,"color_engine":"Adobe","hdr":"Clip","output_space":null,"working_gamma":2.2},"items":{"1":{"id":1,"name":"effectcraft-3456-shake.mp4","label":"Aqua","comment":"","parent":null,"kind":{"type":"Footage","path":"/tmp/effectcraft-3456-shake.mp4","kind":"Video","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"native_rate":null,"duration":254016000000,"has_video":true,"has_audio":false,"alpha":"Ignore","premul_color":[0.0,0.0,0.0],"loop_count":1,"codec":"H.264 (High)","missing":false,"color_profile":"Rec709"}},"2":{"id":2,"name":"effectcraft-3456-shake","label":"Sandstone","comment":"","parent":null,"kind":{"type":"Comp","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"duration":254016000000,"display_start":0,"background":[0.0,0.0,0.0],"work_area":[0,254016000000],"layers":[{"id":3,"name":"effectcraft-3456-shake.mp4","source":{"type":"Footage","item":1},"label":"Aqua","comment":"","start_time":0,"in_point":0,"out_point":254016000000,"stretch":100.0,"switches":{"video":true,"audio":true,"solo":false,"locked":false,"shy":false,"collapse":false,"quality":"Best","sampling":"Bilinear","effects":true,"frame_blend":"Off","motion_blur":false,"adjustment":false,"three_d":false,"guide":false},"blend_mode":"Normal","preserve_transparency":false,"track_matte":null,"parent":null,"markers":[],"auto_orient":"Off","props":{"uid":4,"match":"layer","name":"effectcraft-3456-shake.mp4","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":76,"match":"timeRemap","name":"Time Remap","value":{"t":"Scalar","v":0.0},"keys":[{"time":0,"value":{"t":"Scalar","v":0.2},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false},{"time":254016000000,"value":{"t":"Scalar","v":1.0},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false}],"ui":{"kind":"Number"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":5,"match":"masks","name":"Masks","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":77,"match":"mask","name":"Mask 1","kind":{"kind":"Mask","mode":"Add","inverted":false,"color":[177,180,79],"locked":false,"motion_blur":"SameAsLayer","feather_falloff":"Smooth","roto_bezier":false},"enabled":true,"children":[{"node":"Prop","uid":78,"match":"path","name":"Mask Path","value":{"t":"Path","v":{"vertices":[[10.0,10.0],[120.0,10.0],[120.0,100.0],[10.0,100.0]],"in_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"out_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"closed":false}},"ui":{"kind":"Path"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":79,"match":"feather","name":"Mask Feather","value":{"t":"Vec2","v":[0.0,0.0]},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":80,"match":"opacity","name":"Mask Opacity","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":81,"match":"expansion","name":"Mask Expansion","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Group","uid":6,"match":"effects","name":"Effects","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":26,"match":"ec.distort.warpstabilizer","name":"Warp Stabilizer","kind":{"kind":"Effect","effect":"ec.distort.warpstabilizer"},"enabled":true,"children":[{"node":"Group","uid":28,"match":"stabilization","name":"Stabilization","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":27,"match":"result","name":"Result","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Smooth Motion","No Motion"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":29,"match":"smoothness","name":"Smoothness","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":30,"match":"method","name":"Method","value":{"t":"Enum","v":3},"ui":{"kind":"Popup","options":["Position","Position, Scale, Rotation","Perspective","Subspace Warp"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":31,"match":"preserveScale","name":"Preserve Scale","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":33,"match":"borders","name":"Borders","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":32,"match":"framing","name":"Framing","value":{"t":"Enum","v":2},"ui":{"kind":"Popup","options":["Stabilize Only","Stabilize, Crop","Stabilize, Crop, Auto-scale","Stabilize, Synthesize Edges"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":35,"match":"autoScale","name":"Auto-scale","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":34,"match":"maximumScale","name":"Maximum Scale","value":{"t":"Scalar","v":150.0},"ui":{"kind":"Slider","min":100.0,"max":1000.0,"slider_min":100.0,"slider_max":200.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":36,"match":"actionSafeMargin","name":"Action-safe Margin","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":45.0,"slider_min":0.0,"slider_max":20.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":37,"match":"additionalScale","name":"Additional Scale","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":1.0,"max":1000.0,"slider_min":50.0,"slider_max":150.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":39,"match":"advanced","name":"Advanced","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":38,"match":"detailedAnalysis","name":"Detailed Analysis","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":40,"match":"rollingShutterRipple","name":"Rolling Shutter Ripple","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Automatic Reduction","Enhanced Reduction"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":41,"match":"cropLessSmoothMore","name":"Crop Less <-> Smooth More","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":42,"match":"synthesizeInputRange","name":"Synthesis Input Range (seconds)","value":{"t":"Scalar","v":1.0},"ui":{"kind":"Slider","min":0.0,"max":10.0,"slider_min":0.0,"slider_max":5.0,"decimals":2},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":43,"match":"synthesizeEdgeFeather","name":"Synthesis Edge Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":45,"match":"edgeCropping","name":"Synthesis Edge Cropping","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":44,"match":"left","name":"Left","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":46,"match":"top","name":"Top","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":47,"match":"right","name":"Right","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":48,"match":"bottom","name":"Bottom","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":49,"match":"hideWarningBanner","name":"Hide Warning Banner","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":50,"match":"showTrackPoints","name":"Show Track Points","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":51,"match":"trackPointSize","name":"Track Point Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":200.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":52,"match":"analysis","name":"Analysis","value":{"t":"Str","v":"saved analysis"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":53,"match":"analysisKey","name":"Analysis Key","value":{"t":"Str","v":"03efb7c4b0f27b65"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":54,"match":"ec.perspective.cameratracker","name":"3D Camera Tracker","kind":{"kind":"Effect","effect":"ec.perspective.cameratracker"},"enabled":true,"children":[{"node":"Prop","uid":55,"match":"shotType","name":"Shot Type","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Fixed Angle of View","Variable Zoom","Specify Angle of View"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":56,"match":"horizontalAngleOfView","name":"Horizontal Angle of View","value":{"t":"Scalar","v":40.0},"ui":{"kind":"Slider","min":1.0,"max":170.0,"slider_min":10.0,"slider_max":120.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":57,"match":"showTrackPoints","name":"Show Track Points","value":{"t":"Enum","v":1},"ui":{"kind":"Popup","options":["2D Source","3D Solved"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":58,"match":"renderTrackPoints","name":"Render Track Points","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":59,"match":"trackPointSize","name":"Track Point Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":300.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":60,"match":"targetSize","name":"Target Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":1.0,"max":1000.0,"slider_min":10.0,"slider_max":300.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":62,"match":"advanced","name":"Advanced","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":61,"match":"solveMethod","name":"Solve Method","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Auto Detect","Typical","Mostly Flat Scene","Tripod Pan"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":63,"match":"methodUsed","name":"Method Used","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":64,"match":"averageError","name":"Average Error","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":65,"match":"detailedAnalysis","name":"Detailed Analysis","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":66,"match":"lensDistortion","name":"Solve Lens Distortion","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":67,"match":"undistort","name":"Undistort Footage","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":68,"match":"autoDeletePoints","name":"Auto-delete Points Across Time","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":69,"match":"hideWarningBanner","name":"Hide Warning Banner","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":70,"match":"tracks","name":"Tracks","value":{"t":"Str","v":"saved tracks"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":71,"match":"tracksKey","name":"Tracks Key","value":{"t":"Str","v":"03efb7c4b0f27b65"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":72,"match":"solve","name":"Solve","value":{"t":"Str","v":"saved solve"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":73,"match":"solveKey","name":"Solve Key","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":74,"match":"deleted","name":"Deleted Points","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":75,"match":"camera","name":"Camera Layer","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":82,"match":"ec.blur.gaussian","name":"Gaussian Blur","kind":{"kind":"Effect","effect":"ec.blur.gaussian"},"enabled":true,"children":[{"node":"Prop","uid":83,"match":"blurriness","name":"Blurriness","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":3000.0,"slider_min":0.0,"slider_max":50.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":84,"match":"dimensions","name":"Blur Dimensions","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Horizontal and Vertical","Horizontal","Vertical"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":85,"match":"repeatEdge","name":"Repeat Edge Pixels","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":86,"match":"ec.matte.rotobrush","name":"Roto Brush & Refine Edge","kind":{"kind":"Effect","effect":"ec.matte.rotobrush"},"enabled":true,"children":[{"node":"Prop","uid":87,"match":"version","name":"Version","value":{"t":"Enum","v":2},"ui":{"kind":"Popup","options":["1.0","2.0","3.0"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":88,"match":"quality","name":"Quality","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Standard","Best"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":90,"match":"rotoBrushPropagation","name":"Roto Brush Propagation","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":89,"match":"searchRadius","name":"Search Radius","value":{"t":"Scalar","v":15.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":50.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":91,"match":"motionThreshold","name":"Motion Threshold","value":{"t":"Scalar","v":10.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":92,"match":"motionDamping","name":"Motion Damping","value":{"t":"Scalar","v":20.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":93,"match":"viewSearchRegion","name":"View Search Region","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":95,"match":"rotoBrushMatte","name":"Roto Brush Matte","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":94,"match":"feather","name":"Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":96,"match":"contrast","name":"Contrast","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":97,"match":"shiftEdge","name":"Shift Edge","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":-100.0,"max":100.0,"slider_min":-100.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":98,"match":"reduceChatter","name":"Reduce Chatter","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":99,"match":"object","name":"Show Object","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":32.0,"slider_min":0.0,"slider_max":32.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":101,"match":"refineEdgeMatte","name":"Refine Edge Matte","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":100,"match":"smooth","name":"Smooth","value":{"t":"Scalar","v":1.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":10.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":102,"match":"feather","name":"Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":103,"match":"contrast","name":"Contrast","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":104,"match":"shiftEdge","name":"Shift Edge","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":-100.0,"max":100.0,"slider_min":-100.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":105,"match":"reduceChatter","name":"Reduce Chatter","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":106,"match":"useMotionBlur","name":"Use Motion Blur","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":108,"match":"motionBlur","name":"Motion Blur","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":107,"match":"motionBlurSamples","name":"Samples","value":{"t":"Scalar","v":11.0},"ui":{"kind":"Slider","min":1.0,"max":64.0,"slider_min":1.0,"slider_max":32.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":109,"match":"shutterAngle","name":"Shutter Angle","value":{"t":"Scalar","v":180.0},"ui":{"kind":"Slider","min":0.0,"max":720.0,"slider_min":0.0,"slider_max":360.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":110,"match":"higherQuality","name":"Higher Quality","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":111,"match":"decontaminateEdgeColors","name":"Decontaminate Edge Colors","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":113,"match":"decontamination","name":"Decontamination","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":112,"match":"decontaminationAmount","name":"Decontamination Amount","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":114,"match":"extendWhereSmoothed","name":"Extend Where Smoothed","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":115,"match":"increaseDecontaminationRadius","name":"Increase Decontamination Radius","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":116,"match":"viewDecontaminationMap","name":"View Decontamination Map","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Prop","uid":117,"match":"strokes","name":"Strokes","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":118,"match":"inputKey","name":"Input Key","value":{"t":"Str","v":"7b319ce93027617c"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":119,"match":"frozen","name":"Frozen","value":{"t":"Str","v":"saved frozen"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":120,"match":"tracked","name":"Tracked","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Group","uid":15,"match":"transform","name":"Transform","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":7,"match":"anchor","name":"Anchor Point","value":{"t":"Vec3","v":[80.0,60.0,0.0]},"ui":{"kind":"Point"},"spatial":true,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":8,"match":"position","name":"Position","value":{"t":"Vec3","v":[80.0,60.0,0.0]},"ui":{"kind":"Point"},"spatial":true,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":9,"match":"scale","name":"Scale","value":{"t":"Vec3","v":[100.0,100.0,100.0]},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":10,"match":"orientation","name":"Orientation","value":{"t":"Vec3","v":[0.0,0.0,0.0]},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":11,"match":"rotationX","name":"X Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":12,"match":"rotationY","name":"Y Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":13,"match":"rotation","name":"Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":14,"match":"opacity","name":"Opacity","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":16,"match":"materialOptions","name":"Material Options","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":17,"match":"castsShadows","name":"Casts Shadows","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Off","On","Only"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":18,"match":"lightTransmission","name":"Light Transmission","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":19,"match":"acceptsShadows","name":"Accepts Shadows","value":{"t":"Enum","v":1},"ui":{"kind":"Popup","options":["Off","On","Only"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":20,"match":"acceptsLights","name":"Accepts Lights","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":21,"match":"ambient","name":"Ambient","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":22,"match":"diffuse","name":"Diffuse","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":23,"match":"specularIntensity","name":"Specular Intensity","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":24,"match":"specularShininess","name":"Specular Shininess","value":{"t":"Scalar","v":5.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":25,"match":"metal","name":"Metal","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]}}],"markers":[],"shutter_angle":180.0,"shutter_phase":-90.0,"motion_blur_samples":16,"motion_blur_adaptive_limit":128,"renderer":"Classic3D","hide_shy":false,"enable_motion_blur":true,"enable_frame_blending":true,"draft_3d":false,"preserve_frame_rate":false,"preserve_resolution":false,"poster_time":0,"global_light":{"angle":{"uid":0,"match":"globalLightAngle","name":"Global Light Angle","value":{"t":"Scalar","v":120.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},"altitude":{"uid":0,"match":"globalLightAltitude","name":"Global Light Altitude","value":{"t":"Scalar","v":30.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}}}}},"next_id":121} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution new file mode 100644 index 000000000..06e8915f4 --- /dev/null +++ b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution @@ -0,0 +1,8 @@ +asset: analysed-0.24.ecproj +title: The tracked-0.22 clip with Time Remap, a mask, Gaussian Blur and Roto Brush, keyed by EffectCraft 0.24.1 +author: arnav-hiwarkar +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-12 by arnav-hiwarkar +notes: Made from tracked-0.22.ecproj with 0.24.1 engine commands (enable Time Remap, a rectangle mask, Gaussian Blur and Roto Brush), its saved analyses replaced by short stand-in strings, and the Warp Stabilizer, 3D Camera Tracker and Roto Brush keys that 0.24.1 computed for it. Written as compact JSON. No media is needed: the tests only check that the keys are kept or cleared. diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj new file mode 100644 index 000000000..9a85aaa65 --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj @@ -0,0 +1,947 @@ +{ + "savedBy": "0.22.0", + "activeComp": 1, + "openComps": [ + 1 + ], + "schema": 2, + "settings": { + "bit_depth": "Bpc8", + "linearize": false, + "working_space": null, + "blend_linear": false, + "time_display": "Timecode", + "frame_start": 0, + "audio_sample_rate": 48000, + "gpu_acceleration": true, + "color_engine": "Adobe", + "hdr": "Clip", + "output_space": null, + "working_gamma": 2.2 + }, + "items": { + "1": { + "id": 1, + "name": "Main", + "label": "Sandstone", + "comment": "", + "parent": null, + "kind": { + "type": "Comp", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "duration": 25401600000, + "display_start": 0, + "background": [ + 0.0, + 0.0, + 0.0 + ], + "work_area": [ + 0, + 25401600000 + ], + "layers": [ + { + "id": 3, + "name": "effectcraft-3430-subject.mp4", + "source": { + "type": "Footage", + "item": 2 + }, + "label": "Aqua", + "comment": "", + "start_time": 0, + "in_point": 0, + "out_point": 25401600000, + "stretch": 100.0, + "switches": { + "video": true, + "audio": true, + "solo": false, + "locked": false, + "shy": false, + "collapse": false, + "quality": "Best", + "sampling": "Bilinear", + "effects": true, + "frame_blend": "Off", + "motion_blur": false, + "adjustment": false, + "three_d": false, + "guide": false + }, + "blend_mode": "Normal", + "preserve_transparency": false, + "track_matte": null, + "parent": null, + "markers": [], + "auto_orient": "Off", + "props": { + "uid": 4, + "match": "layer", + "name": "effectcraft-3430-subject.mp4", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 5, + "match": "masks", + "name": "Masks", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [] + }, + { + "node": "Group", + "uid": 6, + "match": "effects", + "name": "Effects", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 26, + "match": "ec.matte.objectmatte", + "name": "Object Matte", + "kind": { + "kind": "Effect", + "effect": "ec.matte.objectmatte" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 27, + "match": "objectPoint", + "name": "Object Point", + "value": { + "t": "Vec2", + "v": [ + 32.0, + 24.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 28, + "match": "useBox", + "name": "Use Box", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 29, + "match": "boxTopLeft", + "name": "Box Top Left", + "value": { + "t": "Vec2", + "v": [ + 14.0, + 8.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 30, + "match": "boxBottomRight", + "name": "Box Bottom Right", + "value": { + "t": "Vec2", + "v": [ + 50.0, + 40.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 32, + "match": "objectTracking", + "name": "Tracking", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 31, + "match": "trackObject", + "name": "Track Object", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 33, + "match": "referenceFrame", + "name": "Reference Frame", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Number" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 34, + "match": "framesBefore", + "name": "Frames Before", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 35, + "match": "framesAfter", + "name": "Frames After", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 37, + "match": "objectMatte", + "name": "Matte", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 36, + "match": "feather", + "name": "Feather", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 20.0, + "decimals": 1 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 38, + "match": "contrast", + "name": "Contrast", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 39, + "match": "shiftEdge", + "name": "Shift Edge", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": -100.0, + "max": 100.0, + "slider_min": -100.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 40, + "match": "invert", + "name": "Invert", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 41, + "match": "view", + "name": "View", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Final Output", + "Matte" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 42, + "match": "inputKey", + "name": "Input Key", + "value": { + "t": "Str", + "v": "cbefe0c85977c4aa" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 43, + "match": "frozen", + "name": "Frozen", + "value": { + "t": "Str", + "v": "{\"key\":\"1a097b69366afd7f\",\"size\":[64,48],\"frames\":{\"0\":{\"a\":\"gBgAkAX/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IACQBQ==\",\"b\":\"AA==\"}}}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + }, + { + "node": "Group", + "uid": 15, + "match": "transform", + "name": "Transform", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 7, + "match": "anchor", + "name": "Anchor Point", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 8, + "match": "position", + "name": "Position", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 9, + "match": "scale", + "name": "Scale", + "value": { + "t": "Vec3", + "v": [ + 100.0, + 100.0, + 100.0 + ] + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 10, + "match": "orientation", + "name": "Orientation", + "value": { + "t": "Vec3", + "v": [ + 0.0, + 0.0, + 0.0 + ] + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 11, + "match": "rotationX", + "name": "X Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 12, + "match": "rotationY", + "name": "Y Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 13, + "match": "rotation", + "name": "Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 14, + "match": "opacity", + "name": "Opacity", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 16, + "match": "materialOptions", + "name": "Material Options", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 17, + "match": "castsShadows", + "name": "Casts Shadows", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 18, + "match": "lightTransmission", + "name": "Light Transmission", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 19, + "match": "acceptsShadows", + "name": "Accepts Shadows", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 20, + "match": "acceptsLights", + "name": "Accepts Lights", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 21, + "match": "ambient", + "name": "Ambient", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 22, + "match": "diffuse", + "name": "Diffuse", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 23, + "match": "specularIntensity", + "name": "Specular Intensity", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 24, + "match": "specularShininess", + "name": "Specular Shininess", + "value": { + "t": "Scalar", + "v": 5.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 25, + "match": "metal", + "name": "Metal", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + } + } + ], + "markers": [], + "shutter_angle": 180.0, + "shutter_phase": -90.0, + "motion_blur_samples": 16, + "motion_blur_adaptive_limit": 128, + "renderer": "Classic3D", + "hide_shy": false, + "enable_motion_blur": true, + "enable_frame_blending": true, + "draft_3d": false, + "preserve_frame_rate": false, + "preserve_resolution": false, + "poster_time": 0, + "global_light": { + "angle": { + "uid": 0, + "match": "globalLightAngle", + "name": "Global Light Angle", + "value": { + "t": "Scalar", + "v": 120.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + "altitude": { + "uid": 0, + "match": "globalLightAltitude", + "name": "Global Light Altitude", + "value": { + "t": "Scalar", + "v": 30.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + } + } + }, + "2": { + "id": 2, + "name": "effectcraft-3430-subject.mp4", + "label": "Aqua", + "comment": "", + "parent": null, + "kind": { + "type": "Footage", + "path": "/tmp/effectcraft-3430-subject.mp4", + "kind": "Video", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "native_rate": null, + "duration": 25401600000, + "has_video": true, + "has_audio": false, + "alpha": "Ignore", + "premul_color": [ + 0.0, + 0.0, + 0.0 + ], + "loop_count": 1, + "codec": "H.264 (High)", + "missing": false, + "color_profile": "Rec709" + } + } + }, + "next_id": 44 +} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution new file mode 100644 index 000000000..c20c8b02c --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution @@ -0,0 +1,8 @@ +asset: object-matte-0.22.ecproj +title: One frozen Object Matte from EffectCraft 0.22.0 +author: lisn0 +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-11 by lisn0 +notes: Saved by the released 0.22.0 CLI using a generated 64x48 orange rectangle on a blue background. Contains one frozen alpha plane. The source path is a generic test path, and the source video is unnecessary for the state-preservation regression test. diff --git a/crates/engine/src/tests/fixtures/tracked-0.22.ecproj b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj new file mode 100644 index 000000000..8e1d21b40 --- /dev/null +++ b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj @@ -0,0 +1,1593 @@ +{ + "savedBy": "0.22.0", + "activeComp": 2, + "openComps": [ + 2 + ], + "schema": 2, + "settings": { + "bit_depth": "Bpc8", + "linearize": false, + "working_space": null, + "blend_linear": false, + "time_display": "Timecode", + "frame_start": 0, + "audio_sample_rate": 48000, + "gpu_acceleration": true, + "color_engine": "Adobe", + "hdr": "Clip", + "output_space": null, + "working_gamma": 2.2 + }, + "items": { + "1": { + "id": 1, + "name": "effectcraft-3456-shake.mp4", + "label": "Aqua", + "comment": "", + "parent": null, + "kind": { + "type": "Footage", + "path": "/tmp/effectcraft-3456-shake.mp4", + "kind": "Video", + "width": 160, + "height": 120, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 12, + "den": 1 + }, + "native_rate": null, + "duration": 254016000000, + "has_video": true, + "has_audio": false, + "alpha": "Ignore", + "premul_color": [ + 0.0, + 0.0, + 0.0 + ], + "loop_count": 1, + "codec": "H.264 (High)", + "missing": false, + "color_profile": "Rec709" + } + }, + "2": { + "id": 2, + "name": "effectcraft-3456-shake", + "label": "Sandstone", + "comment": "", + "parent": null, + "kind": { + "type": "Comp", + "width": 160, + "height": 120, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 12, + "den": 1 + }, + "duration": 254016000000, + "display_start": 0, + "background": [ + 0.0, + 0.0, + 0.0 + ], + "work_area": [ + 0, + 254016000000 + ], + "layers": [ + { + "id": 3, + "name": "effectcraft-3456-shake.mp4", + "source": { + "type": "Footage", + "item": 1 + }, + "label": "Aqua", + "comment": "", + "start_time": 0, + "in_point": 0, + "out_point": 254016000000, + "stretch": 100.0, + "switches": { + "video": true, + "audio": true, + "solo": false, + "locked": false, + "shy": false, + "collapse": false, + "quality": "Best", + "sampling": "Bilinear", + "effects": true, + "frame_blend": "Off", + "motion_blur": false, + "adjustment": false, + "three_d": false, + "guide": false + }, + "blend_mode": "Normal", + "preserve_transparency": false, + "track_matte": null, + "parent": null, + "markers": [], + "auto_orient": "Off", + "props": { + "uid": 4, + "match": "layer", + "name": "effectcraft-3456-shake.mp4", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 5, + "match": "masks", + "name": "Masks", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [] + }, + { + "node": "Group", + "uid": 6, + "match": "effects", + "name": "Effects", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 26, + "match": "ec.distort.warpstabilizer", + "name": "Warp Stabilizer", + "kind": { + "kind": "Effect", + "effect": "ec.distort.warpstabilizer" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 28, + "match": "stabilization", + "name": "Stabilization", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 27, + "match": "result", + "name": "Result", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Smooth Motion", + "No Motion" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 29, + "match": "smoothness", + "name": "Smoothness", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 30, + "match": "method", + "name": "Method", + "value": { + "t": "Enum", + "v": 3 + }, + "ui": { + "kind": "Popup", + "options": [ + "Position", + "Position, Scale, Rotation", + "Perspective", + "Subspace Warp" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 31, + "match": "preserveScale", + "name": "Preserve Scale", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 33, + "match": "borders", + "name": "Borders", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 32, + "match": "framing", + "name": "Framing", + "value": { + "t": "Enum", + "v": 2 + }, + "ui": { + "kind": "Popup", + "options": [ + "Stabilize Only", + "Stabilize, Crop", + "Stabilize, Crop, Auto-scale", + "Stabilize, Synthesize Edges" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 35, + "match": "autoScale", + "name": "Auto-scale", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 34, + "match": "maximumScale", + "name": "Maximum Scale", + "value": { + "t": "Scalar", + "v": 150.0 + }, + "ui": { + "kind": "Slider", + "min": 100.0, + "max": 1000.0, + "slider_min": 100.0, + "slider_max": 200.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 36, + "match": "actionSafeMargin", + "name": "Action-safe Margin", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 45.0, + "slider_min": 0.0, + "slider_max": 20.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 37, + "match": "additionalScale", + "name": "Additional Scale", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 1.0, + "max": 1000.0, + "slider_min": 50.0, + "slider_max": 150.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 39, + "match": "advanced", + "name": "Advanced", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 38, + "match": "detailedAnalysis", + "name": "Detailed Analysis", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 40, + "match": "rollingShutterRipple", + "name": "Rolling Shutter Ripple", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Automatic Reduction", + "Enhanced Reduction" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 41, + "match": "cropLessSmoothMore", + "name": "Crop Less <-> Smooth More", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 42, + "match": "synthesizeInputRange", + "name": "Synthesis Input Range (seconds)", + "value": { + "t": "Scalar", + "v": 1.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 10.0, + "slider_min": 0.0, + "slider_max": 5.0, + "decimals": 2 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 43, + "match": "synthesizeEdgeFeather", + "name": "Synthesis Edge Feather", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 45, + "match": "edgeCropping", + "name": "Synthesis Edge Cropping", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 44, + "match": "left", + "name": "Left", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 46, + "match": "top", + "name": "Top", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 47, + "match": "right", + "name": "Right", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 48, + "match": "bottom", + "name": "Bottom", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 49, + "match": "hideWarningBanner", + "name": "Hide Warning Banner", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 50, + "match": "showTrackPoints", + "name": "Show Track Points", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 51, + "match": "trackPointSize", + "name": "Track Point Size", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 200.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 52, + "match": "analysis", + "name": "Analysis", + "value": { + "t": "Str", + "v": "{\"version\":2,\"start\":0.0,\"frameDuration\":0.08333333333333333,\"size\":[160.0,120.0],\"detailed\":false,\"frames\":[{\"t\":[0.0,0.0],\"s\":[1.0,0.0,0.0,0.0],\"h\":[1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0],\"inliers\":0,\"features\":0},{\"t\":[-22.388798410890686,30.01868418985143],\"s\":[0.9277781961008736,-0.04422418749279422,-15.523833932787909,35.6091856999425],\"h\":[0.9277781961008736,0.04422418749279422,-15.523833932787909,-0.04422418749279422,0.9277781961008736,35.6091856999425,0.0,0.0],\"inliers\":9,\"features\":15,\"points\":[[92.3,107.9],[95.2,95.7],[92.2,103.7],[74.2,53.6],[74.3,37.4],[99.3,90.7],[82.6,41.5],[82.5,55.5],[88.5,43.5]]},{\"t\":[23.98122532957055,-1.9084399677775892],\"s\":[0.9063620953625465,-0.04409531016401132,30.797313797392007,5.599646102270199],\"h\":[0.7965841302132126,0.29995334448297317,20.168498543438005,-0.0740592055495628,1.046481410433727,-0.473260319309297,-0.002014895402579317,0.0023197947752658875],\"inliers\":10,\"features\":24,\"points\":[[118.4,97.1],[124.8,102.9],[100.5,51.5],[127.3,63.9],[127.5,45.0],[106.1,39.6],[106.2,53.7],[113.7,56.7],[52.5,35.6],[116.8,43.6]]},{\"t\":[11.687620662467452,-7.932031814223215],\"s\":[0.9951636472613027,0.0038103578382923703,12.438092774450126,-8.118641076572445],\"h\":[1.643484923435082,-0.7137632860918511,-2.5905208024072124,0.3548180400361871,0.46037059943190983,-10.538467107984474,0.004219805542554225,-0.0059212698862967],\"inliers\":9,\"features\":18,\"points\":[[132.6,73.5],[131.5,85.1],[106.5,41.5],[138.4,70.5],[143.4,74.0],[112.0,43.6],[122.5,47.5],[130.2,49.8],[149.3,58.5]]},{\"t\":[-30.310403914516613,33.907848152443364],\"s\":[1.007168616747288,0.0037069147029014813,-31.056396618707396,33.11324659331262],\"h\":[0.6198845159470996,0.0005618559126509149,-3.178267007922358,-0.13009530252511717,0.7726770220834841,43.55009577470153,-0.001720398359559105,0.00004737533435711085],\"inliers\":6,\"features\":6,\"points\":[[83.4,78.4],[111.5,91.5],[111.5,72.5],[89.0,80.3],[94.7,83.3],[92.1,67.5]]},{\"t\":[-5.583259809628714,-21.883441966142215],\"s\":[1.0031742036915483,-0.0014807972400717364,-6.0689262496146625,-22.023221544860448],\"h\":[1.1420296883900174,-0.04739597390990014,-10.605302345116598,0.09741208494601904,0.9834838974219691,-26.601790074159226,0.0009113645792746124,-0.0004613682942006241],\"inliers\":12,\"features\":26,\"points\":[[44.4,0.4],[95.7,91.4],[102.5,90.5],[96.9,82.3],[102.8,85.5],[102.6,81.2],[97.1,78.5],[127.8,90.7],[128.5,80.1],[108.6,91.2],[108.5,79.5],[105.0,77.7]]},{\"t\":[27.686733462643232,8.049769635251877],\"s\":[0.929460928303922,0.035973851378530715,35.6798165298027,9.23027450987749],\"h\":[1.008918148822829,0.1261308536278953,23.55178846805191,-0.030309790827892834,1.108836841709476,6.4896286706810855,-0.0005830225271129425,0.0013957761568033347],\"inliers\":9,\"features\":24,\"points\":[[55.5,112.5],[100.6,55.5],[100.6,69.5],[106.2,57.5],[106.1,71.6],[113.5,74.5],[76.4,61.6],[76.6,45.5],[116.6,61.6]]},{\"t\":[-2.1632428252979565,18.033956762750776],\"s\":[0.9823494564274133,-0.03858067520697906,-3.292485287213367,25.146037618301847],\"h\":[0.9579598760613574,-0.13114734456280325,2.830816183351523,0.009276438160611545,0.8429238585075404,27.816268384517308,0.00010866570427004524,-0.0010986404477924933],\"inliers\":9,\"features\":25,\"points\":[[10.2,63.8],[4.5,45.5],[53.4,60.9],[106.6,83.4],[106.6,97.6],[58.5,80.5],[71.6,85.4],[76.7,86.4],[112.4,85.5]]},{\"t\":[-30.66640840418232,-21.989852679037867],\"s\":[1.0156367307051672,0.004551071962152525,-31.763785052482092,-23.691591888755113],\"h\":[1.0156367307051672,-0.004551071962152525,-31.763785052482092,0.004551071962152525,1.0156367307051672,-23.691591888755113,0.0,0.0],\"inliers\":6,\"features\":9,\"points\":[[65.6,55.5],[68.5,58.4],[76.3,61.6],[58.5,55.9],[78.5,77.7],[43.4,48.3]]},{\"t\":[5.708740901092206,24.075281194659254],\"s\":[1.0135826886534363,-0.10515588147251018,-1.1163514746928627,27.59825643107368],\"h\":[1.1505001196625904,-0.004492526279963958,-0.5319258967829654,-0.025738957694540945,0.966887539263372,28.15190307627249,0.0007845902586792284,-0.00031935492918067597],\"inliers\":9,\"features\":24,\"points\":[[104.6,97.7],[104.8,101.6],[108.5,88.8],[103.9,89.0],[116.1,92.7],[112.4,87.8],[113.2,96.3],[47.3,72.5],[46.4,87.5]]},{\"t\":[27.779201319161004,9.926771642811133],\"s\":[0.9890321818477387,0.0013711446087583204,28.575284295787874,10.772618168795788],\"h\":[1.061498205619746,0.009950102807178602,26.602191497678987,0.03562744518371215,1.0470570180393335,7.3128067380884545,0.00040746752749711983,0.00013875206582740715],\"inliers\":19,\"features\":31,\"points\":[[101.5,91.5],[100.5,107.5],[95.4,104.6],[93.2,89.4],[62.5,94.4],[108.2,95.5],[108.2,109.3],[114.0,97.5],[89.2,102.5],[61.5,75.5],[70.5,80.5],[89.3,88.3],[114.9,111.1],[69.5,95.5],[66.5,78.5],[82.3,100.6],[76.5,82.5],[82.5,84.4],[119.1,112.0]]},{\"t\":[-18.08353148330616,-15.925984647541327],\"s\":[0.9665330472023047,-0.007772653084006622,-14.505725243709165,-11.14686987633948],\"h\":[1.396312496581232,-0.10268053165320767,-33.15101241390654,0.021275151660258673,1.173514724589782,-24.185902320422414,0.001847437675107323,-0.0003034312788189646],\"inliers\":9,\"features\":23,\"points\":[[119.3,75.5],[116.5,84.0],[121.6,82.9],[128.5,87.4],[127.6,83.0],[124.8,80.6],[128.7,71.5],[71.6,86.5],[58.4,66.5]]}],\"tracks\":[{\"id\":0,\"start\":0,\"pts\":[[121.5,16.5],[101.4,48.2],[127.2,46.6],[141.2,40.2],[111.2,74.3],[105.1,58.7],[135.1,68.5]]},{\"id\":1,\"start\":0,\"pts\":[[107.5,26.5],[85.4,56.6],[108.9,54.9],[121.0,46.9],[90.3,80.7],[82.3,64.9],[109.6,73.1],[105.7,97.1]]}]}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 53, + "match": "analysisKey", + "name": "Analysis Key", + "value": { + "t": "Str", + "v": "f87b8df2e353c28e" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 54, + "match": "ec.perspective.cameratracker", + "name": "3D Camera Tracker", + "kind": { + "kind": "Effect", + "effect": "ec.perspective.cameratracker" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 55, + "match": "shotType", + "name": "Shot Type", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Fixed Angle of View", + "Variable Zoom", + "Specify Angle of View" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 56, + "match": "horizontalAngleOfView", + "name": "Horizontal Angle of View", + "value": { + "t": "Scalar", + "v": 40.0 + }, + "ui": { + "kind": "Slider", + "min": 1.0, + "max": 170.0, + "slider_min": 10.0, + "slider_max": 120.0, + "decimals": 1 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 57, + "match": "showTrackPoints", + "name": "Show Track Points", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "2D Source", + "3D Solved" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 58, + "match": "renderTrackPoints", + "name": "Render Track Points", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 59, + "match": "trackPointSize", + "name": "Track Point Size", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 300.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 60, + "match": "targetSize", + "name": "Target Size", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 1.0, + "max": 1000.0, + "slider_min": 10.0, + "slider_max": 300.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 62, + "match": "advanced", + "name": "Advanced", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 61, + "match": "solveMethod", + "name": "Solve Method", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Auto Detect", + "Typical", + "Mostly Flat Scene", + "Tripod Pan" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 63, + "match": "methodUsed", + "name": "Method Used", + "value": { + "t": "Str", + "v": "Tripod Pan" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 64, + "match": "averageError", + "name": "Average Error", + "value": { + "t": "Scalar", + "v": 0.48427435472856495 + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 65, + "match": "detailedAnalysis", + "name": "Detailed Analysis", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 66, + "match": "lensDistortion", + "name": "Solve Lens Distortion", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 67, + "match": "undistort", + "name": "Undistort Footage", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 68, + "match": "autoDeletePoints", + "name": "Auto-delete Points Across Time", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 69, + "match": "hideWarningBanner", + "name": "Hide Warning Banner", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 70, + "match": "tracks", + "name": "Tracks", + "value": { + "t": "Str", + "v": "{\"version\":1,\"size\":[160.0,120.0],\"start\":0.0,\"frameDuration\":0.08333333333333333,\"frames\":12,\"factor\":1.0,\"detailed\":false,\"tracks\":[{\"id\":0,\"start\":0,\"pts\":[[77.5,21.5],[63.0,42.02],[84.44,46.2],[94.51,43.46],[76.29,65.76],[77.07,73.7],[83.0,80.68],[85.69,85.86],[87.92,90.96],[89.4,99.49],[90.13,103.45],[90.97,109.78]]},{\"id\":1,\"start\":0,\"pts\":[[137.5,41.5],[125.34,65.53],[144.58,68.06],[155.96,64.9],[137.55,87.17]]},{\"id\":2,\"start\":0,\"pts\":[[103.5,9.5],[85.74,29.12],[104.73,32.21],[110.44,21.58],[92.15,43.67]]},{\"id\":3,\"start\":0,\"pts\":[[104.5,29.5],[85.9,50.12],[103.93,52.29],[109.88,48.47],[92.18,70.78]]},{\"id\":4,\"start\":0,\"pts\":[[135.5,18.5],[123.93,41.77],[143.14,45.58],[156.62,36.13],[137.86,58.99],[138.18,64.4],[142.35,72.76]]},{\"id\":5,\"start\":0,\"pts\":[[145.5,43.5],[136.49,68.62],[152.24,70.07]]},{\"id\":6,\"start\":0,\"pts\":[[68.5,16.5],[54.6,38.04],[75.81,40.97],[85.89,39.76],[67.52,62.45],[65.27,69.0]]},{\"id\":7,\"start\":0,\"pts\":[[126.5,18.5],[111.0,39.74],[128.76,42.63],[136.89,32.11],[117.68,54.39]]},{\"id\":8,\"start\":1,\"pts\":[[62.5,17.5],[85.58,22.87],[94.22,13.25],[75.42,35.02],[75.53,43.03],[82.19,51.59],[83.63,56.75],[86.42,64.01],[88.28,72.0],[89.17,76.16],[88.59,83.65]]},{\"id\":9,\"start\":1,\"pts\":[[114.5,106.5],[136.85,100.86],[147.03,92.4]]},{\"id\":10,\"start\":1,\"pts\":[[72.5,23.5],[97.31,29.7],[103.74,19.02],[85.16,40.96],[87.87,50.25],[90.99,56.84],[95.55,64.14],[94.95,69.09],[98.79,80.06],[99.53,84.17],[96.16,89.83]]},{\"id\":11,\"start\":1,\"pts\":[[105.5,56.5],[123.21,58.51],[130.67,54.61],[112.22,76.63],[107.86,82.64],[110.99,88.56],[110.29,92.35],[107.8,96.22],[105.42,105.58],[106.34,109.09],[103.49,114.69]]},{\"id\":12,\"start\":1,\"pts\":[[48.5,14.5],[74.56,20.81],[80.17,10.47],[60.5,31.52]]},{\"id\":13,\"start\":1,\"pts\":[[56.5,17.5],[79.51,23.22],[88.21,13.13],[69.38,34.7],[69.5,43.04],[76.12,50.88],[77.6,55.88],[80.34,63.05],[82.34,69.83],[83.23,74.02],[82.59,80.86]]},{\"id\":14,\"start\":1,\"pts\":[[111.5,56.5],[129.28,58.38],[136.92,54.94]]},{\"id\":15,\"start\":2,\"pts\":[[111.5,35.5],[116.71,24.74],[98.61,46.94],[95.39,52.62],[97.78,59.24]]},{\"id\":16,\"start\":2,\"pts\":[[111.5,54.5],[116.45,50.61],[98.85,72.82],[95.85,78.52],[98.15,84.77]]},{\"id\":17,\"start\":2,\"pts\":[[35.5,25.5],[51.37,25.98],[29.35,47.25],[34.79,58.33],[37.82,64.46],[35.7,68.67]]},{\"id\":18,\"start\":2,\"pts\":[[65.5,37.5],[73.16,34.37],[53.32,56.61]]},{\"id\":19,\"start\":3,\"pts\":[[139.5,91.5],[121.67,103.33],[122.29,98.68],[128.91,94.42],[130.04,88.08],[132.48,82.73],[132.69,85.66],[135.56,80.73],[133.86,80.34]]},{\"id\":20,\"start\":3,\"pts\":[[128.5,27.5],[109.77,49.81],[105.68,55.33]]},{\"id\":21,\"start\":4,\"pts\":[[132.5,108.5],[134.61,103.06],[139.18,98.97]]},{\"id\":22,\"start\":4,\"pts\":[[12.5,44.5],[15.77,54.38],[21.97,62.49],[23.19,67.31],[25.95,73.59],[32.15,76.64],[31.59,80.8],[37.46,83.52]]},{\"id\":23,\"start\":5,\"pts\":[[88.5,76.5],[91.53,82.49],[96.99,88.67],[96.31,92.35]]},{\"id\":24,\"start\":5,\"pts\":[[23.5,26.5],[34.16,36.76],[31.29,40.39]]},{\"id\":25,\"start\":5,\"pts\":[[29.5,29.5],[41.15,39.45],[37.02,43.33],[44.0,51.94]]},{\"id\":26,\"start\":5,\"pts\":[[16.5,25.5],[25.23,35.61],[24.0,39.41],[27.82,47.95],[34.62,50.88],[34.62,55.8],[39.37,61.06]]},{\"id\":27,\"start\":5,\"pts\":[[12.5,21.5],[20.78,30.91],[20.0,35.48],[23.13,42.77],[30.29,46.76],[30.38,51.79],[34.65,56.39]]},{\"id\":28,\"start\":5,\"pts\":[[115.5,82.5],[118.64,88.43],[118.09,93.19],[117.39,96.49],[114.83,106.78],[114.88,110.08]]},{\"id\":29,\"start\":5,\"pts\":[[122.5,61.5],[124.31,67.9],[123.88,72.08],[123.44,76.68],[120.82,86.9],[120.84,90.61],[117.85,97.98]]},{\"id\":30,\"start\":5,\"pts\":[[120.5,66.5],[122.86,72.98],[122.45,77.21],[121.98,81.49]]},{\"id\":31,\"start\":7,\"pts\":[[130.5,110.5],[132.07,103.93],[132.1,107.25],[134.31,101.57],[134.08,101.95]]},{\"id\":32,\"start\":7,\"pts\":[[144.5,87.5],[147.64,81.13],[147.29,84.43]]},{\"id\":33,\"start\":7,\"pts\":[[149.5,87.5],[151.62,81.15],[151.6,83.45]]},{\"id\":34,\"start\":7,\"pts\":[[141.5,109.5],[142.65,102.86],[144.73,106.45]]},{\"id\":35,\"start\":7,\"pts\":[[130.5,105.5],[132.21,97.98],[132.16,101.56]]},{\"id\":36,\"start\":7,\"pts\":[[46.5,46.5],[46.28,52.04],[53.71,58.71],[53.81,63.25]]},{\"id\":37,\"start\":7,\"pts\":[[63.5,78.5],[60.78,82.81],[61.38,87.33],[61.18,91.39],[60.13,92.98]]},{\"id\":38,\"start\":7,\"pts\":[[147.5,79.5],[148.79,73.74],[148.89,76.93],[152.13,71.83],[150.74,73.15]]},{\"id\":39,\"start\":8,\"pts\":[[110.5,73.5],[108.69,82.44],[109.4,86.46],[105.45,92.92]]},{\"id\":40,\"start\":8,\"pts\":[[37.5,75.5],[39.63,77.92],[39.24,82.09],[46.3,85.66]]},{\"id\":41,\"start\":9,\"pts\":[[72.5,92.5],[72.19,96.59],[71.1,99.0]]},{\"id\":42,\"start\":9,\"pts\":[[71.5,66.5],[71.58,70.65],[69.4,74.61]]},{\"id\":43,\"start\":9,\"pts\":[[42.5,52.5],[42.63,57.29],[48.81,63.22]]},{\"id\":44,\"start\":9,\"pts\":[[65.5,63.5],[65.9,67.71],[62.84,71.25]]},{\"id\":45,\"start\":9,\"pts\":[[54.5,84.5],[54.33,88.51],[54.49,89.47]]},{\"id\":46,\"start\":9,\"pts\":[[93.5,108.5],[94.19,112.24],[95.37,117.47]]}]}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 71, + "match": "tracksKey", + "name": "Tracks Key", + "value": { + "t": "Str", + "v": "f87b8df2e353c28e" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 72, + "match": "solve", + "name": "Solve", + "value": { + "t": "Str", + "v": "{\"version\":1,\"size\":[160.0,120.0],\"start\":0.0,\"frameDuration\":0.08333333333333333,\"shot\":\"fixedAngle\",\"methodUsed\":\"tripodPan\",\"averageError\":0.48427435472856495,\"frames\":[{\"rot\":[0.0,0.0,0.0],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.020706432791589117,-0.008075142299053078,-0.06077723052316193],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.023015120128512003,0.005766803085062508,-0.05310541732098104],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.0170200841043793,0.014154804924310056,-0.050762146721755434],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.03356753265765158,-0.00032501028659040183,-0.05939089867356119],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.03943129932097289,0.0005848011077747351,-0.08866812978617226],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.04587385659719372,0.00556317017360105,-0.0922903070481816],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.04943028885690781,0.006586719185689197,-0.1030177435734751],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.05420648867299307,0.008981748111204299,-0.12219539780340982],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.06013167434751454,0.009061063842010806,-0.02749230328481726],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.06324873745045556,0.009255262692673143,-0.0355321639175462],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.06618656945718411,0.009319774678589094,-0.015189163276848288],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true}],\"points\":[{\"id\":0,\"pos\":[-0.001884543346613208,-0.029037452307042782,0.9806051370981879],\"error\":0.56470084,\"first\":0,\"last\":11},{\"id\":1,\"pos\":[0.04710854544038102,-0.015334858559503525,1.0773487523110807],\"error\":0.4844214,\"first\":0,\"last\":4},{\"id\":2,\"pos\":[0.015277830948334362,-0.04530698241035739,1.0393293228690133],\"error\":0.3392279,\"first\":0,\"last\":4},{\"id\":3,\"pos\":[0.013625157539074177,-0.028048508549162222,1.005205908477461],\"error\":0.27458376,\"first\":0,\"last\":4},{\"id\":4,\"pos\":[0.04631029390834158,-0.03435434088491794,1.0740657193502177],\"error\":0.58201736,\"first\":0,\"last\":6},{\"id\":5,\"pos\":[0.053943894461564695,-0.013610066623604573,1.082119350918587],\"error\":0.33030823,\"first\":0,\"last\":2},{\"id\":6,\"pos\":[-0.008886582549498147,-0.03423365291632946,1.0500072858529375],\"error\":0.5071384,\"first\":0,\"last\":5},{\"id\":7,\"pos\":[0.03502079415367347,-0.03650999899856251,1.0590501262399414],\"error\":0.3427207,\"first\":0,\"last\":4},{\"id\":8,\"pos\":[-0.0010752140340087664,-0.05237350807489353,0.9970760865042246],\"error\":0.35491616,\"first\":1,\"last\":11},{\"id\":9,\"pos\":[0.04045532180900739,0.011287908043101045,1.1118602867055563],\"error\":0.35821888,\"first\":1,\"last\":3},{\"id\":12,\"pos\":[-0.012247058383356772,-0.05711454082770741,1.026095977383411],\"error\":0.7576372,\"first\":1,\"last\":4},{\"id\":13,\"pos\":[-0.0057782770174717585,-0.05312155593792027,1.002454847850506],\"error\":0.543182,\"first\":1,\"last\":11},{\"id\":14,\"pos\":[0.03444063369446883,-0.02322488536327569,1.0525377480568132],\"error\":0.5363785,\"first\":1,\"last\":3},{\"id\":15,\"pos\":[0.016549051463405264,-0.04448675703801009,1.039611880351983],\"error\":0.31473064,\"first\":2,\"last\":6},{\"id\":16,\"pos\":[0.015561268129555224,-0.024177980474891878,1.0505609541603724],\"error\":0.448273,\"first\":2,\"last\":6},{\"id\":17,\"pos\":[-0.037387500298151244,-0.046978366490905726,1.0292596184739917],\"error\":0.5814284,\"first\":2,\"last\":7},{\"id\":18,\"pos\":[-0.018963958767977365,-0.03840654941654895,1.029868839109744],\"error\":0.62771547,\"first\":2,\"last\":4},{\"id\":20,\"pos\":[0.025824410036239483,-0.04221331533850395,1.051265936102142],\"error\":0.2203965,\"first\":3,\"last\":5},{\"id\":22,\"pos\":[-0.04851356310948073,-0.04922371159135405,1.0148030992016972],\"error\":0.60021263,\"first\":4,\"last\":11},{\"id\":23,\"pos\":[0.0036124978633337353,-0.027413664985930772,0.9671987963193897],\"error\":0.2763749,\"first\":5,\"last\":8},{\"id\":24,\"pos\":[-0.040597577069465214,-0.0703196502868751,1.0235859186232383],\"error\":0.20348576,\"first\":5,\"last\":7},{\"id\":26,\"pos\":[-0.04416469175733074,-0.0698384544617488,1.0211428377131564],\"error\":0.7978787,\"first\":5,\"last\":11},{\"id\":27,\"pos\":[-0.04818954744569584,-0.07416243876625056,1.013378662747375],\"error\":0.6526291,\"first\":5,\"last\":11},{\"id\":36,\"pos\":[-0.02865646311969018,-0.06261471940209673,1.0103266984710924],\"error\":0.47229937,\"first\":7,\"last\":10},{\"id\":37,\"pos\":[-0.022363582487320836,-0.038494993762751006,0.9688013952945587],\"error\":0.5830236,\"first\":7,\"last\":11},{\"id\":39,\"pos\":[0.012856414034624068,-0.040629816490058715,0.9569374661053799],\"error\":0.26666406,\"first\":8,\"last\":11},{\"id\":40,\"pos\":[-0.039487212277480116,-0.04718518517554586,0.9962321113853498],\"error\":0.48472655,\"first\":8,\"last\":11},{\"id\":41,\"pos\":[-0.014270376399443,-0.03389203058278074,0.9480752077612952],\"error\":0.79931283,\"first\":9,\"last\":11},{\"id\":42,\"pos\":[-0.014596706636024855,-0.05446828640367654,0.9808373411559306],\"error\":0.021755833,\"first\":9,\"last\":11},{\"id\":43,\"pos\":[-0.03666075088242092,-0.06654420263172509,0.9970944504474955],\"error\":0.20362298,\"first\":9,\"last\":11},{\"id\":44,\"pos\":[-0.01908451035407613,-0.057350036080083884,0.9896103813509345],\"error\":0.18076603,\"first\":9,\"last\":11},{\"id\":45,\"pos\":[-0.028179725825267236,-0.04186664641926184,0.9918891096090983],\"error\":0.24652581,\"first\":9,\"last\":11},{\"id\":46,\"pos\":[0.0010670642752641536,-0.018463010859931878,0.8064992130555431],\"error\":0.7289736,\"first\":9,\"last\":11}]}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 73, + "match": "solveKey", + "name": "Solve Key", + "value": { + "t": "Str", + "v": "FixedAngle||Auto|[]" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 74, + "match": "deleted", + "name": "Deleted Points", + "value": { + "t": "Str", + "v": "" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 75, + "match": "camera", + "name": "Camera Layer", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + }, + { + "node": "Group", + "uid": 15, + "match": "transform", + "name": "Transform", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 7, + "match": "anchor", + "name": "Anchor Point", + "value": { + "t": "Vec3", + "v": [ + 80.0, + 60.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 8, + "match": "position", + "name": "Position", + "value": { + "t": "Vec3", + "v": [ + 80.0, + 60.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 9, + "match": "scale", + "name": "Scale", + "value": { + "t": "Vec3", + "v": [ + 100.0, + 100.0, + 100.0 + ] + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 10, + "match": "orientation", + "name": "Orientation", + "value": { + "t": "Vec3", + "v": [ + 0.0, + 0.0, + 0.0 + ] + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 11, + "match": "rotationX", + "name": "X Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 12, + "match": "rotationY", + "name": "Y Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 13, + "match": "rotation", + "name": "Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 14, + "match": "opacity", + "name": "Opacity", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 16, + "match": "materialOptions", + "name": "Material Options", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 17, + "match": "castsShadows", + "name": "Casts Shadows", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 18, + "match": "lightTransmission", + "name": "Light Transmission", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 19, + "match": "acceptsShadows", + "name": "Accepts Shadows", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 20, + "match": "acceptsLights", + "name": "Accepts Lights", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 21, + "match": "ambient", + "name": "Ambient", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 22, + "match": "diffuse", + "name": "Diffuse", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 23, + "match": "specularIntensity", + "name": "Specular Intensity", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 24, + "match": "specularShininess", + "name": "Specular Shininess", + "value": { + "t": "Scalar", + "v": 5.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 25, + "match": "metal", + "name": "Metal", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + } + } + ], + "markers": [], + "shutter_angle": 180.0, + "shutter_phase": -90.0, + "motion_blur_samples": 16, + "motion_blur_adaptive_limit": 128, + "renderer": "Classic3D", + "hide_shy": false, + "enable_motion_blur": true, + "enable_frame_blending": true, + "draft_3d": false, + "preserve_frame_rate": false, + "preserve_resolution": false, + "poster_time": 0, + "global_light": { + "angle": { + "uid": 0, + "match": "globalLightAngle", + "name": "Global Light Angle", + "value": { + "t": "Scalar", + "v": 120.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + "altitude": { + "uid": 0, + "match": "globalLightAltitude", + "name": "Global Light Altitude", + "value": { + "t": "Scalar", + "v": 30.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + } + } + } + }, + "next_id": 76 +} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/tracked-0.22.ecproj.attribution b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj.attribution new file mode 100644 index 000000000..611f620f9 --- /dev/null +++ b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj.attribution @@ -0,0 +1,8 @@ +asset: tracked-0.22.ecproj +title: One clip with an analysed Warp Stabilizer and 3D Camera Tracker from EffectCraft 0.22.0 +author: arnav-hiwarkar +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-12 by arnav-hiwarkar +notes: Saved by the released 0.22.0 CLI after importing a generated 160x120, 12-frame shaking crop of an ffmpeg testsrc2 pattern, applying both effects and analysing them. The source path is a generic test path; the video is not needed by the tests, which only check that the saved analyses are kept or cleared. diff --git a/crates/engine/src/tests/keyframe_text.rs b/crates/engine/src/tests/keyframe_text.rs index 30d3b436e..a299899eb 100644 --- a/crates/engine/src/tests/keyframe_text.rs +++ b/crates/engine/src/tests/keyframe_text.rs @@ -480,3 +480,29 @@ fn camera_light_and_material_options_are_written_as_after_effects_does() { assert!(text.contains(block), "{block}in\n{text}"); } } + +/// The unit column of `name`'s block in copied keyframe text. +fn unit_of<'a>(text: &'a str, name: &str) -> &'a str { + let mut lines = text.lines().skip_while(|l| !l.contains(&format!("\t{name} #"))); + lines.next().unwrap_or_else(|| panic!("no {name} in\n{text}")); + lines.next().and_then(|l| l.strip_prefix("\tFrame\t")).and_then(|l| l.strip_suffix('\t')).unwrap_or_else(|| panic!("{name} in\n{text}")) +} + +/// Widening a 0..100 slider to After Effects' range (#3479, #3483, #3484) keeps the unit the +/// keyframe text writes for it. +#[test] +fn widened_effect_ranges_keep_their_keyframe_text_units() { + let (mut s, l) = session(); + s.execute("effect.apply", json!({"layer": l.0, "effect": "ec.generate.fourcolor"})).unwrap(); + let text = copy_props(&mut s, l, &["effects/#1/blend", "effects/#1/jitter"]); + assert_eq!(unit_of(&text, "Blend"), "percent"); + assert_eq!(unit_of(&text, "Jitter"), "percent"); + for fx in ["ec.noise.noisehls", "ec.noise.noisehlsauto"] { + let (mut s, l) = session(); + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + let text = copy_props(&mut s, l, &["effects/#1/hue", "effects/#1/lightness", "effects/#1/saturation", "effects/#1/grainSize"]); + for (name, unit) in [("Hue", "percent"), ("Lightness", "percent"), ("Saturation", "percent"), ("Grain Size", "")] { + assert_eq!(unit_of(&text, name), unit, "{fx} {name}"); + } + } +} diff --git a/crates/engine/src/tests/legacy_analysis_keys.rs b/crates/engine/src/tests/legacy_analysis_keys.rs new file mode 100644 index 000000000..6cdb125d3 --- /dev/null +++ b/crates/engine/src/tests/legacy_analysis_keys.rs @@ -0,0 +1,127 @@ +//! Warp Stabilizer and 3D Camera Tracker analyses saved by an earlier release keep matching +//! their footage after an edit that doesn't change it (#3456), and a real change still clears them. + +use effectcraft_effects::camera_tracker::{SOLVE, TRACKS, TRACKS_KEY}; +use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY}; +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemId, LayerId, Project}; +use serde_json::json; + +use crate::Session; + +const FOOTAGE: ItemId = ItemId(1); +const COMP: ItemId = ItemId(2); +const CLIP: LayerId = LayerId(3); +const WARP: u64 = 26; +const TRACKER: u64 = 54; + +/// The analysis (as its length and a hash: it's long) and key of the Warp Stabilizer and the +/// 3D Camera Tracker. +fn saved(s: &Session) -> [(usize, u64, String); 2] { + use std::hash::{Hash, Hasher}; + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let get = |uid: u64, data: &str, key: &str| { + let params = crate::camera_track::static_params(layer.props.find_group(uid).unwrap()); + let text = |id: &str| match params.get(id) { + Some(Value::Str(s)) => s.clone(), + other => panic!("{id}: {other:?}"), + }; + let analysis = text(data); + let mut h = std::collections::hash_map::DefaultHasher::new(); + analysis.hash(&mut h); + (analysis.len(), h.finish(), text(key)) + }; + [get(WARP, ANALYSIS, ANALYSIS_KEY), get(TRACKER, TRACKS, TRACKS_KEY)] +} + +fn open(json: &str) -> Session { + let mut s = Session::default(); + s.replace_project(Project::from_json(json).unwrap(), None); + s.open_comp(COMP); + s +} + +#[test] +fn analyses_saved_by_0_22_survive_edits_that_keep_their_frames() { + // Saved by the released 0.22.0 CLI after analysing both effects; its footage is offline here. + let mut s = open(include_str!("fixtures/tracked-0.22.ecproj")); + let before = saved(&s); + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + for (len, _, key) in &before { + assert!(*len > 0); + // The key predates Footage::placed_page, so it isn't today's signature. + assert_ne!(key, &crate::analysis::frames_key::signature(&s.project, COMP, layer)); + } + + // Adding to the render queue edits the project, as `effectcraft-cli render` does. + s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap(); + assert_eq!(saved(&s), before); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + + // Saved and reopened (by this version), the old keys are kept and still match. + let mut s = open(&s.project.to_json()); + s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap(); + assert_eq!(saved(&s), before); + + // Other frames, from other timing or a reinterpreted footage item: both analyses are + // cleared, and undo brings them back. + let cleared = |s: &Session| saved(s).iter().all(|(len, _, key)| *len == 0 && key.is_empty()); + s.execute_checked("layer.timing", json!({"layer": CLIP.0, "out": 0.5})).unwrap(); + assert!(cleared(&s)); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute_checked("file.interpretFootage", json!({"items": [FOOTAGE.0], "invertAlpha": true})).unwrap(); + assert!(cleared(&s)); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + // Footage framed as a placed page has other frames too, though 0.22 had no such field. + s.edit("place page", None, |p, _| { + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(FOOTAGE).map(|i| &mut i.kind) { + f.placed_page = true; + } + Ok(()) + }) + .unwrap(); + assert!(cleared(&s)); +} + +/// Solving again reuses the 0.22 tracks rather than tracking the (here offline) footage again. +#[test] +fn camera_tracker_solves_again_from_tracks_saved_by_0_22() { + let mut s = open(include_str!("fixtures/tracked-0.22.ecproj")); + let tracks = saved(&s)[1].clone(); + let solve = |s: &Session| { + let g = s.project.comp(COMP).unwrap().layer(CLIP).unwrap().props.find_group(TRACKER).unwrap(); + crate::camera_track::static_params(g).get(SOLVE).cloned().unwrap() + }; + // Another solve method, and back, clears the solve and keeps the tracks. + for method in [1, 0] { + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "effects/#2/advanced/solveMethod", "value": method})).unwrap(); + } + assert_eq!(solve(&s), Value::Str(String::new())); + assert_eq!(saved(&s)[1], tracks); + s.execute_checked("camera.analyze", json!({"layer": CLIP.0, "effect": TRACKER, "wait": true})).unwrap(); + assert!(matches!(solve(&s), Value::Str(v) if !v.is_empty())); + assert_eq!(saved(&s)[1], tracks); +} + +/// The footage text analysis keys are hashed from, as 0.23 saved it. A new `Footage` field must +/// not change it (or every saved analysis is dropped again). +#[test] +fn footage_signature_text_is_pinned() { + use effectcraft_project::{Footage, FootageKind}; + let f = Footage { + path: "clip.mp4".into(), + kind: FootageKind::Video, + width: 160, + height: 120, + invert_alpha: true, + page: 2, + placed_page: true, + ..Footage::default() + }; + let fields = r#"path: "clip.mp4", kind: Video, width: 160, height: 120, pixel_aspect: 1.0, frame_rate: FrameRate { num: 30, den: 1 }, native_rate: None, duration: Tick(0), has_video: false, has_audio: false, alpha: Straight, premul_color: [0.0, 0.0, 0.0], loop_count: 1, codec: "", missing: false, sequence: [], color_profile: None, layer: None, fields: Off, invert_alpha: true, linear_light: false, preserve_rgb: false, cineon: CineonSettings { log_conversion: false, converted_black: 0.0, converted_white: 1023.0, black: 95.0, white: 685.0, gamma: 1.7, rolloff: 20.0, units: TenBit }, data: None, page: 2, alphabetical: false, start_timecode: None, file_timecode: None, markers: []"#; + assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, true), format!("Footage {{ {fields}, placed_page: true }}")); + assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, false), format!("Footage {{ {fields} }}")); +} diff --git a/crates/engine/src/tests/lut_files.rs b/crates/engine/src/tests/lut_files.rs new file mode 100644 index 000000000..98c95652a --- /dev/null +++ b/crates/engine/src/tests/lut_files.rs @@ -0,0 +1,227 @@ +//! A LUT file that changes on disk renders again (#3493): Apply Color LUT and Lumetri's Input +//! LUT name a `.cube` file; an edit to it, or a file created, deleted or repaired, shows on the +//! next render of the same frame, from warm layer caches in memory and on disk, through a precomp +//! and a layer reading that precomp, and a frame kept on disk made with the old file is refused. + +use std::path::Path; +use std::time::{Duration, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use crate::Session; +use crate::render::RenderOpts; +use effectcraft_project::ItemId; +use effectcraft_render::disk_cache::{DiskCache, comp_content_key, frame_key}; +use effectcraft_render::reads::ReadLog; +use effectcraft_time::Tick; + +/// Every input to black, or to white (1D, two entries: the same size, so only the modification +/// time tells the two apart). +const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n"; +const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n"; +const GREY: f32 = 128.0 / 255.0; + +/// Write `text` to `path` with its modification time `secs` after the epoch. +fn write(path: &Path, text: &str, secs: u64) { + std::fs::write(path, text).unwrap(); + let f = std::fs::File::options().write(true).open(path).unwrap(); + f.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap(); +} + +/// A session, its layer buffers written to a disk cache in `disk` when given. +fn session(disk: Option<&Path>) -> Session { + let mut s = Session::default(); + if let Some(dir) = disk { + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + s.disk_cache.as_ref().unwrap().set_min_layer_ms(0); + } + s +} + +/// Comp "Grade": a grey solid graded by `effect` (`params` set on it). Comp "Shot" shows it as a +/// precomp layer, under a solid that copies the precomp's channels (Set Channels): that layer is +/// cached with what the precomp shows. Returns (Grade, Shot). +fn shot(s: &mut Session, effect: &str, params: &[(&str, Value)]) -> (ItemId, ItemId) { + s.execute_checked("comp.new", json!({"name": "Shot", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); + let shot = s.active_comp_id().unwrap(); + let plate = s.execute_checked("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": plate, "effect": effect})).unwrap(); + for (path, value) in params { + s.execute_checked("prop.set", json!({"layer": plate, "path": format!("effects/#1/{path}"), "value": value})).unwrap(); + } + let pre = s.execute_checked("layer.precompose", json!({"layers": [plate], "name": "Grade"})).unwrap(); + let grade = ItemId(pre["comp"].as_u64().unwrap()); + let copy = s.execute_checked("layer.newSolid", json!({"name": "Copy", "color": "#00ff00", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": copy, "effect": "Set Channels"})).unwrap(); + for i in 1..=3 { + s.execute_checked("prop.set", json!({"layer": copy, "path": format!("effects/#1/sourceLayer{i}"), "value": pre["layer"]})).unwrap(); + } + (grade, shot) +} + +/// The red and green of the centre of both comps' first frame. +fn colours(s: &Session, (grade, shot): (ItemId, ItemId)) -> [f32; 4] { + let at = |comp| { + let img = s.render(comp, Tick::ZERO, RenderOpts::default()); + img.get(i64::from(img.width / 2), i64::from(img.height / 2)) + }; + let (g, c) = (at(grade), at(shot)); + [g[0], g[1], c[0], c[1]] +} + +#[track_caller] +fn assert_all(got: [f32; 4], want: f32) { + assert!(got.iter().all(|v| (v - want).abs() < 0.01), "{got:?}, want {want}"); +} + +#[test] +fn an_edited_lut_file_renders_again_from_warm_caches() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("grade.cube"); + write(&lut, BLACK, 1_000); + let mut s = session(None); + let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), 0.0); + let misses = s.layer_cache.stats().misses; + assert_all(colours(&s, comps), 0.0); + assert_eq!(s.layer_cache.stats().misses, misses, "an unchanged file keeps the cached layers"); + // A frame on disk made with the file names it. + let frames = DiskCache::open(dir.path().join("frames"), 1 << 30).unwrap(); + let key = frame_key(comp_content_key(&s.project, comps.1), 0, 1000, 0); + let log = ReadLog::new(); + let mut r = s.renderer(RenderOpts::default()); + r.reads = Some(&log); + let _ = r.comp_frame(comps.1, Tick::ZERO); + let files = log.into_files(); + assert!(files.get(&lut.to_string_lossy()).is_some(), "the render notes the LUT file: {files:?}"); + frames.put_frame_now(key, 1, 1, &[0; 4], &files); + assert!(frames.get_frame(key, None).is_some()); + let generation = effectcraft_effects::lut_generation(); + write(&lut, WHITE, 2_000); + assert_all(colours(&s, comps), 1.0); + assert!(effectcraft_effects::lut_generation() > generation, "the viewer's frames made before are dropped"); + assert!(frames.get_frame(key, None).is_none(), "the frame on disk made with the old file"); +} + +#[test] +fn an_edited_lut_file_is_not_served_from_the_disk_cache() { + let dir = tempfile::tempdir().unwrap(); + let (lut, disk) = (dir.path().join("grade.cube"), dir.path().join("cache")); + write(&lut, BLACK, 1_000); + let mut s = session(Some(&disk)); + let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), 0.0); + let dc = s.disk_cache.clone().unwrap(); + dc.flush(); + assert!(dc.stats().entries > 0, "layer buffers written"); + let project = s.project.clone(); + drop((s, dc)); + // A restart on the same cache, after the file changed. + write(&lut, WHITE, 2_000); + let mut s = session(Some(&disk)); + s.project = project; + s.bump(); + assert_all(colours(&s, comps), 1.0); +} + +#[test] +fn a_lut_file_created_deleted_or_repaired_renders_again() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("later.cube"); + let mut s = session(None); + let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), GREY); + write(&lut, WHITE, 1_000); + assert_all(colours(&s, comps), 1.0); + std::fs::remove_file(&lut).unwrap(); + assert_all(colours(&s, comps), GREY); + write(&lut, "not a LUT", 2_000); + assert_all(colours(&s, comps), GREY); + write(&lut, BLACK, 3_000); + assert_all(colours(&s, comps), 0.0); +} + +#[test] +fn lumetri_follows_its_input_lut_file() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("input.cube"); + write(&lut, BLACK, 1_000); + let mut s = session(None); + let comps = shot(&mut s, "Lumetri Color", &[("basicCorrection/inputLut", json!(1)), ("basicCorrection/inputLutFile", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), 0.0); + write(&lut, WHITE, 2_000); + assert_all(colours(&s, comps), 1.0); +} + +/// A precomp instance whose Essential Property names another LUT file follows that file, also +/// for a layer that reads the instance through a layer parameter. +#[test] +fn an_overridden_lut_file_renders_again() { + let dir = tempfile::tempdir().unwrap(); + let (base, overridden) = (dir.path().join("base.cube"), dir.path().join("override.cube")); + write(&base, WHITE, 1_000); + write(&overridden, BLACK, 1_000); + let mut s = session(None); + s.execute_checked("comp.new", json!({"name": "Grade", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); + let grade = s.active_comp_id().unwrap(); + let plate = s.execute_checked("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": plate, "effect": "Apply Color LUT"})).unwrap(); + s.execute_checked("prop.set", json!({"layer": plate, "path": "effects/#1/lut", "value": base.to_string_lossy()})).unwrap(); + let control = s.execute_checked("essential.addProperty", json!({"layer": plate, "path": "effects/#1/lut", "name": "LUT"})).unwrap()["controls"][0].clone(); + s.execute_checked("comp.new", json!({"name": "Shot", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); + let shot = s.active_comp_id().unwrap(); + let instance = s.execute_checked("layer.addItem", json!({"item": grade.0})).unwrap()["layer"].clone(); + s.execute_checked("essential.set", json!({"layer": instance, "control": control, "value": overridden.to_string_lossy()})).unwrap(); + let copy = s.execute_checked("layer.newSolid", json!({"name": "Copy", "color": "#00ff00", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": copy, "effect": "Set Channels"})).unwrap(); + for i in 1..=3 { + s.execute_checked("prop.set", json!({"layer": copy, "path": format!("effects/#1/sourceLayer{i}"), "value": instance})).unwrap(); + } + let centre = |s: &Session| { + let img = s.render(shot, Tick::ZERO, RenderOpts::default()); + let p = img.get(i64::from(img.width / 2), i64::from(img.height / 2)); + [p[0], p[1], p[0], p[1]] + }; + assert_all(centre(&s), 0.0); + write(&overridden, WHITE, 2_000); + assert_all(centre(&s), 1.0); +} + +/// #3538: OCIO File Transform and a custom OCIO config (and the LUT it names) follow their files +/// through warm layer caches, and a render notes the config's LUT for the disk cache. +#[test] +fn ocio_files_render_again_from_warm_caches() { + let dir = tempfile::tempdir().unwrap(); + let (lut, config, grade) = (dir.path().join("file.cube"), dir.path().join("config.ocio"), dir.path().join("luts/grade.cube")); + std::fs::create_dir_all(dir.path().join("luts")).unwrap(); + write(&lut, BLACK, 1_000); + write(&grade, BLACK, 1_000); + let text = "ocio_profile_version: 2\nsearch_path: luts\nroles:\n scene_linear: lin\ncolorspaces:\n - !\n name: lin\n - !\n name: graded\n to_reference: ! {src: grade.cube, interpolation: linear}\n"; + write(&config, text, 1_000); + let mut s = session(None); + let file = shot(&mut s, "OCIO File Transform", &[("file", json!(lut.to_string_lossy()))]); + let custom = shot( + &mut s, + "OCIO Color Space Transform", + &[("config", json!(1)), ("configFile", json!(config.to_string_lossy())), ("sourceName", json!("graded")), ("destinationName", json!("lin"))], + ); + // A render notes the LUT the config names (a frame on disk is made of it too). + let log = ReadLog::new(); + let mut r = s.renderer(RenderOpts::default()); + r.reads = Some(&log); + let _ = r.comp_frame(custom.1, Tick::ZERO); + let files = log.into_files(); + assert!(files.get(&grade.to_string_lossy()).is_some(), "the render notes the config's LUT: {files:?}"); + assert_all(colours(&s, file), 0.0); + assert_all(colours(&s, custom), 0.0); + let misses = s.layer_cache.stats().misses; + assert_all(colours(&s, file), 0.0); + assert_all(colours(&s, custom), 0.0); + assert_eq!(s.layer_cache.stats().misses, misses, "unchanged files keep the cached layers"); + let generation = effectcraft_effects::lut_generation(); + write(&lut, WHITE, 2_000); + assert_all(colours(&s, file), 1.0); + write(&grade, WHITE, 2_000); + assert_all(colours(&s, custom), 1.0); + assert!(effectcraft_effects::lut_generation() > generation, "the viewer's frames made before are dropped"); +} diff --git a/crates/engine/src/tests/mask_warp.rs b/crates/engine/src/tests/mask_warp.rs index 5387fe562..e884091a5 100644 --- a/crates/engine/src/tests/mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -391,6 +391,15 @@ fn warp_analysis_invalidates_and_cancels() { let fx = r["effect"].as_u64().unwrap(); let cid = s.active_comp_id().unwrap(); assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true); + // A mask is part of the analysed input (#3535): drawing one clears the analysis, undo keeps it. + s.execute_checked("mask.new", json!({"layer": clip.0, "vertices": [[10, 10], [200, 10], [200, 150]]})).unwrap(); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true); + // An effect below it isn't. + s.execute_checked("effect.apply", json!({"layers": [clip.0], "effect": "ec.blur.gaussian"})).unwrap(); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true); + s.execute_checked("edit.undo", json!({})).unwrap(); // Trimming the layer clears the analysis and queues it again. s.execute("layer.timing", json!({"layers": [clip.0], "out": 0.8})).unwrap(); let st = s.execute("warp.status", json!({})).unwrap(); diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 99c155265..e751681c6 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -603,6 +603,35 @@ fn object_matte_background_job_cancel_and_freeze() { assert_eq!(status(&mut s)["frozen"], true); } +/// A queue edit preserves a frozen matte made before the footage signature gained placed_page. +#[test] +fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { + use effectcraft_effects::roto::{FROZEN, INPUT_KEY}; + use effectcraft_keyframe::Value::Str; + + hold_roto_cache_test_lock(); + // Saved by the released 0.22.0 CLI, independently of the compatibility helper. + let project = effectcraft_project::Project::from_json(include_str!("fixtures/object-matte-0.22.ecproj")).unwrap(); + let mut s = Session::default(); + s.replace_project(project, None); + let cid = effectcraft_project::ItemId(1); + let clip = LayerId(3); + let uid = 26; + s.open_comp(cid); + let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); + let legacy = params(&s).get(INPUT_KEY).unwrap().clone(); + let saved = params(&s).get(FROZEN).unwrap().clone(); + let layer = s.project.comp(cid).unwrap().layer(clip).unwrap(); + assert_ne!(legacy, Str(crate::roto::input_key(&s.project, cid, layer, uid))); + + s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); + assert_eq!(params(&s).get(INPUT_KEY), Some(&legacy)); + assert_eq!(params(&s).get(FROZEN), Some(&saved)); + + s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.05})).unwrap(); + assert_eq!(params(&s).get(FROZEN), Some(&Str(String::new()))); +} + /// Computed Object Matte segmentations go to the persistent disk cache: a second session on the /// same folder and project gets them back without segmenting (its frames cannot even be read /// here), and another prompt (other keys) finds nothing. diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index b253640e7..020c2daf0 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -96,6 +96,25 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() { assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y"); } +/// What an import left out of a file (a 3D model's textures over the limits) comes back in the +/// import's `warnings`. +#[test] +fn what_an_import_left_out_is_a_warning() { + use effectcraft_project::{Footage, FootageKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + Ok(Footage { path: path.into(), kind: FootageKind::Model, ..Default::default() }) + } + fn probe_noting(&self, path: &str) -> Result<(Footage, Option), String> { + Ok((self.probe(path)?, Some(format!("{path}: texture `a.png` left out")))) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let r = s.execute("file.import", json!({"paths": ["/m/scene.obj"]})).unwrap(); + assert_eq!(r["warnings"], json!(["/m/scene.obj: texture `a.png` left out"])); +} + /// #270: files dropped or picked in the Import dialog (`file.import {background}`) are probed in /// a background job that says which file it is reading and how many are left; then they arrive /// in one undo step, all selected, with a count. The same file imported again arrives again. diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index b37c5ca3e..42bcefc97 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -382,6 +382,127 @@ fn new_layers_start_at_the_current_time_when_asked() { assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0); } +/// Settings ▸ General ▸ Fit New Layers to Work Area: new text, solid, shape, null and adjustment +/// layers span the work area, ahead of the start-time setting; footage keeps its own length. +#[test] +fn new_layers_fit_the_work_area_when_asked() { + let mut s = session_with_comp(); + s.execute("comp.workArea", json!({"start": 2, "end": 5})).unwrap(); + let span = |s: &Session| { + let l = &s.active_comp().unwrap().layers[0]; + let r = |t: effectcraft_time::Tick| (t.seconds() * 10.0).round() / 10.0; + (r(l.start_time), r(l.in_point), r(l.out_point)) + }; + s.execute("layer.newSolid", json!({})).unwrap(); + assert_eq!(span(&s), (0.0, 0.0, 10.0), "off by default"); + + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap(); + // The tools that draw on their new layer (Type, shape tools, Pen) move the playhead into it; + // the menu commands leave it. + for (cmd, p, draws) in [ + ("layer.newSolid", json!({}), false), + ("layer.newAdjustment", json!({}), false), + ("layer.newNull", json!({}), false), + ("layer.newText", json!({"text": "a"}), false), + ("layer.newText", json!({"text": "a", "edit": true}), true), + ("layer.newShape", json!({"kind": "rect"}), false), + ("shape.newShape", json!({"kind": "ellipse"}), true), + ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}), true), + ] { + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + assert_eq!(span(&s), (2.0, 2.0, 5.0), "{cmd}"); + assert_eq!(s.time().seconds().round(), if draws { 2.0 } else { 7.0 }, "{cmd} playhead"); + } + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + + let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 3, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + assert_eq!(span(&s), (7.0, 7.0, 10.0), "footage and comps keep their own timing"); +} + +/// A tool command aimed at another comp moves that comp's playhead into its work area and +/// leaves the active comp's playhead where it was. +#[test] +fn fitted_layers_in_another_comp_move_that_comps_playhead() { + let mut s = session_with_comp(); + let active = s.active_comp_id().unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + let other = effectcraft_project::ItemId(s.execute("comp.new", json!({"name": "Other", "duration": 10, "open": false})).unwrap()["comp"].as_u64().unwrap()); + s.execute("comp.workArea", json!({"comp": other.0, "start": 2, "end": 5})).unwrap(); + s.execute("comp.workArea", json!({"start": 8, "end": 9})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + for (cmd, p) in [ + ("layer.newText", json!({"comp": other.0, "text": "a", "edit": true})), + ("shape.newShape", json!({"comp": other.0, "kind": "ellipse"})), + ("shape.newPath", json!({"comp": other.0, "vertices": [[0, 0], [10, 0], [10, 10]]})), + ] { + s.set_time_of(other, effectcraft_time::Tick::from_seconds_f64(7.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + assert_eq!(s.time_of(other).seconds().round(), 2.0, "{cmd}: the target comp's playhead"); + assert_eq!(s.time_of(active).seconds().round(), 7.0, "{cmd}: the active comp's playhead"); + } +} + +/// A work area off the frame grid (set at 25 fps, then the comp went to 30 fps): the playhead +/// goes to the first frame inside it, not the nearest frame before its start. +#[test] +fn fitted_layers_off_the_frame_grid_move_the_playhead_inside_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Grid", "duration": 2, "frameRate": 25})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 0.04, "end": 0.08})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 30})).unwrap(); + for (cmd, p) in [("layer.newText", json!({"text": "a", "edit": true})), ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}))] { + s.set_time(effectcraft_time::Tick::from_seconds_f64(1.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{cmd}: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0 / 30.0), "{cmd}: the first frame inside"); + } +} + +/// A duration off the frame grid (2.01 s set at 100 fps, then 24 fps) keeps its final partial +/// frame reachable: a layer fitted to a work area inside it gets the playhead on that frame. +#[test] +fn fitted_layers_in_the_final_partial_frame_get_the_playhead() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "duration": 2.01, "frameRate": 100})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 2.0, "end": 2.01})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 24})).unwrap(); + s.set_time(effectcraft_time::Tick::ZERO); + s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0), "frame 48 of 24 fps"); + // The playhead can't go past that last frame. + s.set_time(effectcraft_time::Tick::from_seconds_f64(5.0)); + assert_eq!(s.time(), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + +/// Synchronize Time of All Related Items reaches a nested comp's final partial frame as a direct +/// move does: the inner comp is 2.01 s (set at 100 fps, then 24 fps), the outer sits at 2.0 s. +#[test] +fn synced_time_reaches_a_nested_final_partial_frame() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 2.01, "frameRate": 100, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.settings", json!({"comp": inner, "frameRate": 24})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "duration": 4, "frameRate": 24})).unwrap(); + s.execute("layer.addItem", json!({"item": inner, "time": 0})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0)); + assert_eq!(s.time_of(effectcraft_project::ItemId(inner)), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); @@ -667,6 +788,26 @@ fn context_shortcuts_on_the_same_keys_conflict() { assert!(t.conflicts(b, "F9").iter().any(|c| c.key == "test.paint" && !c.overrides)); } +/// A preset repeating one binding many times lists it once, and conflicts stay bounded. +#[test] +fn repeated_bindings_are_one_binding() { + let mut s = Session::default(); + s.set_ui_commands(vec![ + crate::shortcuts::UiCommand { id: "test.a".into(), label: "A".into(), shortcut: Some("F9".into()), scope: Some("Timeline".into()) }, + crate::shortcuts::UiCommand { id: "test.b".into(), label: "B".into(), shortcut: Some("F9".into()), scope: Some("Timeline".into()) }, + ]); + let mut t = s.shortcuts().clone(); + t.keys.insert("test.a".into(), vec!["F9".into(); 10_000]); + assert_eq!(t.bindings().iter().filter(|(_, b)| b.key == "test.a").count(), 1); + assert_eq!(t.all_conflicts().iter().filter(|(a, c)| a == "test.a" && c.key == "test.b").count(), 1); + // Many commands on one key: the listing stops at its cap. + for i in 0..200 { + t.bindables.push(crate::shortcuts::Bindable { key: format!("test.many{i}"), ..t.find("test.b").unwrap().clone() }); + t.keys.insert(format!("test.many{i}"), vec!["F9".into()]); + } + assert_eq!(t.all_conflicts().len(), crate::shortcuts::MAX_CONFLICTS); +} + // ---------------------------------------------------------------- auto-save / recovery /// The names in `d`, sorted. diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 55475eef3..291ad884e 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -649,6 +649,31 @@ fn time_set_keeps_the_frame_shown_in_the_current_time_field() { assert_eq!(r["frame"], 13); } +/// A comp that starts before 0 shows its first frames as negative timecode (`-0:00:00:12`). +/// Typing back what the current-time display shows (Go to Time opens with it) stays on that frame; +/// the leading `-` used to move back from the current frame instead. A bare `-n` still steps back, +/// and in a comp that starts at 0 a `-` stays relative. +#[test] +fn time_set_round_trips_negative_displayed_timecode() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Pre-roll", "width": 64, "height": 64, "frameRate": 24, "duration": 4, "startTime": -1})).unwrap(); + for frame in [0_i64, 1, 12, 23, 24, 25, 60] { + let r = s.execute("time.set", json!({"frame": frame})).unwrap(); + let display = r["display"].as_str().unwrap().to_string(); + let r = s.execute("time.set", json!({"timecode": display})).unwrap(); + assert_eq!(r["frame"], frame, "displayed {display}"); + } + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:12"})).unwrap()["display"], "-0:00:00:12"); + assert_eq!(s.execute("time.set", json!({"timecode": "-5"})).unwrap()["frame"], 7, "a bare -n steps back"); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:20"})).unwrap()["frame"], 4); + assert!(s.execute("time.set", json!({"timecode": "- -1:00"})).is_err(), "a second sign is still an error"); + + s.execute("comp.settings", json!({"startTime": 0})).unwrap(); + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:02"})).unwrap()["frame"], 10, "relative when the comp starts at 0"); +} + /// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530). #[test] fn time_set_reads_frames_style_digits_as_a_frame_count() { @@ -1556,3 +1581,28 @@ fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() { let keys: Vec> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect(); assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels"); } + +#[test] +fn convert_expression_to_keyframes_refuses_a_layer_of_millions_of_frames() { + let (mut s, l) = setup(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/rotation", "expression": "value"})).unwrap(); + // A saved Out point a million seconds after the In point. + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(l)).unwrap().out_point = + effectcraft_time::Tick::from_seconds_f64(1e6); + let e = s.execute("prop.convertExpressionToKeyframes", json!({"layer": l, "path": "transform/rotation"})).unwrap_err().to_string(); + assert!(e.contains("at most"), "{e}"); + assert!(prop(&s, l, "transform/rotation").keys.is_empty()); +} + +#[test] +fn convert_expression_to_keyframes_refuses_a_zero_frame_duration() { + let (mut s, l) = setup(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/rotation", "expression": "value"})).unwrap(); + // A saved frame rate of 0/1 s has a frame duration of 0: the sampling loop never advanced. + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().frame_rate.den = 0; + let e = s.execute("prop.convertExpressionToKeyframes", json!({"layer": l, "path": "transform/rotation"})).unwrap_err().to_string(); + assert!(e.contains("frame duration"), "{e}"); + assert!(prop(&s, l, "transform/rotation").keys.is_empty()); +} diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 10c07c2ad..6372ffb35 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -320,6 +320,41 @@ fn paste_svg_markup_as_a_shape_layer() { assert_eq!(s.active_comp().unwrap().layers.len(), 1); } +#[test] +fn pasted_svg_pattern_keeps_geometry_and_editable_fallback_paint() { + let (mut s, _) = text_session(); + let svg = r##" + + + + + + + + "##; + let result = s.execute_checked("edit.pasteSvg", json!({"svg": svg})).unwrap(); + let layer_id = effectcraft_project::LayerId(result["layer"].as_u64().unwrap()); + let layer = s.active_comp().unwrap().layer(layer_id).unwrap(); + let shape = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned").unwrap(); + let items: Vec<_> = shape.sub("contents").unwrap().groups().collect(); + assert!(items.iter().any(|item| item.match_id == "rect"), "the editable geometry remains"); + let fill = items.iter().find(|item| item.match_id == "fill").expect("fallback fill remains editable"); + assert_eq!(fill.get("color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 0.5])); + let stroke = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-stroke").unwrap(); + let items: Vec<_> = stroke.sub("contents").unwrap().groups().collect(); + assert!(items.iter().any(|item| item.match_id == "path"), "the editable stroke path remains"); + let stroke = items.iter().find(|item| item.match_id == "stroke").expect("fallback stroke remains editable"); + assert_eq!(stroke.get("color").unwrap().value, KV::Color([0.0, 0.0, 1.0, 0.25])); + let no_fallback = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-no-fallback").unwrap(); + let items: Vec<_> = no_fallback.sub("contents").unwrap().groups().collect(); + assert!(items.iter().any(|item| item.match_id == "rect"), "the editable geometry remains without a fallback"); + assert!(!items.iter().any(|item| item.match_id == "fill"), "pattern paint without a fallback is omitted"); + let no_fallback_stroke = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-stroke-no-fallback").unwrap(); + let items: Vec<_> = no_fallback_stroke.sub("contents").unwrap().groups().collect(); + assert!(items.iter().any(|item| item.match_id == "path"), "the editable stroke geometry remains without a fallback"); + assert!(!items.iter().any(|item| item.match_id == "stroke"), "pattern stroke without a fallback is omitted"); +} + #[test] fn paste_svg_rejects_text_that_is_not_usable_svg() { let (mut s, _) = text_session(); @@ -383,3 +418,58 @@ fn psd_with_damaged_layer_section_imports_the_flattened_image() { let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; assert!(f.layer.is_none(), "the merged image, not a Photoshop layer"); } + +/// A vector footage thumbnail is drawn at the thumbnail's size, never at its declared size. +#[test] +fn vector_footage_thumbnails_are_drawn_at_thumbnail_size() { + use std::sync::{Arc, Mutex}; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Vector(Mutex>); + impl crate::render::FootageSource for Vector { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + panic!("the full-size frame was decoded"); + } + fn vector_frame(&self, _: ItemId, f: &Footage, scale: f64) -> Option> { + self.0.lock().unwrap().push(scale); + Some(Arc::new(Image::new((f.width as f64 * scale).ceil() as u32, (f.height as f64 * scale).ceil() as u32))) + } + } + let source = Arc::new(Vector(Mutex::new(vec![]))); + let mut s = Session { footage: source.clone(), ..Default::default() }; + let f = Footage { path: "/m/huge.svg".into(), codec: "SVG".into(), width: 16_000, height: 8_000, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("huge.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, _) = (s.thumbnail_job(item, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!((w, h), (64, 32)); + assert_eq!(*source.0.lock().unwrap(), [64.0 / 16_000.0]); +} + +/// A vector footage thumbnail whose drawn size is a pixel over the requested one is shrunk by +/// that pixel, not halved by an integer step. +#[test] +fn a_vector_thumbnail_one_pixel_over_is_not_halved() { + use std::sync::Arc; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Over; + impl crate::render::FootageSource for Over { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn vector_frame(&self, _: ItemId, _: &Footage, _: f64) -> Option> { + Some(Arc::new(Image::new(64, 64))) + } + } + let mut s = Session { footage: Arc::new(Over), ..Default::default() }; + let f = Footage { path: "/m/sq.svg".into(), codec: "SVG".into(), width: 121, height: 121, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("sq.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, px) = (s.thumbnail_job(item, Tick::ZERO, 63).unwrap())().unwrap(); + assert_eq!((w, h), (63, 63)); + assert_eq!(px.len(), 63 * 63 * 4); +} diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 31197b2a8..a356aa192 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -346,6 +346,263 @@ fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePat l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone() } +#[test] +fn toggle_closed_respects_path_and_vertex_selection() { + let mut s = comp(); + let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let lid = a["layer"].as_u64().unwrap(); + let a = a["path"].as_u64().unwrap(); + let b = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap()["path"].as_u64().unwrap(); + let leaf = layer(&s, lid).props.find_group(a).unwrap().get("path").unwrap().uid; + s.state.selected_vertices.clear(); + s.state.selected_props = vec![(LayerId(lid), leaf)]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, lid, a).closed); + assert!(shape_path(&s, lid, b).closed, "an unselected sibling stays closed"); + s.state.selected_props = vec![(LayerId(lid), b)]; + s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(lid), mask: a, index: 1 }]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(shape_path(&s, lid, a).closed, "selected points and properties both describe target paths"); + assert!(shape_path(&s, lid, b).closed); + s.state.selected_vertices.clear(); + s.state.selected_props = vec![(LayerId(lid), a), (LayerId(lid), leaf), (LayerId(lid), b)]; + let r = s.execute("path.toggleClosed", json!({})).unwrap(); + assert_eq!(r["paths"], json!(2)); + assert!(!shape_path(&s, lid, a).closed); + assert!(!shape_path(&s, lid, b).closed); + s.execute("edit.undo", json!({})).unwrap(); + assert!(shape_path(&s, lid, a).closed); + assert!(shape_path(&s, lid, b).closed, "all paths undo in one step"); + s.execute("mask.setClosed", json!({"layer": lid, "mask": a, "closed": false})).unwrap(); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(false), "a mixed selection is unchecked"); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(shape_path(&s, lid, a).closed); + assert!(shape_path(&s, lid, b).closed, "a mixed selection closes all paths"); + s.state.selected_props.clear(); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, lid, a).closed); + assert!(!shape_path(&s, lid, b).closed, "layer selection addresses all editable paths"); +} + +#[test] +fn toggle_closed_combines_property_and_shape_group_selections() { + let mut s = comp(); + let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (lid, a_path) = (a["layer"].as_u64().unwrap(), a["path"].as_u64().unwrap()); + let b = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap(); + let (b_path, b_group) = (b["path"].as_u64().unwrap(), b["group"].as_u64().unwrap()); + let c_path = + s.execute("shape.newPath", json!({"layer": lid, "vertices": [[20, 20], [80, 20], [80, 80]], "closed": true})).unwrap()["path"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": lid, "prop": a_path})).unwrap(); + s.execute("prop.select", json!({"layer": lid, "prop": b_group, "add": true})).unwrap(); + assert!(s.state.selected_vertices.iter().all(|v| v.mask == a_path), "only the selected path group implies selected vertices"); + assert!(!s.state.selected_vertices.is_empty()); + assert!(s.state.selected_props.contains(&(LayerId(lid), b_group))); + let result = s.execute("path.toggleClosed", json!({})).unwrap(); + assert_eq!(result["paths"], json!(2)); + assert!(!shape_path(&s, lid, a_path).closed); + assert!(!shape_path(&s, lid, b_path).closed, "the added shape group is included alongside implied vertices"); + assert!(shape_path(&s, lid, c_path).closed, "unselected sibling stays unchanged"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(shape_path(&s, lid, a_path).closed); + assert!(shape_path(&s, lid, b_path).closed, "both selected paths undo together"); + assert!(shape_path(&s, lid, c_path).closed); +} + +#[test] +fn toggle_closed_ignores_selections_retained_on_other_layers() { + let mut s = comp(); + let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (a_layer, a_path) = (a["layer"].as_u64().unwrap(), a["path"].as_u64().unwrap()); + let b_layer = s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.state.selected_vertices.iter().any(|v| v.layer == LayerId(a_layer)), "insertion retains old vertices"); + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert!(shape_path(&s, a_layer, a_path).closed); + + let b_path = + s.execute("shape.newPath", json!({"layer": b_layer, "vertices": [[0, 0], [50, 0], [50, 50]], "closed": true})).unwrap()["path"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": b_layer, "prop": b_path})).unwrap(); + s.execute("layer.selectNext", json!({})).unwrap(); + assert_eq!(s.state.selected_layers, vec![LayerId(a_layer)]); + assert!(s.state.selected_vertices.iter().any(|v| v.layer == LayerId(b_layer)), "navigation retains old vertices"); + assert!(s.state.selected_props.contains(&(LayerId(b_layer), b_path))); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(true)); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, a_layer, a_path).closed); + assert!(shape_path(&s, b_layer, b_path).closed, "the deselected layer stays unchanged"); + s.execute("layer.selectPrevious", json!({})).unwrap(); + assert_eq!(s.state.selected_layers, vec![LayerId(b_layer)]); + s.execute("prop.select", json!({"layer": b_layer, "path": "transform/position"})).unwrap(); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(true), "a non-path property falls back to the layer's paths"); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, b_layer, b_path).closed); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.selectNext", json!({})).unwrap(); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(shape_path(&s, a_layer, a_path).closed, "a property retained on another layer cannot block this layer"); + assert!(shape_path(&s, b_layer, b_path).closed); +} + +#[test] +fn toggle_closed_falls_back_from_stale_selection_and_rejects_locked_or_pathless_layers() { + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (lid, uid) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + let before = s.project.clone(); + s.state.selected_vertices.clear(); + s.state.selected_props = vec![(LayerId(lid), u64::MAX)]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, lid, uid).closed, "a stale property selection falls back to the layer's paths"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); + s.state.selected_props.clear(); + s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(lid), mask: uid, index: usize::MAX }]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!shape_path(&s, lid, uid).closed, "a stale vertex selection falls back to the layer's paths"); + s.execute("edit.undo", json!({})).unwrap(); + s.state.selected_vertices.clear(); + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap().switches.locked = true; + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert!(shape_path(&s, lid, uid).closed); + s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); + s.state.selected_props.clear(); + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); +} + +#[test] +fn toggle_closed_handles_masks_and_preserves_explicit_set_closed() { + let mut s = comp(); + let lid = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.execute("mask.new", json!({"layer": lid, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap(); + let path = |s: &Session| layer(s, lid).props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + let original = path(&s); + let other = s.execute("mask.new", json!({"layer": lid, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap()["mask"].as_u64().unwrap(); + let other_closed = |s: &Session| layer(s, lid).props.find_group(other).unwrap().get("path").unwrap().value.as_path().unwrap().closed; + let feather = layer(&s, lid).props.find_group(uid).unwrap().get("feather").unwrap().uid; + s.state.selected_vertices.clear(); + s.state.selected_props = vec![(LayerId(lid), feather)]; + s.execute("path.toggleClosed", json!({})).unwrap(); + assert!(!path(&s).closed, "selecting Mask Feather selects its mask"); + assert!(other_closed(&s), "an unselected mask stays closed"); + assert!(layer(&s, lid).props.find_group(uid).unwrap().get("path").unwrap().keys.is_empty(), "a static path gains no keyframe"); + s.state.selected_props = vec![(LayerId(lid), uid)]; + s.execute("mask.setClosed", json!({"layer": lid, "mask": uid})).unwrap(); + assert_eq!(path(&s), original, "explicit API still defaults to closed"); + s.execute("mask.setClosed", json!({"layer": lid, "mask": uid, "closed": false})).unwrap(); + assert!(!path(&s).closed); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert_eq!(path(&s), original); + let cid = s.active_comp_id().unwrap(); + let g = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap().props.find_group_mut(uid).unwrap(); + if let effectcraft_project::GroupKind::Mask { locked, .. } = &mut g.kind { + *locked = true; + } + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert_eq!(path(&s), original); + assert!(other_closed(&s), "a selected locked mask does not fall back to its unlocked siblings"); +} + +#[test] +fn toggle_closed_recomputes_roto_bezier_tangents() { + let mut s = comp(); + let lid = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap(); + let uid = + s.execute("mask.new", json!({"layer": lid, "vertices": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("path.rotoBezier", json!({"layer": lid, "value": true})).unwrap(); + let path = |s: &Session| layer(s, lid).props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + let closed = path(&s); + assert_ne!(closed.out_tangents[0], [0.0; 2], "a closed RotoBezier path has tangents at every vertex"); + s.state.selected_props = vec![(LayerId(lid), uid)]; + s.execute("path.toggleClosed", json!({})).unwrap(); + let open = path(&s); + assert!(!open.closed); + assert_eq!((open.in_tangents[0], open.out_tangents[3]), ([0.0; 2], [0.0; 2]), "open ends lose their automatic tangents"); + assert_ne!(open.out_tangents[1], [0.0; 2]); + s.execute("path.toggleClosed", json!({})).unwrap(); + assert_eq!(path(&s), closed, "reclosing restores the closed RotoBezier tangents"); +} + +#[test] +fn toggle_closed_fits_short_tangent_lists() { + let mut s = comp(); + let lid = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.execute("mask.new", json!({"layer": lid, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let layer_mut = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap(); + let prop = layer_mut.props.find_group_mut(uid).unwrap().get_mut("path").unwrap(); + let KV::Path(path) = &mut prop.value else { panic!("not a path") }; + // A file can store fewer tangents than vertices (#1250). + path.in_tangents.clear(); + path.out_tangents.truncate(1); + s.state.selected_props = vec![(LayerId(lid), uid)]; + s.execute("path.toggleClosed", json!({})).unwrap(); + let path = layer(&s, lid).props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + assert!(!path.closed); + assert_eq!((path.in_tangents.len(), path.out_tangents.len()), (3, 3)); +} + +#[test] +fn toggle_closed_edits_layer_time_and_roundtrips() { + use effectcraft_keyframe::Keyframe; + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let (lid, uid) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + let cid = s.active_comp_id().unwrap(); + let original = shape_path(&s, lid, uid); + let second = Tick::from_seconds_f64(1.0); + let layer = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap(); + layer.start_time = second; + layer.stretch = 200.0; + let prop = layer.props.find_group_mut(uid).unwrap().get_mut("path").unwrap(); + prop.keys = vec![Keyframe::new(Tick::ZERO, KV::Path(original.clone())), Keyframe::new(second, KV::Path(original.clone()))]; + let keys = prop.keys.clone(); + s.execute("time.set", json!({"time": 3.0})).unwrap(); + s.execute("path.toggleClosed", json!({})).unwrap(); + let prop = s.project.comp(cid).unwrap().layer(LayerId(lid)).unwrap().props.find_group(uid).unwrap().get("path").unwrap(); + assert_eq!(prop.keys.len(), 2); + assert_eq!(prop.keys[0], keys[0], "the other keyframe is untouched"); + let mut opened = original; + opened.closed = false; + assert_eq!(prop.value_at(second), KV::Path(opened.clone())); + assert!(prop.value_at(Tick::from_seconds_f64(0.5)).as_path().unwrap().closed, "the earlier segment keeps its closedness"); + let bytes = serde_json::to_vec(&*s.project).unwrap(); + let restored: effectcraft_project::Project = serde_json::from_slice(&bytes).unwrap(); + let prop = restored.comp(cid).unwrap().layer(LayerId(lid)).unwrap().props.find_group(uid).unwrap().get("path").unwrap(); + assert_eq!(prop.value_at(second), KV::Path(opened)); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(true)); + s.execute("time.set", json!({"time": 3.0})).unwrap(); + assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(false)); +} + +#[test] +fn toggle_closed_rejects_excessive_group_nesting_without_an_edit() { + use effectcraft_project::build::{self, Ids}; + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + let lid = LayerId(r["layer"].as_u64().unwrap()); + let cid = s.active_comp_id().unwrap(); + let project = std::sync::Arc::make_mut(&mut s.project); + let mut next = project.next_id; + let contents = project.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.sub_mut("contents").unwrap(); + let node = contents.children.first_mut().unwrap(); + let mut nested = node.as_group().unwrap().clone(); + for _ in 0..130 { + nested = build::shape_group(&mut Ids(&mut next), "Nested", vec![nested]); + } + *node = effectcraft_project::Node::Group(nested); + project.next_id = next; + let before = s.project.clone(); + assert!(!s.is_enabled("path.toggleClosed")); + assert!(s.execute("path.toggleClosed", json!({})).is_err()); + assert!(s.project.same_content(&before)); +} + #[test] fn shape_pen_builds_shape_group_and_renders() { let mut s = comp(); diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index c477d2787..164b10a7f 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-export" -description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR/Targa sequences, animated GIF, WAV/AIFF" +description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) , HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1) via craft-libs codecs, Opus via effectcraft-opusenc, PNG/JPEG/TIFF/EXR/Targa sequences, animated GIF, WAV/AIFF" version.workspace = true edition.workspace = true license.workspace = true @@ -14,14 +14,10 @@ effectcraft-time = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } -filmcraft-h264enc = { workspace = true } -filmcraft-prores = { workspace = true } -filmcraft-aac = { workspace = true } -filmcraft-isobmff = { workspace = true } +craft-video = { workspace = true } +craft-audio = { workspace = true } +craft-media = { workspace = true } effectcraft-opusenc = { workspace = true } -effectcraft-vp9enc = { workspace = true } -effectcraft-hevcenc = { workspace = true } -effectcraft-av1enc = { workspace = true } image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] } exr = { workspace = true } gif = { workspace = true } @@ -31,9 +27,11 @@ thiserror = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } effectcraft-media = { workspace = true } effectcraft-effects = { workspace = true } effectcraft-color = { workspace = true } -filmcraft-opus = { workspace = true } +filmcraft-hevc = { workspace = true } +filmcraft-av1 = { workspace = true } filmcraft-vp9 = { workspace = true } effectcraft-keyframe = { workspace = true } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index dd972d326..8caad8dea 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -1,13 +1,13 @@ -//! Movie export: FilmCraft H.264, HEVC (`effectcraft-hevcenc`) and AV1 (`effectcraft-av1enc`) +//! Movie export: H.264 (`craft_video::h264`), HEVC (`craft_video::hevc`) and AV1 (`craft_video::av1`) //! → MP4 (+ AAC), ProRes and uncompressed `raw ` → MOV (+ PCM), muxed by -//! FilmCraft's ISO BMFF / QuickTime writer. +//! craft-media's ISO BMFF / QuickTime writer. use std::io::Write; +use craft_media::isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile}; use effectcraft_project::{Comp, ProjectSettings}; use effectcraft_time::{FrameRate, Tick}; -use filmcraft_isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio}; @@ -117,7 +117,7 @@ pub(crate) trait VideoEncoder: Send { // ---------------------------------------------------------------- H.264 struct H264 { - enc: filmcraft_h264enc::Encoder, + enc: craft_video::h264::Encoder, w: u32, h: u32, rate: FrameRate, @@ -129,26 +129,26 @@ struct H264 { impl H264 { fn new(w: u32, h: u32, rate: FrameRate, kbps: u32, codes: Codes) -> Result { - let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); - cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; + let mut cfg = craft_video::h264::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); + cfg.format = craft_video::h264::PacketFormat::LengthPrefixed; cfg.aud = false; cfg.keyint = (rate.as_f64() * 2.0).round().max(1.0) as u32; let kbps = kbps.max(100); - cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 }; - cfg.color = filmcraft_h264enc::ColorConfig { primaries: codes.primaries, transfer: codes.transfer, matrix: codes.matrix, full_range: false }; - let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?; + cfg.rate = craft_video::h264::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 }; + cfg.color = craft_video::h264::ColorConfig { primaries: codes.primaries, transfer: codes.transfer, matrix: codes.matrix, full_range: false }; + let enc = craft_video::h264::Encoder::new(cfg).map_err(enc)?; Ok(H264 { enc, w, h, rate, codes, y: vec![], u: vec![], v: vec![] }) } - fn packets(&self, ps: Vec) -> Vec { + fn packets(&self, ps: Vec) -> Vec { ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect() } } impl VideoEncoder for H264 { fn sample_entry(&self) -> SampleEntry { - let cfg = filmcraft_isobmff::AvcConfig::parse(&self.enc.avcc()).unwrap_or_else(|_| { + let cfg = craft_media::isobmff::AvcConfig::parse(&self.enc.avcc()).unwrap_or_else(|_| { let (sps, pps) = self.enc.sps_pps(); - filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4) + craft_media::isobmff::AvcConfig::new(vec![sps], vec![pps], 4) }); let mut entry = SampleEntry::avc(cfg, self.w as u16, self.h as u16); tag_nclx_limited(&mut entry, self.codes); @@ -170,7 +170,7 @@ impl VideoEncoder for H264 { fn encode_yuv420(&mut self, p: &Yuv420, index: u64) -> Result> { let (w, cw) = (self.w as usize, (self.w as usize).div_ceil(2)); let (y, u, v) = p.slices(w, self.h as usize); - let frame = filmcraft_h264enc::YuvFrame { y, u, v, y_stride: w, uv_stride: cw }; + let frame = craft_video::h264::YuvFrame { y, u, v, y_stride: w, uv_stride: cw }; let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?; Ok(self.packets(ps)) } @@ -188,8 +188,8 @@ pub(crate) use effectcraft_raster::yuv::rgba_to_yuv420; // ---------------------------------------------------------------- ProRes struct ProRes { - enc: filmcraft_prores::Encoder, - profile: filmcraft_prores::Profile, + enc: craft_video::prores::Encoder, + profile: craft_video::prores::Profile, w: u32, h: u32, alpha: bool, @@ -202,8 +202,8 @@ fn prores_colr(c: Codes) -> ColorInfo { ColorInfo::Nclc { primaries: c.primaries.into(), transfer: c.transfer.into(), matrix: c.matrix.into() } } -fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile { - use filmcraft_prores::Profile; +fn prores_profile(p: ProResProfile) -> craft_video::prores::Profile { + use craft_video::prores::Profile; match p { ProResProfile::Proxy => Profile::Proxy, ProResProfile::Lt => Profile::Lt, @@ -220,18 +220,18 @@ impl ProRes { let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile }; let profile = prores_profile(profile); let alpha = channels == Channels::Rgba; - let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h); + let mut cfg = craft_video::prores::EncoderConfig::new(profile, w, h); cfg.encode_alpha = alpha; // The frame header carries the same H.273 codes as the `colr` atom, so readers that trust // either one agree. if let ColorInfo::Nclc { primaries, transfer, matrix } = colr { let code = |c| u8::try_from(c).unwrap_or(2); - cfg.color = filmcraft_prores::ColorInfo { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix) }; + cfg.color = craft_video::prores::ColorInfo { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix) }; } - ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha, colr } + ProRes { enc: craft_video::prores::Encoder::with_config(cfg), profile, w, h, alpha, colr } } fn is_444(&self) -> bool { - matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq) + matches!(self.profile, craft_video::prores::Profile::P4444 | craft_video::prores::Profile::P4444Xq) } } @@ -244,8 +244,8 @@ impl VideoEncoder for ProRes { entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { - let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 }; - let mut fr = filmcraft_prores::Frame::new(self.w, self.h, chroma, 10, self.alpha); + let chroma = if self.is_444() { craft_video::prores::ChromaFormat::Yuv444 } else { craft_video::prores::ChromaFormat::Yuv422 }; + let mut fr = craft_video::prores::Frame::new(self.w, self.h, chroma, 10, self.alpha).map_err(enc)?; rgba_to_yuv_10(rgba, self.w as usize, self.h as usize, self.is_444(), &mut fr.y, &mut fr.cb, &mut fr.cr); if let Some(a) = fr.alpha.as_mut() { for (d, s) in a.iter_mut().zip(rgba.as_chunks::<4>().0.iter()) { @@ -291,7 +291,7 @@ fn rgba_to_yuv_10(rgba: &[u8], w: usize, h: usize, full_chroma: bool, y: &mut [u // ---------------------------------------------------------------- audio enum Audio { - Aac(Box), + Aac(Box), Pcm, } @@ -401,7 +401,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St // from the media duration and drop the last frame. let frames_ts = st.total as i128 * rate.den as i128; let seg = (frames_ts * movie_ts + rate.num as i128 / 2) / rate.num as i128; - vcfg.edits = vec![filmcraft_isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }]; + vcfg.edits = vec![craft_media::isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }]; } let vt = mux.add_track(vcfg).map_err(mux_err)?; let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); @@ -411,7 +411,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St let mut audio = None; if with_audio { let (a, entry, start) = if brand == Brand::Mp4 { - let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, chans as usize, 160_000 * chans)).map_err(enc)?; + let e = craft_audio::aac::Encoder::new(craft_audio::aac::EncoderConfig::cbr(sr, chans as usize, 160_000 * chans)).map_err(enc)?; let entry = SampleEntry::aac(e.audio_specific_config(), chans, sr); let priming = e.priming_samples() as i64; (Audio::Aac(Box::new(e)), entry, Some(priming)) diff --git a/crates/export/src/hevc_av1.rs b/crates/export/src/hevc_av1.rs index d43b1c5ed..539b6e383 100644 --- a/crates/export/src/hevc_av1.rs +++ b/crates/export/src/hevc_av1.rs @@ -1,10 +1,10 @@ -//! HEVC (`effectcraft-hevcenc`) and AV1 (`effectcraft-av1enc`) video encoders for the MP4 +//! HEVC (`craft_video::hevc`) and AV1 (`craft_video::av1`) video encoders for the MP4 //! movie writer (`hvc1` / `av01` sample entries) and AV1 in WebM, configured from the Output //! Module's [`VideoCodecOptions`]. +use craft_media::isobmff::{Av1Config, CodecConfig, FourCc, HevcConfig, SampleEntry}; use effectcraft_project::render_queue::{CodecProfile, OutputModule, RateControlMode, VideoCodecOptions}; use effectcraft_time::FrameRate; -use filmcraft_isobmff::{Av1Config, CodecConfig, FourCc, HevcConfig, SampleEntry}; use rayon::prelude::*; use crate::encode::{Codes, Packet, VideoEncoder}; @@ -55,7 +55,7 @@ struct Planes { // ---------------------------------------------------------------- HEVC pub(crate) struct Hevc { - enc: effectcraft_hevcenc::Encoder, + enc: craft_video::hevc::Encoder, w: u32, h: u32, bits: u8, @@ -64,8 +64,8 @@ pub(crate) struct Hevc { } /// The HEVC encoder settings for an output module. -pub(crate) fn hevc_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> effectcraft_hevcenc::EncoderConfig { - use effectcraft_hevcenc as he; +pub(crate) fn hevc_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> craft_video::hevc::EncoderConfig { + use craft_video::hevc as he; let o: &VideoCodecOptions = &om.codec; let mut cfg = he::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); cfg.profile = match o.profile { @@ -86,7 +86,7 @@ impl Hevc { let mut cfg = hevc_config(w, h, rate, om); (cfg.colour_primaries, cfg.transfer_characteristics, cfg.matrix_coeffs) = (codes.primaries, codes.transfer, codes.matrix); let bits = cfg.profile.bit_depth(); - let enc = effectcraft_hevcenc::Encoder::new(cfg).map_err(enc_err)?; + let enc = craft_video::hevc::Encoder::new(cfg).map_err(enc_err)?; Ok(Hevc { enc, w, h, bits, codes, planes: Planes::default() }) } } @@ -102,8 +102,8 @@ impl VideoEncoder for Hevc { let (w, h) = (self.w as usize, self.h as usize); let p = &mut self.planes; rgba_to_yuv420_bits(rgba, w, h, self.bits, &mut p.y, &mut p.u, &mut p.v); - let frame = effectcraft_hevcenc::Frame { y: &p.y, u: &p.u, v: &p.v, y_stride: w, uv_stride: w.div_ceil(2) }; - let pk = self.enc.encode(&frame); + let frame = craft_video::hevc::Frame { y: &p.y, u: &p.u, v: &p.v, y_stride: w, uv_stride: w.div_ceil(2) }; + let pk = self.enc.encode(&frame).map_err(enc_err)?; Ok(vec![Packet { data: pk.data, key: pk.keyframe, cto: 0 }]) } fn flush(&mut self) -> Result> { @@ -114,7 +114,7 @@ impl VideoEncoder for Hevc { // ---------------------------------------------------------------- AV1 pub(crate) struct Av1 { - enc: effectcraft_av1enc::Encoder, + enc: craft_video::av1::Encoder, w: u32, h: u32, bits: u8, @@ -123,8 +123,8 @@ pub(crate) struct Av1 { } /// The AV1 encoder settings for an output module. -pub(crate) fn av1_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> effectcraft_av1enc::EncoderConfig { - use effectcraft_av1enc as ae; +pub(crate) fn av1_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> craft_video::av1::EncoderConfig { + use craft_video::av1 as ae; let o: &VideoCodecOptions = &om.codec; let mut cfg = ae::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); cfg.bit_depth = o.profile.bit_depth(); @@ -142,7 +142,7 @@ impl Av1 { let mut cfg = av1_config(w, h, rate, om); (cfg.color_primaries, cfg.transfer_characteristics, cfg.matrix_coefficients) = (codes.primaries, codes.transfer, codes.matrix); let bits = cfg.bit_depth; - let enc = effectcraft_av1enc::Encoder::new(cfg).map_err(enc_err)?; + let enc = craft_video::av1::Encoder::new(cfg).map_err(enc_err)?; Ok(Av1 { enc, w, h, bits, codes, planes: Planes::default() }) } /// The `av1C` record (also the WebM CodecPrivate). @@ -162,8 +162,8 @@ impl VideoEncoder for Av1 { let (w, h) = (self.w as usize, self.h as usize); let p = &mut self.planes; rgba_to_yuv420_bits(rgba, w, h, self.bits, &mut p.y, &mut p.u, &mut p.v); - let frame = effectcraft_av1enc::Frame { y: &p.y, u: &p.u, v: &p.v, y_stride: w, uv_stride: w.div_ceil(2) }; - let pk = self.enc.encode(&frame); + let frame = craft_video::av1::Frame { y: &p.y, u: &p.u, v: &p.v, y_stride: w, uv_stride: w.div_ceil(2) }; + let pk = self.enc.encode(&frame).map_err(enc_err)?; Ok(vec![Packet { data: pk.data, key: pk.keyframe, cto: 0 }]) } fn flush(&mut self) -> Result> { @@ -196,9 +196,9 @@ mod tests { om.keyframe_interval = 12; let rate = FrameRate::new(30, 1); let h = hevc_config(64, 32, rate, &om); - assert_eq!(h.profile, effectcraft_hevcenc::Profile::Main10); + assert_eq!(h.profile, craft_video::hevc::Profile::Main10); assert_eq!(h.level_idc, Some(123)); - assert_eq!(h.rate, effectcraft_hevcenc::RateControl::ConstantQp(4)); + assert_eq!(h.rate, craft_video::hevc::RateControl::ConstantQp(4)); assert_eq!(h.keyint, 12); let a = av1_config(64, 32, rate, &om); assert_eq!((a.bit_depth, a.level_idx, a.keyint), (10, Some(9), 12)); @@ -206,6 +206,6 @@ mod tests { om.keyframe_interval = 0; om.bitrate_kbps = 3000; let a = av1_config(64, 32, rate, &om); - assert_eq!((a.rate, a.keyint), (effectcraft_av1enc::RateControl::Bitrate { kbps: 3000 }, 60)); + assert_eq!((a.rate, a.keyint), (craft_video::av1::RateControl::Bitrate { kbps: 3000 }, 60)); } } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 21b7501fa..d9af7b2aa 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,16 +2,16 @@ //! //! | Format | Container / files | Video | Alpha | Audio | //! |---|---|---|---|---| -//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), mono/stereo | +//! | H.264 | MP4 | `craft_video::h264` High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (`craft_audio::aac`), mono/stereo | //! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo | //! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo | //! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — | //! | OpenEXR sequence | one file per frame, scan lines | 32-bit float (default) or half float, linear light, premultiplied; compression None, RLE, ZIPS, ZIP (default), PIZ, PXR24, B44, B44A, DWAA, DWAB | yes | — | //! | Targa sequence | one file per frame | uncompressed 8-bit: 24-bit RGB, 32-bit RGB + Alpha (8 alpha bits), 8-bit grey for Alpha; top-left origin | yes (straight or premultiplied) | — | //! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — | -//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz | -//! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) | -//! | AV1 | MP4 (`av01`) | `effectcraft-av1enc` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC (FilmCraft) | +//! | WebM | WebM (Matroska) | VP9 profile 0 (`craft_video::vp9`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`craft_video::av1`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz | +//! | HEVC | MP4 (`hvc1`) | `craft_video::hevc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (`craft_audio::aac`) | +//! | AV1 | MP4 (`av01`) | `craft_video::av1` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC (`craft_audio::aac`) | //! | WAV / AIFF | RIFF WAVE / AIFF (AIFF-C for float) | — | — | PCM 16/24-bit or 32-bit float, mono/stereo (audio only) | //! //! Every frame goes through the internal `pipeline` module: the Render Settings overrides (Effects, Solo Switches, @@ -122,6 +122,11 @@ pub struct JobOptions<'a> { /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from /// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it. pub gpu_frames: Option, + /// Reads the bytes of memory the system can hand out now (`None`: unknown), for Auto's CPU + /// frames in flight (#1615): read at most once per job, when a job renders on the CPU after + /// a GPU frame measured a frame's working memory. `None`: CPU frames are capped only by + /// `EFFECTCRAFT_CPU_INFLIGHT`. + pub free_memory: Option<&'a (dyn Fn() -> Option + Sync)>, } /// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor, @@ -316,7 +321,7 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) let mut i = 0; while i < st.total { let end = (i + batch).min(st.total); - let ks: Vec = (i..end).filter(|k| !(cx.settings.skip_existing && out::exists(cx.sink, &sequence_path(cx.path, f0 + *k as i64)))).collect(); + let ks: Vec = (i..end).filter(|k| !(cx.settings.skip_existing && cx.frame_exists(&sequence_path(cx.path, f0 + *k as i64)))).collect(); let written: Vec> = cx .frames(comp, ks, |k, img| { let path = sequence_path(cx.path, f0 + k as i64); diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 922b1b4ea..a0e4980ec 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -137,6 +137,17 @@ pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { } } +/// Auto's CPU frames in flight (#1615): as many frames as half of the `free` bytes the system +/// can hand out hold at `frame` bytes each, 1 to `threads`. `frame` is a GPU frame's measured +/// working memory: both compositors keep 16-byte RGBA pixels (`[f32; 4]`, `Rgba32Float`), and +/// the other half is headroom for the layer caches, decoded footage, the encoder and the +/// estimate being low. `None` (not capped) when either is unknown or `frame` is 0. +pub(crate) fn auto_cpu_frames(frame: Option, free: Option, threads: usize) -> Option { + let (frame, free) = (frame.filter(|&f| f > 0)?, free?); + let n = free / 2 / frame; + Some(usize::try_from(n).unwrap_or(usize::MAX).clamp(1, threads.max(1))) +} + /// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for /// the process's. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -253,6 +264,9 @@ pub(crate) struct Cx<'a> { gpu_frames: Mutex>, /// A frame has rendered on the GPU compositor: its memory can be measured. gpu_rendered: std::sync::atomic::AtomicBool, + /// Auto's CPU frames in flight once settled ([`Cx::auto_cpu_frames`]; `Some(None)`: not + /// capped). + cpu_frames: Mutex>>, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -337,6 +351,7 @@ impl<'a> Cx<'a> { choice: Mutex::new(None), gpu_frames: Mutex::new(None), gpu_rendered: Default::default(), + cpu_frames: Mutex::new(None), frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]), @@ -555,9 +570,11 @@ impl<'a> Cx<'a> { let timed = |on: crate::JobRenderer, frames: &[u64]| { let warm = render(first, on); // The GPU's frames in flight settle with its warm-up frame (Auto measures it). + // The CPU's frames are held to what the job would render at once on it, from the + // GPU frames just measured (#1615): uncapped, they could exhaust RAM here already. let caps = match on { crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }, - crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap }, + crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap.or_else(|| self.auto_cpu_frames()) }, }; let t0 = Instant::now(); let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available()); @@ -660,7 +677,48 @@ impl<'a> Cx<'a> { /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). fn inflight(&self) -> Inflight { - Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) } + Inflight { gpu: self.gpu_cap(), cpu: self.cpu_cap() } + } + + /// CPU frames in flight (#1615): `EFFECTCRAFT_CPU_INFLIGHT`, else Auto's + /// ([`Cx::auto_cpu_frames`]) while the job renders on the CPU (it chose the CPU's + /// compositor, or the GPU stopped for good). `None`: not capped. + fn cpu_cap(&self) -> Option { + if let Some(n) = cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) { + return Some(n); + } + let on_cpu = self.backend() == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| !a.available()); + if on_cpu { self.auto_cpu_frames() } else { None } + } + + /// Auto's CPU frames in flight ([`auto_cpu_frames`]), settled once per job from the working + /// memory a GPU frame measured and the memory the system has free + /// ([`crate::JobOptions::free_memory`]). `None` (not capped, as before #1615) until a GPU + /// frame has rendered, and in a job that never renders one (a Software Rendering project): + /// there is no per-frame estimate then. + fn auto_cpu_frames(&self) -> Option { + if let Some(n) = *self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + return n; + } + if !self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + // Read outside the lock: the allocator report walks every allocation, and reading the + // system's memory can take a second (PowerShell on Windows). + let frame = self.job.accel.and_then(|a| a.frame_memory()).map(|m| m.frame); + let free = self.job.options.free_memory.and_then(|read| read()); + let n = auto_cpu_frames(frame, free, rayon::current_num_threads()); + let mut slot = self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *slot.get_or_insert_with(|| { + let mib = |b: Option| b.map_or_else(|| "unknown".to_string(), |b| format!("{} MiB", b >> 20)); + let (frame, free) = (mib(frame), mib(free)); + let label = &self.job.options.label; + match n { + Some(n) => log::info!(target: "effectcraft_export", "{label}: {n} CPU frame(s) at once (Auto: {frame} per frame on the GPU, {free} free)"), + None => log::info!(target: "effectcraft_export", "{label}: CPU frames not capped (Auto: {frame} per frame on the GPU, {free} free)"), + } + n + }) } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. @@ -836,6 +894,20 @@ impl<'a> Cx<'a> { } } + /// Whether Skip Existing finds `path` already written: at its own place or, with Use Storage + /// Overflow, in any overflow folder (checked before a frame is placed, so a skipped frame + /// reserves no quota). + pub fn frame_exists(&self, path: &str) -> bool { + if out::exists(self.sink, path) { + return true; + } + if !self.job.settings.storage_overflow { + return false; + } + let Some(name) = std::path::Path::new(path).file_name() else { return false }; + self.job.options.overflow.iter().any(|dir| out::exists(self.sink, &std::path::Path::new(dir).join(name).to_string_lossy())) + } + /// Where a file goes: `path`, or (Use Storage Overflow) the first overflow folder with room /// for `bytes` when the quota hook says the output volume is full. pub fn place(&self, path: &str, bytes: u64) -> String { @@ -1285,6 +1357,75 @@ mod tests { assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)"); } + #[test] + fn auto_cpu_frames_fit_half_the_free_memory() { + // #1615: 16 threads at 1378 MiB a frame (a GTX 1050 Ti user's comp) with 10 GiB free + // render 3 frames at once, not 16. + assert_eq!(auto_cpu_frames(Some(1378 << 20), Some(10 << 30), 16), Some(3)); + assert_eq!(auto_cpu_frames(Some(1 << 28), Some(64 << 30), 16), Some(16), "at most one per thread"); + assert_eq!(auto_cpu_frames(Some(4 << 30), Some(2 << 30), 16), Some(1), "at least one"); + assert_eq!(auto_cpu_frames(Some(1 << 30), Some(0), 8), Some(1)); + assert_eq!(auto_cpu_frames(Some(1 << 20), Some(u64::MAX), 0), Some(1)); + // Unknown: not capped, as before. + assert_eq!(auto_cpu_frames(None, Some(8 << 30), 16), None); + assert_eq!(auto_cpu_frames(Some(1 << 30), None, 16), None); + assert_eq!(auto_cpu_frames(Some(0), Some(8 << 30), 16), None); + } + + #[test] + fn a_job_that_chose_the_cpu_after_measuring_the_gpu_caps_its_frames() { + // #1615: a GPU project that measured the CPU faster rendered one frame per thread at + // once, so a heavy comp ran out of RAM (an exit without an error, 0.23.1 on Windows). + // Its frames now fit half the free memory at the GPU's measured size per frame. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + if std::env::var_os(CPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() { + return; + } + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 2 << 30, frame: 1 << 30, reserve: 0 }); + let reads = AtomicUsize::new(0); + let free = || { + reads.fetch_add(1, Ordering::SeqCst); + Some(6u64 << 30) + }; + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { free_memory: Some(&free), ..Default::default() }, + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(16).build().unwrap(); + pool.install(|| { + let cx = Cx::new(&job); + assert_eq!(cx.inflight().cpu, None, "nothing measured yet"); + let c = cx.comp().unwrap(); + cx.frame_on(c, 0, crate::JobRenderer::Gpu); + assert_eq!(cx.inflight().cpu, None, "the GPU renders: its own cap holds"); + assert_eq!(reads.load(Ordering::SeqCst), 0, "the free memory is read only for CPU frames"); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(1417.3), cpu_ms: Some(655.8), why: "measured on the first frames" }); + assert_eq!(cx.inflight(), Inflight { gpu: None, cpu: Some(3) }, "3 GiB of 6 GiB free at 1 GiB a frame"); + assert_eq!(cx.inflight().cpu, Some(3)); + assert_eq!(reads.load(Ordering::SeqCst), 1, "settled once per job"); + }); + // Without a free-memory reading the job renders uncapped, as before. + let job = Job { options: Default::default(), ..job }; + let cx = Cx::new(&job); + cx.frame_on(cx.comp().unwrap(), 0, crate::JobRenderer::Gpu); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(2.0), cpu_ms: Some(1.0), why: "measured on the first frames" }); + assert_eq!(cx.inflight().cpu, None); + } + #[test] fn cpu_inflight_is_off_unless_a_positive_number() { assert_eq!(cpu_inflight(None), None); diff --git a/crates/export/src/uncompressed.rs b/crates/export/src/uncompressed.rs index 1bd1d9bad..f5cfc33d5 100644 --- a/crates/export/src/uncompressed.rs +++ b/crates/export/src/uncompressed.rs @@ -7,7 +7,7 @@ //! A R G B / R G B order, and each row is padded to an even number of bytes (only depth 24 at odd //! widths needs it). -use filmcraft_isobmff::{CodecConfig, FourCc, SampleEntry, VideoParams}; +use craft_media::isobmff::{CodecConfig, FourCc, SampleEntry, VideoParams}; use crate::encode::{Packet, VideoEncoder}; use crate::{ExportError, Result}; diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index a12bd4e1a..2802ac928 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -5,7 +5,7 @@ //! (size patched at the end), Info, Tracks and one Cluster per render batch. Video frames are //! SimpleBlocks; with alpha (Channels: RGB + Alpha) each frame is a BlockGroup whose //! BlockAdditional (BlockAddID 1) carries a second VP9 frame coding the alpha channel as luma, -//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (key +//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `craft_video::vp9` (key //! frames every two seconds, inter frames between them: SimpleBlocks carry the key flag, inter //! BlockGroups a ReferenceBlock; the alpha stream's key frames line up with the colour stream's), //! Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets, @@ -142,7 +142,7 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta let colour_channels = if job.output.channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }; let quality = job.output.quality.clamp(1, 100); let fps = rate.as_f64().max(1.0); - let cfg = effectcraft_vp9enc::EncoderConfig { + let cfg = craft_video::vp9::EncoderConfig { width: w, height: h, quality, @@ -152,8 +152,8 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta frame_rate: fps, loop_filter: true, }; - let mut color_enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone()); - let mut alpha_enc = alpha.then(|| effectcraft_vp9enc::Vp9Encoder::new(cfg.clone())); + let mut color_enc = craft_video::vp9::Vp9Encoder::new(cfg.clone()); + let mut alpha_enc = alpha.then(|| craft_video::vp9::Vp9Encoder::new(cfg.clone())); let with_audio = wants_audio(job); let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 }; let mut opus = with_audio.then(|| opus_encoder(job)); @@ -244,12 +244,14 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta let pad = (fs - pcm_left.len() % fs) % fs + fs; pcm_left.extend(std::iter::repeat_n(0.0, pad)); } - while pcm_left.len() >= fs { - let pkt = enc.encode_float(&pcm_left[..fs]); - pcm_left.drain(..fs); + // Packets are read in place and the encoded samples dropped once: draining each packet + // from the front would shift the rest of the batch's audio every 20 ms. + for packet in pcm_left.chunks_exact(fs) { + let pkt = enc.encode_float(packet); frames.push(Frame { ms: packets as i64 * 20, track: 2, key: true, prev_ms: None, data: pkt, alpha: None }); packets += 1; } + pcm_left.drain(..pcm_left.len() / fs * fs); }; let batch = batch_size(); @@ -264,12 +266,12 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta for (j, px) in pixels.iter().enumerate() { let (color, a) = rayon::join( || { - let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(px, w, h, false); + let (y, u, vv) = craft_video::vp9::rgba_to_yuv420(px, w, h, false); color_enc.encode_frame(&y, &u, &vv, false) }, || { alpha_enc.as_mut().map(|ea| { - let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(px, w, h); + let (y, u, vv) = craft_video::vp9::alpha_to_yuv420(px, w, h); ea.encode_frame(&y, &u, &vv, false).data }) }, diff --git a/crates/export/src/webm_av1.rs b/crates/export/src/webm_av1.rs index a85a5c9ec..2e75257f7 100644 --- a/crates/export/src/webm_av1.rs +++ b/crates/export/src/webm_av1.rs @@ -125,12 +125,14 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut let pad = (fs - pcm_left.len() % fs) % fs + fs; pcm_left.extend(std::iter::repeat_n(0.0, pad)); } - while pcm_left.len() >= fs { - let pkt = enc.encode_float(&pcm_left[..fs]); - pcm_left.drain(..fs); + // Packets are read in place and the encoded samples dropped once: draining each packet + // from the front would shift the rest of the batch's audio every 20 ms. + for packet in pcm_left.chunks_exact(fs) { + let pkt = enc.encode_float(packet); blocks.push(Block { ms: packets as i64 * 20, track: 2, key: true, data: pkt }); packets += 1; } + pcm_left.drain(..pcm_left.len() / fs * fs); }; let batch = batch_size(); diff --git a/crates/av1enc/tests/it/bench.rs b/crates/export/tests/it/av1_encoder/bench.rs similarity index 94% rename from crates/av1enc/tests/it/bench.rs rename to crates/export/tests/it/av1_encoder/bench.rs index b29817e1a..302308c50 100644 --- a/crates/av1enc/tests/it/bench.rs +++ b/crates/export/tests/it/av1_encoder/bench.rs @@ -1,13 +1,13 @@ //! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default): -//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`. +//! `cargo test -p effectcraft-export --test it av1_encoder::bench:: -- --ignored --nocapture`. -use crate::common; +use super::common; use std::process::Command; use std::time::Instant; use common::*; -use effectcraft_av1enc::{Encoder, EncoderConfig, RateControl}; +use craft_video::av1::{Encoder, EncoderConfig, RateControl}; /// Raw 8-bit 4:2:0 frames of an ffmpeg lavfi source, or None without ffmpeg. fn lavfi(src: &str, w: usize, h: usize, frames: usize) -> Option> { @@ -52,7 +52,7 @@ fn run(name: &str, pics: &[Pic], rate: RateControl, keyint: u32) { let mut sizes = Vec::new(); for p in pics { let t = Instant::now(); - let pkt = enc.encode(&p.frame()); + let pkt = enc.encode(&p.frame()).expect("encode"); let dt = t.elapsed().as_secs_f64(); if pkt.keyframe { t_key += dt; diff --git a/crates/av1enc/tests/it/common/mod.rs b/crates/export/tests/it/av1_encoder/common.rs similarity index 97% rename from crates/av1enc/tests/it/common/mod.rs rename to crates/export/tests/it/av1_encoder/common.rs index 048e3de17..3cd42a584 100644 --- a/crates/av1enc/tests/it/common/mod.rs +++ b/crates/export/tests/it/av1_encoder/common.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use effectcraft_av1enc::{Encoder, EncoderConfig, Frame, Packet}; +use craft_video::av1::{Encoder, EncoderConfig, Frame, Packet}; /// A synthetic 4:2:0 picture. pub struct Pic { @@ -95,7 +95,7 @@ pub fn encode_and_check(cfg: EncoderConfig, pics: &[Pic]) -> Run { let mut dec = filmcraft_av1::Decoder::with_threads(1); let mut run = Run { packets: Vec::new(), recons: Vec::new(), psnr_y: Vec::new(), lf_levels: Vec::new() }; for (i, p) in pics.iter().enumerate() { - let pkt = enc.encode(&p.frame()); + let pkt = enc.encode(&p.frame()).expect("encode"); let rec = enc.last_reconstruction().expect("reconstruction"); let out = dec.decode(&pkt.data).unwrap_or_else(|e| panic!("frame {i}: decode error {e:?}")); assert_eq!(out.len(), 1, "frame {i}: one picture per packet"); diff --git a/crates/av1enc/tests/it/ffmpeg_oracle.rs b/crates/export/tests/it/av1_encoder/ffmpeg_oracle.rs similarity index 95% rename from crates/av1enc/tests/it/ffmpeg_oracle.rs rename to crates/export/tests/it/av1_encoder/ffmpeg_oracle.rs index b35cbf496..6e235ed3b 100644 --- a/crates/av1enc/tests/it/ffmpeg_oracle.rs +++ b/crates/export/tests/it/av1_encoder/ffmpeg_oracle.rs @@ -2,12 +2,12 @@ //! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1` //! decoder only drives hardware decoders, so it is not used. -use crate::common; +use super::common; use std::process::Command; use common::*; -use effectcraft_av1enc::{Encoder, EncoderConfig, Packet, RateControl}; +use craft_video::av1::{Encoder, EncoderConfig, Packet, RateControl}; fn have_libdav1d() -> bool { let Ok(out) = Command::new("ffmpeg").args(["-hide_banner", "-decoders"]).output() else { @@ -53,12 +53,12 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec { let mut recons = Vec::new(); let mut levels = Vec::new(); for p in pics { - packets.push(enc.encode(&p.frame())); + packets.push(enc.encode(&p.frame()).expect("encode")); recons.push(enc.last_reconstruction().expect("reconstruction")); levels.push(enc.last_loop_filter_level().expect("level")); } - let dir = std::env::temp_dir().join(format!("effectcraft-av1enc-{}-{name}", 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 input = dir.join("in.ivf"); let output = dir.join("out.yuv"); std::fs::write(&input, ivf(w, h, &packets)).expect("write ivf"); diff --git a/crates/export/tests/it/av1_encoder/mod.rs b/crates/export/tests/it/av1_encoder/mod.rs new file mode 100644 index 000000000..d33ea0c55 --- /dev/null +++ b/crates/export/tests/it/av1_encoder/mod.rs @@ -0,0 +1,6 @@ +//! craft-video's AV1 encoder against the filmcraft-av1 decoder and ffmpeg's libdav1d. + +mod bench; +mod common; +mod ffmpeg_oracle; +mod roundtrip; diff --git a/crates/av1enc/tests/it/roundtrip.rs b/crates/export/tests/it/av1_encoder/roundtrip.rs similarity index 93% rename from crates/av1enc/tests/it/roundtrip.rs rename to crates/export/tests/it/av1_encoder/roundtrip.rs index edc98c370..8e7dcc418 100644 --- a/crates/av1enc/tests/it/roundtrip.rs +++ b/crates/export/tests/it/av1_encoder/roundtrip.rs @@ -1,10 +1,10 @@ //! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the //! encoder's own reconstruction bit for bit. -use crate::common; +use super::common; use common::*; -use effectcraft_av1enc::{EncoderConfig, RateControl}; +use craft_video::av1::{EncoderConfig, RateControl}; fn cfg(w: u32, h: u32, bd: u8, q: u8, keyint: u32) -> EncoderConfig { let mut c = EncoderConfig::new(w, h, 30, 1); @@ -89,8 +89,8 @@ fn bitrate_mode_is_accurate_over_many_groups() { let pics: Vec = (0..n).map(|t| synth(192, 128, bd, t)).collect(); let mut c = cfg(192, 128, bd as u8, 0, keyint); c.rate = RateControl::Bitrate { kbps }; - let mut enc = effectcraft_av1enc::Encoder::new(c).unwrap(); - let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).data.len() * 8).sum(); + let mut enc = craft_video::av1::Encoder::new(c).unwrap(); + let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).expect("encode").data.len() * 8).sum(); let got = bits as f64 / (n as f64 / 30.0) / 1000.0; eprintln!("{bd} bit, keyint {keyint}: {got:.1} kbit/s for {kbps}"); assert!((got / kbps as f64 - 1.0).abs() < 0.05, "{got:.1} kbit/s for a {kbps} kbit/s target ({bd} bit, keyint {keyint})"); @@ -99,7 +99,7 @@ fn bitrate_mode_is_accurate_over_many_groups() { #[test] fn headers() { - let enc = effectcraft_av1enc::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap(); + let enc = craft_video::av1::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap(); let c = enc.av1c(); assert_eq!(c[0], 0x81); assert_eq!(c[1] & 0x1f, 8, "1080p30 is level 4.0"); diff --git a/crates/hevcenc/tests/roundtrip.rs b/crates/export/tests/it/hevc_encoder.rs similarity index 98% rename from crates/hevcenc/tests/roundtrip.rs rename to crates/export/tests/it/hevc_encoder.rs index 7591517e9..c19ff9cbd 100644 --- a/crates/hevcenc/tests/roundtrip.rs +++ b/crates/export/tests/it/hevc_encoder.rs @@ -1,7 +1,7 @@ //! Round trips through the filmcraft-hevc decoder and (when installed) ffmpeg: decoded pictures must //! equal the encoder's own reconstruction bit-exactly. -use effectcraft_hevcenc::{Encoder, EncoderConfig, Frame, Profile, RateControl}; +use craft_video::hevc::{Encoder, EncoderConfig, Frame, Profile, RateControl}; use std::process::Command; struct Pic { @@ -74,7 +74,7 @@ fn encode(cfg: EncoderConfig, pics: &[Pic]) -> Run { let mut enc = Encoder::new(cfg).unwrap(); let mut run = Run { packets: vec![], keys: vec![], recons: vec![], hvcc: enc.hvcc(), sets: enc.parameter_sets(), psnr_y: vec![] }; for p in pics { - let pkt = enc.encode(&p.frame()); + let pkt = enc.encode(&p.frame()).expect("encode"); let rec = enc.last_reconstruction().unwrap().clone(); run.psnr_y.push(psnr(&p.y, &rec[0], bd)); run.packets.push(pkt.data); @@ -393,7 +393,7 @@ fn hvcc_layout() { } /// Speed / size / quality report on ffmpeg-generated 1080p test content: -/// `cargo test -p effectcraft-hevcenc --test roundtrip bench_1080p -- --ignored --nocapture` +/// `cargo test -p effectcraft-export --test it hevc_encoder::bench_1080p -- --ignored --nocapture` /// (`HEVCENC_BENCH_SRC` selects another lavfi source). #[test] #[ignore] @@ -424,7 +424,7 @@ fn bench_1080p() { let mut enc = Encoder::new(cfg(w as u32, h as u32, qp, 30, Profile::Main)).unwrap(); for (i, p) in pics.iter().enumerate() { let t = std::time::Instant::now(); - let pkt = enc.encode(&p.frame()); + let pkt = enc.encode(&p.frame()).expect("encode"); let dt = t.elapsed(); let rec = enc.last_reconstruction().unwrap(); eprintln!( diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 07ee96c05..77dc22b49 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -1,10 +1,12 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). mod audio_sample_positions; +mod av1_encoder; mod banding; mod exr_scene_linear; mod h264_gpu_yuv; mod hevc_av1_roundtrip; +mod hevc_encoder; #[cfg(not(target_arch = "wasm32"))] mod mov_audio_sample_positions; mod movie_bytes; diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 76b89d1c7..00e19b396 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -97,7 +97,11 @@ fn h264_movie_bytes_are_pinned() { // FilmCraft 09aaf9b's encoder lists its settings in the first frame's user-data SEI (211 // bytes where 0.1.1's name took 50): 161 bytes more than the FilmCraft 2c5ba7a pin. The encoder's // version string "0.8.0" -> "0.12.0" in that SEI adds one more byte (and one to the size fields). - assert_eq!((data.len(), fnv1a(&data)), (104_342, 17_327_612_315_319_115_693), "H.264 MP4"); + // craft-video's encoder (craft-libs) is that encoder at a later FilmCraft revision than the + // d5ead4b pin: it names itself "craft-h264enc 0.1.0" in the SEI (5 bytes fewer), and with these + // settings its packets match the pin's for the first ten frames and differ after (24 bytes more + // in all, with the size fields). + assert_eq!((data.len(), fnv1a(&data)), (104_366, 4_413_091_804_733_884_658), "H.264 MP4"); } #[test] diff --git a/crates/export/tests/it/movie_color.rs b/crates/export/tests/it/movie_color.rs index 1a5371d91..e93384093 100644 --- a/crates/export/tests/it/movie_color.rs +++ b/crates/export/tests/it/movie_color.rs @@ -6,13 +6,13 @@ use std::path::Path; use std::process::Command; +use craft_media::isobmff::{ColorInfo, TrackKind}; use effectcraft_color::Label; use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; use effectcraft_export::{Job, export}; use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_render::NoFootage; use effectcraft_time::{FrameRate, Tick}; -use filmcraft_isobmff::{ColorInfo, TrackKind}; /// (name, Output color space, H.273 primaries / transfer / matrix, the same as ffprobe names them) const ROWS: &[(&str, Option, [u16; 3], [&str; 3])] = &[ @@ -81,7 +81,7 @@ fn mp4_tags_describe_the_frames() { render(output, format, &path); let bytes = std::fs::read(&path).expect("read movie"); - let mp4 = filmcraft_isobmff::open(bytes.as_slice()).expect("demux"); + let mp4 = craft_media::isobmff::open(bytes.as_slice()).expect("demux"); let track = mp4.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track"); let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone()); assert_eq!(colr, Some(ColorInfo::Nclx { primaries, transfer, matrix, full_range: false }), "{codec} {name}: colr"); diff --git a/crates/export/tests/it/ms_rounded_footage.rs b/crates/export/tests/it/ms_rounded_footage.rs index 3d6880232..2be6c3cd8 100644 --- a/crates/export/tests/it/ms_rounded_footage.rs +++ b/crates/export/tests/it/ms_rounded_footage.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use craft_media::isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use effectcraft_color::Label; use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; use effectcraft_export::{Job, export}; @@ -16,7 +17,6 @@ use effectcraft_media::MediaPool; use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build}; use effectcraft_raster::Image; use effectcraft_time::{FrameRate, Tick}; -use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; const W: u32 = 128; const H: u32 = 32; @@ -38,18 +38,18 @@ fn ms_rounded(rate: FrameRate, n: i64) -> Vec { /// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms); /// frame k shows k in binary. fn write_movie(path: &Path, durations: &[u32]) { - let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1); - cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; + let mut cfg = craft_video::h264::EncoderConfig::new(W, H, 30, 1); + cfg.format = craft_video::h264::PacketFormat::LengthPrefixed; cfg.aud = false; cfg.bframes = 0; cfg.keyint = 30; cfg.scenecut = false; - let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder"); + let mut enc = craft_video::h264::Encoder::new(cfg).expect("encoder"); let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]); let mut packets = Vec::new(); for k in 0..durations.len() { let y: Vec = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect(); - let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 }; + let frame = craft_video::h264::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 }; packets.extend(enc.try_encode(&frame, k as i64).expect("encode")); } packets.extend(enc.flush()); diff --git a/crates/export/tests/it/opus_roundtrip.rs b/crates/export/tests/it/opus_roundtrip.rs index 3cd1edeee..c333946d8 100644 --- a/crates/export/tests/it/opus_roundtrip.rs +++ b/crates/export/tests/it/opus_roundtrip.rs @@ -20,9 +20,9 @@ fn encode(pcm: &[f32], channels: u8, bitrate: u32) -> (OpusEncoder, Vec> (enc, packets) } -/// Decodes with filmcraft-opus, drops the pre-skip and returns planar channels of `len` samples. +/// Decodes with craft-audio, drops the pre-skip and returns planar channels of `len` samples. fn decode(head: &[u8], packets: &[Vec], len: usize) -> 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 { @@ -342,7 +342,8 @@ fn ffmpeg_oracle_decodes_ogg_opus() { } } let (enc, packets) = encode(&pcm, channels, rate); - let path = std::env::temp_dir().join(format!("effectcraft-opusenc-{}-{channels}.opus", std::process::id())); + let guard = tempfile::tempdir().unwrap(); + let path = guard.path().join("out.opus"); std::fs::write(&path, ogg_opus(&enc, &packets, len)).unwrap(); let out = Command::new("ffmpeg") .args(["-v", "error", "-i"]) diff --git a/crates/export/tests/it/prores_color.rs b/crates/export/tests/it/prores_color.rs index a54a17293..b2033c015 100644 --- a/crates/export/tests/it/prores_color.rs +++ b/crates/export/tests/it/prores_color.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::process::Command; +use craft_media::isobmff::{ColorInfo, TrackKind}; use effectcraft_color::Label; use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path}; use effectcraft_export::{Job, export}; @@ -12,7 +13,6 @@ use effectcraft_media::MediaPool; use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_render::{FootageSource, NoFootage}; use effectcraft_time::{FrameRate, Tick}; -use filmcraft_isobmff::{ColorInfo, TrackKind}; struct Row { name: &'static str, @@ -114,7 +114,7 @@ fn prores_colr_describes_the_frames() { render(row, &path); let bytes = std::fs::read(&path).expect("read movie"); - let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux"); + let mov = craft_media::isobmff::open(bytes.as_slice()).expect("demux"); let track = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track"); let [primaries, transfer, matrix] = row.codes; let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone()); diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 39a7e88ff..ddad4336e 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -389,8 +389,8 @@ fn wav_and_aiff_channels_and_formats() { #[test] fn cancelled_export_removes_only_files_it_opened() { use effectcraft_export::ExportError; - let dir = std::env::temp_dir().join(format!("ec-cancel-owned-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); struct Overflow; impl StorageQuota for Overflow { @@ -444,6 +444,54 @@ fn cancelled_export_removes_only_files_it_opened() { std::fs::remove_dir_all(dir).unwrap(); } +/// Skip Existing keeps a frame that is already in the overflow folder the frame is placed in +/// (it checked only the primary destination, then truncated the overflow file). +#[test] +fn skip_existing_keeps_a_frame_in_the_overflow_folder() { + use std::path::PathBuf; + // Room only in the second overflow folder; the frame already sits in the first (which is full). + struct Overflow(PathBuf); + impl StorageQuota for Overflow { + fn has_room(&self, path: &str, _: u64) -> bool { + std::path::Path::new(path).starts_with(&self.0) + } + } + let guard = tempfile::tempdir().unwrap(); + let (dir, full, over) = (guard.path().to_path_buf(), guard.path().join("full"), guard.path().join("over")); + std::fs::create_dir_all(&full).unwrap(); + std::fs::create_dir_all(&over).unwrap(); + std::fs::write(full.join("clip_00000.png"), b"keep").unwrap(); + let quota = Overflow(over.clone()); + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let settings = RenderSettings { skip_existing: true, storage_overflow: true, ..Default::default() }; + let om = OutputModule::for_format(OutputFormat::PngSequence); + let path = dir.join("clip_[#####].png"); + let result = export( + &Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: path.to_str().unwrap(), + sink: None, + nested_switches: true, + options: JobOptions { + storage: Some("a), + overflow: vec![full.to_string_lossy().into_owned(), over.to_string_lossy().into_owned()], + ..Default::default() + }, + }, + &mut |_| true, + ); + assert!(result.is_ok(), "{result:?}"); + assert_eq!(std::fs::read(full.join("clip_00000.png")).unwrap(), b"keep"); + assert!(!over.join("clip_00000.png").exists(), "no duplicate in another overflow folder"); + assert!(over.join("clip_00001.png").exists(), "the other frames are still written"); +} + /// A GPU compositor stand-in that renders every frame solid green (the CPU draws the bar and the /// block on black) and keeps Auto's timing history. #[derive(Default)] diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index c8634fa21..bcf7b9d90 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -288,15 +288,15 @@ fn prores_alpha_final_run_is_present_in_exported_movies() { run_frames(&project, comp, &MediaPool::new(), &output, &path, 1); let bytes = std::fs::read(&path).expect("exported MOV"); - let movie = filmcraft_isobmff::open(bytes.as_slice()).expect("demux exported MOV"); - let video = movie.tracks.iter().find(|track| track.kind == filmcraft_isobmff::TrackKind::Video).expect("video track"); + let movie = craft_media::isobmff::open(bytes.as_slice()).expect("demux exported MOV"); + let video = movie.tracks.iter().find(|track| track.kind == craft_media::isobmff::TrackKind::Video).expect("video track"); assert_eq!(video.samples.len(), 1); let sample = &video.samples[0]; let frame = &bytes[sample.offset as usize..][..sample.size as usize]; - let (header, start, size) = filmcraft_prores::FrameHeader::parse(frame).expect("ProRes header"); + let (header, start, size) = craft_video::prores::FrameHeader::parse(frame).expect("ProRes header"); assert_eq!((header.width, header.height), (16, 16)); let picture = &frame[start..size]; - let header = filmcraft_prores::PictureHeader::parse(picture).expect("picture header"); + let header = craft_video::prores::PictureHeader::parse(picture).expect("picture header"); assert_eq!(header.num_slices, 1); let slice = &picture[header.header_size + 2..]; let color_bytes: usize = slice[2..8].as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b) as usize).sum(); diff --git a/crates/export/tests/it/uncompressed_mov.rs b/crates/export/tests/it/uncompressed_mov.rs index 1f7a66576..200d3116a 100644 --- a/crates/export/tests/it/uncompressed_mov.rs +++ b/crates/export/tests/it/uncompressed_mov.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use craft_media::isobmff::TrackKind; use effectcraft_color::Label; use effectcraft_export::render_queue::{AudioFormat, AudioOutput, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path}; use effectcraft_export::{Job, export}; @@ -15,7 +16,6 @@ use effectcraft_media::MediaPool; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_render::FootageSource; use effectcraft_time::{FrameRate, Tick}; -use filmcraft_isobmff::TrackKind; const W: u32 = 33; const H: u32 = 20; @@ -103,7 +103,7 @@ fn reference_frames(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: & /// The frames as stored in the movie, unpacked to RGBA (rows padded to even bytes). fn stored_frames(path: &Path, alpha: bool) -> Vec> { let bytes = std::fs::read(path).expect("read movie"); - let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux"); + let mov = craft_media::isobmff::open(bytes.as_slice()).expect("demux"); assert!(mov.is_quicktime); let t = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track"); let e = &t.entries[0]; @@ -204,7 +204,7 @@ fn uncompressed_with_pcm_audio() { let path = d.join("comp.mov"); assert!(run(&p, cid, &pool, &om, &path).audio); let bytes = std::fs::read(&path).expect("read"); - let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux"); + let mov = craft_media::isobmff::open(bytes.as_slice()).expect("demux"); let a = mov.tracks.iter().find(|t| t.kind == TrackKind::Audio).expect("audio track"); assert_eq!(a.timescale, 48_000); // 0.6 s of audio at 48 kHz. diff --git a/crates/export/tests/it/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs index 1432e3892..f9d36770d 100644 --- a/crates/export/tests/it/vp9_roundtrip.rs +++ b/crates/export/tests/it/vp9_roundtrip.rs @@ -1,7 +1,7 @@ -//! The VP9 intra encoder (`effectcraft-vp9enc`) round-trips through FilmCraft's VP9 decoder (and +//! The VP9 intra encoder (`craft_video::vp9`) round-trips through FilmCraft's VP9 decoder (and //! ffmpeg as an external oracle when installed): sizes, lossless exactness, PSNR. -use effectcraft_vp9enc::{EncoderConfig, Vp9Encoder}; +use craft_video::vp9::{EncoderConfig, Vp9Encoder}; use filmcraft_vp9::{Decoder, Plane}; fn plane_u8(p: &Plane) -> Vec { @@ -130,8 +130,8 @@ fn ffmpeg_decodes_our_stream() { ivf.extend_from_slice(&(frame.len() as u32).to_le_bytes()); ivf.extend_from_slice(&0u64.to_le_bytes()); ivf.extend_from_slice(&frame); - let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let path = dir.join("a.ivf"); std::fs::write(&path, &ivf).unwrap(); let out = std::process::Command::new("ffmpeg") @@ -291,8 +291,8 @@ fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec], recon: &[[Vec; 3]] ivf.extend_from_slice(&(t as u64).to_le_bytes()); ivf.extend_from_slice(f); } - let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{name}-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::Builder::new().prefix(&format!("effectcraft-vp9-{name}-")).tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let path = dir.join("a.ivf"); std::fs::write(&path, &ivf).unwrap(); let out = std::process::Command::new("ffmpeg") diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 43400ea1d..c397299ae 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -9,7 +9,8 @@ use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; -use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; +use effectcraft_project::ae_order::{self, AeChild}; +use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; use effectcraft_render::EvalCtx; use effectcraft_time::Tick; @@ -272,6 +273,19 @@ fn node_resp(path: String, n: &Node, index: usize) -> Resp { ]) } +/// A top-level slot the layer has no node for, as group data: `@marker` (the markers, built by the +/// prelude) or `@absent` (an empty group, as After Effects shows an unused slot). +fn slot_resp(path: &str, s: &ae_order::AeSlot, index: usize) -> Resp { + Resp::List(vec![ + Resp::Str(path.into()), + Resp::Bool(true), + Resp::Str(s.name.into()), + Resp::Str(s.match_name.into()), + Resp::Num(0.0), + Resp::Num(index as f64), + ]) +} + fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> { match key { Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| g.children.get(i - 1)), @@ -283,6 +297,9 @@ fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> { .find(|n| n.match_id() == m) .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(name))) .or_else(|| g.children.iter().find(|n| compact(n.name()) == c || compact(n.match_id()) == c)) + // An effect by its After Effects match name, an imported placeholder's effect id + // (`effect("Pseudo/0.25")`; its match id is one path segment, `Pseudo_0_25`). + .or_else(|| g.children.iter().find(|n| matches!(n.as_group().map(|g| &g.kind), Some(GroupKind::Effect { effect }) if effect == name))) } } } @@ -348,7 +365,36 @@ impl<'a> Resolver<'a> { let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::().ok()?; let index = parent.children.iter().position(|c| c.uid() == uid)?; let n = parent.children.get(index)?; - Some(node_resp(group_path, n, index + 1)) + // A top-level group's number is After Effects' (see `ae_order`). + let number = if keep == 1 { ae_order::ae_index(l, n.match_id()) } else { None }.unwrap_or(index + 1); + Some(node_resp(group_path, n, number)) + } + + /// A child of a layer (`group` empty) or of one of its groups, by name or 1-based number. + fn child(&self, comp: u64, layer: u64, group: &str, key: &Key) -> Option { + let l = self.layer(comp, layer)?; + // A layer's numbers are After Effects' (Marker is 1, unused groups keep their slot), and so + // are its match names (`thisLayer("ADBE Effect Parade")`, as scripts such as Karton write). + let slot = match key { + Key::Index(i) => usize::try_from(*i).ok(), + Key::Name(name) => ae_order::ae_index_of_match_name(l, name), + }; + if group.is_empty() + && ae_order::ae_slots(&l.source).is_some() + && let Some(i) = slot + { + return Some(match ae_order::ae_child(l, i)? { + AeChild::Node(n) => node_resp(format!("@{}", n.uid()), n, i), + AeChild::Marker => slot_resp("@marker", &ae_order::MARKER, 1), + AeChild::Absent(s) => slot_resp("@absent", s, i), + }); + } + let g = if group.is_empty() { &l.props } else { l.props.group(group)? }; + let n = find_child(g, key)?; + let index = if group.is_empty() { ae_order::ae_index(l, n.match_id()) } else { None } + .unwrap_or_else(|| g.children.iter().position(|c| c.uid() == n.uid()).unwrap_or(0) + 1); + let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) }; + Some(node_resp(path, n, index)) } fn try_resolve(&self, req: &Req) -> Option { @@ -403,14 +449,7 @@ impl<'a> Resolver<'a> { Resp::Bool(l.switches.video), ]) } - Req::Child { comp, layer, group, key } => { - let l = self.layer(*comp, *layer)?; - let g = if group.is_empty() { &l.props } else { l.props.group(group)? }; - let n = find_child(g, key)?; - let index = g.children.iter().position(|c| c.uid() == n.uid()).unwrap_or(0) + 1; - let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) }; - node_resp(path, n, index) - } + Req::Child { comp, layer, group, key } => return self.child(*comp, *layer, group, key), Req::Ancestor { comp, layer, path, up } => return self.ancestor(*comp, *layer, path, *up), Req::PropInfo { comp, layer, path } => { let (l, p) = self.prop(*comp, *layer, path)?; @@ -423,7 +462,10 @@ impl<'a> Resolver<'a> { Some(g) => l.props.group(g)?, None => &l.props, }; - g.children.iter().position(|n| format!("@{}", n.uid()) == last).map(|i| i + 1) + let i = g.children.iter().position(|n| format!("@{}", n.uid()) == last)?; + // A top-level property (an enabled Time Remap) has After Effects' number. + let top = if parent.is_none() { g.children.get(i).and_then(|n| ae_order::ae_index(l, n.match_id())) } else { None }; + Some(top.unwrap_or(i + 1)) }); Resp::List(vec![ Resp::Str(p.name.clone()), diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index cc0405251..a57145568 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -234,7 +234,7 @@ function __cubicBezier(curve) { x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; } if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || - !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + !Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) { return __lin; } x1 = Math.max(0, Math.min(1, x1)); @@ -418,8 +418,8 @@ function hslToRgb(c) { function hexToRgb(hex) { var s = String(hex).replace(/^#|^0x/i, ''); if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; - var n = parseInt(s.slice(0, 6), 16); - var a = s.length >= 8 ? parseInt(s.slice(6, 8), 16) / 255 : 1; + var n = Number.parseInt(s.slice(0, 6), 16); + var a = s.length >= 8 ? Number.parseInt(s.slice(6, 8), 16) / 255 : 1; return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, a]; } @@ -495,6 +495,8 @@ function __mod(a, d) { return ((a % d) + d) % d; } function __child(c, l, group, key) { var info = __h(__CHILD, c, l, group, key); if (info === null) return undefined; + // `thisLayer(1)` is the layer's markers, as in After Effects (see `ae_order`). + if (group === '' && info[0] === '@marker') return new Markers(c, l); return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]); } @@ -502,7 +504,7 @@ function __child(c, l, group, key) { // it (default 1), the layer at the top (#1619). function __ancestor(c, l, path, countUp) { var n = countUp === undefined ? 1 : __v(countUp); - if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); + if (typeof n !== 'number' || !(n >= 1) || !Number.isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); n = Math.floor(n); var info = __h(__ANCESTOR, c, l, path, n); if (info === 'layer') return __layer(c, l); diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs index 38f617289..88dc06b9e 100644 --- a/crates/expr/src/sample.rs +++ b/crates/expr/src/sample.rs @@ -78,7 +78,7 @@ fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Opti r.reads = Some(&log); let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?; let out = if post { - r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale }) + r.layer_content(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale }) } else { r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale }) }; diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 050a5a550..a80f47b82 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1376,6 +1376,28 @@ fn checkbox_and_dropdown_results() { assert_close!(f.ev(f.b, ROT, "effect('Dropdown Menu Control')('Menu') * 10", 0.0).unwrap().as_f64(), 10.0); } +/// An effect EffectCraft does not have (an imported After Effects placeholder, #3087) is read by +/// name like any other: its parameters by their names, a popup 1-based. It is also found by its +/// After Effects match name, its effect id (the group's match id is one path segment). +#[test] +fn an_unknown_effects_parameters_are_read_by_name() { + let mut f = fx(); + let g = group_push(&mut f.p, |ids| { + let mut g = ids.group("Pseudo_0_25", "Settings"); + g.kind = effectcraft_project::GroupKind::Effect { effect: "Pseudo/0.25".into() }; + g.children.push(ids.prop("param1", "Speed", Value::Scalar(3.0)).into()); + g.children.push(ids.prop("param2", "Mode", Value::Enum(1)).into()); + g + }); + f.layer_mut(f.b).props.sub_mut("effects").unwrap().children.push(g.into()); + assert_close!(f.ev(f.b, ROT, "effect('Settings')('Speed') * 2", 0.0).unwrap().as_f64(), 6.0); + assert_close!(f.ev(f.b, ROT, "effect('Settings')('Mode') * 10", 0.0).unwrap().as_f64(), 20.0); + assert_close!(f.ev(f.b, ROT, "effect('Settings')(1)", 0.0).unwrap().as_f64(), 3.0); + assert_close!(f.ev(f.b, ROT, "effect('Pseudo/0.25')('Speed')", 0.0).unwrap().as_f64(), 3.0); + assert_close!(f.ev(f.b, ROT, "thisLayer('Effects')('Pseudo/0.25')('Speed')", 0.0).unwrap().as_f64(), 3.0); + assert_close!(f.ev(f.b, ROT, "thisLayer('ADBE Effect Parade')('Pseudo/0.25')('Speed')", 0.0).unwrap().as_f64(), 3.0); +} + #[test] fn syntax_error_reports_line() { let f = fx(); @@ -2025,6 +2047,7 @@ fn an_expression_cant_change_the_prelude_for_the_next() { } } +mod ae_layer_order; mod property_group; /// Timing harness for the prelude's cost on the evaluation path. Ignored: it is a measurement, not @@ -2070,3 +2093,29 @@ fn bench_evaluation() { println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})"); } } + +/// Viewport clipping must never affect layer-space expression reads (#1388). +#[test] +fn sample_image_reads_3d_shape_behind_camera_before_and_after_effects() { + let mut p = Project::default(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (100, 100), None); + let mut next = p.next_id; + let mut ids = build::Ids(&mut next); + let rect = build::shape_rect(&mut ids, [40.0, 40.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]); + let group = build::shape_group(&mut ids, "Box", vec![rect, fill]); + layer.props.sub_mut("contents").unwrap().children.push(group.into()); + p.next_id = next; + layer.switches.three_d = true; + layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, -300.0]); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(layer); + let comp = p.comp(cid).unwrap(); + let layer = &comp.layers[0]; + let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO); + for post in [false, true] { + let pixel = crate::sample::sample_image(ctx, cid, layer, [0.0, 0.0], [0.5, 0.5], post, 0.0).unwrap(); + assert!(pixel[0] > 0.99 && pixel[3] > 0.99, "postEffect={post}: {pixel:?}"); + } +} diff --git a/crates/expr/src/tests/ae_layer_order.rs b/crates/expr/src/tests/ae_layer_order.rs new file mode 100644 index 000000000..dc97a991c --- /dev/null +++ b/crates/expr/src/tests/ae_layer_order.rs @@ -0,0 +1,98 @@ +//! A layer's top-level numbers are After Effects' (`project::ae_order`): Marker is 1, and slots the +//! layer does not use (Time Remap, Motion Trackers, Layer Styles…) keep their place. Rigs that read +//! a palette by number, such as `comp("Palette").layer(1)(4)(n)(1)` on a shape layer, failed +//! silently before: `(4)` reached Transform, the expression's `try` fell back to the stored value and +//! every linked colour rendered near-black. + +use super::*; +use effectcraft_project::Property; + +/// The fixture with a Slider Control (value 7) on layer "Shape". +fn with_shape_slider() -> Fx { + let mut f = fx(); + let spec = effectcraft_effects::find("ec.control.slider").unwrap(); + let mut slider = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Swatch 1", [20.0, 20.0])); + slider.prop_mut("slider").unwrap().value = Value::Scalar(7.0); + f.layer_mut(f.shape).props.sub_mut("effects").unwrap().children.push(slider.into()); + f +} + +fn yes(f: &Fx, cond: &str) -> f64 { + f.num(&format!("({cond}) ? 1 : 0"), 0.0) +} + +#[test] +fn effects_by_number_on_a_shape_layer_is_four() { + let f = with_shape_slider(); + // The Ray Dynamic Color lookup: layer(1)(4)(swatch)(1). + assert_close!(f.num("thisComp.layer('Shape')(4)(1)(1)", 0.0), 7.0); + assert_close!(f.num("try { thisComp.layer('Shape')(4)(1)(1) } catch (e) { -1 }", 0.0), 7.0); +} + +#[test] +fn effects_by_number_on_a_solid_is_five() { + let f = fx(); + assert_close!(f.num("thisComp.layer('A')(5)(1)(1)", 0.0), 42.0); + assert_close!(f.num("thisComp.layer('A')(5)('Slider Control')('Slider')", 0.0), 42.0); +} + +#[test] +fn top_level_names_follow_after_effects() { + let f = fx(); + assert_close!(yes(&f, "thisComp.layer('A')(4).name == 'Masks'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('A')(5).name == 'Effects'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('A')(6).name == 'Transform'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Shape')(2).name == 'Contents'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Shape')(5).name == 'Transform'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Title')(2).name == 'Text'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Title')(4).name == 'Effects'"), 1.0); + assert_close!(f.num("thisComp.layer('A')(6)('Position')[0]", 0.0), 50.0); +} + +#[test] +fn one_is_the_markers() { + let f = fx(); + assert_close!(f.num("thisComp.layer('A')(1).numKeys", 0.0), 2.0); + assert_close!(f.num("thisComp.layer('A')(1).key(2).time", 0.0), 4.0); +} + +#[test] +fn an_unused_slot_is_an_empty_group() { + let f = fx(); + // Layer Styles (7) and Data (12) exist in After Effects before they are used. + assert_close!(yes(&f, "thisComp.layer('A')(7).name == 'Layer Styles'"), 1.0); + assert_close!(f.num("thisComp.layer('A')(12).numProperties", 0.0), 0.0); + assert_close!(f.num("try { thisComp.layer('A')(12)(1); 1 } catch (e) { -1 }", 0.0), -1.0); + // Past the last slot there is nothing. + assert_close!(f.num("try { thisComp.layer('A')(16); 1 } catch (e) { -1 }", 0.0), -1.0); +} + +#[test] +fn property_index_of_a_top_level_group_is_after_effects() { + let f = fx(); + assert_close!(f.num("thisComp.layer('A').effect('Slider Control').propertyGroup(1).propertyIndex", 0.0), 5.0); + assert_close!(f.num("thisComp.layer('A')('Transform').propertyIndex", 0.0), 6.0); + assert_close!(f.num("thisComp.layer('Shape')('Contents').propertyIndex", 0.0), 2.0); +} + +#[test] +fn top_level_groups_by_after_effects_match_name() { + // Karton's expressions: thisLayer("ADBE Effect Parade")("…"). + let f = with_shape_slider(); + assert_close!(f.num("thisComp.layer('A')('ADBE Effect Parade')('Slider Control')('Slider')", 0.0), 42.0); + assert_close!(f.num("thisComp.layer('Shape')('ADBE Effect Parade')(1)(1)", 0.0), 7.0); + assert_close!(f.num("thisComp.layer('A')('ADBE Transform Group')('Position')[0]", 0.0), 50.0); + assert_close!(f.num("thisComp.layer('A')('ADBE Marker').numKeys", 0.0), 2.0); + assert_close!(f.num("thisComp.layer('A')('ADBE Effect Parade').propertyIndex", 0.0), 5.0); +} + +#[test] +fn an_enabled_time_remap_is_number_two() { + // Enabled Time Remap is a property at the top of the tree; After Effects numbers it 2. + let mut f = fx(); + let uid = f.p.next_id; + f.p.next_id += 1; + f.layer_mut(f.a).props.children.insert(0, Property::new(uid, "timeRemap", "Time Remap", Value::Scalar(0.0)).into()); + assert_close!(f.num("thisComp.layer('A')('Time Remap').propertyIndex", 0.0), 2.0); + assert_close!(f.num("thisComp.layer('A')(2).propertyIndex", 0.0), 2.0); +} diff --git a/crates/gpu/src/diagnostics.rs b/crates/gpu/src/diagnostics.rs new file mode 100644 index 000000000..a37adff22 --- /dev/null +++ b/crates/gpu/src/diagnostics.rs @@ -0,0 +1,102 @@ +//! Per-compositor, fixed-category observations from error scopes. No driver strings escape. + +#[cfg(any(test, not(target_arch = "wasm32")))] +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, OnceLock}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopedError { + OutOfMemory, + Internal, + Validation, +} + +pub type ScopedErrorObserver = Arc; + +#[derive(Default)] +pub(crate) struct ScopedObservations { + observer: OnceLock, + #[cfg(any(test, not(target_arch = "wasm32")))] + seen: AtomicU8, +} + +impl ScopedObservations { + /// Register before rendering. Shared by all clones of the compositor, never global. + pub(crate) fn install(&self, observer: ScopedErrorObserver) -> bool { + self.observer.set(observer).is_ok() + } + + #[cfg(any(test, not(target_arch = "wasm32")))] + pub(crate) fn report_scopes(&self, oom: bool, internal: bool, validation: bool) { + for (present, category) in [(oom, ScopedError::OutOfMemory), (internal, ScopedError::Internal), (validation, ScopedError::Validation)] { + if present { + self.report(category); + } + } + } + + #[cfg(any(test, not(target_arch = "wasm32")))] + fn report(&self, error: ScopedError) { + let Some(observer) = self.observer.get() else { return }; + let bit = match error { + ScopedError::OutOfMemory => 1, + ScopedError::Internal => 2, + ScopedError::Validation => 4, + }; + if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 { + return; + } + // Diagnostics must not turn a recoverable GPU frame error into an application panic. + #[cfg(not(target_arch = "wasm32"))] + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| observer(error))).is_err() { + log::warn!("GPU scoped-error observer panicked"); + } + #[cfg(target_arch = "wasm32")] + observer(error); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn categories_are_independent_bounded_and_not_global() { + let observations = ScopedObservations::default(); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let copy = seen.clone(); + assert!(observations.install(Arc::new(move |error| copy.lock().unwrap().push(error)))); + assert!(!observations.install(Arc::new(|_| panic!("must not replace observer")))); + for _ in 0..100 { + observations.report(ScopedError::Internal); + observations.report(ScopedError::Validation); + observations.report(ScopedError::OutOfMemory); + } + ScopedObservations::default().report(ScopedError::OutOfMemory); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation, ScopedError::OutOfMemory]); + } + + #[test] + fn internal_and_validation_scopes_do_not_claim_out_of_memory() { + let observations = ScopedObservations::default(); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let copy = seen.clone(); + observations.install(Arc::new(move |error| copy.lock().unwrap().push(error))); + observations.report_scopes(false, false, false); + assert!(seen.lock().unwrap().is_empty()); + observations.report_scopes(false, true, true); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation]); + observations.report_scopes(true, true, true); + assert_eq!(*seen.lock().unwrap(), [ScopedError::Internal, ScopedError::Validation, ScopedError::OutOfMemory]); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn observer_panic_does_not_escape_frame_recovery() { + let observations = ScopedObservations::default(); + observations.install(Arc::new(|_| panic!("synthetic observer panic"))); + observations.report(ScopedError::OutOfMemory); + observations.report(ScopedError::Internal); + assert_eq!(observations.seen.load(Ordering::Relaxed), 3); + } +} diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index b33c15e99..fd5a97c38 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -274,7 +274,9 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize] let mut cur = img.clone(); let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0); for (vertical, r) in passes { - let block = if vertical { (4 * r).clamp(32, 256) } else { 256 } as u32; + // Vertical sums restart every `block` rows as the CPU's do; a row is one running sum, as + // on the CPU (restarting it rounds differently). + let block = if vertical { (4 * r).clamp(32, 256) as u32 } else { cur.width.max(1) }; let (lines, n) = if vertical { (cur.width, cur.height) } else { (cur.height, cur.width) }; let mut p = Params::default(); p.u[0] = [r as u32, repeat as u32, block, 0]; diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index c6f2d1def..2dda38f45 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -133,7 +133,7 @@ fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + let r = effectcraft_effects::camera_lens_radius(ctx, b.scale); if r < 0.5 { return Some(b); } @@ -385,7 +385,7 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); } let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale); - let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; + let jitter = effectcraft_effects::four_color_jitter(ctx.params.f("jitter")); p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; run(e, "gen_fourcolor", &p, b, None, None) diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index 9ae503994..a7dd0a3f5 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -202,6 +202,8 @@ pub(crate) fn fractal(e: &mut Enc, ctx: &EffectCtx, b: GBuf, turbulent: bool) -> /// noise::median_image (of the straight colour with alpha 1 when `straight`). pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { + // As on the CPU: the window's radius is capped, so its first fill stays bounded. + let r = r.min(effectcraft_effects::MEDIAN_MAX_RADIUS); let q = run(e, "fxn_quant", &with_u0([straight as u32, 0, 0, 0]), img, None, None); // Preserve the CPU's rounded level / 511 values before the strict Dust & Scratches // threshold. GPU division (even residual-corrected) may differ by one ulp. diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ddbc8d54a..86a498c6e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -58,6 +58,7 @@ mod bokeh; mod classic3d; mod context; pub mod deferred; +mod diagnostics; mod effects; mod fx_color; mod fx_depth; @@ -91,6 +92,7 @@ mod yuv; use std::sync::Arc; pub use context::{GpuContext, GpuImage, TransferStats}; +pub use diagnostics::{ScopedError, ScopedErrorObserver}; use effectcraft_effects::Buf; use effectcraft_project::ItemId; use effectcraft_raster::Image; @@ -126,6 +128,7 @@ impl std::error::Error for FrameError {} /// The GPU compositor (cheap to clone; one device shared by all clones). #[derive(Clone)] pub struct Gpu { + scoped_observations: Arc, ctx: Arc, /// Backend::Auto's per-comp CPU / GPU timings. auto: Arc, @@ -162,7 +165,7 @@ impl Gpu { } pub fn from_context(ctx: GpuContext) -> Gpu { - Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default() } + Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default(), scoped_observations: Default::default() } } /// A device of its own without blocking, with deferred readbacks (a browser worker, see @@ -173,6 +176,13 @@ impl Gpu { Ok(Gpu::from_context(ctx)) } + /// Install a host's fixed-category observer before rendering. It is called at most once + /// per category across all clones, outside device/renderer locks. Returns false if already + /// registered. The observer must remain brief and must not perform GPU work. + pub fn set_scoped_error_observer(&self, observer: ScopedErrorObserver) -> bool { + self.scoped_observations.install(observer) + } + /// [`Gpu::request_deferred`] blocking (tests of the browser worker's path, natively). #[cfg(not(target_arch = "wasm32"))] pub fn headless_deferred() -> Option { @@ -316,6 +326,9 @@ impl Gpu { let validation = pollster::block_on(validation.pop()); let internal = pollster::block_on(internal.pop()); let oom = pollster::block_on(oom.pop()); + // Preserve the typed scope observations before FrameError::Memory combines OOM + // and internal errors. An internal error alone is not evidence of actual OOM. + self.scoped_observations.report_scopes(oom.is_some(), internal.is_some(), validation.is_some()); let memory = oom.is_some() || internal.is_some(); // The allocation failure first: a validation error is often its consequence. let errors: Vec = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect(); diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index bb206adbd..9472d63f0 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -40,7 +40,16 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { if (clip_w) { t = min(t, 1.0); } - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01)); + // color2::LEVELS_IC_CURVE_LIMIT keeps the curve finite. WGSL leaves an overflowing pow + // indeterminate, so a curve past the bound saturates before pow: |t|^(1/g) > 1e30 once + // log2|t| / g > 99.66; between 1e30 and 2^99.7 the clamp gives the same 1e30. + // A branch, not select: select evaluates both sides, so pow would still run. + let g = max(data[o + 2u], 0.01); + var curve = sign(t) * 1e30; + if (!(abs(t) > 1.0 && log2(abs(t)) / g > 99.7)) { + curve = clamp(fxt_gamma(t, g), -1e30, 1e30); + } + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * curve; } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index bb4a5e63a..a123b70ca 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -270,7 +270,7 @@ fn line_get(horizontal: bool, line: i32, i: i32, n: i32, repeat: bool) -> vec4, horizontal: bool) { let dims = out_dims(); let r = i32(P.u[0].x); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 7b85bfc85..1d2782262 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -731,6 +731,35 @@ fn small_blurs_fall_back_to_the_cpu() { } } +/// Horizontal GPU box passes used to restart the running sum every 256 px; the CPU keeps one sum +/// per row, so images wider than 256 disagreed in f32 rounding. Width 320 forces that path. +/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters use the usual +/// Bpc32 tolerance because a tighter bound can fail on unmeasured hardware and does not catch +/// the regression (Metal still differs by ~1e-6 with or without the fix). +#[test] +fn box_blur_horizontal_rows_match_cpu_beyond_256() { + let Some(g) = gpu() else { return }; + let img = pattern(11, 320, 48); + let up = g.ctx.upload_image(&img).expect("upload"); + let tol = if on_llvmpipe() { 0.0 } else { tolerance(BitDepth::Bpc32) }; + let check_case = |name: &str, want: Image, got: Image| { + assert_eq!((got.width, got.height), (want.width, want.height), "{name}: size"); + let d = diff(&want, &got, tol); + assert_eq!(d.over, 0, "{name}: max {} at {:?} on {}", d.max, d.worst, g.ctx.name); + }; + for (name, r) in [("box r5×3", 5usize), ("box r25×3", 25)] { + let mut e = crate::context::Enc::new(&g.ctx); + let rx = [r, r, r]; + let out = crate::effects::box_passes(&mut e, &up, &rx, &rx, false); + check_case(name, effectcraft_raster::box_blur(&img, r, r, 3, false), e.download(&out).expect("download")); + } + for (name, sigma) in [("gauss σ10", 10.0f64), ("gauss σ40", 40.0)] { + let mut e = crate::context::Enc::new(&g.ctx); + let out = crate::effects::gaussian_blur(&mut e, &up, sigma, sigma, false); + check_case(name, effectcraft_raster::gaussian_blur(&img, sigma, sigma, false), e.download(&out).expect("download")); + } +} + #[test] fn gpu_effects_match_cpu() { // Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939). diff --git a/crates/gpu/src/tests/fx_distort.rs b/crates/gpu/src/tests/fx_distort.rs index 1b8621408..6fc18f3c9 100644 --- a/crates/gpu/src/tests/fx_distort.rs +++ b/crates/gpu/src/tests/fx_distort.rs @@ -38,6 +38,8 @@ fn twirl_bulge_ripple_wave_warp() { #[test] fn turbulent_displace_and_cc_lens() { effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); + // #3470: After Effects' maximum Random Seed. + effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0)), ("evolutionOptions/randomSeed", n(100000.0))]); effect_case( "ec.distort.turbulentdisplace", &[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())], diff --git a/crates/gpu/src/tests/fx_gen2.rs b/crates/gpu/src/tests/fx_gen2.rs index 620cced13..1c19725e1 100644 --- a/crates/gpu/src/tests/fx_gen2.rs +++ b/crates/gpu/src/tests/fx_gen2.rs @@ -40,6 +40,8 @@ fn closed_form() { env_case("ec.generate.beam", &[]); env_case("ec.generate.beam", &[("length", n(70.0)), ("time", n(40.0)), ("softness", n(0.0)), ("perspective3d", off()), ("compositeOnOriginal", off())]); env_case("ec.generate.beam", &[("startThickness", n(3.0)), ("endThickness", n(20.0)), ("insideColor", c(0.3, 0.9, 1.0))]); + // #3475: After Effects' maximum thickness. + env_case("ec.generate.beam", &[("startThickness", n(0.0)), ("endThickness", n(4000.0))]); env_case("ec.generate.lensflare", &[]); env_case("ec.generate.lensflare", &[("lensType", e(1)), ("flareCenter", pt(50.0, 30.0)), ("blendWithOriginal", n(30.0))]); env_case("ec.generate.lensflare", &[("lensType", e(2)), ("flareBrightness", n(160.0))]); diff --git a/crates/gpu/src/tests/fx_generate.rs b/crates/gpu/src/tests/fx_generate.rs index d12920797..f8482b5ab 100644 --- a/crates/gpu/src/tests/fx_generate.rs +++ b/crates/gpu/src/tests/fx_generate.rs @@ -83,6 +83,11 @@ fn wipes() { effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]); effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]); effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]); + // #3472-#3474: the ends of After Effects' ranges (a Feather wider than 8 blinds is the CPU's + // even veil). + effect_case("ec.transition.venetian", &[("completion", n(40.0)), ("width", n(1.0)), ("feather", n(6.0))]); + effect_case("ec.transition.venetian", &[("completion", n(40.0)), ("width", n(32000.0)), ("feather", n(32000.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(40.0)), ("feather", n(32000.0))]); effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]); effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]); effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]); @@ -147,6 +152,8 @@ fn generators() { ); effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]); effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]); + // #3476: a border wider than the cells (After Effects' maximum). + effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(2000.0))]); effect_case( "ec.generate.grid", &[ @@ -167,6 +174,8 @@ fn generators() { effect_case("ec.generate.fourcolor", &[]); effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]); effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]); + // #3478, #3479: the top of After Effects' ranges. + effect_case("ec.generate.fourcolor", &[("blend", n(10000.0)), ("jitter", n(500.0))]); } #[test] diff --git a/crates/gpu/src/tests/fx_noise.rs b/crates/gpu/src/tests/fx_noise.rs index 7d13004ee..006128c73 100644 --- a/crates/gpu/src/tests/fx_noise.rs +++ b/crates/gpu/src/tests/fx_noise.rs @@ -164,6 +164,8 @@ fn noise_alpha_and_hls() { effect_case("ec.noise.noisehls", &[("hue", n(30.0)), ("lightness", n(20.0)), ("saturation", n(40.0))]); effect_case("ec.noise.noisehls", &[("noise", e(1)), ("lightness", n(50.0)), ("noisePhase", n(200.0))]); effect_case("ec.noise.noisehls", &[("noise", e(2)), ("hue", n(60.0)), ("saturation", n(25.0)), ("grainSize", n(3.0))]); + // #3483, #3484: the ends of After Effects' ranges. + effect_case("ec.noise.noisehls", &[("noise", e(2)), ("hue", n(1000.0)), ("lightness", n(1000.0)), ("saturation", n(1000.0)), ("grainSize", n(0.0))]); effect_case("ec.noise.noisehlsauto", &[("noise", e(0)), ("hue", n(20.0)), ("lightness", n(30.0)), ("noiseAnimationSpeed", n(7.0))]); } diff --git a/crates/gpu/src/tests/fx_stylize.rs b/crates/gpu/src/tests/fx_stylize.rs index 63d93f207..8c6c7be33 100644 --- a/crates/gpu/src/tests/fx_stylize.rs +++ b/crates/gpu/src/tests/fx_stylize.rs @@ -109,6 +109,8 @@ fn arbitrary_map_signed_fractional_and_near_zero_wraps() { #[test] fn scatter_brush_strokes() { effect_case("ec.stylize.scatter", &[("amount", n(5.0))]); + // #3477: After Effects' maximum, far beyond the layer. + effect_case("ec.stylize.scatter", &[("amount", n(1000.0))]); effect_case("ec.stylize.scatter", &[("amount", n(3.0)), ("grain", e(1))]); effect_case("ec.stylize.scatter", &[("amount", n(7.0)), ("grain", e(2)), ("randomizeEveryFrame", on())]); effect_case("ec.stylize.brushstrokes", &[]); diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index 40bc9483f..3083f5949 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -29,6 +29,17 @@ fn levels_and_gamma() { ); effect_case("ec.color.levelsic", &[("green/greenInBlack", n(0.2)), ("green/greenInWhite", n(0.6)), ("clipToOutputWhite", e(0))]); effect_case("ec.color.levelsic", &[("alpha/alphaOutBlack", n(0.3))]); + // #3487-#3491: the ends of the valid ranges, the curve bounded where gamma 0 would overflow. + effect_case( + "ec.color.levelsic", + &[ + ("rgb/rgbInWhite", n(0.2)), + ("rgb/rgbGamma", n(0.0)), + ("red/redOutBlack", n(-10000.0)), + ("blue/blueOutWhite", n(10000.0)), + ("clipToOutputWhite", e(0)), + ], + ); effect_case("ec.color.levels", &[("redInBlack", n(0.1)), ("greenGamma", n(1.5)), ("blueOutWhite", n(0.7)), ("inWhite", n(0.9))]); effect_case("ec.color.levels", &[("alphaInWhite", n(0.6)), ("redInWhite", n(0.5)), ("redClipToOutputWhite", e(0))]); effect_case("ec.color.gammapedestalgain", &[("redGamma", n(1.4)), ("greenPedestal", n(0.1)), ("blueGain", n(0.8))]); @@ -70,6 +81,8 @@ fn colour_replacement() { &[("amount", n(100.0)), ("color", c(0.8, 0.4, 0.3)), ("matchColors", e(1)), ("tolerance", n(5.0)), ("edgeSoftness", n(30.0))], ); effect_case("ec.color.changecolor", &[("hueTransform", n(90.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]); + // #3480: near the top of After Effects' range (four and three quarter turns). + effect_case("ec.color.changecolor", &[("hueTransform", n(1710.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]); effect_case( "ec.color.changecolor", &[ @@ -188,3 +201,33 @@ fn color_stabilizer() { fn gradient_ramp_matches_the_cpu() { effect_case("ec.generate.gradientramp", &[]); } + +/// #3487-#3491: at the ends of the valid ranges the GPU's Levels (Individual Controls) output is +/// finite (the CPU / GPU comparison counts infinity on both sides as equal). +#[test] +fn levels_ic_is_finite_at_the_ends_of_its_ranges() { + let Some(g) = crate::tests::gpu() else { return }; + let spec = effectcraft_effects::find("ec.color.levelsic").unwrap(); + let size = [16.0, 8.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in [ + ("rgb/rgbInWhite", n(0.2)), + ("rgb/rgbGamma", n(0.0)), + ("red/redOutBlack", n(-10000.0)), + ("blue/blueOutWhite", n(10000.0)), + ("green/greenOutWhite", n(0.0)), + ("clipToOutputWhite", e(0)), + ] { + params.values.insert(k.to_string(), v); + } + let mut img = crate::tests::pattern(7, 16, 8); + for p in &mut img.data { + *p = p.map(|v| v * 3.0); + } + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale: 1.0 }; + let ctx = effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: size, seed: 1, adjustment: false, env: Default::default() }; + let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx }], &buf, None) + .expect("the GPU runs Levels (Individual Controls)"); + assert!(out.img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", out.img.data.iter().find(|p| p.iter().any(|v| !v.is_finite()))); +} diff --git a/crates/hevcenc/Cargo.toml b/crates/hevcenc/Cargo.toml deleted file mode 100644 index 136611ff2..000000000 --- a/crates/hevcenc/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "effectcraft-hevcenc" -description = "Clean-room pure-Rust HEVC (H.265) encoder (Main / Main 10, I and P slices, CABAC) for MP4 export, from ITU-T H.265" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[lints] -workspace = true - -[dependencies] - -[dev-dependencies] -filmcraft-hevc = { workspace = true } diff --git a/crates/hevcenc/README.md b/crates/hevcenc/README.md deleted file mode 100644 index 5c4e5debe..000000000 --- a/crates/hevcenc/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# effectcraft-hevcenc - -Clean-room, pure-Rust HEVC / H.265 encoder for EffectCraft's MP4 export (layer L0, `std` only, -no `unsafe`, builds for `wasm32-unknown-unknown`). - -## What it produces - -- **Profiles:** Main (8-bit) and Main 10 (10-bit), 4:2:0, Main tier. `general_level_idc` is taken - from the config or chosen as the lowest level whose MaxLumaPs, MaxLumaSr (and, in bitrate mode, - MaxBR) fit (Table A.8). -- **Parameter sets:** VPS, SPS (with VUI: BT.709 colour description, `video_full_range_flag`, - timing info from the frame rate) and PPS, as raw NAL units (`Encoder::parameter_sets`) and as an - `hvcC` record (ISO/IEC 14496-15 §8.3.3.1, `lengthSizeMinusOne` 3, complete VPS / SPS / PPS - arrays, `Encoder::hvcc`). -- **Pictures:** one slice per picture; `IDR_N_LP` every `keyint` pictures (`1` = all intra, `0` = - only the first), `TRAIL_R` P pictures otherwise, each referencing the previous picture (one - short-term RPS in the SPS, `num_ref_idx_l0_active` 1, no reordering, pts = dts). Packets are - length-prefixed (4-byte big-endian) NAL units without parameter sets. -- **Picture size:** any even width / height; the coded size is padded to a multiple of 8 by edge - replication and cropped with the conformance window. Odd sizes are rejected with - `Error::InvalidConfig` (4:2:0 conformance-window offsets are in units of two luma samples). - -## Coding tools - -- 64x64 CTUs, coding units 64x64 .. 8x8 chosen by rate-distortion optimisation: every candidate is - actually predicted, transformed, quantised and reconstructed, and its rate is measured with a - CABAC bit estimator that evolves the same context states as the real coder. Split decisions - compare the best unsplit candidate with the recursively coded children (early exit after a skip). -- **Intra:** all 35 modes (SATD search on a coarse grid plus refinement and the MPMs), 2Nx2N at - 8..32 and NxN (four 4x4 PUs) at 8x8, chroma in DM mode, MPM / remaining-mode coding, reference - substitution, [1 2 1] and strong (32x32) smoothing, DC / angular boundary filters. -- **Inter (P):** skip and merge (spatial candidates + zero candidates, `MaxNumMergeCand` 5) and AMVP - with mvd coding; integer motion search (predictor starts + square pattern, steps 16..1) and - half / quarter-sample refinement on pre-interpolated half-sample planes; the coded prediction uses - the normative 8-tap / 4-tap interpolation filters and reference-picture edge clamping. -- **Transforms:** DST-VII 4x4 (intra luma) and DCT 4..32 (inverse by even/odd decomposition, - bit-identical to the matrix form), dead-zone quantisation (1/3 intra, 1/6 inter), flat scaling, - sign data hiding, inferred transform splits only (`max_transform_hierarchy_depth_*` 0, so 64x64 - CUs use four 32x32 TUs). -- **Entropy coding:** CABAC (context initialisation by slice QP and init type, decision / bypass / - terminate bins, flush), `end_of_slice_segment_flag`, byte alignment, emulation prevention. -- **In-loop filters:** the deblocking filter is on (the encoder deblocks its own reconstruction, so - references match the decoder); SAO is disabled. -- **Rate control:** `ConstantQp(qp)` or `Bitrate { kbps }`, a per-picture QP from a bits-x-Qstep - model per picture type (I pictures get roughly 4x the bits of a P picture) with a buffer that - spreads over- and undershoot over the following pictures. - -Not implemented: B pictures / reordering, multiple references, TMVP, AMP and other inter partition -shapes, residual quadtree splits, transform skip, scaling lists, PCM, SAO, RDOQ, cu_qp_delta / -adaptive quantisation, tiles / WPP / multiple slices, range extensions (4:2:2, 4:4:4, > 10 bits). - -## Measurements - -On a shared, heavily loaded machine, opt-level 3: about 0.45 s of CPU per 1920x1080 picture -(ffmpeg `testsrc2`, QP 22 / 32). On ffmpeg's `mandelbrot` 1080p source (8 pictures, IPPP, QP 32) -the stream is about 12 % larger at about 0.2 dB lower mean PSNR-Y than x265 `--preset medium` at -the same QP without psy tuning (110.5 kB / 42.3 dB vs 98.3 kB / 42.5 dB). The synthetic test image -reaches 45.8 dB PSNR-Y at QP 22. - -## Tests - -`cargo test -p effectcraft-hevcenc`: - -- every stream is decoded with the first-party `filmcraft-hevc` decoder and compared bit-exactly - with the encoder's reconstruction (`Encoder::last_reconstruction`, hidden API); -- when `ffmpeg` is on `PATH`, the Annex-B elementary stream is decoded with - `ffmpeg -v error -xerror` (no stderr output allowed) and compared bit-exactly as well (skipped - otherwise). ffmpeg is used only as an external test oracle; -- covered: 8-bit and 10-bit, all-intra and IP, QP 0 noise (escape codes), QP 0..51, strong - deblocking at high QP, sizes 8x8 .. 200x136 with cropping, POC wrap (300 pictures without IDR), - bitrate mode within 2x of the target, `hvcC` layout; -- `bench_1080p` (ignored) reports speed, size and PSNR on 1080p content generated by ffmpeg. - -## Specifications - -- ITU-T H.265 (V8, 08/2021) | ISO/IEC 23008-2, *High efficiency video coding*: 7 (syntax and - semantics), 8.4 - 8.7 (intra / inter prediction, scaling and transforms, deblocking), 9.3 (CABAC, - including the informative encoder description of 9.3.4), Annex A (profiles, tiers and levels), - Annex E (VUI). -- ISO/IEC 14496-15 (2019), §8.3.3.1 HEVCDecoderConfigurationRecord. - -Implemented from the specification text. Constant tables (CABAC context layout and initValues, -rangeTabLPS / transIdx, scan orders, the DCT / DST matrices, intra angles, interpolation filters, -the chroma QP table, the deblocking beta / tC tables) and the decoder-side reconstruction -arithmetic (intra reference substitution / filtering / prediction, fractional interpolation, -deblocking) are mirrored from FilmCraft's first-party, spec-derived `filmcraft-hevc` decoder (same -organisation, MIT OR Apache-2.0), so that encoder and decoder reconstruct identically. No -third-party encoder or decoder source (x265, libde265, ffmpeg, HM, ...) was consulted. diff --git a/crates/hevcenc/src/bitstream.rs b/crates/hevcenc/src/bitstream.rs deleted file mode 100644 index 27ed3063e..000000000 --- a/crates/hevcenc/src/bitstream.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! RBSP bit writer (7.2, 9.2 Exp-Golomb) and NAL unit encapsulation with emulation prevention (7.3.1). - -/// MSB-first bit writer. -#[derive(Default)] -pub struct BitWriter { - buf: Vec, - acc: u64, - nbits: u32, -} - -impl BitWriter { - pub fn new() -> Self { - Self::default() - } - - /// Write the `n` (<= 32) low bits of `v`. - #[inline] - pub fn put(&mut self, v: u32, n: u32) { - debug_assert!(n <= 32); - if n == 0 { - return; - } - let v = (v as u64) & ((1u64 << n) - 1); - self.acc = (self.acc << n) | v; - self.nbits += n; - while self.nbits >= 8 { - self.nbits -= 8; - self.buf.push((self.acc >> self.nbits) as u8); - } - self.acc &= (1u64 << self.nbits) - 1; - } - - #[inline] - pub fn bit(&mut self, b: u32) { - self.put(b & 1, 1); - } - - pub fn flag(&mut self, b: bool) { - self.put(b as u32, 1); - } - - /// ue(v). - pub fn ue(&mut self, v: u32) { - let x = v as u64 + 1; - let len = 64 - x.leading_zeros(); // bits of x - // len - 1 leading zeros, then x in len bits - self.put(0, len - 1); - if len > 32 { - self.put((x >> 32) as u32, len - 32); - self.put(x as u32, 32); - } else { - self.put(x as u32, len); - } - } - - /// se(v). - pub fn se(&mut self, v: i32) { - let k = if v > 0 { 2 * v as u32 - 1 } else { (-(v as i64) as u32) * 2 }; - self.ue(k); - } - - pub fn is_aligned(&self) -> bool { - self.nbits == 0 - } - - /// rbsp_trailing_bits(): stop bit and zero alignment. - pub fn trailing(&mut self) { - self.bit(1); - self.align_zero(); - } - - pub fn align_zero(&mut self) { - if self.nbits > 0 { - self.put(0, 8 - self.nbits); - } - } - - pub fn into_bytes(mut self) -> Vec { - self.align_zero(); - self.buf - } -} - -/// Build a NAL unit (2-byte header, `nuh_layer_id` 0, `TemporalId` 0) from an RBSP, inserting -/// emulation prevention bytes. -pub fn nal_unit(nal_type: u8, rbsp: &[u8]) -> Vec { - let mut out = Vec::with_capacity(rbsp.len() + rbsp.len() / 64 + 4); - out.push(nal_type << 1); - out.push(1); - let mut zeros = 0; - for &b in rbsp { - if zeros >= 2 && b <= 3 { - out.push(3); - zeros = 0; - } - out.push(b); - if b == 0 { - zeros += 1; - } else { - zeros = 0; - } - } - // cabac_zero_words / trailing zero bytes never occur: every RBSP ends with the stop bit. - out -} - -pub const NAL_TRAIL_R: u8 = 1; -pub const NAL_IDR_N_LP: u8 = 20; -pub const NAL_VPS: u8 = 32; -pub const NAL_SPS: u8 = 33; -pub const NAL_PPS: u8 = 34; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn exp_golomb() { - let mut w = BitWriter::new(); - w.ue(0); // 1 - w.ue(1); // 010 - w.ue(2); // 011 - w.ue(3); // 00100 - w.se(-1); // ue(2) = 011 - w.trailing(); - // 1 010 011 00100 011 + stop bit: 1010 0110 0100 0111 - assert_eq!(w.into_bytes(), vec![0xa6, 0x47]); - } - - #[test] - fn emulation_prevention() { - let n = nal_unit(1, &[0, 0, 1, 0, 0, 0, 0, 0, 4]); - assert_eq!(&n[2..], &[0, 0, 3, 1, 0, 0, 3, 0, 0, 3, 0, 4]); - } -} diff --git a/crates/hevcenc/src/cabac.rs b/crates/hevcenc/src/cabac.rs deleted file mode 100644 index 220b999cd..000000000 --- a/crates/hevcenc/src/cabac.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! CABAC: context initialisation (9.3.2.2), the arithmetic encoder (9.3.4.x, informative encoder -//! description: EncodeDecision, EncodeBypass, EncodeTerminate, EncodeFlush) and a fractional-bit -//! estimator with the same context-state evolution, used for rate-distortion decisions. - -use crate::bitstream::BitWriter; -use crate::tables::{CABAC_INIT, NEXT_STATE, NUM_CTX, RANGE_TAB_LPS}; -use std::sync::OnceLock; - -/// Context states, each `pStateIdx << 1 | valMps`. -pub type Contexts = [u8; NUM_CTX]; - -/// Initialise all context variables for `slice_qp` and `init_type` (9.3.2.2). -pub fn init_contexts(slice_qp: i32, init_type: usize) -> Contexts { - let mut ctx = [0u8; NUM_CTX]; - let qp = slice_qp.clamp(0, 51); - for (c, &v) in ctx.iter_mut().zip(CABAC_INIT[init_type].iter()) { - let slope = (v >> 4) as i32; - let offset = (v & 15) as i32; - let m = slope * 5 - 45; - let n = (offset << 3) - 16; - let pre = (((m * qp) >> 4) + n).clamp(1, 126); - *c = if pre <= 63 { ((63 - pre) << 1) as u8 } else { (((pre - 64) << 1) | 1) as u8 }; - } - ctx -} - -/// Destination of binarised syntax elements: the real arithmetic coder or the bit estimator. -pub trait Sink { - fn decision(&mut self, ctx: usize, bin: u32); - fn bypass(&mut self, bin: u32); - /// `n` bypass bins carrying the low `n` bits of `v`, MSB first. - fn bypass_bits(&mut self, v: u32, n: u32) { - for i in (0..n).rev() { - self.bypass((v >> i) & 1); - } - } - fn terminate(&mut self, bin: u32); -} - -/// Arithmetic encoder writing into a bit writer. -pub struct CabacEncoder { - pub ctx: Contexts, - low: u32, - range: u32, - outstanding: u32, - first: bool, - pub w: BitWriter, -} - -impl CabacEncoder { - /// Start encoding slice data into `w` (which must be byte aligned). - pub fn new(w: BitWriter, ctx: Contexts) -> Self { - debug_assert!(w.is_aligned()); - CabacEncoder { ctx, low: 0, range: 510, outstanding: 0, first: true, w } - } - - #[inline] - fn put_bit(&mut self, b: u32) { - if self.first { - self.first = false; - } else { - self.w.bit(b); - } - while self.outstanding > 0 { - self.w.bit(1 - b); - self.outstanding -= 1; - } - } - - #[inline] - fn renorm(&mut self) { - while self.range < 256 { - if self.low < 256 { - self.put_bit(0); - } else if self.low >= 512 { - self.low -= 512; - self.put_bit(1); - } else { - self.low -= 256; - self.outstanding += 1; - } - self.range <<= 1; - self.low <<= 1; - } - } - - /// EncodeFlush after a terminating bin equal to 1; the final bit written is the - /// rbsp_stop_one_bit. Returns the writer (byte aligned with zero bits). - pub fn finish(mut self) -> BitWriter { - self.range = 2; - self.renorm(); - let b = (self.low >> 9) & 1; - self.put_bit(b); - self.w.put(((self.low >> 7) & 3) | 1, 2); - self.w.align_zero(); - self.w - } -} - -impl Sink for CabacEncoder { - #[inline] - fn decision(&mut self, ctx_idx: usize, bin: u32) { - let s = self.ctx[ctx_idx] as usize; - let mps = (s & 1) as u32; - let q = ((self.range >> 6) & 3) as usize; - let lps = RANGE_TAB_LPS[s >> 1][q] as u32; - self.range -= lps; - if bin != mps { - self.low += self.range; - self.range = lps; - self.ctx[ctx_idx] = NEXT_STATE[s][1]; - } else { - self.ctx[ctx_idx] = NEXT_STATE[s][0]; - } - self.renorm(); - } - - #[inline] - fn bypass(&mut self, bin: u32) { - self.low <<= 1; - if bin != 0 { - self.low += self.range; - } - if self.low >= 1024 { - self.put_bit(1); - self.low -= 1024; - } else if self.low < 512 { - self.put_bit(0); - } else { - self.low -= 512; - self.outstanding += 1; - } - } - - fn terminate(&mut self, bin: u32) { - self.range -= 2; - if bin != 0 { - self.low += self.range; - // the caller flushes with finish() - } else { - self.renorm(); - } - } -} - -/// Fractional bit cost (1/32768 bit units) of coding the MPS / LPS in each state. -fn cost_table() -> &'static [[u32; 2]; 64] { - static T: OnceLock<[[u32; 2]; 64]> = OnceLock::new(); - T.get_or_init(|| { - let mut t = [[0u32; 2]; 64]; - // pLPS(s) = 0.5 * alpha^s with alpha = (0.01875 / 0.5)^(1/63) (9.3.4.3 design) - let alpha = (0.01875f64 / 0.5).powf(1.0 / 63.0); - for (s, e) in t.iter_mut().enumerate() { - let p = 0.5 * alpha.powi(s as i32); - e[0] = (-(1.0 - p).log2() * 32768.0).round() as u32; - e[1] = (-p.log2() * 32768.0).round() as u32; - } - t - }) -} - -pub const BIT: u64 = 32768; - -/// Bit estimator: tracks context states like the encoder and accumulates the ideal code length. -#[derive(Clone)] -pub struct Estimator { - pub ctx: Contexts, - /// Accumulated cost in 1/32768 bits. - pub bits: u64, - table: &'static [[u32; 2]; 64], -} - -impl Estimator { - pub fn new(ctx: Contexts) -> Self { - Estimator { ctx, bits: 0, table: cost_table() } - } -} - -impl Sink for Estimator { - #[inline] - fn decision(&mut self, ctx_idx: usize, bin: u32) { - let s = self.ctx[ctx_idx] as usize; - let lps = (bin != (s & 1) as u32) as usize; - self.bits += self.table[s >> 1][lps] as u64; - self.ctx[ctx_idx] = NEXT_STATE[s][lps]; - } - #[inline] - fn bypass(&mut self, _bin: u32) { - self.bits += BIT; - } - #[inline] - fn bypass_bits(&mut self, _v: u32, n: u32) { - self.bits += BIT * n as u64; - } - fn terminate(&mut self, _bin: u32) { - self.bits += 7; // ~0 bits for a 0 bin - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn context_init_formula() { - let ctx = init_contexts(30, 0); - // initValue 154: m = 0, n = 64 -> pStateIdx 0, valMps 1 - assert_eq!(ctx[crate::tables::CU_TRANSQUANT_BYPASS], 1); - // split_cu_flag ctx 0 (initValue 139) at QP 30: preCtxState 62 - assert_eq!(ctx[crate::tables::SPLIT_CU], (63 - 62) << 1); - } - - #[test] - fn costs_are_sane() { - let t = cost_table(); - assert_eq!(t[0][0], 32768); - assert_eq!(t[0][1], 32768); - assert!(t[62][0] < 1000 && t[62][1] > 5 * 32768); - } -} diff --git a/crates/hevcenc/src/deblock.rs b/crates/hevcenc/src/deblock.rs deleted file mode 100644 index 6d326e5d4..000000000 --- a/crates/hevcenc/src/deblock.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Deblocking filter (8.7.2) over a whole reconstructed picture, mirrored from the filmcraft-hevc -//! decoder: all vertical edges on the 8x8 grid first, then all horizontal edges. One slice, one QP, -//! uni-directional prediction from a single reference picture, offsets 0. - -use crate::frame::{Blk, E_EDGE_H, E_EDGE_V, F_CBF, F_INTRA}; -use crate::tables::{BETA_TABLE, TC_TABLE, qpc_420}; - -#[inline] -fn tc_beta(q: i32, bs: i32, bd: u32) -> (i32, i32) { - let qb = q.clamp(0, 51); - let qt = (q + 2 * (bs - 1)).clamp(0, 53); - ((TC_TABLE[qt as usize] as i32) << (bd - 8), (BETA_TABLE[qb as usize] as i32) << (bd - 8)) -} - -/// Boundary strength (8.7.2.4) between 4x4 blocks p and q on a transform / prediction edge. -fn boundary_strength(p: &Blk, q: &Blk) -> i32 { - if (p.flags | q.flags) & F_INTRA != 0 { - return 2; - } - if (p.flags | q.flags) & F_CBF != 0 { - return 1; - } - let far = (p.mv[0] as i32 - q.mv[0] as i32).abs() >= 4 || (p.mv[1] as i32 - q.mv[1] as i32).abs() >= 4; - far as i32 -} - -/// Deblock the planes in place. `qp` is the slice QpY (without QpBdOffset). -pub fn deblock(planes: &mut [Vec; 3], blk: &[Blk], w: usize, h: usize, qp: i32, bd: u32) { - let (w4, h4) = (w / 4, h / 4); - let cw = w / 2; - // chroma tc (bS 2 only; QpC from the averaged luma QP, no offsets) - let qt = (qpc_420(qp) + 2).clamp(0, 53); - let tc_c = (TC_TABLE[qt as usize] as i32) << (bd - 8); - // vertical edges - for by in 0..h4 { - for bx in (2..w4).step_by(2) { - let qi = by * w4 + bx; - if blk[qi].flags & E_EDGE_V == 0 { - continue; - } - let bs = boundary_strength(&blk[qi - 1], &blk[qi]); - if bs == 0 { - continue; - } - let (x, y) = (bx * 4, by * 4); - let (tc, beta) = tc_beta(qp, bs, bd); - filter_luma(&mut planes[0], y * w + x, 1, w, tc, beta, bd); - // chroma: 8x8 chroma grid (16 luma samples), one 4-sample chroma segment per 8 luma rows - if bs == 2 && bx % 4 == 0 && by % 2 == 0 { - for c in 1..3 { - filter_chroma(&mut planes[c], (y / 2) * cw + x / 2, 1, cw, tc_c, bd); - } - } - } - } - // horizontal edges - for by in (2..h4).step_by(2) { - for bx in 0..w4 { - let qi = by * w4 + bx; - if blk[qi].flags & E_EDGE_H == 0 { - continue; - } - let bs = boundary_strength(&blk[qi - w4], &blk[qi]); - if bs == 0 { - continue; - } - let (x, y) = (bx * 4, by * 4); - let (tc, beta) = tc_beta(qp, bs, bd); - filter_luma(&mut planes[0], y * w + x, w, 1, tc, beta, bd); - if bs == 2 && by % 4 == 0 && bx % 2 == 0 { - for c in 1..3 { - filter_chroma(&mut planes[c], (y / 2) * cw + x / 2, cw, 1, tc_c, bd); - } - } - } - } -} - -/// Filter one 4-sample luma edge segment. `o` = offset of q0 of the first line, `step` = distance -/// between samples across the edge, `along` = distance between lines. -fn filter_luma(s: &mut [u16], o: usize, step: usize, along: usize, tc: i32, beta: i32, bd: u32) { - let px = |s: &[u16], k: usize, i: usize| s[o + k * along - (i + 1) * step] as i32; - let qx = |s: &[u16], k: usize, i: usize| s[o + k * along + i * step] as i32; - let dp0 = (px(s, 0, 2) - 2 * px(s, 0, 1) + px(s, 0, 0)).abs(); - let dp3 = (px(s, 3, 2) - 2 * px(s, 3, 1) + px(s, 3, 0)).abs(); - let dq0 = (qx(s, 0, 2) - 2 * qx(s, 0, 1) + qx(s, 0, 0)).abs(); - let dq3 = (qx(s, 3, 2) - 2 * qx(s, 3, 1) + qx(s, 3, 0)).abs(); - let (dpq0, dpq3) = (dp0 + dq0, dp3 + dq3); - let (dp, dq) = (dp0 + dp3, dq0 + dq3); - let d = dpq0 + dpq3; - if d >= beta { - return; - } - let dsam = |s: &[u16], k: usize, dpq: i32| -> bool { - dpq < (beta >> 2) - && (px(s, k, 3) - px(s, k, 0)).abs() + (qx(s, k, 0) - qx(s, k, 3)).abs() < (beta >> 3) - && (px(s, k, 0) - qx(s, k, 0)).abs() < ((5 * tc + 1) >> 1) - }; - let strong = dsam(s, 0, 2 * dpq0) && dsam(s, 3, 2 * dpq3); - let dep = dp < ((beta + (beta >> 1)) >> 3); - let deq = dq < ((beta + (beta >> 1)) >> 3); - let max = (1i32 << bd) - 1; - for k in 0..4 { - let base = o + k * along; - let p = |i: usize| s[base - (i + 1) * step] as i32; - let q = |i: usize| s[base + i * step] as i32; - let (p0, p1, p2, p3) = (p(0), p(1), p(2), p(3)); - let (q0, q1, q2, q3) = (q(0), q(1), q(2), q(3)); - if strong { - let t2 = 2 * tc; - s[base - step] = ((p2 + 2 * p1 + 2 * p0 + 2 * q0 + q1 + 4) >> 3).clamp(p0 - t2, p0 + t2) as u16; - s[base - 2 * step] = ((p2 + p1 + p0 + q0 + 2) >> 2).clamp(p1 - t2, p1 + t2) as u16; - s[base - 3 * step] = ((2 * p3 + 3 * p2 + p1 + p0 + q0 + 4) >> 3).clamp(p2 - t2, p2 + t2) as u16; - s[base] = ((p1 + 2 * p0 + 2 * q0 + 2 * q1 + q2 + 4) >> 3).clamp(q0 - t2, q0 + t2) as u16; - s[base + step] = ((p0 + q0 + q1 + q2 + 2) >> 2).clamp(q1 - t2, q1 + t2) as u16; - s[base + 2 * step] = ((p0 + q0 + q1 + 3 * q2 + 2 * q3 + 4) >> 3).clamp(q2 - t2, q2 + t2) as u16; - } else { - let mut delta = (9 * (q0 - p0) - 3 * (q1 - p1) + 8) >> 4; - if delta.abs() >= tc * 10 { - continue; - } - delta = delta.clamp(-tc, tc); - s[base - step] = (p0 + delta).clamp(0, max) as u16; - s[base] = (q0 - delta).clamp(0, max) as u16; - if dep { - let dp = ((((p2 + p0 + 1) >> 1) - p1 + delta) >> 1).clamp(-(tc >> 1), tc >> 1); - s[base - 2 * step] = (p1 + dp).clamp(0, max) as u16; - } - if deq { - let dq = ((((q2 + q0 + 1) >> 1) - q1 - delta) >> 1).clamp(-(tc >> 1), tc >> 1); - s[base + step] = (q1 + dq).clamp(0, max) as u16; - } - } - } -} - -/// Filter one 4-sample chroma edge segment. -fn filter_chroma(s: &mut [u16], o: usize, step: usize, along: usize, tc: i32, bd: u32) { - let max = (1i32 << bd) - 1; - for k in 0..4 { - let base = o + k * along; - let (p0, p1) = (s[base - step] as i32, s[base - 2 * step] as i32); - let (q0, q1) = (s[base] as i32, s[base + step] as i32); - let delta = ((((q0 - p0) << 2) + p1 - q1 + 4) >> 3).clamp(-tc, tc); - s[base - step] = (p0 + delta).clamp(0, max) as u16; - s[base] = (q0 - delta).clamp(0, max) as u16; - } -} diff --git a/crates/hevcenc/src/frame.rs b/crates/hevcenc/src/frame.rs deleted file mode 100644 index ee141e3e7..000000000 --- a/crates/hevcenc/src/frame.rs +++ /dev/null @@ -1,1481 +0,0 @@ -//! Picture coding: CTU loop, rate-distortion mode decisions (with a CABAC bit estimator) and the -//! slice_segment_data() syntax (7.3.8): coding quadtree, coding unit, prediction unit, transform tree -//! and residual coding. Reconstruction mirrors the decoding process exactly (no in-loop filters). - -use crate::cabac::{BIT, CabacEncoder, Estimator, Sink}; -use crate::inter::{self, PlaneRef}; -use crate::intra::{self, Refs}; -use crate::me::MeRef; -use crate::params::{LOG2_CTB, LOG2_MAX_TB, LOG2_MIN_CB, MAX_MERGE_CAND}; -use crate::tables::{ - ABS_MVD_GT0, ABS_MVD_GT1, CBF_CHROMA, CBF_LUMA, CODED_SUB_BLOCK, CTX_IDX_MAP, CU_SKIP, GT1, GT2, INTRA_CHROMA, LAST_X_PREFIX, LAST_Y_PREFIX, MERGE_FLAG, - MERGE_IDX, MVP_FLAG, PART_MODE, PRED_MODE, PREV_INTRA_LUMA, RQT_ROOT_CBF, SCAN_4, SIG_COEFF, SPLIT_CU, qpc_420, scan_order, -}; -use crate::transform; - -pub const F_INTRA: u8 = 1; -pub const F_SKIP: u8 = 2; -/// The luma transform block covering this 4x4 block has non-zero coefficients. -pub const F_CBF: u8 = 4; -/// The left / top edge of this 4x4 block is a transform (and prediction) block edge. -pub const E_EDGE_V: u8 = 8; -pub const E_EDGE_H: u8 = 16; - -/// Per 4x4 luma block state of the current picture. -#[derive(Clone, Copy, Default)] -pub struct Blk { - pub depth: u8, - pub flags: u8, - /// IntraPredModeY (DC for inter blocks). - pub ipm: u8, - /// L0 motion vector (quarter samples) of inter blocks. - pub mv: [i16; 2], -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CuMode { - Intra { nxn: bool, modes: [u8; 4] }, - Skip { idx: u8 }, - Merge { idx: u8 }, - Amvp { mv: [i16; 2], mvp: u8 }, -} - -#[derive(Clone, Copy, Debug)] -struct CuDec { - x: u32, - y: u32, - log2: u32, - mode: CuMode, -} - -/// One coded transform block of the current CU. -#[derive(Clone, Copy)] -struct Tb { - c: u8, - /// Luma-coordinate region the block belongs to (for cbf derivation). - lx: u32, - ly: u32, - log2: u32, - nz: bool, - off: usize, - scan: u8, -} - -/// Saved samples and block state of a square region. -struct Snap { - x: usize, - y: usize, - n: usize, - planes: [Vec; 3], - blk: Vec, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum SliceKind { - I, - P, -} - -/// Picture geometry and slice coding parameters shared by the CTU encoder. -pub struct FrameConfig { - pub bit_depth: u32, - pub width: usize, - pub height: usize, - pub kind: SliceKind, - pub slice_qp: i32, -} - -/// A square node in the coding or transform quadtree. -#[derive(Clone, Copy)] -struct TreeNode { - x: usize, - y: usize, - log2: u32, - depth: u32, -} - -impl TreeNode { - fn children(self) -> impl Iterator { - let h = 1usize << (self.log2 - 1); - [(0, 0), (h, 0), (0, h), (h, h)].into_iter().map(move |(dx, dy)| Self { x: self.x + dx, y: self.y + dy, log2: self.log2 - 1, depth: self.depth + 1 }) - } -} - -/// Transform syntax inherited from the parent node and the enclosing CU. -#[derive(Clone, Copy)] -struct TransformState { - blk_idx: u32, - parent: [bool; 2], - intra: bool, - nxn: bool, -} - -pub struct FrameCoder<'a> { - pub bd: u32, - pub w: usize, - pub h: usize, - pub cw: usize, - pub ch: usize, - w4: usize, - wctb: usize, - hctb: usize, - pub kind: SliceKind, - /// QP' (with QpBdOffset) for luma and chroma. - slice_qp: i32, - qp_y: i32, - qp_c: i32, - lambda: f64, - lambda_sad: f64, - src: &'a [Vec; 3], - pub rec: [Vec; 3], - refp: Option<&'a [Vec; 3]>, - me: Option<&'a MeRef>, - blk: Vec, - tbs: Vec, - coefs: Vec, - pred: Vec, - scratch: [Vec; 2], -} - -#[inline] -fn morton(x: u32, y: u32) -> u32 { - let mut r = 0; - for i in 0..5 { - r |= ((x >> i) & 1) << (2 * i); - r |= ((y >> i) & 1) << (2 * i + 1); - } - r -} - -/// Intra modes tried in the coarse pass of the SATD search. -const COARSE_MODES: [u32; 11] = [0, 1, 2, 6, 10, 14, 18, 22, 26, 30, 34]; - -impl<'a> FrameCoder<'a> { - pub fn new(config: FrameConfig, src: &'a [Vec; 3], refp: Option<&'a [Vec; 3]>, me: Option<&'a MeRef>) -> Self { - let FrameConfig { bit_depth: bd, width: w, height: h, kind, slice_qp } = config; - let off = 6 * (bd as i32 - 8); - let qpi = slice_qp.clamp(-off, 57); - let qp_c = qpc_420(qpi) + off; - let base = if kind == SliceKind::I { 0.57 } else { 0.62 }; - let lambda = base * 2f64.powf((slice_qp as f64 - 12.0) / 3.0) * (1u64 << (2 * (bd - 8))) as f64; - let ctb = 1usize << LOG2_CTB; - FrameCoder { - bd, - w, - h, - cw: w / 2, - ch: h / 2, - w4: w / 4, - wctb: w.div_ceil(ctb), - hctb: h.div_ceil(ctb), - kind, - slice_qp, - qp_y: slice_qp + off, - qp_c, - lambda, - lambda_sad: lambda.sqrt(), - src, - rec: [vec![0; w * h], vec![0; w * h / 4], vec![0; w * h / 4]], - refp, - me, - blk: vec![Blk::default(); (w / 4) * (h / 4)], - tbs: Vec::with_capacity(16), - coefs: Vec::with_capacity(64 * 64 * 2), - pred: vec![0; 64 * 64], - scratch: [vec![0; 32 * 32], vec![0; 32 * 32]], - } - } - - // --------------------------------------------------------------------------------------- - // neighbourhood - - /// z-scan order availability (6.4.1) of (xn, yn) for the block at (xc, yc): single slice, no tiles. - #[inline] - fn zavail(&self, xc: i32, yc: i32, xn: i32, yn: i32) -> bool { - if xn < 0 || yn < 0 || xn >= self.w as i32 || yn >= self.h as i32 { - return false; - } - let s = LOG2_CTB; - let ctb_n = (yn >> s) as usize * self.wctb + (xn >> s) as usize; - let ctb_c = (yc >> s) as usize * self.wctb + (xc >> s) as usize; - if ctb_n != ctb_c { - ctb_n < ctb_c - } else { - let m = (1 << s) - 1; - morton(((xn & m) >> 2) as u32, ((yn & m) >> 2) as u32) <= morton(((xc & m) >> 2) as u32, ((yc & m) >> 2) as u32) - } - } - - #[inline] - fn blk_at(&self, x: i32, y: i32) -> &Blk { - &self.blk[(y as usize >> 2) * self.w4 + (x as usize >> 2)] - } - - fn fill(&mut self, x: usize, y: usize, n: usize, f: impl Fn(&mut Blk)) { - for by in (y >> 2)..((y + n) >> 2) { - for b in &mut self.blk[by * self.w4 + (x >> 2)..by * self.w4 + ((x + n) >> 2)] { - f(b); - } - } - } - - /// Mark the CU's left / top edges and, for CUs larger than the maximum transform size, the - /// inner transform edges (deblocking). - fn mark_edges(&mut self, x: usize, y: usize, log2: u32) { - let n = 1usize << log2; - let t = 1usize << log2.min(LOG2_MAX_TB); - for ty in (y..y + n).step_by(t) { - for tx in (x..x + n).step_by(t) { - for by in ty / 4..(ty + t) / 4 { - self.blk[by * self.w4 + tx / 4].flags |= E_EDGE_V; - } - for b in &mut self.blk[(ty / 4) * self.w4 + tx / 4..(ty / 4) * self.w4 + (tx + t) / 4] { - b.flags |= E_EDGE_H; - } - } - } - } - - /// In-loop deblocking of the finished picture. - pub fn deblock(&mut self) { - crate::deblock::deblock(&mut self.rec, &self.blk, self.w, self.h, self.slice_qp, self.bd); - } - - fn snap(&self, x: usize, y: usize, n: usize) -> Snap { - let mut planes: [Vec; 3] = Default::default(); - for (c, p) in planes.iter_mut().enumerate() { - let (px, py, pn, stride) = if c == 0 { (x, y, n, self.w) } else { (x / 2, y / 2, n / 2, self.cw) }; - p.reserve(pn * pn); - for r in 0..pn { - p.extend_from_slice(&self.rec[c][(py + r) * stride + px..][..pn]); - } - } - let mut blk = Vec::with_capacity((n / 4) * (n / 4)); - for by in (y >> 2)..((y + n) >> 2) { - blk.extend_from_slice(&self.blk[by * self.w4 + (x >> 2)..by * self.w4 + ((x + n) >> 2)]); - } - Snap { x, y, n, planes, blk } - } - - fn restore(&mut self, s: &Snap) { - let (x, y, n) = (s.x, s.y, s.n); - for c in 0..3 { - let (px, py, pn, stride) = if c == 0 { (x, y, n, self.w) } else { (x / 2, y / 2, n / 2, self.cw) }; - for r in 0..pn { - self.rec[c][(py + r) * stride + px..][..pn].copy_from_slice(&s.planes[c][r * pn..r * pn + pn]); - } - } - let bw = n / 4; - for (i, by) in ((y >> 2)..((y + n) >> 2)).enumerate() { - self.blk[by * self.w4 + (x >> 2)..by * self.w4 + (x >> 2) + bw].copy_from_slice(&s.blk[i * bw..i * bw + bw]); - } - } - - // --------------------------------------------------------------------------------------- - // picture loop - - /// Code all CTUs of the picture into `enc` (one slice); the caller flushes the encoder. - pub fn encode(&mut self, enc: &mut CabacEncoder) { - let ctb = 1usize << LOG2_CTB; - let total = self.wctb * self.hctb; - for i in 0..total { - let (x, y) = ((i % self.wctb) * ctb, (i / self.wctb) * ctb); - let mut est = Estimator::new(enc.ctx); - let mut decs = Vec::with_capacity(64); - self.decide(x, y, LOG2_CTB, 0, &mut est, &mut decs); - let mut idx = 0; - self.write_cq(enc, TreeNode { x, y, log2: LOG2_CTB, depth: 0 }, &decs, &mut idx); - debug_assert_eq!(idx, decs.len()); - debug_assert_eq!(est.ctx, enc.ctx); - enc.terminate((i + 1 == total) as u32); // end_of_slice_segment_flag - } - } - - fn write_split(&self, s: &mut S, x: usize, y: usize, depth: u32, split: bool) { - let (x, y) = (x as i32, y as i32); - let mut inc = 0; - if self.zavail(x, y, x - 1, y) && self.blk_at(x - 1, y).depth as u32 > depth { - inc += 1; - } - if self.zavail(x, y, x, y - 1) && self.blk_at(x, y - 1).depth as u32 > depth { - inc += 1; - } - s.decision(SPLIT_CU + inc, split as u32); - } - - /// Final pass: re-code the decided quadtree with the real arithmetic coder. - fn write_cq(&mut self, enc: &mut CabacEncoder, node: TreeNode, decs: &[CuDec], idx: &mut usize) { - let TreeNode { x, y, log2, depth } = node; - if x >= self.w || y >= self.h { - return; - } - let n = 1usize << log2; - let inside = x + n <= self.w && y + n <= self.h; - let d = decs[*idx]; - let leaf = d.x as usize == x && d.y as usize == y && d.log2 == log2; - if inside && log2 > LOG2_MIN_CB { - self.write_split(enc, x, y, depth, !leaf); - } else { - debug_assert_eq!(leaf, log2 == LOG2_MIN_CB); - } - if leaf { - *idx += 1; - let (eff, _) = self.encode_cu(enc, x, y, log2, depth, d.mode); - debug_assert_eq!(eff, d.mode); - } else { - for child in node.children() { - self.write_cq(enc, child, decs, idx); - } - } - } - - #[inline] - fn cost(&self, dist: u64, bits: u64) -> f64 { - dist as f64 + self.lambda * bits as f64 / BIT as f64 - } - - /// Rate-distortion decision for the quadtree node at (x, y); leaves the chosen reconstruction - /// and block state in place, advances `est` and appends the decided CUs. Returns the RD cost. - fn decide(&mut self, x: usize, y: usize, log2: u32, depth: u32, est: &mut Estimator, decs: &mut Vec) -> f64 { - if x >= self.w || y >= self.h { - return 0.0; - } - let n = 1usize << log2; - let inside = x + n <= self.w && y + n <= self.h; - let can_split = log2 > LOG2_MIN_CB; - let base = est.bits; - let leaf_ok = inside && (self.kind == SliceKind::P || log2 <= LOG2_MAX_TB); - let mut best: Option<(f64, Estimator, Snap, CuMode)> = None; - if leaf_ok { - let mut e0 = est.clone(); - if can_split { - self.write_split(&mut e0, x, y, depth, false); - } - let try_mode = |me: &mut Self, mode: CuMode, best: &mut Option<(f64, Estimator, Snap, CuMode)>| { - let mut e = e0.clone(); - let (eff, dist) = me.encode_cu(&mut e, x, y, log2, depth, mode); - let cost = me.cost(dist, e.bits - base); - if best.as_ref().is_none_or(|b| cost < b.0) { - *best = Some((cost, e, me.snap(x, y, n), eff)); - } - }; - let mut inter_satd = u64::MAX; - if self.kind == SliceKind::P { - let merge = self.merge_list(x, y, n); - let (midx, msatd) = self.best_merge(x, y, n, &merge); - try_mode(self, CuMode::Skip { idx: midx as u8 }, &mut best); - try_mode(self, CuMode::Merge { idx: midx as u8 }, &mut best); - let amvp = self.amvp(x, y, n); - let (mv, mvp, asatd) = self.motion_search(x, y, n, &amvp, &merge); - if mv != merge[midx] { - try_mode(self, CuMode::Amvp { mv, mvp }, &mut best); - } - inter_satd = msatd.min(asatd); - } - if log2 <= LOG2_MAX_TB && !matches!(best, Some((_, _, _, CuMode::Skip { .. }))) { - let (mode, satd) = self.search_intra_mode(x, y, log2); - if satd < inter_satd { - try_mode(self, CuMode::Intra { nxn: false, modes: [mode as u8; 4] }, &mut best); - if log2 == LOG2_MIN_CB { - let modes = self.search_nxn_modes(x, y); - try_mode(self, CuMode::Intra { nxn: true, modes }, &mut best); - } - } - } - if let Some(b) = &best { - self.restore(&b.2); - } - } - let early = matches!(best, Some((_, _, _, CuMode::Skip { .. }))); - if can_split && (!leaf_ok || !early) { - let mut e = est.clone(); - if inside { - self.write_split(&mut e, x, y, depth, true); - } - let limit = best.as_ref().map_or(f64::INFINITY, |b| b.0); - let mut cost = self.cost(0, e.bits - base); - let mut d2 = Vec::new(); - let h = n / 2; - for (dx, dy) in [(0, 0), (h, 0), (0, h), (h, h)] { - cost += self.decide(x + dx, y + dy, log2 - 1, depth + 1, &mut e, &mut d2); - if cost >= limit { - break; - } - } - if cost < limit { - *est = e; - decs.extend(d2); - return cost; - } - } - // A coding unit always has at least one candidate mode (intra, or skip/merge in P - // slices); without one, report an infinite cost. - let Some((cost, e, snap, mode)) = best else { return f64::INFINITY }; - self.restore(&snap); - *est = e; - decs.push(CuDec { x: x as u32, y: y as u32, log2, mode }); - cost - } - - // --------------------------------------------------------------------------------------- - // coding unit - - fn skip_ctx(&self, x: usize, y: usize) -> usize { - let (x, y) = (x as i32, y as i32); - let mut inc = 0; - if self.zavail(x, y, x - 1, y) && self.blk_at(x - 1, y).flags & F_SKIP != 0 { - inc += 1; - } - if self.zavail(x, y, x, y - 1) && self.blk_at(x, y - 1).flags & F_SKIP != 0 { - inc += 1; - } - inc - } - - /// Predict, transform, quantise and reconstruct one CU and write its syntax into `s`. - /// Returns the effective mode (a residual-free merge becomes a skip) and the SSE. - fn encode_cu(&mut self, s: &mut S, x: usize, y: usize, log2: u32, depth: u32, mode: CuMode) -> (CuMode, u64) { - let n = 1usize << log2; - self.tbs.clear(); - self.coefs.clear(); - let p_slice = self.kind == SliceKind::P; - let skip_ctx = if p_slice { self.skip_ctx(x, y) } else { 0 }; - let d8 = depth as u8; - match mode { - CuMode::Intra { nxn, modes } => { - self.fill(x, y, n, |b| { - b.depth = d8; - b.flags = F_INTRA; - b.ipm = modes[0]; - }); - self.mark_edges(x, y, log2); - if nxn { - let h = n / 2; - for k in 1..4 { - let m = modes[k]; - self.fill(x + (k & 1) * h, y + (k >> 1) * h, h, |b| b.ipm = m); - } - for k in 0..4 { - let (px, py) = (x + (k & 1) * h, y + (k >> 1) * h); - self.code_tb(0, px, py, 2, Some(modes[k] as u32), (x, y)); - } - for c in 1..3 { - self.code_tb(c, x / 2, y / 2, 2, Some(modes[0] as u32), (x, y)); - } - } else { - self.code_tus(x, y, log2, Some(modes[0] as u32)); - } - if p_slice { - s.decision(CU_SKIP + skip_ctx, 0); - s.decision(PRED_MODE, 1); - } - if log2 == LOG2_MIN_CB { - s.decision(PART_MODE, (!nxn) as u32); - } - self.write_intra_modes(s, x, y, n, nxn, &modes); - s.decision(INTRA_CHROMA, 0); // intra_chroma_pred_mode 4 (DM) - let mut ti = 0; - self.write_tt(s, TreeNode { x, y, log2, depth: 0 }, TransformState { blk_idx: 0, parent: [true, true], intra: true, nxn }, &mut ti); - (mode, self.cu_dist(x, y, n)) - } - _ => { - let mv = match mode { - CuMode::Skip { idx } | CuMode::Merge { idx } => self.merge_list(x, y, n)[idx as usize], - CuMode::Amvp { mv, .. } => mv, - // Handled by the arm above. - CuMode::Intra { .. } => [0, 0], - }; - let amvp = if let CuMode::Amvp { .. } = mode { self.amvp(x, y, n) } else { [[0; 2]; 2] }; - let skip = matches!(mode, CuMode::Skip { .. }); - self.fill(x, y, n, |b| { - b.depth = d8; - b.flags = if skip { F_SKIP } else { 0 }; - b.ipm = 1; - b.mv = mv; - }); - self.mark_edges(x, y, log2); - self.predict_inter(x, y, n, mv); - let mut eff = mode; - let mut root = false; - if !skip { - self.code_tus(x, y, log2, None); - root = self.tbs.iter().any(|t| t.nz); - if !root && let CuMode::Merge { idx } = mode { - // rqt_root_cbf would be inferred: code a skipped CU instead - eff = CuMode::Skip { idx }; - self.fill(x, y, n, |b| b.flags |= F_SKIP); - } - } - match eff { - CuMode::Skip { idx } => { - s.decision(CU_SKIP + skip_ctx, 1); - write_merge_idx(s, idx as usize); - } - CuMode::Merge { idx } => { - s.decision(CU_SKIP + skip_ctx, 0); - s.decision(PRED_MODE, 0); - s.decision(PART_MODE, 1); // PART_2Nx2N - s.decision(MERGE_FLAG, 1); - write_merge_idx(s, idx as usize); - let mut ti = 0; - self.write_tt( - s, - TreeNode { x, y, log2, depth: 0 }, - TransformState { blk_idx: 0, parent: [true, true], intra: false, nxn: false }, - &mut ti, - ); - } - CuMode::Amvp { mv, mvp } => { - s.decision(CU_SKIP + skip_ctx, 0); - s.decision(PRED_MODE, 0); - s.decision(PART_MODE, 1); - s.decision(MERGE_FLAG, 0); - let p = amvp[mvp as usize]; - write_mvd(s, [mv[0] as i32 - p[0] as i32, mv[1] as i32 - p[1] as i32]); - s.decision(MVP_FLAG, mvp as u32); - s.decision(RQT_ROOT_CBF, root as u32); - if root { - let mut ti = 0; - self.write_tt( - s, - TreeNode { x, y, log2, depth: 0 }, - TransformState { blk_idx: 0, parent: [true, true], intra: false, nxn: false }, - &mut ti, - ); - } - } - // Handled by the arm above. - CuMode::Intra { .. } => {} - } - (eff, self.cu_dist(x, y, n)) - } - } - } - - /// Transform units of a 2Nx2N CU (split to the maximum TB size), luma then Cb, Cr per TU. - fn code_tus(&mut self, x: usize, y: usize, log2: u32, intra_mode: Option) { - let tl = log2.min(LOG2_MAX_TB); - let t = 1usize << tl; - let k = 1usize << (log2 - tl); - for i in 0..k * k { - // z-order of at most 2x2 TUs - let (tx, ty) = (x + (i & 1) * t, y + (i >> 1) * t); - self.code_tb(0, tx, ty, tl, intra_mode, (tx, ty)); - self.code_tb(1, tx / 2, ty / 2, tl - 1, intra_mode, (tx, ty)); - self.code_tb(2, tx / 2, ty / 2, tl - 1, intra_mode, (tx, ty)); - } - } - - fn cu_dist(&self, x: usize, y: usize, n: usize) -> u64 { - let mut d = 0u64; - for c in 0..3 { - let (px, py, pn, stride) = if c == 0 { (x, y, n, self.w) } else { (x / 2, y / 2, n / 2, self.cw) }; - for r in 0..pn { - let o = (py + r) * stride + px; - let a = &self.src[c][o..o + pn]; - let b = &self.rec[c][o..o + pn]; - d += a.iter().zip(b).map(|(&p, &q)| (p as i64 - q as i64).pow(2) as u64).sum::(); - } - } - d - } - - /// Code one transform block of component `c` at component position (x, y): optional intra - /// prediction, residual, transform, quantisation and reconstruction. - fn code_tb(&mut self, c: usize, x: usize, y: usize, log2: u32, intra_mode: Option, lpos: (usize, usize)) { - let n = 1usize << log2; - let stride = if c == 0 { self.w } else { self.cw }; - if let Some(m) = intra_mode { - let refs = self.build_refs(c, x, y, log2); - let bd = self.bd; - predict_from_refs(&refs, c, m, n, bd, &mut self.rec[c][y * stride + x..], stride); - } - let mut scr = std::mem::take(&mut self.scratch); - let [r, coef] = &mut scr; - for yy in 0..n { - let o = (y + yy) * stride + x; - for xx in 0..n { - r[yy * n + xx] = self.src[c][o + xx] as i32 - self.rec[c][o + xx] as i32; - } - } - let intra = intra_mode.is_some(); - let dst = intra && c == 0 && n == 4; - transform::forward(&r[..n * n], &mut coef[..n * n], n, dst, self.bd); - let qp = if c == 0 { self.qp_y } else { self.qp_c }; - let off = self.coefs.len(); - self.coefs.resize(off + n * n, 0); - let lv = &mut self.coefs[off..off + n * n]; - let scan = match intra_mode { - Some(m) if log2 == 2 || (log2 == 3 && c == 0) => scan_idx_for_mode(m), - _ => 0, - }; - let nz = transform::quantize(&coef[..n * n], lv, n, qp, intra, self.bd) > 0; - if nz { - transform::sign_hiding(&coef[..n * n], lv, log2, scan as usize, qp, self.bd); - } - if nz { - let d = &mut r[..n * n]; - transform::dequantize(lv, d, n, qp, self.bd); - transform::inverse(d, n, dst, self.bd); - let max = (1i32 << self.bd) - 1; - for yy in 0..n { - let o = (y + yy) * stride + x; - for xx in 0..n { - let v = &mut self.rec[c][o + xx]; - *v = (*v as i32 + d[yy * n + xx]).clamp(0, max) as u16; - } - } - } - self.scratch = scr; - if nz && c == 0 { - self.fill(x, y, n, |b| b.flags |= F_CBF); - } - self.tbs.push(Tb { c: c as u8, lx: lpos.0 as u32, ly: lpos.1 as u32, log2, nz, off, scan }); - } - - /// Reference samples (substituted, unfiltered) of an intra block, as in 8.4.4.2.1 / 8.4.4.2.2. - fn build_refs(&self, c: usize, x0: usize, y0: usize, log2: u32) -> Refs { - let n = 1usize << log2; - let sc = if c == 0 { 1 } else { 2 }; - let (x0, y0) = (x0 as i32, y0 as i32); - let (xl, yl) = (x0 * sc, y0 * sc); - let unit = 4 / sc as usize; - let stride = if c == 0 { self.w } else { self.cw }; - let mut al = [false; 129]; - let mut at = [false; 129]; - let plane = &self.rec[c]; - let mut r = Refs::default(); - if self.zavail(xl, yl, xl - 1, yl - 1) { - al[0] = true; - at[0] = true; - let v = plane[(y0 - 1) as usize * stride + (x0 - 1) as usize]; - r.left[0] = v; - r.top[0] = v; - } - let mut k = 0; - while k < 2 * n { - let yn = y0 + k as i32; - if self.zavail(xl, yl, xl - 1, yn * sc) { - for i in 0..unit { - al[1 + k + i] = true; - r.left[1 + k + i] = plane[(yn as usize + i) * stride + (x0 - 1) as usize]; - } - } - let xn = x0 + k as i32; - if self.zavail(xl, yl, xn * sc, yl - 1) { - let o = (y0 - 1) as usize * stride + xn as usize; - for i in 0..unit { - at[1 + k + i] = true; - r.top[1 + k + i] = plane[o + i]; - } - } - k += unit; - } - intra::substitute(&mut r, &al, &at, n, self.bd); - r - } - - // --------------------------------------------------------------------------------------- - // syntax writers - - /// Candidate modes candModeList (8.4.2) of the prediction block at (xp, yp). - fn mpm(&self, xp: usize, yp: usize) -> [u32; 3] { - let (xp, yp) = (xp as i32, yp as i32); - let cand_a = if self.zavail(xp, yp, xp - 1, yp) { - let b = self.blk_at(xp - 1, yp); - if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 } - } else { - 1 - }; - let cand_b = if self.zavail(xp, yp, xp, yp - 1) && yp > ((yp >> LOG2_CTB) << LOG2_CTB) { - let b = self.blk_at(xp, yp - 1); - if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 } - } else { - 1 - }; - if cand_a == cand_b { - if cand_a < 2 { [0, 1, 26] } else { [cand_a, 2 + ((cand_a + 29) % 32), 2 + ((cand_a - 2 + 1) % 32)] } - } else { - let c = if cand_a != 0 && cand_b != 0 { - 0 - } else if cand_a != 1 && cand_b != 1 { - 1 - } else { - 26 - }; - [cand_a, cand_b, c] - } - } - - fn write_intra_modes(&self, s: &mut S, x: usize, y: usize, n: usize, nxn: bool, modes: &[u8; 4]) { - let nb = if nxn { 2 } else { 1 }; - let pb = n / nb; - let mut codes = [(false, 0u32); 4]; - for j in 0..nb { - for i in 0..nb { - let k = j * nb + i; - let list = self.mpm(x + i * pb, y + j * pb); - let m = modes[k] as u32; - codes[k] = match list.iter().position(|&c| c == m) { - Some(idx) => (true, idx as u32), - None => { - let mut sorted = list; - sorted.sort_unstable(); - let mut r = m; - for &c in sorted.iter().rev() { - if r > c { - r -= 1; - } - } - (false, r) - } - }; - } - } - for code in codes.iter().take(nb * nb) { - s.decision(PREV_INTRA_LUMA, code.0 as u32); - } - for &(prev, v) in codes.iter().take(nb * nb) { - if prev { - s.bypass((v > 0) as u32); - if v > 0 { - s.bypass((v > 1) as u32); - } - } else { - s.bypass_bits(v, 5); - } - } - } - - fn chroma_cbf(&self, c: u8, x0: usize, y0: usize, n: usize) -> bool { - self.tbs.iter().any(|t| t.c == c && t.nz && (t.lx as usize) >= x0 && (t.lx as usize) < x0 + n && (t.ly as usize) >= y0 && (t.ly as usize) < y0 + n) - } - - /// transform_tree() (7.3.8.8) over the coded transform blocks of the CU; no split flag is ever - /// coded (max_transform_hierarchy_depth_* = 0), splits are inferred. - fn write_tt(&self, s: &mut S, node: TreeNode, state: TransformState, ti: &mut usize) { - let TreeNode { x: x0, y: y0, log2, depth } = node; - let TransformState { blk_idx, parent, intra, nxn } = state; - let n = 1usize << log2; - let split = log2 > LOG2_MAX_TB || (nxn && depth == 0); - let mut cbf_c = [false; 2]; - if log2 > 2 { - for c in 0..2 { - if depth == 0 || parent[c] { - cbf_c[c] = self.chroma_cbf(c as u8 + 1, x0, y0, n); - s.decision(CBF_CHROMA + depth as usize, cbf_c[c] as u32); - } - } - } else if depth > 0 { - cbf_c = parent; - } - if split { - for (i, child) in node.children().enumerate() { - self.write_tt(s, child, TransformState { blk_idx: i as u32, parent: cbf_c, ..state }, ti); - } - return; - } - let tb = self.tbs[*ti]; - *ti += 1; - debug_assert_eq!(tb.c, 0); - if intra || depth != 0 || cbf_c[0] || cbf_c[1] { - s.decision(CBF_LUMA + (depth == 0) as usize, tb.nz as u32); - } else { - debug_assert!(tb.nz, "inferred cbf_luma needs coefficients"); - } - if tb.nz { - let n2 = 1usize << (2 * tb.log2); - residual_coding(s, &self.coefs[tb.off..tb.off + n2], tb.log2, 0, tb.scan as usize); - } - if log2 > 2 || blk_idx == 3 { - for c in 0..2 { - let tb = self.tbs[*ti]; - *ti += 1; - debug_assert_eq!(tb.c as usize, c + 1); - debug_assert_eq!(tb.nz, cbf_c[c]); - if cbf_c[c] { - let n2 = 1usize << (2 * tb.log2); - residual_coding(s, &self.coefs[tb.off..tb.off + n2], tb.log2, c + 1, tb.scan as usize); - } - } - } - } - - // --------------------------------------------------------------------------------------- - // inter prediction - - /// Prediction block availability (6.4.2) for a 2Nx2N CU: available and not intra. - #[inline] - fn pb_avail(&self, xp: i32, yp: i32, xn: i32, yn: i32) -> bool { - self.zavail(xp, yp, xn, yn) && self.blk_at(xn, yn).flags & F_INTRA == 0 - } - - /// Merge candidate list (8.5.3.2.2 - 8.5.3.2.5) of a 2Nx2N P-slice CU: spatial candidates and - /// zero candidates (no temporal candidate, one reference picture). - pub fn merge_list(&self, x: usize, y: usize, n: usize) -> [[i16; 2]; MAX_MERGE_CAND] { - let (xp, yp, w, h) = (x as i32, y as i32, n as i32, n as i32); - let mv = |p: (i32, i32)| self.blk_at(p.0, p.1).mv; - let mut list = [[0i16; 2]; MAX_MERGE_CAND]; - let mut k = 0; - let pa1 = (xp - 1, yp + h - 1); - let av_a1 = self.pb_avail(xp, yp, pa1.0, pa1.1); - if av_a1 { - list[k] = mv(pa1); - k += 1; - } - let pb1 = (xp + w - 1, yp - 1); - let av_b1 = self.pb_avail(xp, yp, pb1.0, pb1.1); - let fl_b1 = av_b1 && !(av_a1 && mv(pa1) == mv(pb1)); - if fl_b1 { - list[k] = mv(pb1); - k += 1; - } - let pb0 = (xp + w, yp - 1); - let fl_b0 = self.pb_avail(xp, yp, pb0.0, pb0.1) && !(av_b1 && mv(pb1) == mv(pb0)); - if fl_b0 { - list[k] = mv(pb0); - k += 1; - } - let pa0 = (xp - 1, yp + h); - let fl_a0 = self.pb_avail(xp, yp, pa0.0, pa0.1) && !(av_a1 && mv(pa1) == mv(pa0)); - if fl_a0 { - list[k] = mv(pa0); - k += 1; - } - let pb2 = (xp - 1, yp - 1); - let fl_b2 = self.pb_avail(xp, yp, pb2.0, pb2.1) - && !((av_a1 && mv(pa1) == mv(pb2)) || (av_b1 && mv(pb1) == mv(pb2))) - && (fl_a0 as u32 + av_a1 as u32 + fl_b0 as u32 + fl_b1 as u32) != 4; - if fl_b2 { - list[k] = mv(pb2); - } - // remaining entries are zero candidates with refIdx 0 - list - } - - /// AMVP candidates (8.5.3.2.6, 8.5.3.2.7) of a 2Nx2N CU with one reference picture. - pub fn amvp(&self, x: usize, y: usize, n: usize) -> [[i16; 2]; 2] { - let (xp, yp, w, h) = (x as i32, y as i32, n as i32, n as i32); - let pa = [(xp - 1, yp + h), (xp - 1, yp + h - 1)]; - let av_a = [self.pb_avail(xp, yp, pa[0].0, pa[0].1), self.pb_avail(xp, yp, pa[1].0, pa[1].1)]; - let is_scaled = av_a[0] || av_a[1]; - let mut a = None; - for k in 0..2 { - if av_a[k] && a.is_none() { - a = Some(self.blk_at(pa[k].0, pa[k].1).mv); - } - } - let pb = [(xp + w, yp - 1), (xp + w - 1, yp - 1), (xp - 1, yp - 1)]; - let mut b = None; - for p in pb { - if b.is_none() && self.pb_avail(xp, yp, p.0, p.1) { - b = Some(self.blk_at(p.0, p.1).mv); - } - } - if !is_scaled && b.is_some() { - a = b; - } - let mut list = [[0i16; 2]; 2]; - match (a, b) { - (Some(a), Some(b)) => { - list[0] = a; - if b != a { - list[1] = b; - } - } - (Some(a), None) => list[0] = a, - (None, Some(b)) => list[0] = b, - (None, None) => {} - } - list - } - - fn predict_inter(&mut self, x: usize, y: usize, n: usize, mv: [i16; 2]) { - // Inter modes are only tried in P slices, which have a reference picture. - let Some(refp) = self.refp else { return }; - for c in 0..3 { - let (px, py, pn, pw, ph) = if c == 0 { (x, y, n, self.w, self.h) } else { (x / 2, y / 2, n / 2, self.cw, self.ch) }; - let pr = PlaneRef { data: &refp[c], w: pw, h: ph }; - if c == 0 { - inter::mc_luma(pr, self.bd, px as i32, py as i32, mv, (pn, pn), &mut self.pred); - } else { - inter::mc_chroma(pr, self.bd, px as i32, py as i32, mv, (pn, pn), &mut self.pred); - } - inter::put_uni(&self.pred, pn, pn, self.bd, &mut self.rec[c][py * pw + px..], pw); - } - } - - // --------------------------------------------------------------------------------------- - // estimation helpers - - /// Best merge index by SATD of the (approximate) luma prediction. - fn best_merge(&self, x: usize, y: usize, n: usize, list: &[[i16; 2]; MAX_MERGE_CAND]) -> (usize, u64) { - let Some(me) = self.me else { return (0, u64::MAX / 4) }; - let mut buf = vec![0u16; n * n]; - let mut best = (0, u64::MAX); - for (i, &mv) in list.iter().enumerate() { - if list[..i].contains(&mv) { - continue; - } - me.approx_pred(x as i32, y as i32, n, mv, &mut buf); - let satd = satd(&self.src[0][y * self.w + x..], self.w, &buf, n, n) as u64; - let cost = satd + (self.lambda_sad * (i + 1) as f64) as u64; - if cost < best.1 { - best = (i, cost); - } - } - best - } - - /// Integer then fractional motion search. Returns (mv, mvp index, SATD cost). - fn motion_search(&self, x: usize, y: usize, n: usize, amvp: &[[i16; 2]; 2], merge: &[[i16; 2]; MAX_MERGE_CAND]) -> ([i16; 2], u8, u64) { - let Some(me) = self.me else { return (amvp[0], 0, u64::MAX / 4) }; - let (xi, yi) = (x as i32, y as i32); - let src = &self.src[0][y * self.w + x..]; - let ls = self.lambda_sad; - let mv_bits = |mv: [i32; 2]| -> f64 { amvp.iter().map(|p| mvd_bits(mv[0] - p[0] as i32) + mvd_bits(mv[1] - p[1] as i32)).fold(f64::MAX, f64::min) }; - let (lo_x, hi_x, lo_y, hi_y) = me.int_range(xi, yi, n); - let clamp_i = |v: [i32; 2]| [v[0].clamp(lo_x, hi_x), v[1].clamp(lo_y, hi_y)]; - let sad_cost = |v: [i32; 2]| -> f64 { me.sad(src, self.w, xi + v[0], yi + v[1], n) as f64 + ls * mv_bits([v[0] * 4, v[1] * 4]) }; - // starting points - let mut best = [0i32; 2]; - let mut best_cost = sad_cost(best); - for mv in amvp.iter().chain(merge.iter()) { - let v = clamp_i([(mv[0] as i32 + 2) >> 2, (mv[1] as i32 + 2) >> 2]); - let c = sad_cost(v); - if c < best_cost { - best_cost = c; - best = v; - } - } - // square pattern search with decreasing steps - let mut step = 16; - while step >= 1 { - let mut moved = true; - let mut iters = 0; - while moved && iters < 4 { - moved = false; - iters += 1; - let center = best; - for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { - let v = clamp_i([center[0] + dx * step, center[1] + dy * step]); - if v == center { - continue; - } - let c = sad_cost(v); - if c < best_cost { - best_cost = c; - best = v; - moved = true; - } - } - } - step /= 2; - } - // fractional refinement with SATD on the approximate interpolated planes - let mut buf = vec![0u16; n * n]; - let mut satd_cost = |mv: [i32; 2]| -> f64 { - me.approx_pred(xi, yi, n, [mv[0] as i16, mv[1] as i16], &mut buf); - satd(src, self.w, &buf, n, n) as f64 + ls * mv_bits(mv) - }; - let mut bq = [best[0] * 4, best[1] * 4]; - let mut bc = satd_cost(bq); - for step in [2, 1] { - let center = bq; - for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { - let v = [center[0] + dx * step, center[1] + dy * step]; - let c = satd_cost(v); - if c < bc { - bc = c; - bq = v; - } - } - } - let mv = [bq[0] as i16, bq[1] as i16]; - let bits = |p: &[i16; 2]| mvd_bits(bq[0] - p[0] as i32) + mvd_bits(bq[1] - p[1] as i32); - let mvp = if bits(&amvp[1]) < bits(&amvp[0]) { 1 } else { 0 }; - (mv, mvp, bc as u64) - } - - /// Best 2Nx2N intra luma mode by SATD (coarse grid, then refinement around the best angle). - fn search_intra_mode(&self, x: usize, y: usize, log2: u32) -> (u32, u64) { - let n = 1usize << log2; - let refs = self.build_refs(0, x, y, log2); - let mpm = self.mpm(x, y); - let mut buf = vec![0u16; n * n]; - let src = &self.src[0][y * self.w + x..]; - let mut eval = |m: u32| -> u64 { - predict_from_refs(&refs, 0, m, n, self.bd, &mut buf, n); - let bits = match mpm.iter().position(|&c| c == m) { - Some(0) => 2.0, - Some(_) => 3.0, - None => 6.0, - }; - satd(src, self.w, &buf, n, n) as u64 + (self.lambda_sad * bits) as u64 - }; - let mut best = (1u32, u64::MAX); - let mut tried = [false; 35]; - for &m in COARSE_MODES.iter().chain(mpm.iter()) { - if !tried[m as usize] { - tried[m as usize] = true; - let c = eval(m); - if c < best.1 { - best = (m, c); - } - } - } - if best.0 >= 2 { - for d in [2i32, 1] { - let center = best.0 as i32; - for m in [center - d, center + d] { - if (2..=34).contains(&m) && !tried[m as usize] { - tried[m as usize] = true; - let c = eval(m as u32); - if c < best.1 { - best = (m as u32, c); - } - } - } - } - } - best - } - - /// Modes of the four 4x4 PUs of an NxN intra CU, chosen sequentially (each PU reconstructed - /// before the next one is searched, as in the real coding order). - fn search_nxn_modes(&mut self, x: usize, y: usize) -> [u8; 4] { - let mut modes = [1u8; 4]; - self.fill(x, y, 8, |b| { - b.flags = F_INTRA; - b.ipm = 1; - }); - for k in 0..4 { - let (px, py) = (x + (k & 1) * 4, y + (k >> 1) * 4); - let (m, _) = self.search_intra_mode(px, py, 2); - modes[k] = m as u8; - self.fill(px, py, 4, |b| b.ipm = m as u8); - self.tbs.clear(); - self.coefs.clear(); - self.code_tb(0, px, py, 2, Some(m), (px, py)); - } - modes - } -} - -/// Filter (luma) and predict an intra block from substituted references. -fn predict_from_refs(refs: &Refs, c: usize, mode: u32, n: usize, bd: u32, out: &mut [u16], os: usize) { - if c == 0 { - let mut r = refs.clone(); - intra::filter(&mut r, mode, n, true, true, bd); - intra::predict(&r, mode, n, true, bd, out, os); - } else { - intra::predict(refs, mode, n, false, bd, out, os); - } -} - -/// scanIdx (7.4.9.11) for an intra prediction mode. -fn scan_idx_for_mode(m: u32) -> u8 { - if (6..=14).contains(&m) { - 2 - } else if (22..=30).contains(&m) { - 1 - } else { - 0 - } -} - -/// Approximate bits of one motion vector difference component. -fn mvd_bits(v: i32) -> f64 { - if v == 0 { 1.0 } else { (2 * (32 - (v.unsigned_abs()).leading_zeros()) + 1) as f64 } -} - -fn write_merge_idx(s: &mut S, idx: usize) { - if MAX_MERGE_CAND > 1 { - s.decision(MERGE_IDX, (idx > 0) as u32); - if idx > 0 { - let mut i = 1; - while i < MAX_MERGE_CAND - 1 { - let b = idx > i; - s.bypass(b as u32); - if !b { - break; - } - i += 1; - } - } - } -} - -/// mvd_coding() (7.3.8.9). -fn write_mvd(s: &mut S, d: [i32; 2]) { - let g0 = [d[0] != 0, d[1] != 0]; - s.decision(ABS_MVD_GT0, g0[0] as u32); - s.decision(ABS_MVD_GT0, g0[1] as u32); - for k in 0..2 { - if g0[k] { - s.decision(ABS_MVD_GT1, (d[k].abs() > 1) as u32); - } - } - for k in 0..2 { - if g0[k] { - let a = d[k].unsigned_abs(); - if a > 1 { - // abs_mvd_minus2: EG1 - let mut v = a - 2; - let mut kk = 1; - while v >= (1 << kk) { - s.bypass(1); - v -= 1 << kk; - kk += 1; - } - s.bypass(0); - s.bypass_bits(v, kk); - } - s.bypass((d[k] < 0) as u32); - } - } -} - -/// coeff_abs_level_remaining binarisation (9.3.3.11): TR prefix up to 3 with Rice suffix, then -/// an escape with an Exp-Golomb-like suffix. -fn write_remaining(s: &mut S, v: u32, rice: u32) { - if v < (4 << rice) { - let p = v >> rice; - for _ in 0..p { - s.bypass(1); - } - s.bypass(0); - s.bypass_bits(v & ((1 << rice) - 1), rice); - } else { - let u = v - (2 << rice); - let kr = 31 - u.leading_zeros(); // k + rice - let k = kr - rice; - for _ in 0..k + 3 { - s.bypass(1); - } - s.bypass(0); - s.bypass_bits(u - (1 << kr), kr); - } -} - -/// last_sig_coeff_{x,y}_prefix value and suffix for a position. -fn last_prefix(v: u32) -> (u32, u32, u32) { - if v < 4 { - return (v, 0, 0); - } - let mut p = 4; - loop { - let nb = (p >> 1) - 1; - let min = (1 << nb) * (2 + (p & 1)); - let next_nb = ((p + 1) >> 1) - 1; - let next = (1 << next_nb) * (2 + ((p + 1) & 1)); - if v < next { - return (p, v - min, nb); - } - p += 1; - } -} - -/// Context increment for significant_coeff_flag (H.265 9.3.4.2). -fn significance_context(group: (usize, usize), pos: (usize, usize), log2: u32, prev_csbf: u8, c: usize, scan_idx: usize) -> usize { - let (xs, ys) = group; - let (xp, yp) = pos; - let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); - if log2 == 2 { - CTX_IDX_MAP[(yc << 2) + xc] as usize - } else if xc + yc == 0 { - 0 - } else { - let mut sc = match prev_csbf { - 0 => { - if xp + yp == 0 { - 2 - } else if xp + yp < 3 { - 1 - } else { - 0 - } - } - 1 => { - if yp == 0 { - 2 - } else if yp == 1 { - 1 - } else { - 0 - } - } - 2 => { - if xp == 0 { - 2 - } else if xp == 1 { - 1 - } else { - 0 - } - } - _ => 2, - }; - if c == 0 { - if xs + ys > 0 { - sc += 3; - } - sc += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 }; - } else { - sc += if log2 == 3 { 9 } else { 12 }; - } - sc - } -} - -/// Absolute levels, signs and Rice remainders for one significant coefficient group. -fn write_coefficient_levels(s: &mut S, sig: &[u8], at: impl Fn(usize) -> i32, i: usize, c: usize, prev_g1: &mut Option) { - let nsig = sig.len(); - let abs_at = |k: usize| at(sig[k] as usize).unsigned_abs(); - let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 }; - if *prev_g1 == Some(0) { - ctx_set += 1; - } - let mut g1ctx = 1u32; - let mut gt1 = [0u8; 16]; - let mut first_g1: Option = None; - let c_off_g1 = if c > 0 { 16 } else { 0 }; - for k in 0..nsig.min(8) { - let f = abs_at(k) > 1; - s.decision(GT1 + ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1, f as u32); - gt1[k] = f as u8; - if f { - g1ctx = 0; - if first_g1.is_none() { - first_g1 = Some(k); - } - } else if g1ctx > 0 { - g1ctx += 1; - } - } - *prev_g1 = Some(g1ctx); - let mut gt2 = 0u32; - if let Some(k) = first_g1 { - gt2 = (abs_at(k) > 2) as u32; - s.decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }, gt2); - } - // sign data hiding: the sign of the first coefficient in scan order (sig[nsig - 1]) is - // inferred from the parity of the sum of absolute levels - let hidden = sig[0] as i32 - sig[nsig - 1] as i32 > 3; - let nsigns = if hidden { nsig - 1 } else { nsig }; - for k in 0..nsigns { - s.bypass((at(sig[k] as usize) < 0) as u32); - } - if hidden { - let sum: u32 = (0..nsig).map(abs_at).sum(); - debug_assert_eq!(at(sig[nsig - 1] as usize) < 0, sum & 1 == 1, "sign hiding parity"); - } - let mut rice = 0u32; - for k in 0..nsig { - let mut base = 1 + gt1[k] as u32; - if Some(k) == first_g1 { - base += gt2; - } - let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 }; - if base == thresh { - let level = abs_at(k); - write_remaining(s, level - base, rice); - if level > 3 * (1 << rice) { - rice = (rice + 1).min(4); - } - } - } -} - -/// residual_coding() (7.3.8.11) of one transform block of levels (row-major, n x n). No transform -/// skip; signs use sign data hiding where the coefficient span permits it. -fn residual_coding(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx: usize) { - let n = 1usize << log2; - let log2sb = log2 - 2; - let sb_w = 1usize << log2sb; - let sb_scan = scan_order(log2sb, scan_idx); - let pos_scan = &SCAN_4[scan_idx]; - let at = |xs: usize, ys: usize, p: usize| lv[(ys * 4 + pos_scan[p].1 as usize) * n + xs * 4 + pos_scan[p].0 as usize]; - // last significant coefficient in scan order - let mut last = None; - 'outer: for i in (0..sb_scan.len()).rev() { - let (xs, ys) = (sb_scan[i].0 as usize, sb_scan[i].1 as usize); - for p in (0..16).rev() { - if at(xs, ys, p) != 0 { - last = Some((i, p)); - break 'outer; - } - } - } - // Callers only code blocks with a nonzero level (coded_block_flag = 1). - let Some((last_sb, last_pos)) = last else { return }; - let lx = sb_scan[last_sb].0 as u32 * 4 + pos_scan[last_pos].0 as u32; - let ly = sb_scan[last_sb].1 as u32 * 4 + pos_scan[last_pos].1 as u32; - let (cx, cy) = if scan_idx == 2 { (ly, lx) } else { (lx, ly) }; - let cmax = (log2 << 1) - 1; - let (off, shift) = if c == 0 { (3 * (log2 - 2) + ((log2 - 1) >> 2), (log2 + 1) >> 2) } else { (15, log2 - 2) }; - let (px, sx, nbx) = last_prefix(cx); - let (py, sy, nby) = last_prefix(cy); - for (p, base) in [(px, LAST_X_PREFIX), (py, LAST_Y_PREFIX)] { - for i in 0..p { - s.decision(base + (off + (i >> shift)) as usize, 1); - } - if p < cmax { - s.decision(base + (off + (p >> shift)) as usize, 0); - } - } - if px > 3 { - s.bypass_bits(sx, nbx); - } - if py > 3 { - s.bypass_bits(sy, nby); - } - let mut csbf = [0u8; 64]; - let mut prev_g1: Option = None; - let c_off_sig = if c == 0 { 0 } else { 27 }; - for i in (0..=last_sb).rev() { - let (xs, ys) = (sb_scan[i].0 as usize, sb_scan[i].1 as usize); - let right = if xs + 1 < sb_w { csbf[ys * 8 + xs + 1] } else { 0 }; - let below = if ys + 1 < sb_w { csbf[(ys + 1) * 8 + xs] } else { 0 }; - let mut infer_dc = false; - if i < last_sb && i > 0 { - let has = (0..16).any(|p| at(xs, ys, p) != 0); - let inc = (right | below).min(1) as usize + if c > 0 { 2 } else { 0 }; - s.decision(CODED_SUB_BLOCK + inc, has as u32); - csbf[ys * 8 + xs] = has as u8; - infer_dc = true; - } else { - csbf[ys * 8 + xs] = 1; - } - if csbf[ys * 8 + xs] == 0 { - continue; - } - let mut sig = [0u8; 16]; - let mut nsig = 0; - let start: i32 = if i == last_sb { - sig[0] = last_pos as u8; - nsig = 1; - last_pos as i32 - 1 - } else { - 15 - }; - let prev_csbf = right + (below << 1); - let mut np = start; - while np >= 0 { - let p = np as usize; - let (xp, yp) = (pos_scan[p].0 as usize, pos_scan[p].1 as usize); - let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); - let v = lv[yc * n + xc] != 0; - if np > 0 || !infer_dc { - let sig_ctx = significance_context((xs, ys), (xp, yp), log2, prev_csbf, c, scan_idx); - s.decision(SIG_COEFF + c_off_sig + sig_ctx, v as u32); - if v { - sig[nsig] = p as u8; - nsig += 1; - infer_dc = false; - } - } else { - debug_assert!(v, "inferred DC coefficient must be non-zero"); - sig[nsig] = 0; - nsig += 1; - } - np -= 1; - } - if nsig == 0 { - continue; - } - write_coefficient_levels(s, &sig[..nsig], |p| at(xs, ys, p), i, c, &mut prev_g1); - } -} - -/// Sum of absolute Hadamard-transformed differences (8x8 blocks, 4x4 for 4-sample blocks). -pub fn satd(src: &[u16], ss: usize, pred: &[u16], ps: usize, n: usize) -> u32 { - if n == 4 { - return satd4(src, ss, pred, ps); - } - let mut total = 0; - for by in (0..n).step_by(8) { - for bx in (0..n).step_by(8) { - total += satd8(&src[by * ss + bx..], ss, &pred[by * ps + bx..], ps); - } - } - total -} - -fn satd4(src: &[u16], ss: usize, pred: &[u16], ps: usize) -> u32 { - let mut d = [0i32; 16]; - for y in 0..4 { - for x in 0..4 { - d[y * 4 + x] = src[y * ss + x] as i32 - pred[y * ps + x] as i32; - } - } - let mut m = [0i32; 16]; - for y in 0..4 { - let r = &d[y * 4..y * 4 + 4]; - let (a, b, c, e) = (r[0] + r[3], r[1] + r[2], r[1] - r[2], r[0] - r[3]); - m[y * 4] = a + b; - m[y * 4 + 1] = e + c; - m[y * 4 + 2] = a - b; - m[y * 4 + 3] = e - c; - } - let mut sum = 0; - for x in 0..4 { - let (r0, r1, r2, r3) = (m[x], m[4 + x], m[8 + x], m[12 + x]); - let (a, b, c, e) = (r0 + r3, r1 + r2, r1 - r2, r0 - r3); - sum += (a + b).abs() + (e + c).abs() + (a - b).abs() + (e - c).abs(); - } - ((sum + 1) >> 1) as u32 -} - -fn hadamard8(v: &mut [i32; 8]) { - let mut t = *v; - let mut h = 1; - while h < 8 { - for i in (0..8).step_by(h * 2) { - for j in i..i + h { - let (a, b) = (t[j], t[j + h]); - t[j] = a + b; - t[j + h] = a - b; - } - } - h *= 2; - } - *v = t; -} - -fn satd8(src: &[u16], ss: usize, pred: &[u16], ps: usize) -> u32 { - let mut m = [[0i32; 8]; 8]; - for y in 0..8 { - for x in 0..8 { - m[y][x] = src[y * ss + x] as i32 - pred[y * ps + x] as i32; - } - hadamard8(&mut m[y]); - } - let mut sum = 0; - for x in 0..8 { - let mut col = [0i32; 8]; - for y in 0..8 { - col[y] = m[y][x]; - } - hadamard8(&mut col); - sum += col.iter().map(|v| v.abs()).sum::(); - } - ((sum + 2) >> 2) as u32 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn last_prefix_groups() { - assert_eq!(last_prefix(3), (3, 0, 0)); - assert_eq!(last_prefix(4), (4, 0, 1)); - assert_eq!(last_prefix(5), (4, 1, 1)); - assert_eq!(last_prefix(7), (5, 1, 1)); - assert_eq!(last_prefix(8), (6, 0, 2)); - assert_eq!(last_prefix(15), (7, 3, 2)); - assert_eq!(last_prefix(16), (8, 0, 3)); - assert_eq!(last_prefix(31), (9, 7, 3)); - } -} diff --git a/crates/hevcenc/src/inter.rs b/crates/hevcenc/src/inter.rs deleted file mode 100644 index 03bf50ffb..000000000 --- a/crates/hevcenc/src/inter.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Fractional sample interpolation (8.5.3.3.3) and default weighted prediction (8-262), mirrored -//! from the filmcraft-hevc decoder. Reference sample fetches clamp to the picture (8-228, 8-229). - -use crate::tables::{CHROMA_FILTER, LUMA_FILTER}; - -/// One plane of a reference picture. -#[derive(Clone, Copy)] -pub struct PlaneRef<'a> { - pub data: &'a [u16], - pub w: usize, - pub h: usize, -} - -impl PlaneRef<'_> { - /// Copy a w x h window at (x0, y0) with edge replication. - fn window(&self, x0: i32, y0: i32, w: usize, h: usize, out: &mut [i16], os: usize) { - let (pw, ph) = (self.w as i32, self.h as i32); - let inside_x = x0 >= 0 && x0 + w as i32 <= pw; - for r in 0..h { - let yy = (y0 + r as i32).clamp(0, ph - 1) as usize; - let row = &self.data[yy * self.w..yy * self.w + self.w]; - let dst = &mut out[r * os..r * os + w]; - if inside_x { - for (d, &s) in dst.iter_mut().zip(&row[x0 as usize..x0 as usize + w]) { - *d = s as i16; - } - } else { - for (c, d) in dst.iter_mut().enumerate() { - *d = row[(x0 + c as i32).clamp(0, pw - 1) as usize] as i16; - } - } - } - } -} - -/// Luma prediction samples (14-bit intermediate) of a w x h block at (x, y) displaced by the -/// quarter-sample vector `mv`, into `out` (stride w). -pub fn mc_luma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], size: (usize, usize), out: &mut [i16]) { - let (w, h) = size; - let (fx, fy) = ((mv[0] & 3) as usize, (mv[1] & 3) as usize); - let xi = x + (mv[0] as i32 >> 2); - let yi = y + (mv[1] as i32 >> 2); - let shift1 = bd.min(12) - 8; - let shift3 = 14 - bd; - let ws = w + 7; - let mut win = vec![0i16; ws * (h + 7)]; - if fx == 0 && fy == 0 { - f.window(xi, yi, w, h, &mut win, ws); - for r in 0..h { - for c in 0..w { - out[r * w + c] = win[r * ws + c] << shift3; - } - } - return; - } - f.window(xi - 3, yi - 3, w + 7, h + 7, &mut win, ws); - let hf = &LUMA_FILTER[fx]; - let vf = &LUMA_FILTER[fy]; - if fy == 0 { - for r in 0..h { - let row = &win[(r + 3) * ws..]; - for c in 0..w { - let s: i32 = (0..8).map(|k| hf[k] as i32 * row[c + k] as i32).sum(); - out[r * w + c] = (s >> shift1) as i16; - } - } - } else if fx == 0 { - for r in 0..h { - for c in 0..w { - let s: i32 = (0..8).map(|k| vf[k] as i32 * win[(r + k) * ws + c + 3] as i32).sum(); - out[r * w + c] = (s >> shift1) as i16; - } - } - } else { - let mut tmp = vec![0i16; ws * (h + 7)]; - for r in 0..h + 7 { - let row = &win[r * ws..]; - for c in 0..w { - let s: i32 = (0..8).map(|k| hf[k] as i32 * row[c + k] as i32).sum(); - tmp[r * ws + c] = (s >> shift1) as i16; - } - } - for r in 0..h { - for c in 0..w { - let s: i32 = (0..8).map(|k| vf[k] as i32 * tmp[(r + k) * ws + c] as i32).sum(); - out[r * w + c] = (s >> 6) as i16; - } - } - } -} - -/// Chroma prediction samples of a w x h block at chroma position (x, y) with a 1/8-sample vector. -pub fn mc_chroma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], size: (usize, usize), out: &mut [i16]) { - let (w, h) = size; - let (fx, fy) = ((mv[0] & 7) as usize, (mv[1] & 7) as usize); - let xi = x + (mv[0] as i32 >> 3); - let yi = y + (mv[1] as i32 >> 3); - let shift1 = bd.min(12) - 8; - let shift3 = 14 - bd; - let ws = w + 7; - let mut win = vec![0i16; ws * (h + 7)]; - if fx == 0 && fy == 0 { - f.window(xi, yi, w, h, &mut win, ws); - for r in 0..h { - for c in 0..w { - out[r * w + c] = win[r * ws + c] << shift3; - } - } - return; - } - f.window(xi - 1, yi - 1, w + 3, h + 3, &mut win, ws); - let hf = &CHROMA_FILTER[fx]; - let vf = &CHROMA_FILTER[fy]; - if fy == 0 { - for r in 0..h { - let row = &win[(r + 1) * ws..]; - for c in 0..w { - let s: i32 = (0..4).map(|k| hf[k] as i32 * row[c + k] as i32).sum(); - out[r * w + c] = (s >> shift1) as i16; - } - } - } else if fx == 0 { - for r in 0..h { - for c in 0..w { - let s: i32 = (0..4).map(|k| vf[k] as i32 * win[(r + k) * ws + c + 1] as i32).sum(); - out[r * w + c] = (s >> shift1) as i16; - } - } - } else { - let mut tmp = vec![0i16; ws * (h + 7)]; - for r in 0..h + 3 { - let row = &win[r * ws..]; - for c in 0..w { - let s: i32 = (0..4).map(|k| hf[k] as i32 * row[c + k] as i32).sum(); - tmp[r * ws + c] = (s >> shift1) as i16; - } - } - for r in 0..h { - for c in 0..w { - let s: i32 = (0..4).map(|k| vf[k] as i32 * tmp[(r + k) * ws + c] as i32).sum(); - out[r * w + c] = (s >> 6) as i16; - } - } - } -} - -/// Default weighted prediction, one list (8-262): 14-bit samples to `bd`-bit samples. -pub fn put_uni(p: &[i16], w: usize, h: usize, bd: u32, dst: &mut [u16], ds: usize) { - let shift = 14 - bd; - let off = if shift > 0 { 1 << (shift - 1) } else { 0 }; - let max = (1i32 << bd) - 1; - for r in 0..h { - for c in 0..w { - dst[r * ds + c] = ((p[r * w + c] as i32 + off) >> shift).clamp(0, max) as u16; - } - } -} diff --git a/crates/hevcenc/src/intra.rs b/crates/hevcenc/src/intra.rs deleted file mode 100644 index f1fe9e4cb..000000000 --- a/crates/hevcenc/src/intra.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Intra sample prediction (8.4.4.2), mirrored from the filmcraft-hevc decoder so the encoder -//! reconstructs exactly like a decoder: reference substitution, filtering (incl. strong intra -//! smoothing), planar, DC and the 33 angular modes. - -use crate::tables::{INTRA_PRED_ANGLE, INV_ANGLE}; - -/// Reference samples of an nTbS block: `left[0]` = `top[0]` = p[-1][-1], `left[1 + y]` = p[-1][y], -/// `top[1 + x]` = p[x][-1] for x, y = 0..2*nTbS-1. -#[derive(Clone)] -pub struct Refs { - pub left: [u16; 129], - pub top: [u16; 129], -} - -impl Default for Refs { - fn default() -> Self { - Refs { left: [0; 129], top: [0; 129] } - } -} - -/// Substitution process (8.4.4.2.2). `avail_left[1 + y]` / `avail_top[1 + x]` (index 0 = corner) tell -/// which samples are available; unavailable ones are replaced. -pub fn substitute(r: &mut Refs, avail_left: &[bool; 129], avail_top: &[bool; 129], n: usize, bit_depth: u32) { - let n2 = 2 * n; - // Sequence order: p[-1][2n-1] .. p[-1][-1], p[0][-1] .. p[2n-1][-1] - let any = avail_left[..=n2].iter().any(|&a| a) || avail_top[1..=n2].iter().any(|&a| a); - if !any { - let v = 1u16 << (bit_depth - 1); - r.left[..=n2].fill(v); - r.top[..=n2].fill(v); - return; - } - if !avail_left[n2] { - // search upwards then along the top - let mut found = None; - for y in (0..n2).rev() { - if avail_left[y] { - found = Some(r.left[y]); - break; - } - } - if found.is_none() { - for x in 1..=n2 { - if avail_top[x] { - found = Some(r.top[x]); - break; - } - } - } - r.left[n2] = found.unwrap_or(0); - } - for y in (0..n2).rev() { - if !avail_left[y] { - r.left[y] = r.left[y + 1]; - } - } - r.top[0] = r.left[0]; - for x in 1..=n2 { - if !avail_top[x] { - r.top[x] = r.top[x - 1]; - } - } -} - -/// Filtering process of neighbouring samples (8.4.4.2.3) for luma (and 4:4:4 chroma). -pub fn filter(r: &mut Refs, mode: u32, n: usize, strong_enabled: bool, is_luma: bool, bit_depth: u32) { - if mode == 1 || n == 4 { - return; - } - let min_dist = (mode as i32 - 26).abs().min((mode as i32 - 10).abs()); - let thres = match n { - 8 => 7, - 16 => 1, - _ => 0, - }; - if min_dist <= thres { - return; - } - let n2 = 2 * n; - let c = r.left[0] as i32; - if strong_enabled && is_luma && n == 32 { - let (bl, tr) = (r.left[n2] as i32, r.top[n2] as i32); - let thr = 1 << (bit_depth - 5); - if (c + tr - 2 * r.top[n] as i32).abs() < thr && (c + bl - 2 * r.left[n] as i32).abs() < thr { - for i in 0..63 { - r.left[1 + i] = (((63 - i as i32) * c + (i as i32 + 1) * bl + 32) >> 6) as u16; - r.top[1 + i] = (((63 - i as i32) * c + (i as i32 + 1) * tr + 32) >> 6) as u16; - } - return; - } - } - let (l, t) = (r.left, r.top); - let corner = ((l[1] as u32 + 2 * c as u32 + t[1] as u32 + 2) >> 2) as u16; - for i in 1..n2 { - r.left[i] = ((l[i - 1] as u32 + 2 * l[i] as u32 + l[i + 1] as u32 + 2) >> 2) as u16; - r.top[i] = ((t[i - 1] as u32 + 2 * t[i] as u32 + t[i + 1] as u32 + 2) >> 2) as u16; - } - r.left[0] = corner; - r.top[0] = corner; -} - -/// Predict an n x n block with `mode` into `out` (stride `os`). `edge_filters`: DC / horizontal / -/// vertical boundary smoothing (luma blocks smaller than 32x32). -pub fn predict(r: &Refs, mode: u32, n: usize, edge_filters: bool, bit_depth: u32, out: &mut [u16], os: usize) { - let max = (1i32 << bit_depth) - 1; - match mode { - 0 => { - // planar - let log2 = n.trailing_zeros(); - let tr = r.top[1 + n] as i32; - let bl = r.left[1 + n] as i32; - for y in 0..n { - let l = r.left[1 + y] as i32; - for x in 0..n { - let t = r.top[1 + x] as i32; - let v = ((n - 1 - x) as i32 * l + (x as i32 + 1) * tr + (n - 1 - y) as i32 * t + (y as i32 + 1) * bl + n as i32) >> (log2 + 1); - out[y * os + x] = v as u16; - } - } - } - 1 => { - let log2 = n.trailing_zeros(); - let sum: u32 = r.top[1..=n].iter().map(|&v| v as u32).sum::() + r.left[1..=n].iter().map(|&v| v as u32).sum::(); - let dc = ((sum + n as u32) >> (log2 + 1)) as u16; - for y in 0..n { - out[y * os..y * os + n].fill(dc); - } - if edge_filters && n < 32 { - let d = dc as u32; - out[0] = ((r.left[1] as u32 + 2 * d + r.top[1] as u32 + 2) >> 2) as u16; - for x in 1..n { - out[x] = ((r.top[1 + x] as u32 + 3 * d + 2) >> 2) as u16; - } - for y in 1..n { - out[y * os] = ((r.left[1 + y] as u32 + 3 * d + 2) >> 2) as u16; - } - } - } - _ => { - let angle = INTRA_PRED_ANGLE[mode as usize] as i32; - // reference array ref[-n..=2n] stored at offset n - let mut refa = [0i32; 3 * 64 + 1]; - let base = n as i32; - let (main, side) = if mode >= 18 { (&r.top, &r.left) } else { (&r.left, &r.top) }; - for x in 0..=n { - refa[(base + x as i32) as usize] = main[x] as i32; // ref[x] = p[-1+x][-1] - } - if angle < 0 { - let lim = (n as i32 * angle) >> 5; - if lim < -1 { - let inv = INV_ANGLE[(mode - 11) as usize] as i32; - for x in lim..=-1 { - let idx = -1 + ((x * inv + 128) >> 8); - refa[(base + x) as usize] = side[(idx + 1) as usize] as i32; - } - } - } else { - for x in n + 1..=2 * n { - refa[(base + x as i32) as usize] = main[x] as i32; - } - } - let horizontal = mode < 18; - for j in 0..n { - let pos = (j as i32 + 1) * angle; - let idx = pos >> 5; - let fact = pos & 31; - for i in 0..n { - let k = (base + i as i32 + idx + 1) as usize; - let v = if fact != 0 { ((32 - fact) * refa[k] + fact * refa[k + 1] + 16) >> 5 } else { refa[k] }; - if horizontal { - out[i * os + j] = v as u16; - } else { - out[j * os + i] = v as u16; - } - } - } - if edge_filters && n < 32 { - if mode == 26 { - for y in 0..n { - let v = r.top[1] as i32 + ((r.left[1 + y] as i32 - r.left[0] as i32) >> 1); - out[y * os] = v.clamp(0, max) as u16; - } - } else if mode == 10 { - for x in 0..n { - let v = r.left[1] as i32 + ((r.top[1 + x] as i32 - r.top[0] as i32) >> 1); - out[x] = v.clamp(0, max) as u16; - } - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn refs(f: impl Fn(i32) -> u16) -> Refs { - let mut r = Refs::default(); - r.left[0] = f(0); - r.top[0] = f(0); - for i in 1..129 { - r.left[i] = f(-(i as i32)); - r.top[i] = f(i as i32); - } - r - } - - #[test] - fn dc_and_planar_flat() { - let r = refs(|_| 100); - let mut out = [0u16; 64]; - predict(&r, 1, 8, true, 8, &mut out, 8); - assert!(out.iter().all(|&v| v == 100)); - predict(&r, 0, 8, true, 8, &mut out, 8); - assert!(out.iter().all(|&v| v == 100)); - } - - #[test] - fn pure_vertical_and_horizontal() { - let r = refs(|i| (128 + i) as u16); - let mut out = [0u16; 16]; - predict(&r, 26, 4, false, 8, &mut out, 4); - for y in 0..4 { - for x in 0..4 { - assert_eq!(out[y * 4 + x], r.top[1 + x]); - } - } - predict(&r, 10, 4, false, 8, &mut out, 4); - for y in 0..4 { - for x in 0..4 { - assert_eq!(out[y * 4 + x], r.left[1 + y]); - } - } - // diagonal mode 34 reads top[x + y + 2] - predict(&r, 34, 4, false, 8, &mut out, 4); - assert_eq!(out[4 + 1], r.top[1 + 1 + 1 + 1]); - } - - #[test] - fn substitution() { - let mut r = Refs::default(); - let mut al = [false; 129]; - let at = [false; 129]; - r.left[3] = 77; - al[3] = true; - substitute(&mut r, &al, &at, 4, 8); - assert!(r.left[..=8].iter().all(|&v| v == 77)); - assert!(r.top[..=8].iter().all(|&v| v == 77)); - let mut r = Refs::default(); - substitute(&mut r, &[false; 129], &[false; 129], 4, 10); - assert_eq!(r.top[5], 512); - } -} diff --git a/crates/hevcenc/src/lib.rs b/crates/hevcenc/src/lib.rs deleted file mode 100644 index 1934143a0..000000000 --- a/crates/hevcenc/src/lib.rs +++ /dev/null @@ -1,365 +0,0 @@ -//! Clean-room pure-Rust HEVC (H.265) encoder, implemented from ITU-T Rec. H.265 (ISO/IEC 23008-2). -//! -//! Main and Main 10 profiles, 4:2:0, one slice per picture: IDR pictures and single-reference P -//! pictures (no reordering), 64x64 CTUs with rate-distortion coding-unit decisions (8x8 .. 64x64), -//! intra (35 modes, NxN at 8x8), merge / skip and AMVP inter prediction with quarter-sample motion, -//! DCT / DST transforms and CABAC. The deblocking filter is on; SAO is not used. -//! -//! ``` -//! use effectcraft_hevcenc::{Encoder, EncoderConfig, Frame}; -//! let mut enc = Encoder::new(EncoderConfig::new(64, 48, 30, 1)).unwrap(); -//! let (y, c) = (vec![128u16; 64 * 48], vec![128u16; 32 * 24]); -//! let packet = enc.encode(&Frame { y: &y, u: &c, v: &c, y_stride: 64, uv_stride: 32 }); -//! assert!(packet.keyframe && !packet.data.is_empty()); -//! let _hvcc = enc.hvcc(); -//! ``` - -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -mod bitstream; -mod cabac; -mod deblock; -mod frame; -mod inter; -mod intra; -mod me; -mod params; -#[rustfmt::skip] -mod tables; -mod transform; - -use bitstream::{BitWriter, NAL_IDR_N_LP, NAL_TRAIL_R, nal_unit}; -use frame::{FrameCoder, SliceKind}; -use params::{LOG2_MAX_POC_LSB, LOG2_MIN_CB, MAX_MERGE_CAND, SeqParams}; - -/// Profile written in the parameter sets. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Profile { - /// Main: 8-bit 4:2:0. - #[default] - Main, - /// Main 10: 10-bit 4:2:0. - Main10, -} - -impl Profile { - pub fn bit_depth(self) -> u8 { - match self { - Profile::Main => 8, - Profile::Main10 => 10, - } - } - fn idc(self) -> u8 { - match self { - Profile::Main => 1, - Profile::Main10 => 2, - } - } -} - -/// Rate control. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RateControl { - /// Constant QP (0–51; lower is better). - ConstantQp(u8), - /// Average bitrate target in kbit/s (frame-level QP adaptation). - Bitrate { kbps: u32 }, -} - -/// Encoder settings. -#[derive(Clone, Debug, PartialEq)] -pub struct EncoderConfig { - /// Picture size; must be even (4:2:0 cropping is in units of two luma samples). - pub width: u32, - pub height: u32, - /// Frame rate numerator / denominator (VUI timing and rate control). - pub fps_num: u32, - pub fps_den: u32, - pub profile: Profile, - /// `general_level_idc` (30 × level, e.g. 93 = level 3.1); `None` picks the lowest level that fits. - pub level_idc: Option, - pub rate: RateControl, - /// Frames between IDR pictures (1 = all intra, 0 = only the first picture). - pub keyint: u32, - /// BT.709 limited range is signalled in the VUI unless this is set. - pub full_range: bool, - /// The VUI colour description (ITU-T H.273 code points); BT.709 by default. - pub colour_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coeffs: u8, -} - -impl EncoderConfig { - pub fn new(width: u32, height: u32, fps_num: u32, fps_den: u32) -> Self { - EncoderConfig { - width, - height, - fps_num, - fps_den, - profile: Profile::Main, - level_idc: None, - rate: RateControl::ConstantQp(28), - keyint: 60, - full_range: false, - colour_primaries: 1, - transfer_characteristics: 1, - matrix_coeffs: 1, - } - } -} - -/// One input picture, 4:2:0, samples at the profile's bit depth (8-bit values 0–255 for Main). -/// Luma is `width` x `height`, chroma `width / 2` x `height / 2`. -#[derive(Clone, Copy, Debug)] -pub struct Frame<'a> { - pub y: &'a [u16], - pub u: &'a [u16], - pub v: &'a [u16], - pub y_stride: usize, - pub uv_stride: usize, -} - -/// One coded picture (an access unit): NAL units, each prefixed by a 4-byte big-endian length -/// (ISO/IEC 14496-15 sample format; parameter sets are only in [`Encoder::hvcc`]). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Packet { - pub data: Vec, - /// IDR picture. - pub keyframe: bool, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum Error { - InvalidConfig(String), -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::InvalidConfig(s) => write!(f, "invalid HEVC encoder config: {s}"), - } - } -} - -impl std::error::Error for Error {} - -/// Frame-level rate control for [`RateControl::Bitrate`]: a bits ≈ X / Qstep model per picture type -/// and a buffer that spreads over- / undershoot over the following pictures. -struct RateCtl { - target: f64, - /// sum(actual) - sum(target), bits. - buffer: f64, - /// Complexity X = bits * Qstep for I and P pictures. - x: [Option; 2], - last_qp: [i32; 2], - init_qp: i32, -} - -fn qstep(qp: i32) -> f64 { - 2f64.powf((qp as f64 - 4.0) / 6.0) -} - -impl RateCtl { - fn new(kbps: u32, cfg: &EncoderConfig) -> Self { - let fps = cfg.fps_num as f64 / cfg.fps_den.max(1) as f64; - let target = kbps as f64 * 1000.0 / fps.max(0.001); - let bpp = target / (cfg.width as f64 * cfg.height as f64); - let init_qp = (28.0 - 6.0 * (bpp / 0.08).log2()).round().clamp(10.0, 46.0) as i32; - RateCtl { target, buffer: 0.0, x: [None, None], last_qp: [init_qp - 3, init_qp], init_qp } - } - - fn qp(&self, idr: bool) -> i32 { - let t = idr as usize ^ 1; // 0 = I, 1 = P - let tp = (self.target - self.buffer / 12.0).clamp(0.3 * self.target, 2.0 * self.target); - let want = if idr { 4.0 * tp } else { tp }; - let x = match (self.x[t], self.x[0]) { - (Some(x), _) => x, - (None, Some(xi)) => xi / 5.0, - (None, None) => return if idr { self.init_qp - 3 } else { self.init_qp }, - }; - let qp = (4.0 + 6.0 * (x / want).log2()).round() as i32; - qp.clamp(self.last_qp[t] - 6, self.last_qp[t] + 6).clamp(1, 51) - } - - fn update(&mut self, idr: bool, qp: i32, bits: f64) { - let t = idr as usize ^ 1; - let x = bits * qstep(qp); - self.x[t] = Some(match self.x[t] { - Some(old) => 0.5 * old + 0.5 * x, - None => x, - }); - self.last_qp[t] = qp; - self.buffer += bits - self.target; - } -} - -/// HEVC encoder. Pictures come out in input order (no reordering: I and P only). -pub struct Encoder { - cfg: EncoderConfig, - seq: SeqParams, - sets: [Vec; 3], - frames: u64, - /// POC of the previous picture (relative to the last IDR). - poc: u32, - reference: Option<[Vec; 3]>, - recon: Option<[Vec; 3]>, - rc: Option, -} - -impl Encoder { - pub fn new(cfg: EncoderConfig) -> Result { - if cfg.width == 0 || cfg.height == 0 { - return Err(Error::InvalidConfig("empty frame".into())); - } - if !cfg.width.is_multiple_of(2) || !cfg.height.is_multiple_of(2) { - return Err(Error::InvalidConfig(format!("{}x{}: 4:2:0 HEVC needs even dimensions", cfg.width, cfg.height))); - } - if cfg.width > 16384 || cfg.height > 16384 { - return Err(Error::InvalidConfig("picture larger than 16384 samples".into())); - } - if cfg.fps_num == 0 || cfg.fps_den == 0 { - return Err(Error::InvalidConfig("zero frame rate".into())); - } - match cfg.rate { - RateControl::ConstantQp(q) if q > 51 => return Err(Error::InvalidConfig(format!("QP {q} > 51"))), - RateControl::Bitrate { kbps: 0 } => return Err(Error::InvalidConfig("zero bitrate".into())), - _ => {} - } - let min_cb = 1u32 << LOG2_MIN_CB; - let (coded_width, coded_height) = (cfg.width.div_ceil(min_cb) * min_cb, cfg.height.div_ceil(min_cb) * min_cb); - let kbps = match cfg.rate { - RateControl::Bitrate { kbps } => Some(kbps), - RateControl::ConstantQp(_) => None, - }; - let level_idc = cfg.level_idc.unwrap_or_else(|| params::choose_level(coded_width, coded_height, cfg.fps_num, cfg.fps_den, kbps)); - let seq = SeqParams { - coded_width, - coded_height, - width: cfg.width, - height: cfg.height, - bit_depth: cfg.profile.bit_depth() as u32, - profile_idc: cfg.profile.idc(), - level_idc, - full_range: cfg.full_range, - colour_primaries: cfg.colour_primaries, - transfer_characteristics: cfg.transfer_characteristics, - matrix_coeffs: cfg.matrix_coeffs, - fps_num: cfg.fps_num, - fps_den: cfg.fps_den, - }; - let sets = [params::vps(&seq), params::sps(&seq), params::pps()]; - let rc = kbps.map(|k| RateCtl::new(k, &cfg)); - Ok(Encoder { cfg, seq, sets, frames: 0, poc: 0, reference: None, recon: None, rc }) - } - - pub fn config(&self) -> &EncoderConfig { - &self.cfg - } - - /// VPS, SPS, PPS NAL units (2-byte NAL header, emulation prevention, no start codes or length - /// prefixes). - pub fn parameter_sets(&self) -> [Vec; 3] { - self.sets.clone() - } - - /// HEVCDecoderConfigurationRecord (`hvcC` payload) with the parameter sets. - pub fn hvcc(&self) -> Vec { - params::hvcc(&self.seq, &self.sets) - } - - /// `general_level_idc` written in the parameter sets. - pub fn level_idc(&self) -> u8 { - self.seq.level_idc - } - - /// Reconstruction of the last encoded picture (what a decoder outputs), cropped planes - /// `[Y, Cb, Cr]` with strides `width` and `width / 2`. For tests. - #[doc(hidden)] - pub fn last_reconstruction(&self) -> Option<&[Vec; 3]> { - self.recon.as_ref() - } - - /// Copy the input into coded-size planes, replicating the right / bottom edges. - fn pad_input(&self, f: &Frame) -> [Vec; 3] { - let max = (1u32 << self.seq.bit_depth) - 1; - let mut out: [Vec; 3] = Default::default(); - for (c, plane) in out.iter_mut().enumerate() { - let (src, stride, w, h, cw, ch) = if c == 0 { - (f.y, f.y_stride, self.seq.width, self.seq.height, self.seq.coded_width, self.seq.coded_height) - } else { - let s = if c == 1 { f.u } else { f.v }; - (s, f.uv_stride, self.seq.width / 2, self.seq.height / 2, self.seq.coded_width / 2, self.seq.coded_height / 2) - }; - let (w, h, cw, ch) = (w as usize, h as usize, cw as usize, ch as usize); - assert!(stride >= w && src.len() >= (h - 1) * stride + w, "HEVC encoder: plane {c} smaller than {w}x{h} (stride {stride})"); - plane.reserve(cw * ch); - for y in 0..ch { - let row = &src[y.min(h - 1) * stride..][..w]; - plane.extend(row.iter().map(|&v| (v as u32).min(max) as u16)); - let last = plane.last().copied().unwrap_or(0); - plane.extend(std::iter::repeat_n(last, cw - w)); - } - } - out - } - - /// Encodes one picture. - pub fn encode(&mut self, frame: &Frame) -> Packet { - let src = self.pad_input(frame); - let keyint = self.cfg.keyint as u64; - let idr = self.reference.is_none() || (keyint > 0 && self.frames.is_multiple_of(keyint)); - self.poc = if idr { 0 } else { self.poc + 1 }; - let qp = match (&self.rc, self.cfg.rate) { - (Some(rc), _) => rc.qp(idr), - (None, RateControl::ConstantQp(q)) => q as i32, - (None, RateControl::Bitrate { .. }) => 28, - }; - let (w, h) = (self.seq.coded_width as usize, self.seq.coded_height as usize); - let bd = self.seq.bit_depth; - let kind = if idr { SliceKind::I } else { SliceKind::P }; - let me = if idr { None } else { self.reference.as_ref().map(|r| me::MeRef::new(&r[0], w, h, bd)) }; - let refp = if idr { None } else { self.reference.as_ref() }; - let mut fc = FrameCoder::new(frame::FrameConfig { bit_depth: bd, width: w, height: h, kind, slice_qp: qp }, &src, refp, me.as_ref()); - - // slice_segment_header() (7.3.6.1) - let mut bw = BitWriter::new(); - bw.put(1, 1); // first_slice_segment_in_pic_flag - if idr { - bw.put(0, 1); // no_output_of_prior_pics_flag - } - bw.ue(0); // slice_pic_parameter_set_id - bw.ue(if idr { 2 } else { 1 }); // slice_type I / P - if !idr { - bw.put(self.poc & ((1 << LOG2_MAX_POC_LSB) - 1), LOG2_MAX_POC_LSB); // slice_pic_order_cnt_lsb - bw.put(1, 1); // short_term_ref_pic_set_sps_flag (the only SPS set, no index coded) - bw.put(0, 1); // num_ref_idx_active_override_flag - bw.ue(5 - MAX_MERGE_CAND as u32); // five_minus_max_num_merge_cand - } - bw.se(qp - 26); // slice_qp_delta (init_qp = 26) - // byte_alignment() - bw.put(1, 1); - bw.align_zero(); - - let ctx = cabac::init_contexts(qp, if idr { 0 } else { 1 }); - let mut enc = cabac::CabacEncoder::new(bw, ctx); - fc.encode(&mut enc); - fc.deblock(); - let rbsp = enc.finish().into_bytes(); - let nal = nal_unit(if idr { NAL_IDR_N_LP } else { NAL_TRAIL_R }, &rbsp); - let mut data = Vec::with_capacity(nal.len() + 4); - data.extend_from_slice(&(nal.len() as u32).to_be_bytes()); - data.extend_from_slice(&nal); - - if let Some(rc) = &mut self.rc { - rc.update(idr, qp, data.len() as f64 * 8.0); - } - // output picture: cropped reconstruction - let rec = std::mem::take(&mut fc.rec); - let (ow, oh) = (self.seq.width as usize, self.seq.height as usize); - let crop = |p: &[u16], stride: usize, w: usize, h: usize| -> Vec { (0..h).flat_map(|y| p[y * stride..y * stride + w].iter().copied()).collect() }; - self.recon = Some([crop(&rec[0], w, ow, oh), crop(&rec[1], w / 2, ow / 2, oh / 2), crop(&rec[2], w / 2, ow / 2, oh / 2)]); - self.reference = Some(rec); - self.frames += 1; - Packet { data, keyframe: idr } - } -} diff --git a/crates/hevcenc/src/me.rs b/crates/hevcenc/src/me.rs deleted file mode 100644 index 5f4cb2ce3..000000000 --- a/crates/hevcenc/src/me.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Motion search reference: the previous reconstructed luma plane padded by edge replication, plus -//! half-sample planes interpolated with the 8-tap half-sample filter. Quarter-sample positions are -//! approximated by averaging neighbouring half-grid samples. Used for estimation only; the coded -//! prediction always uses the normative interpolation in `inter`. - -use crate::tables::LUMA_FILTER; - -const MARGIN: i32 = 80; - -pub struct MeRef { - /// [integer, horizontal half, vertical half, diagonal half] samples, each `stride` x `rows`. - planes: [Vec; 4], - stride: usize, - rows: usize, - w: i32, - h: i32, -} - -impl MeRef { - pub fn new(y: &[u16], w: usize, h: usize, bd: u32) -> MeRef { - let m = MARGIN as usize; - let (stride, rows) = (w + 2 * m, h + 2 * m); - let mut pad = vec![0u16; stride * rows]; - for r in 0..rows { - let sy = (r as i32 - MARGIN).clamp(0, h as i32 - 1) as usize; - let src = &y[sy * w..sy * w + w]; - let row = &mut pad[r * stride..r * stride + stride]; - row[..m].fill(src[0]); - row[m..m + w].copy_from_slice(src); - row[m + w..].fill(src[w - 1]); - } - let max = (1i32 << bd) - 1; - let f = &LUMA_FILTER[2]; - let filt_h = |p: &[u16]| -> Vec { - let mut o = vec![0u16; stride * rows]; - for r in 0..rows { - let row = &p[r * stride..r * stride + stride]; - for x in 0..stride { - let mut s = 0i32; - for k in 0..8 { - let xx = (x as i32 - 3 + k as i32).clamp(0, stride as i32 - 1) as usize; - s += f[k] as i32 * row[xx] as i32; - } - o[r * stride + x] = ((s + 32) >> 6).clamp(0, max) as u16; - } - } - o - }; - let filt_v = |p: &[u16]| -> Vec { - let mut o = vec![0u16; stride * rows]; - let mut acc = vec![0i32; stride]; - for r in 0..rows { - acc.fill(0); - for k in 0..8 { - let rr = (r as i32 - 3 + k as i32).clamp(0, rows as i32 - 1) as usize; - let src = &p[rr * stride..rr * stride + stride]; - let coef = f[k] as i32; - for (a, &s) in acc.iter_mut().zip(src) { - *a += coef * s as i32; - } - } - for (d, &a) in o[r * stride..r * stride + stride].iter_mut().zip(&acc) { - *d = ((a + 32) >> 6).clamp(0, max) as u16; - } - } - o - }; - let hp = filt_h(&pad); - let vp = filt_v(&pad); - let dp = filt_v(&hp); - MeRef { planes: [pad, hp, vp, dp], stride, rows, w: w as i32, h: h as i32 } - } - - /// Integer motion vector range keeping an n x n block (plus filter margin) inside the padding. - pub fn int_range(&self, x: i32, y: i32, n: usize) -> (i32, i32, i32, i32) { - let n = n as i32; - let lo_x = -MARGIN + 4 - x; - let hi_x = self.w + MARGIN - n - 4 - x; - let lo_y = -MARGIN + 4 - y; - let hi_y = self.h + MARGIN - n - 4 - y; - (lo_x.max(-256), hi_x.min(256), lo_y.max(-256), hi_y.min(256)) - } - - /// SAD of the source block against the integer-position reference block at (rx, ry). - pub fn sad(&self, src: &[u16], ss: usize, rx: i32, ry: i32, n: usize) -> u32 { - let p = &self.planes[0]; - let base = (ry + MARGIN) as usize * self.stride + (rx + MARGIN) as usize; - let mut s = 0u32; - for r in 0..n { - let a = &src[r * ss..r * ss + n]; - let b = &p[base + r * self.stride..base + r * self.stride + n]; - s += a.iter().zip(b).map(|(&x, &y)| (x as i32 - y as i32).unsigned_abs()).sum::(); - } - s - } - - /// Approximate luma prediction of an n x n block at (x, y) with quarter-sample vector `mv`. - pub fn approx_pred(&self, x: i32, y: i32, n: usize, mv: [i16; 2], out: &mut [u16]) { - // per axis: one or two (half-sample fraction, integer offset) taps - let axis = |v: i32| -> [(usize, i32); 2] { - if v & 1 == 0 { - let hv = v >> 1; - [((hv & 1) as usize, hv >> 1); 2] - } else { - let (a, b) = ((v - 1) >> 1, (v + 1) >> 1); - [((a & 1) as usize, a >> 1), ((b & 1) as usize, b >> 1)] - } - }; - let (mx, my) = (mv[0] as i32, mv[1] as i32); - let ax = axis(mx); - let ay = axis(my); - let two = mx & 1 != 0 || my & 1 != 0; - let taps = [(ax[0], ay[0]), (ax[1], ay[1])]; - let ntaps = if two { 2 } else { 1 }; - let mut first = true; - for &((fx, ox), (fy, oy)) in taps.iter().take(ntaps) { - let plane = &self.planes[fx + 2 * fy]; - let (bx, by) = (x + ox + MARGIN, y + oy + MARGIN); - let inside = bx >= 0 && by >= 0 && bx as usize + n <= self.stride && by as usize + n <= self.rows; - for r in 0..n { - let dst = &mut out[r * n..r * n + n]; - if inside { - let o = (by as usize + r) * self.stride + bx as usize; - let src = &plane[o..o + n]; - if first { - dst.copy_from_slice(src); - } else { - for (d, &s) in dst.iter_mut().zip(src) { - *d = ((*d as u32 + s as u32 + 1) >> 1) as u16; - } - } - } else { - let yy = (by + r as i32).clamp(0, self.rows as i32 - 1) as usize; - for (c, d) in dst.iter_mut().enumerate() { - let xx = (bx + c as i32).clamp(0, self.stride as i32 - 1) as usize; - let s = plane[yy * self.stride + xx]; - *d = if first { s } else { ((*d as u32 + s as u32 + 1) >> 1) as u16 }; - } - } - } - first = false; - } - } -} diff --git a/crates/hevcenc/src/params.rs b/crates/hevcenc/src/params.rs deleted file mode 100644 index 89bac938e..000000000 --- a/crates/hevcenc/src/params.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Parameter sets (7.3.2.1 VPS, 7.3.2.2 SPS with E.2.1 VUI, 7.3.2.3 PPS), profile_tier_level (7.3.3), -//! level selection (Annex A, Table A.8) and the `hvcC` record (ISO/IEC 14496-15 §8.3.3.1). - -use crate::bitstream::{BitWriter, NAL_PPS, NAL_SPS, NAL_VPS, nal_unit}; - -/// Fixed coding structure of the encoder. -pub const LOG2_CTB: u32 = 6; -pub const LOG2_MIN_CB: u32 = 3; -pub const LOG2_MIN_TB: u32 = 2; -pub const LOG2_MAX_TB: u32 = 5; -pub const LOG2_MAX_POC_LSB: u32 = 8; -/// MaxNumMergeCand. -pub const MAX_MERGE_CAND: usize = 5; - -/// Sequence-level settings shared by the parameter-set writers. -#[derive(Clone, Debug)] -pub struct SeqParams { - /// Coded (padded) size, multiples of the minimum CB size. - pub coded_width: u32, - pub coded_height: u32, - /// Output (cropped) size. - pub width: u32, - pub height: u32, - pub bit_depth: u32, - pub profile_idc: u8, - pub level_idc: u8, - pub full_range: bool, - pub colour_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coeffs: u8, - pub fps_num: u32, - pub fps_den: u32, -} - -/// (level_idc, MaxLumaPs, MaxLumaSr, MaxBR main tier in kbit/s) of Table A.8 (and A.9 for luma rates). -const LEVELS: [(u8, u64, u64, u64); 13] = [ - (30, 36_864, 552_960, 128), - (60, 122_880, 3_686_400, 1_500), - (63, 245_760, 7_372_800, 3_000), - (90, 552_960, 16_588_800, 6_000), - (93, 983_040, 33_177_600, 10_000), - (120, 2_228_224, 66_846_720, 12_000), - (123, 2_228_224, 133_693_440, 20_000), - (150, 8_912_896, 267_386_880, 25_000), - (153, 8_912_896, 534_773_760, 40_000), - (156, 8_912_896, 1_069_547_520, 60_000), - (180, 35_651_584, 1_069_547_520, 60_000), - (183, 35_651_584, 2_139_095_040, 120_000), - (186, 35_651_584, 4_278_190_080, 240_000), -]; - -/// Lowest level whose picture size, sample rate and (when known) bitrate limits fit. -pub fn choose_level(width: u32, height: u32, fps_num: u32, fps_den: u32, kbps: Option) -> u8 { - let ps = width as u64 * height as u64; - let sr = (ps as f64 * fps_num as f64 / fps_den.max(1) as f64).ceil() as u64; - for &(idc, max_ps, max_sr, max_br) in &LEVELS { - let max_dim = ((max_ps * 8) as f64).sqrt() as u64; - if ps <= max_ps && (width as u64) <= max_dim && (height as u64) <= max_dim && sr <= max_sr && kbps.is_none_or(|k| k as u64 <= max_br) { - return idc; - } - } - 186 -} - -fn profile_tier_level(w: &mut BitWriter, p: &SeqParams) { - w.put(0, 2); // general_profile_space - w.put(0, 1); // general_tier_flag (Main tier) - w.put(p.profile_idc as u32, 5); - w.put(compat_flags(p.profile_idc), 32); - w.put(1, 1); // general_progressive_source_flag - w.put(0, 1); // general_interlaced_source_flag - w.put(0, 1); // general_non_packed_constraint_flag - w.put(1, 1); // general_frame_only_constraint_flag - w.put(0, 32); // general_reserved_zero_43bits ... - w.put(0, 11); - w.put(0, 1); // general_inbld_flag / reserved - w.put(p.level_idc as u32, 8); - // sps_max_sub_layers_minus1 == 0: no sub-layer flags -} - -/// general_profile_compatibility_flag[j] as a 32-bit word (flag 0 is the MSB). A Main stream is also -/// Main 10 compatible. -fn compat_flags(profile_idc: u8) -> u32 { - let mut f = 1u32 << (31 - profile_idc as u32); - if profile_idc == 1 { - f |= 1 << (31 - 2); - } - f -} - -pub fn vps(p: &SeqParams) -> Vec { - let mut w = BitWriter::new(); - w.put(0, 4); // vps_video_parameter_set_id - w.put(1, 1); // vps_base_layer_internal_flag - w.put(1, 1); // vps_base_layer_available_flag - w.put(0, 6); // vps_max_layers_minus1 - w.put(0, 3); // vps_max_sub_layers_minus1 - w.put(1, 1); // vps_temporal_id_nesting_flag - w.put(0xffff, 16); - profile_tier_level(&mut w, p); - w.put(1, 1); // vps_sub_layer_ordering_info_present_flag - w.ue(1); // vps_max_dec_pic_buffering_minus1 - w.ue(0); // vps_max_num_reorder_pics - w.ue(0); // vps_max_latency_increase_plus1 - w.put(0, 6); // vps_max_layer_id - w.ue(0); // vps_num_layer_sets_minus1 - w.put(0, 1); // vps_timing_info_present_flag - w.put(0, 1); // vps_extension_flag - w.trailing(); - nal_unit(NAL_VPS, &w.into_bytes()) -} - -pub fn sps(p: &SeqParams) -> Vec { - let mut w = BitWriter::new(); - w.put(0, 4); // sps_video_parameter_set_id - w.put(0, 3); // sps_max_sub_layers_minus1 - w.put(1, 1); // sps_temporal_id_nesting_flag - profile_tier_level(&mut w, p); - w.ue(0); // sps_seq_parameter_set_id - w.ue(1); // chroma_format_idc 4:2:0 - w.ue(p.coded_width); - w.ue(p.coded_height); - let crop = p.coded_width != p.width || p.coded_height != p.height; - w.flag(crop); // conformance_window_flag - if crop { - // offsets in chroma sample units (SubWidthC = SubHeightC = 2) - w.ue(0); - w.ue((p.coded_width - p.width) / 2); - w.ue(0); - w.ue((p.coded_height - p.height) / 2); - } - w.ue(p.bit_depth - 8); // bit_depth_luma_minus8 - w.ue(p.bit_depth - 8); // bit_depth_chroma_minus8 - w.ue(LOG2_MAX_POC_LSB - 4); - w.put(1, 1); // sps_sub_layer_ordering_info_present_flag - w.ue(1); // sps_max_dec_pic_buffering_minus1 - w.ue(0); // sps_max_num_reorder_pics - w.ue(0); // sps_max_latency_increase_plus1 - w.ue(LOG2_MIN_CB - 3); - w.ue(LOG2_CTB - LOG2_MIN_CB); - w.ue(LOG2_MIN_TB - 2); - w.ue(LOG2_MAX_TB - LOG2_MIN_TB); - w.ue(0); // max_transform_hierarchy_depth_inter - w.ue(0); // max_transform_hierarchy_depth_intra - w.put(0, 1); // scaling_list_enabled_flag - w.put(0, 1); // amp_enabled_flag - w.put(0, 1); // sample_adaptive_offset_enabled_flag - w.put(0, 1); // pcm_enabled_flag - w.ue(1); // num_short_term_ref_pic_sets - // st_ref_pic_set(0): one negative picture at delta POC -1, used by the current picture - w.ue(1); // num_negative_pics - w.ue(0); // num_positive_pics - w.ue(0); // delta_poc_s0_minus1 - w.put(1, 1); // used_by_curr_pic_s0_flag - w.put(0, 1); // long_term_ref_pics_present_flag - w.put(0, 1); // sps_temporal_mvp_enabled_flag - w.put(1, 1); // strong_intra_smoothing_enabled_flag - w.put(1, 1); // vui_parameters_present_flag - vui(&mut w, p); - w.put(0, 1); // sps_extension_present_flag - w.trailing(); - nal_unit(NAL_SPS, &w.into_bytes()) -} - -fn vui(w: &mut BitWriter, p: &SeqParams) { - w.put(0, 1); // aspect_ratio_info_present_flag - w.put(0, 1); // overscan_info_present_flag - w.put(1, 1); // video_signal_type_present_flag - w.put(5, 3); // video_format: unspecified - w.flag(p.full_range); - w.put(1, 1); // colour_description_present_flag - w.put(p.colour_primaries as u32, 8); // colour_primaries - w.put(p.transfer_characteristics as u32, 8); // transfer_characteristics - w.put(p.matrix_coeffs as u32, 8); // matrix_coeffs - w.put(0, 1); // chroma_loc_info_present_flag - w.put(0, 1); // neutral_chroma_indication_flag - w.put(0, 1); // field_seq_flag - w.put(0, 1); // frame_field_info_present_flag - w.put(0, 1); // default_display_window_flag - w.put(1, 1); // vui_timing_info_present_flag - w.put(p.fps_den, 32); // vui_num_units_in_tick - w.put(p.fps_num, 32); // vui_time_scale - w.put(0, 1); // vui_poc_proportional_to_timing_flag - w.put(0, 1); // vui_hrd_parameters_present_flag - w.put(0, 1); // bitstream_restriction_flag -} - -pub fn pps() -> Vec { - let mut w = BitWriter::new(); - w.ue(0); // pps_pic_parameter_set_id - w.ue(0); // pps_seq_parameter_set_id - w.put(0, 1); // dependent_slice_segments_enabled_flag - w.put(0, 1); // output_flag_present_flag - w.put(0, 3); // num_extra_slice_header_bits - w.put(1, 1); // sign_data_hiding_enabled_flag - w.put(0, 1); // cabac_init_present_flag - w.ue(0); // num_ref_idx_l0_default_active_minus1 - w.ue(0); // num_ref_idx_l1_default_active_minus1 - w.se(0); // init_qp_minus26 - w.put(0, 1); // constrained_intra_pred_flag - w.put(0, 1); // transform_skip_enabled_flag - w.put(0, 1); // cu_qp_delta_enabled_flag - w.se(0); // pps_cb_qp_offset - w.se(0); // pps_cr_qp_offset - w.put(0, 1); // pps_slice_chroma_qp_offsets_present_flag - w.put(0, 1); // weighted_pred_flag - w.put(0, 1); // weighted_bipred_flag - w.put(0, 1); // transquant_bypass_enabled_flag - w.put(0, 1); // tiles_enabled_flag - w.put(0, 1); // entropy_coding_sync_enabled_flag - w.put(0, 1); // pps_loop_filter_across_slices_enabled_flag - w.put(1, 1); // deblocking_filter_control_present_flag - w.put(0, 1); // deblocking_filter_override_enabled_flag - w.put(0, 1); // pps_deblocking_filter_disabled_flag - w.se(0); // pps_beta_offset_div2 - w.se(0); // pps_tc_offset_div2 - w.put(0, 1); // pps_scaling_list_data_present_flag - w.put(0, 1); // lists_modification_present_flag - w.ue(0); // log2_parallel_merge_level_minus2 - w.put(0, 1); // slice_segment_header_extension_present_flag - w.put(0, 1); // pps_extension_present_flag - w.trailing(); - nal_unit(NAL_PPS, &w.into_bytes()) -} - -/// HEVCDecoderConfigurationRecord with one VPS, SPS and PPS (lengthSizeMinusOne = 3). -pub fn hvcc(p: &SeqParams, sets: &[Vec; 3]) -> Vec { - let mut o = vec![1u8]; // configurationVersion - o.push(p.profile_idc); // profile_space 0, tier 0, profile_idc - o.extend_from_slice(&compat_flags(p.profile_idc).to_be_bytes()); - // general_constraint_indicator_flags (48 bits): progressive, interlaced, non_packed, frame_only - o.extend_from_slice(&[0x90, 0, 0, 0, 0, 0]); - o.push(p.level_idc); - o.extend_from_slice(&[0xf0, 0x00]); // reserved + min_spatial_segmentation_idc 0 - o.push(0xfc); // reserved + parallelismType 0 - o.push(0xfc | 1); // reserved + chroma_format_idc 1 - o.push(0xf8 | (p.bit_depth - 8) as u8); - o.push(0xf8 | (p.bit_depth - 8) as u8); - o.extend_from_slice(&[0, 0]); // avgFrameRate unspecified - // constantFrameRate 0, numTemporalLayers 1, temporalIdNested 1, lengthSizeMinusOne 3 - o.push((1 << 3) | (1 << 2) | 3); - o.push(3); // numOfArrays - for (set, ty) in sets.iter().zip([NAL_VPS, NAL_SPS, NAL_PPS]) { - o.push(0x80 | ty); // array_completeness 1 - o.extend_from_slice(&1u16.to_be_bytes()); - o.extend_from_slice(&(set.len() as u16).to_be_bytes()); - o.extend_from_slice(set); - } - o -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn levels() { - assert_eq!(choose_level(1920, 1080, 30, 1, None), 120); - assert_eq!(choose_level(1920, 1080, 60, 1, None), 123); - assert_eq!(choose_level(3840, 2160, 30, 1, None), 150); - assert_eq!(choose_level(1280, 720, 30, 1, None), 93); - assert_eq!(choose_level(64, 64, 30, 1, None), 30); - assert_eq!(choose_level(1920, 1080, 30, 1, Some(15_000)), 123); - } -} diff --git a/crates/hevcenc/src/tables.rs b/crates/hevcenc/src/tables.rs deleted file mode 100644 index 7c2fc4470..000000000 --- a/crates/hevcenc/src/tables.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Constant tables of ITU-T H.265, mirrored from the first-party, spec-derived `filmcraft-hevc` -//! decoder (same organisation, MIT OR Apache-2.0): CABAC context layout and initValues (9.3.2.2), -//! rangeTabLPS / transIdx (Tables 9-52, 9-53), scans (6.5.3 - 6.5.5), transform matrices (8.6.4.2), -//! intra angles (Tables 8-5, 8-6), interpolation filters (8.5.3.3.3) and the chroma QP mapping (Table 8-10). - -#![allow(dead_code)] - -/// Table 9-52 (same values as H.264) rangeTabLPS[pStateIdx][qCodIRangeIdx]. -#[rustfmt::skip] -pub static RANGE_TAB_LPS: [[u8; 4]; 64] = [ - [128, 176, 208, 240], [128, 167, 197, 227], [128, 158, 187, 216], [123, 150, 178, 205], - [116, 142, 169, 195], [111, 135, 160, 185], [105, 128, 152, 175], [100, 122, 144, 166], - [95, 116, 137, 158], [90, 110, 130, 150], [85, 104, 123, 142], [81, 99, 117, 135], - [77, 94, 111, 128], [73, 89, 105, 122], [69, 85, 100, 116], [66, 80, 95, 110], - [62, 76, 90, 104], [59, 72, 86, 99], [56, 69, 81, 94], [53, 65, 77, 89], - [51, 62, 73, 85], [48, 59, 69, 80], [46, 56, 66, 76], [43, 53, 63, 72], - [41, 50, 59, 69], [39, 48, 56, 65], [37, 45, 54, 62], [35, 43, 51, 59], - [33, 41, 48, 56], [32, 39, 46, 53], [30, 37, 43, 50], [29, 35, 41, 48], - [27, 33, 39, 45], [26, 31, 37, 43], [24, 30, 35, 41], [23, 28, 33, 39], - [22, 27, 32, 37], [21, 26, 30, 35], [20, 24, 29, 33], [19, 23, 27, 31], - [18, 22, 26, 30], [17, 21, 25, 28], [16, 20, 23, 27], [15, 19, 22, 25], - [14, 18, 21, 24], [14, 17, 20, 23], [13, 16, 19, 22], [12, 15, 18, 21], - [12, 14, 17, 20], [11, 14, 16, 19], [11, 13, 15, 18], [10, 12, 15, 17], - [10, 12, 14, 16], [9, 11, 13, 15], [9, 11, 12, 14], [8, 10, 12, 14], - [8, 9, 11, 13], [7, 9, 11, 12], [7, 9, 10, 12], [7, 8, 10, 11], - [6, 8, 9, 11], [6, 7, 9, 10], [6, 7, 8, 9], [2, 2, 2, 2], -]; - -/// Table 9-53 transIdxLPS. -#[rustfmt::skip] -static TRANS_IDX_LPS: [u8; 64] = [ - 0, 0, 1, 2, 2, 4, 4, 5, 6, 7, 8, 9, 9, 11, 11, 12, - 13, 13, 15, 15, 16, 16, 18, 18, 19, 19, 21, 21, 22, 22, 23, 24, - 24, 25, 26, 26, 27, 27, 28, 29, 29, 30, 30, 30, 31, 32, 32, 33, - 33, 33, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 63, -]; - -/// Table 9-53 transIdxMPS. -#[rustfmt::skip] -static TRANS_IDX_MPS: [u8; 64] = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, - 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, - 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 62, 63, -]; - -/// Combined state transition: index = (pStateIdx << 1 | valMPS) * 2 + bin_is_lps. -/// Each context is stored as `pStateIdx << 1 | valMPS`. -pub static NEXT_STATE: [[u8; 2]; 128] = { - let mut t = [[0u8; 2]; 128]; - let mut s = 0; - while s < 128 { - let p = s >> 1; - let mps = s & 1; - // MPS path - t[s][0] = (TRANS_IDX_MPS[p] << 1) | mps as u8; - // LPS path - let new_mps = if p == 0 { 1 - mps } else { mps }; - t[s][1] = (TRANS_IDX_LPS[p] << 1) | new_mps as u8; - s += 1; - } - t -}; - -/// Up-right diagonal scan (6.5.3) of a `N x N` block as (x, y) pairs. -const fn diag(n: usize) -> [(u8, u8); L] { - let mut out = [(0u8, 0u8); L]; - let mut i = 0; - let mut x: i32 = 0; - let mut y: i32 = 0; - while i < L { - while y >= 0 { - if (x as usize) < n && (y as usize) < n { - out[i] = (x as u8, y as u8); - i += 1; - } - y -= 1; - x += 1; - } - y = x; - x = 0; - } - out -} - -/// Horizontal (6.5.4, `vertical == false`) or vertical (6.5.5) scan. -const fn hv(n: usize, vertical: bool) -> [(u8, u8); L] { - let mut out = [(0u8, 0u8); L]; - let mut i = 0; - while i < L { - let (a, b) = ((i % n) as u8, (i / n) as u8); - out[i] = if vertical { (b, a) } else { (a, b) }; - i += 1; - } - out -} - -pub static SCAN_1: [[(u8, u8); 1]; 3] = [[(0, 0)], [(0, 0)], [(0, 0)]]; -pub static SCAN_2: [[(u8, u8); 4]; 3] = [diag::<4>(2), hv::<4>(2, false), hv::<4>(2, true)]; -pub static SCAN_4: [[(u8, u8); 16]; 3] = [diag::<16>(4), hv::<16>(4, false), hv::<16>(4, true)]; -pub static SCAN_8: [[(u8, u8); 64]; 3] = [diag::<64>(8), hv::<64>(8, false), hv::<64>(8, true)]; - -/// ScanOrder[log2BlockSize][scanIdx] (log2BlockSize 0..=3). -#[inline] -pub fn scan_order(log2: u32, scan_idx: usize) -> &'static [(u8, u8)] { - match log2 { - 0 => &SCAN_1[scan_idx], - 1 => &SCAN_2[scan_idx], - 2 => &SCAN_4[scan_idx], - _ => &SCAN_8[scan_idx], - } -} - -/// Up-right diagonal scan for log2 size 2 or 3. -pub fn scan_diag(log2: u32) -> &'static [(u8, u8)] { - scan_order(log2, 0) -} - -/// Table 8-10: QpC as a function of qPi (ChromaArrayType 1). -#[inline] -pub fn qpc_420(qpi: i32) -> i32 { - if qpi < 30 { - qpi - } else if qpi > 43 { - qpi - 6 - } else { - QPC_30_43[(qpi - 30) as usize] as i32 - } -} - -/// Table 9-50 ctxIdxMap for 4x4 sig_coeff_flag contexts. -pub static CTX_IDX_MAP: [u8; 16] = [0, 1, 4, 5, 2, 3, 4, 5, 6, 6, 8, 8, 7, 7, 8, 8]; - -/// levelScale (8-309). -pub static LEVEL_SCALE: [i32; 6] = [40, 45, 51, 57, 64, 72]; - -/// DST-VII 4x4 matrix (8.6.4.2, trType 1). -pub static DST_MATRIX: [[i8; 4]; 4] = [[29, 55, 74, 84], [74, 74, 0, -74], [84, -29, -74, 55], [55, -84, 74, -29]]; - - -// CABAC context layout: offset of each syntax element in the context array (Table 9-4). -pub const SAO_MERGE: usize = 0; -pub const SAO_TYPE: usize = 1; -pub const SPLIT_CU: usize = 2; -pub const CU_TRANSQUANT_BYPASS: usize = 5; -pub const CU_SKIP: usize = 6; -pub const PRED_MODE: usize = 9; -pub const PART_MODE: usize = 10; -pub const PREV_INTRA_LUMA: usize = 14; -pub const INTRA_CHROMA: usize = 15; -pub const RQT_ROOT_CBF: usize = 16; -pub const MERGE_FLAG: usize = 17; -pub const MERGE_IDX: usize = 18; -pub const INTER_PRED_IDC: usize = 19; -pub const REF_IDX: usize = 24; -pub const MVP_FLAG: usize = 26; -pub const SPLIT_TRANSFORM: usize = 27; -pub const CBF_LUMA: usize = 30; -pub const CBF_CHROMA: usize = 32; -pub const ABS_MVD_GT0: usize = 37; -pub const ABS_MVD_GT1: usize = 38; -pub const CU_QP_DELTA_ABS: usize = 39; -pub const TRANSFORM_SKIP: usize = 41; -pub const LAST_X_PREFIX: usize = 43; -pub const LAST_Y_PREFIX: usize = 61; -pub const CODED_SUB_BLOCK: usize = 79; -pub const SIG_COEFF: usize = 83; -pub const GT1: usize = 127; -pub const GT2: usize = 151; -pub const EXPLICIT_RDPCM: usize = 157; -pub const EXPLICIT_RDPCM_DIR: usize = 159; -pub const CU_CHROMA_QP_OFFSET_FLAG: usize = 161; -pub const CU_CHROMA_QP_OFFSET_IDX: usize = 162; -pub const NUM_CTX: usize = 163; - -/// initValue per initType (9.3.2.2). -pub static CABAC_INIT: [[u8; NUM_CTX]; 3] = [ - [153, 200, 139, 141, 157, 154, 154, 154, 154, 154, 184, 154, 154, 154, 184, 63, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 153, 138, 138, 111, 141, 94, 138, 182, 154, 154, 154, 154, 154, 154, 139, 139, 110, 110, 124, 125, 140, 153, 125, 127, 140, 109, 111, 143, 127, 111, 79, 108, 123, 63, 110, 110, 124, 125, 140, 153, 125, 127, 140, 109, 111, 143, 127, 111, 79, 108, 123, 63, 91, 171, 134, 141, 111, 111, 125, 110, 110, 94, 124, 108, 124, 107, 125, 141, 179, 153, 125, 107, 125, 141, 179, 153, 125, 107, 125, 141, 179, 153, 125, 140, 139, 182, 182, 152, 136, 152, 136, 153, 136, 139, 111, 136, 139, 111, 141, 111, 140, 92, 137, 138, 140, 152, 138, 139, 153, 74, 149, 92, 139, 107, 122, 152, 140, 179, 166, 182, 140, 227, 122, 197, 138, 153, 136, 167, 152, 152, 154, 154, 154, 154, 154, 154], - [153, 185, 107, 139, 126, 154, 197, 185, 201, 149, 154, 139, 154, 154, 154, 152, 79, 110, 122, 95, 79, 63, 31, 31, 153, 153, 168, 124, 138, 94, 153, 111, 149, 107, 167, 154, 154, 140, 198, 154, 154, 139, 139, 125, 110, 94, 110, 95, 79, 125, 111, 110, 78, 110, 111, 111, 95, 94, 108, 123, 108, 125, 110, 94, 110, 95, 79, 125, 111, 110, 78, 110, 111, 111, 95, 94, 108, 123, 108, 121, 140, 61, 154, 155, 154, 139, 153, 139, 123, 123, 63, 153, 166, 183, 140, 136, 153, 154, 166, 183, 140, 136, 153, 154, 166, 183, 140, 136, 153, 154, 170, 153, 123, 123, 107, 121, 107, 121, 167, 151, 183, 140, 151, 183, 140, 140, 140, 154, 196, 196, 167, 154, 152, 167, 182, 182, 134, 149, 136, 153, 121, 136, 137, 169, 194, 166, 167, 154, 167, 137, 182, 107, 167, 91, 122, 107, 167, 139, 139, 139, 139, 154, 154], - [153, 160, 107, 139, 126, 154, 197, 185, 201, 134, 154, 139, 154, 154, 183, 152, 79, 154, 137, 95, 79, 63, 31, 31, 153, 153, 168, 224, 167, 122, 153, 111, 149, 92, 167, 154, 154, 169, 198, 154, 154, 139, 139, 125, 110, 124, 110, 95, 94, 125, 111, 111, 79, 125, 126, 111, 111, 79, 108, 123, 93, 125, 110, 124, 110, 95, 94, 125, 111, 111, 79, 125, 126, 111, 111, 79, 108, 123, 93, 121, 140, 61, 154, 170, 154, 139, 153, 139, 123, 123, 63, 124, 166, 183, 140, 136, 153, 154, 166, 183, 140, 136, 153, 154, 166, 183, 140, 136, 153, 154, 170, 153, 138, 138, 122, 121, 122, 121, 167, 151, 183, 140, 151, 183, 140, 140, 140, 154, 196, 167, 167, 154, 152, 167, 182, 182, 134, 149, 136, 153, 121, 136, 122, 169, 208, 166, 167, 154, 152, 167, 182, 107, 167, 91, 107, 107, 167, 139, 139, 139, 139, 154, 154], -]; - - -/// Table 8-5: intraPredAngle for predModeIntra 0..34 (0 for planar / DC). -pub static INTRA_PRED_ANGLE: [i8; 35] = [0, 0, 32, 26, 21, 17, 13, 9, 5, 2, 0, -2, -5, -9, -13, -17, -21, -26, -32, -26, -21, -17, -13, -9, -5, -2, 0, 2, 5, 9, 13, 17, 21, 26, 32]; -/// Table 8-6: invAngle for predModeIntra 11..25 (index = mode - 11). -pub static INV_ANGLE: [i16; 15] = [-4096, -1638, -910, -630, -482, -390, -315, -256, -315, -390, -482, -630, -910, -1638, -4096]; - -/// Table 8-10: QpC for qPi = 30..43 (qPi < 30: QpC = qPi; qPi > 43: QpC = qPi - 6). -pub static QPC_30_43: [u8; 14] = [29, 30, 31, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37]; - - -/// Luma 8-tap interpolation filter fL[xFrac][k] (8-236..8-238), taps at offsets -3..4. -pub static LUMA_FILTER: [[i8; 8]; 4] = [[0, 0, 0, 64, 0, 0, 0, 0], [-1, 4, -10, 58, 17, -5, 1, 0], [-1, 4, -11, 40, 40, -11, 4, -1], [0, 1, -5, 17, 58, -10, 4, -1]]; -/// Chroma 4-tap interpolation filter fC[xFrac][k] (8-255..8-261), taps at offsets -1..2. -pub static CHROMA_FILTER: [[i8; 4]; 8] = [[0, 64, 0, 0], [-2, 58, 10, -2], [-4, 54, 16, -2], [-6, 46, 28, -4], [-4, 36, 36, -4], [-4, 28, 46, -6], [-2, 16, 54, -4], [-2, 10, 58, -2]]; - -/// DCT matrix (8-319, 8-321): TRANS_MATRIX[n][m] = transMatrix[m][n] (row n = basis function). -pub static TRANS_MATRIX: [[i8; 32]; 32] = [ - [64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64], - [90, 90, 88, 85, 82, 78, 73, 67, 61, 54, 46, 38, 31, 22, 13, 4, -4, -13, -22, -31, -38, -46, -54, -61, -67, -73, -78, -82, -85, -88, -90, -90], - [90, 87, 80, 70, 57, 43, 25, 9, -9, -25, -43, -57, -70, -80, -87, -90, -90, -87, -80, -70, -57, -43, -25, -9, 9, 25, 43, 57, 70, 80, 87, 90], - [90, 82, 67, 46, 22, -4, -31, -54, -73, -85, -90, -88, -78, -61, -38, -13, 13, 38, 61, 78, 88, 90, 85, 73, 54, 31, 4, -22, -46, -67, -82, -90], - [89, 75, 50, 18, -18, -50, -75, -89, -89, -75, -50, -18, 18, 50, 75, 89, 89, 75, 50, 18, -18, -50, -75, -89, -89, -75, -50, -18, 18, 50, 75, 89], - [88, 67, 31, -13, -54, -82, -90, -78, -46, -4, 38, 73, 90, 85, 61, 22, -22, -61, -85, -90, -73, -38, 4, 46, 78, 90, 82, 54, 13, -31, -67, -88], - [87, 57, 9, -43, -80, -90, -70, -25, 25, 70, 90, 80, 43, -9, -57, -87, -87, -57, -9, 43, 80, 90, 70, 25, -25, -70, -90, -80, -43, 9, 57, 87], - [85, 46, -13, -67, -90, -73, -22, 38, 82, 88, 54, -4, -61, -90, -78, -31, 31, 78, 90, 61, 4, -54, -88, -82, -38, 22, 73, 90, 67, 13, -46, -85], - [83, 36, -36, -83, -83, -36, 36, 83, 83, 36, -36, -83, -83, -36, 36, 83, 83, 36, -36, -83, -83, -36, 36, 83, 83, 36, -36, -83, -83, -36, 36, 83], - [82, 22, -54, -90, -61, 13, 78, 85, 31, -46, -90, -67, 4, 73, 88, 38, -38, -88, -73, -4, 67, 90, 46, -31, -85, -78, -13, 61, 90, 54, -22, -82], - [80, 9, -70, -87, -25, 57, 90, 43, -43, -90, -57, 25, 87, 70, -9, -80, -80, -9, 70, 87, 25, -57, -90, -43, 43, 90, 57, -25, -87, -70, 9, 80], - [78, -4, -82, -73, 13, 85, 67, -22, -88, -61, 31, 90, 54, -38, -90, -46, 46, 90, 38, -54, -90, -31, 61, 88, 22, -67, -85, -13, 73, 82, 4, -78], - [75, -18, -89, -50, 50, 89, 18, -75, -75, 18, 89, 50, -50, -89, -18, 75, 75, -18, -89, -50, 50, 89, 18, -75, -75, 18, 89, 50, -50, -89, -18, 75], - [73, -31, -90, -22, 78, 67, -38, -90, -13, 82, 61, -46, -88, -4, 85, 54, -54, -85, 4, 88, 46, -61, -82, 13, 90, 38, -67, -78, 22, 90, 31, -73], - [70, -43, -87, 9, 90, 25, -80, -57, 57, 80, -25, -90, -9, 87, 43, -70, -70, 43, 87, -9, -90, -25, 80, 57, -57, -80, 25, 90, 9, -87, -43, 70], - [67, -54, -78, 38, 85, -22, -90, 4, 90, 13, -88, -31, 82, 46, -73, -61, 61, 73, -46, -82, 31, 88, -13, -90, -4, 90, 22, -85, -38, 78, 54, -67], - [64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64], - [61, -73, -46, 82, 31, -88, -13, 90, -4, -90, 22, 85, -38, -78, 54, 67, -67, -54, 78, 38, -85, -22, 90, 4, -90, 13, 88, -31, -82, 46, 73, -61], - [57, -80, -25, 90, -9, -87, 43, 70, -70, -43, 87, 9, -90, 25, 80, -57, -57, 80, 25, -90, 9, 87, -43, -70, 70, 43, -87, -9, 90, -25, -80, 57], - [54, -85, -4, 88, -46, -61, 82, 13, -90, 38, 67, -78, -22, 90, -31, -73, 73, 31, -90, 22, 78, -67, -38, 90, -13, -82, 61, 46, -88, 4, 85, -54], - [50, -89, 18, 75, -75, -18, 89, -50, -50, 89, -18, -75, 75, 18, -89, 50, 50, -89, 18, 75, -75, -18, 89, -50, -50, 89, -18, -75, 75, 18, -89, 50], - [46, -90, 38, 54, -90, 31, 61, -88, 22, 67, -85, 13, 73, -82, 4, 78, -78, -4, 82, -73, -13, 85, -67, -22, 88, -61, -31, 90, -54, -38, 90, -46], - [43, -90, 57, 25, -87, 70, 9, -80, 80, -9, -70, 87, -25, -57, 90, -43, -43, 90, -57, -25, 87, -70, -9, 80, -80, 9, 70, -87, 25, 57, -90, 43], - [38, -88, 73, -4, -67, 90, -46, -31, 85, -78, 13, 61, -90, 54, 22, -82, 82, -22, -54, 90, -61, -13, 78, -85, 31, 46, -90, 67, 4, -73, 88, -38], - [36, -83, 83, -36, -36, 83, -83, 36, 36, -83, 83, -36, -36, 83, -83, 36, 36, -83, 83, -36, -36, 83, -83, 36, 36, -83, 83, -36, -36, 83, -83, 36], - [31, -78, 90, -61, 4, 54, -88, 82, -38, -22, 73, -90, 67, -13, -46, 85, -85, 46, 13, -67, 90, -73, 22, 38, -82, 88, -54, -4, 61, -90, 78, -31], - [25, -70, 90, -80, 43, 9, -57, 87, -87, 57, -9, -43, 80, -90, 70, -25, -25, 70, -90, 80, -43, -9, 57, -87, 87, -57, 9, 43, -80, 90, -70, 25], - [22, -61, 85, -90, 73, -38, -4, 46, -78, 90, -82, 54, -13, -31, 67, -88, 88, -67, 31, 13, -54, 82, -90, 78, -46, 4, 38, -73, 90, -85, 61, -22], - [18, -50, 75, -89, 89, -75, 50, -18, -18, 50, -75, 89, -89, 75, -50, 18, 18, -50, 75, -89, 89, -75, 50, -18, -18, 50, -75, 89, -89, 75, -50, 18], - [13, -38, 61, -78, 88, -90, 85, -73, 54, -31, 4, 22, -46, 67, -82, 90, -90, 82, -67, 46, -22, -4, 31, -54, 73, -85, 90, -88, 78, -61, 38, -13], - [9, -25, 43, -57, 70, -80, 87, -90, 90, -87, 80, -70, 57, -43, 25, -9, -9, 25, -43, 57, -70, 80, -87, 90, -90, 87, -80, 70, -57, 43, -25, 9], - [4, -13, 22, -31, 38, -46, 54, -61, 67, -73, 78, -82, 85, -88, 90, -90, 90, -90, 88, -85, 82, -78, 73, -67, 61, -54, 46, -38, 31, -22, 13, -4], -]; -/// Table 8-12: beta' for Q = 0..51. -pub static BETA_TABLE: [u8; 52] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64]; -/// Table 8-12: tC' for Q = 0..53. -pub static TC_TABLE: [u8; 54] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5, 5, 6, 6, 7, 8, 9, 10, 11, 13, 14, 16, 18, 20, 22, 24]; diff --git a/crates/hevcenc/src/transform.rs b/crates/hevcenc/src/transform.rs deleted file mode 100644 index cffea75c2..000000000 --- a/crates/hevcenc/src/transform.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Forward transforms (encoder side, any integer approximation is allowed), quantisation, and the -//! normative scaling (8.6.3) and inverse transforms (8.6.4.2) the decoder applies. The inverse path -//! is exact integer arithmetic: the even/odd decomposition computes the same sums as the matrix -//! products of the specification. - -use crate::tables::{DST_MATRIX, LEVEL_SCALE, TRANS_MATRIX}; - -#[inline] -fn m(n: usize, k: usize, i: usize) -> i32 { - // k-th basis function of the n-point DCT, sample i - TRANS_MATRIX[k * (32 / n)][i] as i32 -} - -/// 1-D forward DCT of `x` (length n) into `out`. -fn fdct1d(x: &[i32], out: &mut [i32], n: usize) { - if n <= 4 { - for k in 0..n { - let mut s = 0; - for i in 0..n { - s += m(n, k, i) * x[i]; - } - out[k] = s; - } - return; - } - let h = n / 2; - let mut e = [0i32; 16]; - let mut o = [0i32; 16]; - for i in 0..h { - e[i] = x[i] + x[n - 1 - i]; - o[i] = x[i] - x[n - 1 - i]; - } - let mut ev = [0i32; 16]; - fdct1d(&e[..h], &mut ev[..h], h); - for k in 0..h { - out[2 * k] = ev[k]; - let kk = 2 * k + 1; - let mut s = 0; - for i in 0..h { - s += m(n, kk, i) * o[i]; - } - out[kk] = s; - } -} - -/// 1-D inverse DCT: x[i] = sum_k M[k][i] * c[k]. -fn idct1d(c: &[i32], out: &mut [i32], n: usize) { - if n <= 4 { - for i in 0..n { - let mut s = 0; - for k in 0..n { - s += m(n, k, i) * c[k]; - } - out[i] = s; - } - return; - } - let h = n / 2; - let mut ce = [0i32; 16]; - for k in 0..h { - ce[k] = c[2 * k]; - } - let mut e = [0i32; 16]; - idct1d(&ce[..h], &mut e[..h], h); - for i in 0..h { - let mut o = 0; - let mut k = 1; - while k < n { - let v = c[k]; - if v != 0 { - o += m(n, k, i) * v; - } - k += 2; - } - out[i] = e[i] + o; - out[n - 1 - i] = e[i] - o; - } -} - -fn fdst1d(x: &[i32], out: &mut [i32]) { - for k in 0..4 { - out[k] = (0..4).map(|i| DST_MATRIX[k][i] as i32 * x[i]).sum(); - } -} - -fn idst1d(c: &[i32], out: &mut [i32]) { - for i in 0..4 { - out[i] = (0..4).map(|k| DST_MATRIX[k][i] as i32 * c[k]).sum(); - } -} - -/// Forward 2-D transform of the n x n residual `r` (row-major) into `c`. -pub fn forward(r: &[i32], c: &mut [i32], n: usize, dst: bool, bit_depth: u32) { - let log2 = n.trailing_zeros(); - let shift1 = log2 + bit_depth - 9; - let shift2 = log2 + 6; - let (r1, r2) = (1i32 << (shift1 - 1), 1i32 << (shift2 - 1)); - let mut tmp = vec![0i32; n * n]; - let mut out = [0i32; 32]; - // rows (horizontal frequencies) - for y in 0..n { - let row = &r[y * n..y * n + n]; - if dst { - fdst1d(row, &mut out); - } else { - fdct1d(row, &mut out, n); - } - for k in 0..n { - tmp[k * n + y] = (out[k] + r1) >> shift1; // transposed: tmp[kx][y] - } - } - // columns - let mut col = [0i32; 32]; - for kx in 0..n { - col[..n].copy_from_slice(&tmp[kx * n..kx * n + n]); - if dst { - fdst1d(&col, &mut out); - } else { - fdct1d(&col, &mut out, n); - } - for ky in 0..n { - c[ky * n + kx] = (out[ky] + r2) >> shift2; - } - } -} - -/// Inverse 2-D transform (8.6.4.2) of scaled coefficients `d` in place, producing residuals. -pub fn inverse(d: &mut [i32], n: usize, dst: bool, bit_depth: u32) { - let bd_shift = 20 - bit_depth; - let mut tmp = vec![0i32; n * n]; - let mut col = [0i32; 32]; - let mut out = [0i32; 32]; - // max non-zero row, to skip empty work - let mut nz_cols = [false; 32]; - for y in 0..n { - for x in 0..n { - if d[y * n + x] != 0 { - nz_cols[x] = true; - } - } - } - // 1. columns - for x in 0..n { - if !nz_cols[x] { - continue; // tmp column stays zero - } - for j in 0..n { - col[j] = d[j * n + x]; - } - if dst { - idst1d(&col, &mut out); - } else { - idct1d(&col, &mut out, n); - } - for y in 0..n { - tmp[y * n + x] = ((out[y] + 64) >> 7).clamp(-32768, 32767); - } - } - // 2. rows - let round = 1 << (bd_shift - 1); - for y in 0..n { - let row = &tmp[y * n..y * n + n]; - if dst { - idst1d(row, &mut out); - } else { - idct1d(row, &mut out, n); - } - for x in 0..n { - d[y * n + x] = (out[x] + round) >> bd_shift; - } - } -} - -const QUANT_SCALE: [i64; 6] = [26214, 23302, 20560, 18396, 16384, 14564]; - -/// Quantise transform coefficients `c` into levels `l` with a dead-zone rounding offset -/// (`intra`: 1/3, otherwise 1/6). `qp` includes QpBdOffset. Returns the number of non-zero levels. -pub fn quantize(c: &[i32], l: &mut [i32], n: usize, qp: i32, intra: bool, bit_depth: u32) -> usize { - let log2 = n.trailing_zeros() as i32; - let tshift = 15 - bit_depth as i32 - log2; - let qbits = 14 + qp / 6 + tshift; - let scale = QUANT_SCALE[(qp % 6) as usize]; - let offset = (if intra { 171i64 } else { 85 }) << (qbits - 9); - let mut nz = 0; - for i in 0..n * n { - let v = c[i] as i64; - let a = ((v.abs() * scale + offset) >> qbits).min(32767); - if a != 0 { - nz += 1; - } - l[i] = if v < 0 { -(a as i32) } else { a as i32 }; - } - nz -} - -/// Sign data hiding (7.3.8.11 signHidden): in every 4x4 sub-block whose first and last non-zero -/// coefficients (in scan order) are more than 3 positions apart, the sign of the first one is not -/// coded but inferred from the parity of the sum of absolute levels. Fix the parity where needed by -/// the +-1 level change that adds the least quantisation error, without moving the first / last -/// non-zero positions. -pub fn sign_hiding(c: &[i32], l: &mut [i32], log2: u32, scan_idx: usize, qp: i32, bit_depth: u32) { - use crate::tables::{SCAN_4, scan_order}; - let n = 1usize << log2; - let tshift = 15 - bit_depth as i32 - log2 as i32; - let qbits = 14 + qp / 6 + tshift; - let scale = QUANT_SCALE[(qp % 6) as usize]; - let sb_scan = scan_order(log2 - 2, scan_idx); - let pos = &SCAN_4[scan_idx]; - for &(xs, ys) in sb_scan { - let idx = |p: usize| (ys as usize * 4 + pos[p].1 as usize) * n + xs as usize * 4 + pos[p].0 as usize; - let (Some(first), Some(last)) = ((0..16).find(|&p| l[idx(p)] != 0), (0..16).rev().find(|&p| l[idx(p)] != 0)) else { continue }; - if last - first <= 3 { - continue; - } - let sum: i32 = (0..16).map(|p| l[idx(p)].abs()).sum(); - let negative = l[idx(first)] < 0; - if negative == (sum & 1 == 1) { - continue; - } - // cost of +-1 changes in 1/256 units of quantisation error - let mut best: Option<(i64, usize, i32)> = None; - for p in first..=last { - let i = idx(p); - let q = ((c[i] as i64).abs() * scale) >> (qbits - 8); - let a = l[i].abs() as i64; - let err = |lv: i64| (q - (lv << 8)).abs(); - let mut consider = |cost: i64, d: i32| { - if best.is_none_or(|b| cost < b.0) { - best = Some((cost, i, d)); - } - }; - if a < 32767 && (a > 0 || (p > first && p < last)) { - consider(err(a + 1) - err(a), 1); - } - if a > 1 || (a == 1 && p > first && p < last) { - consider(err(a - 1) - err(a), -1); - } - } - if let Some((_, i, d)) = best { - let a = l[i].abs() + d; - l[i] = if c[i] < 0 { -a } else { a }; - } - } -} - -/// Scaling process (8.6.2 / 8.6.3, flat scaling matrix): levels to scaled coefficients. -pub fn dequantize(l: &[i32], d: &mut [i32], n: usize, qp: i32, bit_depth: u32) { - let log2 = n.trailing_zeros(); - let bd_shift = bit_depth + log2 - 5; - let round = 1i64 << (bd_shift - 1); - let scale = (LEVEL_SCALE[(qp % 6) as usize] as i64) << (qp / 6); - for i in 0..n * n { - let v = l[i]; - d[i] = if v == 0 { 0 } else { ((v as i64 * 16 * scale + round) >> bd_shift).clamp(-32768, 32767) as i32 }; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Direct matrix form of the inverse transform, as written in the specification. - fn inverse_ref(c: &mut [i32], n: usize, dst: bool, bd: u32) { - let mut tmp = vec![0i32; n * n]; - let step = 32 / n; - for x in 0..n { - for y in 0..n { - let mut s = 0i32; - for j in 0..n { - let m = if dst { DST_MATRIX[j][y] as i32 } else { TRANS_MATRIX[j * step][y] as i32 }; - s += m * c[j * n + x]; - } - tmp[y * n + x] = ((s + 64) >> 7).clamp(-32768, 32767); - } - } - let bd_shift = 20 - bd; - for y in 0..n { - for x in 0..n { - let mut s = 0i32; - for j in 0..n { - let m = if dst { DST_MATRIX[j][x] as i32 } else { TRANS_MATRIX[j * step][x] as i32 }; - s += m * tmp[y * n + j]; - } - c[y * n + x] = (s + (1 << (bd_shift - 1))) >> bd_shift; - } - } - } - - #[test] - fn inverse_matches_matrix_form() { - let mut seed = 7u32; - for &(n, dst) in &[(4usize, true), (4, false), (8, false), (16, false), (32, false)] { - for _ in 0..20 { - let mut c = vec![0i32; n * n]; - for v in c.iter_mut() { - seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); - if (seed >> 24).is_multiple_of(3) { - *v = ((seed >> 8) % 2001) as i32 - 1000; - } - } - let mut a = c.clone(); - let mut b = c.clone(); - inverse(&mut a, n, dst, 8); - inverse_ref(&mut b, n, dst, 8); - assert_eq!(a, b, "n {n} dst {dst}"); - } - } - } - - #[test] - fn round_trip_is_close() { - for &(n, dst) in &[(4usize, true), (8, false), (32, false)] { - for bd in [8u32, 10] { - let r: Vec = (0..n * n).map(|i| ((i * 37 + i / n * 11) % 61) as i32 - 30).collect(); - let mut c = vec![0; n * n]; - forward(&r, &mut c, n, dst, bd); - let mut l = vec![0; n * n]; - let qp = 4; - quantize(&c, &mut l, n, qp, true, bd); - let mut d = vec![0; n * n]; - dequantize(&l, &mut d, n, qp, bd); - inverse(&mut d, n, dst, bd); - let err: i32 = r.iter().zip(&d).map(|(a, b)| (a - b).abs()).max().unwrap(); - assert!(err <= 2, "n {n} bd {bd} err {err}"); - } - } - } -} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 3d1384a90..7212fd730 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -1,6 +1,6 @@ //! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs), //! the media importer, the expression engine, JavaScript scripting (`effectcraft-script`) and -//! Render Queue export through `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, +//! Render Queue export through `effectcraft-export` (craft-libs' encoders). Frontends (desktop, //! CLI, MCP, web) start here. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -20,6 +20,9 @@ impl Importer for MediaImporter { fn probe(&self, path: &str) -> Result { effectcraft_media::probe(path).map_err(|e| e.to_string()) } + fn probe_noting(&self, path: &str) -> Result<(Footage, Option), String> { + effectcraft_media::probe_noting(path).map_err(|e| e.to_string()) + } } /// Render Queue export via `effectcraft-export`: to the file system, or to `sink` (the web app @@ -65,6 +68,7 @@ impl Exporter for FileExporter { caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }), renderer: job.renderer, gpu_frames: job.gpu_frames, + free_memory: Some(&free_memory), }, nested_switches: job.nested_switches, }; @@ -87,6 +91,11 @@ impl Exporter for FileExporter { } } +/// The memory the system can hand out now, for an export's Auto CPU frames in flight (#1615). +fn free_memory() -> Option { + effectcraft_engine::sysinfo::memory().map(|m| m.available) +} + /// A new session with media, import, expressions, scripting and export enabled. pub fn session() -> Session { let mut s = Session { diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index 55240698f..687aafe77 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -547,6 +547,7 @@ fn script_imports_footage_and_saves() { std::fs::create_dir_all(&dir).unwrap(); let q = |n: &str| serde_json::to_string(&dir.join(n).to_string_lossy()).unwrap(); let mut s = super::session(); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); let code = format!( r#" var c = app.project.items.addComp("Src", 32, 32, 1, 1, 10); diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs index 168ec127a..ecc1abb0f 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -15,3 +15,5 @@ mod timeline_interop; mod vector_import; mod wav_markers; mod webm_metadata; + +mod repros; diff --git a/crates/host/tests/it/plugins.rs b/crates/host/tests/it/plugins.rs index 33748d366..a5fc0b8e1 100644 --- a/crates/host/tests/it/plugins.rs +++ b/crates/host/tests/it/plugins.rs @@ -276,8 +276,8 @@ pub(super) fn wat_plugin(id: &str, api: i32, render: &str) -> String { #[test] fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { - let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let files = [ ("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()), ("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()), diff --git a/crates/host/tests/it/repros.rs b/crates/host/tests/it/repros.rs new file mode 100644 index 000000000..1b343603c --- /dev/null +++ b/crates/host/tests/it/repros.rs @@ -0,0 +1,31 @@ +//! Regression tests as data: every `tests/repros/*.jsonl` (repository root) is a replay script +//! (`effectcraft_engine::replay`, docs/agents.md "Repro scripts") run against a fully wired +//! headless session, like `effectcraft-cli replay`. A crash fix's regression test is a `.jsonl` +//! dropped there: it must replay without a panic and meet its expectations. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::replay; + +fn repros_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros") +} + +#[test] +fn every_repro_replays_cleanly() { + let mut files: Vec = + std::fs::read_dir(repros_dir()).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "jsonl")).collect(); + files.sort(); + assert!(!files.is_empty(), "no repros in {}", repros_dir().display()); + let mut failures = vec![]; + for f in &files { + let script = std::fs::read_to_string(f).unwrap(); + let mut s = effectcraft_host::session(); + let opts = replay::Options { keep_going: false, base_dir: f.parent().map(Path::to_path_buf) }; + let r = replay::run(&mut s, &script, &opts); + if !r.ok { + failures.push(format!("{}: {}", f.display(), serde_json::to_string(&r.first_failure).unwrap())); + } + } + assert!(failures.is_empty(), "{} of {} repros failed:\n{}", failures.len(), files.len(), failures.join("\n")); +} diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 375c1bf1b..fd396b00b 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -303,16 +303,41 @@ impl Exporter<'_> { } /// The bin mirroring a Project panel folder. + /// + /// Walks up the folder's parents without recursion: a damaged or hostile project can hold a + /// parent cycle (or a very long chain), which recursed until the stack ran out. A folder seen + /// twice in one walk ends it, and the cycle's topmost folder becomes a top-level bin. fn bin(&mut self, folder: Option) -> Option { let f = folder?; - if let Some(b) = self.bins.get(&f) { - return Some(*b); + // The uncached folders from `f` up, and the bin they hang from. + let mut chain: Vec = vec![]; + let mut in_chain = std::collections::HashSet::new(); + let mut parent = None; + let mut at = Some(f); + while let Some(id) = at { + if let Some(b) = self.bins.get(&id) { + parent = Some(*b); + break; + } + if !in_chain.insert(id) { + self.warn("a folder that contains itself was exported as a top-level bin"); + break; + } + let Some(it) = self.p.item(id) else { break }; + chain.push(id); + at = it.parent; } - let it = self.p.item(f)?; - let parent = self.bin(it.parent); - let b = self.fp.add_bin(&it.name, parent); - self.bins.insert(f, b); - Some(b) + if chain.is_empty() { + // `f` is cached already (`parent` is its bin), or is not an item (`None`). + return parent; + } + for id in chain.into_iter().rev() { + let name = self.p.item(id).map(|it| it.name.clone()).unwrap_or_default(); + let b = self.fp.add_bin(&name, parent); + self.bins.insert(id, b); + parent = Some(b); + } + parent } fn footage_item(&mut self, item: ItemId, f: &Footage) -> fp::ItemId { @@ -615,12 +640,34 @@ impl Exporter<'_> { } } +/// Whether `c` or a comp nested in it (at any depth) has a layer of footage with audio. +/// +/// A walk with a stack and a visited set, as in `Project::comp_contains`: recursion never ended +/// on a precomp cycle, and re-walked a comp shared by many layers once per path (exponential +/// for a chain of comps that each use the next one twice). fn comp_has_audio(p: &Project, c: &Comp) -> bool { - c.layers.iter().any(|l| match &l.source { - LayerSource::Footage { item } => matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio), - LayerSource::Comp { item } => p.comp(*item).is_some_and(|c| comp_has_audio(p, c)), - _ => false, - }) + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![c]; + while let Some(c) = stack.pop() { + for l in &c.layers { + match &l.source { + LayerSource::Footage { item } => { + if matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio) { + return true; + } + } + LayerSource::Comp { item } => { + if seen.insert(*item) + && let Some(nested) = p.comp(*item) + { + stack.push(nested); + } + } + _ => {} + } + } + } + false } fn differs(v: &Value, def: [f64; 2]) -> bool { @@ -703,3 +750,37 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option fp::MediaClip { fp::MediaClip { media, info, interpret: Default::default(), mark_in: None, mark_out: None, markers: vec![], offline: false, proxy: None, identity: None } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A folder's bin is the same one each time it is asked for (a cached folder once answered + /// `None`, so a folder's second item went to the project root); a non-item has no bin. + #[test] + fn folder_bins_are_reused_once_cached() { + let mut p = Project::default(); + 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); + let (opts, pre) = (ExportOptions::default(), HashMap::new()); + let mut ex = Exporter { + p: &p, + opts: &opts, + pre: &pre, + fp: fp::Project::new("x"), + out: ExportOutput::default(), + seqs: HashMap::new(), + media: HashMap::new(), + bins: HashMap::new(), + stack: vec![], + }; + let bb = ex.bin(Some(b)); + assert!(bb.is_some()); + assert_eq!(ex.bin(Some(b)), bb); + let ba = ex.bin(Some(a)); + assert!(ba.is_some() && ba != bb); + assert_eq!(ex.bin(Some(a)), ba); + assert_eq!(ex.bin(Some(ItemId(u64::MAX))), None); + assert_eq!(ex.bin(None), None); + } +} diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 22e22b4f2..6faadd91a 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -177,10 +177,12 @@ pub fn import( } let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() }; res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder); + let warned = res.warnings.iter().cloned().collect(); let mut im = Importer { src: &imported.project, project, res, + warned, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, @@ -273,6 +275,8 @@ struct Importer<'a> { src: &'a fp::Project, project: &'a mut Project, res: ImportResult, + /// The warnings in `res`, to say each once without searching them all every time. + warned: HashSet, /// Source item → created item (footage, comp). items: HashMap, /// Generator / adjustment solids by (source item, width, height). @@ -290,7 +294,7 @@ struct Importer<'a> { impl Importer<'_> { fn warn(&mut self, m: impl Into) { let m = m.into(); - if !self.res.warnings.contains(&m) { + if self.warned.insert(m.clone()) { self.res.warnings.push(m); } } @@ -429,23 +433,8 @@ impl Importer<'_> { comp.markers = seq.markers.iter().map(marker_in).collect(); // Video: the top track's clips first (layer #1); later clips of a track above earlier ones // so incoming clips of dissolves cover outgoing ones. - for track in seq.video_tracks.iter().rev() { - let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); - items.sort_by_key(|i| std::cmp::Reverse(i.start)); - for ti in items { - if let Some(l) = self.clip_layer(&comp, seq, track, ti) { - comp.layers.push(l); - } - } - } - for track in &seq.audio_tracks { - let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); - items.sort_by_key(|i| std::cmp::Reverse(i.start)); - for ti in items { - if let Some(l) = self.clip_layer(&comp, seq, track, ti) { - comp.layers.push(l); - } - } + for track in seq.video_tracks.iter().rev().chain(&seq.audio_tracks) { + self.track_layers(&mut comp, seq, track); } if !seq.caption_tracks.is_empty() { self.warn("caption tracks are not imported"); @@ -508,7 +497,45 @@ impl Importer<'_> { } } - fn clip_layer(&mut self, comp: &Comp, seq: &fp::Sequence, track: &fp::Track, ti: &fp::TrackItem) -> Option { + /// A track's clips as layers of `comp`, the latest first. + fn track_layers(&mut self, comp: &mut Comp, seq: &fp::Sequence, track: &fp::Track) { + let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.start)); + // Each clip's transitions, found once per track: scanning every transition of the track + // for every clip made a long flat edit (many clips and many transitions) quadratic. + let mut transitions: HashMap> = HashMap::new(); + for tr in &track.transitions { + for clip in [tr.from, tr.to].into_iter().flatten() { + let list = transitions.entry(clip).or_default(); + // A transition from and to the same clip still counts once. + if !list.last().is_some_and(|last| std::ptr::eq(*last, tr)) { + list.push(tr); + } + } + } + let mut any = false; + for ti in items { + let own = transitions.get(&ti.id).map(Vec::as_slice).unwrap_or_default(); + if let Some(l) = self.clip_layer(comp, seq, track, ti, own) { + comp.layers.push(l); + any = true; + } + } + // What the track's transitions become, said once a clip of the track was imported. + if any && track.kind != fp::TrackKind::Audio { + for tr in &track.transitions { + let id = tr.effect.effect.as_str(); + if !matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "film_dissolve" | "additive_dissolve" | "non_additive_dissolve") { + self.warn(format!("transition \"{}\" was imported as a cross dissolve", tr.effect.def().map(|d| d.name).unwrap_or(id))); + } + } + } + if any && track.transitions.iter().any(|tr| tr.effect.effect == "dip_to_white") { + self.warn("Dip to White was imported as a dip to the background"); + } + } + + fn clip_layer(&mut self, comp: &Comp, seq: &fp::Sequence, track: &fp::Track, ti: &fp::TrackItem, transitions: &[&fp::Transition]) -> Option { let audio_track = track.kind == fp::TrackKind::Audio; let (source, size, has_audio) = self.source_of(ti, comp.width, comp.height)?; let is_adjustment = matches!(self.src.item(ti.item).map(|i| &i.kind), Some(fp::ItemKind::AdjustmentLayer { .. })); @@ -561,18 +588,9 @@ impl Importer<'_> { } // Transitions extend the layer over their handles. let mut fades: Vec<(Tick, Tick, f64, f64)> = vec![]; - for tr in &track.transitions { + for tr in transitions { let (ts, te) = (tick_in(tr.start), tick_in(tr.end())); - let id = tr.effect.effect.as_str(); - let dip = id.starts_with("dip_to"); - if !audio_track - && !matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "film_dissolve" | "additive_dissolve" | "non_additive_dissolve") - { - self.warn(format!("transition \"{}\" was imported as a cross dissolve", tr.effect.def().map(|d| d.name).unwrap_or(id))); - } - if dip && id == "dip_to_white" { - self.warn("Dip to White was imported as a dip to the background"); - } + let dip = tr.effect.effect.starts_with("dip_to"); let mid = ts + Tick((te - ts).0 / 2); if tr.to == Some(ti.id) { l.in_point = l.in_point.min(ts); diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index b451ecf81..4ce892c15 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -787,3 +787,98 @@ fn deeply_nested_documents_error_instead_of_overflowing_the_stack() { assert_eq!(super::import::xml_depth(b"]]>"), 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..f91d8be4d 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -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/src/anim.rs b/crates/lottie/src/anim.rs index 5b6dabf50..40a56a7eb 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -21,6 +21,9 @@ use serde_json::{Value as 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 +34,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 +50,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); } } diff --git a/crates/lottie/src/export.rs b/crates/lottie/src/export.rs index eeb6e2737..88dcf4ed4 100644 --- a/crates/lottie/src/export.rs +++ b/crates/lottie/src/export.rs @@ -145,16 +145,18 @@ fn hex(c: [f32; 3]) -> String { 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" +/// The image type `bytes` hold, by their signature; `None` for anything else. +pub(crate) fn image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + Some("image/webp") } else { - "image/png" + None } } @@ -286,7 +288,13 @@ 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)); + // 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; + }; + let uri = format!("data:{mime};base64,{}", crate::base64::encode(&bytes)); self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1})); self.done.insert(id.clone()); Some(id) diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 57ef6427a..1e1430e12 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -1,6 +1,6 @@ //! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use effectcraft_color::Label; @@ -17,6 +17,9 @@ use crate::anim::{self, Im, 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)] @@ -129,9 +132,12 @@ pub fn import( 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 { + // 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 = 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(); + let fam = short(s(fo, "fFamily").unwrap_or(&n)); + let st = short(s(fo, "fStyle").unwrap_or("Regular")); cx.fonts.push((n, fam, st)); } } @@ -150,11 +156,12 @@ pub fn import( 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(); + let layer_sizes = precomp_layer_sizes(&doc, &assets); for a in &assets { let Some(id) = s(a, "id") else { continue }; if a.get("layers").is_some() { let folder = cx.folder(project); - let layer = precomp_layer_size(&doc, &assets, id); + let layer = layer_sizes.get(id).copied(); 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); @@ -255,17 +262,20 @@ pub fn import( 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)> { +/// 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>(doc: &'a Json, assets: &'a [Json]) -> HashMap<&'a str, (u32, u32)> { + let mut out = HashMap::new(); 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; + for l in lists.filter_map(Json::as_array).flatten() { + if l.get("ty").and_then(Json::as_u64) != Some(0) { + continue; } - 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)) - }) + let (Some(id), Some((lw, lh))) = (s(l, "refId"), f(l, "w").zip(f(l, "h"))) 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) { @@ -411,8 +421,8 @@ fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> O fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) { 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); @@ -463,8 +473,8 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ 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); + let mut items = j.get("shapes").and_then(Json::as_array).into_iter().flatten(); + let groups = shapes::import_items(&mut cx.im, &mut ids, &mut items); if let Some(c) = l.props.sub_mut("contents") { c.children.extend(groups.into_iter().map(Node::Group)); } @@ -507,10 +517,10 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ _ => None, } .map(|k| (k, j.get("tp").and_then(Json::as_i64))); - pending.push((l.id, parent, matte, ji)); + pending.push((l.id, comp.layers.len(), 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 { @@ -520,7 +530,7 @@ 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; } diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index fab8d3646..6cd1f09cc 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -436,7 +436,7 @@ fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) { } /// 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(""); @@ -447,9 +447,10 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { - 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); + // Borrowed, not cloned: each nesting level copying its subtree costs memory + // quadratic in the depth. + let list = j.get("it").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default(); + let children = import_items(im, ids, &mut list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr"))); 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") diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 414c875f0..8656852de 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -725,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, @@ -775,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(); @@ -985,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..4e50b461d 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -310,7 +310,8 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro 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") diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 4947d928a..76cce90fa 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -22,9 +22,8 @@ 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-audio = { workspace = true } +craft-media = { workspace = true } filmcraft-mpegts = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } @@ -38,5 +37,6 @@ thiserror = { workspace = true } log = { workspace = true } [dev-dependencies] +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/gif_anim.rs b/crates/media/src/gif_anim.rs index 09c0cda34..d201c8b7e 100644 --- a/crates/media/src/gif_anim.rs +++ b/crates/media/src/gif_anim.rs @@ -93,7 +93,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 +190,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 +234,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 64ade9394..adc23cf31 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -119,7 +119,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - 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_with(&doc, w, h, to_image); + 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)); @@ -132,10 +132,15 @@ const MAX_VECTOR_SIDE: u32 = 16384; /// 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). -pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option { +/// +/// `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); - Some(effectcraft_svg::rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image)) + 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 @@ -173,6 +178,18 @@ mod tests { 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( @@ -214,7 +231,7 @@ mod tests { (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).map(|i| (i.width, i.height)), Some((100, 50))); + 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(); @@ -226,7 +243,7 @@ mod tests { 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(); + 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. diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 0b509c45f..454c3705f 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -33,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)] diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index cd6676220..f5d109797 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,13 +6,14 @@ 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; @@ -53,10 +54,28 @@ struct Key { /// 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, @@ -112,8 +131,8 @@ struct Lru { } impl Lru { - /// The cached image and its read files for `k`, marking the entry as most recently used. - fn get(&mut self, k: &Key) -> Option<(Arc, Arc)> { + /// 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; @@ -121,9 +140,9 @@ impl Lru { self.order.insert(e.stamp, k.clone()); Some((e.img.clone(), e.files.clone())) } - /// Stores an image 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: Arc, files: 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, files, stamp: self.clock, bytes }) { self.order.remove(&old.stamp); @@ -145,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); } @@ -185,6 +206,8 @@ 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 @@ -407,6 +430,7 @@ impl MediaPool { c.lru.remove(&k); } c.last.remove(path); + c.wide.remove(path); drop(c); crate::exr_channels::forget(path); } @@ -422,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 { @@ -775,7 +814,7 @@ impl Inner { } << 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); @@ -811,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)) { @@ -854,7 +905,7 @@ impl Inner { drop(c); let (img, files) = reads::captured(|| self.decode(&loc, footage)); reads::note_all(&files); - return Ok(Arc::new(img?)); + return img; } c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner); } @@ -864,8 +915,14 @@ impl Inner { let _guard = Inflight { inner: self, key: loc.key.clone() }; let (img, files) = reads::captured(|| self.decode(&loc, footage)); reads::note_all(&files); - let img = Arc::new(img?); - lock(&self.cache).lru.insert(loc.key, img.clone(), Arc::new(files), self.budget()); + 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) } @@ -876,24 +933,25 @@ impl Inner { 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); + 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 @@ -910,9 +968,9 @@ 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), } } @@ -953,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, &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")))?; @@ -985,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))) } } } @@ -1038,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() @@ -1051,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, 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]) { @@ -1275,8 +1357,8 @@ 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(), Arc::default(), budget); @@ -1292,6 +1374,18 @@ mod tests { 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/webp_anim.rs b/crates/media/src/webp_anim.rs index 9420be1ed..f0784d1e3 100644 --- a/crates/media/src/webp_anim.rs +++ b/crates/media/src/webp_anim.rs @@ -157,6 +157,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 +331,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/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..4f952847c 100644 --- a/crates/model/src/fbx.rs +++ b/crates/model/src/fbx.rs @@ -36,6 +36,12 @@ const MAX_ARRAY_BYTES: usize = 1 << 30; /// wire type) plus the meshes built from them (#1234). const MAX_IMPORT_BYTES: usize = 1 << 31; +/// Most pixels one texture may hold (8192 × 8192) and all the textures of one import together +/// (2^29: 8 GiB as float RGBA, thirty-two 4K maps). A texture past a limit is left out and the +/// import says so. +const MAX_TEXTURE_PIXELS: u64 = 1 << 26; +const MAX_TEXTURE_IMPORT_PIXELS: u64 = 1 << 29; + /// What is left of an import's memory budget. struct Budget(std::cell::Cell); @@ -230,7 +236,7 @@ impl Bin<'_> { } let mut out = vec![]; while *pos < end { - match self.record(pos, depth)? { + match self.record(pos, end, depth)? { Some(n) => out.push(n), None => break, } @@ -238,8 +244,9 @@ impl Bin<'_> { Ok(out) } - /// One node record; `None` for the null record that closes a list. - fn record(&self, pos: &mut usize, depth: usize) -> Result> { + /// One node record ending within `limit` (the enclosing record's end); `None` for the null + /// record that closes a list. + fn record(&self, pos: &mut usize, limit: usize, depth: usize) -> Result> { let start = *pos; let end = Self::to_usize(self.offset(pos)?)?; let nprops = self.offset(pos)?; @@ -248,7 +255,9 @@ impl Bin<'_> { if end == 0 && nprops == 0 && plen == 0 && nlen == 0 { return Ok(None); } - if end <= start || end > self.d.len() { + // A child reaching past its parent would be parsed again once the parent's end is + // restored. + if end <= start || end > limit.min(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(); @@ -302,7 +311,8 @@ impl Bin<'_> { 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:?}")))?, + // The status only: the error's Debug form prints what was decompressed. + 1 => miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, want).map_err(|e| perr(format!("FBX: array zlib: {:?}", e.status)))?, 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"))?; @@ -334,7 +344,7 @@ fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { let bin = Bin { d: bytes, wide: version >= 7500, budget }; let mut nodes = vec![]; while pos < bytes.len() { - match bin.record(&mut pos, 0) { + match bin.record(&mut pos, bytes.len(), 0) { Ok(Some(n)) => nodes.push(n), Ok(None) => break, // A damaged tail (footer, padding) after complete sections is ignored. @@ -651,6 +661,7 @@ fn build_mesh(name: &str, geom: &FbxNode, materials: &[usize], with_normal_maps: type Key = (u32, [u32; 3], [u32; 2]); let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; + let mut prim_of: HashMap, usize> = HashMap::new(); let mut corners: Vec<(usize, usize)> = vec![]; let mut poly = 0usize; for (pv, &raw) in pvi.iter().enumerate() { @@ -671,13 +682,10 @@ fn build_mesh(name: &str, geom: &FbxNode, materials: &[usize], with_normal_maps: 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 at = *prim_of.entry(slot).or_insert_with(|| { + 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 { @@ -816,6 +824,12 @@ fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: links.push((a, b, e.str_at(3))); } } + // The same links by parent, so each object finds its children without scanning them all. + let mut children_of: HashMap)>> = HashMap::new(); + for &(a, b, prop) in &links { + children_of.entry(b).or_default().push((a, prop)); + } + let children_of = |id: i64| children_of.get(&id).map(Vec::as_slice).unwrap_or_default().iter().copied(); 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(); @@ -851,43 +865,91 @@ fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: 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 { + // Decoded images by the source actually used (embedded Video id, else the resolved file + // name): Texture objects aliasing one image share its pixels instead of each keeping a copy. + let mut image_of: HashMap<(Option, String), Option> = HashMap::new(); + // Textures have their own allowance (pixels, as `Texture::decode_within` counts them); the + // memory budget is for the file's own records. + let mut texture_pixels_left = MAX_TEXTURE_IMPORT_PIXELS; + let mut warnings: Vec = vec![]; + // Ok(None): no usable image (the material goes untextured). Err: the memory budget ran out. + let mut load_texture = |tid: i64, textures: &mut Vec>| -> Result> { if let Some(t) = texture_of.get(&tid) { - return *t; + return Ok(*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 Some(tex) = by_id.get(&tid) else { + texture_of.insert(tid, None); + return Ok(None); + }; + let videos: Vec = children_of(tid).filter(|l| kind(l.0) == Some("Video")).map(|l| l.0).collect(); + let files: Vec = ["RelativeFilename", "FileName"].iter().filter_map(|k| Some(tex.child(k)?.str_at(0)?.replace('\\', "/"))).collect(); + let embedded = videos.iter().find_map(|&v| { + let content = by_id.get(&v)?.child("Content")?; + let bytes = 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()) } - }); - 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) - })(); + _ => None, + }?; + Some(((Some(v), String::new()), bytes)) + }); + let source = embedded.or_else(|| { + files.iter().find_map(|f| { + let g = resolve(f).map(|b| (f.clone(), b)).or_else(|| { + let base = f.rsplit('/').next()?; + Some((base.to_string(), resolve(base)?)) + })?; + Some(((None, g.0), g.1)) + }) + }); + let t = match source { + None => None, + Some((key, bytes)) => match image_of.get(&key) { + Some(i) => *i, + None => { + // Charge the decoded size before decoding, so a small file claiming huge + // dimensions is refused before its pixels are allocated. A texture past the + // limits is left out (the material goes untextured) and the import says so. + let loaded = Texture::dimensions(&bytes).and_then(|(w, h)| { + let pixels = u64::from(w) * u64::from(h); + if pixels > MAX_TEXTURE_PIXELS { + return Err(perr(format!("a {w}×{h} texture (at most {MAX_TEXTURE_PIXELS} pixels)"))); + } + if pixels > texture_pixels_left { + return Err(perr(format!("a {w}×{h} texture: the model's textures would hold more than {MAX_TEXTURE_IMPORT_PIXELS} pixels"))); + } + let t = Texture::decode(&bytes)?; + texture_pixels_left -= pixels; + Ok(t) + }); + let i = match loaded { + Ok(t) => { + textures.push(Arc::new(t)); + Some(textures.len() - 1) + } + Err(e) => { + log::warn!("model: FBX texture left out: {e}"); + warnings.push(format!("texture left out: {e}")); + None + } + }; + image_of.insert(key, i); + i + } + }, + }; texture_of.insert(tid, t); - t + Ok(t) }; - let mut material_index = |mid: i64, materials: &mut Vec, textures: &mut Vec>| -> Option { + let mut material_index = |mid: i64, materials: &mut Vec, textures: &mut Vec>| -> Result> { if let Some(&m) = material_of.get(&mid) { - return Some(m); + return Ok(Some(m)); } - let node = by_id.get(&mid)?; + let Some(node) = by_id.get(&mid) else { return Ok(None) }; 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")) { + for (tid, prop) in children_of(mid).filter(|l| kind(l.0) == Some("Texture")) { let slot = match prop { Some("DiffuseColor") => &mut m.base_color_tex, Some("NormalMap") => &mut m.normal_tex, @@ -895,7 +957,7 @@ fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: _ => continue, }; if slot.is_none() { - *slot = load_texture(tid, textures).map(|texture| TexRef { texture, uv_set: 0 }); + *slot = load_texture(tid, textures)?.map(|texture| TexRef { texture, uv_set: 0 }); } } if m.emissive_tex.is_some() && m.emissive == [0.0; 3] { @@ -903,7 +965,7 @@ fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: } materials.push(m); material_of.insert(mid, materials.len() - 1); - Some(materials.len() - 1) + Ok(Some(materials.len() - 1)) }; // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the @@ -912,10 +974,12 @@ fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: 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(gid) = children_of(id).find(|l| 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 mut mats: Vec = vec![]; + for (mid, _) in children_of(id).filter(|l| kind(l.0) == Some("Material")) { + mats.extend(material_index(mid, &mut materials, &mut textures)?); + } 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, @@ -947,7 +1011,7 @@ fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: if meshes.is_empty() { return Err(perr("FBX file without meshes")); } - Ok(Model { meshes, nodes, roots: vec![0], materials, textures, ..Model::default() }) + Ok(Model { meshes, nodes, roots: vec![0], materials, textures, warnings, ..Model::default() }) } #[cfg(test)] diff --git a/crates/model/src/fbx/tests.rs b/crates/model/src/fbx/tests.rs index 1ba1978ba..fcd34c4f0 100644 --- a/crates/model/src/fbx/tests.rs +++ b/crates/model/src/fbx/tests.rs @@ -409,3 +409,112 @@ fn fbx_models_instancing_one_geometry_share_its_mesh() { 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 binary_child_record_past_its_parent_is_an_error() { + let parent = FbxNode { name: "P".into(), props: vec![], children: vec![FbxNode { name: "C".into(), props: vec![], children: vec![] }] }; + let after = FbxNode { name: "Q".into(), props: vec![Prop::Int(1)], children: vec![] }; + let mut bin = write_binary(&[parent, after], 7400, false); + assert_eq!(read(&bin).unwrap().1.len(), 2); + // `P` starts after the magic and version; `C` after P's 12-byte header, name length and name. + let p = BINARY_MAGIC.len() + 4; + let p_end = u32::from_le_bytes(bin[p..p + 4].try_into().unwrap()); + let c = p + 14; + bin[c..c + 4].copy_from_slice(&(p_end + 1).to_le_bytes()); + assert!(read(&bin).unwrap_err().to_string().contains("bad record end")); +} + +#[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))); + // Textures have their own allowance: a tight record budget does not drop them. + let (_, nodes) = read(doc.as_bytes()).unwrap(); + let values = value_bytes(&nodes).unwrap(); + let mesh = mesh_bytes(&m.meshes[0]).unwrap(); + assert_eq!(parse_within(doc.as_bytes(), &resolve, values + mesh).unwrap().textures.len(), 1); + 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..d55c39455 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -11,12 +11,14 @@ //! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets //! are ignored. +use std::collections::HashMap; use std::{cell::Cell, sync::Arc}; use serde_json::Value; use crate::{ - AlphaMode, Animation, Channel, ChannelPath, Interp, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, TexRef, Texture, Wrap, perr, + AlphaMode, Animation, Channel, ChannelPath, Interp, MAX_IMPORT_TEXTURE_PIXELS, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, + TexRef, Texture, Wrap, perr, }; const GLB_MAGIC: u32 = 0x4654_6C67; @@ -352,19 +354,41 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>> = 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 naming one source decode once, and all of them within one pixel budget. + let mut image_of: HashMap>> = HashMap::new(); + let mut pixels_left = MAX_IMPORT_TEXTURE_PIXELS; + let mut warnings: Vec = vec![]; + for (n, im) in arr(&j, "images").iter().enumerate() { + let (uri, view) = (im.get("uri").and_then(Value::as_str), idx(im, "bufferView")); + let key = match (uri, view) { + (Some(u), _) => format!("uri:{u}"), + (None, Some(bv)) => format!("bufferView:{bv}"), + (None, None) => { + images.push(None); + continue; + } }; - images.push(data.and_then(|b| Texture::decode(&b).ok()).map(Arc::new)); + let image = image_of.entry(key).or_insert_with(|| { + let data = if let Some(u) = uri { + load_uri(u, resolve).ok() + } else { + arr(&j, "bufferViews").get(view.unwrap_or(usize::MAX)).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) + }) + }; + match data.map(|b| Texture::decode_within(&b, &mut pixels_left)) { + Some(Ok(t)) => Some(Arc::new(t)), + Some(Err(e)) => { + log::warn!("model: glTF image {n} left out: {e}"); + warnings.push(format!("image {n} left out: {e}")); + None + } + None => None, + } + }); + images.push(image.clone()); } let wrap = |v: u64| match v { 33071 => Wrap::Clamp, @@ -373,18 +397,45 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option> = vec![]; let mut tex_map: Vec> = vec![]; + // One texture per decoded image and wrapping: repeated references share it instead of copying + // the pixels. The image is keyed by its allocation, so image entries naming the same file + // (which share one decode) also share their wrap variants. + let mut texture_of: HashMap<(usize, [Wrap; 2]), usize> = HashMap::new(); 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 src = Arc::as_ptr(&img) as usize; 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 i = match texture_of.get(&(src, w)) { + Some(&i) => i, + None => { + let tex = if img.wrap == w { + img + } else { + // A wrap variant copies the pixels, so it is charged to the import budget too; + // one that does not fit is left out like an undecodable image. + let cost = u64::from(img.width) * u64::from(img.height); + if cost > pixels_left { + warnings.push(format!( + "a texture with another wrap mode left out: the model's textures would hold more than {MAX_IMPORT_TEXTURE_PIXELS} pixels" + )); + tex_map.push(None); + continue; + } + pixels_left -= cost; + Arc::new(Texture { wrap: w, ..(*img).clone() }) + }; + textures.push(tex); + texture_of.insert((src, w), textures.len() - 1); + textures.len() - 1 + } + }; + tex_map.push(Some(i)); } let tref = |v: Option<&Value>| -> Option { let v = v?; @@ -536,7 +587,7 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option String { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 19a0d37f3..b95a9f487 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -46,7 +46,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 +64,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] }) } @@ -512,6 +538,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 +598,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 +651,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 +660,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..c9d364fc7 100644 --- a/crates/model/src/obj.rs +++ b/crates/model/src/obj.rs @@ -9,10 +9,10 @@ //! 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. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::{AlphaMode, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; +use crate::{AlphaMode, MAX_IMPORT_TEXTURE_PIXELS, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; /// 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 @@ -39,24 +39,41 @@ 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 { +/// The textures an OBJ import has decoded, by reference, and the pixels it may still decode. +pub struct TextureCache { + loaded: HashMap>, + pixels_left: u64, + /// Textures left out, for the user. + pub warnings: Vec, +} + +impl Default for TextureCache { + fn default() -> Self { + TextureCache { loaded: HashMap::new(), pixels_left: MAX_IMPORT_TEXTURE_PIXELS, warnings: vec![] } + } +} + +/// Parse MTL text into named materials (textures appended to `textures`, decoded once per +/// reference through `cache`). +pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>, cache: &mut TextureCache) -> Vec { let mut out: Vec = vec![]; - let mut loaded: HashMap> = HashMap::new(); + let TextureCache { loaded, pixels_left, warnings } = cache; 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"); + warnings.push(format!("texture `{key}` was not found next to the model")); return None; }; - match Texture::decode(&b) { + match Texture::decode_within(&b, pixels_left) { Ok(t) => { textures.push(Arc::new(t)); Some(textures.len() - 1) } Err(e) => { log::warn!("model: cannot decode texture `{key}`: {e}"); + warnings.push(format!("texture `{key}` left out: {e}")); None } } @@ -151,9 +168,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let mut vt: Vec<[f32; 2]> = vec![]; let mut vn: Vec<[f32; 3]> = vec![]; let mut materials: Vec = vec![]; + let mut material_of: HashMap = HashMap::new(); let mut textures: Vec> = vec![]; + let mut cache = TextureCache::default(); + let mut libraries: HashSet = HashSet::new(); // One primitive per material, in order of first use. let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; + let mut prim_of: HashMap, usize> = HashMap::new(); let mut current: Option = None; let mut smooth = 0u32; let mut flat_id = 1u32 << 31; @@ -181,11 +202,18 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "mtllib" => { let name = line["mtllib".len()..].trim(); + // A library named again is already loaded. + if !libraries.insert(name.to_string()) { + continue; + } 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); + let ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures, &mut cache); + for m in ms { + material_of.entry(m.name.clone()).or_insert(materials.len()); + materials.push(m); + } found = true; break; } @@ -196,12 +224,11 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "usemtl" => { let name = line["usemtl".len()..].trim(); - current = materials.iter().position(|m| m.name == name); - if current.is_none() { + current = Some(*material_of.entry(name.to_string()).or_insert_with(|| { // 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); - } + materials.len() - 1 + })); } "f" => { if rest.len() < 3 { @@ -234,13 +261,10 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } 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 slot = *prim_of.entry(current).or_insert_with(|| { + 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 { @@ -290,7 +314,9 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } mesh.primitives.push(p); } - Ok(Model::single(mesh, materials).with_textures(textures)) + let mut model = Model::single(mesh, materials).with_textures(textures); + model.warnings = cache.warnings; + Ok(model) } impl Model { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 5ee6a6950..fd3576afe 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -432,6 +432,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#"{ @@ -731,3 +783,102 @@ fn gltf_strip_expansion_is_charged_to_the_read_budget() { 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}"); } + +#[test] +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 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_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/README.md b/crates/pdf/README.md index eb960cc6e..a88adff0b 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -144,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` +`e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4`, 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/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 0ebc0421d..ba67b39aa 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -669,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); @@ -681,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"); @@ -784,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..8fb72698a 100644 --- a/crates/pdf/src/ps/eps.rs +++ b/crates/pdf/src/ps/eps.rs @@ -19,7 +19,8 @@ pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { return Some([v[0], v[1], v[2], v[3]]); } - from = s; + // A rejected comment is done with: the scan goes on past its line, not over it again. + from = e; } None }; @@ -31,8 +32,15 @@ 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 +56,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 +91,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 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 { @@ -1068,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..516ff054d 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,45 @@ 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(); + src.extend_from_slice("%%BoundingBox: x ".repeat(20_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/src/wasm.rs b/crates/plugin/src/wasm.rs index 499d57397..18f3fb0d0 100644 --- a/crates/plugin/src/wasm.rs +++ b/crates/plugin/src/wasm.rs @@ -13,6 +13,8 @@ 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; @@ -84,7 +86,10 @@ fn err(e: impl std::fmt::Display) -> String { /// Instantiate `module` in a fresh, limited store and read its memory and allocator. fn begin(engine: &Engine, module: &Module) -> Result { - let mut store = Store::new(engine, StoreLimitsBuilder::new().memory_size(MAX_MEMORY).instances(1).build()); + // 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); 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/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/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/lib.rs b/crates/project/src/lib.rs index 6d0a72976..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; 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/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/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/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/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/src/accel.rs b/crates/render/src/accel.rs index d1ab70ab9..21f609574 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -374,7 +374,7 @@ 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))); - let key = self.mask_gate_key(ctx, layer, key); + let key = self.mask_gate_key(ctx, layer, self.clipped_key(ctx, layer, key)); if let Some((b, _)) = self.cached(key) { return Some(b); } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 00342847d..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. @@ -157,7 +173,7 @@ impl MixEnv<'_> { let m = b - a; // 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(); + 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 { @@ -213,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); @@ -342,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; } @@ -375,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 7bc52606f..4f72e79fa 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -361,10 +361,11 @@ pub trait LayerStore: Send + Sync { /// 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. `since`: the + /// 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: &Buf, files: &EntryFiles, since: u64); + 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 { @@ -540,7 +541,7 @@ impl LayerStore for PrefetchStore { } /// Queues the layer for writing to the persistent store the first time `key` is seen. - fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, _since: u64) { + 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, files))); @@ -983,7 +984,7 @@ mod shared_insert_tests { assert!(store.get_layer(5, Some(&two)).is_none()); assert!(!store.has(5)); assert_eq!(store.take_misses(), [5]); - store.put_layer(5, &buf(), &EntryFiles::of(&files, Some(&two)).unwrap(), 0); + 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(); @@ -1065,7 +1066,7 @@ mod shared_insert_tests { 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: &Buf, files: &crate::disk_cache::EntryFiles, since: u64) { + 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); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index dc4216098..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; @@ -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 f509a5896..3674b2667 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -58,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 { @@ -264,18 +268,50 @@ 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 { /// Frame writes (taken first) and their payload bytes. 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, @@ -330,7 +366,21 @@ impl Queue { } 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() @@ -433,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 { @@ -462,6 +554,7 @@ impl DiskCache { 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(), @@ -650,6 +743,7 @@ impl DiskCache { #[cfg(target_arch = "wasm32")] { q.jobs.clear(); + q.job_bytes = 0; q.frames.clear(); q.pending_frames.clear(); } @@ -771,20 +865,20 @@ 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, None) + self.enqueue_format(kind, key, FORMAT_RGBA8, Payload::Encoded(payload), None) } /// [`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: Vec, made: Option<(&EntryFiles, u64)>) -> bool { + 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")] { if self.queue.0.lock().is_ok_and(|q| stale(&q)) { return false; } - self.write_format(kind, key, format, &payload); + self.write_format(kind, key, format, &payload.encode()); true } #[cfg(not(target_arch = "wasm32"))] @@ -794,11 +888,7 @@ impl DiskCache { if stale(&q) { 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 q.full(kind, payload.len()) { self.dropped.fetch_add(1, Ordering::Relaxed); return false; } @@ -807,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(); @@ -1033,7 +1124,7 @@ impl DiskCache { } self.count_encode(); let payload = with_files(&files, encode_frame_f16(width, height, rgba)); - self.enqueue_format(Kind::Frame, key, FORMAT_F16_FILES, payload, Some((&files, since))) + self.enqueue_format(Kind::Frame, key, FORMAT_F16_FILES, Payload::Encoded(payload), Some((&files, since))) } /// Write a half-float frame synchronously (tests); a list that can't be stored is not. @@ -1050,6 +1141,22 @@ impl DiskCache { /// 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; } @@ -1057,13 +1164,13 @@ impl DiskCache { if self.contains(Kind::Frame, key) { return true; } - // (no room on the disk: refused before the pixels are compressed) + // (no room on the disk: refused before the pixels are queued and compressed) if !self.accepts_writes() { return false; } self.count_encode(); - let payload = with_files(&files, encode_frame(width, height, rgba)); - self.enqueue_format(Kind::Frame, key, FORMAT_RGBA8_FILES, payload, Some((&files, since))) + 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 @@ -1078,17 +1185,22 @@ impl DiskCache { /// [`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. - pub fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64) { - // (no room on the disk: refused before the buffer is compressed) - if self.contains(Kind::Layer, key) || !self.accepts_writes() { + /// 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; } self.count_encode(); - let _ = self.enqueue_format(Kind::Layer, key, FORMAT_LAYER_FILES, with_files(files, encode_layer(buf)), Some((files, since))); + 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); 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))); } @@ -1097,8 +1209,12 @@ impl DiskCache { /// 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. @@ -1192,7 +1308,7 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let Some(cache) = dc.upgrade() else { return }; let written = match job { Job::Write(kind, key, format, payload) => { - cache.write_format(kind, key, format, &payload); + cache.write_format(kind, key, format, &payload.encode()); (kind == Kind::Frame).then_some(key) } Job::Drop(paths) => { @@ -1292,7 +1408,7 @@ impl crate::cache::LayerStore for DiskCache { DiskCache::get_layer(self, key, virtual_id).map(|(b, f)| (Arc::new(b), f)) } /// Queues a layer write on the disk cache. - fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64) { + 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. @@ -1337,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 { @@ -1374,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() { @@ -1420,9 +1555,9 @@ 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).checked_mul(h as usize)?; - if planes.len() != n.checked_mul(16)? { + let planes = decompress_checked(&p[32..], Some(n.checked_mul(16)?))?; + if planes.len() != n * 16 { return None; } let mut img = Image::new(w, h); @@ -1441,7 +1576,7 @@ 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) { @@ -1553,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); @@ -1706,7 +1917,7 @@ mod tests { let dc = DiskCache::open(&dir, 1 << 30).unwrap(); assert!(dc.get_layer(42, None).is_none()); let b = buf(1, 33, 17); - dc.put_layer(42, &b, &EntryFiles::default(), 0); + dc.put_layer(42, &Arc::new(b.clone()), &EntryFiles::default(), 0); dc.flush(); let (back, files) = dc.get_layer(42, None).unwrap(); assert!(files.is_empty() && files.is_complete()); @@ -1715,10 +1926,14 @@ mod tests { let rgba: Vec = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect(); 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, 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, None).map(|f| f.0).is_none()); } @@ -1773,6 +1988,36 @@ mod tests { 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] @@ -1780,7 +2025,7 @@ 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), &EntryFiles::default(), 0); + 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 { @@ -1950,7 +2195,7 @@ mod tests { 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, &buf(3, 16, 16), &EntryFiles::default(), 0); + 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); @@ -1960,7 +2205,7 @@ mod tests { 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, &buf(3, 16, 16), &EntryFiles::default(), 0); + 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); @@ -1971,7 +2216,7 @@ mod tests { 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, &buf(3, 16, 16), &EntryFiles::default(), 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); @@ -2076,11 +2321,11 @@ mod tests { 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, &buf(1, 4, 4), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + 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, &buf(1, 4, 4), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + 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. @@ -2324,7 +2569,7 @@ mod tests { 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, &buf(3, 2, 2), &EntryFiles::of(&read(&[&a]), None).unwrap(), 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)); @@ -2334,7 +2579,7 @@ mod tests { 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, &buf(6, 2, 2), &EntryFiles::of(&read(&[&a]), None).unwrap(), 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()); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 41e6132d3..a9abefb16 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -283,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 6b7fdcbf8..c14961d0f 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -37,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. @@ -215,6 +223,9 @@ impl FootageSource for CancellableFootage<'_> { 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, PDF, Illustrator and EPS files, and a diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 05b443763..f47443b18 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -26,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}; @@ -102,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 f856db82c..c0f4c8dd9 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -5,8 +5,9 @@ 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; @@ -55,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 { @@ -141,6 +165,7 @@ impl<'a> Renderer<'a> { top: true, through: [None; MAX_THROUGH], through_lost: false, + whole_raster: false, } } @@ -276,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 } } @@ -416,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, @@ -451,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)) { @@ -521,23 +608,24 @@ impl<'a> Renderer<'a> { 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) = scope(a)? { - if let Some((p, key)) = auto { - p.record(key, true, t0.elapsed().as_secs_f64() * 1e3); + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1e3); } 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 = cpu.comp_frame_cpu(comp_id, t); - if let Some((p, key)) = auto { - p.record(key, false, t0.elapsed().as_secs_f64() * 1e3); + if let Some(t) = ticket { + t.record(t0.elapsed().as_secs_f64() * 1e3); } return Ok(img); } @@ -692,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 @@ -815,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 }); } @@ -847,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. @@ -895,8 +999,12 @@ impl<'a> Renderer<'a> { return Some(buf); } } + // 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)) + Some(self.footage_buf(&img, pf, f.width, f.height, quantized && Self::prequantized_footage(layer))) } // (the font files go to this render's log, which `ctx` may not carry: see // [`Renderer::with_reads`]) @@ -910,9 +1018,12 @@ impl<'a> Renderer<'a> { { log.mark_unkeyed(); } - Some(self.authored(text::render(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer)))) + 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(ctx, layer, c, self.raster_scale(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, } } @@ -926,15 +1037,74 @@ 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 { - let mut buf = match self.footage_layout(img, w, h) { - FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale }, - FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale }, - }; - if let Some(c) = self.pipe.media_in(f) { - color::convert(&mut buf.img, &c); + /// + /// `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 } + } } - buf + } + + /// [`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 @@ -1093,13 +1263,14 @@ impl<'a> Renderer<'a> { /// 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) @@ -1149,7 +1320,13 @@ impl<'a> Renderer<'a> { /// (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 @@ -1189,7 +1366,7 @@ impl<'a> Renderer<'a> { 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); + 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; @@ -1211,8 +1388,13 @@ 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); + let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key)); if let Some((b, _)) = self.cached(key) { return Some(b); } @@ -1229,3 +1411,9 @@ impl<'a> Renderer<'a> { 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 index ed97e50fd..73b7ce830 100644 --- a/crates/render/src/reads.rs +++ b/crates/render/src/reads.rs @@ -65,6 +65,34 @@ impl ReadFile { } } +/// 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; 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 53e2a512c..2d0f90ac5 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -22,6 +22,7 @@ mod motion_tile; mod nested; mod paint; mod proxy_cache; +mod raster_clip; mod remap; mod roi; mod stretch_limits; @@ -2001,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 5cc503173..a53bb718a 100644 --- a/crates/render/src/tests/auto.rs +++ b/crates/render/src/tests/auto.rs @@ -229,6 +229,12 @@ fn healthy_gpu_decline_renders_cpu_once_outside_scope() { 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); @@ -238,11 +244,119 @@ fn healthy_gpu_decline_renders_cpu_once_outside_scope() { }) .unwrap(); assert!(frame.get(100, 50)[3] > 0.99); - assert_eq!(src.reads.load(Ordering::SeqCst), 1); + 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, 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), 2); + 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/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/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/text.rs b/crates/render/src/text.rs index ed369afa5..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. @@ -250,13 +279,31 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: & { log.mark_unkeyed(); } - let stat = ctx.v3(layer, sel, "amount", [100.0; 3]); + // 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)) @@ -282,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)]; @@ -509,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 { @@ -1122,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; @@ -1137,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 9b52d3098..bbc1cd28e 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -802,3 +802,12 @@ fn text_notes_the_font_files_it_draws_with() { 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/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 ff08eb05f..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) { 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..4b65a5298 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 @@ -292,6 +292,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 +338,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 +461,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 +653,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 +928,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 +1719,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 +1729,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 c23eaefb0..28e490158 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -427,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}"))?; @@ -530,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); @@ -769,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) { @@ -797,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 { @@ -880,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/tests.rs b/crates/script/src/tests.rs index 78a16e5d3..8995b8202 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -10,6 +10,7 @@ use serde_json::{Value, json}; use crate::{Outcome, run_code}; +mod ae_layer_order; mod callable; mod ease_presets; mod evalfile; @@ -700,6 +701,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] @@ -1949,3 +2000,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/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..e542bed47 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 @@ -16,3 +16,4 @@ 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 `