After Effects' Channel Info kind 2 "(copy)" was imported as R, G, B or A.
EffectCraft's EXtractoR resolves those names through the footage's aux planes
first, and an OpenEXR keeps its own R/G/B/A planes, so "(copy)" read the
original file plane and dropped what an earlier effect did to the channel.
"(copy)" is now a reserved EXtractoR channel name (EXTRACTOR_COPY) that the
CPU and GPU renderers never look up in the aux planes: it is the input's own
channel of the same position. The importer writes it for kind 2.
Render regression: a modified input buffer with same-named R/G/B/A aux planes
of other values; GPU parity case with (copy) on an aux scene with R/G/B.
* 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>
* Color Stabilizer: sample the reference where the points were at the reference time (#3125)
After Effects 2026 samples the reference frame at the positions the
Black / Mid / White Points had at the reference time. With keyframed
points it therefore corrects even a static picture: our importer sample
(colstab-43-keys, Levels, Black Point (64, 90) -> (151, 99) over 1 s)
renders at 0.5 s as the Levels map out = a + b*v of the ramp with
b 1.2935..1.2965 and a -0.2365..-0.2350, which is the Black Point's
current sample (107.5, 94.5) sent to the reference sample at (64, 90).
EffectCraft sampled both frames at the current points, so the card
stayed unchanged (slope 1). The samples now read the points at the
reference time through the host (params_at), falling back to the
current points without one. CPU and GPU share the sampler
(color_stabilizer_maps).
Sample Size's unit is not pinned by the samples (every size 1..40 leaves
the card unchanged in After Effects; that only bounds the sampled area)
and is left as it is.
Part of #3125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Linear Wipe: After Effects' edge position and feather profile (#3125)
Measured on our importer samples (linwipe-*, 2026-11-05, After Effects
2026, 320 x 180):
- The edge sits at lo + completion * (hi - lo) whatever the feather (the
set-all render's 50 % line is at -5.25, predicted -5.266); 0 % keeps
the layer and 100 % clears it even with Feather 32000. EffectCraft
extended the travel by the feather, 13 px off at Feather 16.
- The feathered edge is the hard matte blurred with the layer's edges
repeated: three box passes of radius 6 per axis fit Feather 16
(sigma 6.35..6.6, taken as Feather / 2.5), including the extra
opacity where the edge meets the layer's left side. EffectCraft drew
a linear ramp Feather px wide.
- Without feather the edge pixel is anti-aliased (143/255; coverage
gives 0.6). EffectCraft drew a hard step.
set-all alpha: before max 195, 4646 px (8.1 %) off by more than 2/255;
after max 15, 529 px (0.9 %), all along the layer's left side (at most
2 elsewhere). Feather 0 renders: max 112 -> 10 on the edge column.
CPU: coverage matte, raster::gaussian_blur (3 box passes, edges
repeated); GPU: the same matte from gen_linear_wipe (new modes), its
gaussian_blur, then a multiply, so both match. Sigma is capped at twice
the layer's longer side; NaN values leave the layer as it is.
Projects saved before keep the old ramp: a hidden legacyFeather
parameter that migrate::legacy_default sets on upgrade (as for Ripple
and Block Dissolve).
Part of #3125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Linear Wipe on the GPU: 100 % clears the layer before the angle is read
A NaN angle left the layer unchanged on the GPU while the CPU cleared it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: bit-pack holes, spill frames to disk, stream outputs (#2570 phases 2-4)
* Content-Aware Fill phases 2-4: bit-pack holes, spill frames, stream outputs (clippy clean)
* Address CodeRabbit review: spill safety, streaming correctness, perf, memory guard (#2570)
* Address the remaining CodeRabbit review findings on Content-Aware Fill (#2570)
A non-streaming fill returned empty frames, progress could jump backwards between
the sink and the fill, the memory guard ignored the membrane frames still resident
for the flow pass (and wasm's in-RAM copies), and the GPU fill stages ran outside
an error scope, so an OOM retired the shared context instead of falling back to
the CPU for that frame.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Content-Aware Fill: sample spilled frames without decoding them, check spill errors (#2570)
Tracing sampled spilled frames by decoding whole frames under the shared cache lock, so
parallel traces across neighbouring frames evicted each other and mostly paid a full-frame
read while every other thread waited. Sample only the 2x2 bilinear support with positioned
reads on an open handle instead, and report a failed spill read in the no-hole branch
rather than streaming black frames as a successful fill.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Content-Aware Fill: spill membrane/propagated frames, unquantized input, surface scratch errors (#2570)
Flow and propagation now write their intermediate frames to scratch spills beside
the output instead of holding the whole range as float RGBA, and the input spill
uses 32-bit floats whatever the project depth so the fill sees the same values as
the old in-memory path. Spill reads/writes and scratch creation report through a
shared error slot that run_plan surfaces instead of masking disk failures as a
cancellation. Sampling uses positioned reads; the guard models the packed masks
plus per-thread scratch.
* Content-Aware Fill: report a membrane-spill write failure instead of cancelling (#2570)
* Content-Aware Fill: include the flow fields in the memory guard (#2570)
* Content-Aware Fill: scale the memory guard with workers and check scratch space (#2570)
The guard's scratch term was a fixed 8 frames, which under-counted a wide
machine with Lighting Correction by several times. It now models the sequential
synthesis stage plus a few frames per worker (from available parallelism) and
takes Lighting Correction into account.
Native fills write three f32 copies of the range to scratch beside the output,
so a range that cannot fit on that volume is now refused up front with the
free space in the message, before any frame is rendered.
Tests cover the worker/lighting budget, the scratch formula, a shared error slot
between the input and secondary spills, an uncreatable spill folder, and the
engine surfacing a spill failure as an error instead of a cancellation.
* Content-Aware Fill: resolve a relative output folder for the scratch check (#2570)
A bare relative output name has an empty parent, so the walk-up found no
existing ancestor and skipped the free-space check. Resolve it against the
process directory first, and cover that in the test.
* Do not reformat the GPU fill tests in the spill branch (#2570)
The file was already not rustfmt-clean upstream, so the formatter only added
noise to a file this PR no longer needs to touch: phase 1's call-site changes
are already on the base branch.
* Content-Aware Fill: measure peak RSS and correct the per-worker scratch model (#2570)
The ignored measure_peak_rss_of_a_fill test fills a spilled 640x360 range and
reports VmHWM. On this 18-worker machine 90 frames peak at 340-470 MB across
runs (35 MB with RAYON_NUM_THREADS=1) and 600 frames at 582 MB, so the scratch
slope is ~7 frames a worker, not the 2 the first guard revision assumed.
Scaling the summary down showed a clean linear slope at 8 and 4 workers, and
Lighting Correction stayed below the flow stage's peak.
Budget 8 frames a worker (9 with Lighting Correction), just above the measured
top of the range, and adjust the guard test to the real numbers. At 600 frames
the measured peak is 582 MB against ~11.1 GB for the old five-copies model, a
19x reduction.
* Contributors: credit Madhav Agarwal in the About window
Adds the self-chosen real and display name for @gmrmad1. The generated
contributors.json picks it up once the PR is merged.
* Address CodeRabbit: size the guard from the rayon pool; migrate GPU tests (#2570)
fill_workers used available_parallelism, but the parallel fill stages run on
the rayon pool, which RAYON_NUM_THREADS can size larger. Use
rayon::current_num_threads so the scratch budget follows the threads that
actually run; wasm keeps rayon's single-thread fallback.
The GPU inpaint tests still used the pre-spill FillInput API. The earlier
'do not reformat' revert dropped their API migration along with the
formatting, leaving cargo test -p effectcraft-gpu uncompilable. Restore the
Frames::Memory / HoleMask / sink migration and verify the 15 inpaint tests.
* Fix gate: flatten the wasm prop lookup and keep the budget wrapper test-only (#2570)
* Address CodeRabbit review: spill error check, global-pool preflight, docs (#2570)
* Include RAM-backed spill in the preflight and guard streaming reads (#2570)
* Resolve relative spill paths, prefer stacked mounts, guard the Edge Blend sink (#2570)
* Make the fill spill anonymous and test the streaming claims (#2570)
The spill was a named file in the output folder that only Drop removed, so
an out-of-memory kill or a force-quit left a multi-gigabyte
effectcraft-fill-* file in the user's project folder, and a failed set_len
after keep() leaked it too. The spill is now an anonymous temporary file
(tempfile_in), which the system reclaims when its last handle closes; every
read and write is positioned on that one handle (pwrite/seek_write join the
existing pread/seek_read), and the folder is kept only so the propagated
frames spill beside the input.
Also:
- Edge Blend checks for a failed read before handing a frame to the sink,
so a black fallback frame never streams.
- The propagation workers stop as soon as the spill records an error.
- HoleMask::get/set use get()/get_mut(), so a mask whose public words are
shorter than len cannot panic.
New tests cover the PR's headline claims: a spilled range fills
bit-identically to the same frames in RAM (returned and streamed), the sink
sees frames strictly in order, a truncated spill fails with a recorded
error and streams nothing, a failure mid-stream stops before the next frame,
propagation stops after a failure, the spill has no name in its folder, and
HoleMask round-trips at word boundaries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: clean up a stopped fill and count two scratch copies (#2570)
- A fill that fails or is cancelled after frames started streaming deletes
the PNGs it wrote, and the output folder when the fill made it and it is
left empty (OutputFiles, dropped after the spill so Windows can remove
the folder).
- After a successful fill, run_plan checks that every frame has a file, and
apply_plan/add_fill_layer return an error on an empty range or file list
(a job worker's reply) instead of indexing [0].
- scratch_need charges two f32 copies (32 B/px): flow_pairs drops the
membrane spill before the propagated one is created, so the third copy
never coexists.
- The spill-creation error carries a stable "spill: cannot make the scratch
file" marker; the panel test asserted on the OS wording, which on Windows
is "Cannot create a file when that file already exists".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: a stopped fill leaves files it didn't create (#2570)
A fill into a given outputDir that already holds an earlier sequence
overwrites its frames as it goes (as before). The cleanup of a failed or
stopped fill deleted every path it wrote, including those earlier frames, so
a layer still using that sequence lost them. It now deletes only the files
the fill created (CodeRabbit on #3079).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Grow the buffer to the pinned quad so Corner Pin draws outside the layer rectangle (#1938)
* Corner Pin: grow through pad_sides, skip adjustment layers, clamp to the pixel budget (#1938)
grow_sides returns the four sides for Buf::pad_sides on both paths, replacing grow_to/regrow and the inline GPU copy. Growth is skipped on adjustment layers, cut proportionally to the shared pixel budget (and 16384 per side) instead of giving up, and covered by tests for a far pin, an adjustment layer and a degenerate quad.
* Corner Pin: give the grow_sides test its own doc comment (#1938)
* effects: Corner Pin keeps a finite buffer for non-finite pins and grows without popping as a pin moves far away
* effects: rustfmt the Corner Pin non-finite pin test
---------
Co-authored-by: skylar <hi@skylar.cc>
The effect used a soft knee on the brightest channel and left everything
below the knee unchanged. After Effects compresses the whole range. Fitting
our own 16-bit renders of an HDR ramp and coloured patches (Amount 0, 84,
90, 100) gives:
Y = max(0.30 R + 0.59 G + 0.11 B, 0)
comp = min(c / (1 + Y), 1) per channel
out = ((1 - t) c^0.4 + t comp^0.4)^(1/0.4) t = Amount / 100
A grey value x maps to x / (1 + x) at Amount 100. The worst error against AE
drops from 985 to about 2-4 of 65535 at Amounts 100, 90 and 84. Amount 0
stays identical.
CPU and GPU change together. Tests: a reference table of measured values,
GPU parity at Amount 90, and over-range input that exercises the clip at 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Matte Only on, Cryptomatte wrote the matte into all four channels:
a premultiplied white only as opaque as the matte. After Effects'
Cryptomatte plug-in shows Matte Only as an opaque grey picture, the
matte in RGB with alpha 1 (its Matte Only render of 2026-11-05 is opaque
black where nothing is selected). The CPU effect and the GPU shader now
both give (m, m, m, 1).
Projects saved before keep their render: a hidden Matte Only In Alpha
switch (off for new instances, presets and AEP imports) is turned on by
migrate::legacy_default when an older instance is upgraded on open, and
then keeps the matte in every channel on CPU and GPU.
Tests: the CPU path for a partly covered pixel, an unselected one and
outside the aux area, in both forms; the upgrade of a saved instance;
the GPU parity test over every display with Matte Only off, on and on
with the legacy switch; the host pick tests read whole pixels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* #1590: build effect kernels on first use instead of with the GPU context
GpuContext::new built every compute pipeline up front: the compositor's own
kernels and ~208 effect kernels. In Chrome on Windows that kept the GPU
process busy past its watchdog, so the device was lost ~60 s after every load
(#1590); on DirectX 12 with FXC the window took over a minute to appear (#613).
The compositor's kernels are still built up front and required. Effect kernels
are built the first time a dispatch asks for one, inside the same error scopes,
so one a compiler rejects still renders its effects on the CPU. unavailable_kernels
lists the failures so far; build_all_kernels builds the rest.
RX 6900 XT, Windows: Vulkan context 0.27 s, all effect kernels 0.9 s, GPU tests
77 s -> 26 s; on #1571's DX12/FXC, context ~66 s -> 1.96 s, tests 1306 s -> 252 s,
same results.
Co-Authored-By: Claude <noreply@anthropic.com>
* #1590: compile each kernel family as a shader module of its own
Building effect kernels on first use was not enough in Chrome: the page and
its frame workers each create a device, every one compiled its required
kernels against the single ~460K-character module, and Dawn compiles the
module again for each pipeline, so the GPU process still passed its watchdog
and the device was lost ~64 s after load (RX 6900 XT, Chrome 155).
Each kernel family (FAMILIES) is now common.wgsl + kernels.wgsl + its own file
and the files it borrows functions from (at most ~120K characters), built with
the family's first kernel; the compositor's kernels, sky and classic3d use
small modules of their own.
Chrome 155 on Windows / RX 6900 XT, release web build, default flags: main
loses the device at ~64 s (60.7 s with ?nogpuworkers); with this, two 100 s
runs keep it, and both frame workers get the GPU. Native Vulkan tests
unchanged (212 passed, the 3 failures that main has, #1605).
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Posterize: round_away decided half-way by subtracting, and drivers may fuse the
product that feeds it into one fma (AMD does), skipping the product's rounding.
A straight value of 5/6 at level 4 gives 5/6 * 3 = 2.5 exactly in f32 on the
CPU but 2.49999994 fused, so the GPU rounded a level down. round_away now
compares x with t +/- 0.5 (exact for integer t), which no driver can fuse, and
the level divide uses an fma-corrected division (div_rn) like the CPU's.
CC Grid Wipe: the cell lookup floor(q / cs) used WGSL's division, which may be
off by 2.5 ULP; at q = 7, cs = 3.5 AMD and Intel return just under 2 and pick
the neighbouring cell. It uses div_rn too.
New test: posterize matches the CPU exactly on every 8 bpc premultiplied colour
and alpha at three levels (59715 of 65280 pairs differed before at level 4).
RX 6900 XT, Vulkan: effectcraft-gpu 222 passed; the one failure left is CC Hex
Tile, fixed by #1776. DX12 + FXC (on #1571): both tests pass.
Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* gpu: run Content-Aware Fill's PatchMatch on the device
Content-Aware Fill's synthesis is the stage that makes the tool slow: at
1920×1080 with a 300×200 hole, PatchMatch costs ≈ 1.1 s per frame on the CPU,
and it is what Object, Surface and every refining frame spend their time in.
Add a `FillBackend` trait to `effectcraft-raster` whose stages (the membrane
solve, the flow fields, PatchMatch) default to the CPU reference, and
`Accelerator::inpaint()` on the compositor, which is how the engine asks for a
device backend. `effectcraft_gpu::inpaint` implements PatchMatch: the
multi-scale pyramid, the EM rounds, the propagation (a two-pass checkerboard
instead of the CPU's scan order), the random search, the SSD, the
histogram-based σ² and the weighted vote, as compute kernels in
`shaders/inpaint.wgsl`.
The fill's frames are read back to the CPU before it runs (analyses composite
on the CPU by design), so the backend is about the fill's own work; the
orchestration, the flow-guided propagation and the per-frame flow control stay
on the CPU. Only the hole's bounds cross back to the CPU, which is what makes
the offload pay.
Measured on an Intel Arc (MTL) iGPU, 1920×1080, 300×200 hole:
CPU 1087 ms GPU 397 ms (2.7×, 41.0 dB PSNR against the CPU's synthesis)
The membrane (harmonic) solve deliberately stays on the CPU: the same
measurement has it at ≈ 90 ms on the device against ≈ 55 ms on the CPU,
because a fill's full-frame data movement (upload, the known pyramid, a copy)
does not shrink with the hole, while the relaxation does. The device takes only
the stage where its parallel search pays; `membrane_tex` still runs on the
device inside PatchMatch, where it is the coarsest level's start and never
crosses back.
Closes#1839.
* gpu: fix the fill's vote histogram and run its flow on the device
Review follow-ups on the Content-Aware Fill GPU port, plus the fill's flow.
Fixes:
* imp_hist kept about one sample in 256: each of the 256 threads scanned a
strided subset of the pixels and retained only those whose bin selected it,
so the vote's sigma² came from a sample correlated with the pixel index (and
a small hole usually fell back to 1.0, making the weights near-uniform). One
workgroup now accumulates the whole population through shared atomic bins,
with a test that pins the sample count.
* imp_pm_prop and imp_pm_rand were dispatched over grow(near, 1) but with
near's origin, so the ring's left column and top row were never written
(NaN under test, stale pooled data in production) while a strip past it was.
`over` now derives the shader's origin from the box it sizes the dispatch
from, so a dispatch's region and its origin can no longer disagree.
* Gpu::inpaint() requires its kernels to have built. Otherwise every stage
paid an upload and a failed dispatch to fall back to the CPU, while the fill
still reported the GPU path.
* imp_rnd's XOR chain parenthesises its multiplicative terms: WGSL's grammar
makes each XOR operand a unary expression, so `a * b ^ c * d` is rejected
by spec-compliant parsers (naga accepts it).
* FillBackend documents that its stages may be called concurrently.
* The coarsest membrane's sweep count (64, against the CPU's 200) is now
justified: matching 200 measured ~6% slower over the fill with no change in
the result.
And the flow: Gpu::flow runs block_flow's coarse-to-fine search, sub-pixel fit
and vector median on the device. The luma is the CPU's (flow::luma_pair), so
the field matches block_flow's to a float rounding and the propagation sees
the same motion; the hole completion stays on the CPU with the membrane. A
four-frame 1920x1080 fill goes from 1.27x to 1.42x against the CPU, and the
device path is covered for holes from 16x16 to 300x200.
* gpu: keep the fill vote's histogram range on HDR input
imp_hist binned every distance at a fixed 256 bins per unit and clamped to
1023, so the vote's sigma2 topped out at 3.998. A 32-bpc frame's channels can
exceed 1, and masked_frame forwards them unclamped: a patch distance of 1600
then reported sigma2 ~ 4, exp(-1600/(2*sigma2)) rounded to zero in f32, and
imp_pm_vote kept the prior image instead of synthesising.
The histogram now reports the largest finite distance it saw (atomicMax on the
float bits, carried in bin 0's unused .g channel). SDR input keeps the fixed
0..4 range with identical bins; HDR input is re-binned over the observed range
and percentile() maps the bin centre back through it. Nothing clamps the input.
Also group over/sweeps/pm_level's arguments into small structs: the workspace
denies too_many_arguments and the lint policy (#2496) does not allow new
#[allow]s. No behaviour change on those paths.
* engine: don't report Edge Blend fills as device fills
Edge Blend calls only the membrane, which the GPU backend leaves to the CPU
reference, so run_plan's "Filling (GPU)" status was wrong for it whenever a
backend was present. Object and Surface still run the flow and PatchMatch on
the device and keep the label.
CodeRabbit's #1856 finding.
* gpu: run the fill's device stages inside error scopes
An allocation or validation failure inside patch_image/flow_image left an
invalid texture whose later use raised an uncaptured error, retiring the
shared compositor instead of falling back to the CPU for that frame. Wrap both
stages in Gpu::within_memory, whose scopes are native thread-local, so the
fill's rayon workers do not share them.
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
- A watch-folder render gives the host session's set back after its own
project replaced it on the shared footage source.
- Guard gpu fx_depth::IDS against reads_aux drifting apart.
- Count picture and channel decodes per thread instead of reading the
shared channel cache, so parallel tests can't make the checks flaky.
- The engine test fails when no set was ever sent.
- Docs: Media Replacement items are learned at their first frame.
Refs #474
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
craft-update's App gains icon_png here: the updater, the self-update helper and
craft-update's startup prompt all get it through crate::UPDATE_APP. The prompt draws it
once craft-libs includes storytold/craft-libs#434; AppImage menu entries get it now.
Clippy fixes for Rust 1.99 in untouched code (same logic):
- crates/gpu/src/tests/wgsl_grammar.rs
Refraction samples are clamped to the layer rectangle instead of the buffer, so a sample past the
layer's edge in a buffer with transparent padding (a half-resolution preview's, a text or shape
layer's) repeats the layer's edge pixels rather than reaching the pale colour that stands in for
transparent ones. Transparent pixels inside the layer still show it. Same bounds on the GPU path.
Re-pins the four half-resolution CC Bubbles golden hashes (aarch64 macOS); the full-resolution
ones are unchanged. Adds a CPU test and a GPU-vs-CPU parity test on padded buffers.
Carry property, Font and Media Replacement overrides into the collapsed nested EvalCtx so nested layers render beyond the precomp frame instead of falling back to a clipped, flattened image. Key the layer cache on instance overrides to keep distinct collapsed instances isolated.
Every undoable edit keeps the previous project for undo, so the comp it touches
is shared and Arc::make_mut deep-copies it: every layer and every keyframe. An
edit therefore costs time in proportion to the comp's size, and a script that
sets many keyframes in a big comp goes quadratic (a 1,600-layer build with
~100k sampled keys took ~45 minutes).
Comp::layers is now a Layers collection backed by Vec<Arc<Layer>>: copying the
stack copies pointers, and only the layer an edit changes is copied. The API
mirrors Vec<Layer>; serialized form is unchanged (a list of layers).
Comp::layer_mut and the iter_mut().filter/find call sites use find_mut /
iter_mut_where so they copy only the layers they actually change.
- prop.addKey in a 400-layer comp: 1.36 ms -> 0.16 ms per key (flat in comp size)
- the 1,600-layer script build: ~45 min -> 47 s, frames pixel-identical
- regression tests: an edit leaves the other layers shared with undo; layers
round-trip as a plain list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkpGcKfWyCudfSsQNiWAaG
occlusion computes its three channels as one vector instead of writing a
vector component through a loop index, which FXC cannot address and tried
to unroll every enclosing loop for (X3550/X3511). classic3d now also
builds like the effect kernels: if a compiler rejects it, 3D runs render
on the CPU instead of the whole compositor failing to start. The ignored
FXC diagnostic test now checks classic3d instead of the stale pointwise
failure.
Turbulent Displace read its noise through value_noise, whose quintic fade also
eased the evolution axis. Near every whole revolution the pattern hardly moved:
Evolution 0 and 37 degrees (or 360 and 400) differed by well under a hundredth of
the frame, so different Evolution values looked identical, and the motion sped up
and slowed down within each revolution (the #1816 symptom).
Add value_noise_linear_z (x and y keep the fade, z is linear) on the CPU and in the
WGSL kernels and use it for Turbulent Displace on both paths. value_noise is
unchanged, so the other effects and the pinned simulation goldens are unaffected.
Tests: CPU test that non-equivalent Evolution values (fractional, past 360, past
720) all differ and that Cycle Evolution still matches 0/360; unit test of the
linear z axis; GPU-vs-CPU cases at small and large Evolution.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYXhNN7W7nB6sKjwwU7CDM