diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index 7a3782d79..8fca7c27b 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -52,8 +52,9 @@ pub struct Footage { /// 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. +/// 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). @@ -62,6 +63,9 @@ pub struct PsdLayer { /// 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. @@ -76,6 +80,12 @@ impl PsdLayer { 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 { if opti.get(..4) != Some(&b"8BPS"[..]) { @@ -85,7 +95,8 @@ impl PsdLayer { 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)?] }) + 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 }) } } @@ -444,6 +455,14 @@ mod tests { 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] diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 9da8bb39a..2804d365b 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -352,14 +352,23 @@ fn is_placed_page_path(path: &str) -> bool { /// 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. +/// 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, and its bounds are used. 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() }) + let layer_size = size == (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 diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 0c9e79446..0c00088a5 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -844,12 +844,14 @@ fn a_default_state_colour_record_holds_the_value() { } /// 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. +/// 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 { 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()); } @@ -896,6 +898,22 @@ fn a_photoshop_layer_item_shows_its_layer() { } } +/// 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. +#[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); + (the_footage(&p).layer.map(|l| l.layer_size), found.iter().any(|d| d.contains("'Moved/art.psd'") && d.contains("as big as the document"))) + }; + assert_eq!(framing([5, 10, 1005, 1010], (1000, 1000)), (Some(true), true)); + assert_eq!(framing([0, 0, 1000, 1000], (1000, 1000)), (Some(true), false)); + assert_eq!(framing([9, -346, 1083, 1086], (1432, 1074)), (Some(true), false)); + // An item of another size frames the layer on the document: no ambiguity. + assert_eq!(framing([5, 10, 1005, 1010], (800, 800)), (Some(false), false)); +} + /// #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] diff --git a/docs/aep-format.md b/docs/aep-format.md index 2b4dae27f..0b73dcfd5 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-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 +> **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,7 +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 | +| **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 | diff --git a/docs/aep-import.md b/docs/aep-import.md index d2a418b85..9f9f8b2b0 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -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. **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 | +| 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; 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) | 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; an item made from a Photoshop layer without pixels (it shows the merged image) | +| `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) | | `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: