diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 531d74843..77511fcf1 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -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}; diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index da0369d64..7a3782d79 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -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,48 @@ 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, +} + +/// 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), 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], +} + +/// 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)) + } + + /// The layer an `opti` names; `None` for another tag, the merged image or a cut record. + fn read(opti: &[u8]) -> Option { + 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(); + Some(Self { index, name, bounds: [le_i32(78)?, le_i32(82)?, le_i32(86)?, le_i32(90)?] }) + } } impl Footage { @@ -330,6 +372,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 +413,38 @@ 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 { + 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); + } #[test] fn reads_a_string_with_escapes() { diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 7d5b902f1..9da8bb39a 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -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 `/`, 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,18 @@ 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. +fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option { + 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; + }; + Some(SourceLayer { index: l.index, name: l.name.clone(), layer_size: size == (f.width, f.height), ..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 { diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 2825b61b3..454b2991b 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -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, diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index 4bcdbab9e..cb4b9ff60 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -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, }; diff --git a/crates/aep/tests/it/container/robustness.rs b/crates/aep/tests/it/container/robustness.rs index 5c3422a10..0c887b8c4 100644 --- a/crates/aep/tests/it/container/robustness.rs +++ b/crates/aep/tests/it/container/robustness.rs @@ -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() }; diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 8e4c28f16..f1c36110c 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -842,3 +842,68 @@ fn a_default_state_colour_record_holds_the_value() { assert_eq!(colour(&default_state), colour(&written)); 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, 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 { + let mut opti = vec![0u8; 602]; + opti[..4].copy_from_slice(b"8BPS"); + opti[14..18].copy_from_slice(&index.to_be_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 +/// `/`; 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)); + } +} + +/// #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); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index 8830a869e..2b4dae27f 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -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 (`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; 78, 82, 86, 90; 344 | i32 BE; 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 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) | 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 | diff --git a/docs/aep-import.md b/docs/aep-import.md index c704e0a89..32e19fd42 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** 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 (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; 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: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **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: 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 (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; 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: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 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 `/` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported | 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: @@ -341,7 +341,7 @@ parameter gets the file's value: | `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) | | `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: