Merge remote-tracking branch 'upstream/main' into fix/3087-aep-effect-placeholders

# Conflicts:
#	docs/aep-import.md
This commit is contained in:
arnav-hiwarkar committed 2026-11-12 10:39:03 +05:30
commit 949fdb4b08
197 files changed
+5255 -17474

No files matched your search

+4
View File
@@ -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
+5
View File
@@ -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 }}
+6
View File
@@ -129,6 +129,12 @@ 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/object-matte-0.22.ecproj` | lisn0 | 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
Generated
+88 -89
View File
@@ -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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
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,7 +1448,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"chrono",
"cpal",
@@ -1448,7 +1476,7 @@ dependencies = [
[[package]]
name = "effectcraft-aep"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1462,7 +1490,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1474,16 +1502,9 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-av1enc"
version = "0.23.1"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1502,7 +1523,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"proptest",
"serde",
@@ -1510,7 +1531,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"chrono",
"effectcraft-color",
@@ -1528,7 +1549,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -1567,12 +1588,13 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.23.1"
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",
@@ -1580,25 +1602,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.23.1"
version = "0.24.1"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1615,7 +1634,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"proptest",
"serde",
@@ -1623,7 +1642,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1646,7 +1665,7 @@ dependencies = [
[[package]]
name = "effectcraft-helper-host"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"bytemuck",
"effectcraft-effects",
@@ -1655,16 +1674,9 @@ dependencies = [
"serde_json",
]
[[package]]
name = "effectcraft-hevcenc"
version = "0.23.1"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1691,7 +1703,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1710,7 +1722,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1722,7 +1734,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1740,9 +1752,11 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.23.1"
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",
@@ -1754,12 +1768,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",
@@ -1768,12 +1779,13 @@ dependencies = [
"log",
"rayon",
"serde_json",
"tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-model"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-geom",
"image",
@@ -1786,14 +1798,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"filmcraft-opus",
"craft-audio",
]
[[package]]
name = "effectcraft-path"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1805,7 +1817,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1821,7 +1833,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1833,7 +1845,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"craft-ecproj-format",
"effectcraft-color",
@@ -1847,14 +1859,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"miniz_oxide 0.9.1",
]
[[package]]
name = "effectcraft-raster"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1864,7 +1876,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1887,7 +1899,7 @@ dependencies = [
[[package]]
name = "effectcraft-sam2"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"bytemuck",
"candle-core",
@@ -1902,7 +1914,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1920,7 +1932,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"miniz_oxide 0.9.1",
"ndarray",
@@ -1929,19 +1941,20 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
"effectcraft-raster",
"kurbo",
"log",
"rayon",
"roxmltree",
]
[[package]]
name = "effectcraft-text"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1953,7 +1966,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"proptest",
"serde",
@@ -1961,7 +1974,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1973,7 +1986,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"bytemuck",
"chrono",
@@ -2012,13 +2025,9 @@ dependencies = [
"web-time",
]
[[package]]
name = "effectcraft-vp9enc"
version = "0.23.1"
[[package]]
name = "effectcraft-web"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -2483,7 +2492,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]]
@@ -2584,16 +2593,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"
@@ -2626,7 +2625,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]]
@@ -2634,7 +2633,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]]
@@ -3323,7 +3322,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4"
dependencies = [
"hayro-ccitt",
]
@@ -3337,7 +3336,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4#e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
@@ -7835,7 +7834,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.23.1"
version = "0.24.1"
dependencies = [
"chrono",
"pkg-config",
+22 -43
View File
@@ -4,19 +4,19 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.23.1"
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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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" }
+1 -1
View File
@@ -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,
+41 -1
View File
@@ -1142,11 +1142,37 @@ 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 {
let s = item.trim().to_ascii_lowercase();
let s = item.trim().to_lowercase();
s.strip_suffix(" channel").map(str::to_string).unwrap_or(s)
}
/// 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.
pub fn by_match_name(match_name: &str) -> Option<&'static EffectMap> {
EFFECTS.iter().find(|m| m.match_name == match_name)
@@ -1164,3 +1190,17 @@ pub const HUE_SATURATION_MASTER: [&str; 3] = ["hue", "saturation", "lightness"];
/// The EffectCraft parameters Levels' RGB channel values (`Extra::Levels`) go to, in the order of
/// the data (input black, input white, gamma, output black, output white: 512-516).
pub const LEVELS_RGB: [&str; 5] = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"];
#[cfg(test)]
mod tests {
use super::*;
/// 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("ÉCRAN"), popup_key("écran"));
assert_eq!(popup_key(" Désactivé "), "désactivé");
assert_eq!(popup_key("Off for 32 bpc Color"), "off for 32 bpc color");
assert_eq!(popup_key("Red Channel"), "red");
}
}
+9 -4
View File
@@ -35,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};
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,
@@ -422,8 +422,8 @@ 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 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<Vec<Option<u32>>> {
let ParamUi::Popup { options } = ui else { return None };
let norm = popup_key;
@@ -434,7 +434,12 @@ fn popup_mapping(file_items: &[String], ui: &ParamUi) -> Option<Vec<Option<u32>>
if item.trim() == "(-" {
return None;
}
options.iter().position(|o| norm(o) == norm(item)).and_then(|i| u32::try_from(i).ok())
let key = norm(item);
options
.iter()
.position(|o| norm(o) == key)
.or_else(|| popup_alias(&key).and_then(|a| options.iter().position(|o| norm(o) == a)))
.and_then(|i| u32::try_from(i).ok())
})
.collect(),
)
+12 -6
View File
@@ -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(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation expression (not decoded)"));
}
_ => col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation")),
}
}
+40 -5
View File
@@ -98,8 +98,12 @@ pub struct Loss {
pub category: LossCategory,
/// 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<String>,
/// How many of the `count` occurrences no line of `details` describes.
#[serde(default)]
pub unlisted: usize,
}
/// What arrived in the project.
@@ -288,7 +292,7 @@ const MAX_UNMAPPED_EFFECTS: usize = 100;
impl Collector {
/// Records one loss.
pub fn lose(&mut self, category: LossCategory, detail: impl Into<String>) {
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() });
let loss = self.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.into());
@@ -299,7 +303,8 @@ impl Collector {
/// made in [`Collector::finish`], one per effect with the number of instances, so that many
/// other losses cannot hide which effects were dropped.
pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: String) {
let loss = self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new() });
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);
@@ -310,12 +315,22 @@ impl Collector {
}
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) {
let named = std::mem::take(&mut self.unmapped_effects).into_values().map(|(first, n)| match n {
// 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, named);
loss.details.splice(0..0, lines);
}
ImportReport {
imported: self.imported,
@@ -362,6 +377,25 @@ mod tests {
// 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
@@ -376,5 +410,6 @@ mod tests {
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");
}
}
+44 -2
View File
@@ -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);
}
}
+98
View File
@@ -842,3 +842,101 @@ 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");
}
/// #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<u8> = [10.0f64, 20.0, 30.0].iter().flat_map(|d| d.to_be_bytes()).collect();
set_data(k, 0, &degrees);
}
}
}
});
let (p, r) = run(&bytes, true);
assert_eq!(losses(&r.unwrap(), LossCategory::TransformProperties), Vec::<String>::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<u8> {
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);
}
+197 -1
View File
@@ -75,6 +75,9 @@ const REFUSED: &[&str] = &[
"library.preview",
];
/// 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"]),
@@ -202,6 +205,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<Vec<Lookup>> {
@@ -221,6 +298,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.
@@ -258,6 +341,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"))),
}
@@ -321,11 +407,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(())
}
@@ -757,6 +847,112 @@ mod tests {
] {
assert!(c.check_command("effect.plugins.install", &params).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);
}
}
-15
View File
@@ -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 }
-76
View File
@@ -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.
-87
View File
@@ -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<u8>,
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<u8> {
self.buf
}
}
/// `leb128()` encoding of an OBU size.
pub(crate) fn leb128(mut v: usize, out: &mut Vec<u8>) {
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<u8>) {
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]);
}
}
-172
View File
@@ -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<Cdfs> {
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<const N: usize> ClearCount for [u16; N] {
fn clear_count(&mut self) {
self[N - 1] = 0;
}
}
impl<T: ClearCount, const M: usize> ClearCount for [T; M] {
fn clear_count(&mut self) {
for e in self.iter_mut() {
e.clear_count();
}
}
}
File diff suppressed because it is too large. Load diff
-670
View File
@@ -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<u8>,
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<u8>,
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<u8> {
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<u8>,
frame_num: u64,
refr: Option<RefFrame>,
rc: Option<RateState>,
}
impl Encoder {
pub fn new(cfg: EncoderConfig) -> Result<Encoder, Error> {
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<u8> {
self.seq_obu.clone()
}
/// AV1CodecConfigurationRecord (`av1C` payload, also the WebM CodecPrivate).
pub fn av1c(&self) -> Vec<u8> {
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<u8> {
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<u8>, 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<u8>, 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<u8>, 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<u32> {
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<u16>; 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
}))
}
}
-209
View File
@@ -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;
}
}
}
-445
View File
@@ -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<u16>,
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::<i32>() + above[..w].iter().sum::<i32>() + ((w + h) >> 1) as i32;
sum / (w + h) as i32
} else if e.have_left {
(left[..h].iter().sum::<i32>() + (h >> 1) as i32) >> log2
} else if e.have_above {
(above[..w].iter().sum::<i32>() + (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<u16> {
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<u16>; 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<u16> = 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]);
}
}
}
-267
View File
@@ -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<u8>,
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<u8> {
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<u8> = 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<bool> = (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");
}
}
}
}
File diff suppressed because it is too large. Load diff
-575
View File
@@ -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<const N: u32>(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<const N: u32>(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<const N: u32>(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<const LOG2: u32>(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<f32>; 4],
adst: [Vec<f32>; 3],
/// The same matrices transposed (`[i][k]`).
dct_t: [Vec<f32>; 4],
adst_t: [Vec<f32>; 3],
}
fn transpose(m: &[f32]) -> Vec<f32> {
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<Bases> = 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<const N: usize, const NN: usize>(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<i32> = (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<i32> = 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}");
}
}
}
}
File diff suppressed because it is too large. Load diff
-6
View File
@@ -1,6 +0,0 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod bench;
mod common;
mod ffmpeg_oracle;
mod roundtrip;
+30 -3
View File
@@ -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: &params, 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);
+2 -2
View File
@@ -98,14 +98,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 blur2::{CAMERA_LENS_MAX_RADIUS, camera_lens_plain, camera_lens_radius, camera_lens_spans, radial_fast_pad};
pub use generate::{four_color_softening, gen_mode};
pub use generate2::pattern_kind as cell_pattern_kind;
pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan};
pub use generate3::{
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.
+18
View File
@@ -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();
@@ -871,6 +878,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);
+8 -1
View File
@@ -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
+18 -1
View File
@@ -394,7 +394,7 @@ pub fn specs() -> Vec<EffectSpec> {
};
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]);
+64 -3
View File
@@ -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<f32> {
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 {
+36
View File
@@ -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 })
}
+55 -1
View File
@@ -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<Image>, scale: f64) -> Option<(Vec<f32>, Vec<u8>, 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(&params, [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(&params);
params.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
let out = render(&params);
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]);
+4 -2
View File
@@ -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)]
+72 -13
View File
@@ -562,13 +562,22 @@ struct SimEntry<S> {
/// 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<S: Clone> SimCache<S> {
pub const fn new(cap: usize) -> SimCache<S> {
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<S> {
self.run_where(key, target, init, step, |_, _| true)
}
@@ -586,6 +595,7 @@ impl<S: Clone> SimCache<S> {
valid: impl Fn(u64, &S) -> bool,
) -> std::sync::Arc<S> {
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;
@@ -604,11 +614,17 @@ impl<S: Clone> SimCache<S> {
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);
@@ -628,19 +644,23 @@ impl<S: Clone> SimCache<S> {
};
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<u64> = 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
}
@@ -818,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<u64> = 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<u64> = 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<u64> = 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<u64> = 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]];
+17 -1
View File
@@ -344,7 +344,9 @@ pub fn load_lut(src: &str) -> Option<Arc<Lut>> {
{
return v.clone();
}
let inline = src.contains('\n') || src.contains("LUT_");
// `.cube` text always spans lines (a size header, then data rows); a path never does, even one
// whose file name contains `LUT_` (#3454). Trimmed, as the path is, so a trailing newline is a path.
let inline = src.trim().contains('\n');
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());
@@ -484,6 +486,20 @@ 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();
}
#[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();
+13 -2
View File
@@ -219,7 +219,16 @@ fn fnv(s: &str) -> u64 {
/// 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);
input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid)
}
/// Recognize the input key serialized before 0.23 without re-keying its frozen matte.
fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option<String> {
let signature = crate::warp::signature_before_placed_page(p, comp, layer)?;
Some(input_key_with_signature(signature, layer, uid))
}
fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String {
if let Some(m) = layer.masks() {
s.push_str(&format!("{m:?}"));
}
@@ -247,7 +256,9 @@ 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.
if saved_key != key && input_key_before_placed_page(after, *cid, l, g.uid).as_deref() != Some(saved_key.as_str()) {
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)) {
+14
View File
@@ -119,6 +119,20 @@ pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
},
_ => String::new(),
};
signature_with_source(p, comp, layer, &src)
}
/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the
/// same Debug representation without that last field. Only ordinary footage can match it.
pub(crate) fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option<String> {
let LayerSource::Footage { item } = &layer.source else { return None };
let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None };
let src = format!("{footage:?}");
let old = src.strip_suffix(", placed_page: false }")?;
Some(signature_with_source(p, comp, layer, &format!("{old} }}")))
}
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}",
+14 -5
View File
@@ -395,6 +395,9 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
// 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<String, usize> = 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<Value> {
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).
+37 -10
View File
@@ -9,6 +9,8 @@
//! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `<layer name>.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>) -> 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<String>, next: &mut HashMap<String, usize>) -> 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<CommandSpec> {
),
]
}
#[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<String> = ["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);
}
}
+25 -6
View File
@@ -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<Value> {
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<String, std::result::Result<Footage, String>> = 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<Value> {
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();
+17 -1
View File
@@ -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<i64, String> {
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(';');
+91 -10
View File
@@ -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<String>, 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<String> {
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<String, HashMap<RunKey, Vec<String>>>) -> Vec<String> {
let Some(key) = run_key(path, alphabetical) else { return vec![] };
let mut files: Vec<String> = 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<RunKey, Vec<String>> = 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<Str
/// and (with the Sequence option, or [`SequenceOptions::folders`] for a folder's files) numbered
/// stills of one run one [`Source::Sequence`].
pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -> Vec<Source> {
// 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<String, HashMap<RunKey, Vec<String>>> = 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<String> =
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<RunKey, Vec<String>> = 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<String> = 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<String>)> = 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<String>)> = vec![];
let mut run_of: HashMap<RunKey, usize> = HashMap::new();
let mut out: Vec<Option<Source>> = vec![];
let mut slot: Vec<usize> = 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<Vec<u8>> {
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<String> {
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<String> = 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<Vec<u8>> {
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<String> {
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]
+6 -3
View File
@@ -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<u8>)], dir: &str) -> Result<Vec<String>> {
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/<item>/<file>` → 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<u8>)
// 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}")))?;
+105 -19
View File
@@ -477,35 +477,51 @@ pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
})
.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<Node>) -> 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<Node>)> = 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<Option<effectcraft_svg::Group>> = 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 +671,73 @@ pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec<Sh
}
l
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_svg::{Geom, Group, Image, Shape};
fn shape(name: &str) -> 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>) -> Node {
let mut g = Group::new(name);
g.children = children;
Node::Group(g)
}
fn names(g: &Group, out: &mut Vec<String>) {
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<Vec<String>> = segs
.iter()
.map(|d| {
let mut v = vec![];
names(&d.root, &mut v);
v
})
.collect();
let want: Vec<Vec<&str>> =
vec![vec!["a"], vec!["<g1", "b", "g1>"], vec!["<g1", "c", "<g2", "d", "g2>", "g1>"], vec!["<g1", "<g2", "e", "g2>", "g1>"], vec![], vec!["f"]];
assert_eq!(got, want);
assert!(segs.iter().all(|d| d.root.name == "root" && d.width == 10.0));
}
}
+16
View File
@@ -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)));
}
+107 -4
View File
@@ -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<f64> = 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<usize> = (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::<f64>()
};
let rect = |x: f64, y: f64, w: f64, h: f64| PathSeg::R { x, y, w, h };
let rects: Vec<PathSeg> = (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<PathSeg> = (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<PathSeg> = (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));
}
}
+12 -5
View File
@@ -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]);
}
}
+16 -1
View File
@@ -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(),
+25 -1
View File
@@ -125,7 +125,13 @@ fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
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}")
}
@@ -523,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"));
}
}
@@ -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"]);
}
@@ -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
}
@@ -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.
+29
View File
@@ -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.
+20
View File
@@ -788,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.
+25
View File
@@ -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() {
+55
View File
@@ -383,3 +383,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<Vec<f64>>);
impl crate::render::FootageSource for Vector {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
panic!("the full-size frame was decoded");
}
fn vector_frame(&self, _: ItemId, f: &Footage, scale: f64) -> Option<Arc<Image>> {
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<Arc<Image>> {
None
}
fn vector_frame(&self, _: ItemId, _: &Footage, _: f64) -> Option<Arc<Image>> {
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);
}
+7 -9
View File
@@ -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 }
+26 -26
View File
@@ -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<H264> {
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<filmcraft_h264enc::Packet>) -> Vec<Packet> {
fn packets(&self, ps: Vec<craft_video::h264::Packet>) -> Vec<Packet> {
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<Vec<Packet>> {
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<Vec<Packet>> {
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<filmcraft_aac::Encoder>),
Aac(Box<craft_audio::aac::Encoder>),
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))
+17 -17
View File
@@ -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<Vec<Packet>> {
@@ -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<Vec<Packet>> {
@@ -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));
}
}
+9 -4
View File
@@ -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<usize>,
/// 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<u64> + Sync)>,
}
/// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor,
+129 -2
View File
@@ -137,6 +137,17 @@ pub(crate) fn cpu_inflight(var: Option<&str>) -> Option<usize> {
}
}
/// 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<u64>, free: Option<u64>, threads: usize) -> Option<usize> {
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<Option<(usize, String)>>,
/// 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<Option<Option<usize>>>,
/// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs).
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
/// 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<usize> {
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<usize> {
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<u64>| 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.
@@ -1285,6 +1343,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);
+1 -1
View File
@@ -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};
+6 -6
View File
@@ -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));
@@ -266,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
})
},
@@ -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<Vec<Pic>> {
@@ -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;
@@ -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");
@@ -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<u32> {
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");
@@ -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;
@@ -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<Pic> = (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");
@@ -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!(
+2
View File
@@ -1,10 +1,12 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.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;
+5 -1
View File
@@ -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]
+2 -2
View File
@@ -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<ColorSpace>, [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");
+5 -5
View File
@@ -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<u32> {
/// 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<u8> = (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());
+4 -3
View File
@@ -20,9 +20,9 @@ fn encode(pcm: &[f32], channels: u8, bitrate: u32) -> (OpusEncoder, Vec<Vec<u8>>
(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<u8>], len: usize) -> Vec<Vec<f32>> {
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<f32>> = 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"])
+2 -2
View File
@@ -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());
+2 -2
View File
@@ -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 {
+4 -4
View File
@@ -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();
+3 -3
View File
@@ -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<Vec<u8>> {
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.
+6 -6
View File
@@ -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<u8> {
@@ -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<u8>], recon: &[[Vec<u8>; 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")
+1 -1
View File
@@ -133,7 +133,7 @@ fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
}
fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
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);
}
+2
View File
@@ -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.
-15
View File
@@ -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 }
-88
View File
@@ -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.
-135
View File
@@ -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<u8>,
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<u8> {
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<u8> {
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]);
}
}
-220
View File
@@ -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);
}
}
-148
View File
@@ -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<u16>; 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;
}
}
File diff suppressed because it is too large. Load diff
-157
View File
@@ -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;
}
}
}
-254
View File
@@ -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::<u32>() + r.left[1..=n].iter().map(|&v| v as u32).sum::<u32>();
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);
}
}
-365
View File
@@ -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<u8>,
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<u8>,
/// 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<f64>; 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<u8>; 3],
frames: u64,
/// POC of the previous picture (relative to the last IDR).
poc: u32,
reference: Option<[Vec<u16>; 3]>,
recon: Option<[Vec<u16>; 3]>,
rc: Option<RateCtl>,
}
impl Encoder {
pub fn new(cfg: EncoderConfig) -> Result<Encoder, Error> {
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<u8>; 3] {
self.sets.clone()
}
/// HEVCDecoderConfigurationRecord (`hvcC` payload) with the parameter sets.
pub fn hvcc(&self) -> Vec<u8> {
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<u16>; 3]> {
self.recon.as_ref()
}
/// Copy the input into coded-size planes, replicating the right / bottom edges.
fn pad_input(&self, f: &Frame) -> [Vec<u16>; 3] {
let max = (1u32 << self.seq.bit_depth) - 1;
let mut out: [Vec<u16>; 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<u16> { (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 }
}
}
-144
View File
@@ -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<u16>; 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<u16> {
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<u16> {
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::<u32>();
}
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;
}
}
}
-265
View File
@@ -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<u32>) -> 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<u8> {
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<u8> {
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<u8> {
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<u8>; 3]) -> Vec<u8> {
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);
}
}
-236
View File
@@ -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<const L: usize>(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<const L: usize>(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];
-328
View File
@@ -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<i32> = (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}");
}
}
}
}
+7 -1
View File
@@ -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)]
@@ -65,6 +65,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 +88,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<u64> {
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 {
+1
View File
@@ -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);
+2 -2
View File
@@ -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()),
+93 -12
View File
@@ -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<ItemId>) -> Option<fp::BinId> {
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<ItemId> = 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<VideoSp
fn media_clip(media: fp::MediaRef, info: MediaInfo) -> 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);
}
}
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more