mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
Merge remote-tracking branch 'upstream/main' into fix/3535-analysis-inputs
This commit is contained in:
10 files changed
+657
-47
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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, ¶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 +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(),
|
||||
|
||||
@@ -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(), ¶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<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:?})")),
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -22,6 +22,7 @@ mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod mattes;
|
||||
mod placeholders;
|
||||
mod puppet;
|
||||
mod resaved_defaults;
|
||||
mod robustness;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# After Effects project import
|
||||
|
||||
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an item made from one layer of a Photoshop file shows that layer (#3087); before that: the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#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 RotatLine truncated
|
||||
> **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: an item made from one layer of a Photoshop file shows that layer (#3087); before that: the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#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 imLine truncated
|
||||
|
||||
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,26 @@ 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 (expressions also find it by that name, `effect("Pseudo/0.25")`; its match id
|
||||
is that name as one path segment, `Pseudo_0_25`). 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, a float slider with its range, 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, and their keys and expression are
|
||||
reported),
|
||||
so expressions that read them (`effect("Settings")("Speed")`) keep working. A parameter's id is
|
||||
its match name (`CC Cylinder-0003`), or `param<index>` 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; parameters it
|
||||
does not keep count, and an item one more per 16 bytes of its name); the rest is
|
||||
reported, and once that is spent a later effect without a counterpart arrives without a
|
||||
placeholder (reported). A placeholder is still reported in `effects` and counted in
|
||||
`imported.placeholderEffects`; .aep export reports it and leaves it out.
|
||||
|
||||
## What is reported
|
||||
|
||||
@@ -331,7 +351,7 @@ for the `unlisted` ones. The categories:
|
||||
|
||||
| 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) |
|
||||
|
||||
Reference in new issue
Block a user