diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index fae3b3256..20a4b7b1e 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -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; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 1ab4b208a..86faac76a 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -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,29 @@ 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)), - } 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, ¶ms, 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, ¶ms, 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, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); } else if map.extra == Extra::Cryptomatte { @@ -134,6 +120,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 +373,7 @@ fn report_beyond_limit<'v>(limit: Option<(f64, f64)>, values: impl Iterator, 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 @@ -799,7 +808,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 { +pub(crate) fn convert(value: ParamValue, kind: Kind) -> Option { 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(), diff --git a/crates/aep/src/import/placeholder.rs b/crates/aep/src/import/placeholder.rs new file mode 100644 index 000000000..4e9fd8047 --- /dev/null +++ b/crates/aep/src/import/placeholder.rs @@ -0,0 +1,166 @@ +//! 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. Paths and arbitrary data are not kept (reported when +//! they hold something). +//! +//! The parameters a placeholder holds are bounded per import ([`MAX_COST`]): definitions can be +//! shared by many instances, so a small file could otherwise ask for very many properties. + +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 properties and popup items one import creates. +pub(crate) const MAX_COST: usize = 200_000; + +/// Most items read from one placeholder popup. +const MAX_POPUP_ITEMS: usize = 1024; + +/// 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, + col: &mut Collector, +) -> PropGroup { + let mut group = ids.group(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.iter().filter(|p| !carries_nothing(p)) { + if col.placeholder_cost >= MAX_COST { + // Once the budget is spent nothing more is built (definitions shared by many instances + // would otherwise still be walked item by item). + 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 { + if p.stored().is_some() { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not kept (type {:?})", p.kind)); + } + continue; + }; + let items = match &ui { + ParamUi::Popup { options } => options.len(), + _ => 0, + }; + let cost = col.placeholder_cost.saturating_add(items).saturating_add(1); + if cost > MAX_COST { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not kept (past the import's {MAX_COST} placeholder parameters)")); + continue; + } + col.placeholder_cost = cost; + let mut prop = Property::new(ids.alloc(), ¶m_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, ParamUi, Value)> { + let (kind, ui, default) = match p.kind { + ParamKind::IntSlider | ParamKind::FixedSlider | ParamKind::FloatSlider => (Kind::EffectNumber(1.0), ParamUi::Number, 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 = match &p.items { + Some(items) => items.iter().take(MAX_POPUP_ITEMS).cloned().collect(), + None => (1..=usize::from(p.popup_item_count()?).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)) +} + +/// 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` (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 { + let plain = !p.match_name.is_empty() && p.match_name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')); + if plain { p.match_name.clone() } else { format!("param{}", p.index) } +} + +/// 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) { + Some(v) => prop.value = v, + None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?})")), + } +} diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 2825b61b3..8b3599266 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -156,6 +156,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, @@ -273,6 +277,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, + /// 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). diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 2535edcff..240c3ea0f 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -22,6 +22,7 @@ mod lumetri; mod markers; mod masks; mod mattes; +mod placeholders; mod puppet; mod resaved_defaults; mod robustness; diff --git a/crates/aep/tests/it/import/placeholders.rs b/crates/aep/tests/it/import/placeholders.rs new file mode 100644 index 000000000..847060f93 --- /dev/null +++ b/crates/aep/tests/it/import/placeholders.rs @@ -0,0 +1,200 @@ +//! #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::>(), [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`, 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); +} + +/// 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()); +} + +/// 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::>().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::() <= 200_000); + assert!(losses(&r, LossCategory::Effects, "Big Fx").iter().any(|d| d.contains("placeholder parameters"))); +} + +/// `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 { + 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 { + 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); + } +} diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d16f1dd54..f6d07bedc 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1376,6 +1376,24 @@ 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. +#[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); +} + #[test] fn syntax_error_reports_line() { let f = fx(); diff --git a/docs/aep-import.md b/docs/aep-import.md index c704e0a89..b4c37646f 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an effect without a counterpart arrives as a placeholder with its parameters, values, keys and expressions; it renders nothing and is still reported (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -318,6 +318,22 @@ parameter gets the file's value: `approximated_keyframes`. - **The fx switch** of each instance (the `tdsb` of its value group: 1 on, 0 off; `fx-switch.aep`) is imported and exported, and so is an expression on a stored effect parameter (its text and its switch). +- **Effects without a counterpart** (third-party plug-ins, pseudo effects of expression controls, + `Pseudo/…`) arrive as **placeholders** (#3087): an effect group whose effect id is the After + Effects match name. Like a missing plug-in it renders nothing and is listed by Find Missing + Effects. It holds the effect's parameters flat (without the twirl-down group markers), named as + in After Effects, each with its value, keys and expression, in the units After Effects' + expressions read (sliders and angles as stored, points in layer pixels, colours, checkboxes, + popups over the file's items with separators, read 1-based; layer parameters name their layer, + without a record the layer itself as for Layer Control), + so expressions that read them (`effect("Settings")("Speed")`) keep working. A parameter's id is + its match name (`CC Cylinder-0003`), or `param` when that name is no path segment (a + pseudo effect's `Pseudo/0.25-0001`). Paths and arbitrary data are not kept; a popup whose + definition is borrowed, or whose default slot holds no item, and that has no value record takes + its first item (reported). One import keeps at + most 200 000 placeholder parameters and popup items (a popup at most 1024 items); the rest is + reported. A placeholder is still reported in `effects` and counted in + `imported.placeholderEffects`; .aep export reports it and leaves it out. ## What is reported @@ -327,7 +343,7 @@ parameter gets the file's value: | Category | Counted | |---|---| -| `effects` | each effect on an imported layer that is not in the table (by name and match name); for an imported effect: each stored parameter outside the table, a parameter whose type or valid maximum differs from the table, keys or an expression on a layer parameter, an effect without readable parameter definitions | +| `effects` | each effect on an imported layer that is not in the table (by name and match name; it arrives as a placeholder, "kept without rendering", unless it has no readable parameter definitions); for an imported effect: each stored parameter outside the table, a parameter whose type or valid maximum differs from the table, keys or an expression on a layer parameter, an effect without readable parameter definitions | | `masks` | a mask that is not imported because its path cannot be used (a Mask Path entry without shapes, a mask on a text or shape layer whose path was never set (its default is not sampled), unreadable, no vertices, a point count that is not a multiple of three, more than 65 536 vertices, numbers that are not finite), a mask mode code outside the seven known ones (imported as Add) | | `layer_styles` | per layer: a style in switch state 3 (not sampled; state 0 is imported as a style switched off, 988), Pattern Overlay, an unknown style or parameter, a gradient (`GCst`, not decoded: EffectCraft's default gradient stays), keys on a popup or checkbox, a value that does not fit, styles on a layer that takes none here (adjustment, camera, light) | | `markers` | a marker with an unknown cue type (imported as an event cue) |