mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-07 12:13:22 +00:00
.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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Cryptomatte settings: require the selection's NUL to end its declared length Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Format Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * AEP: the exporter knows Extra::Cryptomatte; rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
FredApps
parent
48c8137835
commit
ca1ff9cfc2
@@ -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};
|
||||
|
||||
@@ -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>, <deadbeef>`, 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<Self> {
|
||||
if data.get(..4)? != b"cry1" {
|
||||
return None;
|
||||
}
|
||||
let text = |b: &[u8]| -> Option<String> {
|
||||
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<u8> {
|
||||
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" <de");
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cryptomatte_settings_are_read() {
|
||||
for noise in [0, 0x5a] {
|
||||
let d = CryptomatteData::parse(&crypto("Layer_A", "<0000002a>, <deadbeef>", noise));
|
||||
assert_eq!(d, Some(CryptomatteData { layer: "Layer_A".into(), selection: "<0000002a>, <deadbeef>".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>, <deadbeef>", 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", "<a5deeb9e>", 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<u16, ParDef>, here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
|
||||
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"],
|
||||
|
||||
@@ -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<bool> {
|
||||
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<String> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ mod blend_modes;
|
||||
mod cameras;
|
||||
mod cases;
|
||||
mod combined;
|
||||
mod cryptomatte;
|
||||
mod ease_round2;
|
||||
mod effects;
|
||||
mod effects_batch5;
|
||||
|
||||
@@ -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<'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<u8> {
|
||||
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<Vec<u8>>,
|
||||
/// Output (1-based), if stored.
|
||||
output: Option<u32>,
|
||||
/// 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<Chunk<'a>> {
|
||||
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<u8> {
|
||||
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<PropGroup>, 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<String> {
|
||||
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>, <deadbeef>";
|
||||
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::<String>::new());
|
||||
assert_eq!(losses(&r, LossCategory::DamagedData), Vec::<String>::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 `<hex>` 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::<String>::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::<String>::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::<String>::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::<Vec<_>>())
|
||||
.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<String> = 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::<String>::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::<String>::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<Chunk<'static>>, usize)) -> Vec<u8> {
|
||||
fn walk(chunks: &mut [Chunk<'_>], match_name: &str, f: &dyn Fn(&mut Vec<Chunk<'static>>, usize)) {
|
||||
for c in chunks {
|
||||
if c.is_list(b"tdgp") {
|
||||
let mut kids: Vec<Chunk<'static>> = 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} | ||||