mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 06:42:31 +00:00
Import reads an .aep through TreeIndex, one depth-first array of chunks that borrows the file's bytes, instead of building and freeing the owned Chunk tree (one Vec per list). The parser mirrors read::Parser step for step: the same chunk count order against max_chunks, the same depth limit and opaque fallbacks, the same errors. The typed view reads through Node, a handle to a chunk of either tree, so the accessors work unchanged on both; .aepx keeps its owned tree. Chunk, parse and write stay for export and editing. The array starts small and doubles, never past what the input can hold or the chunk limit allows; a failed allocation is TooLarge, a tree past u32 indices TooManyChunks.
225 lines
11 KiB
Rust
225 lines
11 KiB
Rust
//! Layer styles: fixture -> import -> export -> import keeps every style value, the written
|
|
//! `ADBE Layer Styles` group is After Effects' own (951-979), and styles made in EffectCraft are
|
|
//! written with After Effects' defaults in mind.
|
|
|
|
use crate::export::helpers::{add_comp, add_layer, add_solid, export_all, key, with_comp, with_layer};
|
|
use effectcraft_aep::import::{ImportOptions, import};
|
|
use effectcraft_aep::{PropNode, parse};
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::build::Ids;
|
|
use effectcraft_project::styles::{self as st, GROUP};
|
|
use effectcraft_project::{Expression, Layer, LayerSource, Project, PropGroup};
|
|
use effectcraft_time::FrameRate;
|
|
|
|
fn import_bytes(bytes: &[u8]) -> (Project, Vec<String>) {
|
|
let mut p = Project::default();
|
|
let none = |_: &str| false;
|
|
let opts = ImportOptions { allow_losses: true, ..ImportOptions::new(&none) };
|
|
let r = import(&mut p, bytes, &opts).expect("import");
|
|
let losses = r.report.losses.iter().flat_map(|l| l.details.clone()).collect();
|
|
(p, losses)
|
|
}
|
|
|
|
fn layer<'p>(p: &'p Project, name: &str) -> &'p Layer {
|
|
p.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| l.name == name).expect("layer")
|
|
}
|
|
|
|
/// Every style parameter as (style, param, value, keys, expression).
|
|
fn values(g: &PropGroup) -> Vec<(String, String, Value, usize, Option<Expression>)> {
|
|
let mut out = Vec::new();
|
|
for s in g.groups() {
|
|
for sub in std::iter::once(s).chain(s.groups()) {
|
|
for n in &sub.children {
|
|
if let Some(p) = n.as_prop() {
|
|
// A keyed property's static value means nothing (the importer takes the first key's).
|
|
let v = p.keys.first().map_or_else(|| p.value.clone(), |k| k.value.clone());
|
|
out.push((s.match_id.clone(), p.match_id.clone(), v, p.keys.len(), p.expr.clone()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The stored `ADBE Layer Styles` group of the first layer with a style applied.
|
|
fn stored_group(bytes: &[u8]) -> Vec<u8> {
|
|
let f = parse(bytes).expect("parse");
|
|
let project = effectcraft_aep::Project::new(&f).expect("project");
|
|
for item in project.all_items() {
|
|
let Some(c) = item.comp() else { continue };
|
|
for l in c.layers() {
|
|
if let Some(PropNode::Group(g)) = l.properties().and_then(|p| p.get("ADBE Layer Styles"))
|
|
&& g.flags() == Some(1)
|
|
{
|
|
// As far as the exporter writes records (`decoded`).
|
|
let mut f2 = effectcraft_aep::AepFile {
|
|
chunks: vec![crate::export::helpers::decoded(&g.chunk().to_chunk().into_owned())],
|
|
trailer: std::borrow::Cow::Owned(vec![]),
|
|
};
|
|
f2.trailer = std::borrow::Cow::Owned(vec![]);
|
|
return effectcraft_aep::write(&f2).expect("write");
|
|
}
|
|
}
|
|
}
|
|
panic!("no layer with a style");
|
|
}
|
|
|
|
const CASES: &[&str] = &[
|
|
"951-styles-all-nine-defaults-on-B.aep",
|
|
"966-style-drop-shadow-opacity50-distance20.aep",
|
|
"970-style-stroke-size10-color-green.aep",
|
|
"974-style-gradient-overlay-angle45-scale150.aep",
|
|
"975-style-fill-opacity0-with-drop-shadow.aep",
|
|
"976-style-drop-shadow-opacity-keyed-100-20-linear.aep",
|
|
"977-styles-all-params-changed.aep",
|
|
"979-style-inner-glow-source-2.aep",
|
|
"988-style-drop-shadow-switched-off.aep",
|
|
];
|
|
|
|
#[test]
|
|
fn fixtures_round_trip_without_losses() {
|
|
for name in CASES {
|
|
let bytes = fixture!(name);
|
|
let (p, losses) = import_bytes(&bytes);
|
|
assert!(losses.is_empty(), "{name}: {losses:?}");
|
|
let r = export_all(&p);
|
|
assert!(r.report.losses.is_empty(), "{name}: {:?}", r.report.losses);
|
|
assert!(r.report.exported.layer_styles > 0, "{name}");
|
|
let (q, losses) = import_bytes(&r.bytes);
|
|
assert!(losses.is_empty(), "{name} re-import: {losses:?}");
|
|
let (a, b) = (layer(&p, "B"), layer(&q, "B"));
|
|
assert_eq!(values(a.props.sub(GROUP).expect("styles")), values(b.props.sub(GROUP).expect("styles")), "{name}");
|
|
// The written group is the one After Effects saved, in its decoded bytes.
|
|
assert_eq!(stored_group(&bytes), stored_group(&r.bytes), "{name}: stored group differs");
|
|
let (ca, cb) = (p.comps().next().expect("comp").1, q.comps().next().expect("comp").1);
|
|
assert_eq!(ca.global_light, cb.global_light, "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_style_made_here_is_written_with_after_effects_defaults_in_mind() {
|
|
let mut p = Project::default();
|
|
let comp = add_comp(&mut p, "C", 400, 300, FrameRate::FPS_25, 4.0, None);
|
|
let s = add_solid(&mut p, "S", 200, 100, [0.2, 0.4, 0.8], None);
|
|
add_layer(&mut p, comp, "S", LayerSource::Solid { item: s }, (200, 100));
|
|
let gl = p.comp(comp).expect("comp").global_light.clone();
|
|
let mut next = p.next_id;
|
|
with_layer(&mut p, comp, 0, |l| {
|
|
let mut ids = Ids(&mut next);
|
|
st::add_style(l, &mut ids, "dropShadow", &gl, true);
|
|
st::add_style(l, &mut ids, "stroke", &gl, true);
|
|
let g = l.props.sub_mut(GROUP).expect("styles");
|
|
let ds = g.sub_mut("dropShadow").expect("ds");
|
|
ds.get_mut("opacity").expect("opacity").keys = vec![key(0.0, Value::Scalar(100.0)), key(2.0, Value::Scalar(20.0))];
|
|
ds.get_mut("color").expect("color").expr = Some(Expression { text: "[1, 0, 0, 1]".into(), enabled: true });
|
|
let stroke = g.sub_mut("stroke").expect("stroke");
|
|
stroke.get_mut("position").expect("position").value = Value::Enum(2);
|
|
stroke.get_mut("blendMode").expect("mode").value = Value::Enum(23);
|
|
});
|
|
p.next_id = next;
|
|
with_comp(&mut p, comp, |c| st::set_global_light(c, Some(60.0), None, effectcraft_time::Tick::ZERO));
|
|
let r = export_all(&p);
|
|
assert!(r.report.losses.is_empty(), "{:?}", r.report.losses);
|
|
let (q, losses) = import_bytes(&r.bytes);
|
|
assert!(losses.is_empty(), "{losses:?}");
|
|
let (a, b) = (layer(&p, "S"), layer(&q, "S"));
|
|
assert_eq!(values(a.props.sub(GROUP).expect("styles")), values(b.props.sub(GROUP).expect("styles")));
|
|
// EffectCraft's Use Global Light default (on) differs from After Effects' (off): written.
|
|
assert_eq!(b.props.sub(GROUP).and_then(|g| g.sub("dropShadow")).and_then(|g| g.get("useGlobalLight")).map(|p| p.value.clone()), Some(Value::Bool(true)));
|
|
assert_eq!(q.comps().next().expect("comp").1.global_light.angle.value, Value::Scalar(60.0));
|
|
}
|
|
|
|
#[test]
|
|
fn what_is_not_written_is_reported() {
|
|
let mut p = Project::default();
|
|
let comp = add_comp(&mut p, "C", 400, 300, FrameRate::FPS_25, 4.0, None);
|
|
let s = add_solid(&mut p, "S", 200, 100, [0.2, 0.4, 0.8], None);
|
|
add_layer(&mut p, comp, "S", LayerSource::Solid { item: s }, (200, 100));
|
|
let gl = p.comp(comp).expect("comp").global_light.clone();
|
|
let mut next = p.next_id;
|
|
with_layer(&mut p, comp, 0, |l| {
|
|
let mut ids = Ids(&mut next);
|
|
st::add_style(l, &mut ids, "satin", &gl, false);
|
|
st::add_style(l, &mut ids, "gradientOverlay", &gl, true);
|
|
let g = l.props.sub_mut(GROUP).expect("styles");
|
|
let go = g.sub_mut("gradientOverlay").expect("go");
|
|
if let Some(Value::Gradient(gr)) = go.get_mut("colors").map(|p| &mut p.value) {
|
|
gr.colors[0].1 = [1.0, 0.0, 0.0, 1.0];
|
|
}
|
|
go.get_mut("style").expect("style").keys = vec![key(0.0, Value::Enum(1)), key(1.0, Value::Enum(2))];
|
|
let adv = g.sub_mut(st::BLENDING).and_then(|b| b.sub_mut("advancedBlending")).expect("adv");
|
|
adv.get_mut("knockout").expect("knockout").value = Value::Enum(1);
|
|
});
|
|
p.next_id = next;
|
|
let r = export_all(&p);
|
|
let lines: Vec<String> = r.report.losses.iter().flat_map(|l| l.details.clone()).collect();
|
|
let has = |s: &str| lines.iter().any(|l| l.contains(s));
|
|
assert!(has("gradientFill/gradient"), "{lines:?}");
|
|
assert!(has("gradientFill/type: keys"), "{lines:?}");
|
|
assert!(has("Knockout"), "{lines:?}");
|
|
let (q, _) = import_bytes(&r.bytes);
|
|
let g = layer(&q, "S").props.sub(GROUP).expect("styles");
|
|
// A switched-off style is written switched off (988).
|
|
assert!(g.sub("satin").is_some_and(|s| !s.enabled) && g.sub("gradientOverlay").is_some_and(|s| s.enabled));
|
|
}
|
|
|
|
#[test]
|
|
fn styles_on_every_layer_kind_that_takes_them() {
|
|
let mut p = Project::default();
|
|
let comp = add_comp(&mut p, "C", 400, 300, FrameRate::FPS_25, 4.0, None);
|
|
let s = add_solid(&mut p, "S", 200, 100, [0.2, 0.4, 0.8], None);
|
|
add_layer(&mut p, comp, "S", LayerSource::Solid { item: s }, (200, 100));
|
|
add_layer(&mut p, comp, "Shape", LayerSource::Shape, (400, 300));
|
|
add_layer(&mut p, comp, "Text", LayerSource::Text, (400, 300));
|
|
add_layer(&mut p, comp, "Null", LayerSource::Null, (100, 100));
|
|
let gl = p.comp(comp).expect("comp").global_light.clone();
|
|
let mut next = p.next_id;
|
|
for i in 0..4 {
|
|
with_layer(&mut p, comp, i, |l| {
|
|
st::add_style(l, &mut Ids(&mut next), "stroke", &gl, true);
|
|
});
|
|
}
|
|
p.next_id = next;
|
|
let r = export_all(&p);
|
|
let (q, _) = import_bytes(&r.bytes);
|
|
for name in ["S", "Shape", "Text"] {
|
|
assert!(layer(&q, name).props.sub(GROUP).and_then(|g| g.sub("stroke")).is_some(), "{name}");
|
|
}
|
|
}
|
|
|
|
/// An expression on a style popup that After Effects stores in another order (Inner Glow Source:
|
|
/// Edge is 1 there, Center 1 here) means something else there: reported, and a strict export
|
|
/// refuses. A popup in the same order stays lossless (review finding).
|
|
#[test]
|
|
fn a_style_popup_expression_is_reported_only_when_the_items_are_stored_in_another_order() {
|
|
let build = |param: &str| {
|
|
let mut p = Project::default();
|
|
let comp = add_comp(&mut p, "C", 400, 300, FrameRate::FPS_25, 4.0, None);
|
|
let s = add_solid(&mut p, "S", 200, 100, [0.2, 0.4, 0.8], None);
|
|
add_layer(&mut p, comp, "S", LayerSource::Solid { item: s }, (200, 100));
|
|
let gl = p.comp(comp).expect("comp").global_light.clone();
|
|
let mut next = p.next_id;
|
|
with_layer(&mut p, comp, 0, |l| {
|
|
let mut ids = Ids(&mut next);
|
|
st::add_style(l, &mut ids, "innerGlow", &gl, true);
|
|
let g = l.props.sub_mut(GROUP).expect("styles");
|
|
let glow = g.sub_mut("innerGlow").expect("glow");
|
|
if let Some(prop) = glow.get_mut(param) {
|
|
prop.expr = Some(Expression { text: "1".into(), enabled: true });
|
|
} else {
|
|
panic!("no {param}");
|
|
}
|
|
});
|
|
p.next_id = next;
|
|
p
|
|
};
|
|
let strict = |p: &Project| effectcraft_aep::export::export(p, &effectcraft_aep::export::ExportOptions { allow_losses: false }).is_ok();
|
|
let reversed = build("source");
|
|
let r = export_all(&reversed);
|
|
assert_eq!(r.report.count(effectcraft_aep::export::LossCategory::LayerStyles), 1, "{:?}", r.report.losses);
|
|
assert!(!strict(&reversed));
|
|
let same = build("technique");
|
|
assert_eq!(export_all(&same).report.count(effectcraft_aep::export::LossCategory::LayerStyles), 0);
|
|
assert!(strict(&same));
|
|
}
|