filmcraft-gpu asks wgpu for no backend (`default-features = false`); a build
that includes the app gets them through eframe. A CLI built on its own
(`cargo run --release -p filmcraft-cli`, as the README and
docs/contributing.md do) has none, and `wgpu::Instance::default()` panics
there: an export with GPU rendering on ended as "internal error (see the
crash log); the job was stopped".
ExportRenderer::new returns None in such a build, like a host without an
adapter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: the Lumetri vignette scaled its horizontal coordinate by
`1 + vround * 0.0` and applied aspect correction only for negative
Roundness, so 0 … +100 rendered identically (CPU path, the GPU op and the
GPU shader parameters alike). Negative values moved towards a circle,
the direction positive values should go.
Fix: one helper, `gpufx::lumetri_vignette_aspect`, scales the horizontal
coordinate by aspect^roundness: 0 keeps the ellipse that follows the
frame, +100 is a circle, negative values widen the oval. Non-finite or
out-of-range values fall back safely. The CPU path, the CPU FxOp and the
GPU parameter packing all use it, so they stay in parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- GpuRendering defaults to Off (opt-in per export) until measured on Windows and macOS;
docs and ROADMAP say so.
- The shared export device installs an uncaptured-error handler and a device-lost callback
that log once and retire GPU export for the session (frames go to the CPU) instead of
wgpu's default panic.
- RendererPool drops a renderer that panicked and retires the pool, so the rest of the
export renders on the CPU (regression test in gpu_hook_scenarios); the duplicate
hostile-size test that raced on the global registry is folded into it.
- Rebased onto main's render_plan/transitions: render_export_prepared reads the linear
accumulator after render_plan; transition (Composite) plans fall back to the CPU.
- es/ja rows for the new Export strings; web_time::Instant in the export pipeline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the common slice of Lumetri Color (Basic Correction, Creative without
sharpen, and Vignette) to the GPU effect stage (FxOp::Lumetri, OP_LUMETRI = 31).
Lumetri instances with unsupported sections (input LUT, Look / look LUT,
sharpen, curves, color wheels, HSL secondary, or HDR grading) fall back to CPU.
Parity tests pass within max relative difference 4.28e-7 and 0.000% flips.
🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
The previous commit raised the GPU's un-premultiply threshold for Invert
(alpha channel) to 1e-4, which the CPU reference does not use (it is
1e-6): alphas between the two inverted differently. The 6 % flipped
pixels came from the bilinear leak the same commit fixed in load4; with
it, the CPU's threshold passes gpu_effects_match_cpu_exactly (and the
gpu and golden suites, serially).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix subpixel bilinear interpolation leakage during 1:1 source texture
sampling in composite.wgsl load4, and guard against divide-by-zero /
subpixel noise amplification in OP_INVERT_ALPHA in fx.wgsl.
All fx_tests::gpu_effects_match_cpu_exactly test cases now pass on AMD
(Radeon RX 7900, Mesa 26.2.2, Vulkan) within EXACT (1e-4) and 0.000% flips.
🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
Move Video Limiter from CPU (crates/render/src/vfx/color.rs) to the GPU
compositor (filmcraft_render::gpufx::FxOp, crates/gpu/src/fx.rs, crates/gpu/src/fx.wgsl).
Parity matches CPU reference within 9.28e-6 across all axis modes, clip levels,
compressions, and gamut warning color.
🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
Add Vignette effect to the GPU effect stage (render::gpufx, gpu::fx,
fx.wgsl). Evaluated parameters are shared between the CPU reference and
GPU compute passes. Replaces old CPU vignette body in vfx::color with
gpufx::FxOp::eval and gpufx::FxOp::apply.
Gates: fmt, clippy, test -p filmcraft-render, test -p filmcraft-gpu,
layers, wasm all verified.
🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
GPU export rendering was ~20 % slower in wall-clock time than CPU
rendering: one renderer behind a mutex made the export workers queue.
- Export workers share a pool of up to four renderers on one wgpu device
(one per worker was no faster: the read-back then contends for memory
bandwidth).
- render_export_prepared composites into the linear accumulator and
copies it to a reused staging buffer in one submission (no sRGB resolve
pass); read_accumulator shares the read-back, which now returns None
instead of stale pooled values when a row is missing.
- f16_to_f32_fast: same result as f16_to_f32 for all 65536 inputs (test).
- Timing counters (export::export_lock_wait, gpu::export_renderer::timing)
printed by the export bench.
RX 7900, same build, Off / Auto / Off: H.264 + AAC 4.67 / 4.71 / 4.76 s,
ProRes 422 4.20 / 4.21 / 4.21 s; CPU per frame 287-292 -> 277 (H.264),
316-317 -> 300 (ProRes). Was 4.86 -> 5.78 s. Parity, not faster: on this
single-clip scene decoding and encoding dominate (rendering is ~15 of
~290 CPU ms per frame), so GPU rendering stays Off by default.
Gates: fmt, clippy (gpu, export, ui-egui), layers, assets; gpu
export_pipeline, golden and export tests pass. Pre-existing on the
previous commit: fx_tests::gpu_effects_match_cpu_exactly fails on this
AMD GPU, and the gpu unit tests segfault in the driver when run in
parallel (pass serially apart from that test).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add off-screen GPU export renderer (filmcraft-gpu::ExportRenderer) reading back
the linear float accumulator as premultiplied RGBA f32, plug it into the
export pipeline via FrameRenderer hook and GpuRendering setting, register
in desktop app and CLI, and record export.gpu_frames statistics.
🤖 Completed by agy (Antigravity Agent, Gemini 3.8 Flash) based on draft by Merlin
Draw effect sources from working pixel positions and integer decimation.
Cover identity, alpha inversion and box minification without changing tolerances.
Co-authored-by: Codex <codex@openai.com>
The GPU compositor uploaded only the Y, Cb and Cr planes of a Yuv8/Yuv16 frame and
the shader returned alpha 1.0 for them, so a ProRes 4444 overlay (banner, lower third,
outro) was drawn opaque in the Program monitor and covered the footage under it.
Exports were correct because they use the CPU compositor.
Frames with an alpha plane now upload it as a fourth texture (R8Unorm, or R16Float
converted like the other planes), bound at binding 5 of the layer and blend layouts.
A uniform slot (p3.y) carries the alpha scale (0 = no alpha plane); the shader reads
the alpha at full resolution and premultiplies the linear colour by it.
Adds gpu_draws_yuv_alpha_plane_like_the_cpu: an alpha ramp over an opaque layer at
8, 10 and 12 bits, GPU against the CPU compositor. It fails without the change
(p99 diff 190/255) and passes with it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A media clip whose enabled standard effects all have a GPU implementation
plans as a GPU layer carrying them (render::plan::LayerFx): parameters are
evaluated on the CPU at the frame's time (render::gpufx::FxOp::eval, shared
with the CPU path so both clamp hostile values identically), the source is
drawn into an Rgba32Float working image at the size the CPU decodes it, the
effects run as WGSL compute passes (gpu::fx, fx.wgsl) with the CPU
reference's math, and the result is placed with Motion, opacity and blend
mode as before. Layers without effects keep the single draw.
GPU effects: Brightness & Contrast, ProcAmp, Tint, Black & White, Color
Balance, Leave Color, Change to Color, Color Pass, Color Replace, Channel
Mix, ASC CDL, Gamma Correction, Levels, Extract, Invert, Posterize, Alpha
Adjust, Gaussian / Camera / Directional Blur (+ legacy aliases), Sharpen,
Unsharp Mask, Crop, Edge Feather, Transform, H/V Flip, Mirror, Offset.
Masked effects, opacity masks, non-finite parameters and minifying
Transform/Crop zooms stay on the CPU. Devices without compute shaders run
the effects on the CPU inside the compositor.
Also fixes a debug-build overflow in Image::transformed for non-finite maps.
Parity tests (exact f32 checks and composited plans), a golden test that
plans effect chains as GPU layers, and the stack3-fx playback benchmark.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All 27 blend modes now composite on the GPU instead of sending the
whole frame to the CPU.
- render::plan: PlanLayer carries its Blend; plan_frame no longer falls
back to a CPU frame for non-Normal modes. Media, graphic clips,
multicam angles and CPU-rendered layer images (standard effects) keep
their clip's mode; inside a transition the mode is Normal, as in the
CPU reference. execute_cpu composites with each layer's mode.
- gpu: Normal keeps the single fixed-function pass. Dissolve also uses
fixed-function "over": the CPU's 64-bit pixel hash is reproduced in
WGSL on u32 pairs (pattern bit-exact). The other 25 modes copy the
accumulator region under the layer's quad into a backdrop texture and
composite in fs_blend with the CPU math (sRGB-encoded straight colour,
W3C formulas, back to linear, same premultiplied mix and clamping).
- Tests: per-mode GPU-vs-CPU parity (gpu crate: overlapping transformed
layers, partial opacity/alpha, empty/partial backdrop; golden: every
mode through plan_frame incl. an effect layer, blended clips in a
cross dissolve), exact-value edge cases (black/white, Divide by 0,
Dodge/Burn at 0 and 1, zero alpha) within 4.9e-4, Dissolve pattern
exact, off-output layers, quad bounds.
- Bench: new stack3-blend playback scenario (V2 Screen, V3 Overlay);
CPU ms/frame 151-214 -> 86-89, Full 19/173 and 138/54 -> 191/1, 192/0.
- Docs: architecture §5, gpu crate docs, testing, performance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust/clippy 1.99 added `chunks_exact_to_as_chunks` and tightened
`needless_late_init` and `drain_collect`, failing `clippy -D warnings`
on main.
- `chunks_exact(N)` / `chunks_exact_mut(N)` with a constant N become
`as_chunks::<N>()` / `as_chunks_mut::<N>()` (fixed-size arrays, so
several byte reads drop their per-element indexing). relink's
fingerprint uses the `(chunks, remainder)` split; its hash output is
unchanged.
- Late-initialised `let`s in ac3 bit allocation and opus CELT
bands/rate become `let x = if/match …`.
- `dpb.drain(..).collect()` becomes `std::mem::take(&mut dpb)`.
No behaviour change; `cargo xtask ci` passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- gpu: `composite` returns its output view by value (a cheap handle clone) taken from the
render-target setup, so the accumulation/output views no longer `expect` their slots.
- render: lightning bolt subdivision, tracker pyramid and the Lumetri preset instance without
`expect`.
- cli: dotted parameter insertion without `expect`; the render/mcp backend arms report a usage
error instead of `unreachable!`.
- Each crate denies unwrap/expect/panic/unimplemented/todo/unreachable outside tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Lumetri gains input_lut (Basic Correction) and look_lut (Creative) references: lib:<id> for
LUTs imported into the project's library (embedded .cube/.3dl text, parsed once and cached by
content), builtin:<id> for LUTs generated from code: camera log → Rec. 709 conversions from the
published curves, and the procedural Creative looks (no third-party LUTs)
- section switches basic_on/creative_on/curves_on/wheels_on/vignette_on (+ hsl_on) bypass their
sections; older projects without them render as before
- commands lut.import, lut.list, lut.remove, lut.export (cube/3dl), lumetri.setInputLut,
lumetri.setLook (lut ref or path; imports on the fly), lumetri.setSection; effects.setParam
fills in parameters missing from instances saved by older builds
- Lumetri panel: Input LUT and Look LUT menus (None, built-ins, library, Browse… file dialog),
working section switches (automation ids lumetri.input_lut, lumetri.look_lut,
lumetri.switch.<section>); combo boxes no longer widen the panel
- filmcraft-gpu: GpuLut WGSL compute pass with the same tetrahedral interpolation; parity test
vs Lut3d::apply for 2³–65³ and a non-unit domain (max |Δ| 1.8e-7)
- tests: engine (import/apply/bypass/undo/save, export→import round trips for both formats),
render (built-ins reproduce the looks, library resolution), headless UI (color_ui.rs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GpuCompositor::upload converted f32 RGBA layers (effects rendered on the CPU, captions,
whole-frame fallbacks) and 16-bit YUV frames (ProRes render previews) to half floats on the UI
thread every time a frame was shown: 8.3 M values per 1080p RGBA layer, 4 M per ProRes frame.
filmcraft_gpu::prepare(plan) now does those conversions on the frame worker that built the plan
(parallel chunks; 16-bit codes through a cached per-bit-depth table) and the plan cache stores
them with the plan (frames::GpuPlan), bounded by count and bytes (1 GiB). composite_prepared()
only copies bytes into textures. The texels are bit-identical (new test compares the read-back
output of both paths for f32, 10-bit 4:2:2 and fallback-image plans).
bench, same-session A/B (GPU path, after-preview Full, 2 rounds, load avg 50–95):
UI-thread present time p50/p95: plays from previews 11–31 / 34–77 ms → 3.6–5.8 / 4–14 ms;
live effects play 17–28 / 31–56 ms → 4.1 / 12 ms (first run)
plays from previews: 184/8, 183/9, 189/3, 169/23 → 192/0, 192/0, 191/1, 192/0 shown/dropped
live effects play (CPU-bound, ~6 cores at this load): too noisy to compare (0–117 shown either way)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/golden (test-only, L3): nine procedural 320x180 scenes (transform
and opacity, blend modes, Gaussian Blur, Lumetri basic, Crop, Cross
Dissolve / Dip to Black / Wipe mid-transition, timecode/clip-name text)
rendered on the CPU and compared with committed goldens (PSNR >= 45 dB,
max <= 12, p99 <= 2); FILMCRAFT_BLESS=1 regenerates them with
attribution sidecars. testkit::golden adds PNG I/O, diff metrics and
failure images. The same scenes are composited on the GPU and compared
with the CPU.
GPU parity exposed a real bug: GpuCompositor cached uploads by
pixel-buffer address without owning the buffer, so a new frame
allocated at a freed frame's address was drawn with a stale texture
(whole wrong frames, PSNR ~12 dB). The cache now keeps the buffer
alive; regression test upload_cache_keeps_buffers_alive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>