Merge pull request #3449 from arnav-hiwarkar/fix/3087-aep-psd-layers

.aep import: an item made from one Photoshop layer shows that layer (#3087)
This commit is contained in:
ash authored and GitHub committed 2026-11-12 12:58:31 +05:30
commit 9a4d02a827
9 files changed
+236 -14

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
pub use error::AepError;
pub use footage::{Footage, SequenceInfo};
pub use footage::{Footage, PsdLayer, SequenceInfo};
pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer};
pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey};
pub use masks::{Mask, MaskAtom, MaskMode, PathVertex, ShapePath};
+95 -2
View File
@@ -1,7 +1,7 @@
//! Item ids and labels, footage items (solids and files), precomp linkage, and the name and size
//! of a layer's source.
use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text};
use crate::container::bytes::{be_f32, be_f64, be_i32, be_u32, cstr, le_u32, text, until_nul};
use crate::container::chunk::Chunk;
use crate::container::header::Rational;
use crate::container::typed::{Item, ItemKind, Layer, Project};
@@ -45,6 +45,59 @@ pub struct Footage {
/// True when the alias points at a folder (`"target_is_folder":true`): an image sequence is ONE
/// item whose alias is its folder and whose `opti` holds the first file name.
pub is_sequence: bool,
/// The one layer of a Photoshop file the item shows; `None` for the merged image and for
/// any other footage.
pub psd_layer: Option<PsdLayer>,
}
/// One layer of a Photoshop file shown by a footage item, from its `opti` (tag `8BPS`,
/// docs/aep-format.md 3.3): the layer record's index as a big-endian `i32` at byte 14 (-1 for the
/// merged image), the document's sides as little-endian `u32` at 32 and 36, its bounds top, left,
/// bottom, right as little-endian `i32` at 78, 82, 86 and 90, and its name (UTF-8, NUL
/// terminated) from byte 344.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PsdLayer {
/// The layer record's position in the file (bottom of the stack = 0).
pub index: u32,
pub name: String,
/// Top, left, bottom, right in document pixels; all 0 for a layer without pixels (an
/// adjustment layer).
pub bounds: [i32; 4],
/// The document's two sides (bytes 32 and 36; which one is the width is not sampled: the one
/// file read has a square document). 0 when not stored.
pub document: [u32; 2],
}
/// Most bytes of a Photoshop layer name read.
const MAX_NAME: usize = 1024;
impl PsdLayer {
/// The bounds' width and height; `None` when they are empty.
pub fn size(&self) -> Option<(u32, u32)> {
let [top, left, bottom, right] = self.bounds;
let w = u32::try_from(i64::from(right) - i64::from(left)).ok().filter(|w| *w > 0)?;
let h = u32::try_from(i64::from(bottom) - i64::from(top)).ok().filter(|h| *h > 0)?;
Some((w, h))
}
/// Whether the document is `size` big, its sides in either order.
pub fn document_is(&self, size: (u32, u32)) -> bool {
let [a, b] = self.document;
a > 0 && b > 0 && ((a, b) == size || (b, a) == size)
}
/// The layer an `opti` names; `None` for another tag, the merged image or a cut record.
fn read(opti: &[u8]) -> Option<Self> {
if opti.get(..4) != Some(&b"8BPS"[..]) {
return None;
}
let index = u32::try_from(be_i32(opti, 14)?).ok()?;
let le_i32 = |at| le_u32(opti, at).map(|v| i32::from_le_bytes(v.to_le_bytes()));
// (After Effects writes 602 bytes; a longer hostile name is cut.)
let name = opti.get(344..).map(|b| text(until_nul(b.get(..MAX_NAME).unwrap_or(b)))).unwrap_or_default();
let document = [le_u32(opti, 32).unwrap_or(0), le_u32(opti, 36).unwrap_or(0)];
Some(Self { index, name, bounds: [le_i32(78)?, le_i32(82)?, le_i32(86)?, le_i32(90)?], document })
}
}
impl Footage {
@@ -330,6 +383,7 @@ impl<'f, 'a> Item<'f, 'a> {
file_size: be_u32(sspc, 208).unwrap_or(0),
audio_sample_rate: be_f64(sspc, 160).filter(|r| r.is_finite() && *r > 0.0),
is_sequence,
psd_layer: PsdLayer::read(opti),
})
}
@@ -370,7 +424,46 @@ impl<'f, 'a> Project<'f, 'a> {
#[cfg(test)]
mod tests {
use super::json_string;
use super::{PsdLayer, json_string};
/// A 602-byte Photoshop `opti` naming layer `index` with `bounds` and `name`.
fn opti(index: i32, bounds: [i32; 4], name: &[u8]) -> Vec<u8> {
let mut b = vec![0u8; 602];
b[..4].copy_from_slice(b"8BPS");
b[14..18].copy_from_slice(&index.to_be_bytes());
for (i, v) in bounds.iter().enumerate() {
b[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes());
}
b[344..344 + name.len()].copy_from_slice(name);
b
}
#[test]
fn a_photoshop_layer_reads_its_index_bounds_and_name() {
let l = PsdLayer::read(&opti(7, [749, 800, 764, 848], "tést".as_bytes())).unwrap();
assert_eq!((l.index, l.name.as_str(), l.bounds, l.size()), (7, "tést", [749, 800, 764, 848], Some((48, 15))));
// The merged image, another tag, a cut record: no layer.
assert_eq!(PsdLayer::read(&opti(-1, [0; 4], b"x")), None);
let mut other = opti(1, [0; 4], b"x");
other[..4].copy_from_slice(b"Soli");
assert_eq!(PsdLayer::read(&other), None);
assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..80]), None);
// A cut name, and no name at all.
assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..345]).unwrap().name, "x");
assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..300]).unwrap().name, "");
// A layer without pixels has no size; extreme bounds do not overflow.
assert_eq!(PsdLayer::read(&opti(2, [0; 4], b"")).unwrap().size(), None);
assert_eq!(PsdLayer::read(&opti(2, [i32::MIN, i32::MIN, i32::MAX, i32::MAX], b"")).unwrap().size(), Some((u32::MAX, u32::MAX)));
assert_eq!(PsdLayer::read(&opti(2, [i32::MAX, 0, i32::MIN, 1], b"")).unwrap().size(), None);
// The document's size, its sides in either order; none when not stored.
let mut doc = opti(0, [0, 0, 1000, 800], b"");
doc[32..36].copy_from_slice(&800u32.to_le_bytes());
doc[36..40].copy_from_slice(&1000u32.to_le_bytes());
let l = PsdLayer::read(&doc).unwrap();
assert_eq!(l.document, [800, 1000]);
assert!(l.document_is((800, 1000)) && l.document_is((1000, 800)) && !l.document_is((800, 800)));
assert!(!PsdLayer::read(&opti(0, [0; 4], b"")).unwrap().document_is((0, 0)));
}
#[test]
fn reads_a_string_with_escapes() {
+42 -2
View File
@@ -12,7 +12,8 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, TrackMatte,
AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, SourceLayer,
TrackMatte,
};
use effectcraft_time::Tick;
@@ -216,7 +217,17 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
let aep_id = item.id();
// An item without a stored name shows its file's name, a sequence `shot_[0101-0103].exr`
// (126, 603-607).
let name = if name.is_empty() { default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string()) } else { name };
// One layer of a Photoshop file is `<layer>/<file>`, as EffectCraft's own Photoshop import
// names it (#3087).
let name = if name.is_empty() {
let file = default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string());
match &f.psd_layer {
Some(l) if !l.name.is_empty() => format!("{}/{file}", l.name),
_ => file,
}
} else {
name
};
if f.is_solid {
// A null object's placeholder solid is not an item here.
if aep_id.is_some_and(|i| null_only.contains(&i)) {
@@ -299,6 +310,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
missing: !present,
placed_page,
sequence,
layer: source_layer(&f, &name, &mut cx.col),
..Footage::default()
};
// The native frame rate and the stored duration of sequences and clips (352, 603, 606).
@@ -338,6 +350,34 @@ fn is_placed_page_path(path: &str) -> bool {
matches!(ext.as_deref(), Some("pdf" | "ai"))
}
/// The Photoshop layer a footage item shows (#3087): at its own bounds when the item is as big
/// as they are (Retain Layer Sizes), else on the document. A layer without pixels (an adjustment
/// layer) keeps the merged image and is reported. So is a layer as big as the document but not at
/// its corner (the item's size fits both framings; its bounds are used), and an item as big as
/// neither (the document is used).
fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option<SourceLayer> {
let l = f.psd_layer.as_ref()?;
let Some(size) = l.size() else {
col.lose(LossCategory::UnsupportedItems, format!("footage '{name}': a Photoshop layer without pixels (an adjustment layer), the merged image used"));
return None;
};
let layer_size = size == (f.width, f.height);
if !layer_size && l.document.iter().all(|side| *side > 0) && !l.document_is((f.width, f.height)) {
col.lose(
LossCategory::UnsupportedItems,
format!("footage '{name}': {} x {} is neither the Photoshop layer's size nor the document's, shown on the document", f.width, f.height),
);
}
let [top, left, ..] = l.bounds;
if layer_size && (top, left) != (0, 0) && l.document_is(size) {
col.lose(
LossCategory::UnsupportedItems,
format!("footage '{name}': a Photoshop layer as big as the document but at ({left}, {top}), shown at its own bounds (Retain Layer Sizes assumed)"),
);
}
Some(SourceLayer { index: l.index, name: l.name.clone(), layer_size, ..SourceLayer::default() })
}
/// The name After Effects shows for a footage item that stores none: the file's name, or for a
/// sequence the name with the frame range (`shot_[0101-0103].exr`).
fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option<String> {
+2 -1
View File
@@ -56,7 +56,8 @@ pub enum LossCategory {
/// Layers that could not be imported for another reason (unknown layer type, missing or
/// unsupported source, precomposition loop).
UnsupportedLayers,
/// Project items of a kind that is not imported.
/// Project items of a kind that is not imported, and items made from a Photoshop layer
/// without pixels (they show the merged image).
UnsupportedItems,
/// Data that could not be read (damaged headers, unreadable keyframes).
DamagedData,
+4 -4
View File
@@ -20,8 +20,8 @@ pub use container::{
GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer,
LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS,
Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode,
Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun,
TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace,
curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map,
write, write_to,
Property, PsdLayer, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin,
StyleRun, TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk,
WorkingSpace, curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx,
read_aepx_with_limits, style_map, write, write_to,
};
@@ -720,6 +720,7 @@ fn extreme_values_in_the_batch_3_fields() {
file_size: 0,
audio_sample_rate: None,
is_sequence: true,
psd_layer: None,
};
for (num, den, rate) in [(3i64, 30i64, 30u32), (3, 0, 30), (-3, 30, 30), (i64::MAX, 1, u32::MAX), (i64::MIN, -1, u32::MAX), (1, i64::MAX, 1), (0, 1, 0)] {
let f = Footage { duration: Some(Rational { num, den }), native_frame_rate: rate, ..base.clone() };
+86
View File
@@ -843,6 +843,92 @@ fn a_default_state_colour_record_holds_the_value() {
assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default");
}
/// The project with its png item made one layer of a Photoshop file `art.psd`: the `opti` of
/// docs/aep-format.md 3.3 (tag `8BPS`, the layer index at 14, a 1000 x 1000 document at 32 and 36,
/// its bounds at 78-94, its name at 344) and the item `size` big.
fn as_psd_layer(index: i32, bounds: [i32; 4], name: &str, size: (u32, u32)) -> Vec<u8> {
let mut opti = vec![0u8; 602];
opti[..4].copy_from_slice(b"8BPS");
opti[14..18].copy_from_slice(&index.to_be_bytes());
opti[32..36].copy_from_slice(&1000u32.to_le_bytes());
opti[36..40].copy_from_slice(&1000u32.to_le_bytes());
for (i, v) in bounds.iter().enumerate() {
opti[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes());
}
opti[344..344 + name.len()].copy_from_slice(name.as_bytes());
let alias = br#"{"fullpath":"C:\\x\\art.psd","platform":1,"target_is_folder":false}"#.to_vec();
mutate(&mut |c| {
if !c.is_list(b"Pin ") {
return;
}
let Body::List { children, .. } = &mut c.body else { return };
if !children.iter().any(|k| k.is(b"opti") && k.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..]))) {
return;
}
// No sequence naming between the alias and the `opti`.
children.retain(|k| k.is(b"sspc") || k.is(b"opti") || k.is_list(b"Als2"));
for k in children.iter_mut() {
if k.is(b"opti") {
k.body = Body::Data(opti.clone().into());
} else if k.is(b"sspc") {
set_data(k, 30, &size.0.to_be_bytes());
set_data(k, 34, &size.1.to_be_bytes());
}
}
visit(children, &mut |k| {
if k.is(b"alas") {
k.body = Body::Data(alias.clone().into());
}
});
})
}
/// #3087: an item made from one layer of a Photoshop file shows that layer, named
/// `<layer>/<file>`; it showed the whole document, named after the file. Item as big as the layer:
/// Retain Layer Sizes; as big as the document: the layer on the document.
#[test]
fn a_photoshop_layer_item_shows_its_layer() {
for (size, layer_size) in [((48, 15), true), ((1000, 1000), false)] {
let (p, r) = run(&as_psd_layer(7, [749, 800, 764, 848], "Tëst", size), true);
r.unwrap();
let (name, f) = p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.name.clone(), f.clone())) } else { None }).unwrap();
assert_eq!(name, "Tëst/art.psd");
let l = f.layer.expect("the layer");
assert_eq!((l.index, l.name.as_str(), l.layer_size, (f.width, f.height)), (7, "Tëst", layer_size, size));
}
}
/// A layer as big as the document but off its corner fits both framings: shown at its own bounds
/// and reported. One at the corner, or bigger than the document, reads one way and is not. An item
/// as big as neither is shown on the document and reported.
#[test]
fn a_photoshop_layer_as_big_as_the_document_off_its_corner_is_reported() {
let framing = |bounds, size| {
let (p, r) = run(&as_psd_layer(3, bounds, "Moved", size), true);
let found = losses(&r.unwrap(), LossCategory::UnsupportedItems);
let said = |what: &str| found.iter().any(|d| d.contains("'Moved/art.psd'") && d.contains(what));
(the_footage(&p).layer.map(|l| l.layer_size), said("as big as the document"), said("neither the Photoshop layer's size"))
};
assert_eq!(framing([5, 10, 1005, 1010], (1000, 1000)), (Some(true), true, false));
assert_eq!(framing([0, 0, 1000, 1000], (1000, 1000)), (Some(true), false, false));
assert_eq!(framing([9, -346, 1083, 1086], (1432, 1074)), (Some(true), false, false));
assert_eq!(framing([749, 800, 764, 848], (1000, 1000)), (Some(false), false, false));
// An item as big as neither the layer nor the document: shown on the document, reported.
assert_eq!(framing([5, 10, 1005, 1010], (800, 800)), (Some(false), false, true));
}
/// #3087: a Photoshop layer without pixels (an adjustment layer) keeps the merged image and is
/// reported; the merged image itself (index -1) is no layer.
#[test]
fn a_photoshop_layer_without_pixels_keeps_the_merged_image() {
let (p, r) = run(&as_psd_layer(4, [0; 4], "Levels 1", (1000, 1000)), true);
let found = losses(&r.unwrap(), LossCategory::UnsupportedItems);
assert!(found.iter().any(|d| d.contains("'Levels 1/art.psd'") && d.contains("without pixels")), "{found:?}");
assert_eq!(the_footage(&p).layer, None);
let (p, _) = run(&as_psd_layer(-1, [0; 4], "", (1000, 1000)), true);
assert_eq!(the_footage(&p).layer, None);
}
/// #3087: an expression on Orientation arrives with the stored value, as on any property; it was
/// dropped together with the value and reported as "not decoded".
#[test]
+2 -1
View File
@@ -1,6 +1,6 @@
# After Effects project format (`.aep`)
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** 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:** 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
@@ -263,6 +263,7 @@ property of `L` after 229 and 230). The corpus manifest calls the classic render
| footage size | `Pin /sspc` | 30, 34 | u32, u32 | width, height in pixels | 000 (1920 x 1080), 022 (100 x 100 null), 126 (8 x 8), 211, 212, 259 | V |
| footage name | `Pin /opti` | 26 (solid), 58 (file) | ASCII, NUL terminated | the item's own `Utf8` name is empty for footage | 000 (`L`), 022 (`Null 1`), 126 (`test8x8.png`), 211 (`S100`), 259 (`Blue`) | V |
| **solid colour** | `Pin /opti` | 10, 14, 18, 22 | 4 x f32 | ARGB, 0..1: red [1, 0, 0] is `1, 1, 0, 0`, blue [0, 0, 1] is `1, 0, 0, 1` | 000, 259 | V |
| **Photoshop layer** | `Pin /opti` (tag `8BPS`) | 14; 32, 36; 78, 82, 86, 90; 344 | i32 BE; 2 x u32 **LE**; 4 x i32 **LE**; UTF-8, NUL terminated | an item made from one layer of a `.psd`: the layer record's index in the file (bottom of the stack = 0; -1 is the merged image), the document's two sides (which is the width: **?**, the one sample is square), the layer's bounds top, left, bottom, right in document pixels (all 0 for a layer without pixels, such as an adjustment layer) and the layer's name. The item's size (`sspc` 30 / 34) is the bounds' size with Retain Layer Sizes, else the document's. `Footage::psd_layer` | the project of issue 3087: ten layers, each index, bounds and name equal to the `.psd`'s layer record at that position (`Layer 1` `[9, -346, 1083, 1086]`, item 1432 x 1074; `test` `[749, 800, 764, 848]`, item 48 x 15; 32 and 36 hold 1000, 1000 in every record, the `.psd`'s size) | V |
| file path | `Pin /Als2/alas` | key `fullpath` | JSON string | alias JSON of a file footage item | 126 (the fixture holds a neutral path) | V |
| precomp linkage | `Layr/ldta` | 40 | u32 | the layer's source id is the id of the precomp's item | 124 | V |
+3 -3
View File
@@ -1,6 +1,6 @@
# After Effects project import
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (renderLine truncated
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an item made from one layer of a Photoshop file shows that layer (#3087); before that: the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y RotatLine truncated
File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx`
and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project).
@@ -70,7 +70,7 @@ outside the sampled ones keeps the current value and is reported as `project set
| Shape layers | groups (nested, with anchor, position, scale, skew, skew axis, rotation, opacity), rectangle (size, position, roundness), ellipse (size, position), polystar (star or polygon, points, position, rotation, radii, roundness), bezier path (vertices and tangents in layer pixels, path keys), fill (colour, opacity, fill rule), stroke (colour, opacity, width, cap, join, miter limit), gradient fill and stroke (linear or radial, start and end point; the default white-to-black colours), trim paths (start, end, offset, trim multiple shapes), round corners (radius), merge paths (mode; 707, 708), offset paths (amount, line join, miter limit, copies, copy offset; 710; After Effects offsets copy *i* by Amount x (*i* + Copy Offset), default 1, EffectCraft by Amount x (*i* + 1 + copyOffset), so the copy offset arrives 1 less, also its keys; an expression on it is kept and reported; 826-828, 828 renders equal to After Effects on every grid point), repeater (copies, offset, composite, transform; 709), in file order, with keyframes and expressions, the operators' defaults as After Effects reads them back (731); the layer anchor in pixels; 238-242, 330-333, 451-484, 519, 705-710, 731 |
| Markers | layer and composition markers: time (layer time for layer markers, exact frames), duration (1/600 s units, 509, 510), comment, chapter, URL, frame target, label, cue point (name, event or navigation, parameters); 122, 123, 249, 347-349, 509, 510 |
| Time Remapping | an enabled remap becomes the `timeRemap` property (source seconds, keys in layer time with their interpolation and ease, expression), first in the layer's properties as Layer > Time > Enable Time Remapping puts it; 319, 354 |
| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported |
| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported. **One layer of a Photoshop file** shows that layer (`Footage::layer`: its index and name; at its own size with Retain Layer Sizes, else on the document) and is named `<layer>/<file>` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported; so is a layer as big as the document but not at its corner, which the item's size does not tell apart (shown at its own size), and an item as big as neither the layer nor the document (shown on the document) |
Units, the "absent = default" rule and the keyframe templates are the reader's
([aep-format.md](aep-format.md), sections 3.3–3.6). The importer converts them to EffectCraft's:
@@ -345,7 +345,7 @@ for the `unlisted` ones. The categories:
| `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop |
| `track_mattes` | a track matte of unknown mode, whose matte layer was not imported or does not exist, a layer matted by itself, or a matte of the older "layer above" form on the top layer |
| `unsupported_layers` | an unknown layer type, a layer without a usable source, a precomposition loop (the layer is cut) |
| `unsupported_items` | a project item of an unknown kind |
| `unsupported_items` | a project item of an unknown kind; an item made from a Photoshop layer without pixels (it shows the merged image), or from one as big as the document but not at its corner (shown at its own size), or an item as big as neither the layer nor the document (shown on the document) |
| `damaged_data` | a header, key or value that could not be read, numbers that are not finite or beyond ±1e9 (default used); an unreadable text document (the text layer is imported with empty text), damaged style runs |
`limitations` states what the reader cannot see at all, so it can be neither imported nor counted: