From ca1ff9cfc2d7a13f5e173c4a9ec78e9994a683b2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Fri, 6 Nov 2026 19:36:42 +0100 Subject: [PATCH] .aep import: map the Cryptomatte plug-in to EffectCraft's Cryptomatte (#3109) * AEP import: map the Cryptomatte plug-in to EffectCraft's Cryptomatte The importer reported every instance of the free Cryptomatte plug-in (match name `Cryptomatte`) as a lost effect. It now becomes `ec.3d.cryptomatte`: - CryptomatteData decodes the plug-in's Settings data (its own little-endian layout, documented in docs/aep-format.md 3.7): the Cryptomatte layer name and the selection text, both carried verbatim. - Output maps to Display / Matte Only (Colors -> Colors, Matted RGBA -> Matte, Matte Only -> Matte Only). Matted Colors, Selection Mode when on, keys or expressions and anything else stored are reported, and unreadable data is reported as damaged. - EffectCraft's Cryptomatte gains a Layer Name text parameter. A layer the footage has under exactly that name wins; otherwise the Layer popup decides as before. The CPU, GPU and pick paths share one resolver (AuxChannels::resolve_crypto_layer_named). Without it, plug-in layer names such as Crypto_Material would silently fall back to the first layer. On a real production project this takes the effect losses from 132 to 44. Co-Authored-By: Claude Opus 5.5 * AEP import: check Cryptomatte against an After Effects 2026 sample of our own A sample made in After Effects 2026 (plug-in 2.6.6: a fresh effect, Selection Mode on, each Output item, with read-backs and renders) corrected three points of the mapping: - Selection Mode on was dropped silently: After Effects writes it as a default-state record (tdsb 3) holding 1, which the general reader does not take. The Cryptomatte step reads that record itself and reports it. - No Output record is now the plug-in's default Colors, also for an instance that borrows the project's definition (every instance after the first), which was reported as having no reliable default and fell back to EffectCraft's Preview. Keyed or unreadable Output values fall back to Colors too, still reported. - Matted Colors imports as Display Matte (the same matte over the layer's own colours) instead of Preview, still reported as an approximation. docs/aep-format.md 3.7 gets the sampled Output default, the tdsb 3 Selection Mode record and the real fresh Settings data (an empty text of length 1 and its MurmurHash3, twice, not cry1 and zeros); docs/aep-import.md the render comparison with After Effects (Matted RGBA matches; Matte Only differs in alpha; Colors with no layer chosen shows another layer). Tests: the exact fresh Settings bytes, tdsb 3 Selection Mode, borrowed definitions, and an opt-in test against the sample once it is in the corpus (aep/cryptomatte.aep, ignored until the corpus pin includes it). Co-Authored-By: Claude Opus 5.5 * AEP import: write Cryptomatte's popup items in the test as After Effects does The injected Cryptomatte definition wrote `pdnm` and `fnam` as `LIST` chunks. After Effects writes them as plain chunks whose body is chunks (`NESTED_IDS`), and the reader only looks there, so the tests never read Output's item names: they passed through the fallback for a definition with four items and no `pdnm`, which uses the plug-in's own list. The harness now writes both as After Effects does. New tests check that the injected definition reads back with its items and instance name, that Output is mapped by the item the stored index names (items in another order, case, an unknown item), and that the fallback without a `pdnm` still works. With the old `LIST` form, or with the mapping ignoring the items, the new tests fail. Co-Authored-By: Claude Opus 5.5 * AEP import: check Cryptomatte's layer and selection against presets of our own The After Effects 2026 sample 1400 now has two preset compositions that choose a layer and pick one object (box_red on CryptoObject) or one material (mat_paint on CryptoMaterial). CryptomatteData reads both as they are; the picked tokens are the EXR manifest's hashes, which EffectCraft's selection grammar reads the same as the names. - The u32 at 108 is MurmurHash3 of the selection text with its NUL (three texts). - A unit test pins the 127-byte preset data; the sample test now covers all eight compositions with their layer and selection. - Render check: EffectCraft's matte covers exactly the pixels After Effects draws as selected, edge coverage within 0.0002. docs/aep-format.md 3.7 and docs/aep-import.md. Co-Authored-By: Claude Opus 5.5 * Cryptomatte import: report damaged Settings and Selection Mode records Review follow-ups for #3109: - CryptomatteData refuses a selection text without its NUL and checks the zero byte at 112 for an empty text too. - A Settings record without its data, and a Selection Mode record whose value cannot be read, are reported as damaged data, not taken as the default. - Layer Name is the effect's last parameter, so the earlier parameters keep their indexes and upgraded instances have the same order as new ones. - A Cryptomatte layer name is matched as given before it is trimmed. Co-Authored-By: Claude Opus 5.5 * Cryptomatte settings: require the selection's NUL to end its declared length Co-Authored-By: Claude Opus 5.5 * Cryptomatte: match rank planes by the exact layer name crypto_rank_planes looked planes up with a case-insensitive whole-name match, so layers Crypto_A and crypto_a shared planes. The layer prefix now matches case-sensitively; component letters still match in either case. Co-Authored-By: Claude Sonnet 5.5 * Format Co-Authored-By: Claude Sonnet 5.5 * AEP: the exporter knows Extra::Cryptomatte; rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/aep/src/container.rs | 2 +- crates/aep/src/container/arbitrary.rs | 170 ++++++++ crates/aep/src/container/effect_map.rs | 9 + crates/aep/src/export/effects.rs | 5 +- crates/aep/src/import/effects.rs | 153 +++++++- crates/aep/src/lib.rs | 16 +- crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/cryptomatte.rs | 449 ++++++++++++++++++++++ crates/aep/tests/it/import/effects.rs | 2 +- crates/effects/src/channel3d.rs | 12 +- crates/effects/src/channel3d/tests.rs | 54 +++ crates/effects/src/lib.rs | 2 +- crates/engine/src/cmd/effect.rs | 2 +- crates/gpu/src/fx_depth.rs | 2 +- crates/raster/src/channels3d.rs | 59 ++- docs/aep-format.md | 37 ++ docs/aep-import.md | 1 + docs/effects.md | 4 +- 18 files changed, 950 insertions(+), 30 deletions(-) create mode 100644 crates/aep/tests/it/import/cryptomatte.rs diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 40da07178..b6c3b4490 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -37,7 +37,7 @@ mod version; mod write; pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits}; -pub use arbitrary::{CURVE_IDENTITY, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut}; +pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut}; pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM}; pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk}; pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue}; diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index dea70d22c..5762da111 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -307,3 +307,173 @@ impl MeshData { Some(out) } } + +/// 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 +/// `cry1`; bytes 4..32 are not read (they differ between instances holding the same settings); +/// the Cryptomatte layer's name at 32, NUL-padded to 64 bytes (`CryptoObject`); at 96 a `u32` 1 +/// and a `u32` (neither read); at 104 the `u32` length of the selection text including its NUL, +/// the `u32` MurmurHash3 (x86, 32-bit, seed 0) of that text with its NUL (not read) and a zero +/// byte; the selection text from byte 113 (`<11a97486>, `, the matte list as ID hashes, +/// the hashes of the picked names). Bytes after it are left-overs of a longer earlier text. A +/// fresh effect stores no layer and an empty text (length 1, its NUL); the `u32`s at 100 and 108 +/// are then both `0x514e28b7`, the hash of one NUL byte: the one at 100 looks like the hash of a +/// first text that is empty in every sample, the zero byte at 112 like that text. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CryptomatteData { + /// The Cryptomatte layer's name (`CryptoMaterial`: the EXR layer's name, which is also its + /// channel prefix in every sample); empty when none is chosen. + pub layer: String, + /// The selection (matte list) as the plug-in stores it. + pub selection: String, +} + +impl CryptomatteData { + /// Decodes the data; `None` when it is not this layout (no `cry1`, too short, a length past + /// the end, no zero byte at 112, a text without its NUL, text that is not UTF-8). + pub fn parse(data: &[u8]) -> Option { + if data.get(..4)? != b"cry1" { + return None; + } + let text = |b: &[u8]| -> Option { + let end = b.iter().position(|c| *c == 0).unwrap_or(b.len()); + String::from_utf8(b.get(..end)?.to_vec()).ok() + }; + let layer = text(data.get(32..96)?)?; + let len = usize::try_from(u32::from_le_bytes(data.get(104..108)?.try_into().ok()?)).ok()?; + if *data.get(112)? != 0 { + return None; + } + let selection = if len == 0 { + String::new() + } else { + // The length counts the text's NUL as its last byte: a text without it, or with an early + // NUL before more bytes (which `text` would silently cut short), is not this layout. + let b = data.get(113..113usize.checked_add(len)?)?; + let (last, text_bytes) = b.split_last()?; + if *last != 0 || text_bytes.contains(&0) { + return None; + } + text(b)? + }; + Some(Self { layer, selection }) + } +} + +#[cfg(test)] +mod tests { + use super::CryptomatteData; + + /// Settings data in the layout [`CryptomatteData::parse`] reads, with `noise` in the bytes it + /// does not read. + fn crypto(layer: &str, selection: &str, noise: u8) -> Vec { + let mut d = b"cry1".to_vec(); + d.resize(32, noise); + d.extend(layer.as_bytes()); + d.resize(96, 0); + d.extend(1u32.to_le_bytes()); + d.extend([noise; 4]); + let len = if selection.is_empty() { 0 } else { selection.len() + 1 }; + d.extend(u32::try_from(len).unwrap_or(0).to_le_bytes()); + d.extend([noise; 4]); + d.push(0); + d.extend(selection.as_bytes()); + d.push(0); + // Left-overs of an earlier, longer text. + d.extend(b" , ", noise)); + assert_eq!(d, Some(CryptomatteData { layer: "Layer_A".into(), selection: "<0000002a>, ".into() }), "noise {noise}"); + } + } + + #[test] + fn a_selection_whose_nul_is_not_its_last_byte_is_refused() { + // An early NUL followed by more declared bytes would shorten the selection silently. + let mut d = crypto("Layer_A", "<0000002a>, ", 0); + d[113 + 4] = 0; + assert_eq!(CryptomatteData::parse(&d), None); + // No NUL at the end of the declared length. + let mut d = crypto("Layer_A", "<0000002a>", 0); + d[113 + 10] = b'x'; + assert_eq!(CryptomatteData::parse(&d), None); + } + + #[test] + fn the_default_cryptomatte_settings_are_empty() { + let mut d = b"cry1".to_vec(); + d.resize(116, 0); + assert_eq!(CryptomatteData::parse(&d), Some(CryptomatteData::default())); + // What After Effects 2026 wrote for a fresh effect (plug-in 2.6.6, sample 1400 + // `crypto-00-defaults`, the `pard` default and the instance's record alike): an empty text + // of length 1 and its hash, twice. + let mut fresh = b"cry1".to_vec(); + fresh.resize(96, 0); + for _ in 0..2 { + fresh.extend(1u32.to_le_bytes()); + fresh.extend(0x514e_28b7u32.to_le_bytes()); + } + fresh.extend([0; 4]); + assert_eq!(fresh.len(), 116); + assert_eq!(CryptomatteData::parse(&fresh), Some(CryptomatteData::default())); + } + + /// The Settings data After Effects 2026 wrote for the two presets of sample 1400 (plug-in + /// 2.6.6, `crypto-06-preset-object-box` and `crypto-07-preset-material-paint`: a layer chosen + /// and one object or material picked in the plug-in's UI), byte for byte: 127 bytes, the `u32` + /// at 108 the MurmurHash3 of the text with its NUL, three zero bytes after the text. + #[test] + fn the_presets_of_our_sample_are_read() { + for (layer, selection, hash) in [("CryptoObject", "<11a97486>", 0x2788_0767u32), ("CryptoMaterial", "", 0xf737_4a5b)] { + let mut d = b"cry1".to_vec(); + d.resize(32, 0); + d.extend(layer.as_bytes()); + d.resize(96, 0); + for v in [1, 0x514e_28b7, 11, hash] { + d.extend(u32::to_le_bytes(v)); + } + d.push(0); + d.extend(selection.as_bytes()); + d.extend([0; 4]); + assert_eq!(d.len(), 127); + assert_eq!(CryptomatteData::parse(&d), Some(CryptomatteData { layer: layer.into(), selection: selection.into() }), "{layer}"); + } + } + + #[test] + fn damaged_cryptomatte_settings_are_not_read() { + let good = crypto("L", "<00000001>", 0); + let mut bad_magic = good.clone(); + bad_magic[0] = b'x'; + let mut past_end = good.clone(); + past_end[104..108].copy_from_slice(&u32::MAX.to_le_bytes()); + let mut no_zero = good.clone(); + no_zero[112] = b'<'; + let mut not_utf8 = good.clone(); + not_utf8[32] = 0xff; + // The length covers the text without its NUL (`<00000001>` is 10 bytes, its NUL the 11th). + let mut no_nul = good.clone(); + no_nul[104..108].copy_from_slice(&10u32.to_le_bytes()); + // No zero byte at 112 with an empty text. + let mut empty_no_zero = crypto("L", "", 0); + empty_no_zero[112] = b'<'; + for (what, d) in [ + ("magic", bad_magic), + ("length", past_end), + ("zero byte", no_zero), + ("utf-8", not_utf8), + ("no NUL", no_nul), + ("empty text, zero byte", empty_no_zero), + ("short", good[..100].to_vec()), + ("empty", Vec::new()), + ] { + assert_eq!(CryptomatteData::parse(&d), None, "{what}"); + } + } +} diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index f6a05f83b..ed723a82e 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -110,6 +110,8 @@ pub const HUE_SAT_CHANNEL: &[&str] = &["Master", "Reds", "Yellows", "Greens", "C pub const LEVELS_CHANNEL: &[&str] = &["RGB", "Red", "Green", "Blue", "Alpha"]; /// 511. pub const LEVELS_CLIP: &[&str] = &["On", "Off", "Off for 32 bpc Color"]; +/// The Cryptomatte plug-in's Output (`Cryptomatte-0002`). +pub const CRYPTOMATTE_OUTPUT: &[&str] = &["Colors", "Matted Colors", "Matted RGBA", "Matte Only"]; /// One mapped parameter. #[derive(Clone, Copy, Debug)] @@ -139,6 +141,10 @@ pub enum Extra { Curves, /// Mesh Warp's Distortion Mesh (965, 985, `MeshData`). MeshWarp, + /// 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. + Cryptomatte, } /// One mapped effect. @@ -526,6 +532,9 @@ pub const EFFECTS: &[EffectMap] = &[ ], extra: Extra::MeshWarp, }, + // 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 }, // Expression controls (070-077, 217-221, 310). // Directional Blur (711): Direction in degrees, Blur Length in pixels (valid max 1 000). fx( diff --git a/crates/aep/src/export/effects.rs b/crates/aep/src/export/effects.rs index 9ca496e7b..a06441dca 100644 --- a/crates/aep/src/export/effects.rs +++ b/crates/aep/src/export/effects.rs @@ -460,7 +460,8 @@ fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop: /// default data with the values patched in); `None` when they are at the default. fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap, here: &str, col: &mut Collector) -> Option<(u16, Vec)> { let ids: &[&str] = match spec.extra { - Extra::None | Extra::Curves | Extra::MeshWarp => return None, + // (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::HueSaturation => &HUE_SATURATION_MASTER, Extra::Levels => &LEVELS_RGB, }; @@ -642,7 +643,7 @@ 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::None | Extra::Cryptomatte => &[], Extra::HueSaturation => &HUE_SATURATION_MASTER, Extra::Levels => &LEVELS_RGB, Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"], diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 094d4eef3..8409a6f7e 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::{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, popup_key}; use crate::{ - CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, - ParamKind, ParamValue, + CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef, + LevelsData, MeshData, ParamKind, ParamValue, }; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; @@ -99,8 +99,12 @@ 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); - for p in ¶ms { - import_param(&fx, map, p, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + if map.extra == Extra::Cryptomatte { + import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col); + } else { + for p in ¶ms { + import_param(&fx, map, p, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + } } col.imported.effects = col.imported.effects.saturating_add(1); target.children.push(Node::Group(group)); @@ -469,7 +473,144 @@ 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::None | Extra::Cryptomatte => {} + } +} + +/// The Cryptomatte plug-in (`Extra::Cryptomatte`, docs/aep-format.md 3.7): 1 Settings data +/// (the layer by name and the selection, both carried as text), 2 Output (EffectCraft's Display +/// and Matte Only), 3 Selection Mode (reported when on). Anything else stored is reported. +fn import_cryptomatte(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, group: &mut PropGroup, col: &mut Collector) { + let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); + let set = |group: &mut PropGroup, id: &str, v: Value| { + if let Some(prop) = group.prop_mut(id) { + prop.value = v; + } + }; + // The plug-in's Output starts at Colors (EffectCraft's Display starts at Preview); an Output + // that cannot be imported keeps it. + let (display, matte_only) = CRYPTO_COLORS; + set(group, "display", Value::Enum(display)); + set(group, "matteOnly", Value::Bool(matte_only)); + for p in params { + if carries_nothing(p) { + continue; + } + let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) }; + let moving = p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()); + match (p.index, p.kind) { + (1, ParamKind::ArbitraryData) => { + if moving { + col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported")); + } + // No record: the definition's default, no layer and no selection. A record + // without its data (every sampled record has it) is damaged. + let Some(data) = fx.arbitrary_data(&p.match_name) else { + if p.stored().is_some() { + col.lose(LossCategory::DamagedData, format!("{here}: the Cryptomatte settings (layer, selection) have no data, none used")); + } + continue; + }; + match CryptomatteData::parse(data) { + Some(d) => { + set(group, "layerName", Value::Str(d.layer)); + set(group, "selection", Value::Str(d.selection)); + } + None => col.lose(LossCategory::DamagedData, format!("{here}: the Cryptomatte settings (layer, selection) are unreadable, none used")), + } + } + (2, ParamKind::Popup) => { + if moving { + col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported, Colors used")); + continue; + } + let (display, matte_only) = crypto_output(p, &here, &label, col); + set(group, "display", Value::Enum(display)); + set(group, "matteOnly", Value::Bool(matte_only)); + } + (3, ParamKind::Checkbox) => { + if moving { + col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported")); + } else { + match selection_mode_on(p) { + // A picking switch of the plug-in; EffectCraft picks with its own tool. + Some(true) => col.lose(LossCategory::Effects, format!("{here}: parameter {label} is on and has no counterpart")), + Some(false) => {} + None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value, off used")), + } + } + } + _ => { + if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} ({:?}) not imported", p.kind)); + } + } + } + } +} + +/// EffectCraft's (Display, Matte Only) for the plug-in's Output Colors, its default. +const CRYPTO_COLORS: (u32, bool) = (0, false); + +/// Is Selection Mode on? The plug-in writes it, when on, as a record in the default state +/// (`tdsb` 3) holding 1.0, which [`EffectParam::stored_value`] does not read; After Effects +/// reads it back as on and renders it (1400 `crypto-01-set-all`, `crypto-02-chk-toggled`). So +/// the record's value counts whatever its state; no record is off (the `pard` default). `None` +/// when a record is there but its value cannot be read (never the definition's default then). +fn selection_mode_on(p: &EffectParam<'_, '_>) -> Option { + match p.stored() { + Some(s) => s.static_value().and_then(|v| v.first().copied()).map(|v| v != 0.0), + None => Some(p.value() == Some(ParamValue::Bool(true))), + } +} + +/// Cryptomatte's Output as EffectCraft's (Display, Matte Only): Colors is Colors, Matted RGBA +/// is Matte (the layer keyed by the selection), Matte Only is Matte Only. Matted Colors (the +/// ID colours keyed by the selection) becomes Matte, the same matte over the layer's own +/// colours, and is reported; a value that cannot be read is reported and Colors used. +fn crypto_output(p: &EffectParam<'_, '_>, here: &str, label: &str, col: &mut Collector) -> (u32, bool) { + let items: Vec = match &p.items { + Some(items) => items.clone(), + None if p.popup_item_count().map(usize::from) == Some(CRYPTOMATTE_OUTPUT.len()) => CRYPTOMATTE_OUTPUT.iter().map(|s| s.to_string()).collect(), + None => { + col.lose( + LossCategory::Effects, + format!("{here}: parameter {label}: the file names no popup items and has {:?} of them, not imported, Colors used", p.popup_item_count()), + ); + return CRYPTO_COLORS; + } + }; + let index = match (p.stored(), p.stored_value()) { + (Some(_), Some(ParamValue::Index(i))) => i, + (Some(_), _) => { + col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value, Colors used")); + return CRYPTO_COLORS; + } + // No record: the default. The plug-in's is Colors (1): the `pard` default of the + // instance's own and of the project's shared definition, and what scripting reads from a + // fresh effect (1400 `crypto-00-defaults`). An instance's own definition with another + // default keeps it; a borrowed one has no reliable popup default. + (None, _) => match p.pard_default() { + Some(ParamValue::Index(i)) if i != 0 && !p.borrowed_definition => i, + _ => return CRYPTO_COLORS, + }, + }; + let item = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).and_then(|i| items.get(i)).map(String::as_str).unwrap_or_default(); + match popup_key(item).as_str() { + "colors" => CRYPTO_COLORS, + "matted rgba" => (1, false), + "matte only" => (1, true), + "matted colors" => { + col.lose( + LossCategory::Effects, + format!("{here}: parameter {label}: item '{item}' ({index}) has no counterpart, imported as Matte (the layer's colours keyed by the selection, not the ID colours)"), + ); + (1, false) + } + _ => { + col.lose(LossCategory::Effects, format!("{here}: parameter {label}: item '{item}' ({index}) has no counterpart, Colors used")); + CRYPTO_COLORS + } } } diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index 19fe273a9..2636dece9 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -15,12 +15,12 @@ pub mod export; pub mod import; pub use container::{ - AeVersion, AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, 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, + 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, }; diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 3f3311588..102a215c8 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -8,6 +8,7 @@ mod blend_modes; mod cameras; mod cases; mod combined; +mod cryptomatte; mod ease_round2; mod effects; mod effects_batch5; diff --git a/crates/aep/tests/it/import/cryptomatte.rs b/crates/aep/tests/it/import/cryptomatte.rs new file mode 100644 index 000000000..96f532c05 --- /dev/null +++ b/crates/aep/tests/it/import/cryptomatte.rs @@ -0,0 +1,449 @@ +//! The Cryptomatte plug-in (match name `Cryptomatte`) arrives as EffectCraft's Cryptomatte +//! (`ec.3d.cryptomatte`): the layer and selection from its Settings data, Output as Display and +//! Matte Only. The effect is built into the committed `synthetic.aep` in place of its Glow, in +//! the layout docs/aep-format.md 3.7 describes (made-up layer names and ID hashes). + +use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import}; +use effectcraft_aep::{Body, Chunk, ParamKind, Project as AepProject, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::{Project, PropGroup}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); + +/// A match name chunk (`tdmn`, 40 bytes). +fn tdmn(name: &str) -> Chunk<'static> { + let mut b = name.as_bytes().to_vec(); + b.resize(40, 0); + Chunk::data(*b"tdmn", b) +} + +/// A parameter definition: the type at byte 15, the name at 16; a popup's default at 56 and +/// item count at 60. +fn pard(kind: u8, name: &str, popup: Option<(u32, u16)>) -> Chunk<'static> { + let mut b = vec![0u8; 148]; + b[15] = kind; + b[16..16 + name.len()].copy_from_slice(name.as_bytes()); + if let Some((default, count)) = popup { + b[56..60].copy_from_slice(&default.to_be_bytes()); + b[60..62].copy_from_slice(&count.to_be_bytes()); + } + Chunk::data(*b"pard", b) +} + +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`, as After Effects writes `pdnm` and `fnam` +/// (`NESTED_IDS`). A `LIST pdnm` is not read as popup items. +fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> { + Chunk { id, body: Body::Chunks(children), pad: None } +} + +/// The plug-in's Output items in the order After Effects 2026 stores them (1400). +const OUTPUT: &[&str] = &["Colors", "Matted Colors", "Matted RGBA", "Matte Only"]; + +/// A static value record holding `v` in state `tdsb` (1 written, 3 the default state), after the +/// one After Effects wrote for Glow's A & B Midpoint (its header; without the expression). +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 plug-in's Settings data: `cry1`, 28 bytes not read, the layer name in 64 bytes, a `u32` 1, +/// a `u32` not read, the text's length with its NUL, a `u32` not read, a zero byte, the text. +fn settings(layer: &str, selection: &str) -> Vec { + let mut d = b"cry1".to_vec(); + d.resize(32, 0x5a); + d.extend(layer.as_bytes()); + d.resize(96, 0); + d.extend(1u32.to_le_bytes()); + d.extend(0x1234_5678u32.to_le_bytes()); + d.extend(u32::try_from(selection.len() + 1).unwrap().to_le_bytes()); + d.extend(0x9abc_def0u32.to_le_bytes()); + d.push(0); + d.extend(selection.as_bytes()); + d.push(0); + d.extend(b" <1"); // left-overs of a longer earlier text + d +} + +struct Crypto { + /// The Settings data, if stored. + settings: Option>, + /// Output (1-based), if stored. + output: Option, + /// Selection Mode, if stored: on, and the record's `tdsb`. + selection_mode: Option<(bool, u32)>, + /// The instance has no definitions of its own (an empty `parT`) and borrows the project's, + /// as every instance but the first of a project does (1400). + borrowed: bool, + /// Output's popup items as written in its `pdnm`; `None` writes no `pdnm` (a definition with + /// four items then falls back to the plug-in's own list). + items: Option<&'static [&'static str]>, +} + +/// The Cryptomatte instance: its own definitions (input layer, Settings, Output, Selection Mode, +/// the built-in entry; none when `own` is false) and the stored values of `c`. +fn sspc(c: &Crypto, template: &Chunk<'_>, own: bool) -> Chunk<'static> { + let name = |i: u32| format!("Cryptomatte-{i:04}"); + let mut part = vec![ + Chunk::data(*b"parn", 5u32.to_be_bytes().to_vec()), + tdmn(&name(0)), + pard(0, "", None), + tdmn(&name(1)), + pard(11, "Settings", None), + Chunk::data(*b"aRbp", settings("", "")), + tdmn(&name(2)), + pard(7, "Output", Some((1, c.items.map_or(4, |i| u16::try_from(i.len()).unwrap())))), + tdmn(&name(3)), + pard(4, "Selection Mode", None), + tdmn("ADBE Effect Built In Params"), + pard(9, "", None), + ]; + if let Some(items) = c.items { + // After the Output definition, as After Effects writes it. + part.insert(8, nested(*b"pdnm", vec![utf8(&items.join("|"))])); + } + let part = if own { part } else { Vec::new() }; + let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())]; + if let Some(d) = &c.settings { + values.push(tdmn(&name(1))); + values.push(record(template, 1, 0.0)); + values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d.clone())])); + } + if let Some(o) = c.output { + values.push(tdmn(&name(2))); + values.push(record(template, 1, f64::from(o))); + } + if let Some((on, tdsb)) = c.selection_mode { + values.push(tdmn(&name(3))); + values.push(record(template, tdsb, if on { 1.0 } else { 0.0 })); + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Cryptomatte")]), 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 `c` in place of the Glow effect (the instance in the effect parade and the project's +/// shared definitions, `defs` below `EfDf`); the number of places. +fn replace_glow(chunks: &mut [Chunk<'_>], c: &Crypto, template: &Chunk<'_>, defs: bool) -> usize { + let mut n = 0; + for ch in chunks { + let defs = defs || ch.is_list(b"EfDf"); + 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("Cryptomatte"); + kids[at + 1] = sspc(c, template, defs || !c.borrowed); + n += 1; + } + n += replace_glow(kids, c, template, defs); + } + n +} + +/// `synthetic.aep` with `c` in place of the Glow effect. +fn bytes_with(c: &Crypto) -> Vec { + let file = parse(SYNTHETIC).unwrap(); + let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let mut file = parse(SYNTHETIC).unwrap(); + assert_eq!(replace_glow(&mut file.chunks, c, &template, false), 2, "the Glow effect and its shared definitions"); + write(&file).unwrap() +} + +fn run(c: &Crypto) -> (Option, ImportResult) { + let bytes = bytes_with(c); + 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.3d.cryptomatte").cloned()) + }); + (g, r) +} + +fn losses(r: &ImportResult, category: LossCategory) -> Vec { + r.report.losses.iter().filter(|l| l.category == category).flat_map(|l| l.details.clone()).filter(|d| d.contains("Cryptomatte")).collect() +} + +fn v(g: &PropGroup, id: &str) -> Value { + g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.clone() +} + +fn str(s: &str) -> Value { + Value::Str(s.into()) +} + +#[test] +fn cryptomatte_arrives_with_its_layer_selection_and_output() { + let sel = "<0000002a>, "; + let (g, r) = run(&Crypto { settings: Some(settings("Layer_A", sel)), output: Some(3), selection_mode: None, borrowed: false, items: Some(OUTPUT) }); + assert_eq!(losses(&r, LossCategory::Effects), Vec::::new()); + assert_eq!(losses(&r, LossCategory::DamagedData), Vec::::new()); + let g = g.expect("an EffectCraft Cryptomatte"); + // Matted RGBA: the layer keyed by the selection. + assert_eq!((v(&g, "layerName"), v(&g, "selection"), v(&g, "display"), v(&g, "matteOnly")), (str("Layer_A"), str(sel), Value::Enum(1), Value::Bool(false))); + // The selection's `` tokens are EffectCraft's own grammar. + assert_eq!(effectcraft_effects::selection_hashes(sel, None), vec![0x2a, 0xdead_beef]); +} + +#[test] +fn cryptomatte_output_maps_to_display_and_matte_only() { + for (output, display, matte_only) in [(1, 0, false), (3, 1, false), (4, 1, true)] { + let (g, r) = run(&Crypto { + settings: Some(settings("L", "<00000001>")), + output: Some(output), + selection_mode: Some((false, 1)), + borrowed: false, + items: Some(OUTPUT), + }); + assert_eq!(losses(&r, LossCategory::Effects), Vec::::new(), "output {output}"); + let g = g.expect("effect"); + assert_eq!((v(&g, "display"), v(&g, "matteOnly")), (Value::Enum(display), Value::Bool(matte_only)), "output {output}"); + } + // No Output record: the default, Colors, from the instance's own definition or, borrowed, + // the plug-in's (1400 `crypto-00-defaults`; every other composition there borrows). + for borrowed in [false, true] { + let (g, r) = run(&Crypto { settings: None, output: None, selection_mode: None, borrowed, items: Some(OUTPUT) }); + assert_eq!(losses(&r, LossCategory::Effects), Vec::::new(), "borrowed {borrowed}"); + let g = g.expect("effect"); + assert_eq!( + (v(&g, "display"), v(&g, "matteOnly"), v(&g, "layerName"), v(&g, "selection")), + (Value::Enum(0), Value::Bool(false), str(""), str("")), + "the default settings: Colors, no layer, no selection (borrowed {borrowed})" + ); + } + // A borrowed definition with a stored Output. + let (g, r) = run(&Crypto { settings: None, output: Some(4), selection_mode: None, borrowed: true, items: Some(OUTPUT) }); + assert_eq!(losses(&r, LossCategory::Effects), Vec::::new()); + let g = g.expect("effect"); + assert_eq!((v(&g, "display"), v(&g, "matteOnly")), (Value::Enum(1), Value::Bool(true))); +} + +/// The harness writes Output's items as After Effects does: the reader finds them in the `pdnm` +/// (and the instance name in `fnam`), so the Output tests go through the item names. +#[test] +fn the_injected_definition_reads_back_with_its_popup_items() { + let bytes = bytes_with(&Crypto { settings: None, output: Some(4), selection_mode: None, borrowed: false, items: Some(OUTPUT) }); + let file = parse(&bytes).unwrap(); + let proj = AepProject::new(&file).unwrap(); + let defs = proj.effect_definition_index(); + let fx = proj + .all_items() + .into_iter() + .filter_map(|i| i.comp()) + .flat_map(|c| c.layers().collect::>()) + .flat_map(|l| l.effects()) + .find(|e| e.match_name() == "Cryptomatte") + .expect("the effect"); + let output = defs.params(&fx).into_iter().find(|p| p.index == 2).expect("Output"); + let want: Vec = OUTPUT.iter().map(|s| s.to_string()).collect(); + assert_eq!((output.kind, output.popup_item_count(), output.items), (ParamKind::Popup, Some(4), Some(want))); + assert_eq!(fx.name().as_deref(), Some("Cryptomatte")); +} + +/// Output is mapped by the item the stored index names, not by its position in the plug-in's +/// list: with the items in another order, index 4 is Colors and index 1 Matte Only. +#[test] +fn cryptomatte_output_maps_by_item_name() { + const REORDERED: &[&str] = &["Matte Only", "Matted RGBA", "Matted Colors", "Colors"]; + for (output, display, matte_only) in [(1, 1, true), (2, 1, false), (4, 0, false)] { + let (g, r) = run(&Crypto { settings: None, output: Some(output), selection_mode: None, borrowed: false, items: Some(REORDERED) }); + assert_eq!(losses(&r, LossCategory::Effects), Vec::::new(), "output {output}"); + let g = g.expect("effect"); + assert_eq!((v(&g, "display"), v(&g, "matteOnly")), (Value::Enum(display), Value::Bool(matte_only)), "output {output}"); + } + // Item names are matched without regard to case and spacing. + let (g, _) = run(&Crypto { + settings: None, + output: Some(2), + selection_mode: None, + borrowed: false, + items: Some(&["colors", "MATTE ONLY", "Matted RGBA", "Matted Colors"]), + }); + assert_eq!((v(&g.expect("effect"), "matteOnly")), Value::Bool(true)); + // An item EffectCraft does not know: reported, Colors used. + let (g, r) = run(&Crypto { + settings: None, + output: Some(2), + selection_mode: None, + borrowed: false, + items: Some(&["Colors", "Something New", "Matted RGBA", "Matte Only"]), + }); + let found = losses(&r, LossCategory::Effects); + assert!(found.iter().any(|d| d.contains("'Something New'") && d.contains("Colors used")), "{found:?}"); + assert_eq!((v(&g.expect("effect"), "display")), Value::Enum(0)); +} + +/// A file without Output's `pdnm` and four items in the definition: the plug-in's own list. +#[test] +fn cryptomatte_output_without_items_falls_back_to_the_plugin_list() { + let (g, r) = run(&Crypto { settings: None, output: Some(4), selection_mode: None, borrowed: false, items: None }); + assert_eq!(losses(&r, LossCategory::Effects), Vec::::new()); + assert_eq!((v(&g.expect("effect"), "matteOnly")), Value::Bool(true)); +} + +#[test] +fn cryptomatte_values_without_a_counterpart_are_reported() { + // Matted Colors has no counterpart: reported, imported as Matte (the same matte). + let (g, r) = run(&Crypto { settings: Some(settings("L", "")), output: Some(2), selection_mode: Some((true, 1)), borrowed: false, items: Some(OUTPUT) }); + let found = losses(&r, LossCategory::Effects); + assert!(found.iter().any(|d| d.contains("'Matted Colors'") && d.contains("no counterpart") && d.contains("imported as Matte")), "{found:?}"); + assert!(found.iter().any(|d| d.contains("Selection Mode") && d.contains("is on")), "{found:?}"); + assert_eq!(found.len(), 2, "{found:?}"); + let g = g.expect("still imported"); + assert_eq!((v(&g, "display"), v(&g, "matteOnly"), v(&g, "layerName")), (Value::Enum(1), Value::Bool(false), str("L"))); + + // Selection Mode on as After Effects writes it: a default-state record (`tdsb` 3) holding 1 + // (1400 `crypto-01-set-all`). Reported like a written one; off in that form is not. + for (on, n) in [(true, 1), (false, 0)] { + let (_, r) = run(&Crypto { settings: None, output: None, selection_mode: Some((on, 3)), borrowed: true, items: Some(OUTPUT) }); + let found = losses(&r, LossCategory::Effects); + assert_eq!(found.iter().filter(|d| d.contains("Selection Mode") && d.contains("is on")).count(), n, "on {on}: {found:?}"); + assert_eq!(found.len(), n, "on {on}: {found:?}"); + } + + // Unreadable Settings data: reported as damaged, no layer and no selection. + let mut bad = settings("L", "<00000001>"); + bad[0] = b'x'; + let (g, r) = run(&Crypto { settings: Some(bad), output: Some(3), selection_mode: None, borrowed: false, items: Some(OUTPUT) }); + let found = losses(&r, LossCategory::DamagedData); + assert!(found.iter().any(|d| d.contains("settings (layer, selection) are unreadable")), "{found:?}"); + assert_eq!((v(&g.expect("effect"), "layerName")), str("")); +} + +/// `bytes` with `f` applied to the children of every value group (`tdgp`) that holds the +/// parameter `match_name`, given the position of its match name. +fn edit_values(bytes: &[u8], match_name: &str, f: &dyn Fn(&mut Vec>, usize)) -> Vec { + fn walk(chunks: &mut [Chunk<'_>], match_name: &str, f: &dyn Fn(&mut Vec>, usize)) { + for c in chunks { + if c.is_list(b"tdgp") { + let mut kids: Vec> = c.children().iter().cloned().map(Chunk::into_owned).collect(); + if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(format!("{match_name}").as_bytes()))) { + f(&mut kids, at); + *c = Chunk::list(*b"tdgp", kids); + continue; + } + } + walk(c.children_mut(), match_name, f); + } + } + let mut file = parse(bytes).unwrap(); + walk(&mut file.chunks, match_name, f); + write(&file).unwrap() +} + +fn import_bytes(bytes: &[u8]) -> ImportResult { + let mut project = Project::default(); + let none = |_: &str| false; + import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap() +} + +/// A stored record that has lost its data is reported as damaged, not read as the default. +#[test] +fn cryptomatte_records_without_their_data_are_reported() { + let c = Crypto { settings: Some(settings("L", "<00000001>")), output: None, selection_mode: Some((true, 3)), borrowed: false, items: Some(OUTPUT) }; + let bytes = bytes_with(&c); + // The Settings record without its `aRbs` list. + let no_data = edit_values(&bytes, "Cryptomatte-0001", &|kids, at| { + if kids.get(at + 2).is_some_and(|k| k.is_list(b"aRbs")) { + kids.remove(at + 2); + } + }); + let found = losses(&import_bytes(&no_data), LossCategory::DamagedData); + assert!(found.len() == 1 && found[0].contains("settings (layer, selection) have no data"), "{found:?}"); + // The Selection Mode record without a readable value: damaged, not the `pard` default. + let unreadable = edit_values(&bytes, "Cryptomatte-0003", &|kids, at| { + if let Some(rec) = kids.get_mut(at + 1) { + let inner: Vec> = rec.children().iter().filter(|k| !k.is(b"cdat")).cloned().map(Chunk::into_owned).collect(); + *rec = Chunk::list(*b"tdbs", inner); + } + }); + let r = import_bytes(&unreadable); + let found = losses(&r, LossCategory::DamagedData); + assert!(found.len() == 1 && found[0].contains("Selection Mode") && found[0].contains("unreadable value, off used"), "{found:?}"); + assert!(losses(&r, LossCategory::Effects).iter().all(|d| !d.contains("is on"))); + // Both intact: nothing damaged. + assert_eq!(losses(&import_bytes(&bytes), LossCategory::DamagedData), Vec::::new()); +} + +/// Our own After Effects 2026 sample of the plug-in (2.6.6), corpus `aep/cryptomatte.aep`: one +/// Cryptomatte on an EXR layer per composition. The first instance has its own definitions, the +/// others borrow the project's. The first six hold the fresh Settings data (no layer, no +/// selection); the last two come from presets that chose a layer and picked one object +/// (`box_red` on CryptoObject) or one material (`mat_paint` on CryptoMaterial) in the plug-in's +/// UI. The EXR's manifest names those hashes, and After Effects draws exactly those objects as +/// selected. +#[test] +#[ignore = "needs aep/cryptomatte.aep in the corpus (EFFECTCRAFT_AEP_FIXTURES); not pinned yet"] +fn the_after_effects_sample_arrives_as_after_effects_shows_it() { + let bytes = fixture!("cryptomatte.aep"); + let mut project = Project::default(); + let none = |_: &str| false; + let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + assert_eq!(losses(&r, LossCategory::DamagedData), Vec::::new()); + let found = losses(&r, LossCategory::Effects); + // (composition, Display, Matte Only, layer, selection, the loss it reports): the readbacks + // are Output 1 (fresh), 1, 1, 2, 3, 4, 1, 1 and Selection Mode 0, 1, 1, 0, 0, 0, 0, 0. + let want = [ + ("crypto-00-defaults", 0, false, "", "", None), + ("crypto-01-set-all", 0, false, "", "", Some("Selection Mode")), + ("crypto-02-chk-toggled", 0, false, "", "", Some("Selection Mode")), + ("crypto-03-pop2-2", 1, false, "", "", Some("'Matted Colors'")), + ("crypto-04-pop2-3", 1, false, "", "", None), + ("crypto-05-pop2-4", 1, true, "", "", None), + ("crypto-06-preset-object-box", 0, false, "CryptoObject", "<11a97486>", None), + ("crypto-07-preset-material-paint", 0, false, "CryptoMaterial", "", None), + ]; + for (comp, display, matte_only, layer, selection, loss) in want { + let c = project.find_by_name(comp).and_then(|i| i.as_comp()).unwrap_or_else(|| panic!("no {comp}")); + let g = 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.3d.cryptomatte").cloned()) + }); + let g = g.unwrap_or_else(|| panic!("{comp}: no Cryptomatte")); + assert_eq!( + (v(&g, "display"), v(&g, "matteOnly"), v(&g, "layerName"), v(&g, "selection")), + (Value::Enum(display), Value::Bool(matte_only), str(layer), str(selection)), + "{comp}" + ); + let mine: Vec<_> = found.iter().filter(|d| d.starts_with(&format!("{comp}:"))).collect(); + match loss { + None => assert!(mine.is_empty(), "{comp}: {mine:?}"), + Some(what) => assert!(mine.len() == 1 && mine[0].contains(what), "{comp}: {mine:?}"), + } + } + assert_eq!(found.len(), 3, "{found:?}"); + // The picked tokens are the manifest's hashes, which are the hashes of the names: EffectCraft + // reads `<11a97486>` and `box_red` as the same object. + for (name, token) in [("box_red", "<11a97486>"), ("mat_paint", "")] { + assert_eq!(effectcraft_effects::selection_hashes(token, None), effectcraft_effects::selection_hashes(name, None), "{name}"); + } + assert_eq!(effectcraft_effects::selection_hashes("<11a97486>", None), vec![0x11a9_7486]); +} diff --git a/crates/aep/tests/it/import/effects.rs b/crates/aep/tests/it/import/effects.rs index aa4180567..71afa8932 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(), 62); + assert_eq!(t.len(), 63); for (_, id) in t { assert!(effectcraft_effects::find(id).is_some(), "{id}"); } diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index a4c935a65..9e9534ac1 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -456,6 +456,12 @@ pub fn crypto_layer_menu(aux: Option<&AuxChannels>, index: usize) -> (Vec<(usize (slots.into_iter().map(|(i, n)| (i, n.to_string())).collect(), shown) } +/// The Cryptomatte layer the effect reads: the one called `name` when the footage has it (Layer +/// Name, the layer an imported After Effects project names), else [`crypto_layer_name`]'s. +pub fn crypto_layer_named(aux: &AuxChannels, name: &str, index: usize) -> Option { + aux.resolve_crypto_layer_named(name, &crypto_layer_name(aux, index)?) +} + /// The Cryptomatte layer of `aux` that the stored Layer value `index` reads: its slot's layer, else /// the first layer (what a file without that layer has always shown). `None` without any. pub fn crypto_layer_name(aux: &AuxChannels, index: usize) -> Option { @@ -578,7 +584,7 @@ pub fn matte_only_px(m: f32, in_alpha: bool) -> Px { fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(a) = aux(ctx) else { return b }; - let Some(layer) = crypto_layer_name(&a, ctx.params.e("layer") as usize) else { return b }; + let Some(layer) = crypto_layer_named(&a, ctx.params.s("layerName"), ctx.params.e("layer") as usize) else { return b }; let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice()); let sel = selection_hashes(ctx.params.s("selection"), manifest); let idx = index_map(&b, &a); @@ -706,6 +712,10 @@ pub fn specs() -> Vec { p("display", "Display", Value::Enum(2), popup(&["Colors", "Matte", "Preview"])), p("matteOnly", "Matte Only", Value::Bool(false), ParamUi::Checkbox), p(MATTE_ONLY_IN_ALPHA, "Matte Only In Alpha", Value::Bool(false), ParamUi::Hidden), + // A layer named in full (`CryptoMaterial`); empty uses Layer. Last, so the + // earlier parameters keep their indexes and saved instances, which get it + // appended, have the same order as new ones. + text("layerName", "Layer Name", ""), ], cryptomatte, ), diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index d872e34a9..ac9547444 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -348,6 +348,60 @@ fn cryptomatte_selects_by_name_and_wildcard() { assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]); } +/// Layer Name picks a Cryptomatte layer named in full (`Crypto_Material`, which the Layer popup's +/// CryptoMaterial does not match); empty or missing it falls back to the Layer popup. +#[test] +fn cryptomatte_layer_name_picks_the_named_layer() { + let base = scene(); + let mut a = (*base.0).clone(); + // A second layer with the IDs swapped: left is on the right half there. + let swap = |name: &str| a.channels.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone()).unwrap(); + let (r, g) = (swap("CryptoObject00.R"), swap("CryptoObject00.G")); + let w = 20; + let r2: Vec = (0..r.len()).map(|i| r[(i / w) * w + (w - 1 - i % w)]).collect(); + a.channels.push(("Crypto_Material00.R".into(), r2)); + a.channels.push(("Crypto_Material00.G".into(), g)); + let h = AuxHost(Arc::new(a)); + let sel = format!("<{:08x}>", crypto_hash("left")); + let matte = |vals: &[(&str, Value)]| { + let mut v = vec![("selection", Value::Str(sel.clone())), ("matteOnly", Value::Bool(true))]; + v.extend(vals.iter().cloned()); + let m = run("ec.3d.cryptomatte", &v, &h); + (m.get(2, 5)[0], m.get(17, 5)[0]) + }; + assert_eq!(matte(&[("layerName", Value::Str("Crypto_Material".into()))]), (0.0, 1.0), "the named layer"); + assert_eq!(matte(&[]), (1.0, 0.0), "the Layer popup's CryptoObject"); + assert_eq!(matte(&[("layerName", Value::Str("Crypto_Name".into()))]), (1.0, 0.0), "a missing name falls back to the popup"); +} + +/// Layers whose names differ only in case are different layers: `layerName` "crypto_a" mattes +/// from crypto_a's own IDs, not from the earlier `Crypto_A`. +#[test] +fn cryptomatte_layer_name_is_case_sensitive() { + let base = scene(); + let mut a = (*base.0).clone(); + let (r, g) = ( + a.channels.iter().find(|(n, _)| n == "CryptoObject00.R").unwrap().1.clone(), + a.channels.iter().find(|(n, _)| n == "CryptoObject00.G").unwrap().1.clone(), + ); + let w = 20; + let flipped: Vec = (0..r.len()).map(|i| r[(i / w) * w + (w - 1 - i % w)]).collect(); + // `Crypto_A` first, with left/right as in the base scene; `crypto_a` has them swapped. + a.channels.push(("Crypto_A00.R".into(), r)); + a.channels.push(("Crypto_A00.G".into(), g.clone())); + a.channels.push(("crypto_a00.r".into(), flipped)); + a.channels.push(("crypto_a00.g".into(), g)); + let h = AuxHost(Arc::new(a)); + let sel = format!("<{:08x}>", crypto_hash("left")); + let matte = |name: &str| { + let v = [("selection", Value::Str(sel.clone())), ("matteOnly", Value::Bool(true)), ("layerName", Value::Str(name.into()))]; + let m = run("ec.3d.cryptomatte", &v, &h); + (m.get(2, 5)[0], m.get(17, 5)[0]) + }; + assert_eq!(matte("Crypto_A"), (1.0, 0.0)); + assert_eq!(matte("crypto_a"), (0.0, 1.0)); +} + /// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is /// quoted or its comma escaped, instead of being cut into two names that match nothing. #[test] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 39e66ab46..0cde17766 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -117,7 +117,7 @@ pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{ CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu, - crypto_layer_name, id_color, matte_only_px, reads_aux, selection_hashes, + crypto_layer_name, crypto_layer_named, id_color, matte_only_px, reads_aux, selection_hashes, }; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index a2675d134..69bdb5a4a 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -521,7 +521,7 @@ impl CryptoProbe { if !live() { return Ok(None); } - let layer_name = effectcraft_effects::crypto_layer_name(&aux, params.e("layer") as usize).ok_or(NO_CRYPTO)?; + let layer_name = effectcraft_effects::crypto_layer_named(&aux, params.s("layerName"), params.e("layer") as usize).ok_or(NO_CRYPTO)?; // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale // up, proxies have their own size, earlier effects can pad it), rendered and cached. if !live() { diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index 9d161de19..5932a5aa1 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -268,7 +268,7 @@ fn identifier(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option< fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option { let pr = ctx.params; - let Some(layer) = effectcraft_effects::crypto_layer_name(a, pr.e("layer") as usize) else { return Some(b) }; + let Some(layer) = effectcraft_effects::crypto_layer_named(a, pr.s("layerName"), pr.e("layer") as usize) else { return Some(b) }; let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice()); let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest); // Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A), on all diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index 034fb50fd..ae2d38303 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -127,12 +127,7 @@ impl AuxChannels { pub fn crypto_rank_planes(&self, layer: &str) -> Vec<(&[f32], &[f32])> { let mut out = vec![]; for k in 0.. { - let (r, g, b, a) = ( - self.get(&format!("{layer}{k:02}.R")), - self.get(&format!("{layer}{k:02}.G")), - self.get(&format!("{layer}{k:02}.B")), - self.get(&format!("{layer}{k:02}.A")), - ); + let (r, g, b, a) = (self.rank(layer, k, 'R'), self.rank(layer, k, 'G'), self.rank(layer, k, 'B'), self.rank(layer, k, 'A')); let (Some(r), Some(g)) = (r, g) else { break }; out.push((r, g)); if let (Some(b), Some(a)) = (b, a) { @@ -142,6 +137,13 @@ impl AuxChannels { out } + /// The plane `{layer}{k:02}.{comp}`: the layer name matches case-sensitively (layers + /// `Crypto_A` and `crypto_a` are different layers), the component letter either case. + fn rank(&self, layer: &str, k: usize, comp: char) -> Option<&[f32]> { + let tail = format!("{k:02}.{comp}"); + self.channels.iter().find(|(n, _)| n.strip_prefix(layer).is_some_and(|rest| rest.eq_ignore_ascii_case(&tail))).map(|(_, v)| v.as_slice()) + } + /// Rank pairs (id, coverage) of Cryptomatte layer `layer` at aux pixel index `i`. For a /// loop over pixels, use [`AuxChannels::crypto_rank_planes`] once instead. pub fn crypto_ranks(&self, layer: &str, i: usize) -> Vec<(f32, f32)> { @@ -155,6 +157,20 @@ impl AuxChannels { layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).or(layers.first()).cloned() } + /// [`AuxChannels::resolve_crypto_layer`] with a layer named in full first: the layer called + /// exactly `name` when it is not empty and the footage has it (`Crypto_Material`, a name an + /// imported After Effects project carries), else the layer for `want`. + /// The name as given is tried first (EXR channel names may carry spaces), then trimmed. + pub fn resolve_crypto_layer_named(&self, name: &str, want: &str) -> Option { + let layers = self.crypto_layers(); + for name in [name, name.trim()] { + if !name.is_empty() && layers.iter().any(|l| l == name) { + return Some(name.to_string()); + } + } + self.resolve_crypto_layer(want) + } + /// The ID (its hash, `f32::to_bits`) with the most coverage at aux pixel `i` of Cryptomatte /// layer `layer`, summed over the ranks it appears in. Ties go to the lower rank; IDs with /// no positive coverage don't count. `None` when nothing covers the pixel. @@ -256,6 +272,19 @@ mod tests { let at = |i| a.crypto_id_at(&l, i); assert_eq!([at(0), at(1), at(2), at(3), at(9)], [Some(crypto_hash("y")), Some(crypto_hash("x")), None, Some(crypto_hash("x")), None]); assert_eq!(AuxChannels::new(1, 1, 1.0).resolve_crypto_layer("CryptoObject"), None); + // A layer named in full wins; an empty or missing name falls back to `want`. + assert_eq!(a.resolve_crypto_layer_named("Other", "CryptoObject").as_deref(), Some("Other")); + assert_eq!(a.resolve_crypto_layer_named(" Other ", "CryptoObject").as_deref(), Some("Other")); + assert_eq!(a.resolve_crypto_layer_named("", "CryptoObject").as_deref(), Some("ViewLayer.CryptoObject")); + assert_eq!(a.resolve_crypto_layer_named("Crypto_Name", "CryptoObject").as_deref(), Some("ViewLayer.CryptoObject")); + assert_eq!(AuxChannels::new(1, 1, 1.0).resolve_crypto_layer_named("Other", "CryptoObject"), None); + // A layer whose name ends in a space is matched as named, before the trimmed name. + let mut b = AuxChannels::new(1, 1, 1.0); + for l in ["Mat", "Mat "] { + b.channels.push((format!("{l}00.R"), vec![0.0])); + } + assert_eq!(b.resolve_crypto_layer_named("Mat ", "CryptoObject").as_deref(), Some("Mat ")); + assert_eq!(b.resolve_crypto_layer_named(" Mat", "CryptoObject").as_deref(), Some("Mat")); } #[test] @@ -321,4 +350,22 @@ mod tests { } assert!(a.crypto_rank_planes("CryptoObject").is_empty()); } + + /// Layers whose names differ only in case stay separate: `Crypto_A` and `crypto_a` each read + /// their own ranks (lowercase component letters still match), and picking agrees. + #[test] + fn crypto_layers_differing_in_case_read_their_own_planes() { + let mut a = AuxChannels::new(1, 1, 1.0); + let f = |n: &str| crypto_float(crypto_hash(n)); + for (n, v) in [("Crypto_A00.R", f("upper")), ("Crypto_A00.G", 1.0), ("crypto_a00.r", f("lower")), ("crypto_a00.g", 1.0)] { + a.channels.push((n.into(), vec![v])); + } + assert_eq!(a.crypto_layers(), vec!["Crypto_A".to_string(), "crypto_a".to_string()]); + assert_eq!(a.resolve_crypto_layer_named("crypto_a", "CryptoObject").as_deref(), Some("crypto_a")); + assert_eq!(a.crypto_rank_planes("crypto_a")[0].0, &[f("lower")][..]); + assert_eq!(a.crypto_rank_planes("Crypto_A")[0].0, &[f("upper")][..]); + assert_eq!(a.crypto_id_at("crypto_a", 0), Some(crypto_hash("lower"))); + assert_eq!(a.crypto_id_at("Crypto_A", 0), Some(crypto_hash("upper"))); + assert!(a.crypto_rank_planes("CRYPTO_A").is_empty()); + } } diff --git a/docs/aep-format.md b/docs/aep-format.md index 9f2c009c5..d6ec7fb14 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -573,6 +573,43 @@ suffix is a parameter id, not the `parT` position (Curves -0002 Channel before - -0005 Quality before -0004). API: `CurvesData`, `MeshData`, `curve_lut`. [V; details: corpus FORMAT S] +**Cryptomatte plug-in** (match name `Cryptomatte`, the free Cryptomatte plug-in, 2.6.6 in our +sample; read from 88 instances in one production project, not made by us, and from our own After +Effects 2026 sample 1400 (corpus `aep/cryptomatte.aep`: a fresh instance, Selection Mode on, each +Output item, and two presets of our own that choose a layer and pick one object or material in the +plug-in's UI; scripting read-backs and renders per composition); H for what neither varies): +`parT` 0 the input layer; 1 Settings: **arbitrary data** (type 11, `Cryptomatte-0001`, a record and +`aRbs` / `aRbp` in every instance); 2 Output: popup (Colors, Matted Colors, Matted RGBA, Matte +Only), **`pard` default 1 = Colors** (bytes 56..64 `00000001 0004 0001`, in the instance's own and +the project's shared definition; scripting reads 1 from a fresh effect: 1400 `crypto-00-defaults`), +a written record (`tdsb` 1) holding the item number when set (1400: 2, 3, 4; production: 3 in 81, +4 in 7); 3 Selection Mode: checkbox (`pdnm` `fnord!`), no record when off, and when on **a +default-state record (`tdsb` 3) holding 1.0** that After Effects reads back as on and renders +(1400 `crypto-01-set-all`, `crypto-02-chk-toggled`; the general reader takes only `tdsb` 1 +values, so the importer reads this one itself); the built-in entry. As in every project, only the +first instance has its own `parT`; the others borrow the project's (1400). The Settings data is +the plug-in's own layout, **little-endian**: `cry1`; bytes 4..32 differ between instances with +the same settings (zeros in one, pointer-like values in others: not read); the layer name at 32, +NUL-padded to 64 bytes (`CryptoObject`, `CryptoMaterial` in 1400: the EXR layer's name, which is +also its channel prefix there, so the sample cannot tell which of the two the plug-in stores); at +96 a `u32` 1 and a `u32` that is the same in every instance; at 104 the `u32` length of the +selection text including its NUL; at 108 **the MurmurHash3 (x86, 32-bit, seed 0) of the selection +text with its NUL** (V on the three texts of 1400: `<11a97486>` `0x27880767`, `` +`0xf7374a5b`, the empty text `0x514e28b7`; not read); a zero byte at 112; the selection text from +113, the matte list as comma-and-space separated ID hashes `<11a97486>, ` (303 tokens in +production, all in that form). Each token is the 8-digit hash of a picked name as the EXR's +manifest lists it, i.e. the MurmurHash3 of the name (1400: `box_red` `11a97486` picked on +CryptoObject, `mat_paint` `a5deeb9e` on CryptoMaterial). After the text 2 to 3 bytes, zeros or +left-overs of a longer earlier text (1400's presets: 127 bytes, three zeros after the text). **A fresh effect** (the `parT` +default and the record alike, 116 bytes, 1400) is `cry1`, zeros to 96, then `u32`s 1, +`0x514e28b7`, 1, `0x514e28b7` and four zero bytes: no layer and an empty text (length 1, its NUL). +`0x514e28b7` is the hash of a single NUL byte, so the `u32` at 100 looks like the hash of a first +text that is empty in every sample, its length the `u32` 1 at 96 and its NUL the zero byte at 112 +(H: one value). Layer and selection decode the same in the production project and in 1400's two +presets, and After Effects draws exactly the picked objects as selected (`crypto-06`: the box, +`crypto-07`: box and disc, the two objects of `mat_paint`). API: `CryptomatteData` (refuses data without `cry1`, a length past the end or +a non-zero byte at 112). + ### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`) | Field | Chunk path | Offset | Type | Meaning | Evidence | Conf. | diff --git a/docs/aep-import.md b/docs/aep-import.md index 1f7c2d364..ed3ab57f5 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -178,6 +178,7 @@ parameter gets the file's value: | `ADBE HUE SATURATION` 1 Channel Control, 2 Channel Range data, 6 Colorize, 7 Colorize Hue, 8 Colorize Saturation, 9 Colorize Lightness | `channelControl`; `hue`, `saturation`, `lightness` from the data; `colorize`, `colorizeHue`, `colorizeSaturation`, `colorizeLightness` | popup by name; the master hue (degrees), saturation and lightness are the `i32` of the arbitrary data (the visible Master parameters keep no record); the colour ranges are reported when they are not at their defaults (not sampled) | 443-450 | | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | +| `Cryptomatte` (the Cryptomatte plug-in) 1 Settings data, 2 Output, 3 Selection Mode | `ec.3d.cryptomatte` `layerName`, `selection`; `display`, `matteOnly` | the layer name and the selection text from the data, verbatim (`CryptomatteData`; Layer Name picks the footage's Cryptomatte layer of exactly that name, the Layer popup is the fallback; the `` selection tokens are EffectCraft's selection grammar); Output Colors -> Display Colors, Matted RGBA -> Display Matte, Matte Only -> Matte Only on (Display Matte); **Matted Colors** (the ID colours keyed by the selection) has no counterpart: imported as Display Matte (the same matte over the layer's own colours) and reported; no Output record is the plug-in's default Colors (also for a borrowed definition; Colors too for an Output that is keyed, has an expression or cannot be read, each reported); Selection Mode (the plug-in's picking switch) is reported when on, also in the default-state record After Effects writes for it; keys or expressions on any of them are reported; unreadable data is damaged data (no layer, no selection). **Render-compared** with After Effects (1400): with a chosen layer and a picked object (the two preset compositions, Output Colors) EffectCraft's matte covers exactly the pixels After Effects draws as selected (6 012 fully covered for the box on CryptoObject, 10 962 for box and disc on CryptoMaterial, the same counts; on the 190 edge pixels of the latter the coverage implied by After Effects' frame is EffectCraft's within 0.0002); After Effects' Colors draws the selection as `c * (1 - m) + m` (a fully selected pixel white), EffectCraft's Colors as `c * (1 - m / 2) + m / 2`, and the ID colours themselves differ, so Colors is not compared per pixel. With the fresh data (empty selection, every matte 0): Matted RGBA matches (transparent); Matte Only differs in alpha (After Effects: the matte in RGB, opaque; EffectCraft: the matte in RGBA, so the same over black and as a luma matte, not as an alpha matte); Matted Colors is transparent in After Effects and in the imported Matte; Colors is a preview of the IDs and not compared per pixel (After Effects' colours are near black and, with no layer chosen, show the footage's first Cryptomatte layer, CryptoMaterial in 1400, where EffectCraft's Layer popup starts at CryptoObject); what Selection Mode draws is not reproduced | aep-format 3.7 (88 instances of one project; 1400); test `import::cryptomatte` | | Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; its unit, pixels here, is not verified). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | diff --git a/docs/effects.md b/docs/effects.md index 3490af5a2..d9f726481 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -EffectCraft ships 308 effects with 2987 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 308 effects with 2988 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. - **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping. @@ -17,7 +17,7 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented | | 3D Layer Instance | `ec.3d.instance3d` | 1 | | 32 | Implemented | -| Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented | +| Cryptomatte | `ec.3d.cryptomatte` | 5 | GPU | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented | | EXtractoR | `ec.3d.extractor` | 8 | GPU | 32 | Implemented |