* Render: memoized audio detection, bounded stretch, preroll, waveform, cache entries, 3D bounds, solids and selector time
comp_has_audio walked a precomp again for every layer using it; each comp
is answered once. A tiny stored stretch made one audio block fetch a vast
source range; playback speed is capped at 100x. Effect preroll is capped at
10 s. Waveforms stop where the audio ends and summarise at most a day.
Disk cache entries are decompressed only when their stated size matches
what their dimensions need (and at most 1 GiB). Advanced 3D triangle bounds
are clamped on both sides before narrowing. Solids larger than 16 Mpx are
made at a lower scale. A camera-facing layer's parent recursion is bounded.
A layer's Expression Selectors share one time allowance. A disk cache
folder under the temp folder is made private and may not be a link.
* address review: audio detection by breadth-first search, private cache folder resolved before the temp check, oversized layers not written, solid cap after rounding, selector static amount, viewport i32 clamp
* address review: private-folder check skips the temp folder itself; override value in expression fallback; assert alias cache opens
* address review: refuse the shared temp folder itself as a disk cache folder
* address review: shared counted selector budget, 30 s preroll, cache hardening (shared temp roots, matte discard, exact LZ4 length, render version 7), orient nesting guard
A precomp instance whose Essential Property names another LUT file renders
with that file, but a layer reading the instance through a layer parameter
was keyed on the nested comp's own file, so editing the overriding file left
that layer cached.
A layer graded with Apply Color LUT or Lumetri's Input LUT and Look was
cached by the parameter text alone, so the same frame kept showing the old
LUT from memory and from the disk cache after the file changed. A layer's
cache key (and the key of a layer reading it, through a precomp too) now
holds each LUT file's size and modification time, and a render lists the
LUT files it read, so a frame kept on disk made with an older file is not
used.
In an 8 bpc comp, a movie used by both a plain and a masked layer has an 8-bit and a float entry in the media pool, decoded separately. Rare and only a speed cost, so it is noted where the 8-bit path starts rather than changed.
AutoPick::ticket chooses a side and reports the frame's time once; dropped without one it ends the frame unmeasured. The viewer's frames, the renderer's top-level frames and frames rendered in passes use it, so an Auto CPU frame turned away by GPU admission (#3028), a cancelled export frame or any later exit no longer leaves its frame counted as running. The frame server keeps its choice across calls and still ends a replaced frame itself.
Layer jobs keep their float buffer until the writer compresses it, so the 64-job limit could hold ~8.5 GB of evicted 4K buffers. Layer and matte writes now also have a byte budget, like frames; a single bigger buffer is still taken when nothing else is queued.
footage_buf8 asked for a movie's 8-bit levels outside Renderer::read, so the media pool noted the movie in no recording: a layer buffer made of it listed no files and was still marked complete, and could be served stale after the movie changed on disk.
A GPU frame that failed or failed its health check, and a remote frame replaced before it finished, stayed counted as running on its side, so every later frame there was timed as one of several side by side and that side looked cheaper. AutoPick::abandoned releases them.
An 8 bpc render at Full quantises plain footage straight away, so the
media pool now keeps those movie frames as the 8-bit levels it is
quantised to (a quarter of the memory) and the renderer expands them
into the layer buffer: the same pixels to the bit. Other depths,
reduced renders, masks, colour profiles, fields, frame blending,
Cineon, Invert Alpha, linear light and 10/12-bit or float movies keep
the float path.
First playback of plain 1080p clips at Full caches ~43% faster on the
CPU, and the footage cache holds 4x the frames.
A preview renders up to 12 frames at once, and Auto only learns a frame's
time when it is done. A starting preview sent every lane to the GPU for
its warm-up, and on an integrated GPU 2–4× slower than the CPU each first
playback stalled. Now at most one GPU warm-up frame (and one re-probe frame
per side) runs at once, a frame's time counts per frame running on its side
at once, and the warm-up starts on the CPU, so the frames on screen come
first. Iris Xe, 1080p: Auto went from 13–34% slower than CPU only to 0–17%.
A footage layer's frame was copied from the footage cache, converted from
its colour profile, then quantised to the project depth in masked_source:
three passes over a 33 MB 1080p float frame. Footage without masks now
comes quantised from one fused pass (Pipe::copy_converted); other source()
callers still get unquantised pixels. Bit-identical (tested at 8/16/32 bpc).
A render thread handed each frame to the disk cache already copied pixel by
pixel and LZ4-compressed (~9 ms per 1080p frame) before it could start the
next one, and layer buffers were encoded even when the queue then dropped
them. The queue now holds raw RGBA (one memcpy via ColorImage::as_raw) and
Arc<Buf>s; the writer thread encodes them. Every frame of a 120-frame bench
run now reaches disk (113–119 before). cache_bench prints the flush time.
* WebAssembly ABI 2: neighbouring frames for effect plug-ins (#2773)
A wasm plug-in can export ec_api_version 2 and ec_render2 to receive the layer's previous and next frames, their layer times and validity flags. Neighbours come from PluginHost::input_at, are resampled onto the current grid, and are zeroed when absent. ABI 1 modules (ec_render) load and render unchanged; the manifest api stays independent. A layer with an ABI 2 plug-in keys the render cache on layer time; the Plug-in Library accepts ABI 1-2 releases. Docs, help text and tests updated.
* Fix ABI 2 neighbour times on NTSC frame boundaries
ime * fps is often a few ulps off an integer at rates such as 24000/1001. The old boundary test then took the bracket branch and passed the current frame back as prev (or
ext). Treat a near-integer as a frame boundary within 1e-6 frames, and test 2400 frames at 24000/1001: every boundary must get the frames either side. Also document the loader helpers for the docstring coverage check.
---------
Co-authored-by: justanshh <294019246+justanshh@users.noreply.github.com>
* #3099: placed .ai pages fill the size After Effects stored; null anchors default to the corner
Footage of an .aep keeps the size After Effects stored for a placed PDF / Illustrator / EPS
file (the ArtBox, rounded up), and masks and anchors are measured against it. The page was
drawn as a PDF viewer frames it (CropBox, /Rotate) at its own size, scaled by the width ratio
at full resolution and stretched to the stored aspect below 3/4: masks too large, art off
centre, squeezed at half resolution.
- effectcraft-pdf: Framing::Placed / parse_page_framed (ArtBox clipped to the CropBox,
/Rotate ignored); boxes with a non-finite corner or span are not used
- Footage::placed_page (set by the .aep importer, serialized only when true): the page is
framed so and filled into the stored size (effectcraft_render::vector_fit), decoded,
continuously rasterised and in Create Shapes from Vector Layer; Reload keeps the size;
the frame cache keys it by size and framing
- .aep import/export: a null's absent Anchor Point is [0, 0], as After Effects reads it
- fixture ai-mask-3099.aep (own synthetic files, scrubbed, profiles stubbed) and tests
against After Effects 2026's read-back
* #3099: placed-page framing only for .ai and .pdf, EPS as before; doc comments
After Effects' sizing was measured with PDFs named .ai only; an EPS from an .aep keeps its
bounding box at its own size (listed as not measured in docs/gaps.md and docs/aep-import.md).
Doc comments on the touched functions.
* #3099: shapes from a placed page keep its frame (track matte)
Create Shapes from Vector Layer converted every path of a placed page, so bleed, art past the
artboard or the page's background showed where the footage's raster hides them. The layers it
makes from a placed page now take a hidden "<layer> Frame" shape layer (the footage's
width x height) as their Alpha track matte: drawn at the comp's resolution, its edge is the
raster's on shape and image layers alike. The command returns the frame layer's id too.
Related to #1365. Display labels only; effect, command, parameter, panel and serialized ids and the AEP/script match-name tables are unchanged.
- 69 Cycore CC effects, Key Light, Mocha shape, Lumetri Color/Scopes and the Mercury renderer labels get descriptive names; the old names stay case-insensitive lookup and search aliases.
- Backend::parse accepts the new renderer labels, so reported settings round-trip.
- Display names are unique per language in every effect catalog (new test).
- Warp Stabilizer and Roto Brush keep their names until RFC-0029 settles.
- Mapping in docs/feature-naming.md.
* Disk cache: record the source files each frame reads
Part of #3184 (step 1): renders now report which footage files they read,
each with its identity (size and modification time) as read. Nothing uses
the list yet: images, cache keys and cache behaviour are unchanged.
- render: `reads` module with `FileStamp`, `ReadFile`, `ReadFiles` (sorted,
deduplicated, bounded; marked incomplete when it can't vouch for every
file) and the thread-safe `ReadLog`. `Renderer::reads` collects a frame's
files; `FootageSource::recording` lets a source note what it reads.
`file_stamp` uses the same identity.
- Layer cache entries keep the files their buffer was made of and report
them on every hit; derived entries (styles, blend space, flattened) keep
their base's. Buffers from the disk cache or the browser store don't know
their files yet and mark the list incomplete.
- Media pool: every file read notes its identity, taken from the file it
opens. Decoded frames, opened movies, GIFs, models and EXR channels keep
it with the cache entry, so a frame served from memory reports its file
without touching the disk.
- Viewer frame worker collects each frame's list and carries it to where
the disk entry is written (step 2 stores it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Automation: confined footage passes on the files it reads
Part of #3184: ConfinedFootage forwards FootageSource::recording to the
source it wraps, so renders of an automation session list their files
instead of coming back incomplete. The read tests also render through the
viewer's CancellableFootage wrapper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: frame entries keep their source files, comp key from memory
Part of #3184 (step 2). Fixes#3175: opening a comp no longer stamps every
footage file of the project before its first frame can show.
- The comp's content key (`disk_cache::comp_content_key`) is taken from the
project in memory alone: no footage or proxy file is stamped, no
`VirtualFileId` is asked. `comp_content_key_with` is gone; `frame_key`
hashes a new entry version, so entries of the earlier format are never
looked up again (LRU evicts them).
- Frame entries (formats 2 and 3) store the files the frame read
(`EntryFiles`: size and modification time as read; size and content hash
through `VirtualFileId` for files from memory; missing files), then the
pixels. Formats 0 and 1 read as misses and are deleted.
- Check on hit: `get_frame`/`get_frame_f16` look only at the listed files
(one metadata call each) on the frame worker. A changed, back or unreadable
file, or a damaged entry, is a miss: the entry is deleted and the frame
rendered and stored again.
- A frame whose file list is incomplete is shown but not stored. The RAM
preview keeps each frame's files, so `persist_missing` writes only frames
with a complete list.
- Remote frame worker: `FrameServer` records each frame's reads
(`take_files`); the web worker stores them with the entry and the page
checks them through the file table before using it. The browser's media
epoch existed only for the old key and is removed. Export does not use
frame entries.
- The cache indicator still only asks whether an entry exists.
Tests: entry format round trip and damage, the check per file, and in the
viewer: a changed file re-renders exactly its frames (and a reopen reads the
rest from disk); a frame checks only its own files in a project with a large
unused sequence and the key looks at no file; missing, unreadable files and
damaged entries never serve a stale frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: layer entries keep their source files, no footage salt
Part of #3184 (step 3 of 4).
Layer buffers on disk (and in the browser's layer store) now carry the
files they were made of, like frame entries since step 2, and are checked
against exactly those files when used. The project-wide footage salt is
gone: nothing stamps every footage file any more.
- Layer entry format 4: the buffer's EntryFiles, then the pixels. A hit
checks only the listed files (metadata, or the VirtualFileId hook for
files from memory; Missing must still be missing); a change, a damaged
entry or the old format 0 is a miss and the entry is deleted, so the
re-rendered buffer replaces it. A hit reports its file list, so frames
made of buffers from disk are complete and get stored again.
- Buffers whose files can't all be named are not written.
- Layer disk keys: layer_disk_key = hash of the layer entry version, the
renderer version and the layer cache key (which names the footage items
with their paths and settings, from memory).
- PrefetchStore checks on use; an entry made of changed files is dropped
and forgotten, so the new buffer is queued over it. FrameServer passes
its virtual-file hook to its layer cache.
- Removed: footage_salt(_with), file_stamp, footage_stamp,
set_disk_salt, set_store_salt, Session::refresh_disk_salt,
poll_disk_salt, footage_fingerprint, disk_salt, disk_salt_background,
disk_salt_job, DiskSaltJob, the UI's per-frame poll, the salt in
FrameServer and in the availability worker (with its cache_enabled
plumbing). The disk cache is attached as soon as it is configured.
- Docs: architecture.md, web.md.
Tests: layer entries check their files (size, time, missing/back,
folder, damaged, old format, incomplete); virtual files; prefetch store;
media tests with a real pool across restarts (only the changed file is
read, projects with the same item ids never share an entry, missing,
unreadable and damaged never serve stale buffers); engine tests that the
cache is attached at once and edits do no file work (#1926, #3106).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: no-salt worker test, sequence paths in the frame-blend key
Part of #3184 (step 3 of 4).
- A frame-blended footage frame's layer-cache key now hashes the
sequence's file list: with no project-wide salt, two projects whose
items share an id and a first frame must not share its disk entry.
- Test: a frame worker syncing, patching and rendering a project with an
unused 5000-frame sequence asks its file hook only about the file its
buffer reads (the salt asked about every file at every sync).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: the comp key covers what its expressions can read
With an expression anywhere in the project, every comp's disk key hashed
every item of the project, so any edit (another comp, an unused footage
item) threw away every comp's frames on disk. The key now covers what the
comp can draw plus what the expressions among it can reach
(Project::pixel_reach):
- without expressions: the items the comp draws, without silent layers,
plus the footage its precomp layers swap in (Media Replacement);
- with expressions: every layer of the comps they reach, their markers,
the comps and footage they name with string literals (comp("Name"),
footage("Name")) together with which items have those names now, and
the footage named like a reached source (its data is read by name);
- the whole project when an expression computes a name, uses the object
model's constructors or internals, dynamic code, computed keys or
writes, or text the scanner can't read for sure (regex literals,
template strings, escapes): expression_reach errs towards reaching more.
Markers count only where an expression can read them (#1083). Memory
only, no file is looked at.
Tests: scanner and reach unit tests in the project crate; engine tests
for the key (own-comp expressions keep the key through edits elsewhere,
comp("Other") follows Other and not a third comp, a second comp taking
the name counts, a computed name counts every item, markers read from
another comp); a ui-egui test that the disk frames outside an expression
layer are carried when the comp it names changes.
Part of #3184. Part of #1492.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: Reload Footage drops the entries of its files, docs
File > Reload Footage now deletes the frame and layer entries whose file
lists name the reloaded item's files (DiskCache::drop_files), so a file
replaced by one with the same size and modification time, which the
check on hit can't tell apart, is read again. The index knows keys, not
files: the writer thread reads the files section at the start of each
frame and layer entry (not the pixels) after the writes queued before
it, and deletes the entries that list one of the files. Until it is
done, an entry made of one of them is a miss and frames aren't linked to
new keys. The entries are gone for good, so they don't come back after a
restart. Automatic reloads don't drop anything: they follow a changed
stamp, which the check on hit catches. The browser needs nothing (its
file identities are content hashes).
Removed: disk_cache::seal, which steps 2 and 3 left without callers (the
tests use seal_format). Updated the module, seal_format and forget_media
docs, and docs/architecture.md / docs/web.md describe the final design:
what is cached, keys from memory, file lists checked on hit, nothing
scans the project, incomplete lists stay in memory, Reload Footage drops
entries, edits keep the frames they can't change.
Tests: render unit tests (same size and time passes the check but not
the drop; other files' entries stay; an entry queued before the drop is
dropped; a layer entry too; written again afterwards it is used; the
drop survives a restart; a pending drop is a miss), and an engine test
through file.reloadFootage.
Closes#3184. Part of #1492.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: file stamps keep the whole modification time
A file's modification time was nanoseconds since 1970 as u64: a time before 1970
read as "no time" (two such files of one size looked alike) and times after 2554
were truncated. It is now i128 nanoseconds, negative before the epoch; None only
means the file system has no time. The content-key hash writes a presence tag and
the full value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Automation: a refused footage read leaves the render's file list incomplete
Footage outside the read roots shows nothing and was noted as no file at all, so a
picture made without it had a complete, empty list and could be kept on disk for
a later session with other roots. A refused read during `recording` now marks the
list incomplete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Render: a buffer kept by another worker comes with its own files
LayerCache::insert returns the files of the buffer it keeps, and Renderer::keep
notes them in the render's log and returns them, so a render that uses another
worker's buffer lists that buffer's files (a file changed between the two reads
then has two identities and the list is incomplete).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Render: sampleImage and text fonts note the files they read
EvalCtx carries the render's read log. A sampleImage render records its footage
reads, keeps them with its per-thread cached buffer and notes them in the
evaluating render's log on every use. Text notes the font files it is drawn with,
with the identity their bytes were read with; a family that isn't installed, a
fallback or missing glyph, or a font from memory leaves the list incomplete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: frame entries store the whole modification time
A frame entry's file list stores a file's modification time as i128 nanoseconds
since 1970 (negative before), as the file stamps now keep it; the frame entry
format version goes to frame-entry-files-2 so entries in the old layout are not
read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: layer entries store the whole modification time
Layer entries share the frame entries' file list layout, which now stores the
full i128 modification time: the layer entry format version goes to
layer-entry-files-2. The module doc names the font files text is drawn with
among an entry's source files, and what keeps a list incomplete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Layer cache: buffers that read other layers are keyed by the switches passed down
A layer whose effect reads another layer (Gradient Wipe, Set Matte, Blend…) gets
that layer drawn by its own renderer. Through a precomp layer, Switches Affect
Nested Comps, the switches inherited from the precomp layers above and motion
blur on change those pixels, but the key held only the layers' own switches: a
buffer made with the setting on was reused (from memory or the disk cache) with
it off. Such layers now derive their key with what the renderer passes down.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Layer cache: masked buffers are keyed by the mask motion blur gate
A mask with Mask Motion Blur On blurs even when the layer's own switch is off,
as long as the render's gate is open (Render Settings motion blur, and with
Switches Affect Nested Comps the precomp layers above). The key held the
layer's own motion blur state only, so a buffer blurred with the gate open was
reused with it closed (and the other way round), also from the disk cache.
Masked layer, input and footprint keys now tell a closed gate apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Render: cached buffers list what their expressions and substituted characters read
- Buffers made under `Renderer::recorded` (layer content, a Time effect's input, an adjustment
layer's footprint) evaluate their masks, shapes and effects with the context that notes into
the buffer's own read log, so a file an expression reads (`sampleImage`, also at shutter times
mask motion blur adds and the cache key doesn't see) is in the buffer's files.
- A styled layer served from the cache lists the files of the cached body and passes too, so
the buffers made of it (flattened, blending space) don't get a complete list when one of them
came with an incomplete one.
- A character put in by Character Offset or Character Value without a relayout notes the font
it is drawn with; a fallback face (or none) leaves the list incomplete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Render: text lists the font file of a run that draws no glyph; tests clean their temp dirs
An empty first paragraph draws no glyph, but in a box its face sets the
first baseline that places every later line. The read list took faces only
from glyphs, so it could be complete without that file. Note each style
run's resolved face as well.
The font-reads and confine tests use tempfile::tempdir(): a fresh
directory per run, removed on panic too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: Reload Footage closes the gaps around its drop (#3184)
- A reload while the disk cache is off is kept on the session
(pending_disk_drops) and applied when the cache is switched on, before
anything is read from it.
- Each render takes the cache's drop generation before it reads (frame
worker; layer cache at the miss). An entry listing a file dropped after
that is refused under the queue's lock, so a render that was under way
during the reload can't store pixels from before it. Such a frame is
shown with an incomplete file list, so persist_missing skips it too.
The per-file record is bounded (past it, everything older is refused).
- An entry read while its drop was pending is a miss even when the
writer ends the sweep between the read and the check (a stale hit
found under load): the pending drops and the generation are taken
before the entry is read.
- Test: a reopened project serves its frame from disk with no source file
opened or decoded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: RAM frames keep the drop generation their render started at
Reload Footage empties the RAM preview, but an idle pass of
persist_missing may already hold a frame from before the reload. It
took the cache's drop generation when it ran, after the drop, so the old
pixels could be written back and, with the file's size and time
unchanged, served again after a restart.
Each RAM frame now keeps the disk cache its render started with and
that cache's drop generation then. persist_missing writes a frame only
to that cache, with that generation, and skips it once one of its files
was dropped since; a frame rendered without that cache is not written
from there.
Tests use tempfile directories (the layer drop test, the frame_files
folders under target/).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Doc comments for the functions this PR touches
* Disk cache: cap the free-space reserve at 10 GB, skip encodes the limit refuses
The reserve the cache leaves free on its disk was max(2 GB, 5% of the
disk) (#2551). On a 4 TB disk with 190 GB free that is 200 GB, so a
cache set to 20 GB had a limit of 0: every write was refused, the cache
never held anything, and only a log warning said so.
The reserve is now clamp(5% of the disk, 2 GB, 10 GB). It is there to
keep the cache from filling the disk, not to keep a share of a large
disk free: 10 GB leaves the system room to work on any disk, a small
disk still keeps 2 GB, and a 100 GB disk keeps 5 GB as before. After
Effects has only a maximum cache size and no reserve; a clamp keeps
the floor for small disks, where a fixed 10 GB would take most of the
space.
Writes the limit refuses cost no work: DiskCache::accepts_writes (one
atomic read) is checked before a frame is converted (put_disk_frame)
and before frames and layers are compressed (put_frame, put_frame_f16,
put_layer). The frame workers spent about 8 ms per frame and 14 ms per
layer on encodes that were then refused. persist_missing returns at
once at limit 0 instead of converting the RAM frames on every idle pass.
Settings > Disk shows the limit in effect in MB under Maximum Disk
Cache Size, with a note when free disk space lowers it
(settings.disk.cacheLimit).
* Disk cache: refuse writes below 64 MB of room, show this disk's reserve
Review follow-ups to the reserve fix:
- accepts_writes also refuses while free disk space leaves less than
64 MB of room (a limit the setting asks for is always used): a few
frames would fill it, and the rest would be compressed only to be
refused.
- While writes are refused the idle passes ask for no repaint, so the
UI keeps a frame coming every 5 s: the free space is read again and
the RAM frames are written once there is room.
- Settings > Disk names the space kept free on this disk (the reserve
the last reading applied; the rule from MIN/MAX_DISK_RESERVE when it
is unknown), and the "no room" note follows accepts_writes.
- The settings.disk.cacheLimit automation label is machine-readable
and the same in every language: limitMb, maxMb, reserveMb,
spaceLimited, writes.
- cargo fmt.
* Disk cache: follow 3D model sources in the comp's reach
The reach walk followed comp, footage and solid sources but not
LayerSource::Model. An expression reading a model layer's source
(thisComp.layer("Model").source.name) left the model item out of the
comp's content key, so renaming the model kept the key and the old
frames on disk were served. Every source item is followed now
(LayerSource::item), which also counts the model when no expression
reads it (Advanced 3D draws it).
The same omission is fixed in the RAM preview's content walk
(frames.rs) and in the layer key's walk of referenced layers
(cache/dependencies.rs).
* Disk cache: a render's drop generation travels with its reads; no disk entries for Expression Selectors
Drop generation per render: the layer cache recovered a buffer's drop
generation from the latest miss of its key, so a second render of the
key that started after File > Reload Footage lent its generation to a
render still running from before, and that render's buffer (the file as
it was) was written after the sweep. Now Renderer::recorded starts each
buffer's read log at the store's drop generation before anything is
read (ReadLog::starting_at); ReadFiles carries the earliest generation
through merges, so derived buffers (styles, the blend-space copy, the
flattened layer) and frames that use a buffer from memory carry it too.
LayerCache::insert keeps a buffer made of a file dropped since its
reads began neither on disk nor in memory; a list that doesn't say
counts as made before every drop. DiskCache::dropped_since takes the
list's own generation too. missed_at keeps only the miss time.
Expression Selectors: the layer key evaluates a text Expression
Selector's amount for the first character only, so a per-character
dependency such as textIndex == 1 ? 0 : footage("ref.png").width was
not in the key, and after the footage item's width changed a reopened
session reused the old layer entry. A text layer with an Expression
Selector now marks its reads unkeyed (ReadFiles::is_unkeyed), which
merges like the generation; such buffers stay in memory and are never
written to disk. Frame entries are unaffected: the comp's frame key
hashes what the expressions name. The equality and hash of ReadFiles
ignore both new fields.
* Disk cache: never serve a RAM layer buffer made before a drop of its files
A render that passed the drop check could sit in put_layer's encode
while Reload Footage cleared the layer cache and dropped the file, then
publish its old pixels to memory after the purge; get, and a fresh
render's insert, then served them. The disk-hit promotion went through
the same insert_mem.
Every way the cache hands out a RAM buffer (get, the kept-first path of
insert, an existing entry in insert_mem) now checks the entry's files
against the store's drops from their own generation (ReadFiles::since):
an entry made before a drop of one of its files is forgotten and the
lookup misses, so a fresh render's buffer replaces it. insert_mem also
re-checks a buffer just before publishing it.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A project file, an .aep, a timeline document or a Lottie file could give a
layer a time stretch of 1e-300 %, 0 or 1e300 %. Layer and comp time divide
by it and the audio mix read about usize::MAX source samples for one block,
which overflowed or tried a huge allocation and aborted the app.
- limits::stretch brings a stretch within After Effects' range (±1 % to
±9900 %, Layer.stretch in the scripting guide), sign kept; 0 and NaN are
100 %. Opening a project file corrects each layer and says so; a stretch
written as null (NaN in an auto-save) now opens instead of failing.
- .aep import and Lottie import clamp and report the stretch; timeline
import clamps the clip speed and warns.
- layer.timeStretch refuses a non-number and more than 9900 %; the Time
Stretch dialog's ranges match.
- Layer::layer_time / comp_time and the audio mix use the clamped stretch,
so a value set in memory can't divide by zero or read without bound.
Fixes#3247
A layer with a negative time stretch was skipped by the mix. It now plays
its audio reversed at its stretched speed, through the same linear
resampler as forward stretch: a reversed read starts at the layer time of
the run's end and is played last to first, so comp sample k plays the
source just before layer_time(k) (at -100 % with the start at the out
point, the exact mirror Backwards gives a forward layer). Backwards on a
reversed layer plays it forwards.
Resampled runs start at the exact source position (from the layer time in
ticks) instead of the grid sample a block starts on, so mixing in frame
blocks and in export's one-second blocks gives the same samples, and they
read one more source sample so a block's last sample blends instead of
holding. Audio effects run on comp time since the layer's start, which
joins up across blocks at any stretch and equals the layer time at 100 %.
Reads are made in pieces of at most MAX_READ source frames, so memory
stays bounded for fast layers and nested fast precomps (the work for
nested 1 % precomps is not bounded; follow-up). A stretch faster than 1 %
is silent as a last-resort guard, with a tolerance so exactly 1 % plays.
A zero or non-finite stretch is skipped.
Fixes#3239
Footage interpreted at a much higher frame rate than the comp (for example a
60 fps clip interpreted as 600 fps in a 25 fps comp) showed no frame blending.
Frame Mix only cross-faded the two source frames around the comp frame's
source time, and only when that time fell between frames. With a whole-number
ratio every comp frame lands exactly on a source frame, so nothing blended,
and at other ratios at most two of the many skipped frames were used.
When a comp frame runs through at least four source frames (conformed rate,
time stretch or time remap), Frame Mix and Pixel Motion now average the
source frames that comp frame spans, each weighted by how much of it the span
covers; spans over 16 frames are sampled at 16 evenly spread frames. Played
backwards, the span ends with the comp frame's own source frame.
The speed is measured locally (over 1/16 of a comp frame) and must agree with
the difference to the next comp frame, so a time remap jump (a hold followed
by a cut, a loop wrapping back) stays crisp instead of smearing across the
jump. Reduced-resolution previews mix frames at the reduced size.
Below four source frames per comp frame (slow-downs, equal rates, 60p in a
30, 25 or 24 fps comp, 50p in 25) nothing changes. The threshold and the
16-frame cap are named constants, pending a comparison with After Effects.
The layer cache key, and the key of a layer that selects a frame-blended
footage layer as an effect input, now include how far the source runs during
the comp frame, which the mix depends on.
Fixes#3180
Masks are applied to a layer before its effects, but with the default
Blending Mode None these generators replaced each pixel with the
generated pattern, so the pattern drew over the whole layer and the
mask was lost. With None the generated alpha is now scaled by the
layer's alpha, as 4-Color Gradient already did (#1381). A shared
gen_alpha helper (CPU) and its WGSL twin hold the rule for Grid,
Checkerboard, Circle and 4-Color Gradient.
Only None keeps the layer's alpha: the other modes composite the
pattern onto the layer as After Effects' blending modes do, and their
alpha outside the layer is not verified against After Effects, so they
are unchanged.
Fixes#3121
* 3D: Orientation and X/Y/Z Rotation turn Z first, keyed Orientation slerps, as After Effects
After Effects composes a 3D layer's (and a camera's) Orientation and its X/Y/Z
Rotation as Rx * Ry * Rz each, Orientation before the axis rotations; EffectCraft
used Rz * Ry * Rx. Measured on synthetic AE 2026 cases: Orientation [10,20,30]
(488) was 5.2 % of the frame off and now matches to the anti-aliased edge (0.03 %);
X/Y/Z Rotation 10/20/30 (800), Orientation + X Rotation (801) and one-node
cameras (804, 805) likewise.
Keyed Orientation moves along the shortest arc between the keys (quaternion
slerp, eased in time, angles written in [0, 360)): keys [0,0,0] -> [10,20,30]
read back [6.2643, 9.3531, 15.2190] half-way where EffectCraft gave [5,10,15];
[0,0,0] -> [60,80,40] linear and eased match AE's read-backs within 0.002 deg.
lookAt() returns AE's angles (z axis on the target, x axis level; two AE
read-backs within 1e-3 deg). The camera tracker, VR editor, Look At and orbit
commands use the new decomposition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keyframes: a linear side next to an eased side has 16.67 % influence, as After Effects
In a segment that is Bezier on one side and linear on the other, the linear
side keeps the segment's average speed, but with the 16.67 % influence After
Effects shows for linear keys in the Keyframe Velocity dialog (and reports to
scripts), not 33.3 %. Measured on an After Effects 2026 time-remap render (a
square at x = 50 + 455 * remap): out of a key at 2 s (3.5, speed 0, influence
50 %) into a linear key at 4 s (0.5), AE plays 3.290 at 2.5 s and 2.657 at
3 s; EffectCraft played 3.308 and 2.629 (8 and 13 px off), now the same
square positions to the pixel.
LINEAR_SIDE_INFLUENCE is used by evaluation (scalar, per-dimension, paths and
spatial), side_ease (Graph Editor, velocity dialog, keyIn/OutTemporalEase) and
the Lottie export, so exported easing plays as EffectCraft does. Segments
linear on both sides stay straight lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Timewarp: retime the layer's source without the effects above it, as After Effects
After Effects' Timewarp reads the layer's source (with its masks) at the
retimed times and ignores the effects applied before it: a red solid with a
Gradient Ramp and then Timewarp (Speed 25) renders as the red solid in AE
2026 (EXPORTER e2 read-back). EffectCraft retimed the layer with its
effects (mean difference 127.5/255 over the comp), now identical (0, CPU and
GPU). Echo, Posterize Time and the other time effects keep reading the
effects before them, as measured before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Image sequences: a gap in the numbering holds the previous frame, as After Effects
After Effects 2026 shows the last existing file across a gap in a sequence's
numbering (case 860: files 1, 2, 4, 5 show frame 2 at frame 3; VERIFY
read-back). EffectCraft drew colour bars there. The media pool now holds the
previous file (Footage::sequence_file_held); colour bars remain only where no
file precedes the frame. Missing-frame reports on import are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Review: keyed Orientation uses LINEAR_SIDE_INFLUENCE for linear sides; real NaN assert
evaluate_orientation still gave a linear side (and the unreachable missing-key fallback)
1/3 influence while segment_value, side_ease and the Lottie export use
LINEAR_SIDE_INFLUENCE (1/6), so a keyed Orientation next to an eased key moved on a
different curve than every other property (CodeRabbit on #1876). New test:
orientation_linear_side_next_to_an_eased_side_matches_a_scalar (both directions).
The always-true NaN assert in keyed_orientation_slerps_as_after_effects now checks that
a non-finite key comes back as given; Property::value_at says why the match_id
"orientation" routing is safe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Format keyframe orientation test (cargo fmt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Move the image-sequence gap change out of this branch
The gap change (hold the previous file instead of colour bars) reverses
#297's documented behaviour and goes to its own PR, as requested in review.
pool.rs, sequence.rs, the footage test and docs/footage.md / docs/agents.md
are back to main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Viewer exposure on the full float range of 32 bpc frames (#2797)
32 bpc viewer frames on the desktop are rendered without the HDR step (skip_hdr_step,
#2916) and kept as half floats in the RAM preview. A display pass on egui-wgpu's device
turns each frame into one RGBA8 texture with mips per viewer, in this order: decode,
exposure in linear light, Compand / Tone Map, encode, display colour, Show Channel,
rounding. Before, exposure was an 8-bit lookup applied after tone mapping, clamping and
the display conversion, so -2 EV could not bring values above 1.0 back.
- engine: viewer::DisplayPass describes the pass as data; its pixel() is the CPU
fallback (no device) and the shader's reference. It replaces exposure_lut /
display_transform. FrameEncoding records a frame's curve and the HDR step it still
needs. 32 bpc snapshots are taken without the HDR step; snapshot_rgba8 applies it.
- ui-egui: FrameImage::CpuF16 (half crate, F16C at run time), byte accounting at 8 bytes
a pixel in the cache and the prefetch estimates; display_gpu.rs + display_gpu.wgsl;
main viewer, snapshot and passive viewers draw through the pass; the 8-bit image other
panels read is made on demand with the HDR step; the Info panel shows float values.
- Half-float frames are not written to the disk cache yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewer display pass: f16 disk cache entries, window-device gating, review fixes
- Disk cache: half-float frame entries (format byte in the entry header, byte planes +
LZ4); 8-bit entries keep format 0 and stay valid. 32 bpc viewer frames are written and
read again, and the timeline's disk bar sees them. A damaged half-float entry is
deleted.
- The display pass runs on the window's egui-wgpu device, with or without the GPU
compositor (still building, failed, GL fallback). Its pipelines are made inside error
scopes; a backend that rejects them gets the CPU pass instead of a blank viewer.
- A new LUT of the same size (another ICC profile, Preserve RGB, another display) runs
the pass again.
- f32 to f16: one pass per L1-sized block, the NaN / infinity fix-up on the bits, no
fill of the buffer (4K: 4.7 ms, as the 8-bit conversion).
- Mips of odd-sized levels include the last column / row; the snapshot's half floats
and texture go with the snapshot; no placeholder texture ids; u64 size checks.
- Tests: LUT profiles and negative values against the shader, Bake reruns, the passive
viewer's colour, f16 disk round trips; exact comparisons allow 1 LSB on software
rasterizers. Docs: the display pass and the f16 disk entries in architecture.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewer f16 disk-cache test: temp dir removed on failure too, wait for the queued disk write
Review points: the test's directory was only removed by its last statement
(now tempfile::TempDir), and the frame is released before its disk write is
queued, so flush() could return early (now waits for the entry).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Viewer: oversized half-float frames use the CPU display pass, so exposure/HDR come before the averaging down
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Viewer: fmt and a debug assert for the half-float upload path
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Disk cache: persist_missing also writes half-float frames
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Passive viewer: oversized half-float frames take the CPU display pass instead of leaving a stale texture
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* EXR export: write the output space's chromaticities attribute (#3037)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* EXR chromaticities: tests for unmanaged and PQ ProEXR, every space once
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* EXR export: note why the comp background is added before decoding (review point)
CodeRabbit asked to convert Comp.background to the output space before
compositing. Every output path (Image::to_rgba8_over) adds the background to
the output-encoded frame as it is, so the EXR now matches the other outputs;
converting would make the EXR colour differ from the PNG of the same comp.
Comment only.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Format the merged code with rustfmt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>