On Linux, the discrete-GPU memory cap now comes from the card's video memory: min(3 GiB, ¾ of amdgpu's `mem_info_vram_total`), so a 2 GB RX 550 gets 1.5 GiB. The card is matched by PCI address, falling back to the smallest card with the same ids.
Also folds in CodeRabbit follow-ups from #1856 (PatchMatch flags buffer limit preflight), #1490 (arbitrary-map index wrap), #1836 (round_away for |x| >= 2^23) and #3025 (saturating FrameCache byte accounting).
Fixes#1487
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.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>
* GPU: free effect working textures after each call instead of at frame end
A GPU frame keeps its encoder open while the CPU renderer runs layers with GPU
effects (Gpu::effects -> run_chain). Each call's Enc handed its textures back
to the shared pool, where they waited for every open encoder, so all effect
intermediates of a frame stayed allocated until the frame ended (17.7 GB by
layer 17 of a heavy 4K comp, then OOM). Effect calls now borrow a pool owner
(Enc::lent) that is given back once the call's work is submitted or
discarded; its kept textures are trimmed to the pool budget, so the rest are
freed after each call.
* GPU: one pool budget for idle effect calls, trimmed at the end of a render
Review of #1523: each idle lent owner kept up to POOL_BUDGET of textures of
its own, outside the shared accounting, and there were as many idle owners as
effect calls ever ran at once (16 frame threads: ~12 GB after a 4K render).
Giving a lent owner back now moves its kept textures to the shared free list,
so one POOL_BUDGET bounds what the free list and every finished effect call
keep. They skip Pool::release's wait for open encoders: only the lent Enc
recorded work on them, and that work is submitted or discarded. Each lend is
a fresh owner id, so a lease that outlives its call (an image handed to
another encoder) is released through Pool::release; this does not rely on
the result being read back.
Accelerator::render_end (called when an export ends) drops the kept textures.
* GPU: evict the pool's oldest free textures, guard escaped leases
Review of #1523 (follow-up to 0a884fb6):
- Pool::make_free rejected a texture when the free list was full and never
evicted, so once sizes that no longer occur (per-frame blur bounds, padded
layer sizes) filled the budget, every later free was a drop and every take
an allocation. It now evicts the least recently freed textures until the
new one fits.
- A lease read by another Enc (dispatch, copy, readback, display) or handed
to a cache is marked escaped and always released through the guarded
Pool::release, so a lent call can no longer reuse a texture that a still
open encoder reads.
- Docs: POOL_BUDGET caps the free list only; textures waiting for open
encoders and those of live Encs are outside it.
- export_async calls render_end from a drop guard, so the pool is also
trimmed when an export panics or its future is dropped.
- Tests use a private context with its own pool budget instead of a global
override; new tests for stale sizes and an image read by an open encoder.
* GPU: evict only stale pool sizes, count evicted textures until the GPU frees them
Evicting the least recently freed texture for every newcomer thrashed when a frame
frees more than the budget in a repeating order (blur/glow intermediates): each free
evicted the size the next call takes first, so misses came back to the old level and
evict + reallocate raised the peak.
The free list now evicts only sizes nobody asked for in the last 2 output frames
(Accelerator::frame_end, called by the export per frame); otherwise the incoming
texture is dropped and the kept set keeps hitting. Evicted bytes count against the
budget until the work submitted so far is done, so evictions run at most one budget
ahead of the GPU.
* GPU: lower the shared pool budget to 256 MB
On 4K RTX 3090 runs 256 MB matches the old code's peak reserved on d_blurglow
(9204 MB) and is 256-512 MB above it on c_effects (6592 vs 6080 MB), while
staying faster.
* GPU: end frames on the multi-frame render paths, route recording through Enc::record
Only the export pipeline called frame_end, so off export (preview, remote
frames) the pool's clock stood still, no size went stale and a full free
list dropped every newcomer. passes::comp_frame (exports), remote frames
and the preview workers now end each frame; test without pool_frame_end.
Docs: the frame clock is coarse with many frames in flight; dying only
throttles eviction, approximately; no cache path escapes images. Enc::record
is the single way to get the encoder outside context.rs and marks the
images it names (Enc::uses).
* GPU: cap in-flight frame memory on Vulkan and DX12 (#1550)
wgpu destroys a dropped texture or buffer only once the submissions
that used it have completed and the device is maintained. With several
4K frames in flight on an export or effect-heavy render, frames can
record and drop working textures faster than the GPU retires them,
accumulating freed-but-not-destroyed allocations until a 4-8 GB adapter
runs out of memory (#879).
Add a memory gate (MemCap) checked every 8 effect steps or layers:
- Default cap of 3 GiB on discrete GPUs (low enough for 4-8 GB cards
while leaving headroom above the 1.5 GiB upload cache and 256 MB
shared pool) and 2 GiB on integrated/software adapters, overridable
via EFFECTCRAFT_GPU_MEMCAP_MB (0 disables).
- Compare total_allocated_bytes against non-reclaimable bytes (upload
cache, pool free list, live leases, staging, zeros) with hysteresis
and a post-wait floor so the gate only drains when a wait can free
dropped allocations and does not spin when caches or other open
encoders hold memory over the cap.
- Submit and wait on the current Enc's own SubmissionIndex via a
dedicated poll_wait helper (serialized by poll_lock) so a timeout
simply carries on without retiring the context or its readback pool.
* GPU: warn on invalid EFFECTCRAFT_GPU_MEMCAP_MB, register crop sources, require GPU in the mem-cap child test
- memcap: an EFFECTCRAFT_GPU_MEMCAP_MB value that is not a whole number
of MiB or overflows 64 bits now logs a warning and uses the default
cap (it used to fall back silently, or wrap on overflow). Unset still
means the default and 0 still turns the gate off.
- Enc::crop records through Enc::record, so a foreign source lease is
marked escaped like in copy_into.
- The mem-cap child test starts its GPU through require_gpu, so
EFFECTCRAFT_REQUIRE_GPU=1 fails when the GPU does not start, and
prints a skip line (with the adapter name when the backend has no
allocator report), which the parent passes on.
* GPU: do not repeat the pool frame-end wait until evicted textures drain
A wait that frees none of the evicted bytes (timeout, or a WebGPU poll that
does not block) marks the pool stalled; later frames only poll until dying
drops. Test covers the no-repeat and partial-progress cases; dying decrement
saturates.
* Remote frames: end a pending frame that a new render replaces
A render arriving while a multi-pass frame waits for readbacks dropped that frame without
`frame_end`, so the GPU pool's clock skipped it. Errors return before `frame_begin`.
* Render: end the frame even when a comp frame panics
`passes::comp_frame` called `frame_end` only after a normal return, so a frame that
panicked (or whose future was dropped between passes) never aged the GPU pool.
* GPU memcap: warn once when kept memory alone is over the cap; test the edge frames
A frame whose non-reclaimable memory exceeds the cap never waits when reclaimable
bytes stay below the hysteresis (waiting cannot free the kept set) and now logs
that once. Tests cover a frame over the cap, two frames that fit one at a time, and a
dropped frame giving its memory back.
---------
Co-authored-by: Matej Kozlovský <MatejKozlovsky@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* GPU: free effect working textures after each call instead of at frame end
A GPU frame keeps its encoder open while the CPU renderer runs layers with GPU
effects (Gpu::effects -> run_chain). Each call's Enc handed its textures back
to the shared pool, where they waited for every open encoder, so all effect
intermediates of a frame stayed allocated until the frame ended (17.7 GB by
layer 17 of a heavy 4K comp, then OOM). Effect calls now borrow a pool owner
(Enc::lent) that is given back once the call's work is submitted or
discarded; its kept textures are trimmed to the pool budget, so the rest are
freed after each call.
* GPU: one pool budget for idle effect calls, trimmed at the end of a render
Review of #1523: each idle lent owner kept up to POOL_BUDGET of textures of
its own, outside the shared accounting, and there were as many idle owners as
effect calls ever ran at once (16 frame threads: ~12 GB after a 4K render).
Giving a lent owner back now moves its kept textures to the shared free list,
so one POOL_BUDGET bounds what the free list and every finished effect call
keep. They skip Pool::release's wait for open encoders: only the lent Enc
recorded work on them, and that work is submitted or discarded. Each lend is
a fresh owner id, so a lease that outlives its call (an image handed to
another encoder) is released through Pool::release; this does not rely on
the result being read back.
Accelerator::render_end (called when an export ends) drops the kept textures.
* GPU: evict the pool's oldest free textures, guard escaped leases
Review of #1523 (follow-up to 0a884fb6):
- Pool::make_free rejected a texture when the free list was full and never
evicted, so once sizes that no longer occur (per-frame blur bounds, padded
layer sizes) filled the budget, every later free was a drop and every take
an allocation. It now evicts the least recently freed textures until the
new one fits.
- A lease read by another Enc (dispatch, copy, readback, display) or handed
to a cache is marked escaped and always released through the guarded
Pool::release, so a lent call can no longer reuse a texture that a still
open encoder reads.
- Docs: POOL_BUDGET caps the free list only; textures waiting for open
encoders and those of live Encs are outside it.
- export_async calls render_end from a drop guard, so the pool is also
trimmed when an export panics or its future is dropped.
- Tests use a private context with its own pool budget instead of a global
override; new tests for stale sizes and an image read by an open encoder.
* GPU: evict only stale pool sizes, count evicted textures until the GPU frees them
Evicting the least recently freed texture for every newcomer thrashed when a frame
frees more than the budget in a repeating order (blur/glow intermediates): each free
evicted the size the next call takes first, so misses came back to the old level and
evict + reallocate raised the peak.
The free list now evicts only sizes nobody asked for in the last 2 output frames
(Accelerator::frame_end, called by the export per frame); otherwise the incoming
texture is dropped and the kept set keeps hitting. Evicted bytes count against the
budget until the work submitted so far is done, so evictions run at most one budget
ahead of the GPU.
* GPU: lower the shared pool budget to 256 MB
On 4K RTX 3090 runs 256 MB matches the old code's peak reserved on d_blurglow
(9204 MB) and is 256-512 MB above it on c_effects (6592 vs 6080 MB), while
staying faster.
* GPU: end frames on the multi-frame render paths, route recording through Enc::record
Only the export pipeline called frame_end, so off export (preview, remote
frames) the pool's clock stood still, no size went stale and a full free
list dropped every newcomer. passes::comp_frame (exports), remote frames
and the preview workers now end each frame; test without pool_frame_end.
Docs: the frame clock is coarse with many frames in flight; dying only
throttles eviction, approximately; no cache path escapes images. Enc::record
is the single way to get the encoder outside context.rs and marks the
images it names (Enc::uses).
* GPU: wait for the empty submission while evicted pool textures still count
stale_sizes_do_not_starve_a_steady_size failed with 0 hits and 20 misses on
hardware adapters: pool_frame_end submitted an empty batch and then only did a
non-blocking Poll, and the queue's work-done callback did not run before the
next frame, so Pool::dying never drained and make_free stopped evicting stale
sizes.
While dying is non-zero, pool_frame_end now waits on that empty submission for
up to the readback timeout (the pool lock is already released). A wait timeout
just moves on and does not retire the context or its readbacks; other poll
errors still retire them. Without dying textures it keeps the cheap Poll.
Enc::crop records through Enc::record(&[src, &dst]) so both pooled images are
registered like on every other recording path (review).
* GPU: do not repeat the pool frame-end wait until evicted textures drain
A wait that frees none of the evicted bytes (timeout, or a WebGPU poll that
does not block) marks the pool stalled; later frames only poll until dying
drops. Test covers the no-repeat and partial-progress cases; dying decrement
saturates.
* Remote frames: end a pending frame that a new render replaces
A render arriving while a multi-pass frame waits for readbacks dropped that frame without
`frame_end`, so the GPU pool's clock skipped it. Errors return before `frame_begin`.
* Render: end the frame even when a comp frame panics
`passes::comp_frame` called `frame_end` only after a normal return, so a frame that
panicked (or whose future was dropped between passes) never aged the GPU pool.
* GPU pool: log the reuse rate at render end; test reuse across effect calls and frames
Log cumulative hits/misses (and reuse %) once per render before trimming. Tests
cover reuse within an open frame across effect calls, and across repeated frames.
---------
Co-authored-by: Matej Kozlovský <MatejKozlovsky@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Mask feather: bound the variable feather's distance pass
Every flattened segment scanned its box grown by the largest feather radius,
so a path with many vertices and a radius spanning the mask revisited every
pixel once per segment. The pixel visits are now counted first; past a
budget of 2^31 the flattened polyline is thinned (every other point, arc
lengths kept) until it fits, and only a degenerate case falls back to the
hard mask.
* clippy manual_is_multiple_of
Winding numbers were dense vectors up to each segment's path index, and the
topology keeps one per segment, so memory grew with the square of the path
count. They now store only the nonzero (path, winding) pairs.
tidy_segments re-checked the whole run on every merge attempt, so a long
chain of collinear pieces cost quadratic time. A run now merges at most 64
pieces; longer runs leave a few extra joints on the same line or curve.
Add 308 Traditional Chinese effect names and 21 category labels, written from the English source with Taiwan creative-software terms. Update the completeness tests to require the zh-hant table and recount the localization docs.
Unselected layers parented to, or track-matted by, a layer that moved into the
new composition kept ids that no longer exist in the outer comp. The command's
default name was always "Pre-comp 1"; it now picks the lowest unused number.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U
The panel catalog said 作業領域 for Work Area while the four menu rows say ワークエリア. The installed After Effects 2026 settles it: Edit > Preferences > Previews reads キャッシュ範囲: ワークエリアと現在の時間.
The row draws the Content-Aware Fill panel range menu and the Time Span form, so a reader met both words for
one thing depending on where they looked. One row, no other change.
Co-Authored-By: DeepSeek V4.1 Flash <noreply@deepseek.com>
The "while reading" sections of the waiting-click, in-order and undo/redo
tests now hold the build with the Held wrapper until their asserts are
done, instead of relying on a 4K plate being slow to read.
The undo tests use the shared helpers from ui_crypto_pick.rs, so they now
go through crypto_exr with k = 1 rather than a copy without it.