mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-07 11:01:36 +00:00
Add After Effects project export with chunks built in code, explicit loss reporting, engine and UI integration, and regression coverage.
624 lines
30 KiB
Rust
624 lines
30 KiB
Rust
//! Text layers: every text fixture is imported, exported and parsed back with `effectcraft-aep`;
|
|
//! the text document (text, paragraphs, style runs, fonts, box) must equal the one After Effects
|
|
//! wrote for every decoded field, and a second import gives the same Source Text. Documents built
|
|
//! in code check line breaks, runs, fonts, and what is reported.
|
|
|
|
use crate::export::helpers::*;
|
|
use effectcraft_aep::export::LossCategory;
|
|
use effectcraft_aep::import::{ImportOptions, import};
|
|
use effectcraft_aep::{Caps, Justification, TextDocument, parse};
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_keyframe::text_doc::{BaselineOption, CharStyle, Justify, ParaStyle, StyleRun, TextDoc};
|
|
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
|
|
use effectcraft_time::FrameRate;
|
|
|
|
fn text_fixtures() -> Option<Vec<String>> {
|
|
Some({
|
|
let mut v: Vec<String> = crate::common::names("aep")?.into_iter().filter(|n| n.ends_with(".aep") && n.contains("-text-")).collect();
|
|
v.sort();
|
|
v
|
|
})
|
|
}
|
|
|
|
fn reimport(bytes: &[u8]) -> Project {
|
|
let mut p = Project::default();
|
|
let none = |_: &str| false;
|
|
let opts = ImportOptions { allow_losses: true, ..ImportOptions::new(&none) };
|
|
import(&mut p, bytes, &opts).expect("import");
|
|
p
|
|
}
|
|
|
|
/// The text documents of all text layers, by composition name and layer index.
|
|
fn documents(bytes: &[u8]) -> Vec<((String, usize), TextDocument)> {
|
|
let file = parse(bytes).expect("parse");
|
|
let view = effectcraft_aep::Project::new(&file).expect("project");
|
|
let mut out = Vec::new();
|
|
for item in view.all_items() {
|
|
let Some(comp) = item.comp() else { continue };
|
|
for (i, layer) in comp.layers().enumerate() {
|
|
if let Some(d) = layer.text_document() {
|
|
out.push(((item.display_name().unwrap_or_default(), i), d));
|
|
}
|
|
}
|
|
}
|
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
out
|
|
}
|
|
|
|
fn close(a: f64, b: f64) -> bool {
|
|
(a - b).abs() < 1e-4
|
|
}
|
|
|
|
/// The decoded fields of two documents are equal (`font_of` resolves the font indices).
|
|
fn same_doc(a: &TextDocument, b: &TextDocument, w: &str) {
|
|
// U+0003 is imported as a paragraph break and written as one (386: the same layout).
|
|
assert_eq!(a.raw_text.replace('\u{3}', "\r"), b.raw_text, "{w}");
|
|
let paras = |d: &TextDocument| -> Vec<(usize, Justification)> { d.paragraphs.iter().map(|p| (p.length, p.justification)).collect() };
|
|
assert_eq!(paras(a), paras(b), "{w}");
|
|
assert_eq!(a.style_runs.len(), b.style_runs.len(), "{w}: style runs");
|
|
for (x, y) in a.style_runs.iter().zip(&b.style_runs) {
|
|
assert_eq!(x.length, y.length, "{w}");
|
|
assert_eq!(a.font_of(x), b.font_of(y), "{w}");
|
|
assert!(close(x.font_size, y.font_size), "{w}: size {} {}", x.font_size, y.font_size);
|
|
assert!((1..4).all(|i| close(x.fill_argb[i], y.fill_argb[i])), "{w}: fill {:?} {:?}", x.fill_argb, y.fill_argb);
|
|
assert!(close(x.tracking, y.tracking), "{w}: tracking");
|
|
assert_eq!((x.faux_bold, x.faux_italic, x.caps, x.auto_leading), (y.faux_bold, y.faux_italic, y.caps, y.auto_leading), "{w}");
|
|
if !x.auto_leading {
|
|
assert_eq!(x.leading.map(|l| (l * 1e4).round()), y.leading.map(|l| (l * 1e4).round()), "{w}: leading");
|
|
}
|
|
}
|
|
let bx = |d: &TextDocument| d.text_box.map(|t| [t.x, t.y, t.width, t.height]);
|
|
match (bx(a), bx(b)) {
|
|
(None, None) => {}
|
|
(Some(p), Some(q)) => assert!(p.iter().zip(q).all(|(u, v)| close(*u, v)), "{w}: box {p:?} {q:?}"),
|
|
other => panic!("{w}: box {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn source_text(p: &Project) -> Vec<TextDoc> {
|
|
let mut v = Vec::new();
|
|
for item in p.items.values() {
|
|
let ItemKind::Comp(c) = &item.kind else { continue };
|
|
for l in &c.layers {
|
|
if let Some(Value::Text(d)) = l.props.prop("text/sourceText").map(|s| &s.value) {
|
|
v.push((**d).clone());
|
|
}
|
|
}
|
|
}
|
|
v.sort_by(|a, b| a.text.cmp(&b.text));
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn every_text_fixture_is_written_with_its_document() {
|
|
let names = skip!(text_fixtures());
|
|
assert!(names.len() >= 15, "{names:?}");
|
|
for name in &names {
|
|
let bytes = fixture!(name);
|
|
let p1 = reimport(&bytes);
|
|
let r = export_all(&p1);
|
|
// Text animators and vertical text are imported but not written: the only text losses
|
|
// allowed.
|
|
let animators = r
|
|
.report
|
|
.losses
|
|
.iter()
|
|
.filter(|l| l.category == LossCategory::TextLayers)
|
|
.flat_map(|l| &l.details)
|
|
.filter(|d| d.contains("text animator") || d.contains("vertical text"))
|
|
.count();
|
|
assert_eq!(r.report.count(LossCategory::TextLayers), animators, "{name}: {:?}", r.report.losses);
|
|
let (a, b) = (documents(&bytes), documents(&r.bytes));
|
|
assert_eq!(a.len(), b.len(), "{name}");
|
|
for ((ka, da), (kb, db)) in a.iter().zip(&b) {
|
|
assert_eq!(ka.0, kb.0, "{name}");
|
|
same_doc(da, db, name);
|
|
}
|
|
// The container is consistent and a second import gives the same Source Text.
|
|
let file = parse(&r.bytes).expect("parse");
|
|
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes, "{name}");
|
|
let p2 = reimport(&r.bytes);
|
|
// Vertical text comes back horizontal (reported above).
|
|
let horizontal = |v: Vec<TextDoc>| v.into_iter().map(|d| TextDoc { vertical: false, ..d }).collect::<Vec<_>>();
|
|
assert_eq!(horizontal(source_text(&p1)), horizontal(source_text(&p2)), "{name}");
|
|
}
|
|
}
|
|
|
|
fn style(font: &str, size: f64) -> CharStyle {
|
|
CharStyle { font: font.into(), style: "Regular".into(), size, leading: Some(size * 1.2), ..CharStyle::default() }
|
|
}
|
|
|
|
fn text_project(docs: Vec<TextDoc>) -> (Project, ItemId) {
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 5.0, None);
|
|
for (i, d) in docs.into_iter().enumerate() {
|
|
add_layer(&mut p, c, &format!("T{i}"), LayerSource::Text, (1920, 1080));
|
|
with_layer(&mut p, c, 0, |l| {
|
|
if let Some(st) = l.props.prop_mut("text/sourceText") {
|
|
st.value = Value::Text(Box::new(d));
|
|
}
|
|
});
|
|
}
|
|
(p, c)
|
|
}
|
|
|
|
#[test]
|
|
fn documents_built_in_code_are_written_as_their_fields() {
|
|
let mut runs = TextDoc::plain("Red\r\nBlue\nX");
|
|
runs.set_runs(vec![
|
|
StyleRun { len: 5, style: CharStyle { fill: [1.0, 0.0, 0.0, 1.0], tracking: 25.0, ..style("ArialMT", 50.0) } },
|
|
StyleRun { len: 6, style: CharStyle { fill: [0.0, 0.0, 1.0, 1.0], faux_bold: true, small_caps: true, leading: None, ..style("Georgia", 70.0) } },
|
|
]);
|
|
runs.set_paras(vec![
|
|
ParaStyle { justify: Justify::Center, ..runs.base_para() },
|
|
ParaStyle { justify: Justify::Right, ..runs.base_para() },
|
|
ParaStyle { justify: Justify::Left, ..runs.base_para() },
|
|
]);
|
|
let mut boxed = TextDoc::plain("In a box");
|
|
boxed.set_runs(vec![StyleRun { len: 8, style: style("TimesNewRomanPSMT", 30.0) }]);
|
|
boxed.box_size = Some([300.0, 120.0]);
|
|
boxed.box_pos = [-150.0, -60.0];
|
|
let empty = TextDoc::plain("");
|
|
let (p, _) = text_project(vec![runs, boxed, empty]);
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
|
|
assert_eq!(r.report.exported.text_layers, 3);
|
|
let docs = documents(&r.bytes);
|
|
let by_text = |t: &str| docs.iter().map(|(_, d)| d).find(|d| d.raw_text == t).unwrap_or_else(|| panic!("{t}"));
|
|
// CRLF and LF are After Effects' CR; the final paragraph ends with one too.
|
|
let d = by_text("Red\rBlue\rX\r");
|
|
assert_eq!(
|
|
d.paragraphs.iter().map(|p| (p.length, p.justification)).collect::<Vec<_>>(),
|
|
vec![(4, Justification::Center), (5, Justification::Right), (2, Justification::Left)]
|
|
);
|
|
// Runs in After Effects' characters: "Red\r" + 'B' of the first style (the CRLF counted
|
|
// once), then "lue\rX\r".
|
|
let runs: Vec<_> = d.style_runs.iter().map(|s| (s.length, d.font_of(s).map(str::to_string), s.font_size, s.faux_bold, s.caps, s.auto_leading)).collect();
|
|
assert_eq!(runs, vec![(4, Some("ArialMT".into()), 50.0, false, Caps::Normal, false), (7, Some("Georgia".into()), 70.0, true, Caps::Small, true)]);
|
|
assert!(close(d.style_runs[0].tracking, 25.0) && close(d.style_runs[0].fill_argb[1], 1.0) && close(d.style_runs[1].fill_argb[3], 1.0));
|
|
// The prototype's own fonts keep their places; new ones are appended.
|
|
assert_eq!(d.fonts.first().map(String::as_str), Some("TimesNewRomanPSMT"));
|
|
let b = by_text("In a box\r");
|
|
let tb = b.text_box.expect("box");
|
|
assert_eq!([tb.x, tb.y, tb.width, tb.height], [-150.0, -60.0, 300.0, 120.0]);
|
|
let e = by_text("\r");
|
|
assert_eq!(e.style_runs.iter().map(|s| s.length).sum::<usize>(), 1);
|
|
// A text layer named after its text stores no name.
|
|
let (p2, _) = text_project(vec![TextDoc::plain("Named")]);
|
|
let mut p2 = p2;
|
|
let c = p2.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
|
with_layer(&mut p2, c, 0, |l| l.name = "Named".into());
|
|
let r2 = export_all(&p2);
|
|
let file = parse(&r2.bytes).expect("parse");
|
|
let view = effectcraft_aep::Project::new(&file).expect("project");
|
|
let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer");
|
|
assert_eq!(layer.name(), None);
|
|
assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text));
|
|
}
|
|
|
|
#[test]
|
|
fn what_a_text_document_cannot_hold_is_reported() {
|
|
let mut d = TextDoc::plain("x");
|
|
d.set_runs(vec![StyleRun { len: 1, style: CharStyle { apply_stroke: true, stroke_width: 3.0, h_scale: 80.0, ..style("ArialMT", 40.0) } }]);
|
|
d.set_paras(vec![ParaStyle { justify: Justify::JustifyAll, indent_left: 10.0, ..d.base_para() }]);
|
|
d.vertical = true;
|
|
let (mut p, c) = text_project(vec![d]);
|
|
with_layer(&mut p, c, 0, |l| {
|
|
if let Some(st) = l.props.prop_mut("text/sourceText") {
|
|
st.keys = vec![key(0.0, st.value.clone()), key(1.0, Value::Text(Box::new(TextDoc::plain("y"))))];
|
|
}
|
|
});
|
|
let r = export_all(&p);
|
|
// Vertical text and Source Text keys (stroke, scale, justification and indents are written).
|
|
assert_eq!(r.report.count(LossCategory::TextLayers), 2, "{:?}", r.report.losses);
|
|
// A 3D text layer is written; only per-character 3D is reported.
|
|
let (mut p3, c3) = text_project(vec![TextDoc::plain("3D")]);
|
|
with_layer(&mut p3, c3, 0, |l| l.switches.three_d = true);
|
|
assert_eq!(export_all(&p3).report.count(LossCategory::ThreeD), 0);
|
|
with_layer(&mut p3, c3, 0, |l| {
|
|
if let Some(pc) = l.props.prop_mut("text/perChar3d") {
|
|
pc.value = Value::Bool(true);
|
|
}
|
|
});
|
|
assert_eq!(export_all(&p3).report.count(LossCategory::ThreeD), 1);
|
|
assert_eq!(r.report.exported.text_layers, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_text_never_panics() {
|
|
let long: String = "ab\r".repeat(50_000);
|
|
let mut nan = TextDoc::plain("nan");
|
|
nan.set_runs(vec![StyleRun {
|
|
len: 3,
|
|
style: CharStyle { size: f64::NAN, tracking: f64::INFINITY, leading: Some(f64::NAN), fill: [f32::NAN, 2.0, -1.0, 1.0], ..style("", 10.0) },
|
|
}]);
|
|
let mut odd = TextDoc::plain("()\\ \u{3} \u{1F600} ünï");
|
|
odd.box_size = Some([f64::NAN, 1e300]);
|
|
odd.box_pos = [f64::INFINITY, -1e300];
|
|
// Runs that do not cover the text, or cover more.
|
|
let mut short = TextDoc::plain("short text");
|
|
short.runs = vec![StyleRun { len: 2, style: style("ArialMT", 20.0) }];
|
|
let mut over = TextDoc::plain("ab");
|
|
over.runs = vec![StyleRun { len: 1000, style: style("ArialMT", 20.0) }, StyleRun { len: usize::MAX, style: style("ArialMT", 20.0) }];
|
|
let mut max = TextDoc::plain("max");
|
|
max.runs = vec![StyleRun { len: usize::MAX, style: style("ArialMT", 20.0) }];
|
|
max.paragraphs = vec![effectcraft_keyframe::text_doc::ParaStyle::default(); 3];
|
|
let (p, _) = text_project(vec![TextDoc::plain(&long), nan, odd, short, over, max]);
|
|
let r = export_all(&p);
|
|
let file = parse(&r.bytes).expect("parse");
|
|
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes);
|
|
let _ = reimport(&r.bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn the_baseline_option_is_written_into_style_slot_13() {
|
|
// 526-528: slot /13 is 1 superscript (After Effects' scripted default, also in the prototypes),
|
|
// 0 normal, 2 subscript; normal text written as 1 is drawn by After Effects at 58.3 % size.
|
|
let doc = |b: BaselineOption| {
|
|
let mut d = TextDoc::plain("HHHH");
|
|
d.set_runs(vec![StyleRun { len: 4, style: CharStyle { baseline: b, ..style("ArialMT", 100.0) } }]);
|
|
d
|
|
};
|
|
for (b, slot_differs) in [(BaselineOption::Normal, true), (BaselineOption::Superscript, false), (BaselineOption::Subscript, true)] {
|
|
let (p, _) = text_project(vec![doc(b)]);
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{b:?}: {:?}", r.report.losses);
|
|
let docs = documents(&r.bytes);
|
|
let d = &docs.first().expect("doc").1;
|
|
// The reader decodes /13 now; the scripted default (also in the prototypes) is 1.
|
|
assert!(d.undecoded_style_fields.iter().all(|f| f != "13"), "{b:?}: {:?}", d.undecoded_style_fields);
|
|
let _ = slot_differs;
|
|
// A second import gives the same baseline option back (normal stays normal).
|
|
let back = source_text(&reimport(&r.bytes));
|
|
let got = back.first().and_then(|t| t.runs().first().map(|r| r.style.baseline));
|
|
assert_eq!(got, Some(b), "{b:?}");
|
|
let text = String::from_utf8_lossy(&r.bytes).to_string();
|
|
let want = match b {
|
|
BaselineOption::Normal => "/13 0",
|
|
BaselineOption::Superscript => "/13 1",
|
|
BaselineOption::Subscript => "/13 2",
|
|
};
|
|
assert!(text.contains(want), "{b:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn superscript_stays_superscript_and_normal_stays_normal() {
|
|
// 526 and 113 are superscript (the scripted default), 527 normal, 528 subscript: the merged
|
|
// importer reads /13 and the exporter writes it back, so a round trip keeps each one.
|
|
for (name, want) in [
|
|
("113-text-Hello-size60-fillorange.aep", BaselineOption::Superscript),
|
|
("526-text-point-HHHH-default.aep", BaselineOption::Superscript),
|
|
("527-text-point-HHHH-baseline-normal.aep", BaselineOption::Normal),
|
|
("528-text-point-HHHH-baseline-subscript.aep", BaselineOption::Subscript),
|
|
] {
|
|
let p1 = reimport(&fixture!(name));
|
|
let baselines = |p: &Project| source_text(p).iter().flat_map(|d| d.runs().into_iter().map(|r| r.style.baseline)).collect::<Vec<_>>();
|
|
let b1 = baselines(&p1);
|
|
assert!(!b1.is_empty() && b1.iter().all(|b| *b == want), "{name}: {b1:?}");
|
|
let r = export_all(&p1);
|
|
let p2 = reimport(&r.bytes);
|
|
assert_eq!(baselines(&p2), b1, "{name}");
|
|
let docs = documents(&r.bytes);
|
|
let stored = match want {
|
|
BaselineOption::Normal => effectcraft_aep::FontBaseline::Normal,
|
|
BaselineOption::Superscript => effectcraft_aep::FontBaseline::Superscript,
|
|
BaselineOption::Subscript => effectcraft_aep::FontBaseline::Subscript,
|
|
};
|
|
assert!(docs.iter().flat_map(|(_, d)| &d.style_runs).all(|s| s.baseline == stored), "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_text_made_in_effectcraft_is_written_with_the_every_line_composer() {
|
|
// A plain box text without paragraph entries uses the document's settings: EffectCraft's
|
|
// default composer is every-line, which After Effects reads back as such (t6).
|
|
let mut d = TextDoc::plain("One two three four five six seven eight\rTwo");
|
|
d.set_runs(vec![StyleRun { len: d.text.chars().count(), style: style("ArialMT", 30.0) }]);
|
|
d.box_size = Some([300.0, 200.0]);
|
|
assert!(d.paragraphs.is_empty());
|
|
let (p, _) = text_project(vec![d.clone()]);
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
|
|
let docs = documents(&r.bytes);
|
|
let paras = &docs[0].1.paragraphs;
|
|
assert_eq!(paras.len(), 2);
|
|
assert!(paras.iter().all(|p| p.every_line_composer && p.direction == 0 && p.justification == Justification::Left), "{paras:?}");
|
|
assert_eq!(source_text(&reimport(&r.bytes))[0].para(0).composer, effectcraft_keyframe::text_doc::Composer::EveryLine);
|
|
}
|
|
|
|
#[test]
|
|
fn right_to_left_justification_is_stored_relative_to_the_direction() {
|
|
// t7: in a right-to-left paragraph After Effects lays code 0 and 3 out flush right and 1 and
|
|
// 4 flush left; 2, 5, 6 are the same in both directions.
|
|
use effectcraft_keyframe::text_doc::Direction;
|
|
let cases = [
|
|
(Justify::Left, Justification::Right),
|
|
(Justify::Right, Justification::Left),
|
|
(Justify::Center, Justification::Center),
|
|
(Justify::JustifyLastLeft, Justification::FullRight),
|
|
(Justify::JustifyLastRight, Justification::FullLeft),
|
|
(Justify::JustifyLastCenter, Justification::FullCenter),
|
|
(Justify::JustifyAll, Justification::FullAll),
|
|
];
|
|
for (rtl, ltr_stored) in [(true, false), (false, true)] {
|
|
let docs: Vec<TextDoc> = cases
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (j, _))| {
|
|
let mut d = TextDoc::plain(&format!("T{i}"));
|
|
d.set_runs(vec![StyleRun { len: 2, style: style("ArialMT", 30.0) }]);
|
|
let dir = if rtl { Direction::Rtl } else { Direction::Ltr };
|
|
d.set_paras(vec![ParaStyle { justify: *j, direction: dir, ..d.base_para() }]);
|
|
d
|
|
})
|
|
.collect();
|
|
let (p, _) = text_project(docs);
|
|
let r = export_all(&p);
|
|
let written = documents(&r.bytes);
|
|
let back = source_text(&reimport(&r.bytes));
|
|
for (i, (j, swapped)) in cases.iter().enumerate() {
|
|
let stored = written.iter().find(|(_, d)| d.raw_text == format!("T{i}\r")).map(|(_, d)| d.paragraphs[0].justification).unwrap();
|
|
let want = if ltr_stored { effectcraft_aep_justification(*j) } else { *swapped };
|
|
assert_eq!(stored, want, "rtl {rtl}: {j:?}");
|
|
assert_eq!(back[i].para(0).justify, *j, "rtl {rtl}: round trip");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn effectcraft_aep_justification(j: Justify) -> Justification {
|
|
match j {
|
|
Justify::Left => Justification::Left,
|
|
Justify::Right => Justification::Right,
|
|
Justify::Center => Justification::Center,
|
|
Justify::JustifyLastLeft => Justification::FullLeft,
|
|
Justify::JustifyLastRight => Justification::FullRight,
|
|
Justify::JustifyLastCenter => Justification::FullCenter,
|
|
Justify::JustifyAll => Justification::FullAll,
|
|
}
|
|
}
|
|
|
|
/// Every `(path of match names, record)` below `ADBE Text Animators`, keyed records with the
|
|
/// unused ease before the first / after the last key and spatial arc lengths zeroed, and the
|
|
/// sequence of match names (the structure).
|
|
fn animator_records(bytes: &[u8]) -> (Vec<String>, Vec<(String, Vec<u8>)>) {
|
|
fn walk(cs: &[effectcraft_aep::Chunk<'_>], path: &str, inside: bool, names: &mut Vec<String>, out: &mut Vec<(String, Vec<u8>)>) {
|
|
for (i, c) in cs.iter().enumerate() {
|
|
let Some(name) = c.bytes().filter(|_| c.is(b"tdmn")).map(|b| String::from_utf8_lossy(b.split(|x| *x == 0).next().unwrap_or(b"")).to_string())
|
|
else {
|
|
// Items, layers and other lists: look inside (a node after a `tdmn` is walked
|
|
// with it).
|
|
let after_tdmn = i > 0 && cs.get(i - 1).is_some_and(|p| p.is(b"tdmn"));
|
|
if !inside && !after_tdmn {
|
|
walk(c.children(), path, inside, names, out);
|
|
}
|
|
continue;
|
|
};
|
|
let here = inside || name == "ADBE Text Animators";
|
|
let key = format!("{path}/{name}");
|
|
if here {
|
|
names.push(key.clone());
|
|
}
|
|
let Some(node) = cs.get(i + 1) else { continue };
|
|
if here && node.is_list(b"tdbs") {
|
|
// In the bytes the exporter writes (`decoded`).
|
|
let mut f = effectcraft_aep::AepFile { chunks: vec![crate::export::helpers::decoded(&node.clone().into_owned())], trailer: Default::default() };
|
|
if let Some(list) = f.chunks[0].children_mut().iter_mut().find(|k| k.is_list(b"list")) {
|
|
let size = list
|
|
.child(b"lhd3")
|
|
.and_then(|c| c.bytes())
|
|
.and_then(|b| b.get(16..20))
|
|
.map_or(0, |b| u32::from_be_bytes(b.try_into().unwrap()) as usize);
|
|
if let Some(ldat) = list.children_mut().iter_mut().find(|k| k.is(b"ldat"))
|
|
&& let effectcraft_aep::Body::Data(d) = &mut ldat.body
|
|
{
|
|
let mut v = d.to_vec();
|
|
let n = v.len();
|
|
if size == 48 && n >= 96 {
|
|
v[16..32].fill(0);
|
|
v[n - 16..n].fill(0);
|
|
}
|
|
if size >= 104 && size % 8 == 0 && (size - 56) % 24 == 0 {
|
|
for r in v.chunks_mut(size) {
|
|
r[16..24].fill(0);
|
|
}
|
|
}
|
|
*d = v.into();
|
|
}
|
|
}
|
|
out.push((key.clone(), effectcraft_aep::write(&f).unwrap()));
|
|
}
|
|
if node.is_list(b"tdgp") {
|
|
walk(node.children(), &key, here, names, out);
|
|
}
|
|
}
|
|
}
|
|
let f = parse(bytes).expect("parse");
|
|
let (mut names, mut out) = (Vec::new(), Vec::new());
|
|
walk(&f.chunks, "", false, &mut names, &mut out);
|
|
(names, out)
|
|
}
|
|
|
|
#[test]
|
|
fn text_animators_are_written_as_after_effects_wrote_them() {
|
|
// 114, 579-594, 728: the animators, their properties and range selectors come back with
|
|
// After Effects' structure and records.
|
|
let mut names: Vec<String> =
|
|
fixture_names!("aep").into_iter().filter(|n| n.ends_with(".aep") && (n.contains("textanim") || n.contains("text-animator"))).collect();
|
|
names.sort();
|
|
assert!(names.len() >= 16, "{names:?}");
|
|
let mut compared = 0;
|
|
for name in &names {
|
|
let bytes = fixture!(name);
|
|
let r = export_all(&reimport(&bytes));
|
|
assert_eq!(r.report.count(LossCategory::TextLayers), 0, "{name}: {:?}", r.report.losses);
|
|
let (mut na, mut ra) = animator_records(&bytes);
|
|
let (nb, rb) = animator_records(&r.bytes);
|
|
// With Units = Index After Effects keeps the percent range it had before (583); the
|
|
// index range is what it uses, the export has only that.
|
|
if na.iter().any(|n| n.ends_with("ADBE Text Index Start")) {
|
|
na.retain(|n| !n.contains("ADBE Text Percent"));
|
|
ra.retain(|(n, _)| !n.contains("ADBE Text Percent"));
|
|
}
|
|
// A record After Effects writes for a value at its default when another one makes it
|
|
// matter (587: Smoothness 100 with Shape Ramp Up): absent = default.
|
|
let explicit = |n: &str| n.ends_with("ADBE Text Selector Smoothness") && !nb.iter().any(|m| m == n);
|
|
let default_100 = |rec: &[u8]| rec.windows(8).any(|w| w == 100.0f64.to_be_bytes());
|
|
let drop: Vec<String> = ra.iter().filter(|(n, rec)| explicit(n) && default_100(rec)).map(|(n, _)| n.clone()).collect();
|
|
na.retain(|n| !drop.contains(n));
|
|
ra.retain(|(n, _)| !drop.contains(n));
|
|
if na != nb {
|
|
let short = |v: &[String]| v.iter().map(|s| s.rsplit('/').next().unwrap_or_default().to_string()).collect::<Vec<_>>();
|
|
panic!(
|
|
"{name}: structure
|
|
AE {:?}
|
|
out {:?}",
|
|
short(&na),
|
|
short(&nb)
|
|
);
|
|
}
|
|
for ((ka, a), (kb, b)) in ra.iter().zip(&rb) {
|
|
assert_eq!(ka, kb, "{name}");
|
|
assert_eq!(a, b, "{name}: {ka}");
|
|
compared += 1;
|
|
}
|
|
assert_eq!(ra.len(), rb.len(), "{name}");
|
|
}
|
|
assert!(compared >= 30, "{compared}");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_text_animators_never_panic() {
|
|
use effectcraft_project::build;
|
|
let mut d = TextDoc::plain("Hostile");
|
|
d.set_runs(vec![StyleRun { len: 7, style: style("ArialMT", 40.0) }]);
|
|
let (mut p, c) = text_project(vec![d]);
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
let mut anims = Vec::new();
|
|
for i in 0..300 {
|
|
let props: Vec<_> =
|
|
["position", "scale", "fillColor", "tracking", "blur"].iter().flat_map(|k| build::text_anim_props(&mut ids, k, false)).collect();
|
|
let mut a = build::text_animator(&mut ids, &format!("A{i}"), props);
|
|
if let Some(pg) = a.sub_mut("properties") {
|
|
pg.get_mut("position").unwrap().value = Value::Vec3([f64::NAN, 1e300, 0.0]);
|
|
pg.get_mut("tracking").unwrap().keys = (0..2000).map(|k| key(f64::from(k) * 0.001, Value::Scalar(f64::from(k)))).collect();
|
|
pg.get_mut("fillColor").unwrap().keys = vec![key(0.0, Value::Scalar(1.0)), key(1.0, Value::Color([f64::INFINITY, 0.0, 0.0, 1.0]))];
|
|
}
|
|
if let Some(sels) = a.sub_mut("selectors") {
|
|
sels.children.push(build::wiggly_selector(&mut ids, "W").into());
|
|
}
|
|
anims.push(effectcraft_project::Node::Group(a));
|
|
}
|
|
with_layer(&mut p, c, 0, |l| l.props.sub_mut("text").unwrap().sub_mut("animators").unwrap().children = anims);
|
|
}
|
|
p.next_id = next;
|
|
let r = export_all(&p);
|
|
let file = parse(&r.bytes).expect("parse");
|
|
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes);
|
|
assert!(r.report.count(LossCategory::TextLayers) >= 300, "{:?}", r.report.losses);
|
|
let _ = reimport(&r.bytes);
|
|
}
|
|
|
|
/// A text animator's Fill Color outside 0 to 1 is clamped and reported under text layers, like a
|
|
/// shape colour (review finding: the animator path skipped the check); an in-range one is
|
|
/// lossless.
|
|
#[test]
|
|
fn a_text_animator_colour_outside_the_range_is_reported() {
|
|
use effectcraft_project::build;
|
|
let with_fill = |colour: [f64; 4]| {
|
|
let mut d = TextDoc::plain("Glow");
|
|
d.set_runs(vec![StyleRun { len: 4, style: style("ArialMT", 40.0) }]);
|
|
let (mut p, c) = text_project(vec![d]);
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
let props = build::text_anim_props(&mut ids, "fillColor", false);
|
|
let mut a = build::text_animator(&mut ids, "A", props);
|
|
if let Some(pg) = a.sub_mut("properties") {
|
|
pg.get_mut("fillColor").unwrap().value = Value::Color(colour);
|
|
}
|
|
with_layer(&mut p, c, 0, |l| l.props.sub_mut("text").unwrap().sub_mut("animators").unwrap().children = vec![effectcraft_project::Node::Group(a)]);
|
|
}
|
|
p.next_id = next;
|
|
p
|
|
};
|
|
let lost = |p: &Project| {
|
|
let r = export_all(p);
|
|
r.report
|
|
.losses
|
|
.iter()
|
|
.filter(|l| l.category == LossCategory::TextLayers)
|
|
.flat_map(|l| l.details.clone())
|
|
.filter(|d| d.contains("outside 0 to 1"))
|
|
.count()
|
|
};
|
|
assert_eq!(lost(&with_fill([0.2, 0.4, 0.6, 1.0])), 0);
|
|
assert_eq!(lost(&with_fill([3.0, 0.4, -1.0, 1.0])), 1);
|
|
}
|
|
|
|
/// A text animator's Fill or Stroke Color alpha is reported (After Effects colours have none; the
|
|
/// converter wrote them opaque without a word, review finding), for the static value and for keys;
|
|
/// the check follows the values that are written: an alpha on the static value of a keyed colour
|
|
/// is not reported, an opaque control is lossless.
|
|
#[test]
|
|
fn a_text_animator_colour_alpha_is_reported_for_the_values_that_are_written() {
|
|
use effectcraft_project::build;
|
|
for id in ["fillColor", "strokeColor"] {
|
|
let make = |value: [f64; 4], keys: Option<([f64; 4], [f64; 4])>| {
|
|
let mut d = TextDoc::plain("Glow");
|
|
d.set_runs(vec![StyleRun { len: 4, style: style("ArialMT", 40.0) }]);
|
|
let (mut p, c) = text_project(vec![d]);
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
let props = build::text_anim_props(&mut ids, id, false);
|
|
let mut a = build::text_animator(&mut ids, "A", props);
|
|
if let Some(pg) = a.sub_mut("properties") {
|
|
let prop = pg.get_mut(id).unwrap();
|
|
prop.value = Value::Color(value);
|
|
if let Some((k0, k1)) = keys {
|
|
prop.keys = vec![key(0.0, Value::Color(k0)), key(1.0, Value::Color(k1))];
|
|
}
|
|
}
|
|
with_layer(&mut p, c, 0, |l| {
|
|
l.props.sub_mut("text").unwrap().sub_mut("animators").unwrap().children = vec![effectcraft_project::Node::Group(a)]
|
|
});
|
|
}
|
|
p.next_id = next;
|
|
p
|
|
};
|
|
let alpha_lost = |p: &Project| {
|
|
let r = export_all(p);
|
|
r.report.losses.iter().filter(|l| l.category == LossCategory::TextLayers).flat_map(|l| l.details.clone()).filter(|d| d.contains("alpha")).count()
|
|
};
|
|
let strict = |p: &Project| effectcraft_aep::export::export(p, &effectcraft_aep::export::ExportOptions { allow_losses: false }).is_ok();
|
|
let opaque = [0.2, 0.4, 0.6, 1.0];
|
|
let clear = [0.2, 0.4, 0.6, 0.25];
|
|
let control = make(opaque, None);
|
|
assert_eq!(alpha_lost(&control), 0, "{id}");
|
|
let static_alpha = make(clear, None);
|
|
assert_eq!(alpha_lost(&static_alpha), 1, "{id}");
|
|
assert!(!strict(&static_alpha), "{id}");
|
|
let keyed_alpha = make(opaque, Some((opaque, clear)));
|
|
assert_eq!(alpha_lost(&keyed_alpha), 1, "{id}");
|
|
// The static value of a keyed colour is not written: its alpha is not reported.
|
|
let keyed_opaque = make(clear, Some((opaque, opaque)));
|
|
assert_eq!(alpha_lost(&keyed_opaque), 0, "{id}");
|
|
}
|
|
}
|
|
|
|
/// Two fonts whose names differ only in bytes a lossy UTF-8 conversion turns into the same text
|
|
/// (U+4E80 and U+4E81 both read as `N` + U+FFFD) get entries of their own (review finding).
|
|
#[test]
|
|
fn non_ascii_font_names_keep_their_own_font_entries() {
|
|
let mut d = TextDoc::plain("ab");
|
|
d.set_runs(vec![StyleRun { len: 1, style: style("\u{4e80}", 40.0) }, StyleRun { len: 1, style: style("\u{4e81}", 40.0) }]);
|
|
let (p, _) = text_project(vec![d]);
|
|
let r = export_all(&p);
|
|
let docs = documents(&r.bytes);
|
|
let (_, doc) = docs.first().expect("document");
|
|
assert!(doc.fonts.iter().any(|f| f == "\u{4e80}") && doc.fonts.iter().any(|f| f == "\u{4e81}"), "{:?}", doc.fonts);
|
|
}
|