Merge pull request #3450 from arnav-hiwarkar/fix/3087-aep-effect-placeholders

.aep import: keep effects without a counterpart as placeholders (#3087)
This commit is contained in:
ash
2026-11-12 14:21:58 +05:30
committed by GitHub
10 changed files with 657 additions and 47 deletions
+3 -1
View File
@@ -14,7 +14,8 @@
//! missing-footage placeholders). `docs/aep-import.md` has the full list.
//!
//! What does not arrive is **reported**, never dropped silently: [`ImportReport`] counts other
//! effects (by match name) and unimported effect parameters, unusable masks, layer style
//! effects (by match name; they arrive as placeholders that render nothing, with their parameters
//! for expressions) and unimported effect parameters, unusable masks, layer style
//! features, text layer features, shape items, unsupported blend modes and transform
//! properties, simplified keyframes and unreadable data per [`LossCategory`]. By default the import
//! is refused when anything would be lost ([`ImportOptions::allow_losses`] lifts that); a refusal
@@ -30,6 +31,7 @@ mod effects;
mod lumetri;
mod markers;
mod masks;
mod placeholder;
mod props;
mod puppet;
mod report;
+55 -38
View File
@@ -1,7 +1,8 @@
//! Effects: a table maps an After Effects effect (its match name) and its parameters (by their
//! index in `parT`) to an EffectCraft effect, a parameter id and a unit conversion. Effects and
//! parameters outside the table are reported in [`LossCategory::Effects`] by match name, never
//! dropped silently.
//! index in `parT`) to an EffectCraft effect, a parameter id and a unit conversion. Parameters
//! outside the table are reported in [`LossCategory::Effects`] by match name, never dropped
//! silently. An effect outside it arrives as a placeholder holding its parameters (`placeholder.rs`)
//! and is reported too.
//!
//! Every conversion is backed by the corpus (`docs/aep-format.md` 3.7, cases 356-450, 511-516):
//!
@@ -82,44 +83,37 @@ pub(crate) fn import_effects<'f, 'a>(
continue;
}
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else {
col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()));
continue;
};
let Some(spec) = effectcraft_effects::find(map.effect) else {
col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()));
continue;
};
let found = EFFECTS.iter().find(|m| m.match_name == fx.match_name()).and_then(|m| Some((m, effectcraft_effects::find(m.effect)?)));
let lost = format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
if defs.source(&fx) == DefinitionSource::Missing {
col.lose(
LossCategory::Effects,
format!("{}: '{}' has the effect '{shown}' ({}) without readable parameter definitions, not imported", cx.comp, cx.layer, fx.match_name()),
);
match found {
Some(_) => col.lose(LossCategory::Effects, format!("{lost} without readable parameter definitions, not imported")),
None => col.lose_unmapped_effect(fx.match_name(), lost),
}
continue;
}
let size = [f64::from(cx.size.0), f64::from(cx.size.1)];
let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size);
match fx.enabled() {
Some(on) => group.enabled = on,
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
if found.is_none() && col.placeholder_cost >= crate::import::placeholder::MAX_COST {
// The import's placeholder budget is spent: the definitions (shared by many instances)
// are not read again. Named like any effect without a counterpart, in a group of its
// own (with the kept instances it would read as kept).
let key = format!("{} (not kept)", fx.match_name());
col.lose_unmapped_effect(&key, format!("{lost}, not kept (past the import's placeholder parameters)"));
continue;
}
let params = defs.params(&fx);
// Borrowed definitions (another instance's or the project copy) may not name every value
// record this instance has: such a record has nothing to import into, so it is reported.
if params.first().is_some_and(|p| p.borrowed_definition) {
let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect();
for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) {
col.lose(
LossCategory::Effects,
format!(
"{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported",
cx.comp,
cx.layer,
fx.match_name()
),
);
}
}
report_records_without_definition(&fx, &params, cx, &shown, col);
let Some((map, spec)) = found else {
// No counterpart: kept as a placeholder that renders nothing, for expressions (#3087).
let mut group = crate::import::placeholder::placeholder(&fx, &params, cx, &shown, ids, refs, col);
set_enabled(&fx, &mut group, cx, &shown, col);
col.lose_unmapped_effect(fx.match_name(), format!("{lost}, kept without rendering"));
col.imported.placeholder_effects = col.imported.placeholder_effects.saturating_add(1);
target.children.push(Node::Group(group));
continue;
};
let size = [f64::from(cx.size.0), f64::from(cx.size.1)];
let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size);
set_enabled(&fx, &mut group, cx, &shown, col);
if map.extra == Extra::Lumetri {
crate::import::lumetri::import_lumetri(&fx, &params, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
} else if map.extra == Extra::Cryptomatte {
@@ -134,6 +128,29 @@ pub(crate) fn import_effects<'f, 'a>(
}
}
/// The effect's fx switch.
fn set_enabled(fx: &AepEffect<'_, '_>, group: &mut PropGroup, cx: &LayerCtx<'_>, shown: &str, col: &mut Collector) {
match fx.enabled() {
Some(on) => group.enabled = on,
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
}
}
/// Borrowed definitions (another instance's or the project copy) may not name every value record
/// this instance has: such a record has nothing to import into, so it is reported.
fn report_records_without_definition(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, col: &mut Collector) {
if !params.first().is_some_and(|p| p.borrowed_definition) {
return;
}
let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect();
for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) {
col.lose(
LossCategory::Effects,
format!("{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported", cx.comp, cx.layer, fx.match_name()),
);
}
}
/// Parameters that never carry a value to import.
pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer)
@@ -364,7 +381,7 @@ fn report_beyond_limit<'v>(limit: Option<(f64, f64)>, values: impl Iterator<Item
/// 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.
fn import_layer_ref(p: &EffectParam<'_, '_>, target: &str, own: bool, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) {
pub(crate) fn import_layer_ref(p: &EffectParam<'_, '_>, target: &str, own: bool, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) {
let (here, label) = names;
let ParamOut { group, refs, col } = out;
// After Effects hands the named layer's source to the effect, without its masks and effects
@@ -804,7 +821,7 @@ fn px(v: f64) -> String {
/// A decoded value in EffectCraft's units, `None` when it does not fit the kind or is not a valid
/// number.
fn convert(value: ParamValue, kind: Kind) -> Option<Value> {
pub(crate) fn convert(value: ParamValue, kind: Kind) -> Option<Value> {
let raw = match (value, kind) {
// The reader gives colours as ARGB fractions; `Kind` takes the stored 0..255.
(ParamValue::Argb(c), Kind::EffectColor) => c.map(|x| x * 255.0).to_vec(),
+218
View File
@@ -0,0 +1,218 @@
//! Placeholders: an effect with no EffectCraft counterpart (a third-party plug-in, a pseudo effect
//! of expression controls) arrives as an effect group that renders nothing, as a missing plug-in
//! does. Its effect id is the After Effects match name, so Find Missing Effects lists it. It holds
//! the effect's parameters, flat and named as in After Effects, each with its value, keys and
//! expression, so expressions that read them (`effect("Settings")("Speed")`) keep working. It is
//! still reported, once per match name.
//!
//! The values keep After Effects' units, the ones its expressions read: sliders and angles as
//! stored, points in layer pixels, colours RGBA 0..1, checkboxes on / off, popups as EffectCraft's
//! 0-based items over the file's items in order (expressions read them 1-based, as After Effects
//! does). Layer parameters name their layer (their keys and expression are reported). Paths and
//! arbitrary data are not kept (reported when they hold something).
//!
//! The parameters placeholders walk, kept or not, and their popup items (by name length) are
//! bounded per import ([`MAX_COST`]): definitions can be shared by many instances, so a small file
//! could otherwise ask for very many properties. Once
//! the budget is spent, later effects without a counterpart are reported without a placeholder.
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, Node, ParamUi, PropGroup, Property};
use crate::import::effects::{LayerCtx, ParamOut, PendingLayerRef, carries_nothing, convert, import_layer_ref, param_label};
use crate::import::props::{Kind, import_expression, import_keys};
use crate::import::report::{Collector, LossCategory};
use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue};
/// Most placeholder parameters (kept or not) and popup items one import walks.
pub(crate) const MAX_COST: usize = 200_000;
/// Most items read from one placeholder popup.
const MAX_POPUP_ITEMS: usize = 1024;
/// A popup item costs one more for each of these bytes in its name, so the names kept stay
/// bounded too.
const NAME_BYTES: usize = 16;
/// The placeholder of `fx`, with `params` (its parameter definitions) as properties.
pub(crate) fn placeholder(
fx: &AepEffect<'_, '_>,
params: &[EffectParam<'_, '_>],
cx: &LayerCtx<'_>,
shown: &str,
ids: &mut Ids<'_>,
refs: &mut Vec<PendingLayerRef>,
col: &mut Collector,
) -> PropGroup {
// The effect id is the match name; the group's match id is one segment of a property path
// (a pseudo effect's `Pseudo/0.25` would be cut apart), so match paths find its parameters.
let mut group = ids.group(&segment(fx.match_name()), shown);
group.kind = GroupKind::Effect { effect: fx.match_name().to_string() };
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
for p in params {
// Every parameter walked counts, kept or not: a definition shared by many instances is
// otherwise walked again for each one.
if carries_nothing(p) {
col.placeholder_cost = col.placeholder_cost.saturating_add(1).min(MAX_COST);
continue;
}
if col.placeholder_cost >= MAX_COST {
col.lose(LossCategory::Effects, format!("{here}: parameters past the import's {MAX_COST} placeholder parameters not kept"));
break;
}
let label = param_label(p);
let Some((kind, ui, default)) = shape(p, cx.size) else {
col.placeholder_cost = col.placeholder_cost.saturating_add(1);
if p.stored().is_some() {
col.lose(LossCategory::Effects, format!("{here}: parameter {label} not kept (type {:?})", p.kind));
}
continue;
};
let (items, names) = match &ui {
ParamUi::Popup { options } => (options.len(), options.iter().map(|o| 1 + o.len() / NAME_BYTES).fold(0, usize::saturating_add)),
_ => (0, 0),
};
if p.kind == ParamKind::Popup && popup_len(p).is_some_and(|n| n > items) {
col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the items past the first {MAX_POPUP_ITEMS} not kept"));
}
let cost = col.placeholder_cost.saturating_add(names).saturating_add(1);
if cost > MAX_COST {
// The budget is spent: nothing more is built, here or in later effects (definitions
// shared by many instances would otherwise still be walked item by item).
col.lose(
LossCategory::Effects,
format!("{here}: parameter {label} and the ones after it not kept (past the import's {MAX_COST} placeholder parameters)"),
);
col.placeholder_cost = MAX_COST;
break;
}
col.placeholder_cost = cost;
let mut prop = Property::new(ids.alloc(), &param_id(p), p.name.trim(), default).with_ui(ui);
prop.spatial = p.kind == ParamKind::Point;
prop.hold_only = matches!(p.kind, ParamKind::Checkbox | ParamKind::Popup | ParamKind::Layer);
match kind {
Some(kind) => {
fill(p, &mut prop, kind, cx, (&here, &label), col);
group.children.push(Node::Prop(prop));
}
// A layer parameter: the layer it names (set once every layer exists, by its uid).
None => {
let id = format!("@{}", prop.uid);
group.children.push(Node::Prop(prop));
import_layer_ref(p, &id, true, cx, (&here, &label), ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
}
}
}
group
}
/// How parameter `p` is held on a layer `size` big: its unit conversion (`None` for a layer
/// parameter), its control and its value until one is read. `None` for a type that is not kept.
fn shape(p: &EffectParam<'_, '_>, size: (u32, u32)) -> Option<(Option<Kind>, ParamUi, Value)> {
let (kind, ui, default) = match p.kind {
ParamKind::IntSlider | ParamKind::FixedSlider | ParamKind::FloatSlider => (Kind::EffectNumber(1.0), slider_ui(p), Value::Scalar(0.0)),
ParamKind::Angle => (Kind::EffectNumber(1.0), ParamUi::Angle, Value::Scalar(0.0)),
ParamKind::Checkbox => (Kind::EffectBool, ParamUi::Checkbox, Value::Bool(false)),
ParamKind::Color => (Kind::EffectColor, ParamUi::Color, Value::Color([0.0, 0.0, 0.0, 1.0])),
ParamKind::Point => (Kind::EffectPoint(f64::from(size.0), f64::from(size.1)), ParamUi::Point, Value::Vec2([0.0, 0.0])),
ParamKind::Layer => return Some((None, ParamUi::Layer, Value::Layer(None))),
ParamKind::Popup => {
// The file's items, separators (`(-`) included: After Effects counts them.
let options: Vec<String> = match &p.items {
Some(items) => items.iter().take(MAX_POPUP_ITEMS).cloned().collect(),
None => (1..=popup_len(p)?.min(MAX_POPUP_ITEMS)).map(|i| format!("Item {i}")).collect(),
};
let n = u32::try_from(options.len()).ok().filter(|n| *n > 0)?;
(Kind::EffectPopup(n), ParamUi::Popup { options }, Value::Enum(0))
}
_ => return None,
};
Some((Some(kind), ui, default))
}
/// A slider's control: a float slider's valid range and drag range when they are ranges (only a
/// float slider's are decoded), else a plain number.
fn slider_ui(p: &EffectParam<'_, '_>) -> ParamUi {
match (p.valid_min(), p.valid_max(), p.slider_range()) {
(Some(min), Some(max), Some((smin, smax))) if [min, max, smin, smax].iter().all(|v| v.is_finite()) && min <= max && smin <= smax => {
ParamUi::Slider { min, max, slider_min: smin.clamp(min, max), slider_max: smax.clamp(min, max), decimals: 2 }
}
_ => ParamUi::Number,
}
}
/// How many items a popup has: the file's names, else the count in its definition.
fn popup_len(p: &EffectParam<'_, '_>) -> Option<usize> {
p.items.as_ref().map(Vec::len).or_else(|| p.popup_item_count().map(usize::from))
}
/// The property id of `p`: its match name when that is one segment of a property path (a
/// third-party effect's `CC Cylinder-0003`), else `param<index>` (a pseudo effect's
/// `Pseudo/0.25-0001`: `/` and `.` separate path segments). Expressions also find it by its name.
fn param_id(p: &EffectParam<'_, '_>) -> String {
if !p.match_name.is_empty() && p.match_name.chars().all(plain) { p.match_name.clone() } else { format!("param{}", p.index) }
}
/// Whether `c` can stand in one segment of a property path (`/`, `.`, `#` and `@` mean more).
fn plain(c: char) -> bool {
c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')
}
/// `name` as one segment of a property path: other characters become `_` (`Pseudo/0.25` is
/// `Pseudo_0_25`).
fn segment(name: &str) -> String {
name.chars().map(|c| if plain(c) { c } else { '_' }).collect()
}
/// Sets `prop` from `p`: its expression, then its keys or its value (the record, else the `pard`
/// default). `names` is `(here, label)` for the report.
fn fill(p: &EffectParam<'_, '_>, prop: &mut Property, kind: Kind, cx: &LayerCtx<'_>, names: (&str, &str), col: &mut Collector) {
let (here, label) = names;
if let Some(stored) = p.stored() {
import_expression(&stored, prop, col);
}
if p.is_animated() {
let Some(stored) = p.stored() else { return };
if kind == Kind::EffectBool {
// Keys on a checkbox are not sampled by the corpus.
col.lose(LossCategory::Effects, format!("{here}: the keyframes of parameter {label} are not kept (not decoded)"));
return;
}
let before = prop.keys.len();
import_keys(&stored, kind, cx.layer, &p.match_name, cx.tm, prop, col);
if prop.keys.len() == before {
col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe"));
}
return;
}
let value = match (p.stored(), p.recorded_value()) {
(Some(_), Some(v)) => v,
(Some(_), None) => {
col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value"));
return;
}
// A borrowed popup's default slot holds another copy's selection (docs/aep-format.md 3.7).
(None, _) if p.kind == ParamKind::Popup && p.borrowed_definition => {
col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the default item is not known (shared definition), the first item used"));
return;
}
// A popup's default slot can hold 0, no item (After Effects' save of a project it opened).
(None, _) => match p.pard_default() {
Some(ParamValue::Index(0)) => {
col.lose(LossCategory::Effects, format!("{here}: parameter {label}: the default item is not stored, the first item used"));
return;
}
Some(v) => v,
None => return,
},
};
match (convert(value, kind), value) {
(Some(v), _) => prop.value = v,
// An item of the file that is past the ones kept (reported with the cut): the first item.
(None, ParamValue::Index(i)) if usize::try_from(i).is_ok_and(|i| i > MAX_POPUP_ITEMS && popup_len(p).is_some_and(|n| i <= n)) => {
col.lose(LossCategory::Effects, format!("{here}: parameter {label}: item {i} is past the items kept, the first item used"));
}
(None, _) => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?})")),
}
}
+7
View File
@@ -161,6 +161,10 @@ pub struct Imported {
/// in [`LossCategory::Effects`]).
#[serde(default)]
pub effects: usize,
/// Effects with no counterpart, kept as placeholders that render nothing (their parameters,
/// for expressions; each is also in [`LossCategory::Effects`]).
#[serde(default)]
pub placeholder_effects: usize,
/// Layer styles (Drop Shadow, Stroke, ...), one per style on a layer.
#[serde(default)]
pub layer_styles: usize,
@@ -278,6 +282,9 @@ pub(crate) struct Collector {
/// many instances there were. They get a line each in [`Collector::finish`], so one that
/// comes after the example lines are used up is still named (#3365).
unmapped_effects: BTreeMap<String, (String, usize)>,
/// The properties and popup items the placeholders of this import hold so far (at most
/// `placeholder::MAX_COST`).
pub placeholder_cost: usize,
}
/// How many different unmapped effects get a line of their own (a file names at most this many).
+1
View File
@@ -22,6 +22,7 @@ mod lumetri;
mod markers;
mod masks;
mod mattes;
mod placeholders;
mod puppet;
mod resaved_defaults;
mod robustness;
+10 -5
View File
@@ -10,7 +10,7 @@
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};
use effectcraft_project::{GroupKind, Project, PropGroup};
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
@@ -274,10 +274,15 @@ pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option<PropGroup>, ImportRes
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()));
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 || matches!(&g.kind, GroupKind::Effect { effect: e } if e == effect))
.cloned()
})
});
(g, r)
}
+315
View File
@@ -0,0 +1,315 @@
//! #3087: an effect with no EffectCraft counterpart (a third-party plug-in, a pseudo effect of
//! expression controls) arrives as a placeholder holding its parameters, so expressions that read
//! them keep working; it renders nothing and is still reported. Each effect is built into
//! `synthetic.aep` (`fx_inject`) in place of its Glow, on the 8 x 8 sequence layer.
use crate::import::fx_inject::{Default, Fx, bytes_with, import_bytes, losses, other, popup, run, slider, v};
use effectcraft_aep::import::LossCategory;
use effectcraft_aep::{Body, Chunk, ParamKind as K, parse, write};
use effectcraft_keyframe::Value;
use effectcraft_project::{GroupKind, ParamUi, PropGroup};
/// A third-party effect with one parameter of each kept kind, inside a twirl-down group.
fn third_party(values: Vec<(usize, f64)>, keys: Vec<(usize, Vec<(i32, f64)>)>) -> Fx {
Fx {
match_name: "CC Cylinder",
name: "CC Cylinder",
defs: vec![
other(K::GroupStart, "Light", Default::None),
slider(K::FloatSlider, "Radius (%)", 30.0, (0.0, 30000.0)),
other(K::GroupEnd, "", Default::None),
other(K::Angle, "Rotation X", Default::Number(12.5)),
other(K::Checkbox, "Wire", Default::Number(1.0)),
other(K::Color, "Light Color", Default::Color([255, 51, 102, 204])),
other(K::Point, "Position", Default::Point(0.25, 0.5)),
popup("Render", 1, &["Full", "(-", "Outside", "Inside"]),
slider(K::IntSlider, "Count", 3.0, (0.0, 50.0)),
],
values,
data: vec![],
keys,
}
}
fn placeholder_of(fx: &Fx) -> (PropGroup, effectcraft_aep::import::ImportResult) {
let (g, r) = run(fx, fx.match_name);
(g.expect("the placeholder"), r)
}
#[test]
fn an_effect_without_a_counterpart_is_kept_with_its_values() {
// Radius 37.5 and Render "Outside" (3: the separator counts) stored, the rest at defaults.
let (g, r) = placeholder_of(&third_party(vec![(2, 37.5), (8, 3.0)], vec![]));
assert_eq!(g.kind, GroupKind::Effect { effect: "CC Cylinder".into() });
assert_eq!(g.name, "CC Cylinder");
let names: Vec<(&str, &str)> = g.props().map(|p| (p.match_id.as_str(), p.name.as_str())).collect();
assert_eq!(
names,
[
("CC Cylinder-0002", "Radius (%)"),
("CC Cylinder-0004", "Rotation X"),
("CC Cylinder-0005", "Wire"),
("CC Cylinder-0006", "Light Color"),
("CC Cylinder-0007", "Position"),
("CC Cylinder-0008", "Render"),
("CC Cylinder-0009", "Count"),
],
"flat, in order, without the group markers"
);
assert_eq!(v(&g, "CC Cylinder-0002"), Value::Scalar(37.5));
assert_eq!(v(&g, "CC Cylinder-0004"), Value::Scalar(12.5));
assert_eq!(v(&g, "CC Cylinder-0005"), Value::Bool(true));
let Value::Color(c) = v(&g, "CC Cylinder-0006") else { panic!("a colour") };
assert!(c.iter().zip([0.2, 0.4, 0.8, 1.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{c:?}");
assert_eq!(v(&g, "CC Cylinder-0007"), Value::Vec2([2.0, 4.0]), "layer pixels");
assert_eq!(v(&g, "CC Cylinder-0008"), Value::Enum(2), "item 3 of 4, 0-based");
assert_eq!(v(&g, "CC Cylinder-0009"), Value::Scalar(3.0));
let render = g.prop("CC Cylinder-0008").unwrap();
assert_eq!(render.ui, ParamUi::Popup { options: ["Full", "(-", "Outside", "Inside"].map(String::from).to_vec() });
// Still reported, once, and counted apart from the imported effects.
let found = losses(&r, LossCategory::Effects, "CC Cylinder");
assert!(found.len() == 1 && found[0].ends_with("has the effect 'CC Cylinder' (CC Cylinder), kept without rendering"), "{found:?}");
assert_eq!((r.report.imported.placeholder_effects, r.report.imported.effects), (1, 1), "the placeholder and the Levels");
}
#[test]
fn keys_and_expressions_of_a_placeholder_are_kept() {
let fx = third_party(vec![(4, 12.5)], vec![(2, vec![(0, 10.0), (25, 20.0)])]);
let bytes = with_expression(&bytes_with(&fx), "CC Cylinder-0004", "time * 90");
let (g, _) = import_bytes(&bytes, "CC Cylinder");
let g = g.unwrap();
let radius = g.prop("CC Cylinder-0002").unwrap();
assert_eq!(radius.keys.iter().map(|k| k.value.clone()).collect::<Vec<_>>(), [Value::Scalar(10.0), Value::Scalar(20.0)]);
let angle = g.prop("CC Cylinder-0004").unwrap();
assert_eq!(angle.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("time * 90", true)));
}
/// A pseudo effect's parameter match names (`Pseudo/0.25-0001`) are no property path segment:
/// its parameters are `param<index>`, found by their names.
#[test]
fn a_pseudo_effect_is_kept_with_its_parameter_names() {
let fx = Fx {
match_name: "Pseudo/0.25",
name: "Settings",
defs: vec![slider(K::FloatSlider, "Speed", 1.0, (0.0, 100.0)), popup("Mode", 2, &["A", "B"])],
values: vec![(1, 4.0)],
data: vec![],
keys: vec![],
};
let (g, r) = placeholder_of(&fx);
assert_eq!(g.prop("param1").map(|p| (p.name.as_str(), p.value.clone())), Some(("Speed", Value::Scalar(4.0))));
assert_eq!(g.prop("param2").map(|p| (p.name.as_str(), p.value.clone())), Some(("Mode", Value::Enum(1))));
assert_eq!(losses(&r, LossCategory::Effects, "Pseudo/0.25").len(), 1);
// The effect id stays the match name (Find Missing Effects, expressions); the group's match id
// is one path segment, so a match path (keyframe paste, pick whip) finds its parameters again.
assert_eq!(g.kind, GroupKind::Effect { effect: "Pseudo/0.25".into() });
let speed = g.prop("param1").map(|p| p.uid);
let layer = PropGroup::new(0, "layer", "Layer").with(PropGroup::new(0, "effects", "Effects").with(g));
let path = speed.and_then(|u| layer.match_path_of(u));
assert_eq!(path.as_deref().and_then(|p| layer.prop(p)).map(|p| p.name.as_str()), Some("Speed"), "{path:?}");
}
/// A float slider keeps its valid range and slider range, so Effect Controls drags and clamps it
/// as After Effects does; a range that is not a range stays a plain number.
#[test]
fn a_float_slider_keeps_its_range() {
let fx = Fx {
match_name: "Pseudo/r",
name: "Rig",
defs: vec![slider(K::FloatSlider, "Speed", 1.0, (-10.0, 100.0)), slider(K::FloatSlider, "Odd", 1.0, (5.0, 1.0))],
values: vec![],
data: vec![],
keys: vec![],
};
let (g, _) = placeholder_of(&fx);
let ui = |id: &str| g.prop(id).map(|p| p.ui.clone());
assert_eq!(ui("param1"), Some(ParamUi::Slider { min: -10.0, max: 100.0, slider_min: -10.0, slider_max: 100.0, decimals: 2 }));
assert_eq!(ui("param2"), Some(ParamUi::Number));
}
/// A popup whose definition is borrowed (the project copy) and has no value record: its default
/// slot holds another copy's selection, so the first item is used and that is reported.
#[test]
fn a_borrowed_popup_without_a_record_takes_the_first_item_and_is_reported() {
let bytes = without_instance_definitions(&bytes_with(&third_party(vec![], vec![])));
let (g, r) = import_bytes(&bytes, "CC Cylinder");
assert_eq!(v(&g.unwrap(), "CC Cylinder-0008"), Value::Enum(0));
let found = losses(&r, LossCategory::Effects, "CC Cylinder");
assert!(found.iter().any(|d| d.contains("Render") && d.contains("default item is not known")), "{found:?}");
}
/// A popup default slot of 0 (no item; After Effects' save of a project it opened) takes the first
/// item and is reported, not read as damaged data.
#[test]
fn a_popup_default_of_no_item_takes_the_first_item() {
let fx = Fx { match_name: "Some Fx", name: "Some", defs: vec![popup("Mode", 0, &["A", "B"])], values: vec![], data: vec![], keys: vec![] };
let (g, r) = placeholder_of(&fx);
assert_eq!(v(&g, "Some Fx-0001"), Value::Enum(0));
let found = losses(&r, LossCategory::Effects, "Some Fx");
assert!(found.iter().any(|d| d.contains("default item is not stored")), "{found:?}");
assert!(losses(&r, LossCategory::DamagedData, "Some Fx").is_empty());
}
/// A popup past the 1024 items kept: the cut is reported, and a selection among the cut items
/// takes the first item and is reported as such, not as damaged data.
#[test]
fn a_popup_past_the_items_kept_is_reported() {
let items: &'static [&'static str] = Box::leak((0..1100).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::<Vec<_>>().into_boxed_slice());
let fx = Fx { match_name: "Long Fx", name: "Long", defs: vec![popup("Pick", 1, items)], values: vec![(1, 1050.0)], data: vec![], keys: vec![] };
let (g, r) = placeholder_of(&fx);
let pick = g.prop("Long Fx-0001").unwrap();
assert_eq!(pick.value, Value::Enum(0));
assert!(matches!(&pick.ui, ParamUi::Popup { options } if options.len() == 1024));
let found = losses(&r, LossCategory::Effects, "Long Fx");
assert!(found.iter().any(|d| d.contains("past the first 1024")), "{found:?}");
assert!(found.iter().any(|d| d.contains("item 1050 is past the items kept")), "{found:?}");
assert!(losses(&r, LossCategory::DamagedData, "Long Fx").is_empty());
}
/// Many large popups stay within the import's budget: what is beyond is reported, not kept.
#[test]
fn a_hostile_placeholder_stays_bounded() {
let items: &'static [&'static str] = Box::leak((0..1000).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::<Vec<_>>().into_boxed_slice());
let fx = Fx { match_name: "Big Fx", name: "Big", defs: (0..300).map(|_| popup("P", 1, items)).collect(), values: vec![], data: vec![], keys: vec![] };
let (g, r) = placeholder_of(&fx);
let kept = g.props().count();
assert!(kept > 0 && kept < 300, "{kept}");
assert!(g.props().map(|p| if let ParamUi::Popup { options } = &p.ui { options.len() + 1 } else { 1 }).sum::<usize>() <= 200_000);
assert!(losses(&r, LossCategory::Effects, "Big Fx").iter().any(|d| d.contains("placeholder parameters")));
}
/// Two instances of an effect whose parameters fill the import's budget: the first keeps what fits
/// and stops at the first parameter past it; the second is reported without a placeholder (its
/// shared definitions are not read again).
#[test]
fn an_effect_past_the_spent_budget_gets_no_placeholder() {
let items: &'static [&'static str] = Box::leak((0..1000).map(|i| &*Box::leak(format!("I{i}").into_boxed_str())).collect::<Vec<_>>().into_boxed_slice());
let fx = Fx { match_name: "Big Fx", name: "Big", defs: (0..300).map(|_| popup("P", 1, items)).collect(), values: vec![], data: vec![], keys: vec![] };
let bytes = twice(&bytes_with(&fx), "Big Fx");
let mut project = effectcraft_project::Project::default();
let none = |_: &str| false;
let r = effectcraft_aep::import::import(
&mut project,
&bytes,
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
)
.unwrap();
let placeholders: Vec<usize> = project
.comps()
.flat_map(|(_, c)| c.layers.iter())
.filter_map(|l| l.props.sub("effects"))
.flat_map(|fx| fx.children.iter().filter_map(|n| n.as_group()).filter(|g| g.match_id == "Big Fx").map(|g| g.props().count()).collect::<Vec<_>>())
.collect();
// 199 popups of 1000 items (1001 each) fit in 200 000; the 200th stops the first instance.
assert_eq!(placeholders, [199]);
let found = losses(&r, LossCategory::Effects, "Big Fx");
assert_eq!(found.iter().filter(|d| d.contains("and the ones after it not kept")).count(), 1, "{found:?}");
assert!(found.iter().any(|d| d.contains("not kept (past the import's placeholder parameters)")), "{found:?}");
assert!(found.iter().any(|d| d.contains("kept without rendering") && !d.contains("more like it")), "the kept instance alone: {found:?}");
}
/// Parameters a placeholder does not keep (markers, arbitrary data) still spend the budget: a
/// definition of very many of them, shared by many instances, is not walked again for each one.
#[test]
fn parameters_not_kept_still_spend_the_budget() {
let defs = (0..200_000).map(|i| other(if i % 2 == 0 { K::NoData } else { K::ArbitraryData }, "X", Default::None)).collect();
let fx = Fx { match_name: "Odd Fx", name: "Odd", defs, values: vec![], data: vec![], keys: vec![] };
let (_, r) = import_bytes(&twice(&bytes_with(&fx), "Odd Fx"), "Odd Fx");
let found = losses(&r, LossCategory::Effects, "Odd Fx");
assert!(found.iter().any(|d| d.contains("not kept (past the import's placeholder parameters)")), "{found:?}");
}
/// Long popup item names spend the budget by their length, so the names kept stay bounded.
#[test]
fn long_popup_item_names_spend_the_budget() {
let long = "x".repeat(1600);
let items: &'static [&'static str] =
Box::leak((0..1024).map(|i| &*Box::leak(format!("{long}{i}").into_boxed_str())).collect::<Vec<_>>().into_boxed_slice());
let fx = Fx { match_name: "Long Fx", name: "Long", defs: vec![popup("A", 1, items), popup("B", 1, items)], values: vec![], data: vec![], keys: vec![] };
let (g, r) = placeholder_of(&fx);
assert_eq!(g.props().count(), 1);
assert!(losses(&r, LossCategory::Effects, "Long Fx").iter().any(|d| d.contains("placeholder parameters")));
}
/// `bytes` with the layer's instance of `match_name` (its `tdmn` and `sspc`) repeated after it.
fn twice(bytes: &[u8], match_name: &str) -> Vec<u8> {
let mut file = parse(bytes).unwrap();
let mut done = 0;
edit(&mut file.chunks, false, &mut |c, in_project_copy| {
let Body::List { children, .. } = &mut c.body else { return };
if in_project_copy {
return;
}
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(match_name.as_bytes())))
else {
return;
};
let pair: Vec<Chunk<'_>> = children[at..at + 2].to_vec();
children.splice(at + 2..at + 2, pair);
done += 1;
});
assert_eq!(done, 1, "one instance");
write(&file).unwrap()
}
/// `bytes` with the expression `text` on the parameter record `param` (enabled: `tdb4` byte 120
/// 1, docs/aep-format.md 3.6).
fn with_expression(bytes: &[u8], param: &str, text: &str) -> Vec<u8> {
let mut file = parse(bytes).unwrap();
let mut done = 0;
edit(&mut file.chunks, false, &mut |c, _| {
let Body::List { children, .. } = &mut c.body else { return };
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else {
return;
};
let Some(Body::List { children: record, .. }) = children.get_mut(at + 1).map(|k| &mut k.body) else { return };
for k in record.iter_mut().filter(|k| k.is(b"tdb4")) {
if let Body::Data(d) = &mut k.body
&& let Some(b) = d.to_mut().get_mut(120)
{
*b = 1;
}
}
record.push(Chunk::data(*b"Utf8", text.as_bytes().to_vec()));
done += 1;
});
assert!(done >= 1);
write(&file).unwrap()
}
/// `bytes` with the layer's instance of `CC Cylinder` stripped of its own definitions (an empty
/// `parT`): it borrows the project copy's (`EfdG`).
fn without_instance_definitions(bytes: &[u8]) -> Vec<u8> {
let mut file = parse(bytes).unwrap();
let mut done = 0;
edit(&mut file.chunks, false, &mut |c, in_project_copy| {
let Body::List { children, .. } = &mut c.body else { return };
if in_project_copy {
return;
}
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(&b"CC Cylinder"[..])))
else {
return;
};
let Some(sspc) = children.get_mut(at + 1).filter(|k| k.is_list(b"sspc")) else { return };
for k in sspc.children_mut() {
if let Body::List { list_type, children } = &mut k.body
&& list_type == b"parT"
{
children.clear();
done += 1;
}
}
});
assert_eq!(done, 1, "one instance");
write(&file).unwrap()
}
/// Calls `f` on every chunk with whether it is inside the project's effect definitions (`EfdG`).
fn edit(chunks: &mut [Chunk<'_>], in_efdg: bool, f: &mut dyn FnMut(&mut Chunk<'_>, bool)) {
for c in chunks {
let inside = in_efdg || c.is_list(b"EfdG");
f(c, inside);
edit(c.children_mut(), inside, f);
}
}
+4 -1
View File
@@ -10,7 +10,7 @@
use effectcraft_geom::{Mat3, Mat4};
use effectcraft_keyframe::Value;
use effectcraft_project::ae_order::{self, AeChild};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property};
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
@@ -297,6 +297,9 @@ fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> {
.find(|n| n.match_id() == m)
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(name)))
.or_else(|| g.children.iter().find(|n| compact(n.name()) == c || compact(n.match_id()) == c))
// An effect by its After Effects match name, an imported placeholder's effect id
// (`effect("Pseudo/0.25")`; its match id is one path segment, `Pseudo_0_25`).
.or_else(|| g.children.iter().find(|n| matches!(n.as_group().map(|g| &g.kind), Some(GroupKind::Effect { effect }) if effect == name)))
}
}
}
+22
View File
@@ -1376,6 +1376,28 @@ fn checkbox_and_dropdown_results() {
assert_close!(f.ev(f.b, ROT, "effect('Dropdown Menu Control')('Menu') * 10", 0.0).unwrap().as_f64(), 10.0);
}
/// An effect EffectCraft does not have (an imported After Effects placeholder, #3087) is read by
/// name like any other: its parameters by their names, a popup 1-based. It is also found by its
/// After Effects match name, its effect id (the group's match id is one path segment).
#[test]
fn an_unknown_effects_parameters_are_read_by_name() {
let mut f = fx();
let g = group_push(&mut f.p, |ids| {
let mut g = ids.group("Pseudo_0_25", "Settings");
g.kind = effectcraft_project::GroupKind::Effect { effect: "Pseudo/0.25".into() };
g.children.push(ids.prop("param1", "Speed", Value::Scalar(3.0)).into());
g.children.push(ids.prop("param2", "Mode", Value::Enum(1)).into());
g
});
f.layer_mut(f.b).props.sub_mut("effects").unwrap().children.push(g.into());
assert_close!(f.ev(f.b, ROT, "effect('Settings')('Speed') * 2", 0.0).unwrap().as_f64(), 6.0);
assert_close!(f.ev(f.b, ROT, "effect('Settings')('Mode') * 10", 0.0).unwrap().as_f64(), 20.0);
assert_close!(f.ev(f.b, ROT, "effect('Settings')(1)", 0.0).unwrap().as_f64(), 3.0);
assert_close!(f.ev(f.b, ROT, "effect('Pseudo/0.25')('Speed')", 0.0).unwrap().as_f64(), 3.0);
assert_close!(f.ev(f.b, ROT, "thisLayer('Effects')('Pseudo/0.25')('Speed')", 0.0).unwrap().as_f64(), 3.0);
assert_close!(f.ev(f.b, ROT, "thisLayer('ADBE Effect Parade')('Pseudo/0.25')('Speed')", 0.0).unwrap().as_f64(), 3.0);
}
#[test]
fn syntax_error_reports_line() {
let f = fx();
+22 -2
View File
File diff suppressed because one or more lines are too long