mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
.aep import: map HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer (#3119)
* AEP import: map HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer The importer reported these five effects as lost. They now become EffectCraft's effects of the same name with every parameter After Effects stores (ranges, defaults and popup items from our own After Effects 2026 project, documented in docs/aep-format.md 3.7): - ADBE HDR ToneMap -> ec.utility.hdrcompression (Amount) - ADBE Echo -> ec.time.echo (Echo Time, Number Of Echoes, Starting Intensity, Decay, Echo Operator by item name) - ADBE Linear Wipe -> ec.transition.linearwipe - ADBE Offset -> ec.distort.offset (Blend With Original is a stored 0..1 fraction, a percentage here) - ADBE Deflicker -> ec.color.colorstabilizer (fresh Stabilize default Levels, points, Sample Size) A table row can name the range EffectCraft's effect works in (ParamMap::limit): Echo values or keys beyond 255 echoes or a Decay of 1 are imported as stored and reported. Tests build each effect into the committed synthetic.aep (fx_inject.rs, which also checks its pard layout reads back). On the sample project the effect losses go from 75 to 3 (the out-of-range Echo values). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AEP import: doc comments on the quick-win test helpers and tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AEP import: Echo keyframe regression for the range loss Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fee50dbfd1
commit
ab3674cc2b
8 files changed
+655
-3
No files matched your search
@@ -121,6 +121,10 @@ pub struct ParamMap {
|
||||
/// The EffectCraft parameter id.
|
||||
pub target: &'static str,
|
||||
pub conv: Conv,
|
||||
/// The range EffectCraft's effect works in when it is narrower than the range After Effects
|
||||
/// accepts (the effect limits the value there): a stored value or key outside it is imported
|
||||
/// as it is and reported. `None`: no narrower range.
|
||||
pub limit: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Values of an effect that are not ordinary parameters.
|
||||
@@ -148,7 +152,12 @@ pub struct EffectMap {
|
||||
}
|
||||
|
||||
const fn pm(index: usize, kind: ParamKind, target: &'static str, conv: Conv) -> ParamMap {
|
||||
ParamMap { index, kind, target, conv }
|
||||
ParamMap { index, kind, target, conv, limit: None }
|
||||
}
|
||||
|
||||
/// [`pm`] for a parameter EffectCraft's effect limits to `lo..=hi` (see [`ParamMap::limit`]).
|
||||
const fn pm_limited(index: usize, kind: ParamKind, target: &'static str, conv: Conv, lo: f64, hi: f64) -> ParamMap {
|
||||
ParamMap { index, kind, target, conv, limit: Some((lo, hi)) }
|
||||
}
|
||||
|
||||
const fn fx(match_name: &'static str, effect: &'static str, params: &'static [ParamMap]) -> EffectMap {
|
||||
@@ -340,6 +349,13 @@ pub const MATTE_USE: &[&str] = &["Red Channel", "Green Channel", "Blue Channel",
|
||||
/// 903.
|
||||
pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"];
|
||||
|
||||
// After Effects' popup items of the #3110 effects as the 2026-11-05 importer block stores them
|
||||
// (`pdnm`; the `echo-*` and `colstab-*` compositions).
|
||||
/// Echo's Echo Operator (`echo-05` ... `echo-10`).
|
||||
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"];
|
||||
|
||||
/// The effects the importer converts.
|
||||
pub const EFFECTS: &[EffectMap] = &[
|
||||
// Tint (356-364): Map Black To, Map White To, Amount to Tint; 4 = Swap Colors (a button).
|
||||
@@ -1007,6 +1023,50 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pm(4, K::FloatSlider, "vectorDetail", Conv::Plain { valid_max: 100.0 }),
|
||||
],
|
||||
),
|
||||
// HDR Highlight Compression (importer block 2026-11-05, `hdr-*`): Amount a float slider in
|
||||
// percent (valid 0..100, default 100).
|
||||
fx("ADBE HDR ToneMap", "ec.utility.hdrcompression", &[pm(1, K::FloatSlider, "amount", Conv::Plain { valid_max: 100.0 })]),
|
||||
// Echo (`echo-*`): Echo Time in seconds (valid -30 000..30 000), Number Of Echoes an integer
|
||||
// (valid 0..30 000), Starting Intensity 0..1, Decay (valid 0..100, slider 0..1), Echo
|
||||
// Operator. EffectCraft's Echo uses at most 255 echoes and a Decay up to 1.
|
||||
fx(
|
||||
"ADBE Echo",
|
||||
"ec.time.echo",
|
||||
&[
|
||||
pm(1, K::FloatSlider, "echoTime", Conv::Plain { valid_max: 30000.0 }),
|
||||
pm_limited(2, K::IntSlider, "numberOfEchoes", Conv::Plain { valid_max: 30000.0 }, 0.0, 255.0),
|
||||
pm(3, K::FloatSlider, "startingIntensity", Conv::Plain { valid_max: 1.0 }),
|
||||
pm_limited(4, K::FloatSlider, "decay", Conv::Plain { valid_max: 100.0 }, 0.0, 1.0),
|
||||
pm(5, K::Popup, "echoOperator", Conv::Popup { default: 1, items: ECHO_OPERATOR }),
|
||||
],
|
||||
),
|
||||
// Linear Wipe (`linwipe-*`): Transition Completion and Feather fixed sliders (valid 100 and
|
||||
// 32 000), Wipe Angle in degrees.
|
||||
fx(
|
||||
"ADBE Linear Wipe",
|
||||
"ec.transition.linearwipe",
|
||||
&[
|
||||
pm(1, K::FixedSlider, "completion", Conv::Plain { valid_max: 100.0 }),
|
||||
pm(2, K::Angle, "angle", Conv::Angle),
|
||||
pm(3, K::FixedSlider, "feather", Conv::Plain { valid_max: 32000.0 }),
|
||||
],
|
||||
),
|
||||
// Offset (`offset-*`): Blend With Original is a 0..1 fraction, a percentage here.
|
||||
fx("ADBE Offset", "ec.distort.offset", &[pm(1, K::Point, "shift", Conv::Point), pm(2, K::FixedSlider, "blend", Conv::Percent { valid_max: 1.0 })]),
|
||||
// Color Stabilizer (`colstab-*`): Stabilize (fresh default Levels), the three sample points,
|
||||
// Sample Size a fixed slider (valid 50, default 3). The reference frame (Set Frame) is not a
|
||||
// parameter in the file and is not imported.
|
||||
fx(
|
||||
"ADBE Deflicker",
|
||||
"ec.color.colorstabilizer",
|
||||
&[
|
||||
pm(1, K::Popup, "stabilize", Conv::Popup { default: 2, items: STABILIZE }),
|
||||
pm(2, K::Point, "blackPoint", Conv::Point),
|
||||
pm(3, K::Point, "midPoint", Conv::Point),
|
||||
pm(4, K::Point, "whitePoint", Conv::Point),
|
||||
pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }),
|
||||
],
|
||||
),
|
||||
// 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 })]),
|
||||
|
||||
@@ -268,6 +268,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
if let Some(map) = &popup_map {
|
||||
remap_popup_keys(prop, map, &here, &label, col);
|
||||
}
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -302,11 +303,24 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
_ => convert(value, kind),
|
||||
};
|
||||
match converted {
|
||||
Some(v) => prop.value = v,
|
||||
Some(v) => {
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col);
|
||||
prop.value = v;
|
||||
}
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports a value After Effects accepts but EffectCraft's effect limits (`ParamMap::limit`): it is
|
||||
/// imported as it is, and the effect renders it as the nearest end of its range.
|
||||
fn report_beyond_limit<'v>(limit: Option<(f64, f64)>, values: impl Iterator<Item = &'v Value>, here: &str, label: &str, col: &mut Collector) {
|
||||
let Some((lo, hi)) = limit else { return };
|
||||
let beyond: Vec<f64> = values.filter_map(|v| if let Value::Scalar(x) = v { Some(*x) } else { None }).filter(|x| *x < lo || *x > hi).collect();
|
||||
if let Some(x) = beyond.first() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is {x}, EffectCraft's effect uses {lo} to {hi} (rendered as the nearest of them)"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer parameter: None stays unset, the owning layer is itself (`own`) or unset, another layer
|
||||
/// is resolved by [`resolve_layer_refs`] once the composition's layers exist.
|
||||
/// `names` is `(here, label)`, the effect and the parameter for the report.
|
||||
|
||||
@@ -11,8 +11,10 @@ mod combined;
|
||||
mod ease_round2;
|
||||
mod effects;
|
||||
mod effects_batch5;
|
||||
mod effects_quickwins;
|
||||
mod effects_round2;
|
||||
mod effects_round3;
|
||||
mod fx_inject;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod mattes;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
|
||||
] {
|
||||
assert!(t.contains(&want), "{want:?}");
|
||||
}
|
||||
assert_eq!(t.len(), 57);
|
||||
assert_eq!(t.len(), 62);
|
||||
for (_, id) in t {
|
||||
assert!(effectcraft_effects::find(id).is_some(), "{id}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer (#3110) 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 (valid
|
||||
//! ranges, defaults, popup items) and the values of that block's compositions.
|
||||
|
||||
use crate::import::fx_inject::{Default, Fx, losses, near, num, other, popup, pt, run, slider, v};
|
||||
use effectcraft_aep::ParamKind as K;
|
||||
use effectcraft_aep::import::LossCategory;
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
const ECHO_OPS: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"];
|
||||
|
||||
/// Echo with the definitions After Effects 2026 wrote and the stored `values`.
|
||||
fn echo(values: Vec<(usize, f64)>) -> Fx {
|
||||
Fx {
|
||||
match_name: "ADBE Echo",
|
||||
name: "Echo",
|
||||
defs: vec![
|
||||
slider(K::FloatSlider, "Echo Time (seconds)", -1.0 / 30.0, (-30000.0, 30000.0)),
|
||||
slider(K::IntSlider, "Number Of Echoes", 1.0, (0.0, 30000.0)),
|
||||
slider(K::FloatSlider, "Starting Intensity", 1.0, (0.0, 1.0)),
|
||||
slider(K::FloatSlider, "Decay", 1.0, (0.0, 100.0)),
|
||||
popup("Echo Operator", 1, ECHO_OPS),
|
||||
],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR Highlight Compression's Amount, set and default.
|
||||
#[test]
|
||||
fn hdr_highlight_compression_amount() {
|
||||
let fx = |values| Fx {
|
||||
match_name: "ADBE HDR ToneMap",
|
||||
name: "HDR Highlight Compression",
|
||||
defs: vec![slider(K::FloatSlider, "Amount", 100.0, (0.0, 100.0))],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
};
|
||||
// `hdr-01-set-all`: 90; `hdr-00-defaults`: no record, 100.
|
||||
for (values, want) in [(vec![(1, 90.0)], 90.0), (vec![], 100.0)] {
|
||||
let (g, r) = run(&fx(values), "ec.utility.hdrcompression");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "ADBE HDR ToneMap"), Vec::<String>::new());
|
||||
near(&g.expect("imported"), "amount", want);
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo's values and operator, set and default.
|
||||
#[test]
|
||||
fn echo_values_and_operator() {
|
||||
// `echo-01-set-all` (without its Decay 20, below) and `echo-09-pop5-6`.
|
||||
let (g, r) = run(&echo(vec![(1, 9.967), (2, 14.0), (3, 0.0), (5, 6.0)]), "ec.time.echo");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "ADBE Echo"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "echoTime", 9.967);
|
||||
near(&g, "numberOfEchoes", 14.0);
|
||||
near(&g, "startingIntensity", 0.0);
|
||||
near(&g, "decay", 1.0);
|
||||
assert_eq!(v(&g, "echoOperator"), Value::Enum(5), "Composite In Front");
|
||||
// `echo-00-defaults`: one echo a frame back (1/30 s), Add.
|
||||
let (g, _) = run(&echo(vec![]), "ec.time.echo");
|
||||
let g = g.expect("imported");
|
||||
assert!((num(&g, "echoTime") + 1.0 / 30.0).abs() < 1e-6);
|
||||
assert_eq!((num(&g, "numberOfEchoes"), v(&g, "echoOperator")), (1.0, Value::Enum(0)));
|
||||
}
|
||||
|
||||
/// Echo values beyond EffectCraft's ranges are imported as stored and reported.
|
||||
#[test]
|
||||
fn echo_values_beyond_effectcrafts_range_are_reported() {
|
||||
// `echo-03-max`: 30 000 echoes and Decay 100; EffectCraft's Echo uses at most 255 and 1.
|
||||
let (g, r) = run(&echo(vec![(2, 30000.0), (4, 100.0)]), "ec.time.echo");
|
||||
let found = losses(&r, LossCategory::Effects, "ADBE Echo");
|
||||
assert_eq!(found.len(), 2, "{found:?}");
|
||||
assert!(found.iter().any(|d| d.contains("Number Of Echoes") && d.contains("0 to 255")), "{found:?}");
|
||||
assert!(found.iter().any(|d| d.contains("Decay") && d.contains("0 to 1")), "{found:?}");
|
||||
// Kept as stored.
|
||||
let g = g.expect("still imported");
|
||||
near(&g, "numberOfEchoes", 30000.0);
|
||||
near(&g, "decay", 100.0);
|
||||
}
|
||||
|
||||
/// A keyframed Echo parameter keeps its keys as stored and reports the range loss.
|
||||
#[test]
|
||||
fn echo_keys_beyond_effectcrafts_range_are_kept_and_reported() {
|
||||
// Number Of Echoes keyed 10 -> 30 000: the second key is beyond EffectCraft's 255.
|
||||
let mut fx = echo(vec![]);
|
||||
fx.keys = vec![(2, vec![(0, 10.0), (1000, 30000.0)])];
|
||||
let (g, r) = run(&fx, "ec.time.echo");
|
||||
let found = losses(&r, LossCategory::Effects, "ADBE Echo");
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert!(found[0].contains("Number Of Echoes") && found[0].contains("30000") && found[0].contains("0 to 255"), "{found:?}");
|
||||
assert!(losses(&r, LossCategory::DamagedData, "ADBE Echo").is_empty());
|
||||
// Both keys arrive with their values.
|
||||
let g = g.expect("still imported");
|
||||
let keys = &g.prop("numberOfEchoes").expect("numberOfEchoes").keys;
|
||||
let values: Vec<Value> = keys.iter().map(|k| k.value.clone()).collect();
|
||||
assert_eq!(values, [Value::Scalar(10.0), Value::Scalar(30000.0)]);
|
||||
}
|
||||
|
||||
/// Linear Wipe's values, set and default.
|
||||
#[test]
|
||||
fn linear_wipe_values() {
|
||||
let fx = |values| Fx {
|
||||
match_name: "ADBE Linear Wipe",
|
||||
name: "Linear Wipe",
|
||||
defs: vec![
|
||||
slider(K::FixedSlider, "Transition Completion", 0.0, (0.0, 100.0)),
|
||||
other(K::Angle, "Wipe Angle", Default::Number(90.0)),
|
||||
slider(K::FixedSlider, "Feather", 0.0, (0.0, 32000.0)),
|
||||
],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
};
|
||||
// `linwipe-01-set-all`.
|
||||
let (g, r) = run(&fx(vec![(1, 10.0), (2, 77.0), (3, 16.0)]), "ec.transition.linearwipe");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "ADBE Linear Wipe"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "completion", 10.0);
|
||||
near(&g, "angle", 77.0);
|
||||
near(&g, "feather", 16.0);
|
||||
// `linwipe-00-defaults`: the Wipe Angle default 90.
|
||||
near(&run(&fx(vec![]), "ec.transition.linearwipe").0.expect("imported"), "angle", 90.0);
|
||||
}
|
||||
|
||||
/// Offset's Blend With Original as a percentage and its centre as a point.
|
||||
#[test]
|
||||
fn offset_blend_is_a_fraction_and_the_centre_a_point() {
|
||||
let fx = |values| Fx {
|
||||
match_name: "ADBE Offset",
|
||||
name: "Offset",
|
||||
defs: vec![other(K::Point, "Shift Center To", Default::Point(0.5, 0.5)), slider(K::FixedSlider, "Blend With Original", 0.0, (0.0, 1.0))],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
};
|
||||
// `offset-02-chk-toggled`: Blend With Original stored 1.0 is 100 %.
|
||||
let (g, r) = run(&fx(vec![(2, 1.0)]), "ec.distort.offset");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "ADBE Offset"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "blend", 100.0);
|
||||
// The default centre is the layer's centre (synthetic.aep's layer is 8x8).
|
||||
pt(&g, "shift", [4.0, 4.0]);
|
||||
near(&run(&fx(vec![(2, 0.25)]), "ec.distort.offset").0.expect("imported"), "blend", 25.0);
|
||||
}
|
||||
|
||||
/// Color Stabilizer's points, Sample Size and Stabilize, default and set.
|
||||
#[test]
|
||||
fn color_stabilizer_points_sample_size_and_stabilize() {
|
||||
let fx = |values| Fx {
|
||||
match_name: "ADBE Deflicker",
|
||||
name: "Color Stabilizer",
|
||||
defs: vec![
|
||||
popup("Stabilize", 2, &["Brightness", "Levels", "Curves"]),
|
||||
other(K::Point, "Black Point", Default::Point(0.2, 0.5)),
|
||||
other(K::Point, "Mid Point", Default::Point(0.5, 0.5)),
|
||||
other(K::Point, "White Point", Default::Point(0.8, 0.5)),
|
||||
slider(K::FixedSlider, "Sample Size", 3.0, (0.0, 50.0)),
|
||||
],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
};
|
||||
// `colstab-00-defaults`: Levels, the points at 20 %, 50 % and 80 % of the width.
|
||||
let (g, r) = run(&fx(vec![]), "ec.color.colorstabilizer");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "ADBE Deflicker"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "stabilize"), Value::Enum(1), "Levels");
|
||||
pt(&g, "blackPoint", [1.6, 4.0]);
|
||||
pt(&g, "whitePoint", [6.4, 4.0]);
|
||||
near(&g, "sampleSize", 3.0);
|
||||
// `colstab-03-pop1-3` and `colstab-27-pop5-25`.
|
||||
let (g, _) = run(&fx(vec![(1, 3.0), (5, 25.0)]), "ec.color.colorstabilizer");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "stabilize"), Value::Enum(2), "Curves");
|
||||
near(&g, "sampleSize", 25.0);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Builds an effect instance into the committed `synthetic.aep` in place of its Glow (the instance
|
||||
//! on the layer and the project's shared definitions), so an effect mapping can be tested without
|
||||
//! the external corpus. The parameter definitions are written in the `pard` layout of
|
||||
//! docs/aep-format.md 3.7 (type at byte 15, name at 16, default at 56, a float slider's valid
|
||||
//! range as `f32` at 104 / 108 and its slider range at 112 / 116, a fixed or integer slider's valid
|
||||
//! maximum at 128, a popup's item count at 60); `pard_layout_decodes_as_written` reads them back.
|
||||
//! The ranges and defaults the tests use are the ones After Effects 2026 wrote in the importer
|
||||
//! block of 2026-11-05 (`1100-quickwins.aep`, `1300-thirdparty.aep`, own projects).
|
||||
|
||||
use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import};
|
||||
use effectcraft_aep::{Body, Chunk, ParamKind, ParamValue, Project as AepProject, parse, write};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Project, PropGroup};
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
|
||||
/// A parameter definition.
|
||||
#[derive(Clone)]
|
||||
pub struct Def {
|
||||
pub kind: ParamKind,
|
||||
pub name: &'static str,
|
||||
pub default: Default,
|
||||
/// A float slider's valid range, a fixed or integer slider's valid maximum (`.1`).
|
||||
pub valid: (f64, f64),
|
||||
/// A popup's items.
|
||||
pub items: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// The default a definition holds at byte 56.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Default {
|
||||
None,
|
||||
Number(f64),
|
||||
Index(u32),
|
||||
Point(f64, f64),
|
||||
/// ARGB 0..255.
|
||||
Color([u8; 4]),
|
||||
}
|
||||
|
||||
/// The parameter type code `pard` byte 15 holds for `k`.
|
||||
fn kind_code(k: ParamKind) -> u8 {
|
||||
match k {
|
||||
ParamKind::Layer => 0,
|
||||
ParamKind::IntSlider => 1,
|
||||
ParamKind::FixedSlider => 2,
|
||||
ParamKind::Angle => 3,
|
||||
ParamKind::Checkbox => 4,
|
||||
ParamKind::Color => 5,
|
||||
ParamKind::Point => 6,
|
||||
ParamKind::Popup => 7,
|
||||
ParamKind::NoData => 9,
|
||||
ParamKind::FloatSlider => 10,
|
||||
ParamKind::ArbitraryData => 11,
|
||||
ParamKind::GroupStart => 13,
|
||||
ParamKind::GroupEnd => 14,
|
||||
ParamKind::Button => 15,
|
||||
_ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as a big-endian 16.16 fixed-point value.
|
||||
fn fixed(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round() as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
/// The 148-byte parameter definition (`pard`) of `d`: type, name, default and range.
|
||||
pub fn pard(d: &Def) -> Chunk<'static> {
|
||||
let mut b = vec![0u8; 148];
|
||||
b[15] = kind_code(d.kind);
|
||||
b[16..16 + d.name.len()].copy_from_slice(d.name.as_bytes());
|
||||
match (d.kind, d.default) {
|
||||
(ParamKind::FloatSlider, Default::Number(v)) => b[56..64].copy_from_slice(&v.to_be_bytes()),
|
||||
(ParamKind::FixedSlider | ParamKind::Angle, Default::Number(v)) => b[56..60].copy_from_slice(&fixed(v)),
|
||||
(ParamKind::IntSlider | ParamKind::Checkbox, Default::Number(v)) => b[56..60].copy_from_slice(&(v as i32).to_be_bytes()),
|
||||
(ParamKind::Popup, Default::Index(i)) => b[56..60].copy_from_slice(&i.to_be_bytes()),
|
||||
(ParamKind::Point, Default::Point(x, y)) => {
|
||||
b[56..60].copy_from_slice(&fixed(x));
|
||||
b[60..64].copy_from_slice(&fixed(y));
|
||||
}
|
||||
(ParamKind::Color, Default::Color(c)) => b[56..60].copy_from_slice(&c),
|
||||
_ => {}
|
||||
}
|
||||
match d.kind {
|
||||
ParamKind::FloatSlider => {
|
||||
b[104..108].copy_from_slice(&(d.valid.0 as f32).to_be_bytes());
|
||||
b[108..112].copy_from_slice(&(d.valid.1 as f32).to_be_bytes());
|
||||
b[112..116].copy_from_slice(&(d.valid.0 as f32).to_be_bytes());
|
||||
b[116..120].copy_from_slice(&(d.valid.1 as f32).to_be_bytes());
|
||||
}
|
||||
ParamKind::FixedSlider => b[128..132].copy_from_slice(&fixed(d.valid.1)),
|
||||
ParamKind::IntSlider => b[128..132].copy_from_slice(&(d.valid.1 as i32).to_be_bytes()),
|
||||
ParamKind::Popup => b[60..62].copy_from_slice(&(d.items.len() as u16).to_be_bytes()),
|
||||
_ => {}
|
||||
}
|
||||
Chunk::data(*b"pard", b)
|
||||
}
|
||||
|
||||
/// A match name chunk (`tdmn`, 40 bytes).
|
||||
pub fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = name.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
/// A UTF-8 string chunk (`Utf8`).
|
||||
fn utf8(s: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`).
|
||||
fn nested(id: [u8; 4], children: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
Chunk { id, body: Body::Chunks(children), pad: None }
|
||||
}
|
||||
|
||||
/// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint
|
||||
/// (its header; without the expression).
|
||||
fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
let mut kids = Vec::new();
|
||||
for c in template.children() {
|
||||
match &c.body {
|
||||
Body::Data(d) if c.id == *b"tdsb" || 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)
|
||||
}
|
||||
|
||||
/// A keyframed value record: `record`'s header with a key list of linear one-component keys.
|
||||
fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
|
||||
let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") };
|
||||
let mut children = children;
|
||||
let mut lhd3 = vec![0u8; 52];
|
||||
lhd3[0..4].copy_from_slice(&0x00d0_0beeu32.to_be_bytes());
|
||||
lhd3[8..12].copy_from_slice(&(keys.len() as u32).to_be_bytes());
|
||||
lhd3[16..20].copy_from_slice(&48u32.to_be_bytes());
|
||||
let mut ldat = Vec::new();
|
||||
for (time, value) in keys {
|
||||
let mut rec = vec![0u8; 48];
|
||||
rec[0..4].copy_from_slice(&time.to_be_bytes());
|
||||
rec[4] = 1;
|
||||
rec[5] = 1;
|
||||
rec[8..16].copy_from_slice(&value.to_be_bytes());
|
||||
ldat.extend(rec);
|
||||
}
|
||||
children.push(Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", ldat)]));
|
||||
Chunk::list(*b"tdbs", children)
|
||||
}
|
||||
|
||||
/// An effect instance: its match name, display name, parameter definitions (after the input
|
||||
/// layer, before the built-in entry), the 1-D values stored (`parT` index, value) and the
|
||||
/// arbitrary data stored (`parT` index, bytes).
|
||||
pub struct Fx {
|
||||
pub match_name: &'static str,
|
||||
pub name: &'static str,
|
||||
pub defs: Vec<Def>,
|
||||
pub values: Vec<(usize, f64)>,
|
||||
pub data: Vec<(usize, Vec<u8>)>,
|
||||
/// Keyframed 1-D parameters (`parT` index, (time units, value) per key), linear keys; the
|
||||
/// parameter then has no entry in `values`.
|
||||
pub keys: Vec<(usize, Vec<(i32, f64)>)>,
|
||||
}
|
||||
|
||||
/// The match name of parameter `i` of `fx` (`<match>-NNNN`).
|
||||
fn param_name(fx: &Fx, i: usize) -> String {
|
||||
format!("{}-{i:04}", fx.match_name)
|
||||
}
|
||||
|
||||
/// The effect's `sspc`: its name, the parameter definitions (`parT`) and the stored values
|
||||
/// (`tdgp`), each value record copied from `template`.
|
||||
fn sspc(fx: &Fx, template: &Chunk<'_>) -> Chunk<'static> {
|
||||
let n = fx.defs.len() + 2;
|
||||
let mut part = vec![
|
||||
Chunk::data(*b"parn", (n as u32).to_be_bytes().to_vec()),
|
||||
tdmn(¶m_name(fx, 0)),
|
||||
pard(&Def { kind: ParamKind::Layer, name: "", default: Default::None, valid: (0.0, 0.0), items: &[] }),
|
||||
];
|
||||
for (i, d) in fx.defs.iter().enumerate() {
|
||||
part.push(tdmn(¶m_name(fx, i + 1)));
|
||||
part.push(pard(d));
|
||||
if d.kind == ParamKind::Popup {
|
||||
part.push(nested(*b"pdnm", vec![utf8(&d.items.join("|"))]));
|
||||
}
|
||||
}
|
||||
part.push(tdmn("ADBE Effect Built In Params"));
|
||||
part.push(pard(&Def { kind: ParamKind::NoData, name: "", default: Default::None, valid: (0.0, 0.0), items: &[] }));
|
||||
let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())];
|
||||
for (i, v) in &fx.values {
|
||||
values.push(tdmn(¶m_name(fx, *i)));
|
||||
values.push(record(template, *v));
|
||||
if let Some((_, d)) = fx.data.iter().find(|(j, _)| j == i) {
|
||||
values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d.clone())]));
|
||||
}
|
||||
}
|
||||
for (i, keys) in &fx.keys {
|
||||
values.push(tdmn(¶m_name(fx, *i)));
|
||||
values.push(keyed_record(template, keys));
|
||||
}
|
||||
Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8(fx.name)]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
/// The value record of Glow's A & B Midpoint (`ADBE Glo2-0011`): a 1-D static record.
|
||||
fn find_glow_template(chunks: &[Chunk<'_>]) -> Option<Chunk<'static>> {
|
||||
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(Chunk::list(
|
||||
*b"tdbs",
|
||||
record
|
||||
.children()
|
||||
.iter()
|
||||
.map(|k| match &k.body {
|
||||
Body::Data(d) => Chunk::data(k.id, d.to_vec()),
|
||||
_ => Chunk::data(k.id, Vec::new()),
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
if let Some(t) = find_glow_template(kids) {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize {
|
||||
let mut n = 0;
|
||||
for ch in chunks {
|
||||
let kids = ch.children_mut();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
|
||||
&& kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
|
||||
{
|
||||
kids[at] = tdmn(fx.match_name);
|
||||
kids[at + 1] = sspc(fx, template);
|
||||
n += 1;
|
||||
}
|
||||
n += replace_glow(kids, fx, template);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `fx` in place of the Glow effect.
|
||||
pub fn bytes_with(fx: &Fx) -> 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, fx, &template), 2, "the Glow effect and its shared definitions");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if
|
||||
/// imported) and the result.
|
||||
pub fn run(fx: &Fx, effect: &str) -> (Option<PropGroup>, ImportResult) {
|
||||
let bytes = bytes_with(fx);
|
||||
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 == effect).cloned()));
|
||||
(g, r)
|
||||
}
|
||||
|
||||
/// The losses of `category` that name the effect `match_name`.
|
||||
pub fn losses(r: &ImportResult, category: LossCategory, match_name: &str) -> Vec<String> {
|
||||
let tag = format!("({match_name})");
|
||||
r.report.losses.iter().filter(|l| l.category == category).flat_map(|l| l.details.clone()).filter(|d| d.contains(&tag)).collect()
|
||||
}
|
||||
|
||||
/// The value of property `id` of `g` (panics when it is missing).
|
||||
pub fn v(g: &PropGroup, id: &str) -> Value {
|
||||
g.prop(id).unwrap_or_else(|| panic!("no {id} in {}", g.match_id)).value.clone()
|
||||
}
|
||||
|
||||
/// The scalar value of property `id` of `g`.
|
||||
pub fn num(g: &PropGroup, id: &str) -> f64 {
|
||||
match v(g, id) {
|
||||
Value::Scalar(x) => x,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that property `id` of `g` is `want` (relative 1e-6).
|
||||
pub fn near(g: &PropGroup, id: &str, want: f64) {
|
||||
let got = num(g, id);
|
||||
assert!((got - want).abs() <= want.abs().max(1.0) * 1e-6, "{} {id}: {got} != {want}", g.match_id);
|
||||
}
|
||||
|
||||
/// Asserts that the point property `id` of `g` is `want` (within 0.01).
|
||||
pub fn pt(g: &PropGroup, id: &str, want: [f64; 2]) {
|
||||
let Value::Vec2(a) = v(g, id) else { panic!("{id}") };
|
||||
assert!((a[0] - want[0]).abs() < 1e-2 && (a[1] - want[1]).abs() < 1e-2, "{} {id}: {a:?} != {want:?}", g.match_id);
|
||||
}
|
||||
|
||||
/// A slider definition.
|
||||
pub fn slider(kind: ParamKind, name: &'static str, default: f64, valid: (f64, f64)) -> Def {
|
||||
Def { kind, name, default: Default::Number(default), valid, items: &[] }
|
||||
}
|
||||
|
||||
/// A popup definition: default item (1-based) and items.
|
||||
pub fn popup(name: &'static str, default: u32, items: &'static [&'static str]) -> Def {
|
||||
Def { kind: ParamKind::Popup, name, default: Default::Index(default), valid: (0.0, 0.0), items }
|
||||
}
|
||||
|
||||
/// A definition of another kind (angle, point, colour, ...) with `default`.
|
||||
pub fn other(kind: ParamKind, name: &'static str, default: Default) -> Def {
|
||||
Def { kind, name, default, valid: (0.0, 0.0), items: &[] }
|
||||
}
|
||||
|
||||
/// The definitions written here read back as the reader decodes After Effects' `pard`.
|
||||
#[test]
|
||||
fn pard_layout_decodes_as_written() {
|
||||
// Echo's Decay (float, valid 0..100, default 1), Linear Wipe's Feather (fixed, valid max
|
||||
// 32 000), Echo's Number Of Echoes (integer, max 30 000, default 1), Offset's centre point,
|
||||
// Color Stabilizer's Stabilize (default 2 of 3): what the sample dump of 2026-11-05 read.
|
||||
let fx = Fx {
|
||||
match_name: "Test FX",
|
||||
name: "Test FX",
|
||||
defs: vec![
|
||||
slider(ParamKind::FloatSlider, "Decay", 1.0, (0.0, 100.0)),
|
||||
slider(ParamKind::FixedSlider, "Feather", 0.0, (0.0, 32000.0)),
|
||||
slider(ParamKind::IntSlider, "Number Of Echoes", 1.0, (0.0, 30000.0)),
|
||||
other(ParamKind::Point, "Shift Center To", Default::Point(0.5, 0.5)),
|
||||
popup("Stabilize", 2, &["Brightness", "Levels", "Curves"]),
|
||||
other(ParamKind::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])),
|
||||
],
|
||||
values: vec![(1, 20.0)],
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
};
|
||||
let bytes = bytes_with(&fx);
|
||||
let file = parse(&bytes).unwrap();
|
||||
let proj = AepProject::new(&file).unwrap();
|
||||
let defs = proj.effect_definition_index();
|
||||
let found = 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() == "Test FX")
|
||||
.expect("the effect");
|
||||
let p = defs.params(&found);
|
||||
let by = |i: usize| p.iter().find(|q| q.index == i).expect("parameter").clone();
|
||||
let decay = by(1);
|
||||
assert_eq!(
|
||||
(decay.kind, decay.name.as_str(), decay.valid_min(), decay.valid_max(), decay.pard_default()),
|
||||
(ParamKind::FloatSlider, "Decay", Some(0.0), Some(100.0), Some(ParamValue::Number(1.0)))
|
||||
);
|
||||
assert_eq!(decay.stored_value(), Some(ParamValue::Number(20.0)));
|
||||
assert_eq!((by(2).kind, by(2).valid_max(), by(2).pard_default()), (ParamKind::FixedSlider, Some(32000.0), Some(ParamValue::Number(0.0))));
|
||||
assert_eq!((by(3).kind, by(3).valid_max(), by(3).pard_default()), (ParamKind::IntSlider, Some(30000.0), Some(ParamValue::Number(1.0))));
|
||||
assert_eq!(by(4).pard_default(), Some(ParamValue::Point([0.5, 0.5])));
|
||||
let stab = by(5);
|
||||
assert_eq!((stab.popup_item_count(), stab.items.clone()), (Some(3), Some(vec!["Brightness".to_string(), "Levels".into(), "Curves".into()])));
|
||||
assert_eq!(found.display_name().as_deref(), Some("Test FX"));
|
||||
assert_eq!(by(6).pard_default(), Some(ParamValue::Argb([0.0, 0.0, 0.0, 0.0])));
|
||||
}
|
||||
@@ -519,6 +519,20 @@ popup_selected_item, borrowed_definition}`, `Effect::has_own_definitions`,
|
||||
(356, 357, 361, 363, 364, 366, 368, 369, 371, 374, 377, 378, 380, 382, 383),
|
||||
`tests/effects_exposure.rs` (389-391, 393-397).
|
||||
|
||||
**Importer block 2026-11-05, #3110 (`1100-quickwins.aep`, After Effects 26.2.1; V for every
|
||||
row: defaults from the defaults composition, the valid ranges from the minimum and maximum
|
||||
compositions, the items from one composition per popup item).** Each set value is stored as set
|
||||
(Decay 20 is `20.0`, Blend With Original 100 % is `1.0`); a value set to the default leaves no record
|
||||
(HDR Amount 100):
|
||||
|
||||
| Effect (match name) | Parameters (`parT` index: type, valid range, default) |
|
||||
|---|---|
|
||||
| `ADBE HDR ToneMap` (HDR Highlight Compression) | 1 Amount: float, 0..100 (slider 0..100), default 100 |
|
||||
| `ADBE Echo` | 1 Echo Time (seconds): float, -30 000..30 000 (slider -5..5), default -1/30 (float32); 2 Number Of Echoes: integer, max 30 000, default 1; 3 Starting Intensity: float, 0..1, default 1; 4 Decay: float, **valid 0..100, slider 0..1**, default 1; 5 Echo Operator: popup (Add, Maximum, Minimum, Screen, Composite In Back, Composite In Front, Blend), default 1 |
|
||||
| `ADBE Linear Wipe` | 1 Transition Completion: fixed, max 100, default 0; 2 Wipe Angle: angle, default 90; 3 Feather: fixed, max 32 000, default 0 |
|
||||
| `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 |
|
||||
|
||||
**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;
|
||||
|
||||
@@ -178,6 +178,7 @@ parameter gets the file's value:
|
||||
| `ADBE HUE SATURATION` 1 Channel Control, 2 Channel Range data, 6 Colorize, 7 Colorize Hue, 8 Colorize Saturation, 9 Colorize Lightness | `channelControl`; `hue`, `saturation`, `lightness` from the data; `colorize`, `colorizeHue`, `colorizeSaturation`, `colorizeLightness` | popup by name; the master hue (degrees), saturation and lightness are the `i32` of the arbitrary data (the visible Master parameters keep no record); the colour ranges are reported when they are not at their defaults (not sampled) | 443-450 |
|
||||
| `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 |
|
||||
| Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 |
|
||||
| Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; its unit, pixels here, is not verified). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below |
|
||||
| `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 |
|
||||
|
||||
- **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons
|
||||
@@ -308,6 +309,22 @@ frame blending, 3D material options, audio levels, guides, the mask colour, lock
|
||||
between them (363: 50 at 1 s; 364: grey at 1 s; 382: [950, 500] at 1 s), the defaults equal to
|
||||
EffectCraft's, stack order with a reported Gaussian Blur (366), the second instance (383), and
|
||||
lossless imports without `allowLosses`.
|
||||
- `crates/aep/tests/it/import/effects_quickwins.rs` (#3110): HDR Highlight Compression, Echo,
|
||||
Linear Wipe, Offset and Color Stabilizer, each built into `synthetic.aep` (`fx_inject.rs`, which
|
||||
also checks that its `pard` layout reads back) with the definitions and values of the importer
|
||||
block of 2026-11-05. Render check of that block's 75 compositions (320x180, 32 bpc, working space
|
||||
None, 0.5 s) against After Effects' frames (its `saveFrameToPng` of a 32 bpc project writes
|
||||
`floor(value x 25)` per channel, so values are compared at that quantisation): Offset (5), Linear
|
||||
Wipe defaults / min / max, Echo with the Add, Maximum, Minimum, Screen operators and the keyed and
|
||||
expression variants, Color Stabilizer with every Stabilize item and Sample Size up to 25 match
|
||||
within one level. Differences, all in EffectCraft's effects and not in the mapping: HDR
|
||||
Highlight Compression's curve (up to 6 of 25 levels on 19 % of the pixels at Amount 100, equal
|
||||
at Amount 0); Echo's Blend operator (After Effects' frame is as bright as Add's, EffectCraft
|
||||
averages) and the edge of the moving square with Composite In Back / Front (0.5 % of the
|
||||
pixels); Linear Wipe's feathered edge (0.6 % of the pixels); Color Stabilizer with a Sample Size
|
||||
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/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