diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4e08c7c42..354fbd268 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,14 @@ concurrency:
cancel-in-progress: false
jobs:
+ # Regenerates contributors/contributors.json for the release commit; every build bundles it.
+ credits:
+ permissions:
+ contents: read
+ pull-requests: read
+ uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
+ needs: credits
permissions:
contents: write
packages: write
@@ -25,6 +32,7 @@ jobs:
app-name: effectcraft
app-title: EffectCraft
version: ${{ inputs.version }}
+ contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 93b34b9bf..5bff5cdb0 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -64,7 +64,7 @@ and licence texts are in [`NOTICE`](NOTICE).
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
| `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 |
-One small After Effects test project of `crates/aep` is in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
+Two small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
## Screenshots
@@ -99,7 +99,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
(sidecars `.attribution`).
- Bundled extension (ScriptUI panel) with its twelve built-in ease presets:
`extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`).
-- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
+- Example effect plug-in: `examples/plugins/posterize-bands` (WebAssembly ABI 1).
## Trained models (optional downloads, not bundled)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 29521ad73..8ce4b85aa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,7 +26,7 @@ that variable set.
```sh
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo test --workspace
-cargo xtask ci # what every commit must pass
+cargo xtask ci # what every commit must pass (dev-profile checks and tests)
```
The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces
diff --git a/Cargo.lock b/Cargo.lock
index 5b426c8f8..37233ef58 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
[[package]]
name = "craft-ecproj-format"
@@ -1107,12 +1107,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
[[package]]
name = "craft-i18n"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"hayro-syntax",
"thiserror 2.0.21",
@@ -1152,7 +1152,7 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"dispatch2",
"egui",
@@ -1163,7 +1163,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1184,7 +1184,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -1420,8 +1420,9 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
+ "chrono",
"cpal",
"craft-update",
"effectcraft-engine",
@@ -1439,6 +1440,7 @@ dependencies = [
"rfd",
"serde",
"serde_json",
+ "sysinfo",
"winit",
"winresource",
"zbus",
@@ -1446,7 +1448,7 @@ dependencies = [
[[package]]
name = "effectcraft-aep"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1460,7 +1462,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1468,19 +1470,20 @@ dependencies = [
"effectcraft-script",
"image",
"serde_json",
+ "tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-av1enc"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1499,7 +1502,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"proptest",
"serde",
@@ -1507,7 +1510,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"chrono",
"effectcraft-color",
@@ -1525,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -1564,7 +1567,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1595,7 +1598,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1612,7 +1615,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"proptest",
"serde",
@@ -1620,7 +1623,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1643,7 +1646,7 @@ dependencies = [
[[package]]
name = "effectcraft-helper-host"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"bytemuck",
"effectcraft-effects",
@@ -1654,14 +1657,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1688,7 +1691,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1707,7 +1710,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1719,7 +1722,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1737,10 +1740,11 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"craft-docpath",
"effectcraft-color",
+ "effectcraft-effects",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
@@ -1769,7 +1773,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-geom",
"image",
@@ -1782,14 +1786,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1801,7 +1805,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1817,7 +1821,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1829,7 +1833,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"craft-ecproj-format",
"effectcraft-color",
@@ -1843,14 +1847,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"miniz_oxide 0.9.1",
]
[[package]]
name = "effectcraft-raster"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1860,7 +1864,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1877,12 +1881,13 @@ dependencies = [
"lz4_flex",
"rayon",
"serde",
+ "tempfile",
"web-time",
]
[[package]]
name = "effectcraft-sam2"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"bytemuck",
"candle-core",
@@ -1897,7 +1902,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1915,7 +1920,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"miniz_oxide 0.9.1",
"ndarray",
@@ -1924,7 +1929,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1936,7 +1941,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1948,7 +1953,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"proptest",
"serde",
@@ -1956,7 +1961,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1968,7 +1973,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"bytemuck",
"chrono",
@@ -2009,11 +2014,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
-version = "0.21.0"
+version = "0.23.1"
[[package]]
name = "effectcraft-web"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -3065,7 +3070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.5",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -3318,7 +3323,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"hayro-ccitt",
]
@@ -3332,7 +3337,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
@@ -3665,7 +3670,7 @@ dependencies = [
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -3811,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -4117,6 +4122,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -4431,6 +4445,16 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -4661,7 +4685,7 @@ dependencies = [
"libc",
"redox_syscall 0.5.18",
"smallvec",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -6068,6 +6092,20 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "sysinfo"
+version = "0.37.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows 0.61.3",
+]
+
[[package]]
name = "tag_ptr"
version = "0.1.0"
@@ -7221,16 +7259,38 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections 0.2.0",
+ "windows-core 0.61.2",
+ "windows-future 0.2.1",
+ "windows-link 0.1.3",
+ "windows-numerics 0.2.0",
+]
+
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
- "windows-collections",
+ "windows-collections 0.3.2",
"windows-core 0.62.2",
- "windows-future",
- "windows-numerics",
+ "windows-future 0.3.2",
+ "windows-numerics 0.3.1",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
]
[[package]]
@@ -7252,6 +7312,19 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -7260,9 +7333,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
- "windows-link",
+ "windows-link 0.2.1",
"windows-result 0.4.1",
- "windows-strings",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading 0.1.0",
]
[[package]]
@@ -7272,8 +7356,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
- "windows-link",
- "windows-threading",
+ "windows-link 0.2.1",
+ "windows-threading 0.2.1",
]
[[package]]
@@ -7298,12 +7382,28 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -7311,7 +7411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7320,9 +7420,9 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
"windows-result 0.4.1",
- "windows-strings",
+ "windows-strings 0.5.1",
]
[[package]]
@@ -7334,13 +7434,31 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
]
[[package]]
@@ -7349,7 +7467,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7385,7 +7503,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7419,13 +7537,22 @@ dependencies = [
"windows_x86_64_msvc 0.52.6",
]
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7708,7 +7835,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.21.0"
+version = "0.23.1"
dependencies = [
"chrono",
"pkg-config",
@@ -7956,7 +8083,6 @@ dependencies = [
"nix",
"typed-path 0.13.0",
"windows-sys 0.61.2",
- "zopfli",
]
[[package]]
@@ -7971,18 +8097,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-[[package]]
-name = "zopfli"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
-dependencies = [
- "bumpalo",
- "crc32fast",
- "log",
- "simd-adler32",
-]
-
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -8047,18 +8161,3 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
-
-[[patch.unused]]
-name = "egui_demo_lib"
-version = "0.37.0"
-source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
-
-[[patch.unused]]
-name = "egui_inspection"
-version = "0.37.0"
-source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
-
-[[patch.unused]]
-name = "egui_system_font_provider"
-version = "0.37.0"
-source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index abb2256c2..6f96cb012 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,19 +4,19 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
-version = "0.21.0"
+version = "0.23.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/effectcraft"
[workspace.dependencies]
-craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
-craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
-craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+craft-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-update: app-specific pretend versions apply to startup and manual checks.
-craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
@@ -117,7 +117,7 @@ half = "2.7"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
# Shared UI and the complete egui family select reviewed immutable commits below.
-craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
+craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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"] }
@@ -242,8 +242,8 @@ lto = "thin"
codegen-units = 1
[patch.crates-io]
-hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" }
-hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" }
+hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", version = "=0.7.2" }
+hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", version = "=0.3.0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
@@ -254,8 +254,7 @@ egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
-egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
-egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
-egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+# Patch a crate only together with the dependency that needs it: an unused patch makes the Nix
+# build, which uses vendored sources, fail (`cargo xtask lock-patches`).
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
diff --git a/README.md b/README.md
index 81b5a0ba5..913f8aa06 100644
--- a/README.md
+++ b/README.md
@@ -129,18 +129,14 @@ or into the Timeline, between layers and at the time you point to.
308 built-in effects across its categories, each with its parameter names, order and
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
-color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
+color-grading panel…), distort (Warp, Bulge, Turbulent Displace, Perspective Pin…), generate (Fractal Noise,
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
-**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
-stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
+**simulation** (Particle Emitter 3D, Rainfall, Shatter, Card Dance, Caustics, Wave World…),
+stylize (Glow, Glass Relief…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
**audio** (Reverb, Parametric EQ, Delay, Stereo Mixer…), text, transitions, utility and expression
controls. All nine **Layer Styles** (Drop Shadow, Inner/Outer Glow, Bevel and Emboss, Satin,
overlays, Stroke) with Global Light. Preview plays audio in sync, with meters and waveforms.
-
-
-
-
## 3D
Classic 3D compositing: 3D layers with orientation and material options,
diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs
index 2981519e9..f52401a00 100644
--- a/apps/effectcraft-cli/src/main.rs
+++ b/apps/effectcraft-cli/src/main.rs
@@ -31,12 +31,13 @@
//! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes
//! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame)
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
+//! effectcraft-cli replay SCRIPT.jsonl [--keep-going] replay a repro script (docs/agents.md)
//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
//! Saving: --save (back to --project) | --save-as F.ecproj
-//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists;
-//! the default is the CPU (Mercury Software Only)
+//! GPU: --gpu renders on the GPU compositor (GPU Acceleration) when an adapter exists;
+//! the default is the CPU (Software Rendering)
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
//! ```
@@ -97,7 +98,7 @@ fn stdout_broken() -> bool {
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
}
-const USAGE: &str = "usage: effectcraft-cli [args] [--json]
+const USAGE: &str = "usage: effectcraft-cli [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec [--params JSON] run one engine command (exec --list: list them, as `commands`)
@@ -138,6 +139,9 @@ const USAGE: &str = "usage: effectcraft-cli Result {
}
/// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's
-/// renderer setting, Mercury GPU Acceleration by default).
+/// renderer setting, GPU Acceleration by default).
fn session(args: &Args) -> Result {
let mut s = effectcraft_host::session();
if args.flag("--autosave") {
@@ -552,6 +557,59 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
Ok(())
}
+/// `replay SCRIPT.jsonl`: run a repro script against a headless session (its start line picks
+/// the project) and report every step. Exits 1 when the replay fails.
+fn replay_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
+ use effectcraft_engine::replay;
+ let Some(file) = args.pos.first() else { return usage_err("replay needs a .jsonl script") };
+ if args.opt("--bridge").is_some() || args.project.is_some() || args.flag("--demo") || args.flag("--empty") {
+ return usage_err("replay runs headless from the script's start line ({\"start\": \"new\" | \"demo\" | {\"project\": path}})");
+ }
+ use std::io::Read;
+ let path = std::path::Path::new(file);
+ let input = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?;
+ // Bound the read itself: metadata can be stale or describe a pipe rather than its contents.
+ let mut script = String::new();
+ input.take(replay::MAX_SCRIPT_BYTES as u64 + 1).read_to_string(&mut script).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?;
+ if script.len() > replay::MAX_SCRIPT_BYTES {
+ return Err(Failure::Error(format!("{file} is larger than {} MB", replay::MAX_SCRIPT_BYTES >> 20)));
+ }
+ let mut s = session(args)?;
+ let opts = replay::Options { keep_going: args.flag("--keep-going"), base_dir: path.parent().map(std::path::Path::to_path_buf) };
+ let report = replay::run(&mut s, &script, &opts);
+ if json_out {
+ emit(&serde_json::to_value(&report).unwrap_or_else(|e| json!({"error": e.to_string()})), true);
+ } else {
+ for st in &report.steps {
+ let what = st.command.as_deref().unwrap_or(st.kind);
+ match (&st.error, st.ok) {
+ (None, _) => say!("line {:>4} ok {what}", st.line),
+ (Some(e), true) => say!("line {:>4} ok {what} (tolerated error: {e})", st.line),
+ (Some(e), false) => say!("line {:>4} FAIL {what}: {e}", st.line),
+ }
+ }
+ }
+ let Some(first) = &report.first_failure else {
+ if !json_out {
+ say!("replay ok: {} steps", report.steps.len());
+ }
+ return Ok(());
+ };
+ let msg = format!(
+ "failed at line {} ({}){}: {}{}",
+ first.line,
+ first.command.as_deref().unwrap_or(first.kind),
+ if report.panicked { ", a command panicked" } else { "" },
+ first.error.as_deref().unwrap_or(""),
+ if report.skipped > 0 { format!(" ({} later steps not run)", report.skipped) } else { String::new() }
+ );
+ if json_out {
+ note!("effectcraft-cli replay: {msg}");
+ std::process::exit(1);
+ }
+ Err(Failure::Error(msg))
+}
+
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
@@ -614,6 +672,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
"commands" => list_commands_cmd(args, json_out)?,
"exec" if args.flag("--list") => list_commands_cmd(args, json_out)?,
"script" => script_cmd(args, json_out)?,
+ "replay" => replay_cmd(args, json_out)?,
"exec" => {
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
@@ -1443,7 +1502,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option- Result<(), Failure> {
use effectcraft_render::Backend;
diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs
index 363eb8e52..4510f16c9 100644
--- a/apps/effectcraft-cli/tests/it/cli.rs
+++ b/apps/effectcraft-cli/tests/it/cli.rs
@@ -1223,3 +1223,39 @@ fn render_exr_compression_and_depth() {
assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}");
let _ = std::fs::remove_dir_all(&root);
}
+
+#[test]
+fn replay_reports_steps_and_the_first_failure() {
+ let repro = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros/comp-zero-frame-rate.jsonl");
+ let v = ok_json(&["replay", repro.to_str().unwrap()]);
+ assert_eq!(v["ok"], true, "{v}");
+ assert!(v["steps"].as_array().unwrap().len() > 5);
+ let bad = tmp("bad-repro.jsonl");
+ std::fs::write(
+ &bad,
+ "{\"start\": \"new\"}\n{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\nnot json\n",
+ )
+ .unwrap();
+ let (code, v) = run_json(&["replay", bad.to_str().unwrap()]);
+ assert_eq!(code, 1);
+ assert_eq!(v["ok"], false);
+ assert_eq!(v["first_failure"]["line"], 3, "{v}");
+ assert_eq!(v["skipped"], 1);
+ let out = bin().args(["replay", bad.to_str().unwrap(), "--keep-going"]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ let text = String::from_utf8_lossy(&out.stdout);
+ assert!(text.contains("line 2 ok comp.new") && text.contains("line 4 FAIL invalid: not JSON"), "{text}");
+ assert!(String::from_utf8_lossy(&out.stderr).contains("failed at line 3 (comp.info)"));
+ // The start line picks the project.
+ assert_eq!(bin().args(["replay", bad.to_str().unwrap(), "--demo"]).output().unwrap().status.code(), Some(2));
+}
+
+/// Text editors can prefix an exported JSON-lines repro with a UTF-8 BOM.
+#[test]
+fn replay_accepts_a_utf8_bom() {
+ let path = tmp("bom-repro.jsonl");
+ std::fs::write(&path, "\u{feff}{\"command\":\"comp.new\"}\n").unwrap();
+ let (code, report) = run_json(&["replay", path.to_str().unwrap()]);
+ assert_eq!(code, 0, "{report}");
+ assert_eq!(report["ok"], true);
+}
diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs
index 2882b16cf..99e71117f 100644
--- a/apps/effectcraft-web/src/app.rs
+++ b/apps/effectcraft-web/src/app.rs
@@ -329,7 +329,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
if n > 0 {
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
}
- app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path)));
+ app.frames.set_virtual_id(Arc::new(|path| files::STORE.file_identity(path)));
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
diff --git a/apps/effectcraft-web/src/diskcache.rs b/apps/effectcraft-web/src/diskcache.rs
index ef9d46f8c..72e9a9e11 100644
--- a/apps/effectcraft-web/src/diskcache.rs
+++ b/apps/effectcraft-web/src/diskcache.rs
@@ -4,23 +4,28 @@
//!
//! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key
//! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes
-//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access
-//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports
-//! `FrameReply::Stored`.
+//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4,
+//! checksum) with a synchronous access handle into a temporary file moved into place
+//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't
+//! all be named is not stored.
//! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache):
//! rebuilt at startup from the folder listing (oldest files first), updated by the workers'
//! `Stored` replies and by reads; eviction deletes the least recently used files.
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
-//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
+//! of rendering it (`crate::frames`), if the files its entry lists still have the size and
+//! content hash it was made of (the file table's identities); a damaged or missing entry, or
+//! one whose files changed, is a miss and is deleted.
//!
//! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a
//! synchronous render, where the browser's file system can't be read, so the frame workers
//! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache
//! misses; before the next request the page names the recent misses that are on disk
//! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries
-//! into memory before rendering, so the lookups find them. Slow buffers are written by the
-//! worker after its frame and indexed here like frames (one least-recently-used order for
-//! both kinds).
+//! into memory before rendering, so the lookups find them; an entry is used only while the
+//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose
+//! files can't all be named is not written (#3184). Slow buffers are written by the worker
+//! after its frame and indexed here like frames (one least-recently-used order for both
+//! kinds).
use std::cell::RefCell;
@@ -187,11 +192,12 @@ pub fn note_miss() {
}
/// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or
-/// damaged.
+/// damaged, or the source files it was made of changed since.
pub async fn read(key: u128) -> Option
{
let name = disk_cache::entry_name(key);
let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::().ok()).map(|b| b.to_vec());
- let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry);
+ let identity = |path: &str| crate::files::STORE.file_identity(path);
+ let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity)));
ST.with(|s| {
let mut s = s.borrow_mut();
if frame.is_some() {
diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs
index 69089e308..0159fe23b 100644
--- a/apps/effectcraft-web/src/frames.rs
+++ b/apps/effectcraft-web/src/frames.rs
@@ -473,10 +473,13 @@ pub async fn worker_frame(json: String) {
g.settled().await;
replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default());
}
+ // The source files the frame read, kept with its entry (checked by the page before use); a
+ // frame whose files can't all be named is not stored (#3184).
+ let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default());
for (r, px) in replies {
post_reply(&r, px.as_deref());
- if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) {
- let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px);
+ if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) {
+ let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue };
let name = format!("{key}.ecc");
match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await {
Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None),
diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs
index 0cc877948..a4947600b 100644
--- a/apps/effectcraft-web/src/store.rs
+++ b/apps/effectcraft-web/src/store.rs
@@ -20,7 +20,6 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_engine::config::{ConfigStore, FileOps};
@@ -187,13 +186,12 @@ pub struct Store {
pub on_change: OnceLock,
/// Clock in milliseconds since the Unix epoch (tests pin it).
pub clock: OnceLock f64>,
- /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
- media_epoch: Arc,
}
impl Default for Store {
+ /// An empty store with no mirror contents, change callback or clock.
fn default() -> Self {
- Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
+ Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() }
}
}
@@ -218,22 +216,12 @@ impl Store {
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
let now = self.now();
let key = file_key(path);
- let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data));
self.lock().put(&key, data, now, persist);
- if replaced {
- self.media_epoch.fetch_add(1, Ordering::Relaxed);
- }
if persist {
self.changed();
}
}
- /// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes
- /// a different blob, and it is not a content hash.
- pub fn media_epoch(&self) -> Arc {
- self.media_epoch.clone()
- }
-
/// Size and content hash of the file under `path`. The hash is stored on the entry so a
/// later key does not walk the bytes again.
pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
@@ -541,11 +529,9 @@ mod tests {
assert_ne!(a, b);
assert_eq!(&data[..], b"blue");
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
- let epoch = s.media_epoch().load(Ordering::Relaxed);
let id = s.file_identity("/collision.png").expect("identity");
assert_eq!(id, (4, content_hash(b"blue")));
assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
- assert!(epoch >= 2, "each new blob bumps the generation the preview keys on");
}
/// A write that failed is flushed again with the key's latest state (#215).
diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml
index 3030ee96d..21bd46755 100644
--- a/apps/effectcraft/Cargo.toml
+++ b/apps/effectcraft/Cargo.toml
@@ -25,6 +25,9 @@ rfd = { workspace = true }
pollster = { workspace = true }
cpal = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
+# MIT; safe process handles and target-only memory observation for the crash helper.
+sysinfo = { version = "=0.37.2", default-features = false, features = ["system"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Native GNOME/Wayland controls without a bundled title font.
diff --git a/apps/effectcraft/src/crash_observation.rs b/apps/effectcraft/src/crash_observation.rs
new file mode 100644
index 000000000..b67dc07fe
--- /dev/null
+++ b/apps/effectcraft/src/crash_observation.rs
@@ -0,0 +1,264 @@
+//! Bounded observations made outside the failed GUI. Measurements are evidence, not a cause.
+use std::io::{self, Write};
+use std::sync::mpsc::{self, Receiver};
+use std::time::{Duration, Instant, SystemTime};
+
+use sysinfo::{MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, System};
+
+pub fn utc() -> String {
+ chrono::DateTime::::from(SystemTime::now()).to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
+}
+
+#[derive(Default)]
+struct Memory {
+ samples: u64,
+ resident_peak: u64,
+ virtual_peak: u64,
+ available_min: Option,
+ total: u64,
+ last_at: Option,
+ last_uptime_secs: Option,
+}
+
+impl Memory {
+ fn sample(&mut self, resident: u64, virtual_bytes: u64, available: u64, total: u64) {
+ self.samples = self.samples.saturating_add(1);
+ self.resident_peak = self.resident_peak.max(resident);
+ self.virtual_peak = self.virtual_peak.max(virtual_bytes);
+ // Zero total means the platform could not supply physical-memory information.
+ if total != 0 {
+ self.available_min = Some(self.available_min.map_or(available, |old| old.min(available)));
+ self.total = total;
+ }
+ self.last_at = Some(utc());
+ }
+}
+
+pub struct Observation {
+ pid: Pid,
+ system: System,
+ birth: Option,
+ started: Instant,
+ started_at: String,
+ detected_at: Option,
+ last_sample: Instant,
+ memory: Memory,
+ // Only Windows can retrieve an unrelated process's ExitStatus through a retained handle.
+ exit: Receiver>,
+ status: Option,
+ alive: Option,
+}
+
+fn target_system(pid: Pid) -> System {
+ let mut system = System::new();
+ system.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), false, ProcessRefreshKind::nothing().with_memory());
+ system
+}
+
+impl Observation {
+ /// Acquire the Windows handle before acknowledging reporter readiness, so even an immediate
+ /// GUI abort leaves an observable process object. Never enumerate commands or environments.
+ pub fn start(pid: u32) -> Self {
+ let pid = Pid::from_u32(pid);
+ let system = target_system(pid);
+ let birth = system.process(pid).map(|p| p.start_time());
+ let (send, exit) = mpsc::sync_channel(1);
+ #[cfg(windows)]
+ {
+ let waiting = target_system(pid);
+ let _ = std::thread::Builder::new().name("ec-exit-status".into()).spawn(move || {
+ let status = waiting.process(pid).and_then(|p| p.wait());
+ let _ = send.send(status);
+ });
+ }
+ #[cfg(not(windows))]
+ drop(send);
+ let now = Instant::now();
+ let mut result = Self {
+ pid,
+ system,
+ birth,
+ started: now,
+ started_at: utc(),
+ detected_at: None,
+ last_sample: now,
+ memory: Memory::default(),
+ exit,
+ status: None,
+ alive: None,
+ };
+ result.sample();
+ result
+ }
+
+ fn sample(&mut self) {
+ self.system.refresh_processes_specifics(ProcessesToUpdate::Some(&[self.pid]), false, ProcessRefreshKind::nothing().with_memory());
+ self.system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
+ let process = self.system.process(self.pid).filter(|p| Some(p.start_time()) == self.birth && p.exists());
+ self.alive = self.birth.map(|_| process.is_some());
+ if let Some(process) = process {
+ self.memory.last_uptime_secs = Some(process.run_time());
+ self.memory.sample(process.memory(), process.virtual_memory(), self.system.available_memory(), self.system.total_memory());
+ }
+ if let Ok(status) = self.exit.try_recv() {
+ self.status = status;
+ }
+ self.last_sample = Instant::now();
+ }
+
+ pub fn tick(&mut self) {
+ if self.last_sample.elapsed() >= Duration::from_secs(1) {
+ self.sample();
+ }
+ }
+
+ /// Pipe closure can precede the OS signalling process termination. Bound that grace period;
+ /// a broken pipe or early reporter drop must never be called a confirmed process exit.
+ pub fn finish(&mut self) {
+ self.detected_at = Some(utc());
+ if self.status.is_none()
+ && let Ok(status) = self.exit.recv_timeout(Duration::from_millis(750))
+ {
+ self.status = status;
+ }
+ self.sample();
+ }
+
+ pub fn confirmed_exit(&self) -> bool {
+ self.status.is_some()
+ }
+
+ pub fn write(&self, report: &mut impl Write) -> io::Result<()> {
+ writeln!(
+ report,
+ "Detection UTC: {}\nObserver started UTC: {}\nObserved duration: {} ms",
+ self.detected_at.as_deref().unwrap_or("not captured"),
+ self.started_at,
+ self.started.elapsed().as_millis()
+ )?;
+ writeln!(
+ report,
+ "Process observation: {}",
+ if self.confirmed_exit() {
+ "process termination observed"
+ } else if self.alive == Some(true) {
+ "GUI still present when the control channel ended"
+ } else {
+ "target absent from refresh or process identity/status unavailable (not proof of termination)"
+ }
+ )?;
+ match &self.status {
+ Some(status) => {
+ writeln!(report, "OS exit status: {status}")?;
+ #[cfg(windows)]
+ if let Some(code) = status.code() {
+ write_windows_status(report, code as u32)?;
+ }
+ }
+ None => writeln!(report, "OS exit status: unavailable (not inferred from the control pipe)")?,
+ }
+ let memory = &self.memory;
+ writeln!(report, "\nMemory evidence (1 s sampling; sampled maxima, not OS peaks):\nTarget-present sampling attempts: {}", memory.samples)?;
+ if let Some(uptime) = memory.last_uptime_secs {
+ writeln!(report, "GUI uptime at last target-present observation: {uptime} s")?;
+ }
+ #[cfg(windows)]
+ let virtual_label = "Peak sampled private committed bytes";
+ #[cfg(not(windows))]
+ let virtual_label = "Peak sampled virtual bytes";
+ if memory.samples != 0 {
+ writeln!(
+ report,
+ "Last target sampling attempt UTC: {}\nPeak sampled resident bytes: {}\n{virtual_label}: {}",
+ memory.last_at.as_deref().unwrap_or("unavailable"),
+ memory.resident_peak,
+ memory.virtual_peak
+ )?;
+ }
+ if let Some(available) = memory.available_min {
+ writeln!(report, "Minimum sampled system available RAM bytes: {available}\nSystem total RAM bytes at last sample: {}", memory.total)?;
+ }
+ writeln!(
+ report,
+ "Samples can miss short spikes or retain stale/zero readings when an OS query fails; per-field success is not exposed by sysinfo. Windows private commit is not GPU VRAM; on other platforms virtual memory is not physical RAM. System commit limit/usage and GPU memory budgets were not collected. No memory-pressure threshold establishes the cause of this incident.\nThe memory observer does not collect process command lines, environments, documents, native dumps or system event logs. The existing session-log tail below may contain paths or other logged details; review it before sharing."
+ )
+ }
+}
+
+#[cfg(any(windows, test))]
+fn write_windows_status(report: &mut impl Write, code: u32) -> io::Result<()> {
+ let meaning = match code {
+ 0xc0000005 => "STATUS_ACCESS_VIOLATION",
+ 0xc0000017 => "STATUS_NO_MEMORY",
+ 0xc000012d => "STATUS_COMMITMENT_LIMIT",
+ 0xc0000374 => "STATUS_HEAP_CORRUPTION",
+ 0xc0000409 => "STATUS_STACK_BUFFER_OVERRUN / fast fail",
+ 0x40000015 => "STATUS_FATAL_APP_EXIT",
+ _ => "unclassified",
+ };
+ writeln!(
+ report,
+ "Windows status bits: 0x{code:08X} ({meaning})\nThese are process exit bits, not a verified exception record. Software can set an exit code explicitly; an access violation does not identify the faulting module or prove a driver crash."
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn missing_target_without_exit_status_does_not_confirm_termination() {
+ let (send, exit) = mpsc::sync_channel(1);
+ drop(send);
+ let now = Instant::now();
+ let observation = Observation {
+ pid: Pid::from_u32(1),
+ system: System::new(),
+ birth: Some(1),
+ started: now,
+ started_at: utc(),
+ detected_at: Some(utc()),
+ last_sample: now,
+ memory: Memory::default(),
+ exit,
+ status: None,
+ alive: Some(false),
+ };
+ assert!(!observation.confirmed_exit());
+ let mut report = Vec::new();
+ observation.write(&mut report).unwrap();
+ let report = String::from_utf8(report).unwrap();
+ assert!(report.contains("not proof of termination"));
+ assert!(!report.contains("process termination observed"));
+ }
+
+ #[test]
+ fn memory_high_water_marks_are_bounded_and_not_a_causal_diagnosis() {
+ let mut memory = Memory::default();
+ memory.sample(40, 80, 30, 100);
+ memory.sample(20, 90, 50, 100);
+ assert_eq!((memory.samples, memory.resident_peak, memory.virtual_peak, memory.available_min), (2, 40, 90, Some(30)));
+ memory.sample(0, 0, 0, 0);
+ assert_eq!(memory.available_min, Some(30));
+ }
+
+ #[test]
+ fn windows_status_bits_preserve_unsigned_codes_without_claiming_a_driver_crash() {
+ for (code, name) in
+ [(0xc0000005, "STATUS_ACCESS_VIOLATION"), (0xc0000017, "STATUS_NO_MEMORY"), (0xc000012d, "STATUS_COMMITMENT_LIMIT"), (42, "unclassified")]
+ {
+ let mut text = Vec::new();
+ write_windows_status(&mut text, code).unwrap();
+ let text = String::from_utf8(text).unwrap();
+ assert!(text.contains(name));
+ assert!(text.contains(&format!("0x{code:08X}")));
+ assert!(text.contains("not a verified exception record"));
+ }
+ }
+
+ #[test]
+ fn utc_timestamps_are_parseable_and_zero_offset() {
+ let time = chrono::DateTime::parse_from_rfc3339(&utc()).unwrap();
+ assert_eq!(time.offset().local_minus_utc(), 0);
+ }
+}
diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs
new file mode 100644
index 000000000..6ca42aab7
--- /dev/null
+++ b/apps/effectcraft/src/crash_report.rs
@@ -0,0 +1,797 @@
+//! A small, separate process reports an unexpected desktop exit (#3357).
+//!
+//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a
+//! helper which never loads a project, font database or GPU. Only an explicit marker after
+//! normal teardown suppresses the report. EOF alone identifies a lost control channel, not
+//! a process exit or its cause. System-wide exhaustion can still prevent the helper or its native dialog working.
+
+use std::collections::VecDeque;
+use std::ffi::{OsStr, OsString};
+use std::fs::{File, OpenOptions};
+use std::io::{self, Read, Seek, SeekFrom, Write};
+use std::path::{Path, PathBuf};
+use std::process::{Child, ChildStdin, Command, Stdio};
+use std::sync::mpsc::{self, RecvTimeoutError};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+#[path = "crash_observation.rs"]
+mod observation;
+
+const FLAG: &str = "--internal-crash-reporter";
+const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n";
+const LOG_BYTES: usize = 64 * 1024;
+const HEADER_BYTES: usize = 128;
+const READY: &[u8] = b"READY\n";
+const EVENT_BYTES: usize = 128;
+const EVENT_COUNT: usize = 32;
+
+/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes
+/// the pipe and reports the incident; there is intentionally no clean-exit Drop hook.
+pub struct Reporter {
+ pipe: ChildStdin,
+ child: Child,
+}
+
+impl Reporter {
+ /// Call only after application teardown, including handled startup/device-loss errors.
+ pub fn finish(mut self) -> io::Result<()> {
+ let sent = self.pipe.write_all(CLEAN).and_then(|()| self.pipe.flush());
+ drop(self.pipe);
+ // Do not let a suspended/broken helper hang shutdown. Reap it before the GUI
+ // exits where possible, so Windows updates do not race an old executable.
+ let cleanup = finish_child(&mut self.child, Duration::from_secs(2));
+ match (sent, cleanup) {
+ (Err(sent), Err(cleanup)) => Err(io::Error::other(format!("clean marker failed: {sent}; cleanup failed: {cleanup}"))),
+ (Err(error), _) | (_, Err(error)) => Err(error),
+ (Ok(()), Ok(())) => Ok(()),
+ }
+ }
+}
+
+fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> {
+ let start = Instant::now();
+ while start.elapsed() < grace {
+ if let Some(status) = child.try_wait()? {
+ return if status.success() { Ok(()) } else { Err(io::Error::other(format!("crash reporter exited with {status}"))) };
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ child.kill()?;
+ // Even after termination, do not introduce an unbounded wait on a stuck OS process.
+ let start = Instant::now();
+ while start.elapsed() < Duration::from_secs(1) {
+ if child.try_wait()?.is_some() {
+ return Err(io::Error::other("crash reporter did not finish promptly and was terminated"));
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ Err(io::Error::other("crash reporter termination could not be confirmed"))
+}
+
+/// Shared ownership lets macOS finish in on_exit, while main retains the startup-error path.
+#[derive(Clone, Default)]
+pub struct Shutdown(std::sync::Arc>>);
+
+impl Shutdown {
+ /// Install the one reporter created for this desktop launch.
+ pub fn install(&self, reporter: Reporter) -> io::Result<()> {
+ let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?;
+ *slot = Some(reporter);
+ Ok(())
+ }
+
+ /// Keep only fixed-vocabulary, bounded lifecycle facts outside the GUI. Callers emit each
+ /// category once; their combined payload fits well below the smallest OS pipe buffer.
+ pub fn record(&self, event: &'static str) -> io::Result<()> {
+ if event.len() > 100 || event.contains(['\n', '\r']) {
+ return Err(io::Error::other("invalid diagnostic observation"));
+ }
+ let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?;
+ if let Some(reporter) = slot.as_mut() {
+ writeln!(reporter.pipe, "EVENT {event}")?;
+ reporter.pipe.flush()?;
+ }
+ Ok(())
+ }
+
+ /// Consume the reporter exactly once, after the applicable platform teardown boundary.
+ pub fn finish(&self) -> io::Result<()> {
+ let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take();
+ reporter.map_or(Ok(()), Reporter::finish)
+ }
+}
+
+/// A panic during teardown must leave the reporter armed. macOS terminates after this hook
+/// without returning to main; other platforms pass None and finish after run_native returns.
+pub fn after_teardown(reporter: Option<&Shutdown>, teardown: impl FnOnce()) -> io::Result<()> {
+ teardown();
+ reporter.map_or(Ok(()), Shutdown::finish)
+}
+
+/// Each process/run has its own report, including simultaneous launches.
+pub fn run_id() -> String {
+ let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
+ format!("{}-{nanos}", std::process::id())
+}
+
+pub fn session_marker(id: &str) -> String {
+ format!("crash-report session {id}")
+}
+
+/// Start before heavy work. A missing helper is a warning, never a reason to refuse the app.
+pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> io::Result {
+ let exe = std::env::current_exe()?;
+ let args = [
+ OsString::from(FLAG),
+ OsString::from(id),
+ log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()),
+ reports.as_os_str().to_owned(),
+ OsString::from(if interactive { "1" } else { "0" }),
+ OsString::from(std::process::id().to_string()),
+ ];
+ spawn(&exe, &args)
+}
+
+fn spawn(exe: &Path, args: &[OsString]) -> io::Result {
+ let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?;
+ if let Err(error) = ready(&mut child) {
+ let _ = child.kill();
+ let _ = finish_child(&mut child, Duration::from_millis(100));
+ return Err(error);
+ }
+ match child.stdin.take() {
+ Some(pipe) => Ok(Reporter { pipe, child }),
+ None => {
+ let _ = child.kill();
+ let _ = child.wait();
+ Err(io::Error::other("crash reporter has no control pipe"))
+ }
+ }
+}
+
+/// A fixed acknowledgement means the helper holds the process observation before heavy GUI
+/// work begins. A suspended helper cannot hold startup indefinitely.
+fn ready(child: &mut Child) -> io::Result<()> {
+ let mut output = child.stdout.take().ok_or_else(|| io::Error::other("crash reporter has no readiness pipe"))?;
+ let (send, receive) = mpsc::sync_channel(1);
+ std::thread::Builder::new().name("ec-reporter-ready".into()).spawn(move || {
+ let mut bytes = [0; READY.len()];
+ #[cfg(not(test))]
+ let result =
+ output.read_exact(&mut bytes).and_then(|()| if bytes == READY { Ok(()) } else { Err(io::Error::other("invalid reporter acknowledgement")) });
+ // libtest prints its bounded banner before invoking the helper role.
+ #[cfg(test)]
+ let result = (|| {
+ for _ in 0..1024 {
+ bytes.rotate_left(1);
+ output.read_exact(&mut bytes[READY.len() - 1..])?;
+ if bytes == READY {
+ return Ok(());
+ }
+ }
+ Err(io::Error::other("reporter acknowledgement missing"))
+ })();
+ let _ = send.send(result);
+ // Keep the pipe open for libtest's trailing result (and future helper output), without
+ // storing it. Dropping it at READY would turn a clean helper exit into BrokenPipe.
+ let _ = io::copy(&mut output, &mut io::sink());
+ })?;
+ receive.recv_timeout(Duration::from_secs(3)).map_err(|_| io::Error::other("crash reporter readiness timed out"))?
+}
+
+/// Handle the internal mode before logging, argument parsing, updates or GUI initialization.
+/// The callback is a native dialog on this helper's main thread, not on the failed GUI's.
+pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool {
+ if args.first().map(OsString::as_os_str) != Some(OsStr::new(FLAG)) {
+ return false;
+ }
+ let parsed = match args {
+ [_, id, log, reports, interactive, pid] => id
+ .to_str()
+ .filter(|id| valid_id(id))
+ .zip(match interactive.to_str() {
+ Some("0") => Some(false),
+ Some("1") => Some(true),
+ _ => None,
+ })
+ .zip(pid.to_str().and_then(|p| p.parse::().ok()).filter(|pid| *pid != 0))
+ .map(|((id, interactive), pid)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive, pid)),
+ _ => None,
+ };
+ let Some((id, log, reports, interactive, pid)) = parsed else {
+ eprintln!("EffectCraft crash reporter: invalid internal arguments");
+ return true;
+ };
+ // Keep the exact file open while the GUI is alive: a later rotation must not substitute
+ // another instance's log. If rotation won the startup race, the session marker rejects it.
+ let mut log = SessionLog::open(log, id);
+ let mut observation = observation::Observation::start(pid);
+ // A GUI that dies during helper startup can already have closed this read end. Keep
+ // watching its control pipe and save the report rather than losing that early failure.
+ let _ = io::stdout().write_all(READY).and_then(|()| io::stdout().flush());
+ // The reader has one bounded queue; the monitor never waits on a live pipe, so it can
+ // sample the target even when no log or lifecycle message is emitted.
+ let (send, receive) = mpsc::sync_channel(1);
+ let reader = std::thread::Builder::new().name("ec-reporter-pipe".into()).spawn(move || {
+ let mut input = io::stdin().lock();
+ loop {
+ let message = read_message(&mut input);
+ let terminal = !matches!(message, Ok(Message::Event(_)));
+ if send.send(message).is_err() || terminal {
+ break;
+ }
+ }
+ });
+ let mut events = VecDeque::with_capacity(EVENT_COUNT);
+ let failure = if reader.is_err() {
+ Some("control reader could not start".to_owned())
+ } else {
+ loop {
+ observation.tick();
+ match receive.recv_timeout(Duration::from_millis(250)) {
+ Ok(Ok(Message::Clean)) => return true,
+ Ok(Ok(Message::Event(event))) => {
+ if events.len() == EVENT_COUNT {
+ events.pop_front();
+ }
+ events.push_back(format!("{} {event}", observation::utc()));
+ }
+ Ok(Ok(Message::Ended)) => break None,
+ Ok(Err(error)) => break Some(error.to_string()),
+ Err(RecvTimeoutError::Timeout) => continue,
+ Err(RecvTimeoutError::Disconnected) => break Some("control reader disconnected".to_owned()),
+ }
+ }
+ };
+ observation.finish();
+ let saved = write_report(reports, id, &mut log, failure.as_deref(), &observation, &events);
+ let description = description(&saved, observation.confirmed_exit());
+ eprintln!("{description}");
+ if interactive {
+ show(&description);
+ }
+ true
+}
+
+fn valid_id(id: &str) -> bool {
+ !id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-')
+}
+
+enum Message {
+ Clean,
+ Event(String),
+ Ended,
+}
+
+/// Every line and the retained event ring are bounded, including malformed control input.
+fn read_message(input: &mut impl Read) -> io::Result {
+ let mut line = [0u8; EVENT_BYTES];
+ for i in 0..line.len() {
+ let byte = line.get_mut(i).ok_or_else(|| io::Error::other("invalid control offset"))?;
+ loop {
+ match input.read(std::slice::from_mut(byte)) {
+ Ok(0) => return Ok(Message::Ended),
+ Ok(_) => break,
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
+ Err(error) => return Err(error),
+ }
+ }
+ if *byte == b'\n' {
+ let text = line.get(..=i).ok_or_else(|| io::Error::other("invalid control line"))?;
+ if text == CLEAN {
+ return Ok(Message::Clean);
+ }
+ if let Some(event) = text.strip_prefix(b"EVENT ") {
+ let event = std::str::from_utf8(event).map_err(|_| io::Error::other("non-UTF8 diagnostic observation"))?;
+ return Ok(Message::Event(event.trim_end().to_owned()));
+ }
+ return Ok(Message::Ended);
+ }
+ }
+ Err(io::Error::other("control line exceeded its fixed limit"))
+}
+
+#[cfg(test)]
+fn clean_exit(input: &mut impl Read) -> io::Result {
+ Ok(matches!(read_message(input)?, Message::Clean))
+}
+
+enum SessionLog {
+ Captured { file: File, marker: String },
+ Unavailable(String),
+}
+
+impl SessionLog {
+ fn open(path: Option<&Path>, id: &str) -> Self {
+ let opened = (|| {
+ let path = path.ok_or_else(|| io::Error::other("no session log was opened"))?;
+ let mut file = File::open(path)?;
+ let mut header = [0u8; HEADER_BYTES];
+ let marker = format!("{}\n", session_marker(id));
+ let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?;
+ file.read_exact(prefix)?;
+ if prefix != marker.as_bytes() {
+ return Err(io::Error::other("matching session marker unavailable; the log may have rotated"));
+ }
+ Ok(file)
+ })();
+ match opened {
+ Ok(file) => Self::Captured { file, marker: format!("{}\n", session_marker(id)) },
+ Err(e) => Self::Unavailable(e.to_string()),
+ }
+ }
+
+ fn write_tail(&mut self, report: &mut File) -> io::Result<()> {
+ match self {
+ Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"),
+ Self::Captured { file, marker } => {
+ // An older concurrent launcher may have truncated the captured file.
+ // Recheck its identity before copying any content into this report.
+ file.seek(SeekFrom::Start(0))?;
+ let mut header = [0u8; HEADER_BYTES];
+ let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?;
+ if file.read_exact(prefix).is_err() || prefix != marker.as_bytes() {
+ return writeln!(report, "Recent log unavailable: session identity changed");
+ }
+ let size = file.metadata()?.len();
+ file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?;
+ writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?;
+ io::copy(&mut file.take(LOG_BYTES as u64), report)?;
+ Ok(())
+ }
+ }
+ }
+}
+
+fn write_report(
+ reports: &Path,
+ id: &str,
+ log: &mut SessionLog,
+ pipe_error: Option<&str>,
+ observation: &observation::Observation,
+ events: &VecDeque,
+) -> io::Result {
+ if !valid_id(id) {
+ return Err(io::Error::other("invalid report identity"));
+ }
+ std::fs::create_dir_all(reports)?;
+ let path = reports.join(format!("crash-{id}.txt"));
+ let mut report = OpenOptions::new().write(true).create_new(true).open(&path)?;
+ writeln!(
+ report,
+ "EffectCraft unexpected-exit diagnostic\nVersion: {}\nPlatform: {} / {}\nSession: {id}",
+ env!("CARGO_PKG_VERSION"),
+ std::env::consts::OS,
+ std::env::consts::ARCH
+ )?;
+ writeln!(
+ report,
+ "The GUI control channel ended without its clean-exit marker.\nThe exact cause is not established. GPU fallback, memory pressure and process exit are separate observations, not a proven causal chain.\nThis report was saved locally; it was not sent anywhere."
+ )?;
+ observation.write(&mut report)?;
+ writeln!(report, "\nRetained lifecycle observations (last {EVENT_COUNT}; UTC is helper receipt time):")?;
+ for event in events {
+ writeln!(report, "{event}")?;
+ }
+ if let Some(error) = pipe_error {
+ writeln!(report, "Control pipe error: {error}")?;
+ }
+ if let Err(error) = log.write_tail(&mut report) {
+ writeln!(report, "\nCould not read the recent session log: {error}")?;
+ }
+ report.flush()?;
+ Ok(path)
+}
+
+fn description(saved: &io::Result, confirmed_exit: bool) -> String {
+ let diagnostic = match saved {
+ Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()),
+ Err(error) => format!("A diagnostic report could not be saved: {error}"),
+ };
+ let incident =
+ if confirmed_exit { "EffectCraft stopped unexpectedly." } else { "EffectCraft lost its diagnostic control channel; process exit is not confirmed." };
+ format!(
+ "{incident}\n\n{diagnostic}\n\nThe exact cause is unknown. If EffectCraft has closed, reopen it to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere."
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::thread;
+ use std::time::{Duration, Instant};
+
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
+ const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE";
+
+ fn test_report(dir: &Path, id: &str, log: &mut SessionLog) -> io::Result {
+ write_report(dir, id, log, None, &observation::Observation::start(std::process::id()), &VecDeque::new())
+ }
+
+ fn folder() -> PathBuf {
+ let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed)));
+ std::fs::create_dir_all(&p).unwrap();
+ p
+ }
+
+ fn test_name(name: &str) -> String {
+ // Also usable from an isolated harness with the reporter dependencies.
+ let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest);
+ format!("{prefix}::{name}")
+ }
+
+ fn helper(dir: &Path, id: &str) -> Reporter {
+ helper_mode(dir, id, false)
+ }
+
+ fn helper_mode(dir: &Path, id: &str, interactive: bool) -> Reporter {
+ let mut child = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("reporter_process"), "--ignored", "--nocapture"])
+ .env(ROLE, "reporter")
+ .env("EC_REPORT_DIR", dir)
+ .env("EC_REPORT_ID", id)
+ .env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" })
+ .env("EC_REPORT_PID", std::process::id().to_string())
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ .unwrap();
+ ready(&mut child).unwrap();
+ Reporter { pipe: child.stdin.take().unwrap(), child }
+ }
+
+ fn wait_file(path: &Path) {
+ let until = Instant::now() + Duration::from_secs(10);
+ while !path.is_file() && Instant::now() < until {
+ thread::sleep(Duration::from_millis(20));
+ }
+ assert!(path.is_file(), "report did not arrive: {}", path.display());
+ }
+
+ #[test]
+ fn bounded_protocol_rejects_eof_and_partial_or_wrong_markers() {
+ let mut clean = CLEAN;
+ assert!(clean_exit(&mut clean).unwrap());
+ for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] {
+ assert!(!clean_exit(&mut input).unwrap());
+ }
+ assert!(clean_exit(&mut &vec![b'x'; 100_000][..]).is_err());
+ }
+
+ #[test]
+ fn report_is_bounded_and_keeps_its_session_after_log_rotation() {
+ let dir = folder();
+ let original = dir.join("effectcraft.log");
+ let id = "123-456";
+ std::fs::write(&original, format!("{}\n", session_marker(id))).unwrap();
+ let mut log = SessionLog::open(Some(&original), id);
+ std::fs::rename(&original, dir.join("effectcraft.1.log")).unwrap();
+ std::fs::write(&original, "OTHER SESSION MUST NOT APPEAR").unwrap();
+ let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap();
+ old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap();
+ old.write_all(b"\nGPU context retired\n").unwrap();
+ let report = test_report(&dir, id, &mut log).unwrap();
+ let text = std::fs::read_to_string(report).unwrap();
+ assert!(text.len() < LOG_BYTES + 4096);
+ assert!(text.contains("GPU context retired"));
+ assert!(!text.contains("OTHER SESSION"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn wrong_session_and_missing_logs_are_reported_without_reading_other_content() {
+ let dir = folder();
+ let path = dir.join("log");
+ std::fs::write(&path, "private other session").unwrap();
+ for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] {
+ let report = test_report(&dir, id, &mut SessionLog::open(source, id)).unwrap();
+ let text = std::fs::read_to_string(report).unwrap();
+ assert!(text.contains("Recent log unavailable"));
+ assert!(!text.contains("private other session"));
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn captured_log_rejects_a_session_overwritten_after_open() {
+ let dir = folder();
+ let path = dir.join("log");
+ std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap();
+ let mut log = SessionLog::open(Some(&path), "1-2");
+ std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap();
+ let report = test_report(&dir, "1-2", &mut log).unwrap();
+ let text = std::fs::read_to_string(report).unwrap();
+ assert!(text.contains("Recent log unavailable"));
+ assert!(!text.contains("private other session"));
+ assert!(!text.contains("session 3-4"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn session_identity_requires_the_exact_first_line() {
+ let dir = folder();
+ let path = dir.join("log");
+ for header in ["crash-report session 1-20\n", "unrelated\ncrash-report session 1-2\n"] {
+ std::fs::write(&path, header).unwrap();
+ assert!(matches!(SessionLog::open(Some(&path), "1-2"), SessionLog::Unavailable(_)));
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn report_write_failure_is_described_and_existing_report_is_not_overwritten() {
+ let dir = folder();
+ let mut log = SessionLog::Unavailable("no log".into());
+ let first = test_report(&dir, "2-1", &mut log).unwrap();
+ let before = std::fs::read(&first).unwrap();
+ let result = test_report(&dir, "2-1", &mut log);
+ assert!(result.is_err());
+ assert!(description(&result, false).contains("could not be saved"));
+ assert_eq!(std::fs::read(first).unwrap(), before);
+ assert!(test_report(&dir, "../escape", &mut log).is_err());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn normal_and_handled_shutdown_send_clean_marker_without_a_report() {
+ for id in ["3-1", "3-2"] {
+ let dir = folder();
+ let r = helper(&dir, id);
+ r.finish().unwrap();
+ // A completion file proves the helper consumed the marker, not merely that it
+ // hadn't yet produced a report.
+ wait_file(&dir.join(format!("ready-{id}")));
+ assert!(!dir.join(format!("crash-{id}.txt")).exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+ }
+
+ #[test]
+ fn accepted_native_close_finishes_before_process_termination() {
+ let dir = folder();
+ let status = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
+ .env(ROLE, "native-close")
+ .env("EC_REPORT_DIR", &dir)
+ .stdout(Stdio::null())
+ .status()
+ .unwrap();
+ assert!(status.success());
+ wait_file(&dir.join("ready-8-1"));
+ assert!(!dir.join("crash-8-1.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn a_teardown_panic_does_not_mark_the_reporter_clean() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "8-2")).unwrap();
+ let result = std::panic::catch_unwind(|| {
+ let _ = after_teardown(Some(&reporter), || panic!("simulated teardown failure"));
+ });
+ assert!(result.is_err());
+ drop(reporter);
+ wait_file(&dir.join("ready-8-2"));
+ assert!(dir.join("crash-8-2.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn platforms_returning_to_main_keep_the_reporter_armed_until_then() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "8-3")).unwrap();
+ after_teardown(None, || {}).unwrap();
+ assert!(reporter.0.lock().unwrap().is_some());
+ reporter.clone().finish().unwrap();
+ reporter.finish().unwrap();
+ wait_file(&dir.join("ready-8-3"));
+ assert!(!dir.join("crash-8-3.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn an_aborting_parent_leaves_one_diagnostic_report() {
+ let dir = folder();
+ let status = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
+ .env(ROLE, "abort")
+ .env("EC_REPORT_DIR", &dir)
+ .stdout(Stdio::null())
+ .status()
+ .unwrap();
+ assert!(!status.success());
+ wait_file(&dir.join("ready-4-1"));
+ let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap();
+ assert!(text.contains("exact cause is not established"));
+ assert!(text.contains("Detection UTC:"));
+ #[cfg(windows)]
+ assert!(text.contains("Windows status bits:"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn an_unrelated_child_does_not_keep_the_control_pipe_open() {
+ let dir = folder();
+ let r = helper(&dir, "5-1");
+ let mut unrelated = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"])
+ .env(ROLE, "linger")
+ .stdout(Stdio::null())
+ .spawn()
+ .unwrap();
+ drop(r);
+ wait_file(&dir.join("ready-5-1"));
+ assert!(unrelated.try_wait().unwrap().is_none());
+ unrelated.kill().unwrap();
+ unrelated.wait().unwrap();
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn an_unresponsive_helper_cannot_hang_clean_shutdown() {
+ let mut child = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"])
+ .env(ROLE, "linger")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::null())
+ .spawn()
+ .unwrap();
+ let pipe = child.stdin.take().unwrap();
+ let started = Instant::now();
+ let result = Reporter { pipe, child }.finish();
+ assert!(result.unwrap_err().to_string().contains("was terminated"));
+ assert!(started.elapsed() < Duration::from_secs(5));
+ }
+
+ #[test]
+ fn simultaneous_sessions_write_separate_reports() {
+ let dir = folder();
+ let a = helper(&dir, "6-1");
+ let b = helper(&dir, "6-2");
+ drop(a);
+ drop(b);
+ wait_file(&dir.join("ready-6-1"));
+ wait_file(&dir.join("ready-6-2"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn interactive_failure_calls_dialog_once_after_saving_the_report() {
+ let dir = folder();
+ drop(helper_mode(&dir, "7-1", true));
+ wait_file(&dir.join("ready-7-1"));
+ let text = std::fs::read_to_string(dir.join("dialog-7-1")).unwrap();
+ assert!(text.contains("crash-7-1.txt"));
+ assert!(text.contains("No report has been sent"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn lost_pipe_keeps_bounded_lifecycle_evidence_without_claiming_process_exit() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "9-1")).unwrap();
+ for _ in 0..40 {
+ reporter.record("GPU observation: wgpu validation error").unwrap();
+ }
+ drop(reporter);
+ wait_file(&dir.join("ready-9-1"));
+ let text = std::fs::read_to_string(dir.join("crash-9-1.txt")).unwrap();
+ assert!(text.contains("GUI still present"));
+ assert!(!text.contains("process termination observed"));
+ assert_eq!(text.matches("GPU observation: wgpu validation error").count(), EVENT_COUNT);
+ assert!(text.contains("Target-present sampling attempts:"));
+ assert!(text.len() < 8192);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn clean_exit_after_lifecycle_events_does_not_write_a_report() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "9-2")).unwrap();
+ reporter.record("Backend selection source: default").unwrap();
+ reporter.finish().unwrap();
+ wait_file(&dir.join("ready-9-2"));
+ assert!(!dir.join("crash-9-2.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ #[cfg(windows)]
+ fn windows_report_preserves_real_unsigned_exit_bits() {
+ let dir = folder();
+ let status = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
+ .env(ROLE, "windows-status")
+ .env("EC_REPORT_DIR", &dir)
+ .stdout(Stdio::null())
+ .status()
+ .unwrap();
+ assert_eq!(status.code().map(|code| code as u32), Some(0xc0000017));
+ wait_file(&dir.join("ready-9-3"));
+ let text = std::fs::read_to_string(dir.join("crash-9-3.txt")).unwrap();
+ assert!(text.contains("Windows status bits: 0xC0000017 (STATUS_NO_MEMORY)"));
+ assert!(text.contains("not a verified exception record"));
+ assert!(text.contains("process termination observed"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn internal_mode_rejects_invalid_arguments_without_launching_the_app() {
+ assert!(!maybe_run(&[], |_| panic!("no dialog")));
+ assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog")));
+ assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog")));
+ let args = [FLAG, "../escape", "", "", "1", "1"].map(OsString::from);
+ assert!(maybe_run(&args, |_| panic!("no dialog")));
+ let args = [FLAG, "1-1", "", "", "invalid", "1"].map(OsString::from);
+ assert!(maybe_run(&args, |_| panic!("no dialog")));
+ for pid in ["0", "not-a-pid", "4294967296"] {
+ let args = [FLAG, "1-1", "", "", "0", pid].map(OsString::from);
+ assert!(maybe_run(&args, |_| panic!("no dialog")));
+ }
+ }
+
+ #[test]
+ fn missing_helper_is_an_error_not_a_panic() {
+ assert!(spawn(Path::new("/nonexistent-effectcraft-crash-helper"), &[]).is_err());
+ }
+
+ // Roles are ignored by ordinary test runs. Explicit subprocess invocations exercise
+ // real EOF/abort behavior without allocating a huge image or killing the test runner.
+ #[test]
+ #[ignore]
+ fn reporter_process() {
+ assert_eq!(std::env::var(ROLE).unwrap(), "reporter");
+ let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap());
+ let id = std::env::var("EC_REPORT_ID").unwrap();
+ let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap();
+ let args = [
+ OsString::from(FLAG),
+ OsString::from(&id),
+ OsString::new(),
+ dir.as_os_str().to_owned(),
+ OsString::from(&interactive),
+ std::env::var_os("EC_REPORT_PID").unwrap(),
+ ];
+ assert!(maybe_run(&args, |description| {
+ assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog");
+ assert!(dir.join(format!("crash-{id}.txt")).exists());
+ let mut shown = OpenOptions::new().write(true).create_new(true).open(dir.join(format!("dialog-{id}"))).unwrap();
+ shown.write_all(description.as_bytes()).unwrap();
+ }));
+ std::fs::write(dir.join(format!("ready-{id}")), b"done").unwrap();
+ }
+
+ #[test]
+ #[ignore]
+ fn aborting_parent() {
+ let role = std::env::var(ROLE).unwrap();
+ let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap());
+ if role == "native-close" {
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "8-1")).unwrap();
+ after_teardown(Some(&reporter.clone()), || {}).unwrap();
+ std::process::exit(0); // Models macOS not returning from its native event loop.
+ }
+ if role == "windows-status" {
+ let _reporter = helper(&dir, "9-3");
+ // The real OS status is intentionally set, not actual memory exhaustion. The
+ // report must preserve this distinction rather than claiming a verified exception.
+ std::process::exit(0xc0000017u32 as i32);
+ }
+ assert_eq!(role, "abort");
+ let _reporter = helper(&dir, "4-1");
+ std::process::abort();
+ }
+
+ #[test]
+ #[ignore]
+ fn lingering_child() {
+ assert_eq!(std::env::var(ROLE).unwrap(), "linger");
+ thread::sleep(Duration::from_secs(30));
+ }
+}
diff --git a/apps/effectcraft/src/diagnostic_state.rs b/apps/effectcraft/src/diagnostic_state.rs
new file mode 100644
index 000000000..042e6e88c
--- /dev/null
+++ b/apps/effectcraft/src/diagnostic_state.rs
@@ -0,0 +1,147 @@
+//! Fixed-vocabulary observations retained by the crash helper independently of the log tail.
+//! Driver messages and environment text can contain private data; never forward either here.
+
+use std::ffi::OsStr;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU8, Ordering};
+
+use crate::crash_report::Shutdown;
+
+pub fn record(reporter: &Shutdown, event: &'static str) {
+ log::info!("diagnostic observation: {event}");
+ if let Err(error) = reporter.record(event) {
+ log::warn!("could not retain diagnostic observation: {error}");
+ }
+}
+
+/// Mirror only wgpu's documented names/aliases, without calling its parser: that parser
+/// logs unknown tokens verbatim. Unknown, non-Unicode and oversized values remain private.
+pub fn backend_override_events(value: &OsStr) -> Vec<&'static str> {
+ let Some(value) = value.to_str().filter(|value| value.len() <= 4096) else {
+ return vec!["WGPU_BACKEND: non-Unicode or oversized value omitted"];
+ };
+ let mut selected = [false; 6];
+ let mut unknown = false;
+ for token in value.split(',').map(str::trim) {
+ let aliases: &[&[&str]] = &[&["vulkan", "vk"], &["dx12", "d3d12"], &["metal", "mtl"], &["opengl", "gles", "gl"], &["webgpu"], &["noop"]];
+ if let Some(index) = aliases.iter().position(|names| names.iter().any(|name| token.eq_ignore_ascii_case(name))) {
+ if let Some(flag) = selected.get_mut(index) {
+ *flag = true;
+ }
+ } else {
+ unknown = true;
+ }
+ }
+ let events = [
+ "WGPU_BACKEND requests: vulkan",
+ "WGPU_BACKEND requests: dx12",
+ "WGPU_BACKEND requests: metal",
+ "WGPU_BACKEND requests: gl",
+ "WGPU_BACKEND requests: webgpu",
+ "WGPU_BACKEND requests: noop",
+ ];
+ let mut result: Vec<_> = selected.into_iter().zip(events).filter_map(|(present, event)| present.then_some(event)).collect();
+ if unknown {
+ result.push("WGPU_BACKEND: unrecognized or empty tokens omitted");
+ }
+ result
+}
+
+/// At most eight events per device, even when a driver floods its callbacks. A wgpu OOM
+/// observation is not proof of system RAM exhaustion; device loss is not a driver-crash verdict.
+#[derive(Clone)]
+pub struct GpuObservations {
+ seen: Arc,
+ reporter: Shutdown,
+}
+
+impl GpuObservations {
+ pub fn new(reporter: &Shutdown) -> Self {
+ Self { seen: Arc::new(AtomicU8::new(0)), reporter: reporter.clone() }
+ }
+
+ pub fn uncaptured(&self, error: &eframe::wgpu::Error) {
+ let (bit, event) = match error {
+ eframe::wgpu::Error::OutOfMemory { .. } => (1, "GPU observation: wgpu out-of-memory error (not proof of system RAM exhaustion)"),
+ eframe::wgpu::Error::Validation { .. } => (2, "GPU observation: wgpu validation error"),
+ eframe::wgpu::Error::Internal { .. } => (4, "GPU observation: wgpu internal error"),
+ };
+ self.once(bit, event);
+ }
+
+ pub fn scoped(&self, error: effectcraft_ui_egui::gpu_failure::ScopedError) {
+ use effectcraft_ui_egui::gpu_failure::ScopedError;
+ let (bit, event) = match error {
+ ScopedError::OutOfMemory => (32, "GPU scoped observation: out-of-memory (not proof of system RAM exhaustion)"),
+ ScopedError::Internal => (64, "GPU scoped observation: internal error (not confirmed out-of-memory)"),
+ ScopedError::Validation => (128, "GPU scoped observation: validation error"),
+ };
+ self.once(bit, event);
+ }
+
+ pub fn device_lost(&self, reason: eframe::wgpu::DeviceLostReason) {
+ match reason {
+ eframe::wgpu::DeviceLostReason::Unknown => self.once(8, "GPU observation: device lost, reason unknown (not proof of a driver crash)"),
+ eframe::wgpu::DeviceLostReason::Destroyed => self.once(16, "GPU observation: device-lost callback, explicit device destruction"),
+ };
+ }
+
+ fn once(&self, bit: u8, event: &'static str) -> bool {
+ if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 {
+ return false;
+ }
+ record(&self.reporter, event);
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn override_names_are_normalized_deduplicated_and_private() {
+ let events = backend_override_events(OsStr::new(" Vk, VULKAN, d3d12, MTL, gles, webgpu, noop, private-token"));
+ assert_eq!(events.len(), 7);
+ assert_eq!(events.first(), Some(&"WGPU_BACKEND requests: vulkan"));
+ assert!(!events.join(" ").contains("private-token"));
+ assert!(events.iter().all(|event| event.len() <= 100));
+ assert_eq!(backend_override_events(OsStr::new("")).len(), 1);
+ assert_eq!(backend_override_events(OsStr::new(&"x".repeat(4097))), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]);
+ }
+
+ #[test]
+ fn gpu_categories_are_latched_independently() {
+ let observations = GpuObservations::new(&Shutdown::default());
+ observations.uncaptured(&eframe::wgpu::Error::OutOfMemory { source: Box::new(std::io::Error::other("private driver detail")) });
+ observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown);
+ observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown);
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 9);
+ observations.device_lost(eframe::wgpu::DeviceLostReason::Destroyed);
+ observations.uncaptured(&eframe::wgpu::Error::Validation {
+ source: Box::new(std::io::Error::other("private driver detail")),
+ description: "private validation detail".into(),
+ });
+ observations.uncaptured(&eframe::wgpu::Error::Internal {
+ source: Box::new(std::io::Error::other("private driver detail")),
+ description: "private internal detail".into(),
+ });
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 31);
+ use effectcraft_ui_egui::gpu_failure::ScopedError;
+ observations.scoped(ScopedError::Internal);
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 95, "internal scope must not set scoped OOM bit");
+ observations.scoped(ScopedError::OutOfMemory);
+ observations.scoped(ScopedError::Validation);
+ observations.scoped(ScopedError::OutOfMemory);
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 255);
+ assert!(!observations.once(1, "GPU observation: duplicate"));
+ assert!(!observations.once(8, "GPU observation: duplicate"));
+ }
+
+ #[test]
+ #[cfg(unix)]
+ fn non_unicode_override_is_not_copied() {
+ use std::os::unix::ffi::OsStrExt;
+ assert_eq!(backend_override_events(OsStr::from_bytes(b"private\xff")), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]);
+ }
+}
diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs
index fac91c05b..2656179ac 100644
--- a/apps/effectcraft/src/launch_guard.rs
+++ b/apps/effectcraft/src/launch_guard.rs
@@ -8,9 +8,9 @@
//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A
//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those
//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still
-//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile
-//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to
-//! Automatic ([`opengl_failed`]) rather than failing every launch.
+//! wins over both. A separate guard file is locked while its launch runs, so a second copy opened
+//! meanwhile isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics
+//! back to Automatic ([`opengl_failed`]) rather than failing every launch.
use std::fs::File;
use std::path::{Path, PathBuf};
@@ -21,6 +21,46 @@ use eframe::wgpu::Backends;
/// Left in the settings folder from a launch until its window has settled.
const MARKER: &str = "launch-pending";
+const LOCK: &str = "launch.lock";
+
+/// Keep this file in place: unlinking a locked file lets a second inode acquire a new lock.
+fn acquire_guard(dir: &Path) -> Option {
+ let file = match std::fs::create_dir_all(dir)
+ .and_then(|()| std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)))
+ {
+ Ok(file) => file,
+ Err(e) => {
+ log::warn!("launch guard unavailable: {e}");
+ return None;
+ }
+ };
+ match file.try_lock() {
+ Ok(()) => {}
+ Err(std::fs::TryLockError::WouldBlock) => return None,
+ Err(e) => {
+ log::warn!("launch guard lock unavailable: {e}");
+ return None;
+ }
+ }
+ // Older versions lock the marker itself. Probe only the lock: Windows also prevents
+ // reads through another handle while it is held. An unreadable marker is not a stale one.
+ match File::open(dir.join(MARKER)) {
+ Ok(marker) => match marker.try_lock() {
+ Ok(()) => {}
+ Err(std::fs::TryLockError::WouldBlock) => return None,
+ Err(e) => {
+ log::warn!("launch marker lock unavailable: {e}");
+ return None;
+ }
+ },
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => {
+ log::warn!("launch marker unavailable: {e}");
+ return None;
+ }
+ }
+ Some(file)
+}
/// How long the window keeps drawing after its first frame before the launch counts as made.
/// A driver that fails as the window is shown and its surface re-created does so within the
@@ -28,16 +68,39 @@ const MARKER: &str = "launch-pending";
/// switch back in Settings.
pub const SETTLE: Duration = Duration::from_secs(3);
+/// Why this launch requested its window graphics APIs. An interrupted startup is an
+/// observation, not proof that a driver caused the previous exit.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub enum BackendSource {
+ #[default]
+ Default,
+ SavedPreference,
+ InterruptedStartup,
+ EnvironmentOverride,
+}
+
+impl BackendSource {
+ pub fn event(self) -> &'static str {
+ match self {
+ Self::Default => "backend source: default",
+ Self::SavedPreference => "backend source: saved OpenGL preference",
+ Self::InterruptedStartup => "backend source: previous interrupted startup; OpenGL fallback",
+ Self::EnvironmentOverride => "backend source: WGPU_BACKEND present",
+ }
+ }
+}
+
/// This launch's window graphics.
#[derive(Debug, Default)]
pub struct Launch {
/// The graphics APIs the window may use (`None`: eframe's default).
pub backends: Option,
+ pub source: BackendSource,
/// Switched to OpenGL because the previous launch stopped as its window opened: the message
/// to show.
pub notice: Option,
marker: Option,
- /// Holds the marker's lock until the launch settles (or the process ends).
+ /// Holds the separate guard file's lock until the launch settles (or the process ends).
lock: Option,
/// When the window drew its first frame, until the launch settles.
first_frame: Option,
@@ -48,14 +111,22 @@ impl Launch {
/// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this
/// launch's marker. `env_override`: `WGPU_BACKEND` is set.
pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch {
- let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() };
+ if env_override {
+ return Launch { source: BackendSource::EnvironmentOverride, ..Launch::default() };
+ }
+ let Some(dir) = dir else { return Launch::default() };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let marker = dir.join(MARKER);
- // Locked by another launch that is still opening its window: not a crash.
- let held = File::open(&marker).is_ok_and(|f| matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
+ let Some(lock) = acquire_guard(dir) else {
+ let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
+ let source = if backends.is_some() { BackendSource::SavedPreference } else { BackendSource::Default };
+ return Launch { backends, source, ..Launch::default() };
+ };
+ // Read again while owning the guard: another launch may have just changed preferences.
+ prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let mut notice = None;
- if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
+ if marker.exists() && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
prefs.startup.window_graphics = "gl".into();
match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) {
Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"),
@@ -69,15 +140,21 @@ impl Launch {
);
}
let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
- let (marker, lock) = match std::fs::create_dir_all(dir).and_then(|()| File::create(&marker)) {
- // Where locks aren't available the guard works as before.
- Ok(f) => (Some(marker), f.try_lock().is_ok().then_some(f)),
+ let source = if notice.is_some() {
+ BackendSource::InterruptedStartup
+ } else if backends.is_some() {
+ BackendSource::SavedPreference
+ } else {
+ BackendSource::Default
+ };
+ let marker = match File::create(&marker) {
+ Ok(_) => Some(marker),
Err(e) => {
log::warn!("launch marker: {e}");
- (None, None)
+ None
}
};
- Launch { backends, notice, marker, lock, first_frame: None }
+ Launch { backends, source, notice, marker, lock: Some(lock), first_frame: None }
}
/// The window drew a frame. Until the launch has settled, returns how long that still takes
@@ -104,18 +181,19 @@ impl Launch {
/// The launch made it: release and remove its marker.
fn settled(&mut self) {
- self.lock = None;
if let Some(m) = self.marker.take()
&& let Err(e) = std::fs::remove_file(&m)
{
log::warn!("launch marker: {e}");
}
+ self.lock = None;
}
}
/// The window couldn't open with OpenGL (an error, not a crash): back to Automatic, and no marker.
pub fn opengl_failed(dir: Option<&Path>) {
let Some(dir) = dir else { return };
+ let Some(_lock) = acquire_guard(dir) else { return };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
prefs.startup.window_graphics = "auto".into();
@@ -142,6 +220,7 @@ mod tests {
// may still crash as the window is shown); frames for `SETTLE` do.
let mut l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (None, false));
+ assert_eq!(l.source, BackendSource::Default);
assert!(dir.join(MARKER).exists());
let t0 = Instant::now();
assert_eq!(l.frame_drawn_at(t0), Some(SETTLE));
@@ -159,14 +238,18 @@ mod tests {
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
+ assert_eq!(l.source, BackendSource::InterruptedStartup);
assert_eq!(window_graphics(&dir), "gl", "saved in Settings");
l.exited();
let l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice");
+ assert_eq!(l.source, BackendSource::SavedPreference);
}
// `WGPU_BACKEND` wins, and leaves no marker.
let _ = std::fs::remove_file(dir.join(MARKER));
- assert_eq!(Launch::begin(Some(&dir), true).backends, None);
+ let overridden = Launch::begin(Some(&dir), true);
+ assert_eq!(overridden.backends, None);
+ assert_eq!(overridden.source, BackendSource::EnvironmentOverride);
assert!(!dir.join(MARKER).exists());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -217,4 +300,232 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A fresh settings folder for one test.
+ fn fresh_dir(case: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("ec-launch-guard-{case}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ /// Window Graphics saved as OpenGL.
+ fn save_opengl(dir: &Path) {
+ let mut prefs = Prefs::default();
+ prefs.startup.window_graphics = "gl".into();
+ std::fs::write(dir.join(PREFS_FILE), prefs.to_json()).unwrap();
+ }
+
+ #[test]
+ fn a_second_copy_reports_the_saved_backend_without_owning_the_guard() {
+ let dir = fresh_dir("second-source");
+ save_opengl(&dir);
+ let mut first = Launch::begin(Some(&dir), false);
+ let mut second = Launch::begin(Some(&dir), false);
+ assert!(second.lock.is_none());
+ assert_eq!((second.backends, second.source), (Some(Backends::GL), BackendSource::SavedPreference));
+ second.exited();
+ first.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A second copy that quits normally while the first is still opening doesn't remove the first
+ /// one's marker (#477).
+ #[test]
+ fn a_second_copy_quitting_leaves_the_running_launchs_marker() {
+ let dir = fresh_dir("second-quits");
+ let mut first = Launch::begin(Some(&dir), false);
+ let mut second = Launch::begin(Some(&dir), false);
+ second.exited();
+ let kept = dir.join(MARKER).exists();
+ first.exited();
+ let _ = std::fs::remove_dir_all(&dir);
+ assert!(kept, "the second copy removed the running launch's marker");
+ }
+
+ /// A second copy whose OpenGL window fails doesn't remove a running launch's marker.
+ #[test]
+ fn failed_opengl_in_a_second_copy_leaves_the_running_launchs_marker() {
+ let dir = fresh_dir("second-gl-fails");
+ let mut first = Launch::begin(Some(&dir), false);
+ let second = Launch::begin(Some(&dir), false);
+ opengl_failed(Some(&dir));
+ let kept = dir.join(MARKER).exists();
+ drop(second);
+ first.exited();
+ let _ = std::fs::remove_dir_all(&dir);
+ assert!(kept, "OpenGL cleanup removed another running launch's marker");
+ }
+
+ /// With `WGPU_BACKEND` set, a stale marker and a saved OpenGL choice are left as they are.
+ #[test]
+ fn wgpu_backend_leaves_a_stale_marker_and_saved_opengl_alone() {
+ let dir = fresh_dir("env-override");
+ save_opengl(&dir);
+ std::fs::write(dir.join(MARKER), b"stale-marker").unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut l = Launch::begin(Some(&dir), true);
+ assert_eq!(l.backends, None);
+ assert!(l.notice.is_none());
+ l.exited();
+ assert!(dir.join(MARKER).exists());
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// After OpenGL fails to open a window, the next launch is back on Automatic, without a notice.
+ #[test]
+ fn failed_opengl_puts_the_next_launch_back_on_automatic() {
+ let dir = fresh_dir("gl-recovers");
+ save_opengl(&dir);
+ let l = Launch::begin(Some(&dir), false);
+ assert_eq!(l.backends, Some(Backends::GL));
+ drop(l);
+ opengl_failed(Some(&dir));
+ assert!(!dir.join(MARKER).exists());
+ assert_eq!(Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics, "auto");
+ let mut l = Launch::begin(Some(&dir), false);
+ assert_eq!(l.backends, None);
+ assert!(l.notice.is_none());
+ l.exited();
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The guard file is never removed or replaced: unlinking a locked file would let the next
+ /// launch lock a new one while the old lock is still held.
+ #[cfg(unix)]
+ #[test]
+ fn the_guard_file_stays_the_same_file_across_launches() {
+ use std::os::unix::fs::MetadataExt;
+ let dir = fresh_dir("guard-identity");
+ let mut a = Launch::begin(Some(&dir), false);
+ let before = std::fs::metadata(dir.join(LOCK)).unwrap();
+ let t0 = Instant::now();
+ assert_eq!(a.frame_drawn_at(t0), Some(SETTLE));
+ assert_eq!(a.frame_drawn_at(t0 + SETTLE), None);
+ assert!(!dir.join(MARKER).exists());
+ let after = std::fs::metadata(dir.join(LOCK)).unwrap();
+ assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
+ let mut b = Launch::begin(Some(&dir), false);
+ assert!(b.lock.is_some());
+ let reused = std::fs::metadata(dir.join(LOCK)).unwrap();
+ assert_eq!((before.dev(), before.ino()), (reused.dev(), reused.ino()));
+ b.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A second copy's frames and quit neither settle nor unlock the running launch.
+ #[test]
+ fn a_second_copys_frames_dont_settle_the_running_launch() {
+ let dir = fresh_dir("second-frames");
+ let mut a = Launch::begin(Some(&dir), false);
+ std::fs::write(dir.join(MARKER), b"owner-marker").unwrap();
+ let mut b = Launch::begin(Some(&dir), false);
+ assert!(b.lock.is_none());
+ assert!(b.marker.is_none());
+ let t0 = Instant::now();
+ assert_eq!(b.frame_drawn_at(t0), None);
+ assert_eq!(b.frame_drawn_at(t0 + SETTLE), None);
+ b.exited();
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"owner-marker");
+ let probe = File::open(dir.join(LOCK)).unwrap();
+ assert!(matches!(probe.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
+ a.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// Older versions lock the marker itself: while one runs, a new launch changes nothing, and
+ /// once it has stopped without settling the next launch still switches to OpenGL.
+ #[test]
+ fn a_running_older_version_holding_the_marker_is_left_alone() {
+ let dir = fresh_dir("older-version");
+ std::fs::write(dir.join(MARKER), b"old-owner").unwrap();
+ std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
+ let old = File::open(dir.join(MARKER)).unwrap();
+ old.try_lock().unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut b = Launch::begin(Some(&dir), false);
+ assert!(b.lock.is_none());
+ assert!(b.marker.is_none());
+ assert!(b.backends.is_none());
+ assert!(b.notice.is_none());
+ b.exited();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ // Windows locks forbid reading through another handle. Inspect the contents only after
+ // releasing the old owner's lock, but before recovery can truncate the marker.
+ drop(old);
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner");
+ let mut recovered = Launch::begin(Some(&dir), false);
+ if cfg!(not(target_os = "macos")) {
+ assert_eq!(recovered.backends, Some(Backends::GL));
+ }
+ recovered.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A Windows sharing violation is not evidence that a marker is stale or absent.
+ #[cfg(windows)]
+ #[test]
+ fn an_unopenable_marker_leaves_settings_and_marker_alone() {
+ use std::os::windows::fs::OpenOptionsExt;
+
+ let dir = fresh_dir("marker-unopenable");
+ std::fs::write(dir.join(MARKER), b"old-owner").unwrap();
+ std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
+ let old = std::fs::OpenOptions::new().read(true).write(true).share_mode(0).open(dir.join(MARKER)).unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut launch = Launch::begin(Some(&dir), false);
+ assert!(launch.lock.is_none());
+ assert!(launch.marker.is_none());
+ assert!(launch.backends.is_none());
+ assert!(launch.notice.is_none());
+ launch.exited();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ drop(old);
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner");
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A guard file that can't be opened leaves Window Graphics and the marker as they are.
+ #[test]
+ fn an_unusable_guard_file_leaves_settings_and_marker_alone() {
+ let dir = fresh_dir("guard-unusable");
+ std::fs::create_dir(dir.join(LOCK)).unwrap();
+ std::fs::write(dir.join(MARKER), b"stale-marker").unwrap();
+ std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut l = Launch::begin(Some(&dir), false);
+ assert!(l.backends.is_none());
+ assert!(l.notice.is_none());
+ assert!(l.lock.is_none());
+ assert!(l.marker.is_none());
+ l.exited();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"stale-marker");
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// OpenGL cleanup from elsewhere waits until the running launch has let go; then it resets
+ /// Window Graphics to Automatic and removes the marker.
+ #[test]
+ fn failed_opengl_cleanup_waits_for_the_running_launch() {
+ let dir = fresh_dir("gl-cleanup-waits");
+ save_opengl(&dir);
+ let launch = Launch::begin(Some(&dir), false);
+ assert_eq!(launch.backends, Some(Backends::GL));
+ assert!(launch.lock.is_some());
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ assert!(dir.join(MARKER).exists(), "cleanup elsewhere can't remove a running launch's marker");
+ drop(launch);
+ opengl_failed(Some(&dir));
+ let after = Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics;
+ assert_eq!(after, "auto", "once the launch has let go, failed OpenGL resets Window Graphics");
+ assert!(!dir.join(MARKER).exists());
+ let _ = std::fs::remove_dir_all(dir);
+ }
}
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
index 07d327a21..5a602adcb 100644
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -22,6 +22,8 @@ mod appimage;
mod apple_events;
mod audio_out;
mod control_server;
+mod crash_report;
+mod diagnostic_state;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -180,6 +182,23 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) {
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
/// reports startup failure through `startup_failed`. Returns the event loop's result.
fn main() -> eframe::Result {
+ let args: Vec<_> = std::env::args_os().skip(1).collect();
+ if crash_report::maybe_run(&args, |description| {
+ rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft diagnostic report").set_description(description).show();
+ }) {
+ return Ok(());
+ }
+ let reporter = crash_report::Shutdown::default();
+ let result = run_desktop(&reporter);
+ // run_desktop has dropped the app and handled any startup error with its existing dialog.
+ // An unwinding panic or abort never reaches this marker.
+ if let Err(error) = reporter.finish() {
+ log::warn!("could not mark crash reporter clean: {error}");
+ }
+ result
+}
+
+fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) {
@@ -205,20 +224,38 @@ fn main() -> eframe::Result {
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
+ let run_id = crash_report::run_id();
if let Some(dir) = effectcraft_host::config_dir() {
- match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
+ let header = format!("{}\n", crash_report::session_marker(&run_id));
+ match effectcraft_engine::logging::attach_session_log_with_header(&effectcraft_engine::logging::log_dir(Some(&dir)), &header) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
}
}
+ let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref());
+ match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) {
+ Ok(started) => {
+ if let Err(error) = reporter.install(started) {
+ log::warn!("crash reporter could not be retained: {error}");
+ }
+ }
+ Err(error) => log::warn!("crash reporter could not start: {error}"),
+ }
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
- // stopped as its window opened (a crashing graphics driver, #243).
- let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
+ // stopped as its window opened (the marker alone does not establish a cause).
+ let backend_override = std::env::var_os("WGPU_BACKEND");
+ let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), backend_override.is_some());
+ diagnostic_state::record(reporter, launch.source.event());
+ if let Some(value) = backend_override {
+ for event in diagnostic_state::backend_override_events(&value) {
+ diagnostic_state::record(reporter, event);
+ }
+ }
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
let notice = launch.notice.take();
let options = eframe::NativeOptions {
@@ -245,22 +282,40 @@ fn main() -> eframe::Result {
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
+ let desktop_reporter = reporter.clone();
let result = eframe::run_native(
"EffectCraft",
options,
Box::new(move |cc| {
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
match &cc.wgpu_render_state {
- Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
- None => log::info!("graphics: no wgpu device"),
+ Some(rs) => {
+ let info = rs.adapter.get_info();
+ log::info!("graphics: {info:?}");
+ let event = match info.backend {
+ eframe::wgpu::Backend::Noop => "actual GPU backend: noop",
+ eframe::wgpu::Backend::Vulkan => "actual GPU backend: vulkan",
+ eframe::wgpu::Backend::Metal => "actual GPU backend: metal",
+ eframe::wgpu::Backend::Dx12 => "actual GPU backend: dx12",
+ eframe::wgpu::Backend::Gl => "actual GPU backend: gl",
+ eframe::wgpu::Backend::BrowserWebGpu => "actual GPU backend: webgpu",
+ };
+ diagnostic_state::record(&desktop_reporter, event);
+ }
+ None => diagnostic_state::record(&desktop_reporter, "actual GPU backend: no wgpu device"),
}
// This executable owns the shared device's handlers. Install them before
// EffectCraft's compositor pipelines are built, without changing handlers from
// inside Gpu::new. eframe has already constructed its presentation renderer here.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
+ let observations = diagnostic_state::GpuObservations::new(&desktop_reporter);
+ let scoped_observed = observations.clone();
+ gpu_failures.set_scoped_error_observer(move |error| scoped_observed.scoped(error));
if let Some(rs) = &cc.wgpu_render_state {
+ let errors_observed = observations.clone();
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
+ errors_observed.uncaptured(&error);
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
@@ -268,6 +323,7 @@ fn main() -> eframe::Result {
}));
let lost = gpu_failures.clone();
rs.device.set_device_lost_callback(move |reason, message| {
+ observations.device_lost(reason);
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
});
}
@@ -276,9 +332,6 @@ fn main() -> eframe::Result {
// written on background threads (M13.14).
session.check_footage_on_open = true;
session.autosave.background = true;
- // The disk cache's footage stamps too: an imported or opened project with thousands of
- // sequence frames on a network share took half a minute to stamp.
- session.disk_salt_background = true;
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = effectcraft_host::config_dir() {
@@ -401,6 +454,7 @@ fn main() -> eframe::Result {
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
+ reporter: desktop_reporter,
}))
}),
);
@@ -494,6 +548,7 @@ struct Desktop {
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
+ reporter: crash_report::Shutdown,
}
impl eframe::App for Desktop {
@@ -528,15 +583,20 @@ impl eframe::App for Desktop {
}
fn on_exit(&mut self) {
- // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
- // before `eframe::run_native` returns (#341).
- log::info!("the window is closing");
- self.launch.exited();
- self.window.save(self.app.session.config.as_deref());
- self.app.on_exit();
- effectcraft_host::stop_helpers();
- // Scripts in Scripts/Shutdown run with the project closed.
- let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
+ // macOS terminates after this hook without returning from run_native (#341).
+ // Other platforms keep the reporter armed until main sees complete event-loop teardown.
+ let reporter = cfg!(target_os = "macos").then(|| self.reporter.clone());
+ if let Err(error) = crash_report::after_teardown(reporter.as_ref(), || {
+ log::info!("the window is closing");
+ self.launch.exited();
+ self.window.save(self.app.session.config.as_deref());
+ self.app.on_exit();
+ effectcraft_host::stop_helpers();
+ // Scripts in Scripts/Shutdown run with the project closed.
+ let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
+ }) {
+ log::warn!("could not mark crash reporter clean after teardown: {error}");
+ }
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
diff --git a/crates/aep/README.md b/crates/aep/README.md
index 965bea317..5e8035d8f 100644
--- a/crates/aep/README.md
+++ b/crates/aep/README.md
@@ -69,13 +69,19 @@ tests reach them only through `tests/it/common/mod.rs`, which hands a test its c
of its own cut from the combined file; a test whose fixture is missing (or with the variable not
set) prints `SKIPPED: fixture missing` and passes.
-`tests/fixtures/synthetic.aep` (115 KB) is the one project in the repository: made in After Effects 2026
+`tests/fixtures/synthetic.aep` (115 KB) is one of the two projects in the repository: made in After Effects 2026
with the working space set to None (one composition, an image sequence of three 8x8 PNGs, a
solid, a folder, a Levels and a Glow effect with expressions). Every After Effects project embeds
the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk;
the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was
changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file.
+`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs
+named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a
+Null parented to a Null (generated by `tests/fixtures/tools/ai-mask-3099.py` and
+`ai-mask-3099.jsx`, scrubbed with `scrub.py` and `stubicc`). `tests/it/import/ai_mask.rs` checks
+the import against After Effects' read-back, and the host's `vector_import` tests render it.
+
## Tools
- `tests/fixtures/tools/`: the generator of the test projects (regenerates the corpus with After
diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs
index b6c3b4490..531d74843 100644
--- a/crates/aep/src/container.rs
+++ b/crates/aep/src/container.rs
@@ -37,7 +37,9 @@ mod version;
mod write;
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
-pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut};
+pub use arbitrary::{
+ CURVE_IDENTITY, CryptomatteData, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type,
+};
pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs
index 5762da111..e4916cfe8 100644
--- a/crates/aep/src/container/arbitrary.rs
+++ b/crates/aep/src/container/arbitrary.rs
@@ -308,6 +308,82 @@ impl MeshData {
}
}
+/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian
+/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset
+/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records
+/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by
+/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one
+/// definition's default, so only byte 0 is read) and the channel name from byte 35,
+/// NUL-terminated. The other bytes are not read (they differ between instances with the same
+/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted
+/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ExtractorChannels {
+ /// Per output channel (red, green, blue, alpha): the kind and the name.
+ pub channels: [(u32, String); 4],
+}
+
+impl ExtractorChannels {
+ /// The data's length.
+ pub const LEN: usize = 688;
+ const RECORD: usize = 164;
+
+ /// `None` when the data does not have this layout.
+ pub fn parse(data: &[u8]) -> Option {
+ if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() {
+ return None;
+ }
+ let mut channels: [(u32, String); 4] = Default::default();
+ for (k, slot) in channels.iter_mut().enumerate() {
+ let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?;
+ let rec = data.get(at..at.checked_add(Self::RECORD)?)?;
+ let kind = u32::from(*rec.first()?);
+ let name = rec.get(35..)?;
+ let end = name.iter().position(|b| *b == 0)?;
+ let name = std::str::from_utf8(name.get(..end)?).ok()?;
+ *slot = (kind, name.to_string());
+ }
+ Some(Self { channels })
+ }
+
+ /// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names,
+ /// Alpha copied from the input.
+ pub fn is_default(&self) -> bool {
+ let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
+ self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn)
+ }
+
+ /// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the
+ /// position of the first record of another kind or without a name: a named channel by its
+ /// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY),
+ /// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`:
+ /// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the
+ /// file's own, which loses what an earlier effect did to the channel.
+ pub fn sources(&self) -> Result<[String; 4], usize> {
+ let mut out: [String; 4] = Default::default();
+ for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() {
+ *slot = match (kind, name.trim()) {
+ (1, n) if !n.is_empty() => n.to_string(),
+ (2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(),
+ _ => return Err(k),
+ };
+ }
+ Ok(out)
+ }
+}
+
+/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name,
+/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310
+/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in
+/// every other instance). The bytes around it differ between instances with the same settings
+/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the
+/// EXR's sorted channel list, as for EXtractoR).
+pub fn identifier_channel_type(data: &[u8]) -> Option<&str> {
+ let s = data.get(19..)?;
+ let end = s.iter().position(|b| *b == 0)?;
+ std::str::from_utf8(s.get(..end)?).ok()
+}
+
/// The Cryptomatte plug-in's Settings data (`Cryptomatte-0001`, the plug-in's own layout,
/// little-endian; read from 88 instances of one project and from our own sample 1400, fresh
/// instances and two presets that pick a layer and an object, docs/aep-format.md 3.7): the magic
diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs
index ed723a82e..aea320395 100644
--- a/crates/aep/src/container/effect_map.rs
+++ b/crates/aep/src/container/effect_map.rs
@@ -141,6 +141,13 @@ pub enum Extra {
Curves,
/// Mesh Warp's Distortion Mesh (965, 985, `MeshData`).
MeshWarp,
+ /// Lumetri Color: every parameter goes through its own table, keyed by match name
+ /// (`import/lumetri.rs`, 1200).
+ Lumetri,
+ /// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name.
+ Extractor,
+ /// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name.
+ Identifier,
/// The Cryptomatte plug-in: the layer and selection in its Settings data
/// (`CryptomatteData`), Output split over EffectCraft's Display and Matte Only; every
/// parameter is handled by the importer's Cryptomatte step, none by the table.
@@ -361,6 +368,8 @@ pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"];
pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"];
/// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels).
pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"];
+/// IDentifier's Display (`identifier-04` ... `identifier-06`, `1300-thirdparty.aep`).
+pub const IDENTIFIER_DISPLAY: &[&str] = &["Colors", "Luma Matte", "Alpha Matte", "Raw"];
/// The effects the importer converts.
pub const EFFECTS: &[EffectMap] = &[
@@ -532,6 +541,9 @@ pub const EFFECTS: &[EffectMap] = &[
],
extra: Extra::MeshWarp,
},
+ // Lumetri Color (1200): the parameters are mapped by match name, not by `parT` index
+ // (`import/lumetri.rs`).
+ EffectMap { match_name: "ADBE Lumetri", effect: "ec.color.lumetri", params: &[], extra: Extra::Lumetri },
// The Cryptomatte plug-in (docs/aep-format.md 3.7): 1 Settings data, 2 Output, 3 Selection
// Mode, imported by the Cryptomatte step (`Extra::Cryptomatte`).
EffectMap { match_name: "Cryptomatte", effect: "ec.3d.cryptomatte", params: &[], extra: Extra::Cryptomatte },
@@ -1076,6 +1088,40 @@ pub const EFFECTS: &[EffectMap] = &[
pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }),
],
),
+ // CC Color Neutralizer (importer block 2026-11-05, `ccneutral-*`): Pinning and Blend w.
+ // Original are float sliders in percent (valid 0..100). After Effects' correction is the Red /
+ // Green / Blue slider of each range (3-5, 9-11, 15-17; the Unbalance colours are pickers that
+ // set them and keep no record), then View (22) and Black / White Point (23, 24):
+ // EffectCraft's CC Color Neutralizer has none of them, so they are reported when stored.
+ fx(
+ "CS Color Neutralizer",
+ "ec.color.cccolorneutralizer",
+ &[pm(19, K::FloatSlider, "pinning", Conv::Plain { valid_max: 100.0 }), pm(20, K::FloatSlider, "blendWOriginal", Conv::Plain { valid_max: 100.0 })],
+ ),
+ // EXtractoR (`extractor-*`): 1 Channel Info (arbitrary data, `Extra::Extractor`), 2 / 6 the
+ // Process group, Black and White Point float sliders (valid ±3.4e38, the float32 range),
+ // UnMult. EffectCraft's Clip has no counterpart and stays off.
+ EffectMap {
+ match_name: "EXtractoR",
+ effect: "ec.3d.extractor",
+ params: &[
+ pm(3, K::FloatSlider, "blackPoint", Conv::Plain { valid_max: F32_MAX }),
+ pm(4, K::FloatSlider, "whitePoint", Conv::Plain { valid_max: F32_MAX }),
+ pm(5, K::Checkbox, "unMult", Conv::Checkbox),
+ ],
+ extra: Extra::Extractor,
+ },
+ // IDentifier (`identifier-*`): 1 Channel Info (arbitrary data, `Extra::Identifier`), 2 Display,
+ // 3 ID an integer slider (valid 0..2 147 483 647).
+ EffectMap {
+ match_name: "IDentifier",
+ effect: "ec.3d.identifier",
+ params: &[
+ pm(2, K::Popup, "display", Conv::Popup { default: 1, items: IDENTIFIER_DISPLAY }),
+ pm(3, K::IntSlider, "id", Conv::Plain { valid_max: 2_147_483_647.0 }),
+ ],
+ extra: Extra::Identifier,
+ },
// Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset.
fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]),
fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]),
@@ -1089,6 +1135,8 @@ pub const EFFECTS: &[EffectMap] = &[
pub const EXPOSURE: Conv = Conv::Plain { valid_max: 100.0 };
pub const OFFSET: Conv = Conv::Plain { valid_max: 2.0 };
pub const GAMMA: Conv = Conv::Plain { valid_max: 10.0 };
+/// The largest float32, the valid maximum of EXtractoR's Black and White Point.
+pub const F32_MAX: f64 = f32::MAX as f64;
/// The valid maximum of Glow's A & B Midpoint (16.16 of 0.99, 427).
pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5;
diff --git a/crates/aep/src/container/effects.rs b/crates/aep/src/container/effects.rs
index e336d84e5..2fa77d017 100644
--- a/crates/aep/src/container/effects.rs
+++ b/crates/aep/src/container/effects.rs
@@ -12,6 +12,11 @@
//! project-level `LIST EfdG` (one `EfDf` per effect type: a `tdmn` with the match name, then a copy
//! of one instance's `sspc`), see [`Project::effect_params`]. That copy also carries the values of
//! the instance it was copied from, so values are only ever read from the instance's own `tdgp`.
+//!
+//! Some files have no `EfdG` at all (seen in 17 projects, all from inside After Effects-made
+//! Motion Graphics templates). In those, one instance per effect type kept its full `parT` (which
+//! one is not known) and it is the only place the definitions are written, so the other instances
+//! of that type borrow them from it (#3170).
use std::collections::{HashMap, HashSet};
@@ -138,7 +143,11 @@ pub enum DefinitionSource {
/// The project-level `EfdG/EfDf` copy for the effect's match name (the instance's own `parT`
/// is empty, 383).
Project,
- /// Neither has any: the effect has no readable parameter definitions.
+ /// The own `parT` of another instance of the same match name in the file: the instance's
+ /// own `parT` is empty and the file has no project-level copy of the type (a project without
+ /// `EfdG`, #3170).
+ OtherInstance,
+ /// None of these has any: the effect has no readable parameter definitions.
Missing,
}
@@ -155,7 +164,8 @@ pub struct EffectParam<'f, 'a> {
pub name: String,
/// The popup items (`pdnm`, split at `|`; for a button its label, e.g. `Swap Colors`).
pub items: Option>,
- /// True when the definition is the project-level copy (see [`DefinitionSource::Project`]).
+ /// True when the definition is borrowed: the project-level copy ([`DefinitionSource::Project`])
+ /// or another instance's ([`DefinitionSource::OtherInstance`]).
pub borrowed_definition: bool,
pard: &'f [u8],
stored: Option>,
@@ -214,6 +224,22 @@ impl<'f, 'a> Effect<'f, 'a> {
self.chunk.child_list(b"parT").is_some_and(has_pard)
}
+ /// The match names of the value records in the instance's own `tdgp` (properties only, not
+ /// groups), in file order, each once.
+ pub fn value_record_names(&self) -> Vec {
+ let mut seen: HashSet = HashSet::new();
+ self.chunk
+ .child_list(b"tdgp")
+ .and_then(PropGroup::from_chunk)
+ .map(|g| g.entries())
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|e| matches!(e.node, PropNode::Property(_)))
+ .map(|e| e.match_name)
+ .filter(|n| seen.insert(n.clone()))
+ .collect()
+ }
+
/// The parameters in the order of the instance's own `parT`. Empty for an instance whose
/// `parT` is empty; [`Project::effect_params`] falls back to the project's definitions.
pub fn params(&self) -> Vec> {
@@ -299,8 +325,25 @@ impl<'f, 'a> Project<'f, 'a> {
/// [`Self::effect_definitions`] indexed by match name, for looking up many effects: build it
/// once per file, not once per effect (a file can hold many definitions and many instances).
+ /// For a type without a project-level copy it also keeps the own `parT` of the first instance
+ /// in the file (items, layers and effects in file order) that has one.
pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> {
- EffectDefinitions { by_name: self.effect_definitions().into_iter().collect() }
+ let by_name: HashMap> = self.effect_definitions().into_iter().collect();
+ let mut instances: HashMap> = HashMap::new();
+ for item in self.all_items() {
+ let Some(comp) = item.comp() else { continue };
+ for layer in comp.layers() {
+ for fx in layer.effects() {
+ if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) {
+ continue;
+ }
+ if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) {
+ instances.insert(fx.match_name, part);
+ }
+ }
+ }
+ }
+ EffectDefinitions { by_name, instances }
}
/// Where the parameter definitions of `effect` come from. Builds the index each call: use
@@ -316,10 +359,13 @@ impl<'f, 'a> Project<'f, 'a> {
}
}
-/// The project-level effect definitions by match name ([`Project::effect_definition_index`]).
+/// The borrowable effect definitions by match name ([`Project::effect_definition_index`]).
#[derive(Debug, Clone, Default)]
pub struct EffectDefinitions<'f, 'a> {
+ /// The project-level copies (`EfdG`).
by_name: HashMap>,
+ /// For a type without a project-level copy: the own `parT` of the first instance that has one.
+ instances: HashMap>,
}
impl<'f, 'a> EffectDefinitions<'f, 'a> {
@@ -329,20 +375,23 @@ impl<'f, 'a> EffectDefinitions<'f, 'a> {
DefinitionSource::Instance
} else if self.by_name.contains_key(effect.match_name()) {
DefinitionSource::Project
+ } else if self.instances.contains_key(effect.match_name()) {
+ DefinitionSource::OtherInstance
} else {
DefinitionSource::Missing
}
}
/// The parameters of `effect`: from its own `parT`, or, when that is empty, from the
- /// project-level definition of its match name ([`EffectParam::borrowed_definition`]). Values
- /// always come from the instance's own records (absent = the `pard` default); the project
- /// copy's values belong to another instance and are never read.
+ /// project-level definition of its match name, else from another instance of it that has
+ /// its own ([`EffectParam::borrowed_definition`]). Values always come from the instance's own
+ /// records (absent = the `pard` default); the values next to borrowed definitions belong to
+ /// another instance and are never read.
pub fn params(&self, effect: &Effect<'f, 'a>) -> Vec> {
if effect.has_own_definitions() {
return effect.params();
}
- match self.by_name.get(effect.match_name()) {
+ match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) {
Some(part) => effect.params_from(part, true),
None => Vec::new(),
}
diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs
index 3c9148155..da0369d64 100644
--- a/crates/aep/src/container/footage.rs
+++ b/crates/aep/src/container/footage.rs
@@ -2,6 +2,7 @@
//! of a layer's source.
use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text};
+use crate::container::chunk::Chunk;
use crate::container::header::Rational;
use crate::container::typed::{Item, ItemKind, Layer, Project};
@@ -159,19 +160,25 @@ fn json_string_at(json: &[u8], mut i: usize) -> Option {
}
}
-/// The numbering of an image sequence item (353, 603).
+/// The numbering of an image sequence item (353, 603), or its file list when it was imported with
+/// Force alphabetical order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SequenceInfo {
/// The file name before the frame number (`seq_`, `shot_`): the first `Utf8` after the alias.
+ /// An empty prefix is not stored (`0001.png`): the one `Utf8` is then the suffix.
pub prefix: String,
/// The file name after the frame number (`.png`, `.exr`): the second `Utf8` after the alias.
pub suffix: String,
/// The first and last frame number (`sspc` `u32` at 172 and 176: 1 and 3 in 353, 101 and 103
- /// in 603).
+ /// in 603; both `0xFFFFFFFF` for a sequence with a file list).
pub start: u32,
pub end: u32,
/// The digits of the frame number (`sspc` `u32` at 180: 3 for `seq_001`, 4 for `shot_0101`).
pub digits: u32,
+ /// The file names in order for a sequence imported with Force alphabetical order (`LIST StVc`
+ /// after the alias: `StVS` with the count as a little-endian `u32`, then one `Utf8` per file);
+ /// empty for a numbered sequence. When set, it replaces the numbering.
+ pub files: Vec,
}
impl SequenceInfo {
@@ -180,6 +187,62 @@ impl SequenceInfo {
let width = usize::try_from(self.digits.min(16)).unwrap_or(0);
format!("{}{n:0width$}{}", self.prefix, self.suffix)
}
+
+ /// The number of files: the listed files, or the frame numbers from start to end (0 when the
+ /// end lies before the start).
+ pub fn frame_count(&self) -> u32 {
+ if self.files.is_empty() {
+ self.end.checked_sub(self.start).map_or(0, |d| d.saturating_add(1))
+ } else {
+ u32::try_from(self.files.len()).unwrap_or(u32::MAX)
+ }
+ }
+
+ /// The name of the `i`-th file (from 0): from the file list, or numbered from `start`; `None`
+ /// past the last file.
+ pub fn nth_file_name(&self, i: u32) -> Option {
+ if self.files.is_empty() {
+ let n = self.start.checked_add(i).filter(|n| *n <= self.end)?;
+ Some(self.file_name(n))
+ } else {
+ self.files.get(usize::try_from(i).ok()?).cloned()
+ }
+ }
+}
+
+/// The file names of a `LIST StVc` (Force alphabetical order): the `Utf8` children in order, at most
+/// as many as the count in `StVS` (little-endian `u32`) says. Empty names count toward that number
+/// and are then skipped. Without a readable count (no `StVS`, or one shorter than 4 bytes) the list
+/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control
+/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names
+/// are joined to the folder.
+fn sequence_file_list(list: &Chunk<'_>) -> Vec {
+ // After Effects writes the count first (the one sample); a list without a readable count is
+ // not read rather than guessed.
+ let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else {
+ return Vec::new();
+ };
+ let names: Vec =
+ list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect();
+ if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") {
+ return Vec::new();
+ }
+ names
+}
+
+/// The prefix and suffix of a sequence whose naming holds one `Utf8` (`part`): After Effects leaves
+/// out an empty prefix (`0001.png` stores only `.png`). The stored first file name (`opti`) decides:
+/// a name starting with `part` has it as the prefix, one ending with it as the suffix. Without that
+/// evidence, a `part` starting with `.` is the suffix (an extension); anything else is not read.
+fn single_name_part(part: &str, first_file: Option<&str>) -> Option<(String, String)> {
+ if let Some(first) = first_file.filter(|_| !part.is_empty()) {
+ match (first.starts_with(part), first.ends_with(part)) {
+ (true, false) => return Some((part.to_string(), String::new())),
+ (false, true) => return Some((String::new(), part.to_string())),
+ _ => {}
+ }
+ }
+ part.starts_with('.').then(|| (String::new(), part.to_string()))
}
impl<'f, 'a> Item<'f, 'a> {
@@ -194,10 +257,24 @@ impl<'f, 'a> Item<'f, 'a> {
let sspc = pin.child(b"sspc")?.bytes()?;
let kids = pin.children();
let after = kids.iter().position(|c| c.is_list(b"Als2"))?;
- let mut names = kids.iter().skip(after + 1).take_while(|c| !c.is(b"opti")).filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text);
- let prefix = names.next()?;
- let suffix = names.next()?;
- Some(SequenceInfo { prefix, suffix, start: be_u32(sspc, 172)?, end: be_u32(sspc, 176)?, digits: be_u32(sspc, 180)? })
+ let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti"));
+ let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?);
+ // Force alphabetical order: the file list replaces the numbering.
+ if let Some(list) = naming().find(|c| c.is_list(b"StVc")) {
+ let files = sequence_file_list(list);
+ if files.is_empty() {
+ return None;
+ }
+ return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files });
+ }
+ let names: Vec = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect();
+ let (prefix, suffix) = match names.as_slice() {
+ [prefix, suffix] => (prefix.clone(), suffix.clone()),
+ // An empty prefix is not stored (`0001.png` has only `.png`).
+ [one] => single_name_part(one, f.name.as_deref())?,
+ _ => return None,
+ };
+ Some(SequenceInfo { prefix, suffix, start, end, digits, files: Vec::new() })
}
/// The rate a clip is conformed to (Interpret Footage ▸ Conform to frame rate; `sspc` `u32` at
diff --git a/crates/aep/src/export/effects.rs b/crates/aep/src/export/effects.rs
index a06441dca..9e04a7a46 100644
--- a/crates/aep/src/export/effects.rs
+++ b/crates/aep/src/export/effects.rs
@@ -461,7 +461,7 @@ fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop:
fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap, here: &str, col: &mut Collector) -> Option<(u16, Vec)> {
let ids: &[&str] = match spec.extra {
// (the Cryptomatte plug-in's settings are its own binary layout: not written, reported below)
- Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte => return None,
+ Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
Extra::HueSaturation => &HUE_SATURATION_MASTER,
Extra::Levels => &LEVELS_RGB,
};
@@ -643,7 +643,9 @@ fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64
// effect was added at is not known here.
let (fresh, other) = (fresh([size.0, size.1], &mut next), fresh([size.0 * 2.0 + 7.0, size.1 * 3.0 + 5.0], &mut next));
let extra: &[&str] = match spec.extra {
- Extra::None | Extra::Cryptomatte => &[],
+ // Lumetri Color, EXtractoR and IDentifier have no prototype to write from, so they never
+ // get this far (they are reported).
+ Extra::None | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
Extra::HueSaturation => &HUE_SATURATION_MASTER,
Extra::Levels => &LEVELS_RGB,
Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"],
diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs
index 9f45c50cd..a6789eda9 100644
--- a/crates/aep/src/export/plan.rs
+++ b/crates/aep/src/export/plan.rs
@@ -817,7 +817,7 @@ fn plan_transform(layer: &Layer, src: &LayerSrc, comp: &Comp, source_size: (u32,
let cam_light = matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_));
let is_shape = matches!(src, LayerSrc::Shape(_));
let mut props = Vec::new();
- let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape };
+ let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape, null: matches!(src, LayerSrc::Null) };
if let Some(tr) = layer.transform() {
for kind in PropKind::ALL {
// A camera's or light's point of interest is After Effects' Anchor Point.
@@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col
let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col);
let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col));
let mut name = cut_name(&layer.name, 255, &what("layer"), col);
- // A text layer named after its text stores no name (110, 113: After Effects shows the text).
+ // A text layer named after its text stores no name (110, 113: After Effects shows the text):
+ // the layer's own rule decides (#3233).
if let LayerSrc::Text(tp) = &src
- && tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::() == name)
+ && layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text))
{
name.clear();
}
@@ -1171,16 +1172,21 @@ struct PropAt<'a> {
source: (u32, u32),
/// The anchor is stored in pixels (text, camera, light and shape layers).
text: bool,
+ /// A null layer: an Anchor Point that is not written is its top-left corner (#3099).
+ null: bool,
}
+/// What is written for transform property `p` (`kind`) of a layer: its value or keys in the
+/// stored unit and its expression; `None` when it is at After Effects' default and has neither.
fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>, cp: &CompPlan, col: &mut Collector) -> Option {
- let PropAt { comp, comp_name, layer, source, text } = at;
+ let PropAt { comp, comp_name, layer, source, text, null } = at;
// A text layer's anchor is stored in pixels, with the origin as its default (385).
let (sw, sh) = if text { (1.0, 1.0) } else { (f64::from(source.0.max(1)), f64::from(source.1.max(1))) };
let what = format!("{comp_name}: '{layer}' {}", label.unwrap_or(kind.match_name()));
let dims = kind.dims();
let default: Vec = match kind {
- PropKind::Anchor if text => vec![0.0, 0.0, 0.0],
+ // After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099).
+ PropKind::Anchor if text || null => vec![0.0, 0.0, 0.0],
PropKind::Anchor => vec![0.5, 0.5, 0.0],
PropKind::Position => vec![f64::from(comp.width) / 2.0, f64::from(comp.height) / 2.0, 0.0],
PropKind::Scale => vec![1.0, 1.0, 1.0],
@@ -1318,7 +1324,7 @@ fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>,
/// back disabled).
fn plan_remap(p: &Property, comp: &Comp, cp: &CompPlan, layer: &str, comp_name: &str, col: &mut Collector) -> PropPlan {
// Rotation has the same layout (one component, stored as is, not spatial).
- let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false };
+ let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false, null: false };
let planned = plan_prop(PropKind::Rotation, Some("Time Remap"), p, at, cp, col);
col.exported.time_remaps += 1;
let mut plan = planned.unwrap_or_else(|| PropPlan { kind: PropKind::Rotation, value: vec![0.0], keys: Vec::new(), expr: None });
diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs
index 956e3b1c5..fae3b3256 100644
--- a/crates/aep/src/import.rs
+++ b/crates/aep/src/import.rs
@@ -27,9 +27,11 @@
mod build;
mod camera;
mod effects;
+mod lumetri;
mod markers;
mod masks;
mod props;
+mod puppet;
mod report;
mod settings;
mod shapes;
diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs
index 4ec346328..7d5b902f1 100644
--- a/crates/aep/src/import/build.rs
+++ b/crates/aep/src/import/build.rs
@@ -206,6 +206,8 @@ fn new_comp(h: &CompHeader, renderer: Option, map: &TimeMap, name:
comp
}
+/// Adds one footage item to the project under the given parent.
+/// Unreadable footage is recorded as a loss, solids and null placeholders are handled separately, and file and sequence footage is linked to its files.
fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_>, name: String, label: Label, parent: ItemId, null_only: &HashSet) {
let Some(f) = item.footage() else {
cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': data unreadable, not imported"));
@@ -240,31 +242,42 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
}
let mut path = f.path.clone().unwrap_or_default();
// An image sequence's alias is its folder; its files are named by the stored numbering
- // (353, 603). The first file stands for the sequence, as in EffectCraft.
+ // (353, 603), or listed one by one when imported with Force alphabetical order. The first
+ // file stands for the sequence, as in EffectCraft.
let mut sequence = Vec::new();
- if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.end >= s.start) {
- let frames = (seq.end - seq.start).saturating_add(1);
+ if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.frame_count() > 0) {
+ let frames = seq.frame_count();
let sep = if path.contains('\\') { '\\' } else { '/' };
let folder = path.trim_end_matches(['\\', '/']).to_string();
- // One name is the folder, a separator and the file name; the number is as wide as the stored
- // digits or as the largest number, whichever is longer (an upper bound for every frame).
- let name_bytes = (folder.len() + 1 + seq.prefix.len() + seq.suffix.len() + usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)) as u64;
- let bytes = name_bytes.saturating_mul(u64::from(frames));
+ // One name is the folder, a separator and the file name. A numbered name is as wide as the
+ // stored digits or as the largest number, whichever is longer (an upper bound for every
+ // frame); listed names are counted as they are.
+ let len = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
+ let file_bytes = if seq.files.is_empty() {
+ len(seq.prefix.len().saturating_add(seq.suffix.len()).saturating_add(usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)))
+ .saturating_mul(u64::from(frames))
+ } else {
+ seq.files.iter().fold(0u64, |sum, n| sum.saturating_add(len(n.len())))
+ };
+ let bytes = len(folder.len().saturating_add(1)).saturating_mul(u64::from(frames)).saturating_add(file_bytes);
if frames > MAX_SEQUENCE_FRAMES || frames > cx.sequence_budget || bytes > cx.sequence_bytes {
// Not expanded: the item keeps its first file (a still standing for the sequence).
cx.col.lose(
LossCategory::DamagedData,
format!("footage '{name}': a sequence of {frames} frames is longer than the import expands; only its first file is linked"),
);
- path = format!("{folder}{sep}{}", seq.file_name(seq.start));
+ path = format!("{folder}{sep}{}", seq.nth_file_name(0).unwrap_or_default());
} else {
cx.sequence_budget -= frames;
cx.sequence_bytes -= bytes;
- sequence = (seq.start..=seq.end).map(|n| format!("{folder}{sep}{}", seq.file_name(n))).collect();
+ sequence = (0..frames).filter_map(|i| seq.nth_file_name(i)).map(|n| format!("{folder}{sep}{n}")).collect();
}
if let Some(first) = sequence.first() {
path = first.clone();
}
+ } else if f.is_sequence && !path.is_empty() {
+ // The alias is a folder, but the file names could not be read: the item links the folder.
+ cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': image sequence naming not read, linked to its folder"));
}
let present = !path.is_empty() && (cx.options.exists)(&path);
if !present {
@@ -272,6 +285,8 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
cx.col.missing_footage.push(shown);
}
let kind = if sequence.is_empty() { footage_kind(&path, f.native_frame_rate, f.audio_sample_rate.is_some()) } else { FootageKind::Sequence };
+ // A placed PDF / Illustrator file keeps After Effects' framing and size (#3099).
+ let placed_page = kind == FootageKind::Still && is_placed_page_path(&path);
let mut footage = Footage {
path,
kind,
@@ -282,6 +297,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
// footage), and only then.
has_audio: kind == FootageKind::Audio || (kind == FootageKind::Video && f.audio_sample_rate.is_some()),
missing: !present,
+ placed_page,
sequence,
..Footage::default()
};
@@ -314,10 +330,23 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
}
}
+/// Whether `path` names a file whose page is framed as After Effects places it
+/// ([`Footage::placed_page`]): an Illustrator or PDF file, by its extension. EPS files keep
+/// their bounding box at their own size: how After Effects sizes them was not measured (#3099).
+fn is_placed_page_path(path: &str) -> bool {
+ let ext = path.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).map(|(_, ext)| ext.to_ascii_lowercase());
+ matches!(ext.as_deref(), Some("pdf" | "ai"))
+}
+
/// The name After Effects shows for a footage item that stores none: the file's name, or for a
/// sequence the name with the frame range (`shot_[0101-0103].exr`).
fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option {
if let Some(s) = item.sequence_info() {
+ // A listed sequence (Force alphabetical order) has no numbering: its first file names it.
+ // Assumption: what After Effects shows for such an item without a stored name was not sampled.
+ if let Some(first) = s.files.first() {
+ return Some(first.clone());
+ }
let w = usize::try_from(s.digits.min(16)).unwrap_or(0);
return Some(format!("{}[{:0w$}-{:0w$}]{}", s.prefix, s.start, s.end, s.suffix));
}
@@ -435,11 +464,13 @@ fn camera_light_orient(auto_oriented: bool, light: Option) -> AutoOri
}
/// The unit a layer's anchor is stored in.
-fn anchor_unit(camera_or_light: bool, text_or_shape: bool, size: (u32, u32)) -> AnchorUnit {
+fn anchor_unit(camera_or_light: bool, text_or_shape: bool, null: bool, size: (u32, u32)) -> AnchorUnit {
if camera_or_light {
AnchorUnit::PointOfInterest
} else if text_or_shape {
AnchorUnit::Pixels
+ } else if null {
+ AnchorUnit::Null(size.0, size.1)
} else {
AnchorUnit::Source(size.0, size.1)
}
@@ -479,7 +510,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
.map(|d| crate::import::text::first_paragraph(&d.raw_text))
.filter(|t| !t.trim().is_empty())
};
- let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string());
+ let name = aep_name
+ .clone()
+ .or_else(|| src.as_ref().map(|s| s.name.clone()))
+ .or_else(text_name)
+ // An empty text layer without a name is named as a new one is (#3233).
+ .unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string());
let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap, category: LossCategory, what: &str| {
cx.col.lose(category, format!("{comp_name}: {what} '{name}'"));
skipped.insert(h.id, what.to_string());
@@ -537,6 +573,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
};
let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None);
l.name = name.clone();
+ // A text layer without a stored name follows its text; a stored name is the user's (#3233).
+ if is_text {
+ l.name_follows_text = Some(aep_name.is_none());
+ }
l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None);
apply_timing(&mut l, &h, &tm, &name, &mut cx.col);
apply_switches(&mut l, &h);
@@ -555,7 +595,7 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
import_layer_styles(&layer, &mut l, &comp.global_light, &mut Ids(&mut project.next_id), &tm, &what, &mut cx.col);
// Text and shape layers store their anchor in pixels (385, 462); cameras and lights their
// point of interest.
- let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, size);
+ let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, is_null, size);
if let Some(tr) = l.props.sub_mut("transform") {
import_transform(&layer, &name, comp_size, anchor, &tm, tr, &mut cx.col);
}
@@ -681,7 +721,14 @@ fn apply_timing(l: &mut Layer, h: &LayerHeader, tm: &TimeMap, name: &str, col: &
col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch is zero or unreadable, 100 % is used"));
Rational { num: 1, den: 1 }
});
- l.stretch = stretch.to_f64().map_or(100.0, |r| r * 100.0);
+ let stored = stretch.to_f64().map_or(100.0, |r| r * 100.0);
+ // A ratio such as 1/2^31 (5e-8 %) or 2^31/1 is outside what After Effects allows (±1 % to
+ // ±9900 %); taken as stored, the layer and audio time would divide by ~0 (#3247). The in and
+ // out points keep the comp times the file stores.
+ l.stretch = effectcraft_project::limits::stretch(stored);
+ if l.stretch.to_bits() != stored.to_bits() {
+ col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch of {stored:e} % is outside ±1 % to ±9900 %, {} % is used", l.stretch));
+ }
// A stored time with a zero denominator (or a sum that overflows) has no value; it is read as 0
// and reported, so the layer's timing never changes silently.
let unreadable: Vec<&str> = [("start", h.start), ("in point", h.in_relative), ("out point", h.out_relative)]
@@ -872,6 +919,18 @@ fn resolve_parents(
mod tests {
use super::*;
+ /// #3099: an `.ai` or `.pdf` from an `.aep` is a placed page; an EPS (not measured in After
+ /// Effects) and other files are not.
+ #[test]
+ fn only_illustrator_and_pdf_files_are_placed_pages() {
+ for path in [r"C:\fixture\x\logo.ai", "/work/Logo.AI", "art.pdf", r"C:\a.b\page.PDF"] {
+ assert!(is_placed_page_path(path), "{path}");
+ }
+ for path in [r"C:\fixture\x\still.eps", "a.epsf", "a.epsi", "still.png", r"C:\dir.ai\file", "ai", ""] {
+ assert!(!is_placed_page_path(path), "{path}");
+ }
+ }
+
#[test]
fn unlisted_extensions_follow_what_the_file_stores() {
assert_eq!(footage_kind("C:/clips/a.MP4", 0, false), FootageKind::Video);
diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs
index 8409a6f7e..e53d6a9aa 100644
--- a/crates/aep/src/import/effects.rs
+++ b/crates/aep/src/import/effects.rs
@@ -34,10 +34,10 @@
//! 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, popup_key};
+use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key};
use crate::{
- CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef,
- LevelsData, MeshData, ParamKind, ParamValue,
+ CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData,
+ Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue,
};
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
@@ -76,6 +76,11 @@ pub(crate) fn import_effects<'f, 'a>(
col: &mut Collector,
) {
for fx in layer.effects() {
+ // Puppet keeps its meshes and pins in nested groups, not in `parT` (its own step).
+ if fx.match_name() == crate::import::puppet::MATCH_NAME {
+ crate::import::puppet::import_puppet(defs, &fx, cx, ids, target, col);
+ continue;
+ }
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else {
col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()));
@@ -99,7 +104,25 @@ pub(crate) fn import_effects<'f, 'a>(
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
}
let params = defs.params(&fx);
- if map.extra == Extra::Cryptomatte {
+ // Borrowed definitions (another instance's or the project copy) may not name every value
+ // record this instance has: such a record has nothing to import into, so it is reported.
+ if params.first().is_some_and(|p| p.borrowed_definition) {
+ let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect();
+ for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) {
+ col.lose(
+ LossCategory::Effects,
+ format!(
+ "{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported",
+ cx.comp,
+ cx.layer,
+ fx.match_name()
+ ),
+ );
+ }
+ }
+ if map.extra == Extra::Lumetri {
+ crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
+ } else if map.extra == Extra::Cryptomatte {
import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col);
} else {
for p in ¶ms {
@@ -112,7 +135,7 @@ pub(crate) fn import_effects<'f, 'a>(
}
/// Parameters that never carry a value to import.
-fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
+pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer)
}
@@ -131,10 +154,15 @@ pub(crate) struct PendingLayerRef {
/// Where a parameter is imported to: the effect's group, the layer parameters still to resolve
/// and the report.
-struct ParamOut<'a> {
- group: &'a mut PropGroup,
- refs: &'a mut Vec,
- col: &'a mut Collector,
+pub(crate) struct ParamOut<'a> {
+ pub group: &'a mut PropGroup,
+ pub refs: &'a mut Vec,
+ pub col: &'a mut Collector,
+}
+
+/// How a parameter is named in the report: its name and match name.
+pub(crate) fn param_label(p: &EffectParam<'_, '_>) -> String {
+ if p.name.trim().is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name.trim(), p.match_name) }
}
/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its
@@ -144,7 +172,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
if carries_nothing(p) {
return;
}
- let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
+ let label = param_label(p);
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
if p.kind == ParamKind::ArbitraryData && map.extra != Extra::None {
import_extra(fx, map.extra, p, &here, cx.size, group, col);
@@ -158,6 +186,14 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
}
return;
};
+ import_mapped(p, m, cx, (&here, &label), ParamOut { group, refs, col });
+}
+
+/// Imports parameter `p` into the property `m` names, converted by `m.conv`. `names` is
+/// `(here, label)`, the effect and the parameter for the report.
+pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) {
+ let (here, label) = names;
+ let ParamOut { group, refs, col } = out;
if p.kind != m.kind {
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, m.kind));
return;
@@ -165,7 +201,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
match m.conv {
Conv::Skip => return,
Conv::LayerRef { own } => {
- import_layer_ref(p, m.target, own, cx, (&here, &label), ParamOut { group, refs, col });
+ import_layer_ref(p, m.target, own, cx, (here, label), ParamOut { group, refs, col });
return;
}
_ => {}
@@ -270,9 +306,9 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe, default used"));
}
if let Some(map) = &popup_map {
- remap_popup_keys(prop, map, &here, &label, col);
+ remap_popup_keys(prop, map, here, label, col);
}
- report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col);
+ report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), here, label, col);
}
return;
}
@@ -308,7 +344,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
};
match converted {
Some(v) => {
- report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col);
+ report_beyond_limit(m.limit, std::iter::once(&v), here, label, col);
prop.value = v;
}
None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")),
@@ -422,7 +458,15 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {} are not imported", p.name));
}
let Some(data) = fx.arbitrary_data(&p.match_name) else {
- // No data: every value is at its default.
+ // No record: every value is at its default. A Channel Info value record without its
+ // aRbs/aRbp payload (every sampled record has it) is damaged: the payload may have chosen
+ // other channels (`Z`), so the defaults are not what After Effects renders.
+ if p.stored().is_some() && matches!(extra, Extra::Extractor | Extra::Identifier) {
+ col.lose(
+ LossCategory::DamagedData,
+ format!("{here}: the Channel Info data is missing (a value record without its data), the default channels used"),
+ );
+ }
return;
};
let mut set = |id: &str, v: f64| {
@@ -473,7 +517,61 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
Some(m) => import_mesh(&m, here, size, group, col),
None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")),
},
- Extra::None | Extra::Cryptomatte => {}
+ Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) {
+ // Each output channel reads the footage's channel of the stored name, which
+ // EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the
+ // input's own channel, EffectCraft's `(copy)` (not `A`, which EffectCraft reads from an
+ // OpenEXR footage's alpha plane, losing what earlier effects did to it).
+ Some((Ok(names), _)) => {
+ for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) {
+ if let Some(prop) = group.prop_mut(id) {
+ prop.value = Value::Str(name);
+ }
+ }
+ }
+ Some((Err(k), c)) => {
+ let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect();
+ col.lose(
+ LossCategory::Effects,
+ format!(
+ "{here}: the channels {} are not imported (record {} is of an unknown kind or names no channel), R, G, B, A used",
+ names.join(", "),
+ k.saturating_add(1)
+ ),
+ );
+ }
+ None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")),
+ },
+ Extra::Identifier => match crate::identifier_channel_type(data) {
+ // No channel chosen (the type's label): After Effects lists only the footage's integer
+ // (UINT) channels and, with none chosen, leaves the layer unchanged, where EffectCraft
+ // draws the footage's object ID channel.
+ Some("Object ID") => {
+ if let Some(prop) = group.prop_mut("channelType") {
+ prop.value = Value::Enum(0);
+ }
+ col.lose(
+ LossCategory::Effects,
+ format!("{here}: no ID channel is chosen (Channel Info), so After Effects leaves the layer unchanged; EffectCraft draws the footage's object ID channel"),
+ );
+ }
+ // A chosen channel, by name. EffectCraft's IDentifier has no channel of its own: it
+ // reads the footage's first `ObjectID` (`MaterialID`) channel, names compared without
+ // regard to case, so a channel of that name is the one After Effects reads.
+ Some(name) => match IDENTIFIER_OWN.iter().position(|own| own.eq_ignore_ascii_case(name.trim())) {
+ Some(kind) => {
+ if let Some(prop) = group.prop_mut("channelType") {
+ prop.value = Value::Enum(u32::try_from(kind).unwrap_or(0));
+ }
+ }
+ None => col.lose(
+ LossCategory::Effects,
+ format!("{here}: the ID channel '{name}' (Channel Info) is not imported; EffectCraft reads the footage's object ID channel (ObjectID, object_id, objectId, id)"),
+ ),
+ },
+ None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")),
+ },
+ Extra::None | Extra::Lumetri | Extra::Cryptomatte => {}
}
}
@@ -614,6 +712,10 @@ fn crypto_output(p: &EffectParam<'_, '_>, here: &str, label: &str, col: &mut Col
}
}
+/// The channels EffectCraft's IDentifier reads first for its Channel Type Object ID and Material
+/// ID (`AuxChannels::object_id`, `material_id`).
+const IDENTIFIER_OWN: [&str; 2] = ["ObjectID", "MaterialID"];
+
/// Curves' EffectCraft parameters, in the stored order (RGB, Red, Green, Blue, Alpha).
const CURVE_IDS: [&str; 5] = ["rgb", "red", "green", "blue", "alpha"];
diff --git a/crates/aep/src/import/lumetri.rs b/crates/aep/src/import/lumetri.rs
new file mode 100644
index 000000000..5b64d3832
--- /dev/null
+++ b/crates/aep/src/import/lumetri.rs
@@ -0,0 +1,803 @@
+//! Lumetri Color (`ADBE Lumetri`) to EffectCraft's Lumetri Color (`ec.color.lumetri`).
+//!
+//! The parameters are found by their match name (`ADBE Lumetri-0011` is Exposure), not by their
+//! place in `parT`: After Effects has added parameters in the middle of the list over the
+//! versions (`-0127` ... `-0130` sit between older ones), and several sliders share a valid range
+//! (Contrast, Highlights, Shadows, Whites and Blacks are all -150..150), so a shifted index
+//! would map silently to the wrong control. Evidence: the 1200 samples (After Effects 2026,
+//! `docs/aep-format.md` 3.7).
+//!
+//! - **Values in any record state.** Lumetri writes the values of its hidden controls (High
+//! Dynamic Range, HDR Specular, the second Look popup, ...) in records in the default state
+//! (`tdsb` 3); After Effects reads them back as set, so every record counts here
+//! ([`EffectParam::recorded_value`]).
+//! - **Section switches.** Each section (Basic Correction, Creative, Curves, RGB Curves, the five
+//! hue / saturation curves, Color Wheels, HSL Secondary, Vignette) has an on / off checkbox in
+//! After Effects; EffectCraft's Lumetri has none. A section that is off does nothing in After
+//! Effects (1200 `lumetri-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`),
+//! so its values are not imported (the effect looks the same) and are listed in the report.
+//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it). Curves are a
+//! curve type, a point count and points as `f64` x, y ([`curve_points`]).
+//! - The RGB curve (`-0039`) holds four curves; the first is the master (white) curve (1200
+//! `lumetri-60-preset-curves-rgb`), imported. The other three are reported when moved: no
+//! sample tells which is which channel.
+//! - Hue vs Sat (`-0042`): y is an offset from neutral, 1 doubling the saturation (`-61`), so
+//! EffectCraft's y is `0.5 + y / 2`. The other four hue / saturation curves have no sample
+//! of their scale and are reported when moved.
+//! - Color Wheels (`-0047`): Shadows, Midtones, Highlights and a fourth wheel, each hue,
+//! brightness slider, distance ([`import_wheels`], `-62`).
+//! - The HSL Secondary key ([`HslKey`]) is imported; Split Toning and the correction wheels are
+//! reported when they are not the default (not sampled).
+//! - **LUTs and looks.** The Input LUT and the Look are files After Effects names (the Look's name
+//! is UTF-16 text in `-0096`, the popups only index a list of the installed ones); they are not
+//! in the project, so a set one is reported by name and EffectCraft's popup stays None.
+//! - **Eyedropper colours** (White Balance, the hue curve selectors, the key's Set / Add /
+//! Remove colours) only set other controls in After Effects; they are reported when set.
+//! - The `Blob` (`-0001`, an XML copy of the grade After Effects writes when a Look changes and does
+//! not keep current: 1200 `-01-set-all` holds the defaults of controls set after the Look) and
+//! the Auto Tone analysis (`-0121`, `-0122`) are derived data and not read.
+
+use crate::effect_map::{Conv, ParamMap};
+use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue};
+use effectcraft_keyframe::Value;
+
+use crate::import::effects::{LayerCtx, ParamOut, carries_nothing, import_mapped, param_label};
+use crate::import::report::LossCategory;
+use ParamKind as K;
+
+/// After Effects' Show Mask styles (`-0075`, `pdnm`), EffectCraft's in the same order.
+pub const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
+
+/// A part of the effect with its own on / off switch.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Section {
+ /// No switch (High Dynamic Range and the controls outside the sections).
+ Always,
+ BasicCorrection,
+ Creative,
+ RgbCurves,
+ /// The five hue / saturation curves (0 Hue vs Sat ... 4 Sat vs Sat).
+ HueSat(u8),
+ ColorWheels,
+ HslSecondary,
+ Vignette,
+ SpeedGradeCustom,
+}
+
+impl Section {
+ fn name(self) -> &'static str {
+ match self {
+ Section::Always => "",
+ Section::BasicCorrection => "Basic Correction",
+ Section::Creative => "Creative",
+ Section::RgbCurves => "RGB Curves",
+ Section::HueSat(0) => "Hue vs Sat",
+ Section::HueSat(1) => "Hue vs Hue",
+ Section::HueSat(2) => "Hue vs Luma",
+ Section::HueSat(3) => "Luma vs Sat",
+ Section::HueSat(_) => "Sat vs Sat",
+ Section::ColorWheels => "Color Wheels",
+ Section::HslSecondary => "HSL Secondary",
+ Section::Vignette => "Vignette",
+ Section::SpeedGradeCustom => "SpeedGrade Custom",
+ }
+ }
+}
+
+/// Arbitrary data the importer reads.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Data {
+ /// The four RGB curves (4 x 520 bytes).
+ RgbCurves,
+ /// One hue / saturation curve (520 bytes); the EffectCraft parameter when the scale of its y
+ /// is known (Hue vs Sat).
+ HueSatCurve(Option<&'static str>),
+ /// Color Wheels (4 x 3 `f64`, each wheel 0, 0.5, 0 by default).
+ Wheels,
+ /// HSL Secondary's correction wheels (8 x 3 `f64`, the same default).
+ CorrectionWheels,
+ /// Split Toning (32 zero bytes by default).
+ SplitToning,
+ /// HSL Secondary's key ([`HslKey`]).
+ HslKey,
+ /// Data without a counterpart, empty by default (`fe fe` or zero bytes).
+ Other,
+}
+
+/// What a Lumetri parameter is.
+#[derive(Clone, Copy, Debug)]
+enum Role {
+ /// An EffectCraft parameter and the conversion.
+ To(&'static str, Conv),
+ /// The on / off switch of a section.
+ Switch,
+ /// No counterpart: reported when not at the default.
+ Missing,
+ /// A checkbox without counterpart whose `pard` default is not reliable: reported when a
+ /// record says on. Legacy Tone And Saturation (`-0097`) is off in a fresh instance, but its
+ /// `pard` default reads off in 1200 as saved by the script and on after the project was
+ /// opened and saved again (`versions/1200-lumetri-26`, `-25x`, `-24x`).
+ MissingWhenOn,
+ /// An eyedropper colour: reported when set.
+ Eyedropper,
+ /// The Input LUT (`lut` true) or the Look: a popup, a name or an asset.
+ Lut {
+ lut: bool,
+ },
+ /// The Look's colour space (its popup and the space's id): goes with the Look.
+ LookSpace,
+ Data(Data),
+ /// Derived data, not read.
+ Derived,
+}
+
+struct Row {
+ suffix: u16,
+ section: Section,
+ kind: Option,
+ role: Role,
+}
+
+const fn row(suffix: u16, section: Section, kind: Option, role: Role) -> Row {
+ Row { suffix, section, kind, role }
+}
+
+const fn to(suffix: u16, section: Section, kind: ParamKind, target: &'static str, conv: Conv) -> Row {
+ row(suffix, section, Some(kind), Role::To(target, conv))
+}
+
+const fn plain(valid_max: f64) -> Conv {
+ Conv::Plain { valid_max }
+}
+
+use Section as S;
+
+/// Every Lumetri parameter by match-name suffix (1200: `dump.props` and the `pard` of
+/// `lumetri-00-defaults`). The valid maxima are After Effects 2026's; the stored numbers are the
+/// values After Effects shows (read back in every variant).
+const ROWS: &[Row] = &[
+ to(62, S::Always, K::Checkbox, "highDynamicRange", Conv::Checkbox),
+ // Basic Correction: 3 the switch; 4 / 95 the Input LUT's name data, 5 / 127 its popups.
+ row(3, S::BasicCorrection, Some(K::Checkbox), Role::Switch),
+ row(4, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
+ row(95, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
+ row(5, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
+ row(127, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
+ to(63, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrWhite", plain(1000.0)),
+ // The Auto tone's Intensity (next to the Auto and Reset buttons 120, 119).
+ row(123, S::BasicCorrection, Some(K::FloatSlider), Role::Missing),
+ row(94, S::BasicCorrection, Some(K::Color), Role::Eyedropper),
+ to(7, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/temperature", plain(300.0)),
+ to(8, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/tint", plain(300.0)),
+ to(20, S::BasicCorrection, K::FloatSlider, "basicCorrection/saturation", plain(300.0)),
+ to(11, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/exposure", plain(7.0)),
+ to(12, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/contrast", plain(150.0)),
+ to(13, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/highlights", plain(150.0)),
+ to(14, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/shadows", plain(150.0)),
+ to(15, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/whites", plain(150.0)),
+ to(16, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/blacks", plain(150.0)),
+ to(64, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrSpecular", plain(150.0)),
+ // Creative: 23 the switch; 24 / 96 the Look's name, 25 / 128 its popups, 129 / 130 its colour
+ // space.
+ row(23, S::Creative, Some(K::Checkbox), Role::Switch),
+ row(24, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
+ row(96, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
+ row(25, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
+ row(128, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
+ row(129, S::Creative, Some(K::ArbitraryData), Role::LookSpace),
+ row(130, S::Creative, Some(K::Popup), Role::LookSpace),
+ to(26, S::Creative, K::FloatSlider, "creative/lookIntensity", plain(200.0)),
+ to(28, S::Creative, K::FloatSlider, "creative/adjustments/fadedFilm", plain(150.0)),
+ to(29, S::Creative, K::FloatSlider, "creative/adjustments/sharpen", plain(100.0)),
+ to(30, S::Creative, K::FloatSlider, "creative/adjustments/vibrance", plain(100.0)),
+ to(31, S::Creative, K::FloatSlider, "creative/adjustments/creativeSaturation", plain(300.0)),
+ row(32, S::Creative, Some(K::ArbitraryData), Role::Data(Data::SplitToning)),
+ to(33, S::Creative, K::FloatSlider, "creative/adjustments/tintBalance", plain(150.0)),
+ // Curves: 37 switches all curves, 118 the RGB curves, 104 ... 111 each hue / saturation curve.
+ row(37, S::RgbCurves, Some(K::Checkbox), Role::Switch),
+ row(118, S::RgbCurves, Some(K::Checkbox), Role::Switch),
+ row(65, S::RgbCurves, Some(K::FloatSlider), Role::Missing),
+ row(39, S::RgbCurves, Some(K::ArbitraryData), Role::Data(Data::RgbCurves)),
+ row(104, S::HueSat(0), Some(K::Checkbox), Role::Switch),
+ row(113, S::HueSat(0), Some(K::Color), Role::Eyedropper),
+ row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(Some("curves/hueSaturationCurves/hueVsSat")))),
+ row(105, S::HueSat(1), Some(K::Checkbox), Role::Switch),
+ row(114, S::HueSat(1), Some(K::Color), Role::Eyedropper),
+ row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
+ row(107, S::HueSat(2), Some(K::Checkbox), Role::Switch),
+ row(115, S::HueSat(2), Some(K::Color), Role::Eyedropper),
+ row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
+ row(109, S::HueSat(3), Some(K::Checkbox), Role::Switch),
+ row(116, S::HueSat(3), Some(K::Color), Role::Eyedropper),
+ row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
+ row(111, S::HueSat(4), Some(K::Checkbox), Role::Switch),
+ row(117, S::HueSat(4), Some(K::Color), Role::Eyedropper),
+ row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
+ // Color Wheels: 46 the switch, 102 Face Detection (on by default), 66 HDR White, 47 the wheels.
+ row(46, S::ColorWheels, Some(K::Checkbox), Role::Switch),
+ row(102, S::ColorWheels, Some(K::Checkbox), Role::Missing),
+ row(66, S::ColorWheels, Some(K::FloatSlider), Role::Missing),
+ row(47, S::ColorWheels, Some(K::ArbitraryData), Role::Data(Data::Wheels)),
+ // HSL Secondary: 68 the switch; the key (70-76), Refine (81, 82), Correction (85-90).
+ row(68, S::HslSecondary, Some(K::Checkbox), Role::Switch),
+ row(70, S::HslSecondary, Some(K::Color), Role::Eyedropper),
+ row(71, S::HslSecondary, Some(K::Color), Role::Eyedropper),
+ row(72, S::HslSecondary, Some(K::Color), Role::Eyedropper),
+ row(73, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::HslKey)),
+ to(74, S::HslSecondary, K::Checkbox, "hslSecondary/key/showMask", Conv::Checkbox),
+ to(75, S::HslSecondary, K::Popup, "hslSecondary/key/maskStyle", Conv::Popup { default: 1, items: MASK_STYLE }),
+ to(76, S::HslSecondary, K::Checkbox, "hslSecondary/key/invertMask", Conv::Checkbox),
+ to(81, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/denoise", plain(100.0)),
+ to(82, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/blur", plain(1000.0)),
+ row(85, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::CorrectionWheels)),
+ // The correction wheel's mode (4 zero bytes by default).
+ row(93, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::Other)),
+ to(86, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/temperature", plain(300.0)),
+ to(87, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/tint", plain(300.0)),
+ to(88, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/contrast", plain(150.0)),
+ to(89, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/sharpen", plain(100.0)),
+ to(90, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/saturation", plain(300.0)),
+ // Vignette: 50 the switch.
+ row(50, S::Vignette, Some(K::Checkbox), Role::Switch),
+ to(51, S::Vignette, K::FloatSlider, "vignette/vignetteAmount", plain(5.0)),
+ to(52, S::Vignette, K::FloatSlider, "vignette/vignetteMidpoint", plain(100.0)),
+ to(53, S::Vignette, K::FloatSlider, "vignette/vignetteRoundness", plain(100.0)),
+ to(54, S::Vignette, K::FloatSlider, "vignette/vignetteFeather", plain(100.0)),
+ // SpeedGrade Custom: 57 Custom Layer (on by default), 60 its data.
+ row(57, S::SpeedGradeCustom, Some(K::Checkbox), Role::Switch),
+ row(60, S::SpeedGradeCustom, Some(K::ArbitraryData), Role::Data(Data::Other)),
+ // Outside the sections: Legacy Tone And Saturation, the embedded LUTs, Auto Tone, Auto Color,
+ // the LUT and Look assets.
+ row(97, S::Always, Some(K::Checkbox), Role::MissingWhenOn),
+ row(98, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
+ row(99, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
+ row(100, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
+ row(124, S::Always, Some(K::Checkbox), Role::Missing),
+ row(125, S::Always, Some(K::ArbitraryData), Role::Lut { lut: true }),
+ row(126, S::Always, Some(K::ArbitraryData), Role::Lut { lut: false }),
+ row(1, S::Always, Some(K::ArbitraryData), Role::Derived),
+ row(121, S::Always, Some(K::FloatSlider), Role::Derived),
+ row(122, S::Always, Some(K::ArbitraryData), Role::Derived),
+];
+
+/// The names After Effects shows for the parameters whose `pard` name is blank (`dump.props`).
+fn blank_name(suffix: u16) -> Option<&'static str> {
+ Some(match suffix {
+ 62 => "High Dynamic Range",
+ 3 | 23 | 37 | 118 | 104 | 105 | 107 | 109 | 111 | 46 | 68 | 50 => "Active",
+ 4 | 95 => "Input LUT data",
+ 24 | 96 => "Look name",
+ 102 => "Face Detection",
+ 74 => "Show Mask",
+ 75 => "Mask style",
+ 76 => "Invert Mask",
+ 97 => "Legacy Tone And Saturation",
+ 124 => "Auto Color Active",
+ _ => return None,
+ })
+}
+
+/// The parameter's name for the report: its own, else the one After Effects shows.
+fn name_of(p: &EffectParam<'_, '_>) -> String {
+ match (p.name.trim(), suffix(&p.match_name).and_then(blank_name)) {
+ ("", Some(n)) => n.to_string(),
+ ("", None) => p.match_name.clone(),
+ (n, _) => n.to_string(),
+ }
+}
+
+/// The match-name suffix of a Lumetri parameter (`ADBE Lumetri-0011` is 11).
+fn suffix(match_name: &str) -> Option {
+ match_name.strip_prefix("ADBE Lumetri-")?.parse().ok()
+}
+
+/// The parameter's value: the recorded one, else the `pard` default.
+fn value_of(p: &EffectParam<'_, '_>) -> Option {
+ p.recorded_value().or_else(|| p.pard_default())
+}
+
+/// True when the parameter has keyframes, an expression, or a recorded value other than its
+/// default (a record without a readable default counts as set).
+fn is_set(p: &EffectParam<'_, '_>) -> bool {
+ if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
+ return true;
+ }
+ match (p.recorded_value(), p.pard_default()) {
+ (Some(v), Some(d)) => !same(v, d),
+ (Some(_), None) => true,
+ (None, _) => false,
+ }
+}
+
+/// Equal within the float noise of a stored `f64` / `f32`.
+fn same(a: ParamValue, b: ParamValue) -> bool {
+ let close = |x: f64, y: f64| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0);
+ match (a, b) {
+ (ParamValue::Number(x), ParamValue::Number(y)) => close(x, y),
+ (ParamValue::Argb(x), ParamValue::Argb(y)) => x.iter().zip(y).skip(1).all(|(x, y)| close(*x, y)),
+ _ => a == b,
+ }
+}
+
+/// A value as the report shows it.
+fn shown_value(v: Option) -> String {
+ match v {
+ Some(ParamValue::Number(n)) => {
+ let s = format!("{n:.3}");
+ s.trim_end_matches('0').trim_end_matches('.').to_string()
+ }
+ Some(ParamValue::Bool(b)) => if b { "on" } else { "off" }.to_string(),
+ Some(ParamValue::Index(i)) => format!("item {i}"),
+ Some(ParamValue::Argb([_, r, g, b])) => format!("RGB {:.3} {:.3} {:.3}", r, g, b),
+ Some(other) => format!("{other:?}"),
+ None => "set".to_string(),
+ }
+}
+
+/// Empty data: absent, `fe fe` (no text, no asset) or only zero bytes.
+fn is_empty_data(d: Option<&[u8]>) -> bool {
+ match d {
+ None => true,
+ Some(d) => d == [0xfe, 0xfe] || d.iter().all(|b| *b == 0),
+ }
+}
+
+fn le_u32(d: &[u8], at: usize) -> Option {
+ Some(u32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
+}
+
+fn le_f32(d: &[u8], at: usize) -> Option {
+ Some(f32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
+}
+
+fn le_f64(d: &[u8], at: usize) -> Option {
+ Some(f64::from_le_bytes(*d.get(at..at.checked_add(8)?)?.first_chunk::<8>()?))
+}
+
+/// A UTF-16LE text up to its NUL (the Look's name, `-0096`: `SL BLUE ICE` in 1200 `-01-set-all`);
+/// `None` for empty data.
+fn utf16_text(d: &[u8]) -> Option {
+ if is_empty_data(Some(d)) {
+ return None;
+ }
+ let units: Vec = d.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).collect();
+ let s = String::from_utf16_lossy(&units);
+ let s = s.trim();
+ (!s.is_empty()).then(|| s.to_string())
+}
+
+/// A Lumetri curve (520 bytes): `i32` curve type at 0 (3 for the hue curves, which wrap; 2 for
+/// Luma vs Sat and Sat vs Sat; 0 for an RGB curve), the `u32` point count at 4, then the points
+/// as little-endian `f64` pairs x, y from byte 8 (at most 32), sorted by x. Bytes after the
+/// counted points are stale (a fresh hue curve has count 0 and old points behind it). An RGB
+/// curve's x and y are 0..1 (2 points 0,0 and 1,1 by default); a hue / saturation curve's x is
+/// the hue (0 red, 1/3 green) or the luma / saturation, 0..1, and y its offset from neutral.
+/// `None` when the count does not fit.
+fn curve_points(d: &[u8]) -> Option> {
+ let n = usize::try_from(le_u32(d, 4)?).ok()?;
+ if n > d.len().saturating_sub(8) / 16 {
+ return None;
+ }
+ (0..n).map(|i| Some((le_f64(d, 8 + 16 * i)?, le_f64(d, 16 + 16 * i)?))).collect()
+}
+
+/// HSL Secondary's key (`-0073`, 40 bytes): a flag byte (1 once a key colour was picked, 0 for a
+/// fresh effect), then hue, saturation and lightness, each three little-endian `f32` (centre,
+/// range, tolerance; fractions of the full hue circle / of 0..1) and an enabled byte. A fresh effect
+/// stores flag 0 and centre 0.5, range 0, tolerance 0, enabled for each (1200 `-00-defaults`);
+/// After Effects' own copy in `-0001` names the same fields (`middleHLS`, `rangeHLS`,
+/// `toleranceHLS`, `enableHLS`, hue in degrees: centre 180). Picking Set color RGB 0.02 0.55 0.85
+/// gives hue centre 0.5605 (that colour's hue, 201.8 degrees) and lightness centre 0.435 (its
+/// HSL lightness) with range 0.05 and tolerance 0.1 (`-01-set-all`).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct HslKey {
+ pub picked: bool,
+ /// Hue, saturation, lightness: centre, range, tolerance, enabled.
+ pub channels: [(f32, f32, f32, bool); 3],
+}
+
+impl HslKey {
+ pub fn parse(d: &[u8]) -> Option {
+ if d.len() != 40 {
+ return None;
+ }
+ let picked = match d.first()? {
+ 0 => false,
+ 1 => true,
+ _ => return None,
+ };
+ let ch = |at: usize| -> Option<(f32, f32, f32, bool)> {
+ let (c, r, t) = (le_f32(d, at)?, le_f32(d, at + 4)?, le_f32(d, at + 8)?);
+ let on = match d.get(at + 12)? {
+ 0 => false,
+ 1 => true,
+ _ => return None,
+ };
+ (c.is_finite() && r.is_finite() && t.is_finite()).then_some((c, r, t, on))
+ };
+ Some(HslKey { picked, channels: [ch(1)?, ch(14)?, ch(27)?] })
+ }
+
+ fn is_default(&self) -> bool {
+ !self.picked && self.channels.iter().all(|c| *c == (0.5, 0.0, 0.0, true))
+ }
+}
+
+/// The colour wheel data: triples of `f64`, each wheel `0, 0.5, 0` by default.
+fn wheels_default(d: &[u8], wheels: usize) -> bool {
+ d.len() == wheels * 24 && (0..wheels).all(|w| le_f64(d, w * 24) == Some(0.0) && le_f64(d, w * 24 + 8) == Some(0.5) && le_f64(d, w * 24 + 16) == Some(0.0))
+}
+
+/// Is the data of `data` at After Effects' default (as far as it can be told)?
+fn data_default(data: Data, d: Option<&[u8]>) -> bool {
+ let Some(d) = d else { return true };
+ match data {
+ Data::RgbCurves => {
+ d.len() == 4 * 520 && d.as_chunks::<520>().0.iter().all(|c| curve_points(c).is_some_and(|pts| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9)))
+ }
+ Data::HueSatCurve(_) => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)),
+ Data::Wheels => wheels_default(d, 4),
+ Data::CorrectionWheels => wheels_default(d, 8),
+ Data::SplitToning | Data::Other => is_empty_data(Some(d)),
+ Data::HslKey => HslKey::parse(d).is_some_and(|k| k.is_default()),
+ }
+}
+
+/// What the Input LUT or the Look holds: names, a popup item, an asset.
+#[derive(Default)]
+struct LutState {
+ names: Vec,
+ item: Option,
+ asset: bool,
+}
+
+/// How a set parameter of a section that does nothing is listed in the report; `None` when it is
+/// at its default.
+fn off_text(p: &EffectParam<'_, '_>, role: Role, data: Option<&[u8]>) -> Option {
+ match role {
+ Role::Data(d) => (!data_default(d, data)).then(|| name_of(p)),
+ // A LUT by its name (the popups index the same choice).
+ Role::Lut { lut } if p.kind == K::ArbitraryData => {
+ let what = if lut { "Input LUT" } else { "Look" };
+ data.and_then(utf16_text).map(|n| format!("{what} '{n}'")).or_else(|| (!is_empty_data(data)).then(|| what.to_string()))
+ }
+ // The Look's colour space goes with the Look.
+ Role::Lut { .. } | Role::LookSpace | Role::Derived => None,
+ _ => is_set(p).then(|| format!("{} {}", name_of(p), shown_value(p.recorded_value()))),
+ }
+}
+
+/// Notes what an Input LUT or Look parameter holds: a popup item, an asset or a name.
+fn note_lut(p: &EffectParam<'_, '_>, suffix: u16, data: Option<&[u8]>, st: &mut LutState) {
+ if p.kind == K::Popup {
+ // The popup indexes the LUTs installed with After Effects; item 1 is None.
+ if let Some(ParamValue::Index(i)) = p.recorded_value()
+ && i > 1
+ {
+ st.item = usize::try_from(i).ok().and_then(|i| p.items.as_ref()?.get(i - 1).cloned()).or(Some(format!("item {i}")));
+ }
+ } else if suffix == 125 || suffix == 126 {
+ st.asset |= !is_empty_data(data);
+ } else if let Some(name) = data.and_then(utf16_text)
+ && !st.names.contains(&name)
+ {
+ st.names.push(name);
+ }
+}
+
+/// Imports a Lumetri Color instance's parameters into `out.group` and reports what does not arrive.
+pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) {
+ let ParamOut { group, refs, col } = out;
+ let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
+ let find = |s: u16| params.iter().find(|p| suffix(&p.match_name) == Some(s));
+ // The switches: on unless a record says off. Keys or an expression on a switch are not
+ // imported (the static value counts).
+ let switch_on = |s: u16| -> bool {
+ match find(s) {
+ Some(p) => !matches!(value_of(p), Some(ParamValue::Bool(false))),
+ None => true,
+ }
+ };
+ // HSL Secondary without a picked key colour selects nothing: After Effects applies none of
+ // its correction (1200 `-16-sec-correction`, `-15-sec-refine` render as `-00-defaults`);
+ // EffectCraft's default key is the whole image, so the values are left out too.
+ // Unreadable or missing key data does not count as unpicked (it is reported or older).
+ let key_picked = find(73).and_then(|p| fx.arbitrary_data(&p.match_name)).and_then(HslKey::parse).is_none_or(|k| k.picked);
+ let section_on = |sec: Section| -> bool {
+ match sec {
+ Section::Always => true,
+ Section::BasicCorrection => switch_on(3),
+ Section::Creative => switch_on(23),
+ Section::RgbCurves => switch_on(37) && switch_on(118),
+ Section::HueSat(i) => switch_on(37) && switch_on([104, 105, 107, 109, 111].get(usize::from(i)).copied().unwrap_or(104)),
+ Section::ColorWheels => switch_on(46),
+ Section::HslSecondary => switch_on(68) && key_picked,
+ Section::Vignette => switch_on(50),
+ // Custom Layer off is itself reported (a switch without counterpart).
+ Section::SpeedGradeCustom => true,
+ }
+ };
+ let mut off: Vec<(Section, Vec)> = Vec::new();
+ let mut eyedroppers: Vec = Vec::new();
+ let mut luts = [LutState::default(), LutState::default()];
+ for p in params {
+ if carries_nothing(p) || p.match_name == "ADBE Effect Built In Params" {
+ continue;
+ }
+ let label = if p.name.trim().is_empty() { format!("{} ({})", name_of(p), p.match_name) } else { param_label(p) };
+ let Some(row) = suffix(&p.match_name).and_then(|s| ROWS.iter().find(|r| r.suffix == s)) else {
+ // A parameter this table does not know (a newer version): lost when set.
+ if p.stored().is_some()
+ && (p.kind != K::ArbitraryData && is_set(p) || p.kind == K::ArbitraryData && !is_empty_data(fx.arbitrary_data(&p.match_name)))
+ {
+ col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
+ }
+ continue;
+ };
+ if row.kind.is_some_and(|k| k != p.kind) {
+ if p.stored().is_some() {
+ col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, row.kind));
+ }
+ continue;
+ }
+ let data = || fx.arbitrary_data(&p.match_name);
+ // A switched-off section: its set values are listed, not imported.
+ if !matches!(row.role, Role::Switch) && !section_on(row.section) {
+ let text = off_text(p, row.role, data());
+ if let Some(text) = text {
+ match off.iter_mut().find(|(s, _)| *s == row.section) {
+ Some((_, list)) => {
+ if !list.contains(&text) {
+ list.push(text);
+ }
+ }
+ None => off.push((row.section, vec![text])),
+ }
+ }
+ continue;
+ }
+ match row.role {
+ Role::To(target, conv) => {
+ let m = ParamMap { index: p.index, kind: p.kind, target, conv, limit: None };
+ import_mapped(p, &m, cx, (&here, &label), ParamOut { group: &mut *group, refs: &mut *refs, col: &mut *col });
+ }
+ Role::Switch => {
+ if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
+ col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on the switch {label} are not imported"));
+ }
+ if row.section == Section::SpeedGradeCustom && !switch_on(row.suffix) {
+ col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart"));
+ }
+ }
+ Role::MissingWhenOn => {
+ if p.is_animated() || matches!(p.recorded_value(), Some(ParamValue::Bool(true))) {
+ col.lose(LossCategory::Effects, format!("{here}: parameter {label} is on and has no counterpart, not imported"));
+ }
+ }
+ Role::Missing => {
+ if is_set(p) {
+ col.lose(
+ LossCategory::Effects,
+ format!("{here}: parameter {label} is {} and has no counterpart, not imported", shown_value(p.recorded_value())),
+ );
+ }
+ }
+ Role::Eyedropper => {
+ if is_set(p) {
+ eyedroppers.push(name_of(p));
+ }
+ }
+ Role::Lut { lut } => note_lut(p, row.suffix, data(), &mut luts[usize::from(!lut)]),
+ // The Look's colour space only applies to a Look, which is reported.
+ Role::LookSpace => {}
+ Role::Data(_) if p.stored().is_some() && data().is_none() => {
+ // The value record is there but its aRbs/aRbp payload is not: damaged, not the
+ // default (a parameter without a record is the default).
+ col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is missing, not imported (the default used)"));
+ }
+ Role::Data(Data::HslKey) => import_hsl_key(data(), &here, &mut *group, &mut *col),
+ Role::Data(d) => {
+ if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
+ col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported"));
+ }
+ if !data_default(d, data()) {
+ let why = match d {
+ Data::RgbCurves => {
+ import_rgb_curves(data(), (&here, &label), &mut *group, &mut *col);
+ continue;
+ }
+ Data::HueSatCurve(Some(target)) => {
+ import_hue_sat_curve(data(), target, (&here, &label), &mut *group, &mut *col);
+ continue;
+ }
+ Data::Wheels => {
+ import_wheels(data(), (&here, &label), &mut *group, &mut *col);
+ continue;
+ }
+ Data::HueSatCurve(None) => "the curve's points are moved; no sample tells their scale",
+ Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)",
+ Data::HslKey | Data::Other => "no counterpart",
+ };
+ col.lose(LossCategory::Effects, format!("{here}: parameter {label} is not imported ({why})"));
+ }
+ }
+ Role::Derived => {}
+ }
+ }
+ for (sec, list) in off {
+ let why = if sec == Section::HslSecondary && switch_on(68) {
+ "has no key colour picked (After Effects applies it to nothing)"
+ } else {
+ "is switched off (EffectCraft's Lumetri has no section switch)"
+ };
+ col.lose(LossCategory::Effects, format!("{here}: section {} {why}, its values are not imported: {}", sec.name(), list.join(", ")));
+ }
+ if !eyedroppers.is_empty() {
+ col.lose(LossCategory::Effects, format!("{here}: the eyedropper colours {} are not imported (they only set other controls)", eyedroppers.join(", ")));
+ }
+ for (st, what) in luts.iter().zip(["Input LUT", "Look"]) {
+ let name = st.names.first().cloned().or_else(|| st.item.clone());
+ if name.is_some() || st.asset {
+ let name = name.map(|n| format!(" '{n}'")).unwrap_or_default();
+ col.lose(
+ LossCategory::Effects,
+ format!("{here}: the {what}{name} is not imported (the LUT file is not in the project; EffectCraft's {what} stays None)"),
+ );
+ }
+ }
+}
+
+/// HSL Secondary's key as EffectCraft's hue centre / range / softness (degrees) and saturation and
+/// lightness bands (percent): range is taken as the half width, tolerance as the softness. A key
+/// that was never picked keeps EffectCraft's default (the whole image).
+fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
+ let Some(d) = d else { return };
+ let Some(key) = HslKey::parse(d) else {
+ col.lose(LossCategory::DamagedData, format!("{here}: the HSL Secondary key data is unreadable, the default key used"));
+ return;
+ };
+ if !key.picked {
+ if !key.is_default() {
+ col.lose(LossCategory::Effects, format!("{here}: the HSL Secondary key holds values without a picked colour, not imported"));
+ }
+ return;
+ }
+ let mut set = |id: &str, v: Value| {
+ if let Some(prop) = group.prop_mut(&format!("hslSecondary/key/{id}")) {
+ prop.value = v;
+ }
+ };
+ let [h, s, l] = key.channels.map(|(c, r, t, on)| (f64::from(c), f64::from(r), f64::from(t), on));
+ set("hueEnabled", Value::Bool(h.3));
+ set("hueCenter", Value::Scalar((h.0 * 360.0).rem_euclid(360.0)));
+ set("hueRange", Value::Scalar((h.1 * 360.0).clamp(0.0, 180.0)));
+ set("hueSoftness", Value::Scalar((h.2 * 360.0).clamp(0.0, 180.0)));
+ for ((c, r, t, on), id) in [(s.0, s.1, s.2, s.3), (l.0, l.1, l.2, l.3)].into_iter().zip(["saturation", "lightness"]) {
+ set(&format!("{id}Enabled"), Value::Bool(on));
+ set(&format!("{id}Low"), Value::Scalar(((c - r) * 100.0).clamp(0.0, 100.0)));
+ set(&format!("{id}High"), Value::Scalar(((c + r) * 100.0).clamp(0.0, 100.0)));
+ set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0)));
+ }
+}
+
+/// A parameter of `group` set to `v` (ids the spec does not have are skipped).
+fn set_prop(group: &mut effectcraft_project::PropGroup, id: &str, v: Value) {
+ if let Some(prop) = group.prop_mut(id) {
+ prop.value = v;
+ }
+}
+
+/// Curve points as EffectCraft's `x,y x,y ...` text, x and y clamped to 0..1; `None` when a value
+/// is not finite.
+fn points_text(pts: impl IntoIterator- ) -> Option
{
+ let mut out: Vec = Vec::new();
+ for (x, y) in pts {
+ if !x.is_finite() || !y.is_finite() {
+ return None;
+ }
+ out.push(format!("{},{}", x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
+ }
+ Some(out.join(" "))
+}
+
+/// The RGB curve (`-0039`, four curves of 520 bytes). The first is the master curve (1200
+/// `lumetri-60-preset-curves-rgb`: its midpoint raised to 0.787 lifts R, G and B alike) and
+/// becomes `curves/rgbCurves/master`. The other three are the channel curves, but no sample moves
+/// one, so which is red, green or blue is not known: a moved one is reported.
+fn import_rgb_curves(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
+ let Some(d) = d else { return };
+ let curves: Option>> = if d.len() == 4 * 520 { d.as_chunks::<520>().0.iter().map(|c| curve_points(c)).collect() } else { None };
+ let Some(curves) = curves else {
+ col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curves are not imported"));
+ return;
+ };
+ let identity = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9);
+ if let Some(master) = curves.first().filter(|c| !identity(c)) {
+ match points_text(master.iter().copied()) {
+ Some(text) => set_prop(group, "curves/rgbCurves/master", Value::Str(text)),
+ None => col.lose(LossCategory::DamagedData, format!("{here}: the master curve of {label} has unreadable points, not imported")),
+ }
+ }
+ let moved = curves.iter().skip(1).filter(|c| !identity(c)).count();
+ if moved > 0 {
+ col.lose(
+ LossCategory::Effects,
+ format!("{here}: {moved} channel curve(s) of {label} are not imported (no sample tells which curve is red, green or blue)"),
+ );
+ }
+}
+
+/// Hue vs Sat (`-0042`): After Effects' y is an offset from neutral where 1 doubles the
+/// saturation (1200 `lumetri-61-preset-curves-huesat`: a point at hue 0.0155, y 0.97 scales pure
+/// red's saturation by about 1.96 in linear light; pins at -0.03 and -0.02 scale green, blue and
+/// cyan by 0.97). EffectCraft's y = 1 doubles it and 0.5 is neutral, so y becomes `0.5 + y / 2`.
+/// x is the hue in both (0 red, 1/3 green).
+fn import_hue_sat_curve(
+ d: Option<&[u8]>,
+ target: &str,
+ (here, label): (&str, &str),
+ group: &mut effectcraft_project::PropGroup,
+ col: &mut crate::import::report::Collector,
+) {
+ let Some(d) = d else { return };
+ match curve_points(d).and_then(|pts| points_text(pts.into_iter().map(|(x, y)| (x, 0.5 + y / 2.0)))) {
+ Some(text) => set_prop(group, target, Value::Str(text)),
+ None => col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curve is not imported")),
+ }
+}
+
+/// The colour EffectCraft's wheel takes for After Effects' hue (degrees, 0 red, 120 green) and
+/// distance from the centre (0..1): the HSL colour of that hue with saturation = distance and
+/// lightness 0.5 (grey 0.5 at distance 0). EffectCraft's wheel adds that colour's offset from its
+/// grey, so the direction is After Effects' and the strength grows with the distance.
+fn wheel_colour(hue: f64, distance: f64) -> [f64; 4] {
+ let c = distance.clamp(0.0, 1.0);
+ let h = hue.rem_euclid(360.0) / 60.0;
+ let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
+ let (r, g, b) = match h {
+ h if h < 1.0 => (c, x, 0.0),
+ h if h < 2.0 => (x, c, 0.0),
+ h if h < 3.0 => (0.0, c, x),
+ h if h < 4.0 => (0.0, x, c),
+ h if h < 5.0 => (x, 0.0, c),
+ _ => (c, 0.0, x),
+ };
+ let m = 0.5 - c / 2.0;
+ [r + m, g + m, b + m, 1.0]
+}
+
+/// Color Wheels (`-0047`, 96 bytes): four wheels of three little-endian `f64`, the hue in degrees,
+/// the brightness slider (0..1, 0.5 neutral) and the distance from the centre (0..1); `0, 0.5, 0`
+/// each by default. The first three are Shadows, Midtones and Highlights (1200
+/// `lumetri-62-preset-wheels`: Shadows 229 degrees (blue), slider 0.37, distance 0.55; Midtones
+/// 0.1 degrees (red), 0.5, 0.39; Highlights 93 degrees (yellow-green), 0.73, 0.32); the fourth is
+/// not known. Hue and distance become EffectCraft's wheel colour ([`wheel_colour`]); the brightness
+/// sliders and a set fourth wheel have no counterpart and are reported.
+fn import_wheels(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
+ let Some(d) = d else { return };
+ let wheels: Option> = if d.len() == 4 * 24 {
+ (0..4).map(|w| Some([le_f64(d, w * 24)?, le_f64(d, w * 24 + 8)?, le_f64(d, w * 24 + 16)?]).filter(|v| v.iter().all(|x| x.is_finite()))).collect()
+ } else {
+ None
+ };
+ let Some(wheels) = wheels else {
+ col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the colour wheels are not imported"));
+ return;
+ };
+ let mut lost: Vec = Vec::new();
+ for (w, (id, name)) in wheels.iter().zip([("shadowsWheel", "Shadows"), ("midtonesWheel", "Midtones"), ("highlightsWheel", "Highlights")]) {
+ let [hue, brightness, distance] = *w;
+ if distance != 0.0 {
+ set_prop(group, &format!("colorWheels/{id}"), Value::Color(wheel_colour(hue, distance)));
+ }
+ if (brightness - 0.5).abs() > 1e-9 {
+ lost.push(format!("{name} brightness {brightness:.3} (0.5 is neutral)"));
+ }
+ }
+ if wheels.get(3).is_some_and(|w| *w != [0.0, 0.5, 0.0]) {
+ lost.push("the fourth wheel (its role is not known)".to_string());
+ }
+ if !lost.is_empty() {
+ col.lose(LossCategory::Effects, format!("{here}: {label}: {} not imported (no counterpart)", lost.join(", ")));
+ }
+}
diff --git a/crates/aep/src/import/props.rs b/crates/aep/src/import/props.rs
index 838a2ee44..fd0a4f9fc 100644
--- a/crates/aep/src/import/props.rs
+++ b/crates/aep/src/import/props.rs
@@ -136,8 +136,12 @@ const IGNORED: [&str; 4] = ["ADBE Position_0", "ADBE Position_1", "ADBE Position
/// How the stored Anchor Point becomes pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AnchorUnit {
- /// A fraction of a source this big (pixels): AV layers.
+ /// A fraction of a source this big (pixels): AV layers; the source's centre when absent.
Source(u32, u32),
+ /// A fraction of a null object's source this big (pixels); its top-left corner when absent:
+ /// After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099: two nulls made
+ /// with `addNull`, one left at its default, read back `[0, 0, 0]` and `[10, 20, 0]`).
+ Null(u32, u32),
/// Pixels as stored, the origin when absent: text (385) and shape (462) layers, which have no
/// source.
Pixels,
@@ -169,6 +173,14 @@ pub(crate) fn import_transform(
let (sw, sh) = (f64::from(w), f64::from(h));
(Kind::Anchor(sw, sh), tr.anchor_px(sw, sh))
}
+ AnchorUnit::Null(w, h) => {
+ let (sw, sh) = (f64::from(w), f64::from(h));
+ let px = match tr.anchor_fraction.source {
+ ValueSource::Default | ValueSource::Unreadable => [0.0; 3],
+ ValueSource::Stored | ValueSource::Keyframed => tr.anchor_px(sw, sh),
+ };
+ (Kind::Anchor(sw, sh), px)
+ }
AnchorUnit::Pixels | AnchorUnit::PointOfInterest => (Kind::Anchor(1.0, 1.0), tr.text_anchor_px()),
};
let poi = anchor == AnchorUnit::PointOfInterest;
diff --git a/crates/aep/src/import/puppet.rs b/crates/aep/src/import/puppet.rs
new file mode 100644
index 000000000..ce10ae7f5
--- /dev/null
+++ b/crates/aep/src/import/puppet.rs
@@ -0,0 +1,234 @@
+//! After Effects' Puppet effect (`ADBE FreePin3`) arrives as EffectCraft's Puppet
+//! (`ec.distort.puppet`) in its place in the stack, with its name and fx switch, but **without
+//! its meshes and pins**: how After Effects stores a pin's rest place (a vertex index and offset
+//! into the hidden mesh data) and its Position (pixels or a fraction of the layer) is not decoded
+//! yet, and a guessed rest place would deform the layer wrongly without a word. What the instance
+//! holds is reported instead, mesh by mesh: Triangles, Expansion, and per pin container (Deform,
+//! Stiffness, Overlap) the number of pins, how many are keyed or carry an expression, and the
+//! match names of their atoms (Advanced and Bend pins show up there). Any entry this reader does
+//! not know is named verbatim.
+//!
+//! The tree read here (docs/aep-format.md 3.7, Puppet) comes from an external scripting listing
+//! of the effect's property tree, not from the corpus (no corpus file has a Puppet): **?** until a
+//! byte sample exists. It is read with the same verified group reader as masks, shapes and layer
+//! styles; a file whose tree differs is reported, never guessed at.
+
+use effectcraft_project::build::Ids;
+use effectcraft_project::{Node, PropGroup};
+
+use crate::import::effects::{LayerCtx, carries_nothing};
+use crate::import::report::{Collector, LossCategory};
+use crate::{Effect as AepEffect, EffectDefinitions, ParamValue, PropGroup as AepGroup, PropNode, Property as AepProperty};
+
+/// After Effects' Puppet effect.
+pub(crate) const MATCH_NAME: &str = "ADBE FreePin3";
+/// The hidden group that holds the meshes (`arap`).
+const ARAP: &str = "ADBE FreePin3 ARAP Group";
+/// Auto-traced Shapes, beside the meshes.
+const OUTLINES: &str = "ADBE FreePin3 Outlines";
+/// The hidden group of the meshes.
+const MESH_GROUP: &str = "ADBE FreePin3 Mesh Group";
+/// One mesh (`Mesh 1`).
+const MESH_ATOM: &str = "ADBE FreePin3 Mesh Atom";
+/// A mesh's hidden triangulation.
+const MESH_DATA: &str = "ADBE FreePin3 Mesh";
+const TRIANGLES: &str = "ADBE FreePin3 Mesh Tri Count";
+const EXPANSION: &str = "ADBE FreePin3 Mesh Expansion";
+/// At most this many meshes, pin atom names or unrecognised names are listed in a report line;
+/// the rest are counted (`and 3 more`).
+const LIST_CAP: usize = 10;
+/// The pin containers of a mesh: match name and the name After Effects shows.
+const PIN_GROUPS: [(&str, &str); 3] = [("ADBE FreePin3 PosPins", "Deform"), ("ADBE FreePin3 HghtPins", "Overlap"), ("ADBE FreePin3 StarchPins", "Stiffness")];
+
+/// Imports a Puppet instance (the caller checked the match name) into `target`.
+pub(crate) fn import_puppet<'f, 'a>(
+ defs: &EffectDefinitions<'f, 'a>,
+ fx: &AepEffect<'f, 'a>,
+ cx: &LayerCtx<'_>,
+ ids: &mut Ids<'_>,
+ target: &mut PropGroup,
+ col: &mut Collector,
+) {
+ let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
+ let here = format!("{}: '{}' effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer);
+ let Some(spec) = effectcraft_effects::find(effectcraft_effects::puppet::ID) else {
+ col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer));
+ return;
+ };
+ let size = [f64::from(cx.size.0), f64::from(cx.size.1)];
+ let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size);
+ match fx.enabled() {
+ Some(on) => group.enabled = on,
+ None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
+ }
+ // The ordinary parameters (`parT`): none is mapped yet, so a stored one is reported.
+ let params = defs.params(fx);
+ for p in ¶ms {
+ if carries_nothing(p) {
+ continue;
+ }
+ if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) {
+ let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
+ col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
+ }
+ }
+ report_meshes(fx, ¶ms.iter().map(|p| p.match_name.as_str()).collect::>(), &here, col);
+ // The effect itself arrives (an EffectCraft Puppet the user can pin again), so it counts as
+ // imported; what it held is in the losses above.
+ col.imported.effects = col.imported.effects.saturating_add(1);
+ target.children.push(Node::Group(group));
+}
+
+/// Reports the meshes and pins of `fx` and every entry of its value group that is neither a
+/// `parT` parameter (`param_names`) nor the mesh tree.
+fn report_meshes(fx: &AepEffect<'_, '_>, param_names: &[&str], here: &str, col: &mut Collector) {
+ let mut unknown: Vec = Vec::new();
+ let values = fx.chunk().child_list(b"tdgp").and_then(AepGroup::from_chunk);
+ let mut arap = None;
+ for e in values.map(|g| g.entries()).unwrap_or_default() {
+ if e.match_name == ARAP {
+ if let PropNode::Group(g) = e.node {
+ if arap.is_none() {
+ arap = Some(g);
+ }
+ } else {
+ push_unique(&mut unknown, e.match_name);
+ }
+ } else if !param_names.contains(&e.match_name.as_str()) {
+ match e.node {
+ // A value outside `parT` (On Transparent, ...): reported with what it holds.
+ PropNode::Property(p) => col.lose(LossCategory::Effects, format!("{here}: property {} ({}) not imported", e.match_name, describe(&p))),
+ PropNode::Group(_) | PropNode::Other(_) => push_unique(&mut unknown, e.match_name),
+ }
+ }
+ }
+ let mut meshes: Vec = Vec::new();
+ let mut found = false;
+ if let Some(arap) = arap {
+ for e in arap.entries() {
+ match (e.match_name.as_str(), e.node) {
+ (MESH_GROUP, PropNode::Group(g)) if !found => {
+ found = true;
+ for m in g.entries() {
+ match (m.match_name.as_str(), m.node) {
+ (MESH_ATOM, PropNode::Group(mesh)) => {
+ let n = meshes.len().saturating_add(1);
+ meshes.push(mesh_summary(&mesh, n, &mut unknown));
+ }
+ _ => push_unique(&mut unknown, m.match_name.clone()),
+ }
+ }
+ }
+ // Auto-traced Shapes are made from the layer again (EffectCraft traces the alpha).
+ (OUTLINES, _) => {}
+ _ => push_unique(&mut unknown, e.match_name.clone()),
+ }
+ }
+ }
+ let extra = if unknown.is_empty() { String::new() } else { format!("; not recognised: {}", capped(&unknown, ", ")) };
+ if !found {
+ col.lose(
+ LossCategory::Effects,
+ format!("{here}: imported without meshes or pins: its mesh group ({ARAP} / {MESH_GROUP}) was not found or is not a group{extra}"),
+ );
+ } else if !meshes.is_empty() {
+ col.lose(
+ LossCategory::Effects,
+ format!(
+ "{here}: imported without its meshes and pins (After Effects' pin rest places and positions are not decoded yet): {}{extra}",
+ capped(&meshes, "; ")
+ ),
+ );
+ } else if !unknown.is_empty() {
+ col.lose(LossCategory::Effects, format!("{here}: no mesh{extra}"));
+ }
+}
+
+/// `Mesh n (Triangles t, Expansion e): Deform 3 pins (1 keyed) [atom names], Overlap no pins, ...`
+fn mesh_summary(mesh: &AepGroup<'_, '_>, n: usize, unknown: &mut Vec) -> String {
+ let name = mesh.user_name().unwrap_or_else(|| format!("Mesh {n}"));
+ let mut settings: Vec = Vec::new();
+ let mut containers: Vec = Vec::new();
+ for e in mesh.entries() {
+ let label = match e.match_name.as_str() {
+ TRIANGLES => Some("Triangles"),
+ EXPANSION => Some("Expansion"),
+ _ => None,
+ };
+ match (label, e.node) {
+ (Some(label), PropNode::Property(p)) => settings.push(format!("{label} {}", describe(&p))),
+ // The triangulation After Effects keeps; EffectCraft builds its own.
+ _ if e.match_name == MESH_DATA => {}
+ _ => match (PIN_GROUPS.iter().find(|(m, _)| *m == e.match_name), e.node) {
+ (Some((_, shown)), PropNode::Group(g)) => containers.push(format!("{shown} {}", pins_summary(&g, unknown))),
+ _ => push_unique(unknown, e.match_name),
+ },
+ }
+ }
+ let settings = if settings.is_empty() { String::new() } else { format!(" ({})", settings.join(", ")) };
+ let containers = if containers.is_empty() { "no pin groups".to_string() } else { containers.join(", ") };
+ format!("{name}{settings}: {containers}")
+}
+
+/// `3 pins (1 keyed, 1 with an expression) [ADBE FreePin3 PosPin Atom]`, or `no pins`. An entry
+/// of the container that is not a group (a pin is one) goes to `unknown`.
+fn pins_summary(container: &AepGroup<'_, '_>, unknown: &mut Vec) -> String {
+ let mut count = 0usize;
+ let mut keyed = 0usize;
+ let mut expressions = 0usize;
+ let mut atoms: Vec = Vec::new();
+ for e in container.entries() {
+ let PropNode::Group(pin) = e.node else {
+ push_unique(unknown, e.match_name);
+ continue;
+ };
+ count = count.saturating_add(1);
+ push_unique(&mut atoms, e.match_name);
+ let props: Vec> =
+ pin.entries().into_iter().filter_map(|p| if let PropNode::Property(p) = p.node { Some(p) } else { None }).collect();
+ if props.iter().any(AepProperty::is_animated) {
+ keyed = keyed.saturating_add(1);
+ }
+ if props.iter().any(|p| p.expression().is_some()) {
+ expressions = expressions.saturating_add(1);
+ }
+ }
+ if count == 0 {
+ return "no pins".to_string();
+ }
+ let mut what = vec![format!("{keyed} keyed")];
+ if expressions > 0 {
+ what.push(format!("{expressions} with an expression"));
+ }
+ format!("{count} pin{} ({}) [{}]", if count == 1 { "" } else { "s" }, what.join(", "), capped(&atoms, ", "))
+}
+
+/// A stored property's value for the report: `keyed`, the static value, or `unreadable`.
+fn describe(p: &AepProperty<'_, '_>) -> String {
+ if p.is_animated() {
+ return "keyed".to_string();
+ }
+ match p.static_value() {
+ Some(v) if v.len() == 1 => v.first().map(|x| format!("{x}")).unwrap_or_default(),
+ Some(v) => format!("[{}]", v.iter().map(|x| format!("{x}")).collect::>().join(", ")),
+ None => "unreadable".to_string(),
+ }
+}
+
+/// Adds `name` to `names` unless it is there already.
+fn push_unique(names: &mut Vec, name: String) {
+ if !names.contains(&name) {
+ names.push(name);
+ }
+}
+
+/// The first [`LIST_CAP`] of `items` joined by `sep`, and how many more there are.
+fn capped(items: &[String], sep: &str) -> String {
+ let shown: Vec<&str> = items.iter().take(LIST_CAP).map(String::as_str).collect();
+ let mut out = shown.join(sep);
+ let more = items.len().saturating_sub(LIST_CAP);
+ if more > 0 {
+ out.push_str(&format!("{sep}and {more} more"));
+ }
+ out
+}
diff --git a/crates/aep/src/import/text.rs b/crates/aep/src/import/text.rs
index d9f75d5db..5333efcb0 100644
--- a/crates/aep/src/import/text.rs
+++ b/crates/aep/src/import/text.rs
@@ -26,10 +26,11 @@ pub(crate) const MAX_TEXT_CHARS: usize = 1 << 20;
/// U+0003 (END OF TEXT): After Effects' other paragraph break besides `\r`.
const ETX: char = '\u{3}';
-/// The first paragraph of a stored text, at most 64 characters: the name After Effects shows for
-/// a text layer without a name of its own (U+0003 ends a paragraph like `\r`).
+/// The name of a text layer without a name of its own: its first paragraph, cut as EffectCraft
+/// cuts a following name ([`effectcraft_project::text_layer_name`]), so a following name that
+/// export writes as no name comes back unchanged (#3233). U+0003 ends a paragraph like `\r`.
pub(crate) fn first_paragraph(raw: &str) -> String {
- raw.split(['\r', ETX]).next().unwrap_or_default().chars().take(64).collect()
+ effectcraft_project::text_layer_name(raw.split(ETX).next().unwrap_or_default())
}
/// The Source Text value of `doc`, with what cannot be mapped reported for the layer `what`.
diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs
index 2636dece9..4bcdbab9e 100644
--- a/crates/aep/src/lib.rs
+++ b/crates/aep/src/lib.rs
@@ -16,11 +16,12 @@ pub mod import;
pub use container::{
AeVersion, AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CryptomatteData, CueType,
- CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType,
- HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader,
- LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation,
- OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality,
- Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox,
- TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut,
- effect_map, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to,
+ CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, ExtractorChannels, FontBaseline, Footage,
+ GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer,
+ LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS,
+ Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode,
+ Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun,
+ TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace,
+ curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map,
+ write, write_to,
};
diff --git a/crates/aep/tests/fixtures/ai-mask-3099.aep b/crates/aep/tests/fixtures/ai-mask-3099.aep
new file mode 100644
index 000000000..7223fda82
Binary files /dev/null and b/crates/aep/tests/fixtures/ai-mask-3099.aep differ
diff --git a/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution b/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution
new file mode 100644
index 000000000..86efa3b65
--- /dev/null
+++ b/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution
@@ -0,0 +1,7 @@
+asset: ai-mask-3099.aep
+title: Small After Effects test project: placed .ai files with masks under two parented Nulls (#3099)
+author: EffectCraft contributors
+source: original work (made in After Effects 2026 from own data: four hand-written one-page PDFs named .ai, generated by tools/ai-mask-3099.py, imported and laid out by tools/ai-mask-3099.jsx)
+license: MIT OR Apache-2.0
+added: 2026-11-08 by EffectCraft contributors
+notes: Scrubbed with tools/scrub.py (footage paths C:\fixture\x\.ai of the same length, host name HOST000, the XMP packet of an empty project of the contributor); its four embedded colour profiles replaced by stubs (description only, no colour data) with crates/aep/examples/stubicc.rs, then checked with the corpus repository's tools/aep/inspect-embedded.py (CLEAN). No Adobe asset is in it.
diff --git a/crates/aep/tests/fixtures/tools/README.md b/crates/aep/tests/fixtures/tools/README.md
index eee0174ee..f1c720b2e 100644
--- a/crates/aep/tests/fixtures/tools/README.md
+++ b/crates/aep/tests/fixtures/tools/README.md
@@ -29,6 +29,7 @@ single-case files some cases are merged from, and the contributor's own animatio
| `scrub.py` | fixture hygiene of a saved `.aep`: footage alias paths replaced by neutral ones of the same length (`C:\fixtures\...`), host name `HOST000...`, neutral ascend counts, the XMP packet replaced by the base project's |
| `layers-comps-scrub-aepx.py` | the same XMP swap for the `.aepx` of the pair |
| `patch-text.py` | the byte edits of the text cases 732 and 733 (see below) |
+| `ai-mask-3099.py`, `ai-mask-3099.jsx` | the committed `../ai-mask-3099.aep` (#3099): the `.py` writes four one-page PDFs named `.ai` with distinct page boxes, the `.jsx` (ExtendScript, run once through an After Effects MCP server's script call in an empty untitled project) imports them, builds composition `ai-mask` and returns After Effects' read-back that `../../it/import/ai_mask.rs` compares against |
## Run order (per file)
diff --git a/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx b/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx
new file mode 100644
index 000000000..bcac03d0b
--- /dev/null
+++ b/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx
@@ -0,0 +1,94 @@
+// Issue 3099: how After Effects sizes a placed .ai (PDF) and where masks / parented layers land.
+// Generates ai-mask-3099.aep: run `python -I ai-mask-3099.py ` first, set `dir` to that
+// folder, then run this script (ExtendScript) in an empty, untitled project. It returns After
+// Effects' read-back (footage sizes, transforms, mask vertices, toComp points; the tests compare
+// against it) and saves /i3099-ai-mask.aep, which is then scrubbed (README: steps 5 and 6).
+(function () {
+ var dir = "/";
+ var out = {};
+ if (app.project.file !== null || app.project.numItems !== 0) {
+ return "ABORT: project not empty/untitled (" + app.project.numItems + " items)";
+ }
+ var target = new File(dir + "i3099-ai-mask.aep");
+ if (target.exists) { return "ABORT: target exists"; }
+ var names = ["i3099-a-boxes.ai", "i3099-b-frac.ai", "i3099-c-rot.ai", "i3099-d-trim.ai"];
+ var items = {};
+ out.footage = {};
+ for (var i = 0; i < names.length; i++) {
+ var io = new ImportOptions(new File(dir + names[i]));
+ io.importAs = ImportAsType.FOOTAGE;
+ var it = app.project.importFile(io);
+ items[names[i]] = it;
+ out.footage[names[i]] = { w: it.width, h: it.height, par: it.pixelAspect };
+ }
+ var comp = app.project.items.addComp("ai-mask", 1920, 1080, 1, 5, 25);
+ var art = comp.layers.add(items["i3099-a-boxes.ai"]);
+ art.name = "art";
+ var artD = comp.layers.add(items["i3099-d-trim.ai"]);
+ artD.name = "artD";
+ artD.collapseTransformation = true;
+ var artC = comp.layers.add(items["i3099-c-rot.ai"]);
+ artC.name = "artC";
+ artC.property("ADBE Transform Group").property("ADBE Position").setValue([1500, 800]);
+ var n1 = comp.layers.addNull(); n1.name = "N1";
+ var n2 = comp.layers.addNull(); n2.name = "N2";
+ n2.setParentWithJump(n1);
+ art.setParentWithJump(n2);
+ artD.setParentWithJump(n2);
+ var t = function (l, p, v) { l.property("ADBE Transform Group").property(p).setValue(v); };
+ t(n1, "ADBE Position", [700, 400]);
+ t(n1, "ADBE Rotate Z", 15);
+ t(n1, "ADBE Scale", [80, 120]);
+ t(n2, "ADBE Anchor Point", [10, 20]);
+ t(n2, "ADBE Position", [50, -30]);
+ t(n2, "ADBE Scale", [150, 150]);
+ t(art, "ADBE Position", [0, 0]);
+ t(artD, "ADBE Position", [300, 250]);
+ // Two rectangular masks on each art layer, in layer pixels.
+ var rect = function (l, nm, x0, y0, x1, y1) {
+ var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
+ m.name = nm;
+ var s = new Shape();
+ s.vertices = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
+ s.closed = true;
+ m.property("ADBE Mask Shape").setValue(s);
+ };
+ rect(art, "M1", 40, 30, 200, 90);
+ rect(art, "M2", 220, 100, 350, 160);
+ rect(artD, "M1", 40, 30, 200, 90);
+ rect(artD, "M2", 220, 100, 350, 160);
+ // World positions of layer points through the parent chain (toComp), read via a Point Control.
+ var probe = n1.property("ADBE Effect Parade").addProperty("ADBE Point Control");
+ var pt = probe.property(1);
+ var toComp = function (ln, p) {
+ pt.expression = "thisComp.layer('" + ln + "').toComp([" + p[0] + "," + p[1] + "])";
+ var v = pt.valueAtTime(0, false);
+ return [v[0], v[1]];
+ };
+ var rd = function (l) {
+ var tg = l.property("ADBE Transform Group");
+ var r = {
+ w: l.width, h: l.height,
+ anchor: tg.property("ADBE Anchor Point").value,
+ position: tg.property("ADBE Position").value,
+ scale: tg.property("ADBE Scale").value,
+ rot: tg.property("ADBE Rotate Z").value,
+ masks: []
+ };
+ var mp = l.property("ADBE Mask Parade");
+ for (var k = 1; k <= mp.numProperties; k++) {
+ r.masks.push(mp.property(k).property("ADBE Mask Shape").value.vertices);
+ }
+ return r;
+ };
+ out.layers = { art: rd(art), artD: rd(artD), artC: rd(artC), N1: rd(n1), N2: rd(n2) };
+ out.world = {};
+ var pts = { tl: [0, 0], tr: [art.width, 0], bl: [0, art.height], m1: [40, 30], m2: [350, 160] };
+ for (var key in pts) { out.world["art_" + key] = toComp("art", pts[key]); }
+ var ptsD = { tl: [0, 0], tr: [artD.width, 0], bl: [0, artD.height], m1: [40, 30] };
+ for (var key2 in ptsD) { out.world["artD_" + key2] = toComp("artD", ptsD[key2]); }
+ probe.remove();
+ app.project.save(target);
+ out.saved = target.fsName;
+ return out;
+})();
diff --git a/crates/aep/tests/fixtures/tools/ai-mask-3099.py b/crates/aep/tests/fixtures/tools/ai-mask-3099.py
new file mode 100644
index 000000000..6b5b7facf
--- /dev/null
+++ b/crates/aep/tests/fixtures/tools/ai-mask-3099.py
@@ -0,0 +1,66 @@
+"""Writes hand-made one-page PDFs named .ai with distinct page boxes (issue 3099 repro)."""
+import os
+import sys
+
+out = sys.argv[1]
+os.makedirs(out, exist_ok=True)
+
+
+def pdf(page_extra, content):
+ objs = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ b"<< /Type /Page /Parent 2 0 R " + page_extra.encode() + b" /Contents 4 0 R >>",
+ b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"\nendstream",
+ ]
+ data = b"%PDF-1.4\n"
+ offs = []
+ for i, o in enumerate(objs, 1):
+ offs.append(len(data))
+ data += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
+ x = len(data)
+ data += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
+ for o in offs:
+ data += b"%010d 00000 n \n" % o
+ data += b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\nstartxref\n" + str(x).encode() + b"\n%%EOF\n"
+ return data
+
+
+def boxes_content(boxes):
+ # One outline per box (different colours) plus a filled marker at the media box origin corner.
+ cols = ["0 0 0", "1 0 0", "0 0.6 0", "0 0 1", "1 0.5 0"]
+ s = b"0.85 0.85 0.85 rg "
+ x0, y0, x1, y1 = boxes[0]
+ s += b"%g %g %g %g re f\n" % (x0, y0, x1 - x0, y1 - y0)
+ for c, (a, b, cc, d) in zip(cols, boxes):
+ s += c.encode() + b" RG 1 w %g %g %g %g re S\n" % (a + 0.5, b + 0.5, cc - a - 1, d - b - 1)
+ # A solid square in the lower-left of the last (innermost) box: shows y orientation.
+ a, b, _, _ = boxes[-1]
+ s += b"0.2 0.2 0.8 rg %g %g 20 10 re f" % (a + 4, b + 4)
+ return s
+
+
+def arr(b):
+ return "[%s]" % " ".join("%g" % v for v in b)
+
+
+# A: every box distinct, nested, non-square.
+media, crop, bleed, trim, art = [0, 0, 420, 220], [5, 5, 415, 215], [10, 10, 410, 210], [20, 20, 400, 200], [60, 50, 300.6, 170.4]
+a = pdf(
+ f"/MediaBox {arr(media)} /CropBox {arr(crop)} /BleedBox {arr(bleed)} /TrimBox {arr(trim)} /ArtBox {arr(art)}",
+ boxes_content([media, crop, bleed, trim, art]),
+)
+# B: only a fractional media box.
+mb = [0, 0, 300.6, 120.4]
+b = pdf(f"/MediaBox {arr(mb)}", boxes_content([mb]))
+# C: media box with an offset origin and /Rotate 90.
+mc = [100, 50, 400, 150]
+c = pdf(f"/MediaBox {arr(mc)} /Rotate 90", boxes_content([mc]))
+# D: media + trim + art, no crop box (bleed around the artboard).
+md, td, ad = [0, 0, 418, 218], [9, 9, 409, 209], [40, 30, 250, 150]
+d = pdf(f"/MediaBox {arr(md)} /TrimBox {arr(td)} /BleedBox {arr(md)} /ArtBox {arr(ad)}", boxes_content([md, td, ad]))
+
+for name, data in [("i3099-a-boxes.ai", a), ("i3099-b-frac.ai", b), ("i3099-c-rot.ai", c), ("i3099-d-trim.ai", d)]:
+ with open(os.path.join(out, name), "wb") as f:
+ f.write(data)
+ print(name, len(data))
diff --git a/crates/aep/tests/it/container/robustness.rs b/crates/aep/tests/it/container/robustness.rs
index 27622d887..5c3422a10 100644
--- a/crates/aep/tests/it/container/robustness.rs
+++ b/crates/aep/tests/it/container/robustness.rs
@@ -237,9 +237,11 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye
if let Some(root) = layer.properties() {
exercise_group(&root, 0);
}
+ // One index for the layer's effects: building it walks every layer of the file.
+ let defs = project.effect_definition_index();
for e in layer.effects() {
- let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), project.definition_source(&e));
- for p in e.params().into_iter().chain(project.effect_params(&e)) {
+ let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), defs.source(&e));
+ for p in e.params().into_iter().chain(defs.params(&e)) {
let _ = (p.index, p.kind, p.name.len(), p.items.as_ref().map(Vec::len), p.pard().len(), p.value(), p.stored_value(), p.pard_default());
let _ = (p.is_default(), p.popup_item_count(), p.popup_selected_index(), p.popup_selected_item(), p.valid_max(), p.is_animated());
let _ = (p.valid_min(), p.slider_range());
diff --git a/crates/aep/tests/it/export/built.rs b/crates/aep/tests/it/export/built.rs
index 14f9d8795..0da683cc4 100644
--- a/crates/aep/tests/it/export/built.rs
+++ b/crates/aep/tests/it/export/built.rs
@@ -83,6 +83,45 @@ fn ntsc_and_odd_frame_rates_use_the_time_bases_after_effects_writes() {
}
}
+/// #3099: After Effects reads a null's absent Anchor Point as its top-left corner `[0, 0]`. A
+/// null anchored at its centre is written (it was left out as "the default" and came back at the
+/// corner); one at the corner is not, and both import back as they were.
+#[test]
+fn null_anchor_points_round_trip() {
+ let mut p = Project::default();
+ let c = add_comp(&mut p, "C", 800, 600, FrameRate::new(30, 1), 2.0, None);
+ add_layer(&mut p, c, "Corner", LayerSource::Null, (100, 100));
+ add_layer(&mut p, c, "Centre", LayerSource::Null, (100, 100));
+ for (idx, anchor) in [(0, [50.0, 50.0, 0.0]), (1, [0.0, 0.0, 0.0])] {
+ with_layer(&mut p, c, idx, |l| {
+ if let Some(a) = l.props.prop_mut("transform/anchor") {
+ a.value = Value::Vec3(anchor);
+ }
+ });
+ }
+ let r = export_all(&p);
+ let file = parse(&r.bytes).expect("parse");
+ let view = effectcraft_aep::Project::new(&file).expect("project");
+ let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp");
+ let stored: Vec<_> =
+ comp.comp().expect("c").layers().map(|l| (l.name().unwrap_or_default(), l.transform(800, 600).expect("transform").anchor_fraction.source)).collect();
+ assert_eq!(stored, [("Centre".to_string(), effectcraft_aep::ValueSource::Stored), ("Corner".to_string(), effectcraft_aep::ValueSource::Default)]);
+ let mut back = Project::default();
+ let none = |_: &str| false;
+ let res = effectcraft_aep::import::import(
+ &mut back,
+ &r.bytes,
+ &effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
+ )
+ .expect("import");
+ let comp = res.comps.first().and_then(|id| back.comp(*id)).expect("comp");
+ for (name, want) in [("Centre", [50.0, 50.0]), ("Corner", [0.0, 0.0])] {
+ let l = comp.layers.iter().find(|l| l.name == name).expect("layer");
+ let a = l.props.prop("transform/anchor").expect("anchor").value.as_vec2();
+ assert!(close(a[0], want[0]) && close(a[1], want[1]), "{name}: {a:?}");
+ }
+}
+
#[test]
fn layer_header_fields() {
let mut p = Project::default();
diff --git a/crates/aep/tests/it/export/effects.rs b/crates/aep/tests/it/export/effects.rs
index 7a684705a..f8f388d0f 100644
--- a/crates/aep/tests/it/export/effects.rs
+++ b/crates/aep/tests/it/export/effects.rs
@@ -364,6 +364,42 @@ fn what_cannot_be_written_is_reported() {
parse(&r.bytes).expect("still a valid file");
}
+/// Lumetri Color is imported but not written (no prototype to write from): the export reports it
+/// as a loss and still produces a valid file.
+#[test]
+fn lumetri_color_is_reported_not_written() {
+ let mut p = skip!(tint(|_| {}));
+ let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
+ with_layer(&mut p, c, 0, |l| {
+ if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
+ g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.color.lumetri".into() };
+ }
+ });
+ let r = export_all(&p);
+ assert_eq!(r.report.count(LossCategory::Effects), 1, "{:?}", r.report.losses);
+ assert_eq!(r.report.exported.effects, 0);
+ parse(&r.bytes).expect("still a valid file");
+}
+
+/// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype
+/// to write from: each is reported, not written.
+#[test]
+fn imported_plug_in_effects_are_reported_not_written() {
+ for effect in ["ec.color.cccolorneutralizer", "ec.3d.extractor", "ec.3d.identifier"] {
+ let mut p = skip!(tint(|_| {}));
+ let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
+ with_layer(&mut p, c, 0, |l| {
+ if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
+ g.kind = effectcraft_project::GroupKind::Effect { effect: effect.into() };
+ }
+ });
+ let r = export_all(&p);
+ assert_eq!(r.report.count(LossCategory::Effects), 1, "{effect}: {:?}", r.report.losses);
+ assert_eq!(r.report.exported.effects, 0, "{effect}");
+ parse(&r.bytes).expect("still a valid file");
+ }
+}
+
#[test]
fn hostile_effect_values_never_panic() {
for v in [Value::Scalar(f64::NAN), Value::Scalar(1e300), Value::Color([f32::NAN as f64, 2.0, -1.0, 1.0]), Value::Bool(true), Value::Text(Box::default())] {
diff --git a/crates/aep/tests/it/export/text.rs b/crates/aep/tests/it/export/text.rs
index 32c7572c5..befc2f47e 100644
--- a/crates/aep/tests/it/export/text.rs
+++ b/crates/aep/tests/it/export/text.rs
@@ -196,6 +196,69 @@ fn documents_built_in_code_are_written_as_their_fields() {
assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text));
}
+/// #3233: whether a text layer's name follows its text survives export and import. A followed
+/// name is written as no name (After Effects shows the text) and comes back following; a name the
+/// user gave is written, even when it equals the text, and comes back as the user's. Without a
+/// recorded state (older projects) a name equal to the text follows.
+#[test]
+fn a_text_layer_name_follows_its_text_through_export_and_import() {
+ for (name, follows, stored) in [
+ ("Hello", Some(false), Some("Hello")),
+ ("Hello 2", Some(true), None),
+ ("Hello", None, None),
+ ("Text 2", None, None),
+ ("Text Title", None, Some("Text Title")),
+ ("Text Title", Some(false), Some("Text Title")),
+ ] {
+ let case = format!("{name:?} {follows:?}");
+ let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]);
+ with_layer(&mut p, c, 0, |l| {
+ l.name = name.into();
+ l.name_follows_text = follows;
+ });
+ let r = export_all(&p);
+ let file = parse(&r.bytes).expect("parse");
+ let view = effectcraft_aep::Project::new(&file).expect("project");
+ let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer");
+ assert_eq!(layer.name().as_deref(), stored, "stored name ({case})");
+ let back = reimport(&r.bytes);
+ let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
+ assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})");
+ assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})");
+ }
+ // An empty text layer that follows its text is written without a name, as After Effects
+ // writes it, and comes back named "Text", still following.
+ let (mut p, c) = text_project(vec![TextDoc::plain("")]);
+ with_layer(&mut p, c, 0, |l| {
+ l.name = "Text".into();
+ l.name_follows_text = Some(true);
+ });
+ let back = reimport(&export_all(&p).bytes);
+ let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
+ assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer");
+}
+
+/// #3233: a following name cut from a first line longer than 40 characters is written as no name
+/// and comes back as the same cut name, still following; a long name the user gave stays whole.
+#[test]
+fn a_long_first_line_names_a_text_layer_the_same_after_export_and_import() {
+ let line = "A headline that runs past forty characters, to 64";
+ assert!((41..=64).contains(&line.chars().count()));
+ let text = format!("{line}\rSecond");
+ let cut = effectcraft_project::text_layer_name(&text);
+ assert_eq!(cut.chars().count(), 40);
+ for (name, follows, back_name) in [(cut.as_str(), Some(true), cut.as_str()), (line, Some(false), line)] {
+ let (mut p, c) = text_project(vec![TextDoc::plain(&text)]);
+ with_layer(&mut p, c, 0, |l| {
+ l.name = name.into();
+ l.name_follows_text = follows;
+ });
+ let back = reimport(&export_all(&p).bytes);
+ let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
+ assert_eq!((l.name.as_str(), l.name_follows_text), (back_name, follows), "{name:?}");
+ }
+}
+
#[test]
fn what_a_text_document_cannot_hold_is_reported() {
let mut d = TextDoc::plain("x");
diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs
index 102a215c8..2535edcff 100644
--- a/crates/aep/tests/it/import.rs
+++ b/crates/aep/tests/it/import.rs
@@ -4,6 +4,7 @@
mod helpers;
mod aepx;
+mod ai_mask;
mod blend_modes;
mod cameras;
mod cases;
@@ -15,10 +16,13 @@ mod effects_batch5;
mod effects_quickwins;
mod effects_round2;
mod effects_round3;
+mod effects_thirdparty;
mod fx_inject;
+mod lumetri;
mod markers;
mod masks;
mod mattes;
+mod puppet;
mod resaved_defaults;
mod robustness;
mod shapes;
diff --git a/crates/aep/tests/it/import/ai_mask.rs b/crates/aep/tests/it/import/ai_mask.rs
new file mode 100644
index 000000000..e5f57abe0
--- /dev/null
+++ b/crates/aep/tests/it/import/ai_mask.rs
@@ -0,0 +1,113 @@
+//! A placed Illustrator file with masks under two parented Nulls (#3099), against After Effects
+//! 2026's own read-back of the committed project `tests/fixtures/ai-mask-3099.aep` (generator:
+//! `tests/fixtures/tools/ai-mask-3099.py` and `ai-mask-3099.jsx`). Most of these guard After
+//! Effects' values the importer already read so (the drawing of the page is tested in
+//! `effectcraft-media` and the host's `vector_import` tests); the Anchor Point of the null left
+//! at its default (`[0, 0]`, not the centre) was wrong before #3099.
+//!
+//! The four `.ai` files are hand-written one-page PDFs with distinct page boxes. After Effects
+//! sized them by their ArtBox (a, d), by the MediaBox rounded up (b: 300.6 × 120.4 → 301 × 121)
+//! and ignored `/Rotate 90` (c). Composition `ai-mask` (1920 × 1080): `art` (a) and `artD` (d,
+//! Continuously Rasterize) are children of Null `N2`, itself a child of Null `N1`; both carry two
+//! rectangular masks; `artC` (c) is on its own.
+
+use effectcraft_aep::import::{ImportOptions, import};
+use effectcraft_keyframe::Value;
+use effectcraft_project::{ItemKind, Layer, Project};
+
+use crate::import::helpers::comp;
+
+const AI_MASK: &[u8] = include_bytes!("../../fixtures/ai-mask-3099.aep");
+
+/// The fixture imported into an empty project (losses allowed, no footage file present).
+fn load() -> (Project, effectcraft_aep::import::ImportResult) {
+ let mut project = Project::default();
+ let none = |_: &str| false;
+ let r = import(&mut project, AI_MASK, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
+ (project, r)
+}
+
+/// The first two components of the static value of property `path` of `l`.
+fn vec2(l: &Layer, path: &str) -> [f64; 2] {
+ let v = l.props.prop(path).unwrap_or_else(|| panic!("{}: {path}", l.name)).value.as_vec2();
+ [v[0], v[1]]
+}
+
+/// `a` and `b` agree within float32 noise.
+fn near(a: [f64; 2], b: [f64; 2]) -> bool {
+ (a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3
+}
+
+/// The four `.ai` items have After Effects' sizes and are placed pages.
+#[test]
+fn placed_ai_sizes_are_after_effects_sizes() {
+ let (p, _) = load();
+ let mut sizes: Vec<(String, (u32, u32))> = p
+ .items
+ .values()
+ .filter_map(|i| match &i.kind {
+ ItemKind::Footage(f) if f.path.ends_with(".ai") => {
+ // Framed and drawn as After Effects places the page (#3099).
+ assert!(f.placed_page, "{}", i.name);
+ Some((i.name.clone(), (f.width, f.height)))
+ }
+ _ => None,
+ })
+ .collect();
+ sizes.sort();
+ assert_eq!(
+ sizes,
+ [
+ ("i3099-a-boxes.ai".to_string(), (241, 121)),
+ ("i3099-b-frac.ai".to_string(), (301, 121)),
+ ("i3099-c-rot.ai".to_string(), (300, 100)),
+ ("i3099-d-trim.ai".to_string(), (210, 120)),
+ ]
+ );
+}
+
+/// Parents, transforms (the default null anchor at `[0, 0]`) and both masks of each art layer
+/// as After Effects read them back.
+#[test]
+fn masks_anchors_and_parents_are_after_effects_values() {
+ let (p, r) = load();
+ let (_, c) = comp(&p, &r, "ai-mask");
+ let layer = |name: &str| c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"));
+ let (art, art_d, art_c, n1, n2) = (layer("art"), layer("artD"), layer("artC"), layer("N1"), layer("N2"));
+ // Parents: art, artD -> N2 -> N1.
+ assert_eq!(art.parent, Some(n2.id));
+ assert_eq!(art_d.parent, Some(n2.id));
+ assert_eq!(n2.parent, Some(n1.id));
+ assert_eq!(n1.parent, None);
+ assert_eq!(art_c.parent, None);
+ // Transforms as After Effects read them back (anchor = half the source size for footage).
+ for (l, anchor, position, scale) in [
+ (art, [120.5, 60.5], [0.0, 0.0], [100.0, 100.0]),
+ (art_d, [105.0, 60.0], [300.0, 250.0], [100.0, 100.0]),
+ (art_c, [150.0, 50.0], [1500.0, 800.0], [100.0, 100.0]),
+ (n1, [0.0, 0.0], [700.0, 400.0], [80.0, 120.0]),
+ (n2, [10.0, 20.0], [50.0, -30.0], [150.0, 150.0]),
+ ] {
+ assert!(near(vec2(l, "transform/anchor"), anchor), "{} anchor {:?}", l.name, vec2(l, "transform/anchor"));
+ assert!(near(vec2(l, "transform/position"), position), "{} position {:?}", l.name, vec2(l, "transform/position"));
+ assert!(near(vec2(l, "transform/scale"), scale), "{} scale {:?}", l.name, vec2(l, "transform/scale"));
+ }
+ match n1.props.prop("transform/rotation").map(|p| &p.value) {
+ Some(Value::Scalar(v)) => assert!((v - 15.0).abs() < 1e-9, "N1 rotation {v}"),
+ other => panic!("N1 rotation {other:?}"),
+ }
+ // Both art layers: M1 (40, 30)-(200, 90) and M2 (220, 100)-(350, 160) in layer pixels, the
+ // second reaching past the layer, as After Effects read the vertices back.
+ let rects = [[[40.0, 30.0], [200.0, 30.0], [200.0, 90.0], [40.0, 90.0]], [[220.0, 100.0], [350.0, 100.0], [350.0, 160.0], [220.0, 160.0]]];
+ for l in [art, art_d] {
+ let masks: Vec<_> = l.masks().expect("masks").groups().collect();
+ assert_eq!(masks.len(), 2, "{}", l.name);
+ for (m, want) in masks.iter().zip(&rects) {
+ let Some(Value::Path(path)) = m.get("path").map(|p| &p.value) else { panic!("{}: {} has no path", l.name, m.name) };
+ assert_eq!(path.vertices.len(), 4, "{}: {}", l.name, m.name);
+ for (v, w) in path.vertices.iter().zip(want) {
+ assert!(near(*v, *w), "{}: {} vertex {v:?} != {w:?}", l.name, m.name);
+ }
+ }
+ }
+}
diff --git a/crates/aep/tests/it/import/effects.rs b/crates/aep/tests/it/import/effects.rs
index 71afa8932..52f618520 100644
--- a/crates/aep/tests/it/import/effects.rs
+++ b/crates/aep/tests/it/import/effects.rs
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
] {
assert!(t.contains(&want), "{want:?}");
}
- assert_eq!(t.len(), 63);
+ assert_eq!(t.len(), 67);
for (_, id) in t {
assert!(effectcraft_effects::find(id).is_some(), "{id}");
}
diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs
new file mode 100644
index 000000000..567d77642
--- /dev/null
+++ b/crates/aep/tests/it/import/effects_thirdparty.rs
@@ -0,0 +1,296 @@
+//! CC Color Neutralizer, EXtractoR and IDentifier (#3112) arrive as EffectCraft's effects of the
+//! same name. Each is built into `synthetic.aep` (`fx_inject`) with the definitions After Effects
+//! 2026 wrote for it in the importer block of 2026-11-05 (`1300-thirdparty.aep`) and the values
+//! of that block's compositions; the Channel Info data is laid out as those instances store it.
+
+use crate::import::fx_inject::{Default, Fx, losses, near, other, popup, run, slider, v};
+use effectcraft_aep::import::LossCategory;
+use effectcraft_aep::{ExtractorChannels, ParamKind as K, identifier_channel_type};
+use effectcraft_keyframe::Value;
+
+fn neutralizer(values: Vec<(usize, f64)>) -> Fx {
+ let range = |name| slider(K::FloatSlider, name, 0.0, (-100.0, 100.0));
+ let group = |name| other(K::GroupStart, name, Default::None);
+ let end = || other(K::GroupEnd, "", Default::None);
+ Fx {
+ match_name: "CS Color Neutralizer",
+ name: "CC Color Neutralizer",
+ defs: vec![
+ other(K::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])),
+ group("Shadows"),
+ range("Red - Shadows"),
+ range("Green - Shadows"),
+ range("Blue - Shadows"),
+ end(),
+ other(K::Color, "Midtones Unbalance", Default::Color([255, 127, 127, 127])),
+ group("Midtones"),
+ range("Red - Midtones"),
+ range("Green - Midtones"),
+ range("Blue - Midtones"),
+ end(),
+ other(K::Color, "Highlights Unbalance", Default::Color([255, 255, 255, 255])),
+ group("Highlights"),
+ range("Red - Highlights"),
+ range("Green - Highlights"),
+ range("Blue - Highlights"),
+ end(),
+ slider(K::FloatSlider, "Pinning", 0.0, (0.0, 100.0)),
+ slider(K::FloatSlider, "Blend w. Original", 0.0, (0.0, 100.0)),
+ group("Special"),
+ popup("View", 1, &["Final Result", "Correction B to W", "Correction Stair"]),
+ slider(K::FloatSlider, "Black Point", 0.0, (0.0, 49.9)),
+ slider(K::FloatSlider, "White Point", 100.0, (50.1, 100.0)),
+ end(),
+ ],
+ values,
+ data: vec![],
+ keys: vec![],
+ }
+}
+
+#[test]
+fn cc_color_neutralizer_pinning_and_blend() {
+ // `ccneutral-00-defaults` and the Pinning / Blend of `ccneutral-01-set-all`.
+ let (g, r) = run(&neutralizer(vec![(19, 16.0), (20, 19.0)]), "ec.color.cccolorneutralizer");
+ assert_eq!(losses(&r, LossCategory::Effects, "CS Color Neutralizer"), Vec::::new());
+ let g = g.expect("imported");
+ near(&g, "pinning", 16.0);
+ near(&g, "blendWOriginal", 19.0);
+ // EffectCraft's own (identity) balance stays.
+ assert_eq!(v(&g, "midtonesUnbalance"), v(&g, "midtonesBalance"));
+}
+
+#[test]
+fn cc_color_neutralizer_corrections_without_a_counterpart_are_reported() {
+ // `ccneutral-03-sec-shadows`, `ccneutral-09-pop22-2`, `ccneutral-06-sec-special`.
+ let (g, r) = run(&neutralizer(vec![(3, 13.0), (4, 16.0), (5, 19.0), (22, 2.0), (23, 10.0), (24, 87.0)]), "ec.color.cccolorneutralizer");
+ let found = losses(&r, LossCategory::Effects, "CS Color Neutralizer");
+ for name in ["Red - Shadows", "Green - Shadows", "Blue - Shadows", "View", "Black Point", "White Point"] {
+ assert!(found.iter().any(|d| d.contains(&format!("parameter {name} (")) && d.contains("not imported")), "{name}: {found:?}");
+ }
+ assert_eq!(found.len(), 6, "{found:?}");
+ assert!(g.is_some(), "the effect itself is imported");
+}
+
+/// EXtractoR's Channel Info data with these record kinds and names.
+fn channels(recs: [(u32, &str); 4]) -> Vec {
+ let mut d = vec![0u8; ExtractorChannels::LEN];
+ d[..4].copy_from_slice(&1u32.to_le_bytes());
+ for (k, (kind, name)) in recs.iter().enumerate() {
+ let at = 32 + k * 164;
+ d[at..at + 4].copy_from_slice(&kind.to_le_bytes());
+ d[at + 35..at + 35 + name.len()].copy_from_slice(name.as_bytes());
+ }
+ d
+}
+
+const DEFAULT_CHANNELS: [(u32, &str); 4] = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
+
+fn extractor(values: Vec<(usize, f64)>, data: Vec) -> Fx {
+ let mut values = values;
+ values.insert(0, (1, 0.0));
+ Fx {
+ match_name: "EXtractoR",
+ name: "EXtractoR",
+ defs: vec![
+ other(K::ArbitraryData, "Channel Info", Default::None),
+ other(K::GroupStart, "Process", Default::None),
+ slider(K::FloatSlider, "Black Point", 0.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
+ slider(K::FloatSlider, "White Point", 1.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
+ other(K::Checkbox, "", Default::Number(0.0)),
+ other(K::GroupEnd, "", Default::None),
+ ],
+ values,
+ data: vec![(1, data)],
+ keys: vec![],
+ }
+}
+
+#[test]
+fn extractor_channel_info_layout() {
+ let c = ExtractorChannels::parse(&channels(DEFAULT_CHANNELS)).expect("the default layout");
+ assert!(c.is_default());
+ let c = ExtractorChannels::parse(&channels([(1, "Z"), (1, "G"), (1, "B"), (2, "(copy)")])).expect("parses");
+ assert!(!c.is_default());
+ assert_eq!(c.channels[0], (1, "Z".to_string()));
+ assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None);
+ // The kind is byte 0 of a record: the definition default of `extractor-00-defaults` has
+ // `02 00 00 32` on Alpha, still the input's own channel.
+ let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses");
+ assert!(c.is_default());
+ assert_eq!(c.sources(), Ok(["R", "G", "B", "(copy)"].map(String::from)));
+}
+
+#[test]
+fn extractor_points_unmult_and_default_channels() {
+ // `extractor-01-set-all`: Black Point 10, White Point 14, UnMult on.
+ let (g, r) = run(&extractor(vec![(3, 10.0), (4, 14.0), (5, 1.0)], channels(DEFAULT_CHANNELS)), "ec.3d.extractor");
+ assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new());
+ let g = g.expect("imported");
+ near(&g, "blackPoint", 10.0);
+ near(&g, "whitePoint", 14.0);
+ assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false)));
+ // After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's `(copy)`, not `A` (an
+ // OpenEXR's own alpha plane).
+ for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "(copy)")] {
+ assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
+ }
+}
+
+/// `extractor-07-preset-channel-z` (a preset of our own): `Z` into Red, Green and Blue, Alpha
+/// `(copy)`. EffectCraft's EXtractoR reads the footage's channel by the same name.
+#[test]
+fn extractor_channels_are_imported_by_name() {
+ let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor");
+ assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new());
+ let g = g.expect("imported");
+ for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "(copy)")] {
+ assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
+ }
+ // `(copy)` on another channel is that channel of the input (EffectCraft's `(copy)` there).
+ let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor");
+ let g = g.expect("imported");
+ for (id, want) in [("red", "(copy)"), ("green", "CryptoObject00.R"), ("blue", "(copy)"), ("alpha", "Z")] {
+ assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
+ }
+}
+
+#[test]
+fn extractor_channels_of_an_unknown_kind_are_reported() {
+ for recs in [[(1, "Z"), (3, "Z"), (1, "Z"), (2, "(copy)")], [(1, "Z"), (1, ""), (1, "Z"), (2, "(copy)")]] {
+ let (g, r) = run(&extractor(vec![], channels(recs)), "ec.3d.extractor");
+ let found = losses(&r, LossCategory::Effects, "EXtractoR");
+ assert!(found.len() == 1 && found[0].contains("are not imported (record 2 is of an unknown kind or names no channel)"), "{recs:?}: {found:?}");
+ assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into()), "{recs:?}");
+ }
+}
+
+/// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read.
+fn id_info(name: &str) -> Vec {
+ let mut d: Vec = (0..148u32).map(|i| (i * 37 % 251) as u8 | 1).collect();
+ d[19..19 + name.len()].copy_from_slice(name.as_bytes());
+ d[19 + name.len()] = 0;
+ d
+}
+
+fn identifier(values: Vec<(usize, f64)>, data: Vec) -> Fx {
+ let mut values = values;
+ values.insert(0, (1, 0.0));
+ Fx {
+ match_name: "IDentifier",
+ name: "IDentifier",
+ defs: vec![
+ other(K::ArbitraryData, "Channel Info", Default::None),
+ popup("Display", 1, &["Colors", "Luma Matte", "Alpha Matte", "Raw"]),
+ slider(K::IntSlider, "ID", 0.0, (0.0, 2_147_483_647.0)),
+ ],
+ values,
+ data: vec![(1, data)],
+ keys: vec![],
+ }
+}
+
+#[test]
+fn identifier_display_id_and_channel_type() {
+ assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID"));
+ // `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte): no channel chosen
+ // (After Effects found none or none was picked), so After Effects leaves the layer unchanged.
+ let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier");
+ let found = losses(&r, LossCategory::Effects, "IDentifier");
+ assert!(found.len() == 1 && found[0].contains("no ID channel is chosen"), "{found:?}");
+ let g = g.expect("imported");
+ assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0)));
+ near(&g, "id", 10.0);
+}
+
+/// `identifier-09-preset-channel-objectid-id2` (1310, a preset of our own): the UINT channel
+/// `objectId` chosen and ID 2. EffectCraft's IDentifier reads that channel itself (`ObjectID`
+/// without regard to case), so nothing is lost; another name is reported.
+#[test]
+fn identifier_chosen_channels_by_name() {
+ for (name, kind) in [("objectId", 0), ("ObjectID", 0), ("materialid", 1)] {
+ let (g, r) = run(&identifier(vec![(3, 2.0)], id_info(name)), "ec.3d.identifier");
+ assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::::new(), "{name}");
+ let g = g.expect("imported");
+ assert_eq!(v(&g, "channelType"), Value::Enum(kind), "{name}");
+ near(&g, "id", 2.0);
+ }
+ for name in ["IndexOB.X", "ID", "Material ID"] {
+ let (_, r) = run(&identifier(vec![], id_info(name)), "ec.3d.identifier");
+ let found = losses(&r, LossCategory::Effects, "IDentifier");
+ assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}");
+ }
+}
+
+/// EXtractoR's data with the name of record `k` from byte 35 replaced by `name` (no NUL added; the
+/// rest of the record's name field is left as `fill`).
+fn channels_with_raw_name(k: usize, name: &[u8], fill: u8) -> Vec {
+ let mut d = channels(DEFAULT_CHANNELS);
+ let at = 32 + k * 164 + 35;
+ d[at..at + 129].fill(fill);
+ d[at..at + name.len()].copy_from_slice(name);
+ d
+}
+
+/// A channel name that never ends (no NUL up to the end of its record) or is not UTF-8 is
+/// refused by the decoder and reported at the importer; the defaults R, G, B, A stay.
+#[test]
+fn extractor_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
+ let unterminated = channels_with_raw_name(2, b"Z", b'Z');
+ let invalid = channels_with_raw_name(1, &[0x5a, 0xff, 0xfe, 0x00], 0);
+ for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
+ assert_eq!(ExtractorChannels::parse(&data), None, "{what}");
+ let (g, r) = run(&extractor(vec![], data), "ec.3d.extractor");
+ let found = losses(&r, LossCategory::Effects, "EXtractoR");
+ assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
+ let g = g.expect("imported");
+ for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
+ assert_eq!(v(&g, id), Value::Str(want.into()), "{what}: {id}");
+ }
+ }
+}
+
+/// The same for IDentifier: a channel name without NUL up to the end of the data, or in invalid
+/// UTF-8, is refused and reported; the Object ID channel stays.
+#[test]
+fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
+ let mut unterminated = id_info("objectId");
+ unterminated[19..].fill(b'x');
+ let mut invalid = id_info("objectId");
+ invalid[19..22].copy_from_slice(&[b'o', 0xff, 0]);
+ for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
+ assert_eq!(identifier_channel_type(&data), None, "{what}");
+ let (g, r) = run(&identifier(vec![], data), "ec.3d.identifier");
+ let found = losses(&r, LossCategory::Effects, "IDentifier");
+ assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
+ assert_eq!(v(&g.expect("imported"), "channelType"), Value::Enum(0), "{what}");
+ }
+ // Data too short to reach the name.
+ assert_eq!(identifier_channel_type(&[1; 10]), None);
+}
+
+/// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports,
+/// with the defaults, nothing panics, and the missing data is reported as damaged (it may have
+/// chosen other channels, so the defaults are not what After Effects renders).
+#[test]
+fn a_missing_channel_info_payload_is_reported_and_leaves_the_defaults() {
+ let mut fx = extractor(vec![], vec![]);
+ fx.data.clear();
+ let (g, r) = run(&fx, "ec.3d.extractor");
+ assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new());
+ let found = losses(&r, LossCategory::DamagedData, "EXtractoR");
+ assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
+ let g = g.expect("imported");
+ for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
+ assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
+ }
+ let mut fx = identifier(vec![(3, 5.0)], vec![]);
+ fx.data.clear();
+ let (g, r) = run(&fx, "ec.3d.identifier");
+ assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::::new());
+ let found = losses(&r, LossCategory::DamagedData, "IDentifier");
+ assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
+ let g = g.expect("imported");
+ assert_eq!(v(&g, "channelType"), Value::Enum(0));
+ near(&g, "id", 5.0);
+}
diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs
index 81e6a821a..ae61794d3 100644
--- a/crates/aep/tests/it/import/fx_inject.rs
+++ b/crates/aep/tests/it/import/fx_inject.rs
@@ -103,18 +103,18 @@ pub fn tdmn(name: &str) -> Chunk<'static> {
}
/// A UTF-8 string chunk (`Utf8`).
-fn utf8(s: &str) -> Chunk<'static> {
+pub fn utf8(s: &str) -> Chunk<'static> {
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
}
/// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`).
-fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> {
+pub fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> {
Chunk { id, body: Body::Chunks(children), pad: None }
}
/// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint
/// (its header; without the expression).
-fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
+pub fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
let mut kids = Vec::new();
for c in template.children() {
match &c.body {
@@ -131,7 +131,7 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
}
/// A keyframed value record: `record`'s header with a key list of linear one-component keys.
-fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
+pub fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") };
let mut children = children;
let mut lhd3 = vec![0u8; 52];
@@ -229,39 +229,51 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option> {
None
}
-/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced.
-fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize {
+/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with the effect `match_name`
+/// and its `sspc`; the number replaced.
+fn replace_glow(chunks: &mut [Chunk<'_>], match_name: &str, sspc: &Chunk<'static>) -> usize {
let mut n = 0;
for ch in chunks {
let kids = ch.children_mut();
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
&& kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
{
- kids[at] = tdmn(fx.match_name);
- kids[at + 1] = sspc(fx, template);
+ kids[at] = tdmn(match_name);
+ kids[at + 1] = sspc.clone();
n += 1;
}
- n += replace_glow(kids, fx, template);
+ n += replace_glow(kids, match_name, sspc);
}
n
}
-/// `synthetic.aep` with `fx` in place of the Glow effect.
-pub fn bytes_with(fx: &Fx) -> Vec {
+/// `synthetic.aep` with the effect `match_name` in place of the Glow effect; `make` builds its
+/// `sspc` from the Glow's value record (the template of every record).
+pub fn bytes_with_effect(match_name: &str, make: impl Fn(&Chunk<'_>) -> Chunk<'static>) -> Vec {
let file = parse(SYNTHETIC).unwrap();
let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
+ let sspc = make(&template);
let mut file = parse(SYNTHETIC).unwrap();
- assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions");
+ assert_eq!(replace_glow(&mut file.chunks, match_name, &sspc), 2, "the Glow effect and its shared definitions");
write(&file).unwrap()
}
+/// `synthetic.aep` with `fx` in place of the Glow effect.
+pub fn bytes_with(fx: &Fx) -> Vec {
+ bytes_with_effect(fx.match_name, |t| sspc(fx, t))
+}
+
/// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if
/// imported) and the result.
pub fn run(fx: &Fx, effect: &str) -> (Option, ImportResult) {
- let bytes = bytes_with(fx);
+ import_bytes(&bytes_with(fx), effect)
+}
+
+/// Imports `bytes`: the first EffectCraft effect `effect` (if imported) and the result.
+pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option, ImportResult) {
let mut project = Project::default();
let none = |_: &str| false;
- let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
+ let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
let g = project
.comps()
.flat_map(|(_, c)| c.layers.iter())
diff --git a/crates/aep/tests/it/import/lumetri.rs b/crates/aep/tests/it/import/lumetri.rs
new file mode 100644
index 000000000..d31960cef
--- /dev/null
+++ b/crates/aep/tests/it/import/lumetri.rs
@@ -0,0 +1,573 @@
+//! Lumetri Color (`ADBE Lumetri`) arrives as EffectCraft's Lumetri Color (`ec.color.lumetri`),
+//! its parameters found by match name. The effect is built into the committed `synthetic.aep` in
+//! place of its Glow, in the layout of the 1200 samples (After Effects 2026, docs/aep-format.md
+//! 3.7): the definitions are a subset in an order of our own, so a mapping by `parT` index fails.
+
+use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import};
+use effectcraft_aep::{Body, Chunk, parse, write};
+use effectcraft_keyframe::Value;
+use effectcraft_project::{Project, PropGroup};
+
+const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
+
+fn tdmn(name: &str) -> Chunk<'static> {
+ let mut b = name.as_bytes().to_vec();
+ b.resize(40, 0);
+ Chunk::data(*b"tdmn", b)
+}
+
+/// A chunk holding chunks (`pdnm`, `fnam`: `NESTED_IDS`) with one `Utf8`.
+fn nested(id: [u8; 4], text: &[u8]) -> Chunk<'static> {
+ Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.to_vec())]), pad: None }
+}
+
+fn mn(suffix: u16) -> String {
+ format!("ADBE Lumetri-{suffix:04}")
+}
+
+/// A parameter definition (`pard`, 148 bytes): the type at byte 15, the name at 16, the default at
+/// 56 (a float slider's `f64`, a checkbox's or popup's `u32`), a popup's item count at 60, a float
+/// slider's valid range at 104 / 108 and slider range at 112 / 116 (`f32`).
+#[derive(Clone)]
+enum Def {
+ Float { name: &'static str, default: f64, min: f32, max: f32 },
+ Check { default: bool },
+ Popup { name: &'static str, items: &'static [&'static str] },
+ Data { name: &'static str },
+ Group { name: &'static str, end: bool },
+}
+
+fn pard(d: &Def) -> Vec> {
+ let mut b = vec![0u8; 148];
+ let name = |b: &mut Vec, n: &str| b[16..16 + n.len()].copy_from_slice(n.as_bytes());
+ let mut extra = Vec::new();
+ match d {
+ Def::Float { name: n, default, min, max } => {
+ b[15] = 10;
+ name(&mut b, n);
+ b[56..64].copy_from_slice(&default.to_be_bytes());
+ b[104..108].copy_from_slice(&min.to_be_bytes());
+ b[108..112].copy_from_slice(&max.to_be_bytes());
+ b[112..116].copy_from_slice(&min.to_be_bytes());
+ b[116..120].copy_from_slice(&max.to_be_bytes());
+ }
+ Def::Check { default } => {
+ b[15] = 4;
+ name(&mut b, " ");
+ b[56..60].copy_from_slice(&u32::from(*default).to_be_bytes());
+ }
+ Def::Popup { name: n, items } => {
+ b[15] = 7;
+ name(&mut b, n);
+ b[56..60].copy_from_slice(&1u32.to_be_bytes());
+ b[60..62].copy_from_slice(&u16::try_from(items.len()).unwrap().to_be_bytes());
+ extra.push(nested(*b"pdnm", items.join("|").as_bytes()));
+ }
+ Def::Data { name: n } => {
+ b[15] = 11;
+ name(&mut b, n);
+ }
+ Def::Group { name: n, end } => {
+ b[15] = if *end { 14 } else { 13 };
+ name(&mut b, n);
+ }
+ }
+ let mut v = vec![Chunk::data(*b"pard", b)];
+ v.extend(extra);
+ v
+}
+
+/// A stored value.
+#[derive(Clone)]
+enum Rec {
+ /// A number in a record of state `tdsb` (1 written, 3 the default state).
+ Num(f64, u32),
+ /// Arbitrary data.
+ Data(Vec),
+ /// A value record whose arbitrary data (`aRbs`) is missing.
+ NoData,
+}
+
+/// A static value record after the one After Effects wrote for Glow's A & B Midpoint (its header,
+/// one component).
+fn record(template: &Chunk<'_>, tdsb: u32, v: f64) -> Chunk<'static> {
+ let mut kids = Vec::new();
+ for c in template.children() {
+ match &c.body {
+ Body::Data(_) if c.id == *b"tdsb" => kids.push(Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec())),
+ Body::Data(d) if c.id == *b"tdb4" => kids.push(Chunk::data(c.id, d.to_vec())),
+ Body::Data(_) if c.id == *b"cdat" => {
+ let mut d = vec![0u8; 40];
+ d[..8].copy_from_slice(&v.to_be_bytes());
+ kids.push(Chunk::data(*b"cdat", d));
+ }
+ _ => {}
+ }
+ }
+ Chunk::list(*b"tdbs", kids)
+}
+
+/// The defaults of a fresh instance's data (1200 `lumetri-00-defaults`).
+fn empty() -> Vec {
+ vec![0xfe, 0xfe]
+}
+
+/// A Lumetri curve: type, point count, points (`f64` x, y), padded to 520 bytes.
+fn curve(kind: i32, pts: &[(f64, f64)]) -> Vec {
+ let mut d = kind.to_le_bytes().to_vec();
+ d.extend(u32::try_from(pts.len()).unwrap().to_le_bytes());
+ for (x, y) in pts {
+ d.extend(x.to_le_bytes());
+ d.extend(y.to_le_bytes());
+ }
+ d.resize(520, 0);
+ d
+}
+
+fn rgb_curves() -> Vec {
+ (0..4).flat_map(|_| curve(0, &[(0.0, 0.0), (1.0, 1.0)])).collect()
+}
+
+fn wheels(n: usize) -> Vec {
+ (0..n).flat_map(|_| [0.0f64, 0.5, 0.0].into_iter().flat_map(f64::to_le_bytes)).collect()
+}
+
+fn utf16(s: &str) -> Vec {
+ s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
+}
+
+/// HSL Secondary's key data as 1200 `lumetri-01-set-all` stores it (Set color RGB 0.02 0.55 0.85).
+const HSL_PICKED: &str = "01327f0f3fcdcc4c3dcdcccc3d01d519683f2e57c93dcade173e01dfdede3ecdcc4c3dcdcccc3d01";
+/// A fresh instance's key data (`-00-defaults`).
+const HSL_FRESH: &str = "000000003f0000000000000000010000003f0000000000000000010000003f000000000000000001";
+
+fn hex(s: &str) -> Vec {
+ (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
+}
+
+const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
+const LOOKS: &[&str] = &["None", "[Custom]", "Browse...", "(-", "SL BIG", "SL BLUE ICE"];
+
+/// The definitions, an order of our own (not After Effects' `parT` order) and without the ones
+/// After Effects 2026 added late (-0127 ... -0130).
+fn defs() -> Vec<(u16, Def)> {
+ let f = |name, default, min, max| Def::Float { name, default, min, max };
+ vec![
+ (1, Def::Data { name: "Blob" }),
+ (62, Def::Check { default: false }),
+ (2, Def::Group { name: "Basic Correction", end: false }),
+ (3, Def::Check { default: true }),
+ (95, Def::Data { name: " " }),
+ (64, f("HDR Specular", 0.0, -150.0, 150.0)),
+ (16, f("Blacks", 0.0, -150.0, 150.0)),
+ (11, f("Exposure", 0.0, -7.0, 7.0)),
+ (12, f("Contrast", 0.0, -150.0, 150.0)),
+ (7, f("Temperature", 0.0, -300.0, 300.0)),
+ (21, Def::Group { name: "", end: true }),
+ (22, Def::Group { name: "Creative", end: false }),
+ (23, Def::Check { default: true }),
+ (96, Def::Data { name: " " }),
+ (25, Def::Popup { name: "Look", items: LOOKS }),
+ (26, f("Intensity", 100.0, 0.0, 200.0)),
+ (32, Def::Data { name: "Split Toning" }),
+ (35, Def::Group { name: "", end: true }),
+ (39, Def::Data { name: "RGB Curve" }),
+ (104, Def::Check { default: true }),
+ (42, Def::Data { name: "Hue vs Sat" }),
+ (106, Def::Data { name: "Hue vs Hue" }),
+ (47, Def::Data { name: "Wheels" }),
+ (68, Def::Check { default: true }),
+ (73, Def::Data { name: "HSL Sliders" }),
+ (74, Def::Check { default: false }),
+ (75, Def::Popup { name: " ", items: MASK_STYLE }),
+ (85, Def::Data { name: "Color Wheels" }),
+ (86, f("Temperature", 0.0, -300.0, 300.0)),
+ (50, Def::Check { default: true }),
+ (51, f("Amount", 0.0, -5.0, 5.0)),
+ (54, f("Feather", 50.0, 0.0, 100.0)),
+ (97, Def::Check { default: false }),
+ ]
+}
+
+/// The data every instance stores, at the defaults.
+fn default_data() -> Vec<(u16, Rec)> {
+ vec![
+ (1, Rec::Data(empty())),
+ (95, Rec::Data(empty())),
+ (96, Rec::Data(empty())),
+ (32, Rec::Data(vec![0; 32])),
+ (39, Rec::Data(rgb_curves())),
+ (42, Rec::Data(curve(3, &[]))),
+ (106, Rec::Data(curve(3, &[]))),
+ (47, Rec::Data(wheels(4))),
+ (73, Rec::Data(hex(HSL_FRESH))),
+ (85, Rec::Data(wheels(8))),
+ ]
+}
+
+/// The Lumetri instance: the definitions, then the stored values `recs` (data replaces the
+/// default data of the same parameter).
+fn sspc(defs: &[(u16, Def)], recs: &[(u16, Rec)], template: &Chunk<'_>) -> Chunk<'static> {
+ let mut part = vec![Chunk::data(*b"parn", u32::try_from(defs.len() + 2).unwrap().to_be_bytes().to_vec()), tdmn(&mn(0))];
+ let mut layer = vec![0u8; 148];
+ layer[15] = 0;
+ part.push(Chunk::data(*b"pard", layer));
+ for (s, d) in defs {
+ part.push(tdmn(&mn(*s)));
+ part.extend(pard(d));
+ }
+ part.push(tdmn("ADBE Effect Built In Params"));
+ let mut end = vec![0u8; 148];
+ end[15] = 9;
+ part.push(Chunk::data(*b"pard", end));
+ let mut all: Vec<(u16, Rec)> = default_data().into_iter().filter(|(s, _)| !recs.iter().any(|(r, _)| r == s)).collect();
+ all.extend(recs.iter().cloned());
+ let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())];
+ for (s, r) in all {
+ values.push(tdmn(&mn(s)));
+ match r {
+ Rec::Num(v, tdsb) => values.push(record(template, tdsb, v)),
+ Rec::Data(d) => {
+ values.push(record(template, 1, 0.0));
+ values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d)]));
+ }
+ Rec::NoData => values.push(record(template, 1, 0.0)),
+ }
+ }
+ Chunk::list(*b"sspc", vec![nested(*b"fnam", b"Lumetri Color"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
+}
+
+fn find_glow_template<'a>(chunks: &'a [Chunk<'a>]) -> Option> {
+ for c in chunks {
+ let kids = c.children();
+ if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2-0011\0")))
+ && let Some(record) = kids.get(at + 1).filter(|k| k.is_list(b"tdbs"))
+ {
+ return Some(record.clone());
+ }
+ if let Some(t) = find_glow_template(kids) {
+ return Some(t);
+ }
+ }
+ None
+}
+
+/// Puts the instance in place of the Glow effect (in the effect parade and the project's shared
+/// definitions); the number of places.
+fn replace_glow(chunks: &mut [Chunk<'_>], instance: &Chunk<'static>) -> usize {
+ let mut n = 0;
+ for ch in chunks {
+ let kids = ch.children_mut();
+ if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
+ && kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
+ {
+ kids[at] = tdmn("ADBE Lumetri");
+ kids[at + 1] = instance.clone();
+ n += 1;
+ }
+ n += replace_glow(kids, instance);
+ }
+ n
+}
+
+fn run_with(defs: &[(u16, Def)], recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
+ let file = parse(SYNTHETIC).unwrap();
+ let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
+ let instance = sspc(defs, recs, &template);
+ let mut file = parse(SYNTHETIC).unwrap();
+ assert_eq!(replace_glow(&mut file.chunks, &instance), 2, "the Glow effect and its shared definitions");
+ let bytes = write(&file).unwrap();
+ let mut project = Project::default();
+ let none = |_: &str| false;
+ let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
+ let g = project
+ .comps()
+ .flat_map(|(_, c)| c.layers.iter())
+ .find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == "ec.color.lumetri").cloned()))
+ .expect("an EffectCraft Lumetri Color");
+ (g, r)
+}
+
+fn run(recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
+ run_with(&defs(), recs)
+}
+
+fn losses(r: &ImportResult) -> Vec {
+ r.report
+ .losses
+ .iter()
+ .filter(|l| matches!(l.category, LossCategory::Effects | LossCategory::DamagedData))
+ .flat_map(|l| l.details.clone())
+ .filter(|d| d.contains("Lumetri"))
+ .collect()
+}
+
+fn num(g: &PropGroup, id: &str) -> f64 {
+ g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.as_f64()
+}
+
+fn val(g: &PropGroup, id: &str) -> Value {
+ g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.clone()
+}
+
+#[test]
+fn a_fresh_lumetri_arrives_without_losses() {
+ let (g, r) = run(&[]);
+ assert_eq!(losses(&r), Vec::::new());
+ assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "creative/lookIntensity"), num(&g, "vignette/vignetteFeather")), (0.0, 100.0, 50.0));
+ assert_eq!(val(&g, "creative/look"), Value::Enum(0));
+}
+
+#[test]
+fn lumetri_values_arrive_by_match_name_also_from_default_state_records() {
+ // 1200 `lumetri-01-set-all` / `-06-sec-light`: High Dynamic Range and HDR Specular are
+ // recorded in the default state (`tdsb` 3) and read back as set.
+ let (g, r) = run(&[
+ (62, Rec::Num(1.0, 3)),
+ (64, Rec::Num(22.0, 3)),
+ (11, Rec::Num(2.589_999_914_169_311_5, 1)),
+ (12, Rec::Num(37.0, 1)),
+ (16, Rec::Num(-150.0, 1)),
+ (7, Rec::Num(300.0, 1)),
+ (26, Rec::Num(134.0, 1)),
+ (51, Rec::Num(1.85, 1)),
+ (74, Rec::Num(1.0, 1)),
+ (75, Rec::Num(3.0, 1)),
+ (73, Rec::Data(hex(HSL_PICKED))),
+ ]);
+ assert_eq!(losses(&r), Vec::::new());
+ assert_eq!(val(&g, "highDynamicRange"), Value::Bool(true));
+ for (id, want) in [
+ ("basicCorrection/tone/hdrSpecular", 22.0),
+ ("basicCorrection/tone/exposure", 2.589_999_914_169_311_5),
+ ("basicCorrection/tone/contrast", 37.0),
+ ("basicCorrection/tone/blacks", -150.0),
+ // Past EffectCraft's old slider range: kept (the range is After Effects' now).
+ ("basicCorrection/whiteBalance/temperature", 300.0),
+ ("creative/lookIntensity", 134.0),
+ ("vignette/vignetteAmount", 1.85),
+ ] {
+ assert_eq!(num(&g, id), want, "{id}");
+ }
+ assert_eq!((val(&g, "hslSecondary/key/showMask"), val(&g, "hslSecondary/key/maskStyle")), (Value::Bool(true), Value::Enum(2)));
+}
+
+#[test]
+fn a_switched_off_section_is_reported_and_not_applied() {
+ // 1200 `-03-sec-basic-correction`, `-17-sec-vignette`: a section that is off does nothing.
+ let (g, r) = run(&[(3, Rec::Num(0.0, 1)), (11, Rec::Num(2.0, 1)), (50, Rec::Num(0.0, 1)), (51, Rec::Num(-2.0, 1)), (12, Rec::Num(37.0, 1))]);
+ assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "basicCorrection/tone/contrast"), num(&g, "vignette/vignetteAmount")), (0.0, 0.0, 0.0));
+ let l = losses(&r);
+ assert_eq!(l.len(), 2, "{l:?}");
+ assert!(l[0].contains("section Basic Correction is switched off") && l[0].contains("Exposure 2, Contrast 37"), "{l:?}");
+ assert!(l[1].contains("section Vignette is switched off") && l[1].contains("Amount -2"), "{l:?}");
+}
+
+#[test]
+fn a_look_is_reported_by_name() {
+ // 1200 `-01-set-all`: the Look's name in -0096, the popup's record in the default state.
+ let (g, r) = run(&[(96, Rec::Data(utf16("SL BLUE ICE"))), (25, Rec::Num(6.0, 3))]);
+ assert_eq!(val(&g, "creative/look"), Value::Enum(0));
+ let l = losses(&r);
+ assert_eq!(l.len(), 1, "{l:?}");
+ assert!(l[0].contains("the Look 'SL BLUE ICE' is not imported"), "{l:?}");
+ // Only the popup: its item.
+ let (_, r) = run(&[(25, Rec::Num(5.0, 1))]);
+ assert!(losses(&r).iter().any(|d| d.contains("the Look 'SL BIG' is not imported")), "{:?}", losses(&r));
+}
+
+#[test]
+fn hue_vs_sat_arrives_with_its_y_scaled_other_moved_curves_are_reported() {
+ // 1200 `-11-sec-hue-saturation-curves`: the selector put three neutral points on the curve.
+ let neutral = curve(3, &[(0.742_483, 0.0), (0.798_038, 0.0), (0.853_594, 0.0)]);
+ let (g, r) = run(&[(42, Rec::Data(neutral))]);
+ assert_eq!(losses(&r), Vec::::new());
+ assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
+ // 1200 `-61-preset-curves-huesat`: red raised to 0.97 (After Effects nearly doubles its
+ // saturation), the neighbours pinned near 0. EffectCraft's y: 0.5 + y / 2.
+ let preset = curve(3, &[(0.015_463_917_525_773_196, 0.97), (0.262_886_597_938_144_34, -0.03), (0.764_604_810_996_563_5, -0.02)]);
+ let (g, r) = run(&[(42, Rec::Data(preset))]);
+ assert_eq!(losses(&r), Vec::::new());
+ let Value::Str(text) = val(&g, "curves/hueSaturationCurves/hueVsSat") else { panic!("a point list") };
+ let pts: Vec<(f64, f64)> = text
+ .split(' ')
+ .map(|p| {
+ let (x, y) = p.split_once(',').unwrap();
+ (x.parse().unwrap(), y.parse().unwrap())
+ })
+ .collect();
+ let want = [(0.015_464, 0.985), (0.262_887, 0.485), (0.764_605, 0.49)];
+ assert_eq!(pts.len(), 3, "{text}");
+ for ((x, y), (wx, wy)) in pts.iter().zip(want) {
+ assert!((x - wx).abs() < 1e-5 && (y - wy).abs() < 1e-9, "{text}");
+ }
+ // Hue vs Hue moved: no sample tells its scale, reported and left neutral.
+ let (g, r) = run(&[(106, Rec::Data(curve(3, &[(0.5, 0.25)])))]);
+ let l = losses(&r);
+ assert!(l.len() == 1 && l[0].contains("Hue vs Hue (ADBE Lumetri-0106) is not imported"), "{l:?}");
+ assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsHue"), Value::Str(String::new()));
+ // A point count past the data: not a default, reported as damaged, no panic.
+ let mut bad = curve(3, &[]);
+ bad[4..8].copy_from_slice(&1000u32.to_le_bytes());
+ let (g, r) = run(&[(42, Rec::Data(bad))]);
+ let l = losses(&r);
+ assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is unreadable"), "{l:?}");
+ assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
+}
+
+#[test]
+fn the_master_rgb_curve_arrives_moved_channel_curves_are_reported() {
+ // 1200 `-60-preset-curves-rgb`: the first of the four curves, its midpoint raised.
+ let master = curve(0, &[(0.0, 0.0), (0.491_408_934_707_903_76, 0.786_885_245_901_639_3), (1.0, 1.0)]);
+ let identity = curve(0, &[(0.0, 0.0), (1.0, 1.0)]);
+ let mut d = master.clone();
+ (0..3).for_each(|_| d.extend(&identity));
+ let (g, r) = run(&[(39, Rec::Data(d))]);
+ assert_eq!(losses(&r), Vec::::new());
+ assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 0.49140893470790376,0.7868852459016393 1,1".into()));
+ assert_eq!(val(&g, "curves/rgbCurves/red"), Value::Str("0,0 1,1".into()));
+ // A moved channel curve: which channel it is is not known, reported.
+ let mut d = identity.clone();
+ d.extend(&master);
+ (0..2).for_each(|_| d.extend(&identity));
+ let (g, r) = run(&[(39, Rec::Data(d))]);
+ let l = losses(&r);
+ assert!(l.len() == 1 && l[0].contains("1 channel curve(s) of RGB Curve (ADBE Lumetri-0039) are not imported"), "{l:?}");
+ assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
+ // Damaged: the wrong length.
+ let (_, r) = run(&[(39, Rec::Data(master))]);
+ assert!(losses(&r).iter().any(|x| x.contains("RGB Curve (ADBE Lumetri-0039) is unreadable")), "{:?}", losses(&r));
+}
+
+/// A value record without its arbitrary data is damaged, not the default: reported for the RGB
+/// curve and the HSL key, the neighbours stay as they were.
+#[test]
+fn a_value_record_without_its_data_is_reported_as_damaged() {
+ for (suffix, name) in [(39, "RGB Curve (ADBE Lumetri-0039)"), (73, "HSL Sliders (ADBE Lumetri-0073)")] {
+ let (g, r) = run(&[(suffix, Rec::NoData)]);
+ let l = losses(&r);
+ assert!(l.len() == 1 && l[0].contains(name) && l[0].contains("is missing"), "{suffix}: {l:?}");
+ assert!(r.report.losses.iter().any(|x| x.category == LossCategory::DamagedData), "{suffix}");
+ // The grade stays at the default.
+ assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
+ assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
+ }
+}
+
+#[test]
+fn colour_wheels_arrive_as_colours_their_brightness_is_reported() {
+ // 1200 `-62-preset-wheels`: Shadows towards blue with the slider down, Midtones towards red,
+ // Highlights towards yellow-green with the slider up; the fourth wheel at its default.
+ let w: Vec = [
+ 229.119_895_976_548_18,
+ 0.369_892_467_494_294_2,
+ 0.547_209_348_004_638,
+ 0.104_745_366_878_020_72,
+ 0.5,
+ 0.394_961_840_132_529_44,
+ 93.374_076_744_668_57,
+ 0.726_881_718_523_399_9,
+ 0.318_976_840_675_999_35,
+ 0.0,
+ 0.5,
+ 0.0,
+ ]
+ .into_iter()
+ .flat_map(f64::to_le_bytes)
+ .collect();
+ let (g, r) = run(&[(47, Rec::Data(w.clone()))]);
+ let colour = |id: &str| match val(&g, id) {
+ Value::Color(c) => c,
+ other => panic!("{id}: {other:?}"),
+ };
+ // The hue's direction: blue strongest in Shadows, red in Midtones, green in Highlights.
+ let s = colour("colorWheels/shadowsWheel");
+ assert!(s[2] > 0.77 && s[0] < 0.23 && (s[0] + s[2] - 1.0).abs() < 1e-9, "{s:?}");
+ let m = colour("colorWheels/midtonesWheel");
+ assert!((m[0] - 0.697_480_920_066_264_7).abs() < 1e-9 && m[1] > m[2], "{m:?}");
+ let h = colour("colorWheels/highlightsWheel");
+ assert!(h[1] > h[0] && h[0] > h[2], "{h:?}");
+ let l = losses(&r);
+ assert!(l.len() == 1 && l[0].contains("Shadows brightness 0.370") && l[0].contains("Highlights brightness 0.727"), "{l:?}");
+ assert!(!l[0].contains("Midtones") && !l[0].contains("fourth"), "{l:?}");
+ // The fourth wheel set: reported; damaged data: reported, the wheels stay grey.
+ let mut w4 = w;
+ w4[88..96].copy_from_slice(&0.3f64.to_le_bytes());
+ let (_, r) = run(&[(47, Rec::Data(w4))]);
+ assert!(losses(&r).iter().any(|x| x.contains("the fourth wheel")), "{:?}", losses(&r));
+ let (g, r) = run(&[(47, Rec::Data(vec![1; 95]))]);
+ assert!(losses(&r).iter().any(|x| x.contains("Wheels (ADBE Lumetri-0047) is unreadable")), "{:?}", losses(&r));
+ assert_eq!(val(&g, "colorWheels/shadowsWheel"), Value::Color([0.5, 0.5, 0.5, 1.0]));
+}
+
+#[test]
+fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() {
+ let (g, r) = run(&[(73, Rec::Data(hex(HSL_PICKED)))]);
+ assert_eq!(losses(&r), Vec::::new());
+ let close = |id: &str, want: f64| assert!((num(&g, id) - want).abs() < 0.01, "{id}: {} != {want}", num(&g, id));
+ // Hue centre 0.5605 of the circle, range 0.05, tolerance 0.1.
+ close("hslSecondary/key/hueCenter", 201.79);
+ close("hslSecondary/key/hueRange", 18.0);
+ close("hslSecondary/key/hueSoftness", 36.0);
+ // Lightness centre 0.435 +- 0.05, tolerance 0.1, as percent.
+ close("hslSecondary/key/lightnessLow", 38.53);
+ close("hslSecondary/key/lightnessHigh", 48.53);
+ close("hslSecondary/key/lightnessSoftness", 10.0);
+ assert_eq!(val(&g, "hslSecondary/key/hueEnabled"), Value::Bool(true));
+ // With the key picked the correction applies; without, After Effects applies it to nothing
+ // (1200 `-16-sec-correction` renders as `-00-defaults`): left out and reported.
+ let (g, _) = run(&[(73, Rec::Data(hex(HSL_PICKED))), (86, Rec::Num(19.0, 1))]);
+ assert_eq!(num(&g, "hslSecondary/correction/temperature"), 19.0);
+ let (g, r) = run(&[(86, Rec::Num(19.0, 1))]);
+ assert_eq!(num(&g, "hslSecondary/correction/temperature"), 0.0);
+ let l = losses(&r);
+ assert!(l.len() == 1 && l[0].contains("HSL Secondary has no key colour picked") && l[0].contains("Temperature 19"), "{l:?}");
+ // Damaged key data: reported, the default key kept.
+ let (g, r) = run(&[(73, Rec::Data(vec![7; 40]))]);
+ assert!(losses(&r).iter().any(|d| d.contains("HSL Secondary key data is unreadable")), "{:?}", losses(&r));
+ assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
+}
+
+#[test]
+fn undecoded_data_and_controls_without_counterpart_are_reported_when_set() {
+ let mut split = vec![0u8; 32];
+ split[0] = 1;
+ let (_, r) = run(&[(32, Rec::Data(split)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]);
+ let l = losses(&r);
+ assert_eq!(l.len(), 3, "{l:?}");
+ assert!(l.iter().any(|d| d.contains("Split Toning (ADBE Lumetri-0032) is not imported")), "{l:?}");
+ assert!(l.iter().any(|d| d.contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on")), "{l:?}");
+ assert!(l.iter().any(|d| d.contains("the Input LUT 'my.cube' is not imported")), "{l:?}");
+}
+
+#[test]
+fn legacy_tone_on_is_reported_whatever_the_pard_default_says() {
+ // After the project is opened and saved again, the `pard` default of Legacy Tone And
+ // Saturation reads on (versions/1200-lumetri-26 / -25x / -24x; off in 1200 as the script
+ // saved it): a record that says on is still reported, a missing record is not.
+ let mut d = defs();
+ if let Some((_, Def::Check { default })) = d.iter_mut().find(|(s, _)| *s == 97) {
+ *default = true;
+ }
+ let (_, r) = run_with(&d, &[(97, Rec::Num(1.0, 3))]);
+ let l = losses(&r);
+ assert!(l.len() == 1 && l[0].contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on"), "{l:?}");
+ let (_, r) = run_with(&d, &[]);
+ assert_eq!(losses(&r), Vec::::new());
+}
+
+#[test]
+fn an_unknown_scale_or_parameter_is_reported_not_guessed() {
+ let mut d = defs();
+ // Exposure with another valid range (an older or newer version): not guessed at.
+ if let Some((_, Def::Float { min, max, .. })) = d.iter_mut().find(|(s, _)| *s == 11) {
+ (*min, *max) = (-100.0, 100.0);
+ }
+ // A parameter this importer does not know, set.
+ d.push((200, Def::Float { name: "Future", default: 0.0, min: -1.0, max: 1.0 }));
+ let (g, r) = run_with(&d, &[(11, Rec::Num(3.0, 1)), (200, Rec::Num(0.5, 1))]);
+ assert_eq!(num(&g, "basicCorrection/tone/exposure"), 0.0);
+ let l = losses(&r);
+ assert!(l.iter().any(|x| x.contains("Exposure (ADBE Lumetri-0011) has the valid maximum Some(100.0), expected 7")), "{l:?}");
+ assert!(l.iter().any(|x| x.contains("Future (ADBE Lumetri-0200) not imported")), "{l:?}");
+}
diff --git a/crates/aep/tests/it/import/puppet.rs b/crates/aep/tests/it/import/puppet.rs
new file mode 100644
index 000000000..a3054286e
--- /dev/null
+++ b/crates/aep/tests/it/import/puppet.rs
@@ -0,0 +1,171 @@
+//! After Effects' Puppet (`ADBE FreePin3`, #3093) arrives as EffectCraft's Puppet
+//! (`ec.distort.puppet`) in its place in the stack, and what its meshes and pins hold is reported
+//! mesh by mesh instead of the one line "has the effect 'Puppet'". The effect is built into the
+//! committed `synthetic.aep` in place of its Glow (`fx_inject`, in memory), in the tree
+//! docs/aep-format.md 3.7 gives for Puppet. That tree comes from an external scripting listing,
+//! not a byte sample: these tests prove the reader's walk and its report, not the format.
+
+use effectcraft_aep::Chunk;
+use effectcraft_aep::import::{ImportOptions, LossCategory, import};
+use effectcraft_project::Project;
+
+use crate::import::fx_inject::{bytes_with_effect, import_bytes, keyed_record, losses, nested, record, tdmn, utf8};
+
+const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
+const PUPPET: &str = "ADBE FreePin3";
+const POS_ATOM: &str = "ADBE FreePin3 PosPin Atom";
+
+fn tdsb(v: u32) -> Chunk<'static> {
+ Chunk::data(*b"tdsb", v.to_be_bytes().to_vec())
+}
+
+/// A property group of `entries` (match name, node).
+fn group(entries: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
+ let mut kids = vec![tdsb(1)];
+ for (name, node) in entries {
+ kids.push(tdmn(name));
+ kids.push(node);
+ }
+ Chunk::list(*b"tdgp", kids)
+}
+
+/// A pin atom with its hidden vertex records and Position (keyed when `keys`). The values are
+/// one-component records: the reader only asks whether a pin is keyed.
+fn pin(t: &Chunk<'_>, atom: &'static str, keys: bool) -> (&'static str, Chunk<'static>) {
+ let position = if keys { keyed_record(t, &[(0, 1.0), (25_600, 2.0)]) } else { record(t, 1.0) };
+ (
+ atom,
+ group(vec![
+ ("ADBE FreePin3 PosPin Vtx Offset", record(t, 0.0)),
+ ("ADBE FreePin3 PosPin Vtx Index", record(t, 7.0)),
+ ("ADBE FreePin3 PosPin Position", position),
+ ]),
+ )
+}
+
+/// `Mesh 1`: Triangles 500, Expansion 5, four Deform pins (one keyed, one with an atom name
+/// nobody knows) and a property among them, no Overlap pin, one Stiffness pin, and a group
+/// nobody knows.
+fn full_mesh(t: &Chunk<'_>) -> Chunk<'static> {
+ let mesh_data =
+ Chunk::list(*b"tdbs", vec![tdsb(1), Chunk::data(*b"cdat", vec![0u8; 4]), Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![1, 2, 3, 4])])]);
+ let deform = group(vec![
+ pin(t, POS_ATOM, false),
+ pin(t, POS_ATOM, true),
+ pin(t, POS_ATOM, false),
+ pin(t, "ADBE FreePin3 Test Atom", false),
+ ("ADBE FreePin3 Test Prop", record(t, 3.0)),
+ ]);
+ let starch = group(vec![("ADBE FreePin3 StarchPin Atom", group(vec![("ADBE FreePin3 StarchPin Amount", record(t, 0.5))]))]);
+ group(vec![
+ ("ADBE FreePin3 Mesh", mesh_data),
+ ("ADBE FreePin3 Mesh Tri Count", record(t, 500.0)),
+ ("ADBE FreePin3 Mesh Expansion", record(t, 5.0)),
+ ("ADBE FreePin3 PosPins", deform),
+ ("ADBE FreePin3 HghtPins", group(vec![])),
+ ("ADBE FreePin3 StarchPins", starch),
+ ("ADBE FreePin3 Test Group", group(vec![])),
+ ])
+}
+
+/// The Puppet instance: `fx_switch` is the value group's `tdsb`, `arap` its mesh tree (`None`:
+/// none), `extra` more entries of its value group.
+fn sspc(fx_switch: u32, arap: Option>, extra: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
+ let pard = |kind: u8| {
+ let mut b = vec![0u8; 148];
+ b[15] = kind;
+ Chunk::data(*b"pard", b)
+ };
+ let part = vec![Chunk::data(*b"parn", 2u32.to_be_bytes().to_vec()), tdmn("ADBE FreePin3-0000"), pard(0), tdmn("ADBE Effect Built In Params"), pard(9)];
+ let mut values = vec![tdsb(fx_switch)];
+ if let Some(arap) = arap {
+ values.push(tdmn("ADBE FreePin3 ARAP Group"));
+ values.push(arap);
+ }
+ for (name, node) in extra {
+ values.push(tdmn(name));
+ values.push(node);
+ }
+ Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Puppet")]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
+}
+
+/// The layer that holds `effect`, the effect's place in that layer's stack and the stack's size.
+fn stack_place(bytes: &[u8], effect: &str) -> Option<(String, usize, usize)> {
+ let mut project = Project::default();
+ let none = |_: &str| false;
+ import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
+ project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| {
+ let fx: Vec<_> = l.props.sub("effects")?.children.iter().filter_map(|n| n.as_group()).collect();
+ let at = fx.iter().position(|g| g.match_id == effect)?;
+ Some((l.name.clone(), at, fx.len()))
+ })
+}
+
+#[test]
+fn puppet_arrives_and_its_pins_are_reported_mesh_by_mesh() {
+ let bytes = bytes_with_effect(PUPPET, |t| {
+ let arap = group(vec![("ADBE FreePin3 Outlines", group(vec![])), ("ADBE FreePin3 Mesh Group", group(vec![("ADBE FreePin3 Mesh Atom", full_mesh(t))]))]);
+ sspc(1, Some(arap), vec![("ADBE FreePin3 On Transparent", record(t, 1.0))])
+ });
+ let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
+ let g = g.expect("an EffectCraft Puppet in the Glow's place");
+ assert!(g.enabled);
+ assert_eq!(g.name, "Puppet");
+ // No pin is made up: the rest places are not decoded.
+ assert_eq!(effectcraft_effects::puppet::pin_count(&g), 0);
+ // In the Glow's place in its layer's stack (the second of two effects there).
+ assert!(stack_place(SYNTHETIC, "ec.stylize.glow").is_some_and(|(_, at, n)| at > 0 && n > 1));
+ assert_eq!(stack_place(&bytes, effectcraft_effects::puppet::ID), stack_place(SYNTHETIC, "ec.stylize.glow"));
+ let l = losses(&r, LossCategory::Effects, PUPPET);
+ assert_eq!(l.len(), 2, "{l:#?}");
+ assert!(!l.iter().any(|d| d.contains("has the effect")), "not the old one-line report: {l:#?}");
+ assert!(l.iter().any(|d| d.ends_with("property ADBE FreePin3 On Transparent (1) not imported")), "{l:#?}");
+ let mesh = l.iter().find(|d| d.contains("imported without its meshes and pins")).expect("the mesh report");
+ assert!(
+ mesh.ends_with(
+ ": Mesh 1 (Triangles 500, Expansion 5): Deform 4 pins (1 keyed) [ADBE FreePin3 PosPin Atom, ADBE FreePin3 Test Atom], \
+ Overlap no pins, Stiffness 1 pin (0 keyed) [ADBE FreePin3 StarchPin Atom]; not recognised: ADBE FreePin3 Test Prop, ADBE FreePin3 Test Group"
+ ),
+ "{mesh}"
+ );
+ assert_eq!(losses(&r, LossCategory::DamagedData, PUPPET), Vec::::new());
+}
+
+#[test]
+fn many_meshes_are_listed_up_to_ten_and_names_once() {
+ let bytes = bytes_with_effect(PUPPET, |_| {
+ let mesh = || group(vec![("ADBE FreePin3 Test Group", group(vec![]))]);
+ let meshes = (0..12).map(|_| ("ADBE FreePin3 Mesh Atom", mesh())).collect();
+ sspc(1, Some(group(vec![("ADBE FreePin3 Mesh Group", group(meshes))])), vec![])
+ });
+ let (_, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
+ let l = losses(&r, LossCategory::Effects, PUPPET);
+ assert_eq!(l.len(), 1, "{l:#?}");
+ assert!(l[0].ends_with("Mesh 9: no pin groups; Mesh 10: no pin groups; and 2 more; not recognised: ADBE FreePin3 Test Group"), "{}", l[0]);
+}
+
+#[test]
+fn a_puppet_without_a_mesh_loses_nothing_and_keeps_its_fx_switch() {
+ let bytes = bytes_with_effect(PUPPET, |_| sspc(0, Some(group(vec![("ADBE FreePin3 Mesh Group", group(vec![]))])), vec![]));
+ let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
+ let g = g.expect("an EffectCraft Puppet");
+ assert!(!g.enabled, "switched off in the Effect Controls");
+ assert_eq!(losses(&r, LossCategory::Effects, PUPPET), Vec::::new());
+}
+
+#[test]
+fn a_puppet_whose_mesh_tree_is_not_found_is_reported() {
+ let bytes = bytes_with_effect(PUPPET, |t| sspc(1, None, vec![("ADBE FreePin3 Other Group", group(vec![("x", record(t, 1.0))]))]));
+ let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
+ assert!(g.is_some(), "the effect still arrives");
+ let l = losses(&r, LossCategory::Effects, PUPPET);
+ assert_eq!(l.len(), 1, "{l:#?}");
+ assert!(
+ l[0].ends_with(
+ "imported without meshes or pins: its mesh group (ADBE FreePin3 ARAP Group / ADBE FreePin3 Mesh Group) was not found or is not a group; \
+ not recognised: ADBE FreePin3 Other Group"
+ ),
+ "{}",
+ l[0]
+ );
+}
diff --git a/crates/aep/tests/it/import/robustness.rs b/crates/aep/tests/it/import/robustness.rs
index 1a42665bc..902c9cc4b 100644
--- a/crates/aep/tests/it/import/robustness.rs
+++ b/crates/aep/tests/it/import/robustness.rs
@@ -626,9 +626,10 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
"{r:?}"
);
- // No project-level definitions: a Ramp with an empty parT cannot be read and is reported; one
- // with its own parT still arrives. (In the single-case file one of the two had its own; in
- // the combined file an earlier case holds the definitions, so both take the project copy.)
+ // No project-level definitions: a Ramp with an empty parT borrows the definitions of a Ramp
+ // in the file that has its own parT (#3170). In the single-case file one of the two has its
+ // own, so both arrive; in the case cut from the combined file an earlier case (not kept)
+ // held them, so neither has readable definitions and both are reported.
let own = {
let bytes = skip!(crate::common::try_fixture("383-fx-ramp-on-100x100-start-10-20.aep"));
let file = parse(&bytes).unwrap();
@@ -646,8 +647,9 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
}
}));
let r = report_of(&bytes);
- assert_eq!(r.imported.effects, own, "{r:?}");
- assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - own, "{r:?}");
+ let arrive = if own > 0 { 2 } else { 0 };
+ assert_eq!(r.imported.effects, arrive, "{r:?}");
+ assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - arrive, "{r:?}");
assert!(try_all(&bytes)); // 368, the first Gradient Ramp of its file, has its own parT: it still arrives.
let bytes = skip!(mutate("368-fx-ramp-default.aep", &mut |c| {
if let Body::List { list_type, .. } = &mut c.body
diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs
index 18709f9e5..8e4c28f16 100644
--- a/crates/aep/tests/it/import/synthetic.rs
+++ b/crates/aep/tests/it/import/synthetic.rs
@@ -77,6 +77,167 @@ fn a_sequence_with_long_names_is_not_expanded_past_the_byte_budget() {
assert_eq!(longest, 0, "the sequence was not expanded");
}
+/// The project with the sequence's naming (the chunks between `Als2` and `opti` in its `Pin `)
+/// replaced by `naming`, and the `sspc` numbering (start, end, digits at 172, 176, 180) set to
+/// `numbering` when given.
+fn with_sequence_naming(naming: &[Chunk<'static>], numbering: Option<[u32; 3]>) -> Vec {
+ let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
+ let mut patched = 0;
+ visit(&mut file.chunks, &mut |c| {
+ if !c.is_list(b"Pin ") {
+ return;
+ }
+ let Body::List { children, .. } = &mut c.body else { return };
+ let Some(als) = children.iter().position(|k| k.is_list(b"Als2")) else { return };
+ let Some(opti) = children.iter().position(|k| k.is(b"opti")) else { return };
+ assert!(als < opti);
+ let tail = children.split_off(opti);
+ children.truncate(als + 1);
+ children.extend(naming.iter().map(shorten));
+ children.extend(tail);
+ if let Some(numbering) = numbering {
+ let sspc = children.iter_mut().find(|k| k.is(b"sspc")).unwrap();
+ for (at, v) in [172, 176, 180].into_iter().zip(numbering) {
+ set_data(sspc, at, &v.to_be_bytes());
+ }
+ }
+ patched += 1;
+ });
+ assert_eq!(patched, 1, "the synthetic project has one sequence");
+ write(&file).unwrap()
+}
+
+/// `bytes` with the first file name the sequence stores in its png `opti` (byte 58, NUL-terminated)
+/// set to `name`.
+fn with_first_file(bytes: &[u8], name: &str) -> Vec {
+ let mut file: AepFile<'_> = parse(bytes).unwrap();
+ let mut patched = 0;
+ visit(&mut file.chunks, &mut |c| {
+ if c.is(b"opti") && c.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..])) {
+ let mut z = name.as_bytes().to_vec();
+ z.push(0);
+ set_data(c, 58, &z);
+ patched += 1;
+ }
+ });
+ assert_eq!(patched, 1, "the synthetic project has one png item");
+ write(&file).unwrap()
+}
+
+/// The imported sequence item's footage.
+fn the_footage(p: &Project) -> effectcraft_project::Footage {
+ p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).expect("the sequence")
+}
+
+/// The file names (without the folder) of a footage's sequence.
+fn file_names(f: &effectcraft_project::Footage) -> Vec {
+ f.sequence.iter().map(|s| s.rsplit(['\\', '/']).next().unwrap_or_default().to_string()).collect()
+}
+
+/// A copy of `c` that borrows for any lifetime (a chunk is covariant in its lifetime).
+fn shorten<'a>(c: &Chunk<'static>) -> Chunk<'a> {
+ c.clone()
+}
+
+/// A `Utf8` string chunk holding `s`.
+fn utf8(s: &str) -> Chunk<'static> {
+ Chunk::data(*b"Utf8", s.as_bytes().to_vec())
+}
+
+/// A sequence whose naming has only a suffix (no filename prefix) imports as a sequence of its files.
+#[test]
+fn a_sequence_without_a_filename_prefix_imports_its_files() {
+ // `0001.png` .. `0003.png`: After Effects omits the empty prefix, so only `.png` follows the
+ // alias. Before, `.png` was read as the prefix, the suffix was missing and the item became a
+ // video linked to its folder.
+ let (p, r) = run(&with_sequence_naming(&[utf8(".png")], None), true);
+ r.unwrap();
+ let f = the_footage(&p);
+ assert_eq!(f.kind, FootageKind::Sequence);
+ assert_eq!(file_names(&f), ["0001.png", "0002.png", "0003.png"]);
+ assert!(f.path.ends_with("0001.png"), "{}", f.path);
+}
+
+/// A single `Utf8` naming part is read as the suffix or the prefix depending on how the stored first file name matches it.
+#[test]
+fn a_single_naming_part_is_placed_by_the_stored_first_file_name() {
+ // `0001_v2.png` ..: the one `Utf8` is the suffix, as the first file name (`opti`) ends with it.
+ let bytes = with_first_file(&with_sequence_naming(&[utf8("_v2.png")], None), "0001_v2.png");
+ let (p, r) = run(&bytes, true);
+ r.unwrap();
+ let f = the_footage(&p);
+ assert_eq!(f.kind, FootageKind::Sequence);
+ assert_eq!(file_names(&f), ["0001_v2.png", "0002_v2.png", "0003_v2.png"]);
+ // `shot_0001` ..: a name without an extension; the one `Utf8` is the prefix.
+ let bytes = with_first_file(&with_sequence_naming(&[utf8("shot_")], None), "shot_0001");
+ let (p, r) = run(&bytes, true);
+ r.unwrap();
+ assert_eq!(file_names(&the_footage(&p)), ["shot_0001", "shot_0002", "shot_0003"]);
+ // Without that evidence (the stored first file is `shot_0001.png`), a part that is no extension
+ // is not guessed: the item is reported.
+ let bytes = with_sequence_naming(&[utf8("_v2.png")], None);
+ reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
+}
+
+/// A file list containing a name with a separator, drive colon, dot directory or control character is refused and reported.
+#[test]
+fn a_file_list_with_a_name_that_is_not_a_plain_file_name_is_reported() {
+ // The listed names are joined to the folder: a separator, a drive colon or a dot directory
+ // would point outside it, and a NUL or another control character is no file name, so such a
+ // list is not read.
+ for hostile in ["..\\..\\x.png", "../x.png", "C:x.png", "..", ".", "x\0.png", "x\n.png"] {
+ let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("a.png"), utf8(hostile)]);
+ let bytes = with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0]));
+ reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
+ }
+}
+
+/// A file list with a missing or too short count chunk is refused and reported.
+#[test]
+fn a_file_list_without_a_readable_count_is_reported() {
+ // No `StVS`, or one too short for its `u32`: the list is not read rather than taken whole.
+ for count in [None, Some(vec![2u8, 0])] {
+ let mut kids: Vec> = count.map(|c| Chunk::data(*b"StVS", c)).into_iter().collect();
+ kids.extend([utf8("a.png"), utf8("b.png")]);
+ let bytes = with_sequence_naming(&[Chunk::list(*b"StVc", kids)], Some([u32::MAX, u32::MAX, 0]));
+ reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
+ }
+}
+
+/// A force-alphabetical sequence imports its stored file list in the stored order.
+#[test]
+fn a_sequence_imported_with_force_alphabetical_order_imports_its_file_list() {
+ // Force alphabetical order: `LIST StVc` with `StVS` (the count, little-endian) and one `Utf8`
+ // per file instead of the prefix and suffix; the numbering in `sspc` is not set. Before, the
+ // item became a video linked to its folder.
+ let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 3u32.to_le_bytes().to_vec()), utf8("b_take.png"), utf8("a_take.png"), utf8("c_take.png")]);
+ let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
+ let r = r.unwrap();
+ let f = the_footage(&p);
+ assert_eq!(f.kind, FootageKind::Sequence);
+ // The stored order is kept.
+ assert_eq!(file_names(&f), ["b_take.png", "a_take.png", "c_take.png"]);
+ assert!(f.path.ends_with("b_take.png"), "{}", f.path);
+ assert!(losses(&r, LossCategory::DamagedData).is_empty(), "{:?}", r.report.losses);
+ // The count bounds the list.
+ let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("x.png"), utf8("y.png"), utf8("z.png")]);
+ let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
+ r.unwrap();
+ assert_eq!(file_names(&the_footage(&p)), ["x.png", "y.png"]);
+}
+
+/// A sequence with no naming or an empty file list is reported and linked to its folder.
+#[test]
+fn a_sequence_whose_naming_cannot_be_read_is_reported() {
+ // No naming at all after the alias. Before, the item silently linked its folder.
+ let bytes = with_sequence_naming(&[], None);
+ reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
+ // An empty file list is no naming either.
+ let empty = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 0u32.to_le_bytes().to_vec())]);
+ let bytes = with_sequence_naming(&[empty], Some([u32::MAX, u32::MAX, 0]));
+ reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
+}
+
#[test]
fn a_solid_with_a_nan_channel_is_repaired_and_reported() {
let nan = f32::NAN.to_be_bytes();
@@ -273,6 +434,47 @@ fn layer_times_with_a_zero_denominator_are_reported() {
assert!(losses(&r.unwrap(), LossCategory::DamagedData).is_empty());
}
+/// The time stretch of every imported layer.
+fn stretches(p: &Project) -> Vec {
+ p.items.values().filter_map(|i| if let ItemKind::Comp(c) = &i.kind { Some(c) } else { None }).flat_map(|c| c.layers.iter().map(|l| l.stretch)).collect()
+}
+
+#[test]
+fn a_time_stretch_outside_one_to_9900_percent_is_clamped_and_reported() {
+ // ldta byte 8 is the stretch numerator (i32), byte 108 the denominator (u32). 1/(2^31 - 1) is
+ // about 5e-8 %: imported as stored, the layer and audio time divided by ~0 (#3247).
+ for (num, den, want) in [(1i32, i32::MAX as u32, 1.0), (-1, i32::MAX as u32, -1.0), (i32::MAX, 1, 9900.0), (i32::MIN, 1, -9900.0)] {
+ let bytes = mutate(&mut |c| {
+ if c.id == *b"ldta" {
+ set_data(c, 8, &num.to_be_bytes());
+ set_data(c, 108, &den.to_be_bytes());
+ }
+ });
+ let (p, r) = run(&bytes, true);
+ let r = r.unwrap();
+ let found = losses(&r, LossCategory::DamagedData);
+ assert!(found.iter().any(|d| d.contains("time stretch") && d.contains("outside")), "{num}/{den}: {found:?}");
+ let s = stretches(&p);
+ assert!(!s.is_empty() && s.iter().all(|v| *v == want), "{num}/{den}: {s:?}");
+ let (p, r) = run(&bytes, false);
+ assert!(r.is_err(), "{num}/{den}: strict import must refuse");
+ assert!(p.items.is_empty());
+ }
+ // A small stretch within the range (1/64: 1.5625 %) and -9900 % itself are no damage.
+ for (num, den, want) in [(1i32, 64u32, 1.5625), (-99, 1, -9900.0)] {
+ let bytes = mutate(&mut |c| {
+ if c.id == *b"ldta" {
+ set_data(c, 8, &num.to_be_bytes());
+ set_data(c, 108, &den.to_be_bytes());
+ }
+ });
+ let (p, r) = run(&bytes, true);
+ let r = r.unwrap();
+ assert!(!losses(&r, LossCategory::DamagedData).iter().any(|d| d.contains("time stretch")), "{num}/{den}: {:?}", r.report.losses);
+ assert!(stretches(&p).iter().all(|v| *v == want), "{num}/{den}: {:?}", stretches(&p));
+ }
+}
+
/// The project with `nhed` bytes from `at` replaced.
fn header(at: usize, bytes: &[u8]) -> Vec {
mutate(&mut |c| {
@@ -392,6 +594,186 @@ fn default_state_effect_records_import_like_written_ones() {
assert_eq!(clip, vec![("clipToOutputBlack".to_string(), effectcraft_keyframe::Value::Enum(1))]);
}
+/// Sets the `cdat` of the value record `param` in the value group `tdgp` (the first `f64`), and
+/// its state to `tdsb` when given. True when the group has that record.
+fn patch_record(tdgp: &mut Chunk<'_>, param: &str, tdsb: Option, value: f64) -> bool {
+ let kids = tdgp.children_mut();
+ let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else {
+ return false;
+ };
+ let Some(record) = kids.get_mut(at + 1).filter(|k| k.is_list(b"tdbs")) else { return false };
+ for k in record.children_mut() {
+ if k.is(b"tdsb")
+ && let Some(state) = tdsb
+ {
+ set_data(k, 0, &state.to_be_bytes());
+ } else if k.is(b"cdat") {
+ set_data(k, 0, &value.to_be_bytes());
+ }
+ }
+ true
+}
+
+/// The project without its project-level effect definitions (`EfdG`; some files have none, seen
+/// in the projects inside After Effects-made Motion Graphics templates) and with a second Glow
+/// just above the first on the layer: a copy of it whose `parT` is empty, as such files write
+/// every instance of a type but one. `edit_copy` is applied to every chunk of the copy; with
+/// `empty_original` the original's `parT` is emptied too.
+fn second_glow_without_definitions(edit_copy: &mut dyn FnMut(&mut Chunk<'_>), empty_original: bool) -> Vec {
+ fn empty_part(fx: &mut Chunk<'_>) {
+ for k in fx.children_mut() {
+ if let Body::List { list_type, children, .. } = &mut k.body
+ && list_type == b"parT"
+ {
+ children.clear();
+ }
+ }
+ }
+ let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
+ let mut added = 0;
+ visit(&mut file.chunks, &mut |c| {
+ if let Body::List { list_type, children, .. } = &mut c.body {
+ if list_type == b"EfdG" {
+ *list_type = *b"XfdG";
+ return;
+ }
+ if list_type != b"tdgp" {
+ return;
+ }
+ let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2"))) else {
+ return;
+ };
+ let (Some(name), Some(mut fx)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"sspc")).cloned()) else { return };
+ empty_part(&mut fx);
+ edit_copy(&mut fx);
+ visit(fx.children_mut(), edit_copy);
+ if empty_original && let Some(original) = children.get_mut(at + 1) {
+ empty_part(original);
+ }
+ children.insert(at, fx);
+ children.insert(at, name);
+ added += 1;
+ }
+ });
+ assert_eq!(added, 1, "one Glow on the layer");
+ write(&file).unwrap()
+}
+
+/// The imported Glows in stack order, each as its property values by property id.
+fn glow_values(p: &Project) -> Vec> {
+ let glow = effectcraft_project::GroupKind::Effect { effect: "ec.stylize.glow".to_string() };
+ let mut glows = Vec::new();
+ for item in p.items.values() {
+ if let ItemKind::Comp(c) = &item.kind {
+ for layer in &c.layers {
+ for node in layer.effects().map(|fx| fx.children.as_slice()).unwrap_or_default() {
+ if let effectcraft_project::Node::Group(g) = node
+ && g.kind == glow
+ {
+ let mut values = Vec::new();
+ g.walk("", &mut |_, prop| values.push((prop.match_id.clone(), prop.value.clone())));
+ glows.push(values);
+ }
+ }
+ }
+ }
+ }
+ glows
+}
+
+/// The value of property `id` in `values`.
+fn value_of<'v>(values: Option<&'v Vec<(String, effectcraft_keyframe::Value)>>, id: &str) -> Option<&'v effectcraft_keyframe::Value> {
+ values?.iter().find(|(i, _)| i == id).map(|(_, v)| v)
+}
+
+/// Every instance of an effect type but one has an empty `parT`; without a project-level copy the
+/// others borrow the definitions of that one (#3170: 129 Color Controls of a template were
+/// reported "without readable parameter definitions" and dropped).
+#[test]
+fn an_instance_without_definitions_imports_with_those_of_another_instance() {
+ // Both Glows import; before, the copy was reported and not imported.
+ let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, false), true);
+ let r = r.unwrap();
+ let found = losses(&r, LossCategory::Effects);
+ assert!(!found.iter().any(|d| d.contains("without readable parameter definitions") || d.contains("no parameter definition")), "{found:?}");
+ assert_eq!(r.report.imported.effects, 3, "Levels and both Glows: {:?}", r.report.losses);
+ let glows = glow_values(&p);
+ assert_eq!(glows.len(), 2);
+ assert_eq!(glows.first(), glows.get(1), "the copy has the same records, so the same values");
+
+ // The values come from each instance's own records: the copy's A & B Midpoint (`-0011`) set
+ // to 0.75 arrives as 75 % in the copy only; the Glow it borrows from keeps its own.
+ let mut patched = 0;
+ let bytes = second_glow_without_definitions(
+ &mut |c| {
+ if c.is_list(b"tdgp") && patch_record(c, "ADBE Glo2-0011", None, 0.75) {
+ patched += 1;
+ }
+ },
+ false,
+ );
+ assert_eq!(patched, 1, "the copy's record");
+ let (p, r) = run(&bytes, true);
+ r.unwrap();
+ let glows = glow_values(&p);
+ let seventy_five = effectcraft_keyframe::Value::Scalar(75.0);
+ assert_eq!(value_of(glows.first(), "abMidpoint"), Some(&seventy_five));
+ let own = value_of(glows.get(1), "abMidpoint");
+ assert!(own.is_some() && own != Some(&seventy_five), "{own:?}");
+}
+
+/// A value record that the borrowed definitions do not name is reported, not dropped silently.
+#[test]
+fn a_value_record_the_borrowed_definitions_do_not_name_is_reported() {
+ let bytes = second_glow_without_definitions(
+ &mut |c| {
+ if c.is(b"tdmn") && c.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011")) {
+ set_data(c, 0, b"ADBE Glo2-0099\0");
+ }
+ },
+ false,
+ );
+ let (_, r) = run(&bytes, true);
+ let r = r.unwrap();
+ let found = losses(&r, LossCategory::Effects);
+ assert!(found.iter().any(|d| d.contains("ADBE Glo2-0099") && d.contains("no parameter definition")), "{found:?}");
+ assert_eq!(r.report.imported.effects, 3, "{:?}", r.report.losses);
+}
+
+/// When no instance of the type keeps its definitions, every instance is reported.
+#[test]
+fn instances_without_any_definitions_are_reported() {
+ let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, true), true);
+ let r = r.unwrap();
+ let found = losses(&r, LossCategory::Effects);
+ assert_eq!(found.iter().filter(|d| d.contains("ADBE Glo2") && d.contains("without readable parameter definitions, not imported")).count(), 2, "{found:?}");
+ assert_eq!(r.report.imported.effects, 1, "Levels only: {:?}", r.report.losses);
+ assert!(glow_values(&p).is_empty());
+}
+
+/// The reader's side: the copy's definitions come from the other instance.
+#[test]
+fn an_instance_without_definitions_reads_those_of_another_instance() {
+ let bytes = second_glow_without_definitions(&mut |_| {}, false);
+ let file = parse(&bytes).unwrap();
+ let project = effectcraft_aep::Project::new(&file).unwrap();
+ let defs = project.effect_definition_index();
+ let mut sources = Vec::new();
+ for item in project.all_items() {
+ let Some(comp) = item.comp() else { continue };
+ for layer in comp.layers() {
+ for fx in layer.effects().iter().filter(|fx| fx.match_name() == "ADBE Glo2") {
+ sources.push(defs.source(fx));
+ let params = defs.params(fx);
+ assert_eq!(params.len(), 16, "Glow's parameters");
+ assert_eq!(params.iter().all(|p| p.borrowed_definition), defs.source(fx) == effectcraft_aep::DefinitionSource::OtherInstance);
+ }
+ }
+ }
+ // The copy is above the instance it borrows from (earlier in the stack).
+ assert_eq!(sources, [effectcraft_aep::DefinitionSource::OtherInstance, effectcraft_aep::DefinitionSource::Instance]);
+}
+
/// A record in a state other than 1 or 3 is not read and is reported.
#[test]
fn an_effect_record_in_another_state_is_reported_as_damaged() {
@@ -403,3 +785,60 @@ fn an_effect_record_in_another_state_is_reported_as_damaged() {
assert!(damaged.iter().any(|l| l.contains("unreadable value")), "tdsb {tdsb}: {:?}", r.report.losses);
}
}
+
+/// The project with a value record for Glow's Color A (`ADBE Glo2-0012`, which the project does
+/// not store) in state `tdsb` holding the ARGB doubles `argb` (0..255): a copy of the A & B
+/// Midpoint record (`-0011`) made a 4-component colour without its expression.
+fn with_color_a(tdsb: u32, argb: [f64; 4]) -> Vec {
+ let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
+ let mut added = 0;
+ visit(&mut file.chunks, &mut |c| {
+ let Body::List { list_type, children, .. } = &mut c.body else { return };
+ if list_type != b"tdgp" {
+ return;
+ }
+ let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011"))) else {
+ return;
+ };
+ let (Some(mut name), Some(mut record)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"tdbs")).cloned()) else { return };
+ set_data(&mut name, 0, b"ADBE Glo2-0012\0");
+ if let Body::List { children: kids, .. } = &mut record.body {
+ kids.retain(|k| !k.is(b"Utf8"));
+ for k in kids.iter_mut() {
+ if k.is(b"tdsb") {
+ set_data(k, 0, &tdsb.to_be_bytes());
+ } else if k.is(b"tdb4") {
+ // 4 components; no expression (bytes 76, 77 and 120).
+ set_data(k, 3, &[4]);
+ for byte in [76, 77, 120] {
+ set_data(k, byte, &[0]);
+ }
+ } else if k.is(b"cdat") {
+ *k = Chunk::data(*b"cdat", argb.iter().flat_map(|v| v.to_be_bytes()).collect::>());
+ }
+ }
+ }
+ children.insert(at + 2, record);
+ children.insert(at + 2, name);
+ added += 1;
+ });
+ assert_eq!(added, 2, "the instance and the EfdG copy");
+ write(&file).unwrap()
+}
+
+/// A colour recorded in the default state (`tdsb` 3) reads like a written one (#3170's "parameter
+/// Color ... holds an unreadable value" on 0.21.1, which read only written records).
+#[test]
+fn a_default_state_colour_record_holds_the_value() {
+ let argb = [255.0, 51.0, 153.0, 255.0];
+ let (written, r1) = run(&with_color_a(1, argb), true);
+ let (default_state, r3) = run(&with_color_a(3, argb), true);
+ let (r1, r3) = (r1.unwrap(), r3.unwrap());
+ assert!(losses(&r3, LossCategory::DamagedData).is_empty(), "{:?}", r3.report.losses);
+ assert_eq!(r3.report.losses, r1.report.losses);
+ let colour = |p: &Project| value_of(glow_values(p).first(), "colorA").cloned();
+ let (unchanged, _) = run(SYNTHETIC, true);
+ assert!(colour(&written).is_some());
+ assert_eq!(colour(&default_state), colour(&written));
+ assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default");
+}
diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml
index 916cc3501..e0caf3218 100644
--- a/crates/automation/Cargo.toml
+++ b/crates/automation/Cargo.toml
@@ -19,3 +19,4 @@ effectcraft-model = { workspace = true }
[dev-dependencies]
effectcraft-script = { workspace = true }
effectcraft-host = { workspace = true }
+tempfile = { workspace = true }
diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs
index 6d8ce2819..371085938 100644
--- a/crates/automation/src/confine.rs
+++ b/crates/automation/src/confine.rs
@@ -143,6 +143,8 @@ const WRITES: &[(&str, &[&str])] = &[
("shortcuts.export", &["path"]),
("renderQueue.add", &["output", "path"]),
("file.exportTimeline", &["path"]),
+ ("journal.export", &["path"]),
+ ("help.exportRepro", &["path"]),
// `path` / `output` may also be a file-name template, as for render.preRender.
("file.createProxy", &["path", "output"]),
];
@@ -280,6 +282,11 @@ impl Confinement {
if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given {
return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined")));
}
+ // The default repro destination is the settings store, not an automation write root.
+ // Require a single explicit path so missing, null or array parameters cannot bypass it.
+ if id == "help.exportRepro" && params.get("path").and_then(Value::as_str).is_none() {
+ return Err(Error::BadArgs("`help.exportRepro`: give `path` inside an automation write root while file access is confined".into()));
+ }
let mut lookups = vec![];
// An output the agent did not name is derived from where the project sits: the Default
// Output Folder preference when that is set, beside the project file otherwise — and the
@@ -346,32 +353,64 @@ impl ConfinedFootage {
fn inside(&self, f: &Footage) -> bool {
std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()).all(|p| self.confinement.check(p, Access::Read).is_ok())
}
+
+ /// [`Self::inside`] for a read: a refused one is counted on this thread, so the render
+ /// [`recording`](FootageSource::recording) it can't list what it made without the footage.
+ fn readable(&self, f: &Footage) -> bool {
+ let inside = self.inside(f);
+ if !inside {
+ let _ = REFUSED_READS.try_with(|n| n.set(n.get().wrapping_add(1)));
+ }
+ inside
+ }
+}
+
+thread_local! {
+ /// Footage reads refused on this thread (see [`ConfinedFootage::readable`]).
+ static REFUSED_READS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// The number of reads this thread has refused for lying outside the allowed roots.
+fn refused_reads() -> u64 {
+ REFUSED_READS.try_with(std::cell::Cell::get).unwrap_or(0)
}
impl FootageSource for ConfinedFootage {
+ /// The frame from the inner source, or `None` when the footage lies outside the allowed roots.
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> {
- self.inside(footage).then(|| self.inner.frame(item, footage, t)).flatten()
+ self.readable(footage).then(|| self.inner.frame(item, footage, t)).flatten()
}
+ /// The frame scaled to `width` by `height`, or `None` when the footage lies outside the allowed roots.
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> {
- self.inside(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten()
+ self.readable(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten()
}
+ /// The frame with an explicit size and float curve, or `None` when the footage lies outside the allowed roots.
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> {
- self.inside(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten()
+ self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten()
}
+ /// The frame as 8-bit levels, or `None` when the footage lies outside the allowed roots.
+ fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> {
+ self.readable(footage).then(|| self.inner.frame8(item, footage, t, float)).flatten()
+ }
+ /// The footage audio from the inner source, or `None` when the footage lies outside the allowed roots.
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> {
- self.inside(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
+ self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
}
+ /// The auxiliary channels of the footage frame, or `None` when the footage lies outside the allowed roots.
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> {
- self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten()
+ self.readable(footage).then(|| self.inner.aux(item, footage, t)).flatten()
}
+ /// Forwards the auxiliary footage paths to the inner source.
fn set_aux_footage(&self, paths: &[String]) {
self.inner.set_aux_footage(paths)
}
+ /// The 3D model for the footage, or `None` when the footage lies outside the allowed roots.
fn model(&self, item: ItemId, footage: &Footage) -> Option> {
- self.inside(footage).then(|| self.inner.model(item, footage)).flatten()
+ self.readable(footage).then(|| self.inner.model(item, footage)).flatten()
}
+ /// The rasterised vector footage at `scale`, or `None` when the footage lies outside the allowed roots.
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option> {
- self.inside(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten()
+ self.readable(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten()
}
/// Says why footage outside the roots shows nothing, so the agent can act on it.
fn problem(&self, footage: &Footage) -> Option {
@@ -392,15 +431,29 @@ impl FootageSource for ConfinedFootage {
fn set_conform_folder(&self, folder: Option) {
self.inner.set_conform_folder(folder)
}
+ /// The inner source's cache budget.
fn cache_budget(&self) -> Option {
self.inner.cache_budget()
}
+ /// Runs `f` under the inner source's cancel flag.
fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) {
self.inner.cancellable(cancel, f)
}
+ /// Whether the inner source has been called off.
fn called_off(&self) -> bool {
self.inner.called_off()
}
+ /// The files read inside the roots, as the inner source notes them. Footage outside reads
+ /// nothing, and what is made without it can't be listed by its files: a refused read makes
+ /// the list incomplete, so it never outlives the session in a disk cache (another session
+ /// with other roots would read the footage).
+ fn recording(&self, reads: &effectcraft_engine::render::reads::ReadLog, f: &mut dyn FnMut()) {
+ let refused = refused_reads();
+ self.inner.recording(reads, f);
+ if refused_reads() != refused {
+ reads.mark_incomplete();
+ }
+ }
}
/// The session's file reads, only inside the read roots: what a project or an imported file
@@ -593,6 +646,55 @@ mod tests {
}
}
+ /// Footage outside the roots shows nothing: a render that asked for it can't be listed by its
+ /// files (#3184), or a disk cache would keep the empty picture for a session with other
+ /// roots. Reads inside the roots record as the inner source tells.
+ #[test]
+ fn a_refused_footage_read_makes_the_render_unlisted() {
+ use effectcraft_engine::render::reads::{FileStamp, ReadFile, ReadLog};
+ /// Reads every file, noting it.
+ struct Noting;
+ impl FootageSource for Noting {
+ /// Returns a 1x1 frame and notes the footage path it was asked for.
+ fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> {
+ NOTED.with(|n| n.borrow_mut().push(f.path.clone()));
+ Some(Arc::new(Image::new(1, 1)))
+ }
+ /// Runs `f`, then reports every path noted meanwhile to `reads` as a file with a missing stamp.
+ fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
+ f();
+ for p in NOTED.with(|n| std::mem::take(&mut *n.borrow_mut())) {
+ reads.note(ReadFile::new(p, FileStamp::Missing));
+ }
+ }
+ }
+ thread_local! {
+ static NOTED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) };
+ }
+ // (a fresh directory, removed when the test ends, panicking or not)
+ let tmp = tempfile::tempdir().unwrap();
+ let base = tmp.path();
+ let (inside, outside) = (base.join("in"), base.join("elsewhere"));
+ for d in [&inside, &outside] {
+ std::fs::create_dir_all(d).unwrap();
+ }
+ let c = Confinement::new(std::slice::from_ref(&inside), &[]).unwrap();
+ let src = ConfinedFootage::new(Arc::new(Noting), c);
+ let footage = |d: &std::path::Path| Footage { path: d.join("a.png").to_string_lossy().into_owned(), ..Default::default() };
+ let read = |f: &Footage| {
+ let log = ReadLog::new();
+ let mut got = None;
+ src.recording(&log, &mut || got = src.frame(ItemId(1), f, Tick::ZERO));
+ (got.is_some(), log.into_files())
+ };
+ let (shown, files) = read(&footage(&inside));
+ assert!(shown && files.is_complete() && files.len() == 1, "inside the roots: {files:?}");
+ let (shown, files) = read(&footage(&outside));
+ assert!(!shown && !files.is_complete(), "outside the roots: {files:?}");
+ // The next render on the thread is not affected.
+ assert!(read(&footage(&inside)).1.is_complete());
+ }
+
#[test]
fn relative_and_outside_paths_of_newly_listed_commands_are_refused() {
let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id()));
diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs
index 92b62da2f..8a1839ede 100644
--- a/crates/automation/src/tests.rs
+++ b/crates/automation/src/tests.rs
@@ -1865,3 +1865,28 @@ fn automation_roots_check_relinked_footage_again() {
assert!(!(px[0] > 200 && px[3] > 200), "the link pointed outside afterwards was read: {px:?}");
let _ = std::fs::remove_dir_all(&base);
}
+
+/// Exporting a repro must not write to the settings store outside the automation roots.
+#[test]
+fn confined_repro_export_requires_an_explicit_writable_path() {
+ use crate::confine::Confinement;
+ use effectcraft_engine::config::{ConfigStore, MemoryConfig};
+ use std::sync::Arc;
+
+ let dir = tempfile::tempdir().unwrap();
+ let store = Arc::new(MemoryConfig::default());
+ let session = Session { config: Some(store.clone()), ..Default::default() };
+ let mut s = McpServer::new(Backend::headless(session));
+ s.backend().confine(Confinement::new(&[], &[dir.path().to_path_buf()]).unwrap());
+ for params in [json!({}), json!({"path": null}), json!({"path": []}), json!({"path": [dir.path().join("repro.jsonl")]})] {
+ let (content, error) = call(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": params}));
+ assert!(error, "{content:?}");
+ assert!(store.list("Repros").is_empty(), "implicit settings-store writes must be refused");
+ }
+ let path = dir.path().join("repro.jsonl");
+ call_json(&mut s, "execute_command", json!({"command": "help.exportRepro", "params": {"path": path}}));
+ assert!(path.is_file());
+ let journal = call_json(&mut s, "execute_command", json!({"command": "journal.export"}));
+ assert!(journal["script"].is_string(), "in-memory exports need no write root");
+ assert!(store.list("Repros").is_empty());
+}
diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs
index d5de8f687..3cf8356fb 100644
--- a/crates/automation/tests/qa/compositing.rs
+++ b/crates/automation/tests/qa/compositing.rs
@@ -106,7 +106,7 @@ fn track_matte(qa: &mut Qa) {
fn adjustment_layer(qa: &mut Qa, normal: &Img) {
// Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow.
qa.exec("layer.newAdjustment", json!({"name": "Grade"}));
- let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]}));
+ let fx = qa.exec("effect.apply", json!({"effect": "Color Grading", "layers": ["Grade"]}));
assert_eq!(fx["paths"], json!(["effects/#1"]), "effect.apply answers with the instance path: {fx}");
qa.tool("set_property", json!({"layer": "Grade", "path": "effects/#1/basicCorrection/tone/exposure", "value": 2}));
let before = qa.frame(None, 0.5);
diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs
index 929f41553..880ad16c5 100644
--- a/crates/effects/src/blur2.rs
+++ b/crates/effects/src/blur2.rs
@@ -1,6 +1,6 @@
//! Blur & Sharpen, batch 2: edge-preserving blurs (Bilateral, Smart), per-channel and map-driven
//! blurs (Channel, Compound, CC Vector), bokeh (Camera Lens Blur), radial streaks and small
-//! utilities (Reduce Interlace Flicker, CC Cross Blur).
+//! utilities (Reduce Interlace Flicker, Cross Blur).
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
@@ -507,9 +507,9 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
// ---------------------------------------------------------------------------------------------
-// CC Radial Fast Blur
+// Fast Radial Blur
-/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center
+/// Transparent pixels Fast Radial Blur adds on every side of a `w` × `h` buffer with Center
/// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`,
/// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other
/// growing effects). Shared with the GPU path so both grow the same way.
@@ -562,7 +562,7 @@ fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
// ---------------------------------------------------------------------------------------------
-// CC Vector Blur
+// Vector Blur
fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amount = ctx.params.f("amount").max(0.0) * b.scale;
@@ -616,7 +616,7 @@ fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
// ---------------------------------------------------------------------------------------------
-// Reduce Interlace Flicker / CC Cross Blur
+// Reduce Interlace Flicker / Cross Blur
fn reduce_flicker(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = ctx.params.f("softness").max(0.0) * b.scale;
@@ -735,7 +735,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.blur.ccradialfast",
- "CC Radial Fast Blur",
+ "Fast Radial Blur",
vec![
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("amount", "Amount", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
@@ -745,7 +745,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.blur.ccvector",
- "CC Vector Blur",
+ "Vector Blur",
vec![
p("type", "Type", Value::Enum(0), popup(&["Natural", "Constant Length", "Perpendicular", "Direction Center", "Direction Fading"])),
p("amount", "Amount", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
@@ -765,7 +765,7 @@ pub fn specs() -> Vec {
},
spec(
"ec.blur.cccross",
- "CC Cross Blur",
+ "Cross Blur",
vec![
p("radiusX", "Radius X", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p("radiusY", "Radius Y", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
diff --git a/crates/effects/src/blur3.rs b/crates/effects/src/blur3.rs
index aee2a2713..6369a8ced 100644
--- a/crates/effects/src/blur3.rs
+++ b/crates/effects/src/blur3.rs
@@ -1,6 +1,6 @@
-//! Blur & Sharpen: CC Radial Blur and Camera-Shake Deblur.
+//! Blur & Sharpen: Variable Radial Blur and Camera-Shake Deblur.
//!
-//! * **CC Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
+//! * **Variable Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
//! fading) blurs around a centre point, sampled along the ray / arc through each pixel with a
//! sample count set by Quality.
//! * **Camera-Shake Deblur** — frame-based restoration in the spirit of published video deblurring
@@ -22,7 +22,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren
EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true }
}
-// ---------------------------------------------------------------- CC Radial Blur
+// ---------------------------------------------------------------- Variable Radial Blur
pub const RADIAL_TYPES: [&str; 5] = ["Straight Zoom", "Fading Zoom", "Centered", "Rotate", "Rotate Fading"];
@@ -246,7 +246,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.blur.ccradial",
- "CC Radial Blur",
+ "Variable Radial Blur",
vec![
p("type", "Type", Value::Enum(1), popup(&RADIAL_TYPES)),
p("amount", "Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs
index 5be3c94e7..ef391f6f1 100644
--- a/crates/effects/src/channel2.rs
+++ b/crates/effects/src/channel2.rs
@@ -1,5 +1,5 @@
//! Channel effects, batch 2: layer-to-layer combinations (Blend, Calculations, Compound
-//! Arithmetic) and CC Composite. Second layers come from layer parameters through the effect
+//! Arithmetic) and Source Composite. Second layers come from layer parameters through the effect
//! host; with no layer chosen the effect uses the layer itself, as After Effects does.
use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl};
@@ -69,7 +69,7 @@ fn blend(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------- Calculations
-/// Blending modes offered by Calculations / CC Composite (menu order).
+/// Blending modes offered by Calculations / Source Composite (menu order).
const CALC_MODES: [BlendMode; 22] = [
BlendMode::Normal,
BlendMode::Darken,
@@ -150,7 +150,7 @@ fn calculations(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Composite
+// ---------------------------------------------------------------- Source Composite
const CC_COMPOSITE_MODES: [&str; 16] = [
"In Front",
@@ -321,7 +321,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.channel.cccomposite",
- "CC Composite",
+ "Source Composite",
vec![
p("opacity", "Opacity", num(100.0), pct()),
p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)),
diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs
index 9e9534ac1..b84b89ebb 100644
--- a/crates/effects/src/channel3d.rs
+++ b/crates/effects/src/channel3d.rs
@@ -619,6 +619,39 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------- EXtractoR
+/// EXtractoR's channel name for After Effects' `(copy)`: the effect input's own channel of the
+/// same position (red's red ... alpha's alpha), as the earlier effects left it, never a footage
+/// channel of some name (an OpenEXR's `A` is the file's alpha, not the input's).
+pub const EXTRACTOR_COPY: &str = "(copy)";
+
+/// Whether an EXtractoR channel name is [`EXTRACTOR_COPY`].
+pub fn is_extractor_copy(name: &str) -> bool {
+ name.trim().eq_ignore_ascii_case(EXTRACTOR_COPY)
+}
+
+/// The layer's own channel an EXtractoR output channel at position `k` reads when no footage
+/// channel of that name exists: `R`/`G`/`B`/`A` (or the long names) by name, [`EXTRACTOR_COPY`]
+/// its own position.
+pub fn extractor_own_channel(k: usize, name: &str) -> Option {
+ if is_extractor_copy(name) {
+ return (k < 4).then_some(k);
+ }
+ match name.trim().to_ascii_uppercase().as_str() {
+ "R" | "RED" => Some(0),
+ "G" | "GREEN" => Some(1),
+ "B" | "BLUE" => Some(2),
+ "A" | "ALPHA" => Some(3),
+ _ => None,
+ }
+}
+
+/// The footage channel an EXtractoR output channel reads by name: none for an empty name or
+/// [`EXTRACTOR_COPY`] (the input's own channel).
+pub fn extractor_aux_channel<'a>(a: &'a AuxChannels, name: &str) -> Option<&'a [f32]> {
+ let n = name.trim();
+ if n.is_empty() || is_extractor_copy(n) { None } else { a.get(n) }
+}
+
fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
let a = aux(ctx);
let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")];
@@ -626,17 +659,11 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
let unmult = ctx.params.b("unMult");
let clip = ctx.params.b("clip");
let idx = a.as_ref().map(|a| index_map(&b, a));
- // Each output channel: an aux channel by name, or the layer's own R/G/B/A.
- let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
- "R" | "RED" => Some(0),
- "G" | "GREEN" => Some(1),
- "B" | "BLUE" => Some(2),
- "A" | "ALPHA" => Some(3),
- "" => None,
- _ => None,
- };
- let src_ch: Vec> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
- if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) {
+ // Each output channel: an aux channel by name, or the layer's own R/G/B/A (`(copy)`: its
+ // own position, never an aux channel).
+ let own = |k: usize, n: &str| extractor_own_channel(k, n);
+ let src_ch: Vec > = names.iter().map(|n| a.as_ref().and_then(|a| extractor_aux_channel(a, n))).collect();
+ if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k)) {
return b;
}
let src = b.img.clone();
@@ -652,7 +679,7 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) {
(Some(ch), Some(j)) => ch[j],
(Some(_), None) => 0.0,
- (None, _) => match own(names[k]) {
+ (None, _) => match own(k, names[k]) {
Some(c) => straight[c],
None => {
if k == 3 {
diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs
index ac9547444..c1fa21711 100644
--- a/crates/effects/src/channel3d/tests.rs
+++ b/crates/effects/src/channel3d/tests.rs
@@ -457,6 +457,38 @@ fn extractor_maps_named_channels() {
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
}
+/// `(copy)` (After Effects' EXtractoR choice, imported from .aep) is the effect input's own
+/// channel as earlier effects left it, never the OpenEXR's plane of the same name: here the input
+/// buffer differs from the footage's R, G, B and A planes, as after an earlier effect.
+#[test]
+fn extractor_copy_reads_the_current_input_not_the_footage_plane() {
+ let (w, h) = (4usize, 3usize);
+ let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
+ // The file's own planes (what the footage held before any effect) and a depth channel.
+ a.channels = ["R", "G", "B", "A"].iter().map(|n| (n.to_string(), vec![0.9; w * h])).collect();
+ a.channels.push(("Z".into(), vec![0.7; w * h]));
+ let host = AuxHost(Arc::new(a));
+ let env = || EffectEnv { host: Some(&host), ..Default::default() };
+ // The input after an earlier effect: straight (0.2, 0.3, 0.4) at alpha 0.5, premultiplied.
+ let input = Image::filled(w as u32, h as u32, [0.1, 0.15, 0.2, 0.5]);
+ let copy = || Value::Str(EXTRACTOR_COPY.into());
+ // All four `(copy)`: the input unchanged.
+ let all = [("red", copy()), ("green", copy()), ("blue", copy()), ("alpha", copy())];
+ let o = run_fx("ec.3d.extractor", &all, input.clone(), 0.0, env()).img;
+ assert_eq!(o.data, input.data);
+ // Mixed with a named channel (no pass-through): red from Z, green and alpha copied, blue the
+ // footage's B plane.
+ let vals = [("red", Value::Str("Z".into())), ("green", copy()), ("blue", Value::Str("B".into())), ("alpha", copy())];
+ let px = run_fx("ec.3d.extractor", &vals, input.clone(), 0.0, env()).img.get(1, 1);
+ let want = [0.7 * 0.5, 0.3 * 0.5, 0.9 * 0.5, 0.5];
+ for (c, (got, want)) in px.iter().zip(want).enumerate() {
+ assert!((got - want).abs() < 1e-6, "channel {c}: {got} != {want} ({px:?})");
+ }
+ // `A` by name is the footage's alpha plane (0.9), not the input's alpha (0.5).
+ let named = [("alpha", Value::Str("A".into())), ("red", Value::Str("Z".into()))];
+ assert!((run_fx("ec.3d.extractor", &named, input, 0.0, env()).img.get(1, 1)[3] - 0.9).abs() < 1e-6);
+}
+
#[test]
fn channel_extract_antialias_averages_sub_pixels() {
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs
index 25203cd7a..6639e649e 100644
--- a/crates/effects/src/color2.rs
+++ b/crates/effects/src/color2.rs
@@ -878,7 +878,7 @@ fn broadcast_colors(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
// ---------------------------------------------------------------------------------------------
-// CC Toner, CC Color Offset, CC Kernel
+// Multi-Tone Tint, Channel Offset, Convolution Kernel
fn cc_toner(ctx: &EffectCtx, mut b: Buf) -> Buf {
let g = |id: &str| {
@@ -1101,7 +1101,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.color.cctoner",
- "CC Toner",
+ "Multi-Tone Tint",
vec![
p("tones", "Tones", Value::Enum(1), popup(&["Duotone", "Tritone", "Pentone"])),
p("highlights", "Highlights", col(1.0, 1.0, 1.0), ParamUi::Color),
@@ -1115,7 +1115,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.color.cccoloroffset",
- "CC Color Offset",
+ "Channel Offset",
vec![
p("redPhase", "Red Phase", num(0.0), ParamUi::Angle),
p("greenPhase", "Green Phase", num(0.0), ParamUi::Angle),
@@ -1124,7 +1124,7 @@ pub fn specs() -> Vec {
],
cc_color_offset,
),
- spec("ec.color.cckernel", "CC Kernel", k_params, cc_kernel),
+ spec("ec.color.cckernel", "Convolution Kernel", k_params, cc_kernel),
]
}
diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs
index 4850710ab..d0d398100 100644
--- a/crates/effects/src/color3.rs
+++ b/crates/effects/src/color3.rs
@@ -1,5 +1,5 @@
-//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer,
-//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels,
+//! Color Correction effects, batch 3: Change Color, Video Limiter, Color Neutralizer,
+//! PS Arbitrary Map and Color Grading (Basic Correction, Creative, Curves, Color Wheels,
//! Vignette).
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
@@ -154,7 +154,7 @@ fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Color Neutralizer
+// ---------------------------------------------------------------- Color Neutralizer
fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")];
@@ -225,7 +225,7 @@ fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- Lumetri Color
+// ---------------------------------------------------------------- Color Grading
/// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the
/// end points fixed.
@@ -431,7 +431,7 @@ impl HslKey {
}
}
-/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
+/// Color Grading: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves),
/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order.
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
@@ -705,32 +705,37 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
pub fn specs() -> Vec {
let grey = || col(0.5, 0.5, 0.5);
+ // After Effects' valid ranges (a value past the slider's drag range is still kept: .aep
+ // import, 1200), the slider over the range After Effects' slider covers.
+ let wb = || slider(-300.0, 300.0, -100.0, 100.0, 1);
+ let tone = || slider(-150.0, 150.0, -100.0, 100.0, 1);
+ let sat = || slider(0.0, 300.0, 0.0, 200.0, 1);
let mut lum = vec![
p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox),
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])),
// A .cube file path (or the file's text) for Input LUT = Custom.
p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text),
- p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()),
- p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()),
- p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
- p("basicCorrection/tone/contrast", "Contrast", num(0.0), bipolar()),
- p("basicCorrection/tone/highlights", "Highlights", num(0.0), bipolar()),
- p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()),
- p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()),
- p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()),
+ p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), wb()),
+ p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), wb()),
+ p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-7.0, 7.0, -5.0, 5.0, 2)),
+ p("basicCorrection/tone/contrast", "Contrast", num(0.0), tone()),
+ p("basicCorrection/tone/highlights", "Highlights", num(0.0), tone()),
+ p("basicCorrection/tone/shadows", "Shadows", num(0.0), tone()),
+ p("basicCorrection/tone/whites", "Whites", num(0.0), tone()),
+ p("basicCorrection/tone/blacks", "Blacks", num(0.0), tone()),
p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)),
- p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()),
- p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
+ p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), tone()),
+ p("basicCorrection/saturation", "Saturation", num(100.0), sat()),
p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)),
p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text),
p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
- p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()),
+ p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), slider(0.0, 150.0, 0.0, 100.0, 1)),
p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()),
p("creative/adjustments/vibrance", "Vibrance", num(0.0), bipolar()),
- p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
+ p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), sat()),
p("creative/adjustments/shadowTint", "Shadow Tint", grey(), ParamUi::Color),
p("creative/adjustments/highlightTint", "Highlight Tint", grey(), ParamUi::Color),
- p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), bipolar()),
+ p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), tone()),
];
const CURVES: [(&str, &str); 12] = [
("curves/curveMasterShadows", "RGB Curve Shadows"),
@@ -786,13 +791,13 @@ pub fn specs() -> Vec {
p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)),
p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox),
p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()),
- p("hslSecondary/refine/blur", "Blur", num(0.0), pct()),
+ p("hslSecondary/refine/blur", "Blur", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color),
- p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()),
- p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()),
- p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()),
+ p("hslSecondary/correction/temperature", "Temperature", num(0.0), wb()),
+ p("hslSecondary/correction/tint", "Tint", num(0.0), wb()),
+ p("hslSecondary/correction/contrast", "Contrast", num(0.0), tone()),
p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()),
- p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
+ p("hslSecondary/correction/saturation", "Saturation", num(100.0), sat()),
p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()),
p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()),
@@ -829,7 +834,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.color.cccolorneutralizer",
- "CC Color Neutralizer",
+ "Color Neutralizer",
vec![
p("shadowsUnbalance", "Shadows Unbalance", col(0.0, 0.0, 0.0), ParamUi::Color),
p("shadowsBalance", "Shadows Balance", col(0.0, 0.0, 0.0), ParamUi::Color),
@@ -855,7 +860,7 @@ pub fn specs() -> Vec {
],
arbitrary_map,
),
- spec("ec.color.lumetri", "Lumetri Color", lum, lumetri),
+ spec("ec.color.lumetri", "Color Grading", lum, lumetri),
]
}
diff --git a/crates/effects/src/display_names.rs b/crates/effects/src/display_names.rs
new file mode 100644
index 000000000..b30c00d46
--- /dev/null
+++ b/crates/effects/src/display_names.rs
@@ -0,0 +1,123 @@
+//! Display naming must not change file, command or script identities.
+use crate::{find, instantiate, lookup, name_matches};
+use effectcraft_project::build::Ids;
+
+const RENAMED: &[(&str, &str, &str)] = &[
+ ("ec.sim.ccballaction", "CC Ball Action", "Ball Grid"),
+ ("ec.distort.ccbendit", "CC Bend It", "Two-Point Bend"),
+ ("ec.distort.ccbender", "CC Bender", "Flexible Bend"),
+ ("ec.distort.ccblobbylize", "CC Blobbylize", "Organic Displacement"),
+ ("ec.stylize.ccblockload", "CC Block Load", "Block Reveal"),
+ ("ec.sim.ccbubbles", "CC Bubbles", "Bubbles"),
+ ("ec.stylize.ccburnfilm", "CC Burn Film", "Film Burn"),
+ ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "Color Neutralizer"),
+ ("ec.color.cccoloroffset", "CC Color Offset", "Channel Offset"),
+ ("ec.channel.cccomposite", "CC Composite", "Source Composite"),
+ ("ec.blur.cccross", "CC Cross Blur", "Cross Blur"),
+ ("ec.perspective.cccylinder", "CC Cylinder", "Cylinder Wrap"),
+ ("ec.sim.ccdrizzle", "CC Drizzle", "Water Ripples"),
+ ("ec.perspective.ccenvironment", "CC Environment", "Environment Map"),
+ ("ec.distort.ccflomotion", "CC Flo Motion", "Dual Lens Warp"),
+ ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "Temporal Motion Blur"),
+ ("ec.stylize.ccglass", "CC Glass", "Glass Relief"),
+ ("ec.transition.ccglasswipe", "CC Glass Wipe", "Glass Wipe"),
+ ("ec.generate.ccgluegun", "CC Glue Gun", "Paint Beads"),
+ ("ec.transition.ccgridwipe", "CC Grid Wipe", "Grid Wipe"),
+ ("ec.distort.ccgriddler", "CC Griddler", "Tile Grid"),
+ ("ec.sim.cchair", "CC Hair", "Surface Fibers"),
+ ("ec.stylize.cchextile", "CC HexTile", "Hexagonal Tiles"),
+ ("ec.transition.ccimagewipe", "CC Image Wipe", "Luminance Image Wipe"),
+ ("ec.transition.ccjaws", "CC Jaws", "Serrated Wipe"),
+ ("ec.stylize.cckaleida", "CC Kaleida", "Kaleidoscope"),
+ ("ec.color.cckernel", "CC Kernel", "Convolution Kernel"),
+ ("ec.distort.cclens", "CC Lens", "Lens Distortion"),
+ ("ec.generate.cclightburst", "CC Light Burst 2.5", "Radial Light Burst"),
+ ("ec.generate.cclightrays", "CC Light Rays", "Light Rays"),
+ ("ec.generate.cclightsweep", "CC Light Sweep", "Light Sweep"),
+ ("ec.transition.cclightwipe", "CC Light Wipe", "Light Wipe"),
+ ("ec.transition.cclinesweep", "CC Line Sweep", "Line Sweep"),
+ ("ec.sim.ccmrmercury", "CC Mr. Mercury", "Liquid Particles"),
+ ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "Palette Map"),
+ ("ec.utility.ccoverbrights", "CC Overbrights", "Out-of-Range Display"),
+ ("ec.distort.ccpageturn", "CC Page Turn", "Page Curl"),
+ ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "Particle Emitter 2D"),
+ ("ec.sim.ccparticleworld", "CC Particle World", "Particle Emitter 3D"),
+ ("ec.sim.ccpixelpolly", "CC Pixel Polly", "Pixel Shatter"),
+ ("ec.stylize.ccplastic", "CC Plastic", "Plastic Shading"),
+ ("ec.distort.ccpowerpin", "CC Power Pin", "Perspective Pin"),
+ ("ec.blur.ccradial", "CC Radial Blur", "Variable Radial Blur"),
+ ("ec.blur.ccradialfast", "CC Radial Fast Blur", "Fast Radial Blur"),
+ ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "Radial Scale Wipe"),
+ ("ec.sim.ccrainfall", "CC Rainfall", "Rainfall"),
+ ("ec.stylize.ccrepetile", "CC RepeTile", "Edge Tile Repeat"),
+ ("ec.distort.ccripplepulse", "CC Ripple Pulse", "Pulsed Ripple"),
+ ("ec.transition.ccscalewipe", "CC Scale Wipe", "Scale Wipe"),
+ ("ec.sim.ccscatterize", "CC Scatterize", "Pixel Scatter"),
+ ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "Line Removal"),
+ ("ec.distort.ccslant", "CC Slant", "Slant"),
+ ("ec.distort.ccsmear", "CC Smear", "Directional Smear"),
+ ("ec.sim.ccsnowfall", "CC Snowfall", "Snowfall"),
+ ("ec.perspective.ccsphere", "CC Sphere", "Sphere Wrap"),
+ ("ec.distort.ccsplit", "CC Split", "Split Warp"),
+ ("ec.distort.ccsplit2", "CC Split 2", "Dual Split Warp"),
+ ("ec.perspective.ccspotlight", "CC Spotlight", "Surface Spotlight"),
+ ("ec.sim.ccstarburst", "CC Star Burst", "Starfield"),
+ ("ec.generate.ccthreads", "CC Threads", "Woven Threads"),
+ ("ec.stylize.ccthreshold", "CC Threshold", "Channel Threshold"),
+ ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "RGB Threshold"),
+ ("ec.distort.cctiler", "CC Tiler", "Scaled Tiles"),
+ ("ec.color.cctoner", "CC Toner", "Multi-Tone Tint"),
+ ("ec.transition.cctwister", "CC Twister", "Twist Wipe"),
+ ("ec.blur.ccvector", "CC Vector Blur", "Vector Blur"),
+ ("ec.stylize.ccvignette", "CC Vignette", "Vignette"),
+ ("ec.transition.ccwarpomatic", "CC WarpoMatic", "Warp Dissolve"),
+ ("ec.time.ccwidetime", "CC Wide Time", "Temporal Blend"),
+ ("ec.color.lumetri", "Lumetri Color", "Color Grading"),
+ ("ec.obsolete.mochashape", "Mocha shape", "Tracked Shape Matte"),
+ ("ec.keying.keylight", "Key Light", "Screen Color Key"),
+];
+
+#[test]
+fn descriptive_names_keep_every_legacy_lookup_and_search() {
+ for &(id, old, new) in RENAMED {
+ let spec = find(id).unwrap();
+ assert_eq!(spec.name, new, "{id}");
+ for query in [id.to_string(), old.to_string(), old.to_lowercase(), new.to_string()] {
+ assert_eq!(lookup(&query).map(|s| s.id), Some(id), "{query}");
+ }
+ assert!(name_matches(spec, &old.to_lowercase()), "{old}");
+ assert!(name_matches(spec, &new.to_lowercase()), "{new}");
+ }
+ for alias in ["Keylight", "Keylight (1.2)", "Key Light"] {
+ assert_eq!(lookup(alias).map(|s| s.id), Some("ec.keying.keylight"));
+ }
+}
+
+#[test]
+fn descriptive_display_names_do_not_collide() {
+ let mut names = std::collections::HashSet::new();
+ for spec in crate::registry() {
+ assert!(names.insert(spec.name.to_lowercase()), "duplicate name {}", spec.name);
+ let label = spec.name.to_lowercase();
+ assert!(!label.starts_with("cc "), "{}", spec.name);
+ for mark in ["lumetri", "mercury", "mocha", "keylight"] {
+ assert!(!label.contains(mark), "{}", spec.name);
+ }
+ }
+}
+
+#[test]
+fn legacy_instance_names_and_parameter_data_survive_refresh() {
+ for &(id, old, _) in RENAMED {
+ let spec = find(id).unwrap();
+ for stored in [old.to_string(), format!("{old} 2"), "My custom instance".to_string()] {
+ let mut next = 1;
+ let mut ids = Ids(&mut next);
+ let mut instance = instantiate(spec, &mut ids, &stored, [128.0, 72.0]);
+ let before = serde_json::to_value(&instance).unwrap();
+ crate::migrate::upgrade_instance(spec, &mut instance, &mut ids, [128.0, 72.0]);
+ assert_eq!(instance.name, stored, "{id}: never rewrite a stored instance name");
+ assert_eq!(serde_json::to_value(&instance).unwrap(), before, "{id}");
+ }
+ }
+}
diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs
index 7add5fc58..bbd8738a8 100644
--- a/crates/effects/src/distort2.rs
+++ b/crates/effects/src/distort2.rs
@@ -1,6 +1,6 @@
//! Distort effects, batch 2: Turbulent Displace, Displacement Map (self as map), Optics
-//! Compensation, Magnify, Mesh Warp, Bezier Warp, CC Bend It, CC Lens, CC Page Turn, CC Tiler,
-//! CC Griddler.
+//! Compensation, Magnify, Mesh Warp, Bezier Warp, Two-Point Bend, Lens Distortion, Page Curl, Scaled Tiles,
+//! Tile Grid.
//!
//! Mesh Warp and Bezier Warp share a grid rasteriser: a (nx+1)×(ny+1) grid of destination points
//! and matching source points is split into triangles, bucketed per output row and filled
@@ -550,7 +550,7 @@ pub fn bezier_grid(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> (usize, Vec
(n, dest, srcp)
}
-// ---------------------------------------------------------------- CC Bend It
+// ---------------------------------------------------------------- Two-Point Bend
fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf {
let mut beta = ctx.params.f("bend").to_radians();
@@ -606,7 +606,7 @@ fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Lens
+// ---------------------------------------------------------------- Lens Distortion
fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
@@ -629,7 +629,7 @@ fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Page Turn
+// ---------------------------------------------------------------- Page Curl
fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
let f = b.to_px(ctx.params.v2("foldPosition"));
@@ -679,7 +679,7 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Tiler / CC Griddler
+// ---------------------------------------------------------------- Scaled Tiles / Tile Grid
fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = (ctx.params.f("scale") / 100.0).max(0.001);
@@ -829,7 +829,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccbendit",
- "CC Bend It",
+ "Two-Point Bend",
vec![
p("bend", "Bend", num(0.0), ParamUi::Angle),
p("start", "Start", pt(0.5, 0.2), ParamUi::Point),
@@ -840,7 +840,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.cclens",
- "CC Lens",
+ "Lens Distortion",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("size", "Size", num(50.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
@@ -850,7 +850,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccpageturn",
- "CC Page Turn",
+ "Page Curl",
vec![
p("foldPosition", "Fold Position", pt(0.7, 0.7), ParamUi::Point),
p("foldDirection", "Fold Direction", num(315.0), ParamUi::Angle),
@@ -864,7 +864,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.cctiler",
- "CC Tiler",
+ "Scaled Tiles",
vec![
p("scale", "Scale", num(50.0), slider(1.0, 10000.0, 1.0, 200.0, 1)),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
@@ -874,7 +874,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccgriddler",
- "CC Griddler",
+ "Tile Grid",
vec![
p("horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
p("verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs
index a02ab5743..34fc282ed 100644
--- a/crates/effects/src/distort3.rs
+++ b/crates/effects/src/distort3.rs
@@ -1,5 +1,5 @@
-//! Distort effects, batch 3: CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant,
-//! CC Smear, CC Split, CC Split 2, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the
+//! Distort effects, batch 3: Flexible Bend, Organic Displacement, Perspective Pin, Pulsed Ripple, Slant,
+//! Directional Smear, Split Warp, Dual Split Warp, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the
//! fifteen warp styles) and Detail-preserving Upscale.
//!
//! All warps are inverse mapped: every output pixel finds the source point that lands on it.
@@ -59,7 +59,7 @@ fn elastic_exp(i: u32) -> f64 {
[1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.5][(i as usize).min(7)]
}
-// ---------------------------------------------------------------- CC Bender
+// ---------------------------------------------------------------- Flexible Bend
fn cc_bender(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amount = ctx.params.f("amount") / 100.0;
@@ -107,7 +107,7 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
[v[0] / l, v[1] / l, v[2] / l]
}
-/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32;
+/// f32 normal/dot helpers for the Organic Displacement lighting pass. The GPU kernel runs in f32;
/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance.
fn norm3f(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
@@ -118,7 +118,7 @@ fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
-// ---------------------------------------------------------------- CC Blobbylize
+// ---------------------------------------------------------------- Organic Displacement
fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let soft = ctx.params.f("softness").max(0.0) * b.scale;
@@ -221,7 +221,7 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Power Pin
+// ---------------------------------------------------------------- Perspective Pin
fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
@@ -304,7 +304,7 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Ripple Pulse
+// ---------------------------------------------------------------- Pulsed Ripple
fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf {
let level = ctx.params.f("pulseLevel") / 100.0;
@@ -350,7 +350,7 @@ fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Slant
+// ---------------------------------------------------------------- Slant
fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
let k = ctx.params.f("slant") / 100.0;
@@ -375,7 +375,7 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Smear
+// ---------------------------------------------------------------- Directional Smear
fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
let reach = ctx.params.f("reach") / 100.0;
@@ -403,7 +403,7 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and
-/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the
+/// `scale`) for Directional Smear: it moves pixels by up to Reach × From→To, and they may move out to the
/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are
/// comp-sized, so the smear was cut off at the content's box (#659).
pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 {
@@ -417,7 +417,7 @@ pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f6
(moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale))
}
-// ---------------------------------------------------------------- CC Split / CC Split 2
+// ---------------------------------------------------------------- Split Warp / Dual Split Warp
fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf {
if s1 == 0.0 && s2 == 0.0 {
@@ -913,7 +913,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.distort.ccbender",
- "CC Bender",
+ "Flexible Bend",
vec![
p("amount", "Amount", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)),
p("style", "Style", Value::Enum(0), popup(&["Bend", "Marilyn", "Sharp", "Boxer"])),
@@ -925,7 +925,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccblobbylize",
- "CC Blobbylize",
+ "Organic Displacement",
vec![
p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])),
@@ -947,7 +947,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccpowerpin",
- "CC Power Pin",
+ "Perspective Pin",
vec![
p("topLeft", "Top Left", pt(0.0, 0.0), ParamUi::Point),
p("topRight", "Top Right", pt(1.0, 0.0), ParamUi::Point),
@@ -964,7 +964,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccripplepulse",
- "CC Ripple Pulse",
+ "Pulsed Ripple",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("pulseLevel", "Pulse Level (animate)", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
@@ -976,7 +976,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccslant",
- "CC Slant",
+ "Slant",
vec![
p("slant", "Slant", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
p("stretching", "Stretching", Value::Bool(false), ParamUi::Checkbox),
@@ -987,7 +987,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccsmear",
- "CC Smear",
+ "Directional Smear",
vec![
p("from", "From", pt(0.35, 0.5), ParamUi::Point),
p("to", "To", pt(0.65, 0.5), ParamUi::Point),
@@ -998,7 +998,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccsplit",
- "CC Split",
+ "Split Warp",
vec![
p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point),
p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point),
@@ -1008,7 +1008,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.distort.ccsplit2",
- "CC Split 2",
+ "Dual Split Warp",
vec![
p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point),
p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point),
diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs
index b724a5830..9913c6ea5 100644
--- a/crates/effects/src/distort4.rs
+++ b/crates/effects/src/distort4.rs
@@ -1,6 +1,6 @@
-//! Distort: CC Flo Motion, Liquify and Rolling Shutter Repair.
+//! Distort: Dual Lens Warp, Liquify and Rolling Shutter Repair.
//!
-//! * **CC Flo Motion** — two knots that pull the image toward themselves (positive amount) or
+//! * **Dual Lens Warp** — two knots that pull the image toward themselves (positive amount) or
//! push it away (negative), with a falloff exponent, optional tiling and supersampled
//! antialiasing.
//! * **Liquify** — brush strokes build a displacement mesh (a grid of layer-space offsets). The
@@ -32,7 +32,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren
EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true }
}
-// ---------------------------------------------------------------- CC Flo Motion
+// ---------------------------------------------------------------- Dual Lens Warp
fn flo_motion(ctx: &EffectCtx, mut b: Buf) -> Buf {
let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)];
@@ -560,7 +560,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.distort.ccflomotion",
- "CC Flo Motion",
+ "Dual Lens Warp",
vec![
p("knot1", "Knot 1", Value::Vec2([0.25, 0.5]), ParamUi::Point),
p("amount1", "Amount 1", num(50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)),
diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs
index b4781b793..ad80cb9cd 100644
--- a/crates/effects/src/generate2.rs
+++ b/crates/effects/src/generate2.rs
@@ -1,5 +1,5 @@
//! Generate effects, batch 2: Cell Pattern (Worley), Ellipse, Lens Flare, Beam, Radio Waves,
-//! Advanced Lightning, CC Light Rays, CC Light Burst 2.5, CC Light Sweep.
+//! Advanced Lightning, Light Rays, Radial Light Burst, Light Sweep.
//!
//! All designs are original procedural renderings written from the public descriptions of what
//! each effect produces.
@@ -897,7 +897,7 @@ fn advanced_lightning(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Light Rays / Burst
+// ---------------------------------------------------------------- Light Rays / Burst
const RAY_STEPS: usize = 32;
@@ -1007,7 +1007,7 @@ fn light_burst(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Light Sweep
+// ---------------------------------------------------------------- Light Sweep
fn light_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
@@ -1192,7 +1192,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.generate.cclightrays",
- "CC Light Rays",
+ "Light Rays",
vec![
p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
@@ -1207,7 +1207,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.generate.cclightburst",
- "CC Light Burst 2.5",
+ "Radial Light Burst",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)),
@@ -1220,7 +1220,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.generate.cclightsweep",
- "CC Light Sweep",
+ "Light Sweep",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("direction", "Direction", num(-30.0), ang()),
diff --git a/crates/effects/src/generate3/registry.rs b/crates/effects/src/generate3/registry.rs
index f3e08c2d5..baa493110 100644
--- a/crates/effects/src/generate3/registry.rs
+++ b/crates/effects/src/generate3/registry.rs
@@ -180,7 +180,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.generate.ccgluegun",
- "CC Glue Gun",
+ "Paint Beads",
vec![
p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point),
p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
@@ -194,7 +194,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.generate.ccthreads",
- "CC Threads",
+ "Woven Threads",
vec![
p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs
index a03fe804d..5512d1820 100644
--- a/crates/effects/src/keying2.rs
+++ b/crates/effects/src/keying2.rs
@@ -317,7 +317,7 @@ fn unmult(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---------------------------------------------------------------- CC Simple Wire Removal
+// ---------------------------------------------------------------- Line Removal
fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pa = ctx.params.v2("pointA");
@@ -464,7 +464,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.key.ccsimplewireremoval",
- "CC Simple Wire Removal",
+ "Line Removal",
vec![
p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point),
p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point),
diff --git a/crates/effects/src/keylight.rs b/crates/effects/src/keylight.rs
index 84085f4e3..fb36075cb 100644
--- a/crates/effects/src/keylight.rs
+++ b/crates/effects/src/keylight.rs
@@ -1,6 +1,6 @@
-//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled
+//! Screen Color Key — a full screen-colour keyer with the controls of After Effects' bundled
//! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as
-//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
+//! **Screen Color Key**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
//!
//! The method is the textbook *screen difference* key, written from the public description of
//! the controls:
@@ -31,7 +31,7 @@ use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremu
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
/// Other names the effect answers to in [`crate::lookup`].
-pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"];
+pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight", "Key Light"];
pub const VIEWS: [&str; 11] = [
"Source",
@@ -463,7 +463,7 @@ pub fn specs() -> Vec {
let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1));
vec![EffectSpec {
id: "ec.keying.keylight",
- name: "Key Light",
+ name: "Screen Color Key",
category: "Keying",
params: vec![
p("view", "View", Value::Enum(10), popup(&VIEWS)),
@@ -622,7 +622,7 @@ mod tests {
let spec = crate::find("ec.keying.keylight").unwrap();
let mut next = 1;
let mut ids = Ids(&mut next);
- let mut g = crate::instantiate(spec, &mut ids, "Key Light", [10.0, 10.0]);
+ let mut g = crate::instantiate(spec, &mut ids, "Screen Color Key", [10.0, 10.0]);
// Flatten it the way older versions saved it.
let mut flat = vec![];
g.walk_mut(&mut |p| flat.push(p.clone()));
diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs
index 0cde17766..019596f80 100644
--- a/crates/effects/src/lib.rs
+++ b/crates/effects/src/lib.rs
@@ -23,6 +23,8 @@ mod color3;
mod color_fx;
pub mod controls;
pub mod crypto_select;
+#[cfg(test)]
+mod display_names;
mod distort;
mod distort2;
mod distort3;
@@ -36,6 +38,7 @@ mod keying;
mod keying2;
pub mod keylight;
mod matte;
+
pub mod migrate;
mod misc;
pub mod mocha_shape;
@@ -116,8 +119,9 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{
- CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
- crypto_layer_name, crypto_layer_named, id_color, matte_only_px, reads_aux, selection_hashes,
+ CRYPTO_LAYERS, CryptoAux, EXTRACTOR_COPY, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
+ crypto_layer_name, crypto_layer_named, extractor_aux_channel, extractor_own_channel, id_color, is_extractor_copy, matte_only_px, reads_aux,
+ selection_hashes,
};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
@@ -447,7 +451,9 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge
/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these
/// skips the renderer's cover of the whole layer rectangle (#1205).
-pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"];
+/// Drop Shadow also pads by its distance and softness on both CPU and GPU: covering the
+/// layer rectangle first only inflates working images on highly scaled text and shapes (#3357).
+pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace", "ec.perspective.dropshadow"];
/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels
/// of the resampling pyramid used for downscaled layers.
@@ -559,7 +565,7 @@ pub trait EffectHost: Sync {
}
/// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in
/// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer
- /// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373).
+ /// time**: Particle Emitter 3D samples its Birth Rate at every step it simulates (#1373).
/// `None` when unavailable.
fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option {
None
@@ -775,8 +781,82 @@ pub fn categories() -> Vec<&'static str> {
v
}
-/// After Effects' third-party display names of effects we register under a generic name.
-const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)];
+/// Legacy display names accepted for saved scripts and searches. Never display these as
+/// native feature names; stable effect IDs and external format match names stay unchanged.
+const ALIASES: &[(&str, &[&str])] = &[
+ ("ec.sim.ccballaction", &["CC Ball Action"]),
+ ("ec.distort.ccbendit", &["CC Bend It"]),
+ ("ec.distort.ccbender", &["CC Bender"]),
+ ("ec.distort.ccblobbylize", &["CC Blobbylize"]),
+ ("ec.stylize.ccblockload", &["CC Block Load"]),
+ ("ec.sim.ccbubbles", &["CC Bubbles"]),
+ ("ec.stylize.ccburnfilm", &["CC Burn Film"]),
+ ("ec.color.cccolorneutralizer", &["CC Color Neutralizer"]),
+ ("ec.color.cccoloroffset", &["CC Color Offset"]),
+ ("ec.channel.cccomposite", &["CC Composite"]),
+ ("ec.blur.cccross", &["CC Cross Blur"]),
+ ("ec.perspective.cccylinder", &["CC Cylinder"]),
+ ("ec.sim.ccdrizzle", &["CC Drizzle"]),
+ ("ec.perspective.ccenvironment", &["CC Environment"]),
+ ("ec.distort.ccflomotion", &["CC Flo Motion"]),
+ ("ec.time.ccforcemotionblur", &["CC Force Motion Blur"]),
+ ("ec.stylize.ccglass", &["CC Glass"]),
+ ("ec.transition.ccglasswipe", &["CC Glass Wipe"]),
+ ("ec.generate.ccgluegun", &["CC Glue Gun"]),
+ ("ec.transition.ccgridwipe", &["CC Grid Wipe"]),
+ ("ec.distort.ccgriddler", &["CC Griddler"]),
+ ("ec.sim.cchair", &["CC Hair"]),
+ ("ec.stylize.cchextile", &["CC HexTile"]),
+ ("ec.transition.ccimagewipe", &["CC Image Wipe"]),
+ ("ec.transition.ccjaws", &["CC Jaws"]),
+ ("ec.stylize.cckaleida", &["CC Kaleida"]),
+ ("ec.color.cckernel", &["CC Kernel"]),
+ ("ec.distort.cclens", &["CC Lens"]),
+ ("ec.generate.cclightburst", &["CC Light Burst 2.5"]),
+ ("ec.generate.cclightrays", &["CC Light Rays"]),
+ ("ec.generate.cclightsweep", &["CC Light Sweep"]),
+ ("ec.transition.cclightwipe", &["CC Light Wipe"]),
+ ("ec.transition.cclinesweep", &["CC Line Sweep"]),
+ ("ec.sim.ccmrmercury", &["CC Mr. Mercury"]),
+ ("ec.stylize.ccmrsmoothie", &["CC Mr. Smoothie"]),
+ ("ec.utility.ccoverbrights", &["CC Overbrights"]),
+ ("ec.distort.ccpageturn", &["CC Page Turn"]),
+ ("ec.sim.ccparticlesystems2", &["CC Particle Systems II"]),
+ ("ec.sim.ccparticleworld", &["CC Particle World"]),
+ ("ec.sim.ccpixelpolly", &["CC Pixel Polly"]),
+ ("ec.stylize.ccplastic", &["CC Plastic"]),
+ ("ec.distort.ccpowerpin", &["CC Power Pin"]),
+ ("ec.blur.ccradial", &["CC Radial Blur"]),
+ ("ec.blur.ccradialfast", &["CC Radial Fast Blur"]),
+ ("ec.transition.ccradialscalewipe", &["CC Radial ScaleWipe"]),
+ ("ec.sim.ccrainfall", &["CC Rainfall"]),
+ ("ec.stylize.ccrepetile", &["CC RepeTile"]),
+ ("ec.distort.ccripplepulse", &["CC Ripple Pulse"]),
+ ("ec.transition.ccscalewipe", &["CC Scale Wipe"]),
+ ("ec.sim.ccscatterize", &["CC Scatterize"]),
+ ("ec.key.ccsimplewireremoval", &["CC Simple Wire Removal"]),
+ ("ec.distort.ccslant", &["CC Slant"]),
+ ("ec.distort.ccsmear", &["CC Smear"]),
+ ("ec.sim.ccsnowfall", &["CC Snowfall"]),
+ ("ec.perspective.ccsphere", &["CC Sphere"]),
+ ("ec.distort.ccsplit", &["CC Split"]),
+ ("ec.distort.ccsplit2", &["CC Split 2"]),
+ ("ec.perspective.ccspotlight", &["CC Spotlight"]),
+ ("ec.sim.ccstarburst", &["CC Star Burst"]),
+ ("ec.generate.ccthreads", &["CC Threads"]),
+ ("ec.stylize.ccthreshold", &["CC Threshold"]),
+ ("ec.stylize.ccthresholdrgb", &["CC Threshold RGB"]),
+ ("ec.distort.cctiler", &["CC Tiler"]),
+ ("ec.color.cctoner", &["CC Toner"]),
+ ("ec.transition.cctwister", &["CC Twister"]),
+ ("ec.blur.ccvector", &["CC Vector Blur"]),
+ ("ec.stylize.ccvignette", &["CC Vignette"]),
+ ("ec.transition.ccwarpomatic", &["CC WarpoMatic"]),
+ ("ec.time.ccwidetime", &["CC Wide Time"]),
+ ("ec.color.lumetri", &["Lumetri Color"]),
+ ("ec.obsolete.mochashape", &["Mocha shape", "mocha shape"]),
+ ("ec.keying.keylight", keylight::KEYLIGHT_ALIASES),
+];
/// Other names effect `id` answers to (in [`lookup`] and searches).
pub fn aliases(id: &str) -> &'static [&'static str] {
@@ -860,7 +940,7 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
/// effects without their own table.
pub const PARAM_GROUPS: &[(&str, &str)] = &[
// Color Correction: Exposure, Levels (Individual Controls), Change to Color, Colorama,
- // Shadow/Highlight, Lumetri Color.
+ // Shadow/Highlight, Color Grading.
("master", "Master"),
("rgb", "RGB"),
("red", "Red"),
@@ -989,7 +1069,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("particleExploder", "Particle Exploder"),
("affects", "Affects"),
("options", "Options"),
- // Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`).
+ // Keying, Matte, Obsolete (Screen Color Key's groups are in `keylight::GROUPS`).
("ultraSettings", "Ultra Settings"),
("additionalForeground", "Additional Foreground"),
("additionalBackground", "Additional Background"),
@@ -1074,7 +1154,7 @@ fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&
}
/// Effects the GPU compositor (`effectcraft-gpu`) implements with the CPU effect's semantics
-/// (Mercury GPU Acceleration; the Effects & Presets GPU badge). Expression controls are
+/// (GPU Acceleration; the Effects & Presets GPU badge). Expression controls are
/// pass-throughs and count as GPU effects too.
pub const GPU_EFFECTS: &[&str] = &[
"ec.blur.gaussian",
@@ -1436,7 +1516,7 @@ pub const TIME_DEPENDENT: &[&str] = &[
pub fn is_time_dependent(id: &str) -> bool {
// Simulations replay from layer time 0, so every frame differs even when the defaults
// happen to look static at the test's two sample times.
- id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id)
+ id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) || (!id.starts_with("ec.") && plugin::reads_other_times(id))
}
/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample
@@ -1494,7 +1574,7 @@ mod tests {
assert_eq!(grow_sides(lo, hi, 20, 10), Some([0, 0, 30, 20]));
}
- /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
+ /// #2261: a 20 × 20 layer with Fast Radial Blur's Center far away grows to at most 16 Mpx;
/// big layers may still grow to four times their area, and small pads are untouched.
#[test]
fn padded_buffers_stay_within_the_budget() {
diff --git a/crates/effects/src/mocha_shape.rs b/crates/effects/src/mocha_shape.rs
index cf9e2bd88..c669580c6 100644
--- a/crates/effects/src/mocha_shape.rs
+++ b/crates/effects/src/mocha_shape.rs
@@ -1,4 +1,4 @@
-//! Matte ▸ Mocha shape: renders planar-tracked roto shapes as a matte.
+//! Matte ▸ Tracked Shape Matte: renders planar-tracked roto shapes as a matte.
//!
//! Mocha's own clipboard / export formats are not publicly specified, so the effect reads shape
//! data in a small documented JSON format of ours (in the hidden `shapeData` parameter, either the
@@ -184,7 +184,7 @@ fn mocha_shape(ctx: &EffectCtx, mut b: Buf) -> Buf {
pub fn specs() -> Vec {
vec![EffectSpec {
id: "ec.obsolete.mochashape",
- name: "Mocha shape",
+ name: "Tracked Shape Matte",
category: "Matte",
params: vec![
p("blendMode", "Blend mode", Value::Enum(0), popup(&["Add", "Subtract", "Intersect", "Difference"])),
diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs
index 24c561ecd..69c63caf9 100644
--- a/crates/effects/src/perspective.rs
+++ b/crates/effects/src/perspective.rs
@@ -1,5 +1,5 @@
-//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, CC Cylinder,
-//! CC Sphere and CC Spotlight.
+//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, Cylinder Wrap,
+//! Sphere Wrap and Surface Spotlight.
//!
//! Angles follow the convention used by Drop Shadow: measured clockwise from "up", so a light
//! angle `a` sits in the direction `(sin a, -cos a)` (y grows downwards). 3D vectors use x right,
@@ -158,7 +158,7 @@ fn radial_shadow(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-/// Phong-style shading shared by CC Cylinder and CC Sphere.
+/// Phong-style shading shared by Cylinder Wrap and Sphere Wrap.
struct Shading {
l: [f64; 3],
color: [f32; 4],
@@ -451,11 +451,11 @@ pub fn specs() -> Vec {
],
radial_shadow,
),
- spec("ec.perspective.cccylinder", "CC Cylinder", cyl, cylinder),
- spec("ec.perspective.ccsphere", "CC Sphere", sph, sphere),
+ spec("ec.perspective.cccylinder", "Cylinder Wrap", cyl, cylinder),
+ spec("ec.perspective.ccsphere", "Sphere Wrap", sph, sphere),
spec(
"ec.perspective.ccspotlight",
- "CC Spotlight",
+ "Surface Spotlight",
vec![
p("from", "From", pt(0.3, 0.2), ParamUi::Point),
p("to", "To", pt(0.5, 0.5), ParamUi::Point),
diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs
index 13846607f..2bc7e3e7e 100644
--- a/crates/effects/src/perspective2.rs
+++ b/crates/effects/src/perspective2.rs
@@ -1,4 +1,4 @@
-//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer
+//! Perspective effects, batch 2: Environment Map (reflection mapping of an environment layer
//! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a
//! left and right view layer).
@@ -17,7 +17,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren
EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true }
}
-// ---------------------------------------------------------------- CC Environment
+// ---------------------------------------------------------------- Environment Map
/// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates.
fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) {
@@ -194,7 +194,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.perspective.ccenvironment",
- "CC Environment",
+ "Environment Map",
vec![
p("environment", "Environment", Value::Layer(None), ParamUi::Layer),
p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])),
diff --git a/crates/effects/src/plugin.rs b/crates/effects/src/plugin.rs
index 5c973ac57..8bbe17856 100644
--- a/crates/effects/src/plugin.rs
+++ b/crates/effects/src/plugin.rs
@@ -24,10 +24,12 @@
//! * rendering must be deterministic (same inputs, same output) and may run on several threads
//! at once.
//!
-//! API 2 (native plug-ins; the WebAssembly ABI stays at 1) adds [`EffectPlugin::padding`] and
-//! [`EffectPlugin::render_v2`], which gets a [`PluginHost`] to read the effect's input or other
-//! layers at other times, and the extra parameter kinds. External effect helpers
-//! (`effectcraft-helper-host`) are built on it.
+//! API 2 (native plug-ins) adds [`EffectPlugin::padding`] and [`EffectPlugin::render_v2`], which
+//! gets a [`PluginHost`] to read the effect's input or other layers at other times, and the extra
+//! parameter kinds. External effect helpers (`effectcraft-helper-host`) are built on it.
+//!
+//! The WebAssembly ABI is versioned separately ([`WASM_API_VERSION`]): ABI 1 is `ec_render`;
+//! ABI 2 adds `ec_render2`, which receives the layer's previous and next frames.
use std::sync::{Arc, OnceLock, RwLock};
@@ -39,9 +41,12 @@ use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, Params, RenderFn};
/// The plug-in API version this build implements (manifests with API 1 or 2 are accepted).
pub const PLUGIN_API_VERSION: u32 = 2;
-/// The plug-in API a WebAssembly module implements (its `ec_api_version`). API 2 is for native
-/// and helper plug-ins; WebAssembly modules stay at 1.
-pub const WASM_API_VERSION: u32 = 1;
+/// The newest WebAssembly ABI this build implements (a module's `ec_api_version`). ABI 1 is
+/// `ec_render`; ABI 2 adds `ec_render2` and the layer's neighbouring frames. This is separate
+/// from the plug-in API version: a module's manifest `api` stays 1 or 2.
+pub const WASM_API_VERSION: u32 = 2;
+/// The oldest WebAssembly ABI this build still loads.
+pub const MIN_WASM_API_VERSION: u32 = 1;
/// The most plug-in effects one process can register (commercial effect suites register a few hundred each).
pub const MAX_PLUGINS: usize = 1024;
@@ -269,6 +274,22 @@ impl PluginImage {
fn from_buf(b: Buf) -> PluginImage {
PluginImage { width: b.img.width, height: b.img.height, pixels: b.img.data, origin: b.offset, scale: b.scale }
}
+
+ /// This image resampled onto a `width × height` buffer grid at `origin`/`scale` (the current
+ /// frame's); the pixels unchanged when the grids already match.
+ pub fn on_grid(&self, width: u32, height: u32, origin: [f64; 2], scale: f64) -> Vec<[f32; 4]> {
+ if self.width == width && self.height == height && self.origin == origin && self.scale == scale {
+ return self.pixels.clone();
+ }
+ let k = self.scale / scale.max(1e-9);
+ let src = crate::Image { width: self.width, height: self.height, data: self.pixels.clone() };
+ crate::util::gen_image(width, height, |x, y| {
+ let lx = (x as f64 + 0.5 - origin[0]) * k + self.origin[0];
+ let ly = (y as f64 + 0.5 - origin[1]) * k + self.origin[1];
+ src.sample_bilinear(lx, ly)
+ })
+ .data
+ }
}
/// Services available during an API 2 render ([`EffectPlugin::render_v2`]).
@@ -388,6 +409,12 @@ pub trait EffectPlugin: Send + Sync {
fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64, _host: &dyn PluginHost) -> Result<(), String> {
self.render(frame, params, time)
}
+ /// Whether rendering reads the layer at other times (its neighbouring frames). Default
+ /// false; the render cache keys a layer with such an effect on its layer time. WebAssembly
+ /// ABI 2 plug-ins return true.
+ fn reads_other_times(&self) -> bool {
+ false
+ }
}
static SLOTS: [OnceLock>; MAX_PLUGINS] = [const { OnceLock::new() }; MAX_PLUGINS];
@@ -432,6 +459,12 @@ pub fn plugin(id: &str) -> Option> {
SLOTS.iter().filter_map(|s| s.get()).find(|p| p.manifest().id == id).cloned()
}
+/// Whether the registered plug-in `id` reads the layer at other times (its neighbouring frames),
+/// so the render cache must key its layer on the layer time.
+pub fn reads_other_times(id: &str) -> bool {
+ SLOTS.iter().filter_map(|s| s.get()).any(|p| p.manifest().id == id && p.reads_other_times())
+}
+
fn leak(s: &str) -> &'static str {
Box::leak(s.to_string().into_boxed_str())
}
diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs
index f55b99e27..4df07c50a 100644
--- a/crates/effects/src/sim/registry.rs
+++ b/crates/effects/src/sim/registry.rs
@@ -32,7 +32,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.sim.ccrainfall",
- "CC Rainfall",
+ "Rainfall",
vec![
p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)),
p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)),
@@ -53,7 +53,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccsnowfall",
- "CC Snowfall",
+ "Snowfall",
vec![
p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)),
p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)),
@@ -79,7 +79,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccbubbles",
- "CC Bubbles",
+ "Bubbles",
vec![
p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)),
@@ -93,7 +93,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccdrizzle",
- "CC Drizzle",
+ "Water Ripples",
vec![
p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)),
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)),
@@ -114,7 +114,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccstarburst",
- "CC Star Burst",
+ "Starfield",
vec![
p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)),
p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)),
@@ -127,7 +127,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccparticleworld",
- "CC Particle World",
+ "Particle Emitter 3D",
vec![
p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
@@ -170,7 +170,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccparticlesystems2",
- "CC Particle Systems II",
+ "Particle Emitter 2D",
vec![
p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
@@ -198,7 +198,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccmrmercury",
- "CC Mr. Mercury",
+ "Liquid Particles",
vec![
p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
diff --git a/crates/effects/src/sim2/registry.rs b/crates/effects/src/sim2/registry.rs
index 333916a16..dc64b7097 100644
--- a/crates/effects/src/sim2/registry.rs
+++ b/crates/effects/src/sim2/registry.rs
@@ -79,7 +79,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.sim.ccballaction",
- "CC Ball Action",
+ "Ball Grid",
vec![
p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])),
@@ -94,7 +94,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccpixelpolly",
- "CC Pixel Polly",
+ "Pixel Shatter",
vec![
p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)),
p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
@@ -111,7 +111,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.sim.ccscatterize",
- "CC Scatterize",
+ "Pixel Scatter",
vec![
p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle),
diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs
index 88a16d981..c2a286390 100644
--- a/crates/effects/src/sim3.rs
+++ b/crates/effects/src/sim3.rs
@@ -1,4 +1,4 @@
-//! Simulation: Particle Playground and CC Hair.
+//! Simulation: Particle Playground and Surface Fibers.
//!
//! **Particle Playground** is a deterministic 2D particle system stepped from layer time 0 at
//! [`SPS`] steps per second through [`SimCache`] (seeking anywhere gives the same frame as
@@ -17,7 +17,7 @@
//! being rendered (their pixels are part of the cache key). Particles replace the layer's own
//! pixels, as in After Effects.
//!
-//! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a
+//! **Surface Fibers** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a
//! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall
//! map layer (luminance → length, horizontal/vertical gradient → lean), and shades them with a
//! Kajiya–Kay-style strand model (diffuse ∝ sin(tangent, light), specular from the half vector).
@@ -934,7 +934,7 @@ impl PgPlan {
}
}
-// ---------------------------------------------------------------- CC Hair
+// ---------------------------------------------------------------- Surface Fibers
fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
let Some(mut plan) = hair_plan(ctx, &b) else { return b };
@@ -942,7 +942,7 @@ fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
plan.finish(b)
}
-/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
+/// Surface Fibers's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
/// `None` = no hair (the layer passes through).
pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option {
let pr = ctx.params;
@@ -1144,7 +1144,7 @@ pub fn specs() -> Vec {
spec("ec.sim.particleplayground", "Particle Playground", pg, particle_playground),
spec(
"ec.sim.cchair",
- "CC Hair",
+ "Surface Fibers",
vec![
sp("length", "Length", px(30.0, 200.0)),
p("thickness", "Thickness", num(1.0), slider(0.05, 20.0, 0.1, 5.0, 2)),
diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs
index 94eda6370..247854352 100644
--- a/crates/effects/src/stylize2.rs
+++ b/crates/effects/src/stylize2.rs
@@ -343,7 +343,7 @@ fn brush_strokes(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---- CC Kaleida ----
+// ---- Kaleidoscope ----
fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
@@ -368,7 +368,7 @@ fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---- CC RepeTile ----
+// ---- Edge Tile Repeat ----
#[inline]
fn tile_coord(v: f64, size: f64) -> (i64, f64) {
@@ -491,7 +491,7 @@ fn color_emboss(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---- CC Vignette ----
+// ---- Vignette ----
fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("amount") as f32 / 100.0;
@@ -523,7 +523,7 @@ fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ---- CC Threshold / CC Threshold RGB ----
+// ---- Channel Threshold / RGB Threshold ----
fn cc_threshold(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = ctx.params.f("threshold") as f32 / 255.0;
@@ -669,7 +669,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.stylize.cckaleida",
- "CC Kaleida",
+ "Kaleidoscope",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("size", "Size", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
@@ -680,7 +680,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.stylize.ccrepetile",
- "CC RepeTile",
+ "Edge Tile Repeat",
vec![
p("expandRight", "Expand Right", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)),
p("expandLeft", "Expand Left", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)),
@@ -718,7 +718,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.stylize.ccvignette",
- "CC Vignette",
+ "Vignette",
vec![
p("amount", "Amount", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("angleOfView", "Angle of View", num(60.0), slider(0.0, 179.0, 0.0, 179.0, 1)),
@@ -729,7 +729,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.stylize.ccthreshold",
- "CC Threshold",
+ "Channel Threshold",
vec![
p("threshold", "Threshold", num(127.0), thr()),
p("channel", "Channel", Value::Enum(0), popup(&["Luminance", "RGB", "Saturation", "Alpha"])),
@@ -740,7 +740,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.stylize.ccthresholdrgb",
- "CC Threshold RGB",
+ "RGB Threshold",
vec![
p("redThreshold", "Red Threshold", num(127.0), thr()),
p("greenThreshold", "Green Threshold", num(127.0), thr()),
diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs
index e8a141cf4..1121f737b 100644
--- a/crates/effects/src/stylize3.rs
+++ b/crates/effects/src/stylize3.rs
@@ -1,5 +1,5 @@
-//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie
-//! and CC Plastic.
+//! Stylize effects, batch 3: Block Reveal, Film Burn, Glass Relief, Hexagonal Tiles, Palette Map
+//! and Plastic Shading.
//!
//! Written from the public descriptions of what these effects do (progressive block loading,
//! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling,
@@ -27,7 +27,7 @@ fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
-// ------------------------------------------------------------------ CC Block Load
+// ------------------------------------------------------------------ Block Reveal
/// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`).
fn block_means(img: &Image, s: usize) -> (usize, usize, Vec) {
@@ -135,7 +135,7 @@ fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC Burn Film
+// ------------------------------------------------------------------ Film Burn
fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32;
@@ -180,7 +180,7 @@ fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"];
-/// The bump-map layer parameter: CC Glass's Bump Map, CC Plastic's Bump Layer.
+/// The bump-map layer parameter: Glass Relief's Bump Map, Plastic Shading's Bump Layer.
pub fn bump_layer_id(ctx: &EffectCtx) -> &'static str {
if ctx.params.get("bumpLayer").is_some() { "bumpLayer" } else { "bumpMap" }
}
@@ -208,7 +208,7 @@ fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane {
pl
}
-/// The effect light and surface of CC Glass / CC Plastic (shared with the GPU compositor).
+/// The effect light and surface of Glass Relief / Plastic Shading (shared with the GPU compositor).
pub struct BumpLight {
pub intensity: f32,
pub color: [f32; 3],
@@ -342,7 +342,7 @@ fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC HexTile
+// ------------------------------------------------------------------ Hexagonal Tiles
/// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre
/// and integer cell coordinates.
@@ -418,7 +418,7 @@ fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC Mr. Smoothie
+// ------------------------------------------------------------------ Palette Map
const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"];
@@ -518,7 +518,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.stylize.ccblockload",
- "CC Block Load",
+ "Block Reveal",
vec![
p("completion", "Completion", num(0.0), pct()),
p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox),
@@ -530,7 +530,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.stylize.ccburnfilm",
- "CC Burn Film",
+ "Film Burn",
vec![
p("burn", "Burn", num(0.0), pct()),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
@@ -538,10 +538,10 @@ pub fn specs() -> Vec {
],
burn_film,
),
- spec("ec.stylize.ccglass", "CC Glass", glass_p, glass),
+ spec("ec.stylize.ccglass", "Glass Relief", glass_p, glass),
spec(
"ec.stylize.cchextile",
- "CC HexTile",
+ "Hexagonal Tiles",
vec![
p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])),
p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)),
@@ -553,7 +553,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.stylize.ccmrsmoothie",
- "CC Mr. Smoothie",
+ "Palette Map",
vec![
p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)),
@@ -565,7 +565,7 @@ pub fn specs() -> Vec {
],
smoothie,
),
- spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic),
+ spec("ec.stylize.ccplastic", "Plastic Shading", plastic_p, plastic),
]
}
diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs
index 79b0ce479..b9d20e2a7 100644
--- a/crates/effects/src/time_fx.rs
+++ b/crates/effects/src/time_fx.rs
@@ -1,9 +1,9 @@
//! Time effects (Effect > Time): Echo, Posterize Time, Time Difference, Time Displacement,
-//! Timewarp, CC Force Motion Blur, CC Wide Time and Pixel Motion Blur.
+//! Timewarp, Temporal Motion Blur, Temporal Blend and Pixel Motion Blur.
//!
//! These read the layer at *other* layer times through [`EffectHost::self_at`]: its source with
//! its masks and the effects applied before them, as After Effects renders them (SceneYard:
-//! Echo after CC Particle Systems II echoes the fireworks, not the black solid under them;
+//! Echo after Particle Emitter 2D echoes the fireworks, not the black solid under them;
//! Posterize Time after Turbulent Displace steps the displacement). Timewarp is the exception:
//! it retimes the layer's source with its masks and ignores the effects above it (After
//! Effects 2026: a red solid with a Gradient Ramp and then Timewarp shows the red solid).
@@ -123,7 +123,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.time.ccforcemotionblur",
- "CC Force Motion Blur",
+ "Temporal Motion Blur",
vec![
p("motionBlurLevels", "Motion Blur Levels", num(8.0), slider(2.0, 64.0, 2.0, 32.0, 0)),
p("overrideShutterAngle", "Override Shutter Angle", Value::Bool(true), ParamUi::Checkbox),
@@ -134,7 +134,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.time.ccwidetime",
- "CC Wide Time",
+ "Temporal Blend",
vec![
p("forwardSteps", "Forward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)),
p("backwardSteps", "Backward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)),
@@ -994,7 +994,7 @@ fn matte_image(m: &[f32], like: &Image) -> Image {
out
}
-// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur
+// ---------------------------------------------------------------- Temporal Motion Blur / Temporal Blend / Pixel Motion Blur
fn force_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize;
diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs
index e465ffd1b..da3e85694 100644
--- a/crates/effects/src/transition.rs
+++ b/crates/effects/src/transition.rs
@@ -1,5 +1,5 @@
//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card
-//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
+//! Wipe (3D cards with camera, lighting and jitter), Grid Wipe, Radial Scale Wipe, Scale Wipe and Light Wipe.
//!
//! Every transition is the identity at 0 % completion. Angles are clockwise from "up".
@@ -595,7 +595,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.transition.ccgridwipe",
- "CC Grid Wipe",
+ "Grid Wipe",
vec![
cc_done(),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
@@ -609,13 +609,13 @@ pub fn specs() -> Vec {
),
spec(
"ec.transition.ccradialscalewipe",
- "CC Radial ScaleWipe",
+ "Radial Scale Wipe",
vec![cc_done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox)],
radial_scale_wipe,
),
spec(
"ec.transition.ccscalewipe",
- "CC Scale Wipe",
+ "Scale Wipe",
vec![
p("stretch", "Stretch", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 2)),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
@@ -625,7 +625,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.transition.cclightwipe",
- "CC Light Wipe",
+ "Light Wipe",
vec![
cc_done(),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs
index 2e1738b28..6f19a660b 100644
--- a/crates/effects/src/transition2.rs
+++ b/crates/effects/src/transition2.rs
@@ -1,5 +1,5 @@
-//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep,
-//! CC Twister and CC WarpoMatic.
+//! Transition effects, batch 2: Glass Wipe, Luminance Image Wipe, Serrated Wipe, Line Sweep,
+//! Twist Wipe and Warp Dissolve.
//!
//! Written from the public descriptions of these transitions (gradient-driven wipes with glass
//! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that
@@ -68,7 +68,7 @@ fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) {
((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5)
}
-// ------------------------------------------------------------------ CC Glass Wipe
+// ------------------------------------------------------------------ Glass Wipe
fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx);
@@ -105,7 +105,7 @@ fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC Image Wipe
+// ------------------------------------------------------------------ Luminance Image Wipe
fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx);
@@ -132,7 +132,7 @@ fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC Jaws
+// ------------------------------------------------------------------ Serrated Wipe
fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx) as f64;
@@ -193,7 +193,7 @@ fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC Line Sweep
+// ------------------------------------------------------------------ Line Sweep
fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx) as f64;
@@ -235,7 +235,7 @@ fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC Twister
+// ------------------------------------------------------------------ Twist Wipe
fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx) as f64;
@@ -283,7 +283,7 @@ fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
-// ------------------------------------------------------------------ CC WarpoMatic
+// ------------------------------------------------------------------ Warp Dissolve
fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx);
@@ -350,7 +350,7 @@ pub fn specs() -> Vec {
vec![
spec(
"ec.transition.ccglasswipe",
- "CC Glass Wipe",
+ "Glass Wipe",
vec![
done(),
p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer),
@@ -362,7 +362,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.transition.ccimagewipe",
- "CC Image Wipe",
+ "Luminance Image Wipe",
vec![
done(),
p("borderSoftness", "Border Softness", num(0.0), pct()),
@@ -376,7 +376,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.transition.ccjaws",
- "CC Jaws",
+ "Serrated Wipe",
vec![
done(),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
@@ -389,7 +389,7 @@ pub fn specs() -> Vec {
),
spec(
"ec.transition.cclinesweep",
- "CC Line Sweep",
+ "Line Sweep",
vec![
done(),
p("direction", "Direction", num(0.0), ParamUi::Angle),
@@ -401,7 +401,7 @@ pub fn specs() -> Vec