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>
A tile boundary that lands exactly on an output pixel centre puts the tile
fraction at exactly 0 or 1, and the sample position at the source's geometric
edge — where sample_bilinear mixes in half a texel of the transparent outside.
The fold column comes out half-faded: a visible seam line along the mirrored
boundary, which sweeps the tile as Tile Center animates across pixel centres
(#2820). Both the CPU path and the GPU kernel switch to the clamped sampler,
which repeats the edge texel there; interior samples are untouched, and the
default single-tile identity mapping already sampled texel centres.
The regression test puts the folds exactly on pixel centres (tileWidth 50%,
centre on the layer, mirror on) and asserts the fold columns stay opaque and
equal the edge texels they mirror — it fails on the unfixed code at alpha 0.5.
Fixes#2820
Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
An adjustment layer replaced the comp below with its effects' result whatever its blend
mode. As in After Effects, a mode other than Normal now blends the adjusted comp onto the
comp below in that mode, inside the footprint, track matte and opacity (an adjustment
layer with no effects in Screen screens the comp with itself). CPU and GPU paths.
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
`Readbacks::new` spawned a thread that slept 100 ms and checked for expired readbacks forever,
whether or not anything was pending: ten wakeups a second for the life of every desktop session
with a GPU compositor (12–13 idle wakeups/s measured, against 0.4–1.8 for the sibling apps).
The worker now waits on a condition: indefinitely while nothing is pending, otherwise until the
earliest deadline or until what is pending changes (start, complete, cancel, retire poke it).
It owns the state behind an `Arc<Inner>` rather than the `Readbacks`, so dropping the context
closes it instead of leaking the thread. Timeout, exactly-once callbacks, panic isolation and the
browser's timer path are unchanged.
Measured on an M2 Max (release build, window on screen, 60 s samples, A/B interleaved): idle
wakeups 12.15/s → 2.63/s not frontmost, 12.36/s → 2.72/s frontmost.
Tests: an idle worker does not wake; a due deadline wakes it when due; dropping the context stops
it; the existing panic/strand test moves deadlines through a hook that notifies the worker.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Effects that grow their buffer (CC Radial Fast Blur, CC Lens, Magnify,
the distorts and blurs) could pad a 20 x 20 layer by 4096 px a side, a
~8200 x 8200 f32 buffer plus its clone. Buf::pad (CPU and GPU) now cuts
the pad so the grown buffer stays within 16 Mpx or four times its own
area, whichever is larger: small layers are bounded at 256 MiB, big
layers still grow as before in practice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Conflicts were main's new code next to this branch's additions; both kept:
- crates/effects/src/lib.rs: main's Buf::pad_sides stays as main wrote it
(Transform's transform_grow bounds that padding itself); Buf::pad keeps
this branch's effective-pad clamp.
- crates/raster/src/lib.rs: main's quantize_px/round_half_up and this
branch's MAX_PADDED_SIDE (still equal to limits::MAX_SIZE on main).
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.
Resolve the conflict in crates/gpu/src/fx_stylize.rs: main moved ops::warp to
the ops::WarpOptions parameter struct; the branch's per-sample transform loop
(motion-blur samples, now passing the sample by reference) uses the new
signature with the branch's reference argument kept.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Accelerator::comp_frame_yuv420 (default None) renders a top-level comp frame and
converts it over the background to the H.264 export's 8-bit BT.709 limited-range
4:2:0, every byte within one level of the CPU conversion (the GPU kernel follows
it operation for operation, including NaN and half-away-from-zero ties, but a
driver may contract its arithmetic). A 4K frame then reads back 12 MB of planes
instead of 133 MB of RGBA f32.
The H.264 export asks the accelerator for planes when a frame needs nothing after
the render (progressive, project depth, no crop/resize, rendered at the output
size) and otherwise renders and converts on the CPU as before. Deferred
readbacks (a browser worker) keep the pass-based path. Sizes the kernel can't
take (width not a multiple of 8, odd height) are declined before any GPU work.
Like Cx::frames, h264_frames caps GPU frames in flight only while the
accelerator is up (map_frames_while with the same liveness check): once the GPU
stops for good, the remaining frames render on the CPU side by side instead of
one at a time.
rgba_to_yuv420 moves to effectcraft_raster::yuv so the export and the GPU tests
share one reference. The GPU tests skip without an adapter (tests::gpu()), as
the crate's others do.