Merge remote-tracking branch 'upstream/main' into aep-import-lumetri

# Conflicts:
#	crates/aep/src/container/effect_map.rs
#	crates/aep/src/export/effects.rs
#	crates/aep/src/import/effects.rs
#	crates/aep/tests/it/export/effects.rs
#	crates/aep/tests/it/import/effects.rs
#	docs/aep-import.md
This commit is contained in:
vangel76
2026-11-08 16:32:43 +01:00
141 changed files with 7906 additions and 1328 deletions
+78 -1
View File
@@ -1,6 +1,6 @@
# After Effects project format (`.aep`)
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** 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-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
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
@@ -559,6 +559,36 @@ compositions, the items from one composition per popup item).** Each set value i
| `ADBE Offset` | 1 Shift Center To: point, default the layer centre; 2 Blend With Original: fixed, **max 1 (a 0..1 fraction)**, default 0 |
| `ADBE Deflicker` (Color Stabilizer) | 1 Stabilize: popup (Brightness, Levels, Curves), **default 2 (Levels)**; 2 Black Point, 3 Mid Point, 4 White Point: points, defaults 0.2 / 0.5 / 0.8 of the width at half the height; 5 Sample Size: fixed, max 50, default 3. The reference frame the Set Frame button picks is not among the parameters |
**Importer block 2026-11-05, #3112 (`1300-thirdparty.aep`; V for the parameter lists, H for the
data layouts, read from default instances only):**
| Effect (match name, plug-in version) | Parameters (`parT` index: type, valid range, default) |
|---|---|
| `CS Color Neutralizer` (CC Color Neutralizer, 1.3) | 1 / 7 / 13 Shadows / Midtones / Highlights Unbalance: colours (black, 127 grey, white; pickers: setting one sets the range's sliders, e.g. [0.27, 0.55, 0.85] gives Red 42.94, Green 1.18, Blue -44.12 = (mean - channel) x 150 / 255 on 8-bit values, and leaves no colour record); 2-6, 8-12, 14-18 a group per range (start, Red, Green, Blue, end) with float sliders -100..100 (slider -50..50), default 0; 19 Pinning: float 0..100 (slider 0..50); 20 Blend w. Original: float 0..100; 21-25 the Special group: 22 View popup (Final Result, Correction B to W, Correction Stair), default 1, 23 Black Point float 0..49.9, default 0, 24 White Point float 50.1..100, default 100 |
| `EXtractoR` (2.6.6) | 1 Channel Info (`EXtractoR-0013`): arbitrary data, 688 bytes, below; 2-6 the Process group: 3 Black Point and 4 White Point float sliders, valid ±3.4028235e38 (the float32 range; slider ±1 000), default 0 / 1; 5 UnMult: checkbox (empty `pard` name, the label in `pdnm`), default off |
| `IDentifier` (2.6.6) | 1 Channel Info (`IDentifier-0005`): arbitrary data, 148 bytes, below; 2 Display: popup (Colors, Luma Matte, Alpha Matte, Raw), default 1; 3 ID: integer slider, max 2 147 483 647, default 0 |
EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 bytes, then
four 164-byte records (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by
name: `R`, `G`, `B` by default; 2 the input's own channel, named `(copy)`: Alpha by default) and the
channel name NUL-terminated from record byte 35. EffectCraft imports kind 2 as its EXtractoR's `(copy)`,
the effect input's channel of the same position as earlier effects left it, not as `A` (which names
an OpenEXR footage's own alpha plane). The rest is not read: it differs between instances
with the same channels (uninitialised-looking values, also in bytes 1..4 of a record: `02 00 00 32`
on Alpha in one definition's default, which is why only byte 0 is the kind). **A chosen channel**
(`extractor-07-preset-channel-z`, a preset of our own that reads `Z` into Red, Green and Blue) is kind
1 with its name (`Z`), and the `u32` at record byte 4 is then 13, which looks like the channel's
position in the EXR's sorted channel list of 14 (the defaults hold 0 there: H). IDentifier's data
differs between instances with the same settings except for **the chosen channel's name**,
NUL-terminated from byte 19, which is `Object ID` when no channel is chosen. After Effects lists
only integer (UINT) channels: in 1300's EXR (a FLOAT `ID` channel) it found none ("No ID channels
available"), so that block's preset `identifier-09-preset-channel-id` only sets ID 2; in 1310's
EXR (UINT `objectId` and `IndexOB.X` added) the preset `identifier-09-preset-channel-objectid-id2`
stores `objectId`, with 15 at byte 4 (the channel's position in the sorted list of 16, as for
EXtractoR: H). Without a chosen channel After Effects leaves the layer unchanged (1300 and 1310
defaults). The other bytes are not read. API: `ExtractorChannels`,
`identifier_channel_type`.
**Curves and Mesh Warp** (961-965, 985; applied from presets, the data is not scriptable): their
arbitrary-data parameter (type 11) always has a record (`tdbs` with a 4-byte `cdat`) and a `LIST
aRbs` / `aRbp`, also at the default. Curves (`ADBE CurvesCustom-0001`, 1644 bytes): `u16` 1, 1;
@@ -669,6 +699,52 @@ presets, and After Effects draws exactly the picked objects as selected (`crypto
`crypto-07`: box and disc, the two objects of `mat_paint`). API: `CryptomatteData` (refuses data without `cry1`, a length past the end or
a non-zero byte at 112).
**Puppet (`ADBE FreePin3`; ?: external listing, not the corpus, #3093).** No file of the
corpus, the committed fixtures or the samples holds a Puppet. The tree below comes from outside
the corpus: a public scripting listing of the effect's properties (a forum post, an After Effects
release from before the Advanced and Bend pins and Density). It is not bytes we have seen; that
After Effects writes it by the general rule (a `tdmn` and the `LIST tdgp` or `tdbs` it names,
nested in the instance's value group `sspc/tdgp`, as masks, shapes and layer styles are) is
**H**:
```text
Puppet ADBE FreePin3
On Transparent ADBE FreePin3 On Transparent
arap ADBE FreePin3 ARAP Group (hidden)
Auto-traced Shapes ADBE FreePin3 Outlines
Mesh ADBE FreePin3 Mesh Group (hidden)
Mesh 1 ADBE FreePin3 Mesh Atom
Mesh ADBE FreePin3 Mesh (hidden: the triangulation)
Triangles ADBE FreePin3 Mesh Tri Count
Expansion ADBE FreePin3 Mesh Expansion
Deform ADBE FreePin3 PosPins
Puppet Pin 1 ADBE FreePin3 PosPin Atom
Vertex Offset ADBE FreePin3 PosPin Vtx Offset (hidden, 2 values)
Vertex Index ADBE FreePin3 PosPin Vtx Index (hidden; 4294967295 = none)
Position ADBE FreePin3 PosPin Position
Overlap ADBE FreePin3 HghtPins (hidden while empty)
Stiffness ADBE FreePin3 StarchPins (hidden while empty)
```
A pin's rest place is not a property: it is a vertex of the hidden mesh (Vertex Index) plus an
offset, so it cannot be read without decoding the mesh data. The unit of Position (layer pixels,
or a fraction of the layer as effect points are) is **?**. The match names of the Overlap and
Starch pin atoms and their properties, of the Advanced and Bend pins (Scale, Rotation) and of
Density, Mesh Rotation Refinement and the Puppet Engine are **?**, and so is which of them are
`parT` parameters; whether After Effects stores some `parT` parameters of a fresh Puppet even at
their defaults (the importer would then report them every time) is **?**; whether a Puppet
without pins has an (empty) Mesh Group, or no ARAP group at all, is **?**. The importer
(`import/puppet.rs`) therefore brings the effect in as EffectCraft's Puppet without meshes or
pins and reports, per mesh, Triangles, Expansion and per pin group the pins, how many are keyed
or carry an expression, and their atom match names; every entry outside this tree is named in the
report. The sample that settles it: a fresh Puppet with no pins in one composition and, in
another, a 400 x 800 solid in a larger composition (pixels against fractions) with a Puppet of
three Position pins (one keyed, one moved off its rest place without
keys), one Advanced, one Bend, one Starch and one Overlap pin, Triangles, Expansion and Density
off their defaults, a second mesh, and a scripting read-back of every property's match name and
value, the hidden ones included. Pins added by script are reported to come out disabled, so the
pins are placed by hand or by a preset.
### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`)
| Field | Chunk path | Offset | Type | Meaning | Evidence | Conf. |
@@ -953,6 +1029,7 @@ another value).
**Effects**
- `pard` byte 48 (flags: **?**), 68 / 72 (the point default as a percent pair: **V** in the corpus for 074 and 368, not exposed), 120 (the float slider default again as f32: **V**, not exposed), 124 (display decimals: **H**), 132..147 (fixed slider slider-max, default copy and flags; the low bit of @144 is set on the two percent-displayed sliders and clear on Scatter: **H** "display as percent"), the colour default copy at @60, and the stale memory after the parameter name. The fixed slider valid minimum is not decoded (the float slider one is, 389).
- Puppet (`ADBE FreePin3`, 3.7): the hidden mesh data, a pin's Vertex Index and Vertex Offset (its rest place), the unit of a pin's Position, the Overlap, Starch, Advanced and Bend pins, Density, Mesh Rotation Refinement and the Puppet Engine: no sample (**?**).
- Effects renamed, or stacked twice on ONE layer (`Gradient Ramp 2`: does the second keep an empty `parT`?); which instance keeps the full `parT` when a second instance is created on a layer below the first; expressions on effect parameters; keyed per-channel Exposure values and a keyed checkbox; Curves / Levels (arrays), Fractal Noise, and whether the `tdpi` of a Layer Control holds an id or an index after the referenced layer is reordered or deleted.
**Masks, shapes**
+24 -3
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -713,6 +713,11 @@ in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md).
step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers).
* Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a
click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly.
A text layer's name follows its text until it is renamed; renaming it to an empty name makes
it follow the text again (After Effects). `layer.setText` and typing on canvas rename a text
layer whose name still follows its text; `layer.newText {name}` and a name set by
`layer.settings` or a script's `layer.name = …` (which runs `layer.rename`) stay, and an empty
`layer.settings` name follows the text again too.
* Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time
Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`.
* `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its
+37 -10
View File
@@ -240,16 +240,43 @@ converted on demand with the HDR step and without the exposure; the Info panel s
frame's float values. The browser keeps 8-bit frames for now (its frame workers send RGBA8).
A persistent **disk cache** (`render::disk_cache`, Settings ▸ Media & Disk Cache) backs both
the layer cache and the viewer's RAM preview: layer buffers that were slow to render and every
preview frame are written (LZ4, atomic rename, checksummed) under 128-bit content keys — the
comp's content hash (the comp, what it uses, footage file size and time) plus frame, scale and
view for frames; the layer key salted with the footage fingerprint for layers — so entries
survive restarts and never go stale. Frame entries are 8-bit RGBA, or for 32 bpc viewer frames
half floats with their bytes shuffled into planes before LZ4 (the entry header's format byte
tells them apart; the render options in the key keep the two kinds apart). LRU eviction keeps it under the size limit; Edit ▸ Purge
empties it, `cache.diskStats` reports it, and the timeline draws disk-only frames in blue. In the
browser the frames live in the Origin Private File System: frame workers write them, the page
keeps the index (`DiskIndex`) and reads hits ([web.md](web.md#disk-cache)).
the layer cache and the viewer's RAM preview: layer buffers that were slow to render, every
preview frame and Roto Brush / Object Matte segmentations are written (LZ4, atomic rename,
checksummed) and survive restarts. The design (#3184):
- **Keys from memory.** A frame's key is its comp's content key plus frame, scale, view and
render options; the content key hashes the project settings and the items the comp can draw
(its layers but those that can't change the picture, nested comps, footage with its settings
and paths, solids, Media Replacement footage, proxies: `Project::pixel_reach`). Expressions
among them add what they can read: every layer of their comps, the markers, the comps and
footage they name with literal names (`comp("Name")`, `footage("Name")`, and which items have
those names), or the whole project when they compute a name or reach items another way
(`expression_reach`). An edit to anything else keeps the key. A layer buffer's key is the
layer cache's content key (which names the footage it uses with its paths) and the renderer
version. Nothing here looks at a file, and nothing ever scans the project's files.
- **File lists checked on hit.** Every frame and layer entry keeps the source files it was
made of, with the size and modification time they were read with (the browser: size and
content hash). An entry is used only while they are unchanged: one `metadata` call per listed
file when it is read. A changed, missing or unreadable file, a damaged entry or an older
format is a miss; the entry is deleted and the frame renders again and replaces it.
- **Incomplete lists stay in memory.** A frame or buffer whose files can't all be named (a
source that doesn't say what it read, more than 4096 files) is shown but never written.
- **Reload Footage drops entries.** File ▸ Reload Footage deletes the frame and layer entries
whose lists name the item's files (the writer thread reads each entry's list, not its
pixels), so a file replaced by one with the same size and time is read again. A reload while
the cache is off is remembered for the session and applied when it is switched on, before it
is read. A render already under way offers entries made of the files as they were: each
render takes the cache's drop generation before it reads, and an entry listing a file dropped
after that is refused (`DiskCache::drop_generation`); the frame is shown, never stored.
- **Edits keep what they can't change.** RAM frames outside an edit's dirty spans are kept
(`Project::dirty_spans`), and their disk entries are linked to the comp's new key.
Frame entries are 8-bit RGBA, or for 32 bpc viewer frames half floats with their bytes shuffled
into planes before LZ4 (the entry header's format byte tells them apart; the render options in
the key keep the two kinds apart). LRU eviction keeps the cache under the size limit; Edit ▸
Purge empties it, `cache.diskStats` reports it, and the timeline draws disk-only frames in blue.
In the browser the frames live in the Origin Private File System: frame workers write them, the
page keeps the index (`DiskIndex`) and reads hits ([web.md](web.md#disk-cache)).
**Deferred readbacks** (`effectcraft-gpu` `deferred`): where a readback can't be waited for (WebGPU
in a browser worker) a frame renders in passes. `Accelerator::frame_begin` / `pass_begin` /
+2 -1
View File
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. Never hand-edit.
username. Never hand-edit. Release builds regenerate it for the release commit
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
+6 -1
View File
@@ -142,7 +142,12 @@ Commands (CLI, MCP, control channel):
it supports it)
- `disk.diskCacheEnabled`, `disk.diskCacheMaxGb`, `disk.diskCacheFolder`: the persistent disk
cache of processed layers and frames (in the browser: frames in the Origin Private File System,
at most half the origin's quota; the folder setting does not apply there). The Disk page also
at most half the origin's quota; the folder setting does not apply there). On the desktop the
cache never fills its disk: it leaves 5% of the disk free, at least 2 GB and at most 10 GB, and
is smaller than `disk.diskCacheMaxGb` when free space requires it. The Disk page shows the limit
in effect in MB under Maximum Disk Cache Size, with a note naming the space kept free when free
disk space lowers it (with less than 64 MB of room nothing is written, and frames and layers
are not compressed for it). The Disk page also
shows **Browser Storage** in the web app: usage and quota, persistent storage, Clear buttons
(`storage.info` / `storage.persist` / `storage.clear`, see [web.md](web.md))
- `disk.mediaCacheFolder`: audio waveform summaries kept between sessions (`Peaks/`)
+10 -5
View File
@@ -332,9 +332,13 @@ pixels.
Settings ▸ Disk ▸ Disk Cache works in the browser, backed by the **Origin Private File System**
(`src/diskcache.rs`): viewer frames are kept under `effectcraft-cache/v1/frames/<32 hex>.ecc`
between visits, in the desktop cache's format (`disk_cache::frame_entry`: 8-bit premultiplied
RGBA, LZ4, a checksum), keyed by the same content hash (the comp and everything it uses, frame,
scale, view, render options).
between visits, in the desktop cache's format (`disk_cache::frame_entry`: the source files the
frame read with their size and content hash, then 8-bit premultiplied RGBA, LZ4, a checksum),
keyed by the same content hash (from memory: the comp, everything it can draw and what its
expressions can read, frame, scale, view, render options; see
[architecture.md](architecture.md)). The page reads an entry only while the files it lists are
unchanged; a frame whose files can't all be named is not stored. Reload Footage has nothing to
drop here: the file table's identities are content hashes, so a replaced file never matches.
- A render request to a frame worker carries the frame's disk key; the worker posts the frame,
then writes the entry with a **synchronous access handle** into a temporary file moved into
@@ -352,8 +356,9 @@ scale, view, render options).
Cache / `edit.purge {what: "disk"}` clears it. `effectcraft.info().diskCache` has the same.
**Layer buffers (M13.30).** Slow layer buffers (≥ 20 ms to render) go to the disk cache too
(`effectcraft-cache/v1/layers/<32 hex>.ecc`, `disk_cache::layer_entry`, keys salted per project
and footage as on the desktop). A layer lookup happens in the middle of a synchronous render,
(`effectcraft-cache/v1/layers/<32 hex>.ecc`, `disk_cache::layer_entry`, keyed as on the desktop:
`disk_cache::layer_disk_key`; each entry keeps the source files its buffer was made of, with
the file table's size and content hash, and is used only while they are unchanged). A layer lookup happens in the middle of a synchronous render,
where the browser's file system can't be read, so it is made synchronous by fetching first:
1. Each frame worker backs its layer cache with a `PrefetchStore` (`effectcraft_render::cache`):