diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
new file mode 100644
index 000000000..3ef98173a
--- /dev/null
+++ b/.github/workflows/ci-macos.yml
@@ -0,0 +1,28 @@
+name: CI (macOS)
+
+# The macOS leg of the gate: `cargo xtask ci` on Apple silicon, through the suite's shared gate
+# workflow. The Linux gate (ci.yml) has no real GPU and runs on x86, so GPU tests on Metal and
+# results that depend on the platform's math library first ran on a contributor's Mac.
+#
+# On pushes to main, not on every pull request: macOS runners are scarce. A newer push cancels the
+# older run, so main is checked at its latest commit. Pull requests that change this file run it.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ paths: [.github/workflows/ci-macos.yml]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-macos-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ gate:
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ os: '["macos-15"]'
+ timeout-minutes: 120
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..4bf3a044d 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
diff --git a/Cargo.lock b/Cargo.lock
index 0ca2569c9..d14f264fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
@@ -1468,6 +1468,7 @@ dependencies = [
"effectcraft-script",
"image",
"serde_json",
+ "tempfile",
"thiserror 2.0.21",
]
@@ -1741,6 +1742,7 @@ version = "0.22.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
+ "effectcraft-effects",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
@@ -1877,6 +1879,7 @@ dependencies = [
"lz4_flex",
"rayon",
"serde",
+ "tempfile",
"web-time",
]
@@ -7956,7 +7959,6 @@ dependencies = [
"nix",
"typed-path 0.13.0",
"windows-sys 0.61.2",
- "zopfli",
]
[[package]]
@@ -7971,18 +7973,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"
diff --git a/Cargo.toml b/Cargo.toml
index c83940d2a..e988de80b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" }
# craft-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" }
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..b8634178e 100644
--- a/apps/effectcraft-cli/src/main.rs
+++ b/apps/effectcraft-cli/src/main.rs
@@ -35,8 +35,8 @@
//!
//! 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)
//! ```
@@ -466,7 +466,7 @@ fn backend(args: &Args, default_demo: bool) -> 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") {
@@ -1443,7 +1443,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option Result<(), Failure> {
use effectcraft_render::Backend;
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/src/main.rs b/apps/effectcraft/src/main.rs
index 07d327a21..5350addbe 100644
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -276,9 +276,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() {
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..570726932 100644
--- a/crates/automation/src/confine.rs
+++ b/crates/automation/src/confine.rs
@@ -346,32 +346,60 @@ 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 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 +420,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 +635,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/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/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs
index d8bc1f825..b06852809 100644
--- a/crates/av1enc/src/decide.rs
+++ b/crates/av1enc/src/decide.rs
@@ -157,7 +157,8 @@ impl Default for TxScratch {
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
struct Snapshot {
planes: [Vec; 3],
- decoded: Box<[[[bool; 35]; 35]; 3]>,
+ /// BlockDecoded flags of the block area only (the only flags coding inside it can change).
+ decoded: [[[bool; 16]; 16]; 3],
}
pub(crate) struct Decider<'a> {
@@ -267,6 +268,13 @@ impl<'a> Decider<'a> {
}
}
+ /// The BlockDecoded cells `mark_decoded` can set for blocks inside (r, c, n4): rows and
+ /// columns `by + 1 .. by + 1 + step` / `bx + 1 ..` of `decoded[plane]`.
+ fn decoded_region(plane: usize, r: usize, c: usize, n4: usize) -> (usize, usize, usize) {
+ let s = (plane > 0) as usize;
+ ((r & 15) >> s, (c & 15) >> s, (n4 >> s).max(1))
+ }
+
fn snapshot(&self, r: usize, c: usize, n4: usize) -> Snapshot {
let planes = std::array::from_fn(|p| {
let s = (p > 0) as usize;
@@ -278,7 +286,14 @@ impl<'a> Decider<'a> {
}
v
});
- Snapshot { planes, decoded: self.decoded.clone() }
+ let mut decoded = [[[false; 16]; 16]; 3];
+ for (p, d) in decoded.iter_mut().enumerate() {
+ let (by, bx, step) = Self::decoded_region(p, r, c, n4);
+ for i in 0..step {
+ d[i][..step].copy_from_slice(&self.decoded[p][by + i + 1][bx + 1..bx + 1 + step]);
+ }
+ }
+ Snapshot { planes, decoded }
}
fn restore(&mut self, snap: &Snapshot, r: usize, c: usize, n4: usize) {
@@ -290,7 +305,12 @@ impl<'a> Decider<'a> {
pl.data[y * pl.stride + x0..y * pl.stride + x0 + n].copy_from_slice(&snap.planes[p][i * n..i * n + n]);
}
}
- self.decoded.clone_from(&snap.decoded);
+ for p in 0..3 {
+ let (by, bx, step) = Self::decoded_region(p, r, c, n4);
+ for i in 0..step {
+ self.decoded[p][by + i + 1][bx + 1..bx + 1 + step].copy_from_slice(&snap.decoded[p][i][..step]);
+ }
+ }
}
fn set_mvs(&mut self, r: usize, c: usize, n4: usize, mv: [i32; 2]) {
@@ -588,15 +608,22 @@ impl<'a> Decider<'a> {
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) };
comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1])
};
- let cost =
- |m: [i32; 2]| -> f64 { self.sad_ref(&refr.planes[0], x0 + (m[1] >> 3), y0 + (m[0] >> 3), c * 4, r * 4, n) as f64 + self.lambda_sad * mv_bits(m) };
+ // `bound` is the cost to beat: the SAD stops early once the cost cannot be below it (the
+ // callers only accept a strictly lower cost, so the result is unchanged)
+ let cost = |m: [i32; 2], bound: f64| -> f64 {
+ let lb = self.lambda_sad * mv_bits(m);
+ self.sad_ref(&refr.planes[0], [x0 + (m[1] >> 3), y0 + (m[0] >> 3)], [c * 4, r * 4], n, [lb, bound]) as f64 + lb
+ };
+ // positions already evaluated cannot beat `best` again (costs are deterministic)
+ let mut seen: Vec<[i32; 2]> = Vec::with_capacity(64);
let mut best = ([0, 0], f64::MAX);
for &m in &cands {
let m = [m[0] & !7, m[1] & !7];
- if !valid(m) {
+ if !valid(m) || seen.contains(&m) {
continue;
}
- let cst = cost(m);
+ seen.push(m);
+ let cst = cost(m, best.1);
if cst < best.1 {
best = (m, cst);
}
@@ -617,10 +644,11 @@ impl<'a> Decider<'a> {
let center = best.0;
for d in [[-step, 0], [step, 0], [0, -step], [0, step]] {
let m = [center[0] + d[0], center[1] + d[1]];
- if !valid(m) {
+ if !valid(m) || seen.contains(&m) {
continue;
}
- let cst = cost(m);
+ seen.push(m);
+ let cst = cost(m, best.1);
if cst < best.1 {
best = (m, cst);
moved = true;
@@ -631,15 +659,18 @@ impl<'a> Decider<'a> {
}
// half- then quarter-sample refinement (plus the exact neighbour candidates)
let mut buf = vec![0u16; n * n];
- let sub_cost = |m: [i32; 2], buf: &mut Vec| -> f64 {
+ let sub_cost = |m: [i32; 2], buf: &mut Vec, bound: f64| -> f64 {
self.search_block(m, c * 4, r * 4, n, buf);
- self.sad(0, c * 4, r * 4, n, buf) as f64 + self.lambda_sad * mv_bits(m)
+ let lb = self.lambda_sad * mv_bits(m);
+ self.sad_bounded(0, [c * 4, r * 4], n, buf, [lb, bound]) as f64 + lb
};
+ let mut seen_sub: Vec<[i32; 2]> = Vec::with_capacity(24);
for m in cands {
if m[0] & 7 != 0 || m[1] & 7 != 0 {
let m = [m[0] & !1, m[1] & !1];
- if valid(m) {
- let cst = sub_cost(m, &mut buf);
+ if valid(m) && !seen_sub.contains(&m) {
+ seen_sub.push(m);
+ let cst = sub_cost(m, &mut buf, best.1);
if cst < best.1 {
best = (m, cst);
}
@@ -650,10 +681,11 @@ impl<'a> Decider<'a> {
let center = best.0;
for d in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]] {
let m = [center[0] + d[0] * step, center[1] + d[1] * step];
- if !valid(m) {
+ if !valid(m) || seen_sub.contains(&m) {
continue;
}
- let cst = sub_cost(m, &mut buf);
+ seen_sub.push(m);
+ let cst = sub_cost(m, &mut buf, best.1);
if cst < best.1 {
best = (m, cst);
}
@@ -739,7 +771,8 @@ impl<'a> Decider<'a> {
}
/// SAD of the source block at (x, y) (luma) against the reference block at (rx, ry).
- fn sad_ref(&self, refp: &Plane, rx: i32, ry: i32, x: usize, y: usize, n: usize) -> u64 {
+ /// Stops after a row once `sad + lb >= bound` (the cost can no longer win).
+ fn sad_ref(&self, refp: &Plane, [rx, ry]: [i32; 2], [x, y]: [usize; 2], n: usize, [lb, bound]: [f64; 2]) -> u64 {
let (vw, vh) = (self.g.width, self.g.height);
let w = n.min(vw.saturating_sub(x));
let h = n.min(vh.saturating_sub(y));
@@ -759,45 +792,16 @@ impl<'a> Decider<'a> {
sad += (s[j] as i32 - b as i32).unsigned_abs() as u64;
}
}
+ if sad as f64 + lb >= bound {
+ return sad;
+ }
}
sad
}
- /// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction
- /// against the source, scaled to about the size of the SAD.
- fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
- let src = &self.src[p];
- let k = n.min(8);
- let mut total = 0u64;
- let mut d = [0i32; 64];
- for by in (0..n).step_by(k) {
- for bx in (0..n).step_by(k) {
- for i in 0..k {
- let row = &src.row(y + by + i)[x + bx..x + bx + k];
- for j in 0..k {
- d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32;
- }
- }
- for i in 0..k {
- hadamard(&mut d[i * k..i * k + k]);
- }
- let mut col = [0i32; 8];
- let mut s = 0u64;
- for j in 0..k {
- for i in 0..k {
- col[i] = d[i * k + j];
- }
- hadamard(&mut col[..k]);
- s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::();
- }
- total += s / (k as u64 / 2);
- }
- }
- total
- }
-
- /// SAD of a prediction against the source over the visible part of the block.
- fn sad(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
+ /// SAD of a prediction against the source over the visible part of the block. Stops after a row
+ /// once `sad + lb >= bound` (the cost can no longer win).
+ fn sad_bounded(&self, p: usize, [x, y]: [usize; 2], n: usize, pred: &[u16], [lb, bound]: [f64; 2]) -> u64 {
let (vw, vh) = self.g.visible(p);
let w = n.min(vw.saturating_sub(x));
let h = n.min(vh.saturating_sub(y));
@@ -806,10 +810,60 @@ impl<'a> Decider<'a> {
for i in 0..h {
let row = &src.row(y + i)[x..x + w];
s += row.iter().zip(&pred[i * n..i * n + w]).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).sum::() as u64;
+ if s as f64 + lb >= bound {
+ return s;
+ }
}
s
}
+ /// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction
+ /// against the source, scaled to about the size of the SAD.
+ fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
+ if n >= 8 { self.satd_k::<8>(p, x, y, n, pred) } else { self.satd_k::<4>(p, x, y, n, pred) }
+ }
+
+ /// `satd` with K x K Hadamard sub-blocks (fixed sizes so the butterflies vectorise).
+ fn satd_k(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
+ let src = &self.src[p];
+ let mut total = 0u64;
+ for by in (0..n).step_by(K) {
+ for bx in (0..n).step_by(K) {
+ // d[i] holds row i of the differences
+ let mut d = [[0i32; K]; K];
+ for (i, di) in d.iter_mut().enumerate() {
+ let row = &src.row(y + by + i)[x + bx..x + bx + K];
+ let pr = &pred[(by + i) * n + bx..(by + i) * n + bx + K];
+ for j in 0..K {
+ di[j] = row[j] as i32 - pr[j] as i32;
+ }
+ }
+ // vertical butterflies across rows (all columns at once)
+ let mut h = 1;
+ while h < K {
+ for i0 in (0..K).step_by(2 * h) {
+ for i in i0..i0 + h {
+ let (a, b) = (d[i], d[i + h]);
+ for j in 0..K {
+ d[i][j] = a[j] + b[j];
+ d[i + h][j] = a[j] - b[j];
+ }
+ }
+ }
+ h *= 2;
+ }
+ // horizontal butterflies within each row
+ let mut s = 0u64;
+ for di in d.iter_mut() {
+ hadamard(di);
+ s += di.iter().map(|v| v.unsigned_abs() as u64).sum::();
+ }
+ total += s / (K as u64 / 2);
+ }
+ }
+ total
+ }
+
fn sse(&self, p: usize, x: usize, y: usize, n: usize, blk: &[u16]) -> f64 {
let (vw, vh) = self.g.visible(p);
let w = n.min(vw.saturating_sub(x));
@@ -1062,4 +1116,158 @@ mod tests {
assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}");
}
}
+
+ /// The SATD as it was before the fixed-size version: runtime block size, column gather.
+ fn satd_ref(src: &Plane, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
+ let k = n.min(8);
+ let mut total = 0u64;
+ let mut d = [0i32; 64];
+ for by in (0..n).step_by(k) {
+ for bx in (0..n).step_by(k) {
+ for i in 0..k {
+ let row = &src.row(y + by + i)[x + bx..x + bx + k];
+ for j in 0..k {
+ d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32;
+ }
+ }
+ for i in 0..k {
+ hadamard(&mut d[i * k..i * k + k]);
+ }
+ let mut col = [0i32; 8];
+ let mut s = 0u64;
+ for j in 0..k {
+ for i in 0..k {
+ col[i] = d[i * k + j];
+ }
+ hadamard(&mut col[..k]);
+ s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::();
+ }
+ total += s / (k as u64 / 2);
+ }
+ }
+ total
+ }
+
+ #[test]
+ fn satd_fixed_size_matches_runtime_size() {
+ let g = FrameGeom::new(100, 80);
+ let mut state = 0x9e37_79b9_7f4a_7c15u64;
+ let mut next = move || {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ state
+ };
+ for bd in [8u32, 10] {
+ let max = (1u64 << bd) - 1;
+ let mut mk = |w: usize, h: usize| {
+ let mut p = Plane::new(w, h);
+ p.data.iter_mut().for_each(|v| *v = (next() % (max + 1)) as u16);
+ p
+ };
+ let src = [mk(128, 128), mk(64, 64), mk(64, 64)];
+ let dec = Decider::new(&g, bd, 100, true, &src, None);
+ for n in [4usize, 8, 16, 32] {
+ let pred: Vec = (0..n * n).map(|_| (next() % (max + 1)) as u16).collect();
+ for (p, x, y) in [(0, 0, 0), (0, 32, 40), (1, 8, 4)] {
+ assert_eq!(dec.satd(p, x, y, n, &pred), satd_ref(&src[p], x, y, n, &pred), "n {n} plane {p}");
+ }
+ }
+ }
+ }
+
+ fn xorshift(seed: u64) -> impl FnMut() -> u64 {
+ let mut state = seed;
+ move || {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ state
+ }
+ }
+
+ /// A snapshot keeps only the BlockDecoded cells of the block area. Coding inside the block
+ /// marks sub-blocks of every smaller size; restoring must give back the whole 3x35x35 array
+ /// as it was, for every block size of a 64x64 superblock and every aligned position.
+ #[test]
+ fn snapshot_restore_brings_back_every_blockdecoded_flag() {
+ let g = FrameGeom::new(256, 256);
+ let mut next = xorshift(0x2545_f491_4f6c_dd1d);
+ let src = [Plane::new(256, 256), Plane::new(128, 128), Plane::new(128, 128)];
+ let mut dec = Decider::new(&g, 8, 100, true, &src, None);
+ for random_start in [false, true] {
+ for n4 in [1usize, 2, 4, 8, 16] {
+ for sb in [0usize, 16] {
+ for r in (sb..sb + 16).step_by(n4) {
+ for c in (sb..sb + 16).step_by(n4) {
+ for plane in dec.decoded.iter_mut() {
+ for row in plane.iter_mut() {
+ for v in row.iter_mut() {
+ *v = random_start && next() & 1 == 1;
+ }
+ }
+ }
+ let before = dec.decoded.clone();
+ let snap = dec.snapshot(r, c, n4);
+ // every sub-block of every size inside the block, as coding marks them
+ let mut m = n4;
+ loop {
+ for rr in (r..r + n4).step_by(m) {
+ for cc in (c..c + n4).step_by(m) {
+ for p in 0..3 {
+ dec.mark_decoded(p, rr, cc, m);
+ }
+ }
+ }
+ if m == 1 {
+ break;
+ }
+ m /= 2;
+ }
+ dec.restore(&snap, r, c, n4);
+ assert!(dec.decoded == before, "n4 {n4} at ({r}, {c}), random start {random_start}");
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /// The bounded SADs return the full SAD whenever the cost can still win (`sad + lb < bound`)
+ /// and otherwise a partial sum that already loses, so the callers' strict `<` decides the same.
+ #[test]
+ fn bounded_sad_stops_only_once_the_cost_cannot_win() {
+ let g = FrameGeom::new(100, 80);
+ let mut next = xorshift(0x9e37_79b9_7f4a_7c15);
+ let mut mk = |w: usize, h: usize| {
+ let mut p = Plane::new(w, h);
+ p.data.iter_mut().for_each(|v| *v = (next() % 256) as u16);
+ p
+ };
+ let src = [mk(128, 128), mk(64, 64), mk(64, 64)];
+ let refp = mk(128, 128);
+ let mut next = xorshift(0x1234_5678_9abc_def1);
+ let dec = Decider::new(&g, 8, 100, false, &src, None);
+ for n in [4usize, 8, 16, 32] {
+ for (x, y, rx, ry) in [(0usize, 0usize, 0i32, 0i32), (32, 40, 29, 43), (96, 72, 90, 70), (8, 8, -5, -3)] {
+ let full_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [0.0, f64::INFINITY]);
+ let pred: Vec = (0..n * n).map(|_| (next() % 256) as u16).collect();
+ let full_pred = dec.sad_bounded(0, [x, y], n, &pred, [0.0, f64::INFINITY]);
+ for _ in 0..50 {
+ let lb = (next() % 64) as f64 * 0.75;
+ let b_ref = (next() % (2 * full_ref + 2)) as f64;
+ let b_pred = (next() % (2 * full_pred + 2)) as f64;
+ let got_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [lb, b_ref]);
+ let got_pred = dec.sad_bounded(0, [x, y], n, &pred, [lb, b_pred]);
+ for (got, full, bound) in [(got_ref, full_ref, b_ref), (got_pred, full_pred, b_pred)] {
+ if full as f64 + lb < bound {
+ assert_eq!(got, full, "n {n} at ({x}, {y}) bound {bound}");
+ } else {
+ assert!(got as f64 + lb >= bound && got <= full, "n {n} at ({x}, {y}) bound {bound}");
+ }
+ }
+ }
+ }
+ }
+ }
}
diff --git a/crates/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