export: GPU-resident decode for software + HDR export (Stage 3c-export pt 1)

The software and HDR export paths already composite on the shared device (the
eframe device, via render_next_video_frame), so they can consume the same
hardware-decoded NV12 GPU frames the preview uses — no CPU download/upload:

- ExportGpuResources gains the Nv12BlitPipeline; the export compositor's Video arm
  branches on inst.gpu (NV12 blit, mirroring stage.rs) vs the RGBA upload path.
- render_frame_to_gpu_rgba takes a hardware_ok flag: true for the software video +
  image export (shared device), false for the zero-copy path (own device, must
  download). render_frame_to_yuv10_hdr sets it true.

Drops the 4K software/HDR-export decode wall (HW decode + GPU composite, no
per-frame RGBA upload). The common Linux H.264 zero-copy path is unchanged
(separate device) — that's pt 2. Falls back to software when no shared device /
importer (flag is harmless then: get_frame returns CPU anyway). Compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 05:07:02 -04:00
parent 33dec6f327
commit bb3369b709
2 changed files with 32 additions and 11 deletions

View File

@ -625,6 +625,7 @@ impl ExportOrchestrator {
floating_selection,
state.settings.allow_transparency,
raster_store,
true, // image export composites on the shared device
)?;
queue.submit(Some(encoder.finish()));
@ -1393,6 +1394,7 @@ impl ExportOrchestrator {
None, // No floating selection during video export
false, // Video export is never transparent
raster_store,
true, // software export composites on the shared device → may use HW frames
)?;
let render_end = Instant::now();
@ -1498,6 +1500,7 @@ impl ExportOrchestrator {
None,
false,
Some(&raster_store),
false, // zero-copy runs on its own device → download HW frames to CPU
) {
Ok(cmd) => cmd,
Err(e) => {

View File

@ -83,6 +83,8 @@ pub struct ExportGpuResources {
pub linear_to_srgb_sampler: wgpu::Sampler,
/// Canvas blit pipeline for raster/video/float layers (bypasses Vello).
pub canvas_blit: crate::gpu_brush::CanvasBlitPipeline,
/// NV12→linear blit for hardware-decoded video frames (export on the shared device).
pub nv12_blit: crate::nv12_blit::Nv12BlitPipeline,
/// Per-keyframe GPU texture cache for raster layers during export.
pub raster_cache: std::collections::HashMap<uuid::Uuid, crate::gpu_brush::CanvasPair>,
/// Cached HDR accumulator state after the (static) background is composited in. The document
@ -287,6 +289,7 @@ impl ExportGpuResources {
});
let canvas_blit = crate::gpu_brush::CanvasBlitPipeline::new(device);
let nv12_blit = crate::nv12_blit::Nv12BlitPipeline::new(device);
Self {
buffer_pool,
@ -306,6 +309,7 @@ impl ExportGpuResources {
linear_to_srgb_bind_group_layout,
linear_to_srgb_sampler,
canvas_blit,
nv12_blit,
raster_cache: std::collections::HashMap::new(),
cached_bg_hdr: None,
hdr_pipeline: None,
@ -959,16 +963,26 @@ fn composite_document_to_hdr(
}
RenderedLayerType::Video { instances } => {
for inst in instances {
if inst.rgba_data.is_empty() { continue; }
if inst.gpu.is_none() && inst.rgba_data.is_empty() { continue; }
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) {
let bt = crate::gpu_brush::BlitTransform::new(inst.transform, inst.width, inst.height, width, height);
if let Some(gpu) = &inst.gpu {
// Hardware-decoded NV12 plane textures → linear, no CPU upload.
let y_view = gpu.y.create_view(&Default::default());
let uv_view = gpu.uv.create_view(&Default::default());
gpu_resources.nv12_blit.blit(
device, queue, &y_view, &uv_view, hdr_layer_view, &bt,
gpu.full_range, gpu.coeffs, gpu.transfer, gpu.primaries,
);
} else {
// Upload raw sRGB straight-alpha bytes into an sRGB texture; the GPU
// decodes to linear on sample (no per-pixel CPU conversion). Blit with
// blit_straight so the shader doesn't unpremultiply.
let tex = upload_transient_texture(device, queue, &inst.rgba_data, inst.width, inst.height, wgpu::TextureFormat::Rgba8UnormSrgb, Some("export_video_frame_tex"));
let tex_view = tex.create_view(&Default::default());
let bt = crate::gpu_brush::BlitTransform::new(inst.transform, inst.width, inst.height, width, height);
gpu_resources.canvas_blit.blit_straight(device, queue, &tex_view, hdr_layer_view, &bt, None);
}
let compositor_layer = CompositorLayer::new(hdr_layer_handle, inst.opacity, lightningbeam_core::gpu::BlendMode::Normal);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_video_composite") });
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
@ -1401,9 +1415,9 @@ pub fn render_frame_to_yuv10_hdr(
Affine::IDENTITY
};
// Export composites on a separate device → force software video frames.
// HDR export composites on the shared device, so it can consume hardware-decoded GPU frames.
if let Ok(mut vm) = video_manager.lock() {
vm.set_render_hardware_ok(false);
vm.set_render_hardware_ok(true);
}
let composite_result = render_document_for_compositing(
@ -1437,6 +1451,9 @@ pub fn render_frame_to_gpu_rgba(
floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>,
allow_transparency: bool,
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
// True when compositing on the shared device (software/image export) → may consume
// hardware-decoded GPU frames; false for the zero-copy path on its own device.
hardware_ok: bool,
) -> Result<wgpu::CommandEncoder, String> {
use vello::kurbo::Affine;
@ -1468,9 +1485,10 @@ pub fn render_frame_to_gpu_rgba(
Affine::IDENTITY
};
// Export composites on a separate device — force software frames (see above).
// GPU frames are usable only on the shared device (software/image export); the zero-copy path
// runs on its own device and must download to CPU.
if let Ok(mut vm) = video_manager.lock() {
vm.set_render_hardware_ok(false);
vm.set_render_hardware_ok(hardware_ok);
}
// Render document for compositing (returns per-layer scenes)