From 67a39d9a2b0e1915fbd3d8623745df0b50b47c3c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:32:11 +0100 Subject: [PATCH] .aep import: EXtractoR (copy) reads the effect input, not the EXR plane of that name After Effects' Channel Info kind 2 "(copy)" was imported as R, G, B or A. EffectCraft's EXtractoR resolves those names through the footage's aux planes first, and an OpenEXR keeps its own R/G/B/A planes, so "(copy)" read the original file plane and dropped what an earlier effect did to the channel. "(copy)" is now a reserved EXtractoR channel name (EXTRACTOR_COPY) that the CPU and GPU renderers never look up in the aux planes: it is the input's own channel of the same position. The importer writes it for kind 2. Render regression: a modified input buffer with same-named R/G/B/A aux planes of other values; GPU parity case with (copy) on an aux scene with R/G/B. --- crates/aep/src/container/arbitrary.rs | 8 +-- crates/aep/src/import/effects.rs | 3 +- .../aep/tests/it/import/effects_thirdparty.rs | 13 ++--- crates/effects/src/channel3d.rs | 51 ++++++++++++++----- crates/effects/src/channel3d/tests.rs | 32 ++++++++++++ crates/effects/src/lib.rs | 5 +- crates/gpu/src/fx_depth.rs | 15 ++---- crates/gpu/src/tests/fx_depth.rs | 3 ++ docs/aep-format.md | 4 +- 9 files changed, 99 insertions(+), 35 deletions(-) diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index 72e7db28a..e4916cfe8 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -355,14 +355,16 @@ impl ExtractorChannels { /// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the /// position of the first record of another kind or without a name: a named channel by its - /// name, `(copy)` as the input's own channel of the same position (`R`, `G`, `B`, `A`). + /// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY), + /// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`: + /// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the + /// file's own, which loses what an earlier effect did to the channel. pub fn sources(&self) -> Result<[String; 4], usize> { - const OWN: [&str; 4] = ["R", "G", "B", "A"]; let mut out: [String; 4] = Default::default(); for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() { *slot = match (kind, name.trim()) { (1, n) if !n.is_empty() => n.to_string(), - (2, _) => OWN.get(k).ok_or(k)?.to_string(), + (2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(), _ => return Err(k), }; } diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 8729571ec..a65a67d58 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -492,7 +492,8 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) { // Each output channel reads the footage's channel of the stored name, which // EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the - // input's own channel, EffectCraft's R, G, B or A (the fresh channels are R, G, B, A). + // input's own channel, EffectCraft's `(copy)` (not `A`, which EffectCraft reads from an + // OpenEXR footage's alpha plane, losing what earlier effects did to it). Some((Ok(names), _)) => { for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) { if let Some(prop) = group.prop_mut(id) { diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs index 7c7ec190c..e7e00dd77 100644 --- a/crates/aep/tests/it/import/effects_thirdparty.rs +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -118,7 +118,7 @@ fn extractor_channel_info_layout() { // `02 00 00 32` on Alpha, still the input's own channel. let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses"); assert!(c.is_default()); - assert_eq!(c.sources(), Ok(["R", "G", "B", "A"].map(String::from))); + assert_eq!(c.sources(), Ok(["R", "G", "B", "(copy)"].map(String::from))); } #[test] @@ -130,8 +130,9 @@ fn extractor_points_unmult_and_default_channels() { near(&g, "blackPoint", 10.0); near(&g, "whitePoint", 14.0); assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false))); - // After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's A. - for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + // After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's `(copy)`, not `A` (an + // OpenEXR's own alpha plane). + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "(copy)")] { assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); } } @@ -143,13 +144,13 @@ fn extractor_channels_are_imported_by_name() { let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor"); assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); let g = g.expect("imported"); - for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "A")] { + for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "(copy)")] { assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); } - // `(copy)` on another channel is that channel of the input. + // `(copy)` on another channel is that channel of the input (EffectCraft's `(copy)` there). let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor"); let g = g.expect("imported"); - for (id, want) in [("red", "R"), ("green", "CryptoObject00.R"), ("blue", "B"), ("alpha", "Z")] { + for (id, want) in [("red", "(copy)"), ("green", "CryptoObject00.R"), ("blue", "(copy)"), ("alpha", "Z")] { assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); } } diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 9e9534ac1..b84b89ebb 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -619,6 +619,39 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- EXtractoR +/// EXtractoR's channel name for After Effects' `(copy)`: the effect input's own channel of the +/// same position (red's red ... alpha's alpha), as the earlier effects left it, never a footage +/// channel of some name (an OpenEXR's `A` is the file's alpha, not the input's). +pub const EXTRACTOR_COPY: &str = "(copy)"; + +/// Whether an EXtractoR channel name is [`EXTRACTOR_COPY`]. +pub fn is_extractor_copy(name: &str) -> bool { + name.trim().eq_ignore_ascii_case(EXTRACTOR_COPY) +} + +/// The layer's own channel an EXtractoR output channel at position `k` reads when no footage +/// channel of that name exists: `R`/`G`/`B`/`A` (or the long names) by name, [`EXTRACTOR_COPY`] +/// its own position. +pub fn extractor_own_channel(k: usize, name: &str) -> Option { + if is_extractor_copy(name) { + return (k < 4).then_some(k); + } + match name.trim().to_ascii_uppercase().as_str() { + "R" | "RED" => Some(0), + "G" | "GREEN" => Some(1), + "B" | "BLUE" => Some(2), + "A" | "ALPHA" => Some(3), + _ => None, + } +} + +/// The footage channel an EXtractoR output channel reads by name: none for an empty name or +/// [`EXTRACTOR_COPY`] (the input's own channel). +pub fn extractor_aux_channel<'a>(a: &'a AuxChannels, name: &str) -> Option<&'a [f32]> { + let n = name.trim(); + if n.is_empty() || is_extractor_copy(n) { None } else { a.get(n) } +} + fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { let a = aux(ctx); let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")]; @@ -626,17 +659,11 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { let unmult = ctx.params.b("unMult"); let clip = ctx.params.b("clip"); let idx = a.as_ref().map(|a| index_map(&b, a)); - // Each output channel: an aux channel by name, or the layer's own R/G/B/A. - let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() { - "R" | "RED" => Some(0), - "G" | "GREEN" => Some(1), - "B" | "BLUE" => Some(2), - "A" | "ALPHA" => Some(3), - "" => None, - _ => None, - }; - let src_ch: Vec> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect(); - if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) { + // Each output channel: an aux channel by name, or the layer's own R/G/B/A (`(copy)`: its + // own position, never an aux channel). + let own = |k: usize, n: &str| extractor_own_channel(k, n); + let src_ch: Vec> = names.iter().map(|n| a.as_ref().and_then(|a| extractor_aux_channel(a, n))).collect(); + if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k)) { return b; } let src = b.img.clone(); @@ -652,7 +679,7 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) { (Some(ch), Some(j)) => ch[j], (Some(_), None) => 0.0, - (None, _) => match own(names[k]) { + (None, _) => match own(k, names[k]) { Some(c) => straight[c], None => { if k == 3 { diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index ac9547444..c1fa21711 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -457,6 +457,38 @@ fn extractor_maps_named_channels() { assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); } +/// `(copy)` (After Effects' EXtractoR choice, imported from .aep) is the effect input's own +/// channel as earlier effects left it, never the OpenEXR's plane of the same name: here the input +/// buffer differs from the footage's R, G, B and A planes, as after an earlier effect. +#[test] +fn extractor_copy_reads_the_current_input_not_the_footage_plane() { + let (w, h) = (4usize, 3usize); + let mut a = AuxChannels::new(w as u32, h as u32, 1.0); + // The file's own planes (what the footage held before any effect) and a depth channel. + a.channels = ["R", "G", "B", "A"].iter().map(|n| (n.to_string(), vec![0.9; w * h])).collect(); + a.channels.push(("Z".into(), vec![0.7; w * h])); + let host = AuxHost(Arc::new(a)); + let env = || EffectEnv { host: Some(&host), ..Default::default() }; + // The input after an earlier effect: straight (0.2, 0.3, 0.4) at alpha 0.5, premultiplied. + let input = Image::filled(w as u32, h as u32, [0.1, 0.15, 0.2, 0.5]); + let copy = || Value::Str(EXTRACTOR_COPY.into()); + // All four `(copy)`: the input unchanged. + let all = [("red", copy()), ("green", copy()), ("blue", copy()), ("alpha", copy())]; + let o = run_fx("ec.3d.extractor", &all, input.clone(), 0.0, env()).img; + assert_eq!(o.data, input.data); + // Mixed with a named channel (no pass-through): red from Z, green and alpha copied, blue the + // footage's B plane. + let vals = [("red", Value::Str("Z".into())), ("green", copy()), ("blue", Value::Str("B".into())), ("alpha", copy())]; + let px = run_fx("ec.3d.extractor", &vals, input.clone(), 0.0, env()).img.get(1, 1); + let want = [0.7 * 0.5, 0.3 * 0.5, 0.9 * 0.5, 0.5]; + for (c, (got, want)) in px.iter().zip(want).enumerate() { + assert!((got - want).abs() < 1e-6, "channel {c}: {got} != {want} ({px:?})"); + } + // `A` by name is the footage's alpha plane (0.9), not the input's alpha (0.5). + let named = [("alpha", Value::Str("A".into())), ("red", Value::Str("Z".into()))]; + assert!((run_fx("ec.3d.extractor", &named, input, 0.0, env()).img.get(1, 1)[3] - 0.9).abs() < 1e-6); +} + #[test] fn channel_extract_antialias_averages_sub_pixels() { // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 0cde17766..bd239aa6f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -116,8 +116,9 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{ - CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu, - crypto_layer_name, crypto_layer_named, id_color, matte_only_px, reads_aux, selection_hashes, + CRYPTO_LAYERS, CryptoAux, EXTRACTOR_COPY, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu, + crypto_layer_name, crypto_layer_named, extractor_aux_channel, extractor_own_channel, id_color, is_extractor_copy, matte_only_px, reads_aux, + selection_hashes, }; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index 5932a5aa1..3f348c47d 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -292,19 +292,14 @@ fn extractor(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: Option<&AuxChannels>) -> let names = [pr.s("red"), pr.s("green"), pr.s("blue"), pr.s("alpha")]; let (bp, wp) = (pr.f("blackPoint") as f32, pr.f("whitePoint") as f32); let unmult = pr.b("unMult"); - let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() { - "R" | "RED" => Some(0u32), - "G" | "GREEN" => Some(1), - "B" | "BLUE" => Some(2), - "A" | "ALPHA" => Some(3), - _ => None, - }; - let src_ch: Vec> = names.iter().map(|n| a.and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect(); - if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k as u32)) { + // `(copy)`: the input's own channel of the same position, never an aux channel. + let own = |k: usize, n: &str| effectcraft_effects::extractor_own_channel(k, n).and_then(|c| u32::try_from(c).ok()); + let src_ch: Vec> = names.iter().map(|n| a.and_then(|a| effectcraft_effects::extractor_aux_channel(a, n))).collect(); + if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k as u32)) { return Some(b); } // Per output channel: 0..3 = the layer's own channel, 4 = aux, 5 = default (0, alpha 1). - let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(names[k]).unwrap_or(5) }); + let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(k, names[k]).unwrap_or(5) }); let plane = match a { Some(a) if src_ch.iter().any(Option::is_some) => { let tex = aux_texture(e, a, [src_ch[0], src_ch[1], src_ch[2], src_ch[3]])?; diff --git a/crates/gpu/src/tests/fx_depth.rs b/crates/gpu/src/tests/fx_depth.rs index 088b160ce..f60f31e3e 100644 --- a/crates/gpu/src/tests/fx_depth.rs +++ b/crates/gpu/src/tests/fx_depth.rs @@ -288,4 +288,7 @@ fn extractor() { ); case("ec.3d.extractor", &[("red", Value::Str("A".into())), ("blackPoint", n(0.2)), ("whitePoint", n(0.8)), ("clip", on())]); case("ec.3d.extractor", &[("green", Value::Str("".into())), ("alpha", Value::Str("nothing".into()))]); + // `(copy)` reads the input's own channel, not the footage's R plane of aux_scene. + let copy = || Value::Str(effectcraft_effects::EXTRACTOR_COPY.into()); + case("ec.3d.extractor", &[("red", copy()), ("green", Value::Str("Coverage".into())), ("alpha", copy())]); } diff --git a/docs/aep-format.md b/docs/aep-format.md index f4c0059e6..8e5230bed 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -571,7 +571,9 @@ data layouts, read from default instances only):** EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 bytes, then four 164-byte records (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by name: `R`, `G`, `B` by default; 2 the input's own channel, named `(copy)`: Alpha by default) and the -channel name NUL-terminated from record byte 35. The rest is not read: it differs between instances +channel name NUL-terminated from record byte 35. EffectCraft imports kind 2 as its EXtractoR's `(copy)`, +the effect input's channel of the same position as earlier effects left it, not as `A` (which names +an OpenEXR footage's own alpha plane). The rest is not read: it differs between instances with the same channels (uninitialised-looking values, also in bytes 1..4 of a record: `02 00 00 32` on Alpha in one definition's default, which is why only byte 0 is the kind). **A chosen channel** (`extractor-07-preset-channel-z`, a preset of our own that reads `Z` into Red, Green and Blue) is kind