mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 02:34:44 +00:00
* #3170: .aep import of the text animator Stroke Opacity and the Source Text expression ADBE Text Stroke Opacity maps to the animator's strokeOpacity (percent, as After Effects shows it). The Source Text expression is imported like any property's (Utf8 text, tdb4 bytes 119/120); one whose flags give no state is still reported. New committed fixture text-synthetic.aep, made in After Effects 2026 with the working space None. * #3170: .aep export of the Source Text expression and the text animator Stroke Opacity The Source Text expression is written enabled or disabled as After Effects stores it (tdb4 bytes 119/120, Utf8 after cdat); a disabled one is no longer dropped without a report. strokeOpacity is written as ADBE Text Stroke Opacity with the record After Effects wrote in text-synthetic.aep. A test exports the fixture and imports it again. * #3170: doc comments on the functions this change touches
This commit is contained in:
+1
-1
@@ -64,7 +64,7 @@ and licence texts are in [`NOTICE`](NOTICE).
|
||||
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
|
||||
| `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 |
|
||||
|
||||
Two small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
|
||||
Three small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar), `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/text-synthetic.aep` (original work, made with After Effects 2026 by script from own data, working space None, no colour profile embedded, nothing changed after the save; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ solid, a folder, a Levels and a Glow effect with expressions). Every After Effec
|
||||
the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk;
|
||||
the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was
|
||||
changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file.
|
||||
`tests/fixtures/text-synthetic.aep` is the other: made in After Effects 2026 the same way (working
|
||||
space None, nothing changed after the save), one composition with two text layers that hold a
|
||||
text animator's Stroke Opacity (static and keyed) and Source Text expressions (enabled and
|
||||
disabled); `tests/it/import/text_synthetic.rs` reads it (#3170).
|
||||
|
||||
`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs
|
||||
named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a
|
||||
|
||||
@@ -89,6 +89,8 @@ pub(crate) struct TextPlan {
|
||||
pub frame: Option<[f64; 4]>,
|
||||
/// The text animators (planned with the layer's time base, `textanim.rs`).
|
||||
pub animators: Vec<crate::export::textanim::AnimPlan>,
|
||||
/// The Source Text expression and its enable switch.
|
||||
pub expr: Option<(String, bool)>,
|
||||
}
|
||||
|
||||
/// Where a text value is written, for the loss report.
|
||||
@@ -133,16 +135,19 @@ pub(crate) fn plan_text(layer: &Layer, comp_name: &str, col: &mut Collector) ->
|
||||
if !st.keys.is_empty() {
|
||||
col.lose(LossCategory::TextLayers, format!("{what}: Source Text keyframes are not written (the first key's text is)"));
|
||||
}
|
||||
if st.has_expression() {
|
||||
col.lose(LossCategory::TextLayers, format!("{what}: the Source Text expression is not written"));
|
||||
}
|
||||
let value = st.keys.first().map(|k| &k.value).unwrap_or(&st.value);
|
||||
let Value::Text(doc) = value else {
|
||||
col.lose(LossCategory::TextLayers, format!("{what}: Source Text holds no text document"));
|
||||
return None;
|
||||
};
|
||||
report_extras(text, &what, col);
|
||||
Some(plan_doc(doc, &what, col))
|
||||
let mut plan = plan_doc(doc, &what, col);
|
||||
// Written enabled or disabled, as on any other property.
|
||||
plan.expr = st.expr.as_ref().filter(|e| !e.text.is_empty()).map(|e| (e.text.clone(), e.enabled));
|
||||
if plan.expr.is_some() {
|
||||
col.exported.expressions = col.exported.expressions.saturating_add(1);
|
||||
}
|
||||
Some(plan)
|
||||
}
|
||||
|
||||
/// Path / More Options that hold anything other than a new text layer's defaults are not
|
||||
@@ -190,6 +195,8 @@ fn differs(g: &PropGroup, def: Option<&PropGroup>, depth: usize) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The text document `doc` in the file's terms (text, style runs, paragraphs, box); what the file
|
||||
/// cannot hold is reported for `what`.
|
||||
fn plan_doc(doc: &TextDoc, what: &str, col: &mut Collector) -> TextPlan {
|
||||
let base = doc.base_style();
|
||||
let runs = doc.runs();
|
||||
@@ -288,7 +295,7 @@ fn plan_doc(doc: &TextDoc, what: &str, col: &mut Collector) -> TextPlan {
|
||||
at.fit(h, (1.0, 1e6), 1.0, "box height"),
|
||||
]
|
||||
});
|
||||
TextPlan { text, runs: out_runs, paras, frame, animators: Vec::new() }
|
||||
TextPlan { text, runs: out_runs, paras, frame, animators: Vec::new(), expr: None }
|
||||
}
|
||||
|
||||
fn style_out(s: &effectcraft_keyframe::text_doc::CharStyle, what: &str, col: &mut Collector) -> StyleOut {
|
||||
@@ -518,11 +525,47 @@ pub(crate) fn build_doc(proto: &TNode, tp: &TextPlan) -> Option<TNode> {
|
||||
Some(doc)
|
||||
}
|
||||
|
||||
/// Rewrites the text document of a text layer block's `Layr` for `tp` and gives the document a
|
||||
/// fresh `btgu/pgui`.
|
||||
/// The Source Text expression into the `tdbs` of `btds` (`docs/aep-format.md` 3.10, read back from
|
||||
/// `tests/fixtures/text-synthetic.aep`): `tdb4` byte 120 = 1, byte 119 = 1 when disabled, and the
|
||||
/// text as a `Utf8` after the record's `cdat`.
|
||||
fn put_source_text_expression(layr: &mut crate::Chunk<'static>, text: &str, enabled: bool) -> crate::export::tree::R<()> {
|
||||
use crate::Body;
|
||||
use crate::export::tree::tpl;
|
||||
/// The `tdbs` of the first `btds` below `c`, at most 32 levels down.
|
||||
fn find<'c>(c: &'c mut crate::Chunk<'static>, depth: usize) -> Option<&'c mut crate::Chunk<'static>> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
}
|
||||
if c.is_list(b"btds") {
|
||||
return match &mut c.body {
|
||||
Body::List { children, .. } => children.iter_mut().find(|k| k.is_list(b"tdbs")),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match &mut c.body {
|
||||
Body::List { children, .. } | Body::Chunks(children) => children.iter_mut().find_map(|k| find(k, depth + 1)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let tdbs = find(layr, 0).ok_or_else(|| tpl("text prototype without a Source Text record"))?;
|
||||
let Body::List { children, .. } = &mut tdbs.body else { return Err(tpl("text prototype without a Source Text record")) };
|
||||
let tdb4 = children.iter_mut().find(|k| k.is(b"tdb4")).ok_or_else(|| tpl("Source Text record without tdb4"))?;
|
||||
let Body::Data(d) = &mut tdb4.body else { return Err(tpl("Source Text record without tdb4")) };
|
||||
let d = d.to_mut();
|
||||
*d.get_mut(120).ok_or_else(|| tpl("short Source Text tdb4"))? = 1;
|
||||
*d.get_mut(119).ok_or_else(|| tpl("short Source Text tdb4"))? = u8::from(!enabled);
|
||||
children.retain(|k| !k.is(b"Utf8"));
|
||||
let at = children.iter().position(|k| k.is(b"tdum")).unwrap_or(children.len());
|
||||
children.insert(at, crate::Chunk::data(*b"Utf8", text.as_bytes().to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrites the text document of a text layer block's `Layr` for `tp`, writes its Source Text
|
||||
/// expression and gives the document a fresh `btgu/pgui`.
|
||||
pub(crate) fn put_doc(layr: &mut crate::Chunk<'static>, tp: &TextPlan, guid: [u8; 16]) -> crate::export::tree::R<()> {
|
||||
use crate::Body;
|
||||
use crate::export::tree::tpl;
|
||||
/// The first `LIST btdk` below `c`, at most 32 levels down.
|
||||
fn find<'c>(c: &'c mut crate::Chunk<'static>, depth: usize) -> Option<&'c mut crate::Chunk<'static>> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
@@ -535,6 +578,7 @@ pub(crate) fn put_doc(layr: &mut crate::Chunk<'static>, tp: &TextPlan, guid: [u8
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The first `pgui` of the first `btgu` below `c`, at most 32 levels down.
|
||||
fn find_pgui<'c>(c: &'c mut crate::Chunk<'static>, depth: usize) -> Option<&'c mut crate::Chunk<'static>> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
@@ -555,6 +599,9 @@ pub(crate) fn put_doc(layr: &mut crate::Chunk<'static>, tp: &TextPlan, guid: [u8
|
||||
let proto = crate::export::textdoc::parse(data.as_ref()).ok_or_else(|| tpl("the text prototype's document does not parse"))?;
|
||||
let doc = build_doc(&proto, tp).ok_or_else(|| tpl("the text prototype's document has another shape"))?;
|
||||
*data = std::borrow::Cow::Owned(crate::export::textdoc::write(&doc));
|
||||
if let Some((text, enabled)) = &tp.expr {
|
||||
put_source_text_expression(layr, text, *enabled)?;
|
||||
}
|
||||
if let Some(p) = find_pgui(layr, 0) {
|
||||
*p = crate::Chunk::data(*b"pgui", guid.to_vec());
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const PROPS: &[(&str, &str, Conv, Layout)] = &[
|
||||
("opacity", "ADBE Text Opacity", c_scalar, Layout::One),
|
||||
("fillColor", "ADBE Text Fill Color", c_color, Layout::Color),
|
||||
("strokeColor", "ADBE Text Stroke Color", c_color, Layout::Color),
|
||||
("strokeOpacity", "ADBE Text Stroke Opacity", c_scalar, Layout::One),
|
||||
("strokeWidth", "ADBE Text Stroke Width", c_scalar, Layout::One),
|
||||
("tracking", "ADBE Text Tracking Amount", c_scalar, Layout::One),
|
||||
("lineSpacing", "ADBE Text Line Spacing", c_vec2, Layout::Size),
|
||||
|
||||
@@ -264,6 +264,8 @@ pub(crate) static TEXT_ANIMATOR_RECORDS: &[Node] = &[
|
||||
R("ADBE Text Opacity", Rec { state: 1, label: "-_0_/-", dims: 1, template: 0, class: 0xffffffff, byte5: 0x01, header: &[], cdat_len: 40, cdat: &[37.0], key_size: 0, limits: Some((0.0, 100.0)), tdpi: None }),
|
||||
R("ADBE Text Fill Color", Rec { state: 1, label: "-_0_/-", dims: 4, template: 0, class: 0x0002ffff, byte5: 0x07, header: &[(79, 1)], cdat_len: 96, cdat: &[255.0, 0.0, 255.0], key_size: 0, limits: None, tdpi: None }),
|
||||
R("ADBE Text Stroke Color", Rec { state: 1, label: "-_0_/-", dims: 4, template: 0, class: 0x0002ffff, byte5: 0x07, header: &[(79, 1)], cdat_len: 96, cdat: &[255.0, 0.0, 0.0, 255.0], key_size: 0, limits: None, tdpi: None }),
|
||||
// text-synthetic.aep (#3170): the record After Effects wrote for Stroke Opacity 40 %.
|
||||
R("ADBE Text Stroke Opacity", Rec { state: 1, label: "-_0_/-", dims: 1, template: 0, class: 0xffffffff, byte5: 0x01, header: &[], cdat_len: 40, cdat: &[40.0], key_size: 0, limits: Some((0.0, 100.0)), tdpi: None }),
|
||||
R("ADBE Text Stroke Width", Rec { state: 1, label: "-_0_/-", dims: 1, template: 0, class: 0xffffffff, byte5: 0x01, header: &[], cdat_len: 40, cdat: &[3.0], key_size: 0, limits: Some((-1000.0, 1000.0)), tdpi: None }),
|
||||
R("ADBE Text Tracking Amount", Rec { state: 1, label: "-_0_/-", dims: 1, template: 0, class: 0xffffffff, byte5: 0x01, header: &[], cdat_len: 40, cdat: &[20.0], key_size: 0, limits: Some((-32000.0, 32000.0)), tdpi: None }),
|
||||
R("ADBE Text Line Spacing", Rec { state: 1, label: "-_0_/-", dims: 2, template: 0, class: 0x0002ffff, byte5: 0x01, header: &[], cdat_len: 80, cdat: &[10.0, 20.0], key_size: 0, limits: Some((-32000.0, 32000.0)), tdpi: None }),
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::import::props::{AnchorUnit, import_transform};
|
||||
use crate::import::report::{Collector, ImportReport, LossCategory};
|
||||
use crate::import::shapes::import_shapes;
|
||||
use crate::import::styles::import_layer_styles;
|
||||
use crate::import::text::{report_text_extras, text_doc};
|
||||
use crate::import::text::{import_source_text_expression, report_text_extras, text_doc};
|
||||
use crate::import::text_anim::import_animators;
|
||||
use crate::import::time::{TimeMap, frame_rate};
|
||||
|
||||
@@ -707,8 +707,8 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
}
|
||||
}
|
||||
|
||||
/// The Source Text of a text layer from its text document, and the reports of what is not
|
||||
/// imported.
|
||||
/// The Source Text of a text layer from its text document and its expression, and the reports
|
||||
/// of what is not imported.
|
||||
fn import_source_text(l: &mut Layer, layer: &AepLayer<'_, '_>, what: &str, cx: &mut Ctx<'_>) {
|
||||
match layer.text_document() {
|
||||
Some(doc) => {
|
||||
@@ -720,6 +720,7 @@ fn import_source_text(l: &mut Layer, layer: &AepLayer<'_, '_>, what: &str, cx: &
|
||||
}
|
||||
None => cx.col.lose(LossCategory::DamagedData, format!("{what}: the text document is unreadable, the layer is imported with empty text")),
|
||||
}
|
||||
import_source_text_expression(layer, l.props.prop_mut("text/sourceText"), what, &mut cx.col);
|
||||
report_text_extras(layer, what, &mut cx.col);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
use crate::{AutoKern, Caps, FontBaseline, Justification, Layer as AepLayer, ParagraphRun, PropGroup, PropNode, StyleRun as AepRun, TextDocument};
|
||||
use effectcraft_keyframe::text_doc::{BaselineOption, CharStyle, Composer, Direction, Justify, Kerning, ParaStyle, StyleRun, TextDoc};
|
||||
use effectcraft_project::Property;
|
||||
|
||||
use crate::import::props::import_expression;
|
||||
use crate::import::report::{Collector, LossCategory};
|
||||
|
||||
/// How deep the walk over the text property groups goes.
|
||||
@@ -344,15 +346,27 @@ fn has_stored(g: &PropGroup<'_, '_>, depth: usize) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The Source Text expression into `target` (the layer's `text/sourceText`). The Source Text
|
||||
/// record keeps it like any other property: the text in its `Utf8`, the flags in its `tdb4`
|
||||
/// (`tests/fixtures/text-synthetic.aep`). An expression whose flags are unreadable, or a layer
|
||||
/// without a Source Text property, is reported.
|
||||
pub(crate) fn import_source_text_expression(layer: &AepLayer<'_, '_>, target: Option<&mut Property>, what: &str, col: &mut Collector) {
|
||||
let Some(st) = layer.source_text_property() else { return };
|
||||
if st.expression().is_none() {
|
||||
return;
|
||||
}
|
||||
match target {
|
||||
Some(p) if st.expression_enabled().is_some() => import_expression(&st, p, col),
|
||||
_ => col.lose(LossCategory::TextLayers, format!("{what}: the Source Text expression is not imported")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of a text layer besides its first text document that are not imported.
|
||||
pub(crate) fn report_text_extras(layer: &AepLayer<'_, '_>, what: &str, col: &mut Collector) {
|
||||
if let Some(st) = layer.source_text_property() {
|
||||
if st.is_animated() || layer.text_document_count() > 1 {
|
||||
col.lose(LossCategory::TextLayers, format!("{what}: Source Text keyframes are not imported (the first text document is)"));
|
||||
}
|
||||
if st.expression().is_some() {
|
||||
col.lose(LossCategory::TextLayers, format!("{what}: the Source Text expression is not imported"));
|
||||
}
|
||||
if let Some(st) = layer.source_text_property()
|
||||
&& (st.is_animated() || layer.text_document_count() > 1)
|
||||
{
|
||||
col.lose(LossCategory::TextLayers, format!("{what}: Source Text keyframes are not imported (the first text document is)"));
|
||||
}
|
||||
let Some(PropNode::Group(text)) = layer.properties().and_then(|p| p.get("ADBE Text Properties")) else { return };
|
||||
for e in text.entries() {
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//!
|
||||
//! Not imported, and reported in [`LossCategory::TextLayers`]: wiggly and expression selectors,
|
||||
//! animator properties other than the ones mapped below (Character Value, Fill / Stroke
|
||||
//! Hue, Saturation, Brightness and Opacity, 3D rotations, variable font axes, ...), and a Tracking
|
||||
//! Type other than the default.
|
||||
//! Hue, Saturation and Brightness, Fill Opacity, 3D rotations, variable font axes, ...), and a
|
||||
//! Tracking Type other than the default.
|
||||
|
||||
use crate::{PropGroup as AepGroup, PropNode, Property as AepProp};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
@@ -37,6 +37,7 @@ const PROPS: &[(&str, &str, &str, Kind)] = &[
|
||||
("ADBE Text Opacity", "opacity", "opacity", Kind::Number),
|
||||
("ADBE Text Fill Color", "fillColor", "fillColor", Kind::EffectColor),
|
||||
("ADBE Text Stroke Color", "strokeColor", "strokeColor", Kind::EffectColor),
|
||||
("ADBE Text Stroke Opacity", "strokeOpacity", "strokeOpacity", Kind::Number),
|
||||
("ADBE Text Stroke Width", "strokeWidth", "strokeWidth", Kind::Number),
|
||||
("ADBE Text Tracking Amount", "tracking", "tracking", Kind::Number),
|
||||
("ADBE Text Line Spacing", "lineSpacing", "lineSpacing", Kind::Vec2),
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
asset: text-synthetic.aep
|
||||
title: Small After Effects test project for the .aep importer's text animator and Source Text expression tests
|
||||
author: EffectCraft contributors
|
||||
source: original work (made in After Effects 2026 by script from own data: a composition with two text layers, a text animator Stroke Opacity, static and keyed, and Source Text expressions, enabled and disabled)
|
||||
license: MIT OR Apache-2.0
|
||||
added: 2026-11-08 by EffectCraft contributors
|
||||
notes: The working space was set to None, so the file embeds no ICC profile; nothing After Effects wrote was changed. See crates/aep/README.md (Clean room).
|
||||
@@ -33,5 +33,6 @@ mod styles;
|
||||
mod synthetic;
|
||||
mod text;
|
||||
mod text_anim;
|
||||
mod text_synthetic;
|
||||
mod timing;
|
||||
mod versions;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! A small committed text project (`tests/fixtures/text-synthetic.aep`), so the text animator
|
||||
//! Stroke Opacity and the Source Text expression are checked without the external corpus, on
|
||||
//! import and through an export and a second import (#3170).
|
||||
//!
|
||||
//! The project was made in After Effects 2026 with the working space set to None: one composition
|
||||
//! `C` (64x48, 25 fps, 2 s) with two text layers. `T` holds `Hi` (Arial, 20 px, a blue 2 px
|
||||
//! stroke), the Source Text expression `text.sourceText + "!"` (enabled), an animator `Stroke`
|
||||
//! with Stroke Opacity 40 % and an animator `Keyed` with Stroke Opacity keyed 0 % at 0 s and
|
||||
//! 100 % at 1 s. `Off` holds `Off` and the Source Text expression `"X"`, disabled. Nothing After
|
||||
//! Effects wrote was changed.
|
||||
|
||||
use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import};
|
||||
use effectcraft_aep::{AepFile, Body, Chunk, parse, write};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Expression, Layer, Project, PropGroup};
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::import::helpers::comp;
|
||||
|
||||
const TEXT_SYNTHETIC: &[u8] = include_bytes!("../../fixtures/text-synthetic.aep");
|
||||
|
||||
/// Imports `bytes` with losses allowed.
|
||||
fn run(bytes: &[u8]) -> (Project, ImportResult) {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("the project imports");
|
||||
(project, r)
|
||||
}
|
||||
|
||||
/// The loss details of `category`.
|
||||
fn losses(r: &ImportResult, category: LossCategory) -> Vec<String> {
|
||||
r.report.losses.iter().filter(|l| l.category == category).flat_map(|l| l.details.clone()).collect()
|
||||
}
|
||||
|
||||
/// The layer `name` of composition `C`.
|
||||
fn layer<'p>(p: &'p Project, r: &ImportResult, name: &str) -> &'p Layer {
|
||||
let (_, c) = comp(p, r, "C");
|
||||
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("text layer {name}"))
|
||||
}
|
||||
|
||||
/// The text animator `name` of `l`.
|
||||
fn animator<'l>(l: &'l Layer, name: &str) -> &'l PropGroup {
|
||||
l.props.group("text/animators").expect("animators").groups().find(|a| a.name == name).unwrap_or_else(|| panic!("animator {name}"))
|
||||
}
|
||||
|
||||
/// The Source Text of `l`: its stored text and its expression.
|
||||
fn source_text(l: &Layer) -> (String, Option<Expression>) {
|
||||
let p = l.props.prop("text/sourceText").expect("Source Text");
|
||||
let Value::Text(doc) = &p.value else { panic!("{:?}", p.value) };
|
||||
(doc.text.clone(), p.expr.clone())
|
||||
}
|
||||
|
||||
/// Stroke Opacity 40 % and its keys 0 % / 100 % arrive in percent, without a loss.
|
||||
#[test]
|
||||
fn text_animator_stroke_opacity_is_imported_static_and_keyed() {
|
||||
let (p, r) = run(TEXT_SYNTHETIC);
|
||||
let text = losses(&r, LossCategory::TextLayers);
|
||||
assert!(!text.iter().any(|d| d.contains("Stroke Opacity")), "{text:?}");
|
||||
let t = layer(&p, &r, "T");
|
||||
// Stored as After Effects shows it: percent.
|
||||
let st = animator(t, "Stroke").prop("properties/strokeOpacity").expect("strokeOpacity");
|
||||
assert_eq!((st.value.clone(), st.keys.len()), (Value::Scalar(40.0), 0));
|
||||
let keyed = animator(t, "Keyed").prop("properties/strokeOpacity").expect("strokeOpacity");
|
||||
let keys: Vec<_> = keyed.keys.iter().map(|k| (k.time, k.value.clone())).collect();
|
||||
assert_eq!(keys, vec![(Tick::ZERO, Value::Scalar(0.0)), (Tick(TICKS_PER_SECOND), Value::Scalar(100.0))]);
|
||||
}
|
||||
|
||||
/// Both Source Text expressions arrive with their enable switch, next to the stored text.
|
||||
#[test]
|
||||
fn the_source_text_expression_is_imported_enabled_and_disabled() {
|
||||
let (p, r) = run(TEXT_SYNTHETIC);
|
||||
let text = losses(&r, LossCategory::TextLayers);
|
||||
assert!(!text.iter().any(|d| d.contains("Source Text expression")), "{text:?}");
|
||||
// The stored text stays the Source Text value; the expression comes next to it.
|
||||
assert_eq!(source_text(layer(&p, &r, "T")), ("Hi".to_string(), Some(Expression { text: "text.sourceText + \"!\"".into(), enabled: true })));
|
||||
assert_eq!(source_text(layer(&p, &r, "Off")), ("Off".to_string(), Some(Expression { text: "\"X\"".into(), enabled: false })));
|
||||
assert_eq!(r.report.imported.expressions, 2);
|
||||
}
|
||||
|
||||
/// Calls `f` on every chunk below `chunks`, depth-first.
|
||||
fn visit(chunks: &mut [Chunk<'_>], f: &mut dyn FnMut(&mut Chunk<'_>)) {
|
||||
for c in chunks {
|
||||
f(c);
|
||||
visit(c.children_mut(), f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Export and a second import keep the Source Text expressions and Stroke Opacity, unreported.
|
||||
#[test]
|
||||
fn stroke_opacity_and_source_text_expressions_survive_export() {
|
||||
let (p, r0) = run(TEXT_SYNTHETIC);
|
||||
let out = effectcraft_aep::export::export(&p, &effectcraft_aep::export::ExportOptions { allow_losses: true }).expect("export");
|
||||
let text: Vec<String> =
|
||||
out.report.losses.iter().filter(|l| l.category == effectcraft_aep::export::LossCategory::TextLayers).flat_map(|l| l.details.clone()).collect();
|
||||
assert!(!text.iter().any(|d| d.contains("Stroke Opacity") || d.contains("Source Text expression")), "{text:?}");
|
||||
assert_eq!(out.report.exported.expressions, 2);
|
||||
let (q, r) = run(&out.bytes);
|
||||
for name in ["T", "Off"] {
|
||||
assert_eq!(source_text(layer(&q, &r, name)), source_text(layer(&p, &r0, name)), "{name}");
|
||||
}
|
||||
let t = layer(&q, &r, "T");
|
||||
assert_eq!(animator(t, "Stroke").prop("properties/strokeOpacity").map(|p| p.value.clone()), Some(Value::Scalar(40.0)));
|
||||
let keys: Vec<_> = animator(t, "Keyed").prop("properties/strokeOpacity").expect("strokeOpacity").keys.iter().map(|k| (k.time, k.value.clone())).collect();
|
||||
assert_eq!(keys, vec![(Tick::ZERO, Value::Scalar(0.0)), (Tick(TICKS_PER_SECOND), Value::Scalar(100.0))]);
|
||||
}
|
||||
|
||||
/// Guard: an expression whose `tdb4` flags give no state stays reported instead of imported.
|
||||
#[test]
|
||||
fn a_source_text_expression_with_unreadable_flags_stays_reported() {
|
||||
// `tdb4` byte 120 is 1 on a property with an expression; with 0 the expression's state is
|
||||
// unknown, so it is reported instead of imported.
|
||||
let mut file: AepFile<'_> = parse(TEXT_SYNTHETIC).expect("parse");
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if !c.is_list(b"btds") {
|
||||
return;
|
||||
}
|
||||
for tdbs in c.children_mut().iter_mut().filter(|c| c.is_list(b"tdbs")) {
|
||||
for tdb4 in tdbs.children_mut().iter_mut().filter(|c| c.is(b"tdb4")) {
|
||||
if let Body::Data(d) = &mut tdb4.body
|
||||
&& let Some(b) = d.to_mut().get_mut(120)
|
||||
{
|
||||
*b = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&write(&file).expect("write"));
|
||||
let text = losses(&r, LossCategory::TextLayers);
|
||||
assert_eq!(text.iter().filter(|d| d.contains("the Source Text expression is not imported")).count(), 2, "{text:?}");
|
||||
assert_eq!(source_text(layer(&p, &r, "T")), ("Hi".to_string(), None));
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# After Effects project export
|
||||
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-05 · **Change:** a 3D layer's changed Material Options are reported, shape and text animator colours outside 0 to 1 are clamped and reported, and a popup expression is no longer reported where the option order is the same (2026-11-05); before that: composition settings the base writes at their defaults (Preserve Frame Rate / Resolution, motion blur samples, adaptive sample limit) and render queue items are reported, and a sequence's folder time is read only when its file passes the document-path policy (2026-11-04); before that: every chunk is built in code, no template is read (2026-10-29); before that: Curves and Mesh Warp are written (R53); before that: a switched-off shape item or layer style is written switched off (R52, 986-988); before that: a reversed layer (negative stretch) is written as After Effects stores it (R36, 981-983); before that: an explicit None (`NO_LAYER`) on Compound Blur, Displacement Map or Set Matte is written as None, and Layer Control on a named layer no longer reports a source choice; before that: layer styles are written (R51, 950-980; read back in After Effects: `export-tests\styles`); before that: an image sequence's folder modification time is written when the caller knows it (`sspc@118`, R50: without it After Effects rescans the folder on open and warns about every gap of a sparse sequence in a modal dialog); before that: an output space equal to the working space is not reported (After Effects renders in the working space, 902; the importer sets it so for Rec.709 Gamma 2.4); before that: one effect table for both directions again: the 27 effects of the importer round 3 and the Layer Control are written too (prototypes from 903 and 076, layer parameters by layer id; not read back in After Effects yet); before that: the importer round 3 is merged: Offset Paths' Copy Offset is written as EffectCraft's value + 1 (826-828, the importer's inverse); before that: sparse sequences checked (860, written as After Effects stores them); before that: the Rec.709 Gamma 2.4 working space is written with a profile authored here (R49, g5); before that: text animators with range selectors are written (R48, t8); before that: a footage layer running past its source is ended there on import, as After Effects does, and less than a frame past is not a loss (LayerTiming); before that: Anchor Point and mask path ease are written (R47); before that: shape operators (polystar, gradients, trim paths, round corners, merge / offset paths, repeater) and keys and expressions on shape properties are written (R46, o2); before that: every effect of the importer's table is written, through one shared table and records built per parameter type (R45, e2); before that: time remapping is written (319, 354, r1, R44); before that: the character and paragraph settings are read back in After Effects (t6) and right-to-left justification is written relative to the direction (t7, R43); before that: the character and paragraph settings the importer decodes are written (scales, baseline shift, kerning, ligatures, tsume, fill / stroke switches and width, justified variants, indents, spacing, composer, direction); before that: track mattes, markers, cameras, lights and shape layers (R39-R42), keys on stretched layers, conformed clips, tga / psd / ai / eps, project settings (620-634, R37), superscript / subscript (526-528); before: precomposition layers (124, 257, 258, 600-602), the effects Tint, Gradient Ramp and Exposure (356-397, 610), footage (351, 353, 603-607, 611-613), text layers (110, 245, t1-t4), the 3D switch (015) and time stretch (018, 316, 317) are exported · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-08 · **Change:** the Source Text expression and a text animator's Stroke Opacity are written (#3170); before that: a 3D layer's changed Material Options are reported, shape and text animator colours outside 0 to 1 are clamped and reported, and a popup expression is no longer reported where the option order is the same (2026-11-05); before that: composition settings the base writes at their defaults (Preserve Frame Rate / Resolution, motion blur samples, adaptive sample limit) and render queue items are reported, and a sequence's folder time is read only when its file passes the document-path policy (2026-11-04); before that: every chunk is built in code, no template is read (2026-10-29); before that: Curves and Mesh Warp are written (R53); before that: a switched-off shape item or layer style is written switched off (R52, 986-988); before that: a reversed layer (negative stretch) is written as After Effects stores it (R36, 981-983); before that: an explicit None (`NO_LAYER`) on Compound Blur, Displacement Map or Set Matte is written as None, and Layer Control on a named layer no longer reports a source choice; before that: layer styles are written (R51, 950-980; read back in After Effects: `export-tests\styles`); before that: an image sequence's folder modification time is written when the caller knows it (`sspc@118`, R50: without it After Effects rescans the folder on open and warns about every gap of a sparse sequence in a modal dialog); before that: an output space equal to the working space is not reported (After Effects renders in the working space, 902; the importer sets it so for Rec.709 Gamma 2.4); before that: one effect table for both directions again: the 27 effects of the importer round 3 and the Layer Control are written too (prototypes from 903 and 076, layer parameters by layer id; not read back in After Effects yet); before that: the importer round 3 is merged: Offset Paths' Copy Offset is written as EffectCraft's value + 1 (826-828, the importer's inverse); before that: sparse sequences checked (860, written as After Effects stores them); before that: the Rec.709 Gamma 2.4 working space is written with a profile authored here (R49, g5); before that: text animators with range selectors are written (R48, t8); before that: a footage layer running past its source is ended there on import, as After Effects does, and less than a frame past is not a loss (LayerTiming); before that: Anchor Point and mask path ease are written (R47); before that: shape operators (polystar, gradients, trim paths, round corners, merge / offset paths, repeater) and keys and expressions on shape properties are written (R46, o2); before that: every effect of the importer's table is written, through one shared table and records built per parameter type (R45, e2); before that: time remapping is written (319, 354, r1, R44); before that: the character and paragraph settings are read back in After Effects (t6) and right-to-left justification is written relative to the direction (t7, R43); before that: the character and paragraph settings the importer decodes are written (scales, baseline shift, kerning, ligatures, tsume, fill / stroke switches and width, justified variants, indents, spacing, composer, direction); before that: track mattes, markers, cameras, lights and shape layers (R39-R42), keys on stretched layers, conformed clips, tga / psd / ai / eps, project settings (620-634, R37), superscript / subscript (526-528); before: precomposition layers (124, 257, 258, 600-602), the effects Tint, Gradient Ramp and Exposure (356-397, 610), footage (351, 353, 603-607, 611-613), text layers (110, 245, t1-t4), the 3D switch (015) and time stretch (018, 316, 317) are exported · **Target:** Adobe After Effects 2026
|
||||
|
||||
File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`) writes the open project as an `.aep` that
|
||||
After Effects 2026 opens. It is deliberately narrow: what it writes is exact, tested and checked
|
||||
@@ -67,7 +67,7 @@ the dry run `analyze(&Project) -> ExportReport`. A refused or failed export writ
|
||||
| Expressions | on those five properties: source text and enable switch |
|
||||
| Masks | on solid, adjustment and null layers, in stack order: name, mode (all seven), inverted, path (vertices and tangents in layer pixels, open or closed), feather, opacity, expansion (each only when not at the default), path keys (time, linear, bezier or hold sides with their ease, label) with one shape per key, Mask Feather keys (time, interpolation, value, ease per dimension, label) |
|
||||
| Precompositions | a composition shown as a layer of another: the layer's source is the composition item, the using composition lists each composition it shows once (`idpi`); the anchor is a fraction of the composition's size; precompositions nest to any depth, a loop is reported |
|
||||
| Text | point and box text: the text (paragraphs, CR / LF / CRLF as After Effects' CR), per paragraph the justification (left, right, centre and the four justified variants, codes 3-6), first-line / start / end indent, space before / after, hanging punctuation, composer and direction (paragraph slots `/1`-`/5`, `/21`, `/29`, `/33`), per style run the font (PostScript name), size, fill and stroke colour, tracking, automatic or explicit leading, faux bold and italic, all caps and small caps, horizontal / vertical scale, baseline shift, automatic kerning (metrics, optical, none), ligatures, tsume, whether fill and stroke are drawn, stroke width and stroke over fill (style slots `/6`, `/7`, `/9`, `/11`, `/18`, `/36`, `/56`-`/58`, `/63`, as the importer decodes them, 538-569; written only where they differ from the prototype; read back in After Effects, R43), the justification of a right-to-left paragraph relative to its direction (R43), superscript and subscript (style slot `/13`: normal text is written 0, 527; the prototypes and After Effects' scripted default hold 1 = superscript, drawn at 58.3 % size, 526); the box; the anchor in pixels; the name only when it is not the text's first line; text animators: Anchor Point, Position, Scale, Skew, Skew Axis, Rotation, Opacity, Fill / Stroke Color, Stroke Width, Tracking, Line Spacing, Blur, Character Offset with keys and expressions, range selectors (Start / End / Offset in percent or index units, Units, Based On, Mode, Amount, Shape, Smoothness, Ease High / Low, Randomize Order, Random Seed; R48) |
|
||||
| Text | point and box text: the text (paragraphs, CR / LF / CRLF as After Effects' CR), per paragraph the justification (left, right, centre and the four justified variants, codes 3-6), first-line / start / end indent, space before / after, hanging punctuation, composer and direction (paragraph slots `/1`-`/5`, `/21`, `/29`, `/33`), per style run the font (PostScript name), size, fill and stroke colour, tracking, automatic or explicit leading, faux bold and italic, all caps and small caps, horizontal / vertical scale, baseline shift, automatic kerning (metrics, optical, none), ligatures, tsume, whether fill and stroke are drawn, stroke width and stroke over fill (style slots `/6`, `/7`, `/9`, `/11`, `/18`, `/36`, `/56`-`/58`, `/63`, as the importer decodes them, 538-569; written only where they differ from the prototype; read back in After Effects, R43), the justification of a right-to-left paragraph relative to its direction (R43), superscript and subscript (style slot `/13`: normal text is written 0, 527; the prototypes and After Effects' scripted default hold 1 = superscript, drawn at 58.3 % size, 526); the box; the anchor in pixels; the name only when it is not the text's first line; the Source Text expression, enabled or disabled (`tdb4` bytes 119 / 120 and a `Utf8` after `cdat`, as After Effects wrote it in `text-synthetic.aep`, #3170); text animators: Anchor Point, Position, Scale, Skew, Skew Axis, Rotation, Opacity, Fill / Stroke Color, Stroke Opacity, Stroke Width, Tracking, Line Spacing, Blur, Character Offset with keys and expressions, range selectors (Start / End / Offset in percent or index units, Units, Based On, Mode, Amount, Shape, Smoothness, Ease High / Low, Randomize Order, Random Seed; R48) |
|
||||
| Footage | png, jpeg, OpenEXR, TIFF, Targa, (flat) Photoshop, Illustrator and EPS stills, png, OpenEXR and jpeg sequences, mp4 and mov clips (also conformed to another rate, R38), WAV audio: the path (a sequence's folder and numbering), size, duration, native frame rate, the name (none stored when it is the file's, as After Effects does); After Effects re-reads everything else from the file when it opens the project |
|
||||
| Effects | every effect of the table the importer reads (`effectcraft_aep::effect_map`, one table for both directions): Tint, Gradient Ramp, Exposure, Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Timewarp, Glow, Fill, Hue/Saturation (master and colorize), Levels (RGB), Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker, Transform, Brightness & Contrast, Turbulent Displace, Fractal Noise, Beam, CC Radial Fast Blur, Noise, Levels (Individual Controls), Corner Pin, Grid, Sharpen, Linear Color Key, Radial Wipe, Remove Color Matting, CC Bend It, CC Power Pin, 4-Color Gradient, Change Color, Extract, Noise HLS, Scatter, CC Light Sweep, Compound Blur, Displacement Map, Set Matte, Gaussian Blur (Legacy), Pixel Motion Blur and the Slider, Angle, Checkbox, Color, Point and Layer expression controls: the instance name, every parameter that differs from its default (colours, points, fixed, integer and float sliders, angles, popups by item name, checkboxes, layer parameters: the `tdpi` of their always-present record holds the owning layer's id by default, 0 for None (EffectCraft's unset Layer Control, or the explicit `NO_LAYER` of the other effects), or the named layer's id, 320 / 321 / 904; Layer Control's companion source choice is not reported (After Effects has none for it); the 27 effects of batch 6 (903-905) and the Layer Control are written from prototypes but not yet read back in After Effects), expressions on parameters, Hue/Saturation's master values and Levels' RGB values (arbitrary data), keys on colours, points and fixed / float sliders (time, linear / bezier / hold, the ease of sliders and points, labels), in stack order, several instances of a type per layer and per project (R24-R27, R45) |
|
||||
| Layer styles | the nine styles and Blending Options (Fill Opacity, channel switches, Blend Interior, Blend Ranges; the composition's Global Light on every styled layer) on solid, footage, precomposition, text and shape layers: a record for every parameter that differs from After Effects' default, keys on scalars, expressions (R51) |
|
||||
@@ -93,7 +93,7 @@ at all.
|
||||
| `effects` | an effect outside the table (each instance), a parameter without an After Effects counterpart that carries keys or an expression or a value other than a new instance's (a default that depends on the layer size is not compared), the effect's label colour, a colour parameter's opacity (After Effects' have none; Drop Shadow uses it), a fraction on an integer parameter (written whole), Curves points outside 0..1 (written at the limit), keys on a popup, checkbox, angle or integer slider (the first key's value is written), keys or expressions on Hue/Saturation's or Levels' data values, a fractional Hue/Saturation master value (written whole), a popup item After Effects does not have, keys or an expression on a layer parameter (its value is written), a layer parameter naming a layer that is not exported (the owning layer is written) or whose layer is taken with its masks and effects (After Effects takes the source) |
|
||||
| `markers` | a protected marker region (the switch is not decoded; the marker is written), a marker time out of range, markers on a footage item (not written) |
|
||||
| `masks` | masks on a text or shape layer (the path unit there is not verified; not written), a disabled mask or one without a usable path (not written), and per written mask: keys on Mask Opacity or Expansion (no keyed prototype; the static value is written), expressions on mask properties, the lock, mask motion blur other than Same As Layer, Linear feather falloff, RotoBezier, variable-width feather points |
|
||||
| `text_layers` | per text layer what its document cannot hold: wiggly and expression selectors, animator properties outside the list above (3D rotations, hue / saturation, Character Value, ...), a Tracking Type or Character Range / Alignment other than the default, Path and More Options other than the defaults, Source Text keys (the first key's text is written) and expressions, a manual kerning amount, OpenType features, variable font axes, a font style other than Regular, vertical text, a switched-off range selector (written on), a run without a font name (written in Times New Roman), a fill or stroke opacity (text colours have none), and a value beyond what the document holds (written at the limit): colour channels outside 0..1, font size, tracking, leading, scale, baseline shift, tsume, stroke width, paragraph indents and spacing, box position and size |
|
||||
| `text_layers` | per text layer what its document cannot hold: wiggly and expression selectors, animator properties outside the list above (3D rotations, hue / saturation, Character Value, ...), a Tracking Type or Character Range / Alignment other than the default, Path and More Options other than the defaults, Source Text keys (the first key's text is written), a manual kerning amount, OpenType features, variable font axes, a font style other than Regular, vertical text, a switched-off range selector (written on), a run without a font name (written in Times New Roman), a fill or stroke opacity (text colours have none), and a value beyond what the document holds (written at the limit): colour channels outside 0..1, font size, tracking, leading, scale, baseline shift, tsume, stroke width, paragraph indents and spacing, box position and size |
|
||||
| `shape_layers` | shape items outside the list above (wiggle, zig zag, pucker, twist, ...; not written), dashes / taper / wave, blend mode and composite order, the path direction, per-corner roundness of a rectangle (the uniform Roundness is written), gradient colour stops other than the default, keys on popups (the first key's value is written), keys and expressions on a path |
|
||||
| `cameras`, `lights` | keys or expressions on camera / light options (static value written), iris and highlight settings; parallel and ambient lights (not written), light falloff, radius, shadows; a camera's Auto-Orient Along Path or Towards Camera (written one-node), a spot light not oriented towards its point of interest (written oriented), a light colour channel outside 0..1 |
|
||||
| `model_layers` | each 3D model layer (skipped) |
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# After Effects project format (`.aep`)
|
||||
|
||||
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** footage: the Photoshop layer an item shows and its document's size (`opti` tag `8BPS`, section 3.3, issue 3087); before that: Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** text: a Source Text expression is stored like any property's (`tdb4` bytes 119 / 120, `Utf8`; `text-synthetic.aep`, #3170); before that: footage: the Photoshop layer an item shows and its document's size (`opti` tag `8BPS`, section 3.3, issue 3087); before that: Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026
|
||||
|
||||
What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence
|
||||
for each statement. Everything here was observed on project files we generated ourselves; no
|
||||
@@ -851,7 +851,7 @@ verified paths from the top level (keys `/98 /0 /1`; `RUN` = `/1/1[0]/0`):
|
||||
| **stroke colour** | `.../6/54/0/1` = `[A R G B]`, the fill's layout; white in the untouched samples, red `[1 1 0 0]` in 574, blue in 595. The stroke switch, width and order are slots `/57`, `/63`, `/58` (see "character settings") | 110, 113, 574, 595 | V |
|
||||
| **automatic leading** | style slot `.../6/4`: `true` = automatic leading (lines advance by 1.2 x the size; `/5` is then not used), `false` = `/5` is the leading. **243: `/4` false, `/5` 98, the second line's baseline is stored 98 px below the first (`/1/1[0]/1/2[0]/6[0]/6[0]/6[0]/6[1]/0/0[1]`)**. **400 (Auto Leading switched on for `A` CR `B`, size 100, leading 98; against its unedited save 399): the run style changes in `/4` (false -> true) and `/5` (98 -> 0.01) only, the stored second baseline moves from 98 to 120 px below the first (1.2 x 100), and After Effects measures the same 120 px (`text.measure`)**; the rest of the diff is save noise (`head`, the comp's modification time, the text's `btgu/pgui`). That `/5` holds 0.01 under automatic leading is seen in 400 only | 243, 339 (`/5`); 400 (`/4`); test `automatic_leading_is_slot_4` | V |
|
||||
| **undecoded slots** | the run style and paragraph dictionaries of an untouched document (244; 110 stores the same bytes) are the reference: a slot that is not decoded and differs from it is listed (`TextDocument::undecoded_style_fields`, `undecoded_paragraph_fields`); a slot the reference lacks is listed only when it is not 0, `false`, empty or `/nil` (a neutral value is taken to carry nothing, so it is not reported as a loss; `/nil` is how 244 stores its paragraph slots `/27`, `/28` and `/36`). Decoded style slots: `/0 /1 /2 /3 /4 /5 /6 /7 /8 /9 /11 /12 /13 /18 /35 /36 /52 /53 /54 /56 /57 /58 /62 /63 /70`; paragraph slots: `/0 /1 /2 /3 /4 /5 /8 /9 /21 /29 /33`. Not found by any scriptable setting (none of the `text.set_style` attributes tried in S17 changes them): style `/20 /38 /78 /87 /88`, paragraph `/31 /32`. **Paragraph `/24`, `/35` and style `/68`** (1, 1 and 0 in a scripted text) were edited in place in the wrapping box text 517 (732: `/24` 0, 733: `/35` 0, 734: `/68` 1, same length); After Effects opens each without a dialog, keeps the value on a re-save and composes the same four lines, baselines and bounds (`text.measure`, `layer.bounds`; 734 renders like 517), so 0 and 1 are not listed (`NEUTRAL_PARAGRAPH_VALUES`, `NEUTRAL_STYLE_VALUES`; their meaning stays unknown, Latin text only). **Paragraph `/34`** is 0 in a scripted text and 7 after Reset Paragraph (727: the only slot that changes); a justified 600 px box text of 88 characters composes the same four lines, baselines and bounds with both values (S18), so 0 and 7 are not listed (`NEUTRAL_PARAGRAPH_VALUES`), other values are | every text sample lists none; 725, 727 | V (reference), E (/34) |
|
||||
| **Source Text record** | the `tdbs` in `btds[ADBE Text Document]` (next to the `btdk`) has no keys and no expression in every sample; Source Text keyframes (presumably one `btdk` per key) and expressions were not sampled, the reader only reports them (`Layer::source_text_property`, `Layer::text_document_count`) | 110-114, 243-248, 334-340 | E |
|
||||
| **Source Text record** | the `tdbs` in `btds[ADBE Text Document]` (next to the `btdk`) has no keys and no expression in every corpus sample. A Source Text expression is stored as on any other property: its text in the `tdbs`'s `Utf8`, `tdb4` byte 120 = 1, byte 119 = 0 when enabled and 1 when disabled (`tests/fixtures/text-synthetic.aep`: `text.sourceText + "!"` enabled, `"X"` disabled; the `btdk` keeps the typed text). Source Text keyframes (presumably one `btdk` per key) were not sampled, the reader only reports them (`Layer::source_text_property`, `Layer::text_document_count`) | 110-114, 243-248, 334-340, text-synthetic | E; expression V |
|
||||
|
||||
API: `TextDocument` (`paragraphs`, `style_runs`, `text_box`, `paragraph_texts`, `font_of`,
|
||||
`incomplete_runs`, `undecoded_style_fields`, `undecoded_paragraph_fields`, `kerning`,
|
||||
|
||||
+9
-5
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user