mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 03:49:12 +00:00
Merge pull request #3120 from vangel76/aep-import-thirdparty
.aep import: map CC Color Neutralizer, EXtractoR and IDentifier
This commit is contained in:
@@ -37,7 +37,9 @@ mod version;
|
||||
mod write;
|
||||
|
||||
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
|
||||
pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut};
|
||||
pub use arbitrary::{
|
||||
CURVE_IDENTITY, CryptomatteData, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type,
|
||||
};
|
||||
pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
|
||||
pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
|
||||
pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
|
||||
|
||||
@@ -308,6 +308,82 @@ impl MeshData {
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian
|
||||
/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset
|
||||
/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records
|
||||
/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by
|
||||
/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one
|
||||
/// definition's default, so only byte 0 is read) and the channel name from byte 35,
|
||||
/// NUL-terminated. The other bytes are not read (they differ between instances with the same
|
||||
/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted
|
||||
/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExtractorChannels {
|
||||
/// Per output channel (red, green, blue, alpha): the kind and the name.
|
||||
pub channels: [(u32, String); 4],
|
||||
}
|
||||
|
||||
impl ExtractorChannels {
|
||||
/// The data's length.
|
||||
pub const LEN: usize = 688;
|
||||
const RECORD: usize = 164;
|
||||
|
||||
/// `None` when the data does not have this layout.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() {
|
||||
return None;
|
||||
}
|
||||
let mut channels: [(u32, String); 4] = Default::default();
|
||||
for (k, slot) in channels.iter_mut().enumerate() {
|
||||
let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?;
|
||||
let rec = data.get(at..at.checked_add(Self::RECORD)?)?;
|
||||
let kind = u32::from(*rec.first()?);
|
||||
let name = rec.get(35..)?;
|
||||
let end = name.iter().position(|b| *b == 0)?;
|
||||
let name = std::str::from_utf8(name.get(..end)?).ok()?;
|
||||
*slot = (kind, name.to_string());
|
||||
}
|
||||
Some(Self { channels })
|
||||
}
|
||||
|
||||
/// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names,
|
||||
/// Alpha copied from the input.
|
||||
pub fn is_default(&self) -> bool {
|
||||
let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn)
|
||||
}
|
||||
|
||||
/// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the
|
||||
/// position of the first record of another kind or without a name: a named channel by its
|
||||
/// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY),
|
||||
/// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`:
|
||||
/// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the
|
||||
/// file's own, which loses what an earlier effect did to the channel.
|
||||
pub fn sources(&self) -> Result<[String; 4], usize> {
|
||||
let mut out: [String; 4] = Default::default();
|
||||
for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() {
|
||||
*slot = match (kind, name.trim()) {
|
||||
(1, n) if !n.is_empty() => n.to_string(),
|
||||
(2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(),
|
||||
_ => return Err(k),
|
||||
};
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name,
|
||||
/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310
|
||||
/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in
|
||||
/// every other instance). The bytes around it differ between instances with the same settings
|
||||
/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the
|
||||
/// EXR's sorted channel list, as for EXtractoR).
|
||||
pub fn identifier_channel_type(data: &[u8]) -> Option<&str> {
|
||||
let s = data.get(19..)?;
|
||||
let end = s.iter().position(|b| *b == 0)?;
|
||||
std::str::from_utf8(s.get(..end)?).ok()
|
||||
}
|
||||
|
||||
/// The Cryptomatte plug-in's Settings data (`Cryptomatte-0001`, the plug-in's own layout,
|
||||
/// little-endian; read from 88 instances of one project and from our own sample 1400, fresh
|
||||
/// instances and two presets that pick a layer and an object, docs/aep-format.md 3.7): the magic
|
||||
|
||||
@@ -141,6 +141,10 @@ pub enum Extra {
|
||||
Curves,
|
||||
/// Mesh Warp's Distortion Mesh (965, 985, `MeshData`).
|
||||
MeshWarp,
|
||||
/// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name.
|
||||
Extractor,
|
||||
/// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name.
|
||||
Identifier,
|
||||
/// The Cryptomatte plug-in: the layer and selection in its Settings data
|
||||
/// (`CryptomatteData`), Output split over EffectCraft's Display and Matte Only; every
|
||||
/// parameter is handled by the importer's Cryptomatte step, none by the table.
|
||||
@@ -361,6 +365,8 @@ pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"];
|
||||
pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"];
|
||||
/// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels).
|
||||
pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"];
|
||||
/// IDentifier's Display (`identifier-04` ... `identifier-06`, `1300-thirdparty.aep`).
|
||||
pub const IDENTIFIER_DISPLAY: &[&str] = &["Colors", "Luma Matte", "Alpha Matte", "Raw"];
|
||||
|
||||
/// The effects the importer converts.
|
||||
pub const EFFECTS: &[EffectMap] = &[
|
||||
@@ -1076,6 +1082,40 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }),
|
||||
],
|
||||
),
|
||||
// CC Color Neutralizer (importer block 2026-11-05, `ccneutral-*`): Pinning and Blend w.
|
||||
// Original are float sliders in percent (valid 0..100). After Effects' correction is the Red /
|
||||
// Green / Blue slider of each range (3-5, 9-11, 15-17; the Unbalance colours are pickers that
|
||||
// set them and keep no record), then View (22) and Black / White Point (23, 24):
|
||||
// EffectCraft's CC Color Neutralizer has none of them, so they are reported when stored.
|
||||
fx(
|
||||
"CS Color Neutralizer",
|
||||
"ec.color.cccolorneutralizer",
|
||||
&[pm(19, K::FloatSlider, "pinning", Conv::Plain { valid_max: 100.0 }), pm(20, K::FloatSlider, "blendWOriginal", Conv::Plain { valid_max: 100.0 })],
|
||||
),
|
||||
// EXtractoR (`extractor-*`): 1 Channel Info (arbitrary data, `Extra::Extractor`), 2 / 6 the
|
||||
// Process group, Black and White Point float sliders (valid ±3.4e38, the float32 range),
|
||||
// UnMult. EffectCraft's Clip has no counterpart and stays off.
|
||||
EffectMap {
|
||||
match_name: "EXtractoR",
|
||||
effect: "ec.3d.extractor",
|
||||
params: &[
|
||||
pm(3, K::FloatSlider, "blackPoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(4, K::FloatSlider, "whitePoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(5, K::Checkbox, "unMult", Conv::Checkbox),
|
||||
],
|
||||
extra: Extra::Extractor,
|
||||
},
|
||||
// IDentifier (`identifier-*`): 1 Channel Info (arbitrary data, `Extra::Identifier`), 2 Display,
|
||||
// 3 ID an integer slider (valid 0..2 147 483 647).
|
||||
EffectMap {
|
||||
match_name: "IDentifier",
|
||||
effect: "ec.3d.identifier",
|
||||
params: &[
|
||||
pm(2, K::Popup, "display", Conv::Popup { default: 1, items: IDENTIFIER_DISPLAY }),
|
||||
pm(3, K::IntSlider, "id", Conv::Plain { valid_max: 2_147_483_647.0 }),
|
||||
],
|
||||
extra: Extra::Identifier,
|
||||
},
|
||||
// Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset.
|
||||
fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]),
|
||||
fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]),
|
||||
@@ -1089,6 +1129,8 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pub const EXPOSURE: Conv = Conv::Plain { valid_max: 100.0 };
|
||||
pub const OFFSET: Conv = Conv::Plain { valid_max: 2.0 };
|
||||
pub const GAMMA: Conv = Conv::Plain { valid_max: 10.0 };
|
||||
/// The largest float32, the valid maximum of EXtractoR's Black and White Point.
|
||||
pub const F32_MAX: f64 = f32::MAX as f64;
|
||||
/// The valid maximum of Glow's A & B Midpoint (16.16 of 0.99, 427).
|
||||
pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5;
|
||||
|
||||
|
||||
@@ -461,7 +461,7 @@ fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop:
|
||||
fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap<u16, ParDef>, here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
|
||||
let ids: &[&str] = match spec.extra {
|
||||
// (the Cryptomatte plug-in's settings are its own binary layout: not written, reported below)
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte => return None,
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
};
|
||||
@@ -643,7 +643,9 @@ fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64
|
||||
// effect was added at is not known here.
|
||||
let (fresh, other) = (fresh([size.0, size.1], &mut next), fresh([size.0 * 2.0 + 7.0, size.1 * 3.0 + 5.0], &mut next));
|
||||
let extra: &[&str] = match spec.extra {
|
||||
Extra::None | Extra::Cryptomatte => &[],
|
||||
// EXtractoR and IDentifier have no prototype to write from, so they never get this far
|
||||
// (they are reported).
|
||||
Extra::None | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"],
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key};
|
||||
use crate::{
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef,
|
||||
LevelsData, MeshData, ParamKind, ParamValue,
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData,
|
||||
Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue,
|
||||
};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
@@ -438,7 +438,15 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {} are not imported", p.name));
|
||||
}
|
||||
let Some(data) = fx.arbitrary_data(&p.match_name) else {
|
||||
// No data: every value is at its default.
|
||||
// No record: every value is at its default. A Channel Info value record without its
|
||||
// aRbs/aRbp payload (every sampled record has it) is damaged: the payload may have chosen
|
||||
// other channels (`Z`), so the defaults are not what After Effects renders.
|
||||
if p.stored().is_some() && matches!(extra, Extra::Extractor | Extra::Identifier) {
|
||||
col.lose(
|
||||
LossCategory::DamagedData,
|
||||
format!("{here}: the Channel Info data is missing (a value record without its data), the default channels used"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut set = |id: &str, v: f64| {
|
||||
@@ -489,6 +497,60 @@ 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::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) {
|
||||
// Each output channel reads the footage's channel of the stored name, which
|
||||
// EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the
|
||||
// input's own channel, EffectCraft's `(copy)` (not `A`, which EffectCraft reads from an
|
||||
// OpenEXR footage's alpha plane, losing what earlier effects did to it).
|
||||
Some((Ok(names), _)) => {
|
||||
for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) {
|
||||
if let Some(prop) = group.prop_mut(id) {
|
||||
prop.value = Value::Str(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((Err(k), c)) => {
|
||||
let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect();
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{here}: the channels {} are not imported (record {} is of an unknown kind or names no channel), R, G, B, A used",
|
||||
names.join(", "),
|
||||
k.saturating_add(1)
|
||||
),
|
||||
);
|
||||
}
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")),
|
||||
},
|
||||
Extra::Identifier => match crate::identifier_channel_type(data) {
|
||||
// No channel chosen (the type's label): After Effects lists only the footage's integer
|
||||
// (UINT) channels and, with none chosen, leaves the layer unchanged, where EffectCraft
|
||||
// draws the footage's object ID channel.
|
||||
Some("Object ID") => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(0);
|
||||
}
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: no ID channel is chosen (Channel Info), so After Effects leaves the layer unchanged; EffectCraft draws the footage's object ID channel"),
|
||||
);
|
||||
}
|
||||
// A chosen channel, by name. EffectCraft's IDentifier has no channel of its own: it
|
||||
// reads the footage's first `ObjectID` (`MaterialID`) channel, names compared without
|
||||
// regard to case, so a channel of that name is the one After Effects reads.
|
||||
Some(name) => match IDENTIFIER_OWN.iter().position(|own| own.eq_ignore_ascii_case(name.trim())) {
|
||||
Some(kind) => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(u32::try_from(kind).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
None => col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: the ID channel '{name}' (Channel Info) is not imported; EffectCraft reads the footage's object ID channel (ObjectID, object_id, objectId, id)"),
|
||||
),
|
||||
},
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")),
|
||||
},
|
||||
Extra::None | Extra::Cryptomatte => {}
|
||||
}
|
||||
}
|
||||
@@ -630,6 +692,10 @@ fn crypto_output(p: &EffectParam<'_, '_>, here: &str, label: &str, col: &mut Col
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels EffectCraft's IDentifier reads first for its Channel Type Object ID and Material
|
||||
/// ID (`AuxChannels::object_id`, `material_id`).
|
||||
const IDENTIFIER_OWN: [&str; 2] = ["ObjectID", "MaterialID"];
|
||||
|
||||
/// Curves' EffectCraft parameters, in the stored order (RGB, Red, Green, Blue, Alpha).
|
||||
const CURVE_IDS: [&str; 5] = ["rgb", "red", "green", "blue", "alpha"];
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ pub mod import;
|
||||
|
||||
pub use container::{
|
||||
AeVersion, AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CryptomatteData, CueType,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType,
|
||||
HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader,
|
||||
LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation,
|
||||
OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality,
|
||||
Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox,
|
||||
TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut,
|
||||
effect_map, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, ExtractorChannels, FontBaseline, Footage,
|
||||
GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer,
|
||||
LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS,
|
||||
Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode,
|
||||
Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun,
|
||||
TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace,
|
||||
curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map,
|
||||
write, write_to,
|
||||
};
|
||||
|
||||
@@ -364,6 +364,25 @@ fn what_cannot_be_written_is_reported() {
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype
|
||||
/// to write from: each is reported, not written.
|
||||
#[test]
|
||||
fn imported_plug_in_effects_are_reported_not_written() {
|
||||
for effect in ["ec.color.cccolorneutralizer", "ec.3d.extractor", "ec.3d.identifier"] {
|
||||
let mut p = skip!(tint(|_| {}));
|
||||
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
|
||||
g.kind = effectcraft_project::GroupKind::Effect { effect: effect.into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{effect}: {:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0, "{effect}");
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_effect_values_never_panic() {
|
||||
for v in [Value::Scalar(f64::NAN), Value::Scalar(1e300), Value::Color([f32::NAN as f64, 2.0, -1.0, 1.0]), Value::Bool(true), Value::Text(Box::default())] {
|
||||
|
||||
@@ -15,6 +15,7 @@ mod effects_batch5;
|
||||
mod effects_quickwins;
|
||||
mod effects_round2;
|
||||
mod effects_round3;
|
||||
mod effects_thirdparty;
|
||||
mod fx_inject;
|
||||
mod markers;
|
||||
mod masks;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
|
||||
] {
|
||||
assert!(t.contains(&want), "{want:?}");
|
||||
}
|
||||
assert_eq!(t.len(), 63);
|
||||
assert_eq!(t.len(), 66);
|
||||
for (_, id) in t {
|
||||
assert!(effectcraft_effects::find(id).is_some(), "{id}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! CC Color Neutralizer, EXtractoR and IDentifier (#3112) arrive as EffectCraft's effects of the
|
||||
//! same name. Each is built into `synthetic.aep` (`fx_inject`) with the definitions After Effects
|
||||
//! 2026 wrote for it in the importer block of 2026-11-05 (`1300-thirdparty.aep`) and the values
|
||||
//! of that block's compositions; the Channel Info data is laid out as those instances store it.
|
||||
|
||||
use crate::import::fx_inject::{Default, Fx, losses, near, other, popup, run, slider, v};
|
||||
use effectcraft_aep::import::LossCategory;
|
||||
use effectcraft_aep::{ExtractorChannels, ParamKind as K, identifier_channel_type};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
fn neutralizer(values: Vec<(usize, f64)>) -> Fx {
|
||||
let range = |name| slider(K::FloatSlider, name, 0.0, (-100.0, 100.0));
|
||||
let group = |name| other(K::GroupStart, name, Default::None);
|
||||
let end = || other(K::GroupEnd, "", Default::None);
|
||||
Fx {
|
||||
match_name: "CS Color Neutralizer",
|
||||
name: "CC Color Neutralizer",
|
||||
defs: vec![
|
||||
other(K::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])),
|
||||
group("Shadows"),
|
||||
range("Red - Shadows"),
|
||||
range("Green - Shadows"),
|
||||
range("Blue - Shadows"),
|
||||
end(),
|
||||
other(K::Color, "Midtones Unbalance", Default::Color([255, 127, 127, 127])),
|
||||
group("Midtones"),
|
||||
range("Red - Midtones"),
|
||||
range("Green - Midtones"),
|
||||
range("Blue - Midtones"),
|
||||
end(),
|
||||
other(K::Color, "Highlights Unbalance", Default::Color([255, 255, 255, 255])),
|
||||
group("Highlights"),
|
||||
range("Red - Highlights"),
|
||||
range("Green - Highlights"),
|
||||
range("Blue - Highlights"),
|
||||
end(),
|
||||
slider(K::FloatSlider, "Pinning", 0.0, (0.0, 100.0)),
|
||||
slider(K::FloatSlider, "Blend w. Original", 0.0, (0.0, 100.0)),
|
||||
group("Special"),
|
||||
popup("View", 1, &["Final Result", "Correction B to W", "Correction Stair"]),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (0.0, 49.9)),
|
||||
slider(K::FloatSlider, "White Point", 100.0, (50.1, 100.0)),
|
||||
end(),
|
||||
],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_pinning_and_blend() {
|
||||
// `ccneutral-00-defaults` and the Pinning / Blend of `ccneutral-01-set-all`.
|
||||
let (g, r) = run(&neutralizer(vec![(19, 16.0), (20, 19.0)]), "ec.color.cccolorneutralizer");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "CS Color Neutralizer"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "pinning", 16.0);
|
||||
near(&g, "blendWOriginal", 19.0);
|
||||
// EffectCraft's own (identity) balance stays.
|
||||
assert_eq!(v(&g, "midtonesUnbalance"), v(&g, "midtonesBalance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_corrections_without_a_counterpart_are_reported() {
|
||||
// `ccneutral-03-sec-shadows`, `ccneutral-09-pop22-2`, `ccneutral-06-sec-special`.
|
||||
let (g, r) = run(&neutralizer(vec![(3, 13.0), (4, 16.0), (5, 19.0), (22, 2.0), (23, 10.0), (24, 87.0)]), "ec.color.cccolorneutralizer");
|
||||
let found = losses(&r, LossCategory::Effects, "CS Color Neutralizer");
|
||||
for name in ["Red - Shadows", "Green - Shadows", "Blue - Shadows", "View", "Black Point", "White Point"] {
|
||||
assert!(found.iter().any(|d| d.contains(&format!("parameter {name} (")) && d.contains("not imported")), "{name}: {found:?}");
|
||||
}
|
||||
assert_eq!(found.len(), 6, "{found:?}");
|
||||
assert!(g.is_some(), "the effect itself is imported");
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data with these record kinds and names.
|
||||
fn channels(recs: [(u32, &str); 4]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; ExtractorChannels::LEN];
|
||||
d[..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
for (k, (kind, name)) in recs.iter().enumerate() {
|
||||
let at = 32 + k * 164;
|
||||
d[at..at + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
d[at + 35..at + 35 + name.len()].copy_from_slice(name.as_bytes());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const DEFAULT_CHANNELS: [(u32, &str); 4] = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
|
||||
fn extractor(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "EXtractoR",
|
||||
name: "EXtractoR",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
other(K::GroupStart, "Process", Default::None),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
slider(K::FloatSlider, "White Point", 1.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
other(K::Checkbox, "", Default::Number(0.0)),
|
||||
other(K::GroupEnd, "", Default::None),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channel_info_layout() {
|
||||
let c = ExtractorChannels::parse(&channels(DEFAULT_CHANNELS)).expect("the default layout");
|
||||
assert!(c.is_default());
|
||||
let c = ExtractorChannels::parse(&channels([(1, "Z"), (1, "G"), (1, "B"), (2, "(copy)")])).expect("parses");
|
||||
assert!(!c.is_default());
|
||||
assert_eq!(c.channels[0], (1, "Z".to_string()));
|
||||
assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None);
|
||||
// The kind is byte 0 of a record: the definition default of `extractor-00-defaults` has
|
||||
// `02 00 00 32` on Alpha, still the input's own channel.
|
||||
let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses");
|
||||
assert!(c.is_default());
|
||||
assert_eq!(c.sources(), Ok(["R", "G", "B", "(copy)"].map(String::from)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_points_unmult_and_default_channels() {
|
||||
// `extractor-01-set-all`: Black Point 10, White Point 14, UnMult on.
|
||||
let (g, r) = run(&extractor(vec![(3, 10.0), (4, 14.0), (5, 1.0)], channels(DEFAULT_CHANNELS)), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "blackPoint", 10.0);
|
||||
near(&g, "whitePoint", 14.0);
|
||||
assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false)));
|
||||
// After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's `(copy)`, not `A` (an
|
||||
// OpenEXR's own alpha plane).
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `extractor-07-preset-channel-z` (a preset of our own): `Z` into Red, Green and Blue, Alpha
|
||||
/// `(copy)`. EffectCraft's EXtractoR reads the footage's channel by the same name.
|
||||
#[test]
|
||||
fn extractor_channels_are_imported_by_name() {
|
||||
let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
// `(copy)` on another channel is that channel of the input (EffectCraft's `(copy)` there).
|
||||
let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "(copy)"), ("green", "CryptoObject00.R"), ("blue", "(copy)"), ("alpha", "Z")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channels_of_an_unknown_kind_are_reported() {
|
||||
for recs in [[(1, "Z"), (3, "Z"), (1, "Z"), (2, "(copy)")], [(1, "Z"), (1, ""), (1, "Z"), (2, "(copy)")]] {
|
||||
let (g, r) = run(&extractor(vec![], channels(recs)), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("are not imported (record 2 is of an unknown kind or names no channel)"), "{recs:?}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into()), "{recs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read.
|
||||
fn id_info(name: &str) -> Vec<u8> {
|
||||
let mut d: Vec<u8> = (0..148u32).map(|i| (i * 37 % 251) as u8 | 1).collect();
|
||||
d[19..19 + name.len()].copy_from_slice(name.as_bytes());
|
||||
d[19 + name.len()] = 0;
|
||||
d
|
||||
}
|
||||
|
||||
fn identifier(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "IDentifier",
|
||||
name: "IDentifier",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
popup("Display", 1, &["Colors", "Luma Matte", "Alpha Matte", "Raw"]),
|
||||
slider(K::IntSlider, "ID", 0.0, (0.0, 2_147_483_647.0)),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_display_id_and_channel_type() {
|
||||
assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID"));
|
||||
// `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte): no channel chosen
|
||||
// (After Effects found none or none was picked), so After Effects leaves the layer unchanged.
|
||||
let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("no ID channel is chosen"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0)));
|
||||
near(&g, "id", 10.0);
|
||||
}
|
||||
|
||||
/// `identifier-09-preset-channel-objectid-id2` (1310, a preset of our own): the UINT channel
|
||||
/// `objectId` chosen and ID 2. EffectCraft's IDentifier reads that channel itself (`ObjectID`
|
||||
/// without regard to case), so nothing is lost; another name is reported.
|
||||
#[test]
|
||||
fn identifier_chosen_channels_by_name() {
|
||||
for (name, kind) in [("objectId", 0), ("ObjectID", 0), ("materialid", 1)] {
|
||||
let (g, r) = run(&identifier(vec![(3, 2.0)], id_info(name)), "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new(), "{name}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(kind), "{name}");
|
||||
near(&g, "id", 2.0);
|
||||
}
|
||||
for name in ["IndexOB.X", "ID", "Material ID"] {
|
||||
let (_, r) = run(&identifier(vec![], id_info(name)), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's data with the name of record `k` from byte 35 replaced by `name` (no NUL added; the
|
||||
/// rest of the record's name field is left as `fill`).
|
||||
fn channels_with_raw_name(k: usize, name: &[u8], fill: u8) -> Vec<u8> {
|
||||
let mut d = channels(DEFAULT_CHANNELS);
|
||||
let at = 32 + k * 164 + 35;
|
||||
d[at..at + 129].fill(fill);
|
||||
d[at..at + name.len()].copy_from_slice(name);
|
||||
d
|
||||
}
|
||||
|
||||
/// A channel name that never ends (no NUL up to the end of its record) or is not UTF-8 is
|
||||
/// refused by the decoder and reported at the importer; the defaults R, G, B, A stay.
|
||||
#[test]
|
||||
fn extractor_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let unterminated = channels_with_raw_name(2, b"Z", b'Z');
|
||||
let invalid = channels_with_raw_name(1, &[0x5a, 0xff, 0xfe, 0x00], 0);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(ExtractorChannels::parse(&data), None, "{what}");
|
||||
let (g, r) = run(&extractor(vec![], data), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{what}: {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for IDentifier: a channel name without NUL up to the end of the data, or in invalid
|
||||
/// UTF-8, is refused and reported; the Object ID channel stays.
|
||||
#[test]
|
||||
fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let mut unterminated = id_info("objectId");
|
||||
unterminated[19..].fill(b'x');
|
||||
let mut invalid = id_info("objectId");
|
||||
invalid[19..22].copy_from_slice(&[b'o', 0xff, 0]);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(identifier_channel_type(&data), None, "{what}");
|
||||
let (g, r) = run(&identifier(vec![], data), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "channelType"), Value::Enum(0), "{what}");
|
||||
}
|
||||
// Data too short to reach the name.
|
||||
assert_eq!(identifier_channel_type(&[1; 10]), None);
|
||||
}
|
||||
|
||||
/// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports,
|
||||
/// with the defaults, nothing panics, and the missing data is reported as damaged (it may have
|
||||
/// chosen other channels, so the defaults are not what After Effects renders).
|
||||
#[test]
|
||||
fn a_missing_channel_info_payload_is_reported_and_leaves_the_defaults() {
|
||||
let mut fx = extractor(vec![], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
let mut fx = identifier(vec![(3, 5.0)], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(0));
|
||||
near(&g, "id", 5.0);
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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<Option<&[f32]>> = 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<Option<&[f32]>> = 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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Option<&[f32]>> = 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<Option<&[f32]>> = 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]])?;
|
||||
|
||||
@@ -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())]);
|
||||
}
|
||||
|
||||
@@ -559,6 +559,36 @@ compositions, the items from one composition per popup item).** Each set value i
|
||||
| `ADBE Offset` | 1 Shift Center To: point, default the layer centre; 2 Blend With Original: fixed, **max 1 (a 0..1 fraction)**, default 0 |
|
||||
| `ADBE Deflicker` (Color Stabilizer) | 1 Stabilize: popup (Brightness, Levels, Curves), **default 2 (Levels)**; 2 Black Point, 3 Mid Point, 4 White Point: points, defaults 0.2 / 0.5 / 0.8 of the width at half the height; 5 Sample Size: fixed, max 50, default 3. The reference frame the Set Frame button picks is not among the parameters |
|
||||
|
||||
**Importer block 2026-11-05, #3112 (`1300-thirdparty.aep`; V for the parameter lists, H for the
|
||||
data layouts, read from default instances only):**
|
||||
|
||||
| Effect (match name, plug-in version) | Parameters (`parT` index: type, valid range, default) |
|
||||
|---|---|
|
||||
| `CS Color Neutralizer` (CC Color Neutralizer, 1.3) | 1 / 7 / 13 Shadows / Midtones / Highlights Unbalance: colours (black, 127 grey, white; pickers: setting one sets the range's sliders, e.g. [0.27, 0.55, 0.85] gives Red 42.94, Green 1.18, Blue -44.12 = (mean - channel) x 150 / 255 on 8-bit values, and leaves no colour record); 2-6, 8-12, 14-18 a group per range (start, Red, Green, Blue, end) with float sliders -100..100 (slider -50..50), default 0; 19 Pinning: float 0..100 (slider 0..50); 20 Blend w. Original: float 0..100; 21-25 the Special group: 22 View popup (Final Result, Correction B to W, Correction Stair), default 1, 23 Black Point float 0..49.9, default 0, 24 White Point float 50.1..100, default 100 |
|
||||
| `EXtractoR` (2.6.6) | 1 Channel Info (`EXtractoR-0013`): arbitrary data, 688 bytes, below; 2-6 the Process group: 3 Black Point and 4 White Point float sliders, valid ±3.4028235e38 (the float32 range; slider ±1 000), default 0 / 1; 5 UnMult: checkbox (empty `pard` name, the label in `pdnm`), default off |
|
||||
| `IDentifier` (2.6.6) | 1 Channel Info (`IDentifier-0005`): arbitrary data, 148 bytes, below; 2 Display: popup (Colors, Luma Matte, Alpha Matte, Raw), default 1; 3 ID: integer slider, max 2 147 483 647, default 0 |
|
||||
|
||||
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. 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
|
||||
1 with its name (`Z`), and the `u32` at record byte 4 is then 13, which looks like the channel's
|
||||
position in the EXR's sorted channel list of 14 (the defaults hold 0 there: H). IDentifier's data
|
||||
differs between instances with the same settings except for **the chosen channel's name**,
|
||||
NUL-terminated from byte 19, which is `Object ID` when no channel is chosen. After Effects lists
|
||||
only integer (UINT) channels: in 1300's EXR (a FLOAT `ID` channel) it found none ("No ID channels
|
||||
available"), so that block's preset `identifier-09-preset-channel-id` only sets ID 2; in 1310's
|
||||
EXR (UINT `objectId` and `IndexOB.X` added) the preset `identifier-09-preset-channel-objectid-id2`
|
||||
stores `objectId`, with 15 at byte 4 (the channel's position in the sorted list of 16, as for
|
||||
EXtractoR: H). Without a chosen channel After Effects leaves the layer unchanged (1300 and 1310
|
||||
defaults). The other bytes are not read. API: `ExtractorChannels`,
|
||||
`identifier_channel_type`.
|
||||
|
||||
**Curves and Mesh Warp** (961-965, 985; applied from presets, the data is not scriptable): their
|
||||
arbitrary-data parameter (type 11) always has a record (`tdbs` with a 4-byte `cdat`) and a `LIST
|
||||
aRbs` / `aRbp`, also at the default. Curves (`ADBE CurvesCustom-0001`, 1644 bytes): `u16` 1, 1;
|
||||
|
||||
@@ -180,6 +180,7 @@ parameter gets the file's value:
|
||||
| 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 `<hex>` 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 |
|
||||
| Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: each output channel by the footage channel's name, which EffectCraft's EXtractoR reads by the same name (the default R, G, B; `Z` from our preset), and `(copy)` as the input's own channel of that position (EffectCraft's R, G, B or A; the default Alpha is A); a record of another kind or without a name is reported and R, G, B, A used (`ExtractorChannels`), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names the chosen channel (`identifier_channel_type`): After Effects lists only the footage's integer (UINT) channels, and with none chosen (the data then says `Object ID`) it leaves the layer unchanged, where EffectCraft draws the footage's object ID channel: **reported**; a chosen channel named `ObjectID` or `MaterialID` (without regard to case, `objectId` in our preset) is the one EffectCraft's IDentifier reads itself (Channel Type Object ID or Material ID), any other name is reported (EffectCraft's IDentifier has no channel choice) | `tests/it/import/effects_thirdparty.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 |
|
||||
|
||||
- **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons
|
||||
@@ -352,6 +353,26 @@ ranges of Lumetri Color among them), are those of the 2026 plug-in in all three
|
||||
over 25 (EffectCraft's samples drift, After Effects' frame stays unchanged) and with keyed
|
||||
sample points. The renders do not exercise a Decay above 1 (After Effects' set value has
|
||||
Starting Intensity 0) or what Color Stabilizer corrects (the test card does not flicker).
|
||||
- `crates/aep/tests/it/import/effects_thirdparty.rs` (#3112): CC Color Neutralizer, EXtractoR and
|
||||
IDentifier built into `synthetic.aep` like the #3110 effects. Render check of the 29 compositions
|
||||
of `1300-thirdparty.aep` (as above): CC Color Neutralizer at its defaults and the minimum and
|
||||
maximum compositions match within one level (no composition isolates Pinning: the maximum has
|
||||
Blend w. Original 100, which shows the original), the compositions with reported Red /
|
||||
Green / Blue corrections or View differ as reported (up to 5 levels, View 25); EXtractoR at its
|
||||
defaults and with UnMult within 3 levels on 6 % of the pixels, keyed and expression Black Point
|
||||
within one level, the ±3.4e38 compositions differ (reported as damaged data). IDentifier: After
|
||||
Effects' frame is the unchanged EXR in every variant (its default Object ID finds no channel in
|
||||
that EXR), while EffectCraft's IDentifier finds the EXR's `ID` channel and draws it; the import
|
||||
reports this for every IDentifier. The follow-up presets (our own): `extractor-07-preset-channel-z`
|
||||
(`Z` into Red, Green and Blue) imports without a loss and renders equal to After Effects (within
|
||||
3e-5 at After Effects' 1/10 PNG scale; the background's Z of 100 clips there in both). IDentifier
|
||||
needs an integer channel: block 1310 (`1310-identifier-uint.aep`, the EXR with UINT channels
|
||||
`objectId` and `IndexOB.X` added) with the preset `identifier-09-preset-channel-objectid-id2`
|
||||
(`objectId`, ID 2) imports without a loss, and EffectCraft's IDentifier finds the same channel:
|
||||
the ID regions are the same pixels (8 449, 2 400, 1 515, 1 256, 780), the colours differ (After
|
||||
Effects draws flat colours per ID and does not highlight the chosen ID, EffectCraft draws its
|
||||
own ID colours with the chosen ID white). At its defaults (no channel chosen) After Effects
|
||||
leaves the EXR unchanged in 1310 too.
|
||||
- `crates/aep/tests/it/import/combined.rs`: masks and effects on the same layer, an effect on a text
|
||||
layer, and the same through `.aepx` (built in-test from the fixtures: the effect parade of one
|
||||
fixture grafted onto the layer of another).
|
||||
|
||||
Reference in New Issue
Block a user