export: 10-bit HDR video frame path + UI (Stage B pt 2)

Wire HDR frame production end-to-end (encoder side was pt 1):
- linear_to_pq.wgsl: linear scene HDR (BT.709, white=1.0) → BT.2020 primaries →
  PQ (203-nit) or HLG OETF → gamma-encoded R'G'B'. Inverse of the nv12 decode, so
  a decode→encode round-trip is the identity.
- hdr_frame.rs (isolated module): runs that pass into an Rgba16Float target, does a
  synchronous readback (f16-decoded on CPU), then BT.2020 R'G'B'→Y'CbCr limited
  4:2:0 10-bit pack → YUV420P10LE planes. Rgba16Float avoids the 16BIT_NORM device
  feature dependency.
- video_exporter::render_frame_to_yuv10_hdr: composite + HDR encode, returning
  10-bit planes; lazily builds the pipeline.
- orchestrator: HDR uses a synchronous 1-frame-per-call path (the async RGBA
  pipeline is 8-bit only); zero-copy is skipped for HDR.
- dialog: "Dynamic range" dropdown (SDR / HDR10 PQ / HLG).

Software HEVC Main10 path; favors correctness over throughput. Compiles; runtime
verification needs a GPU + HDR-capable player.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 04:47:35 -04:00
parent 41e4f3b12b
commit 33dec6f327
5 changed files with 501 additions and 1 deletions

View File

@ -541,6 +541,22 @@ impl ExportDialog {
}); });
} }
// HDR output: 10-bit BT.2020 PQ/HLG (HEVC). Forces H.265; software path (no zero-copy).
ui.horizontal(|ui| {
use lightningbeam_core::export::HdrExportMode;
ui.label("Dynamic range:");
egui::ComboBox::from_id_salt("video_hdr_mode")
.selected_text(self.video_settings.hdr.name())
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.video_settings.hdr, HdrExportMode::Sdr, HdrExportMode::Sdr.name());
ui.selectable_value(&mut self.video_settings.hdr, HdrExportMode::Pq, HdrExportMode::Pq.name());
ui.selectable_value(&mut self.video_settings.hdr, HdrExportMode::Hlg, HdrExportMode::Hlg.name());
});
});
if self.video_settings.hdr.is_hdr() {
ui.label(egui::RichText::new("HDR exports as 10-bit HEVC (H.265), BT.2020.").weak().small());
}
ui.checkbox(&mut self.include_audio, "Include Audio"); ui.checkbox(&mut self.include_audio, "Include Audio");
ui.add_space(8.0); ui.add_space(8.0);

View File

@ -0,0 +1,308 @@
//! 10-bit HDR frame production for video export (isolated from the SDR readback pipeline).
//!
//! Takes the compositor's Rgba16Float HDR accumulator and produces YUV420P10LE planes:
//! 1. GPU pass `linear_to_pq.wgsl` → PQ/HLG-encoded BT.2020 R'G'B' into an Rgba16Unorm texture
//! (the expensive per-pixel transfer + gamut work).
//! 2. Synchronous GPU→CPU readback of that texture.
//! 3. CPU BT.2020 R'G'B'→Y'CbCr (limited range), 4:2:0 average, 10-bit little-endian pack.
//!
//! Synchronous (no triple-buffering); HDR export favors correctness/simplicity over throughput.
use lightningbeam_core::export::HdrExportMode;
/// Round up to the wgpu copy row alignment (256 bytes).
fn align_256(n: u32) -> u32 {
(n + 255) & !255
}
pub struct HdrFramePipeline {
width: u32,
height: u32,
pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
mode_buf: wgpu::Buffer,
/// PQ/HLG-encoded BT.2020 R'G'B' (Rgba16Unorm) render target.
enc_texture_view: wgpu::TextureView,
enc_texture: wgpu::Texture,
/// Staging buffer for readback; rows padded to 256-byte alignment.
staging: wgpu::Buffer,
padded_bytes_per_row: u32,
}
impl HdrFramePipeline {
pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("linear_to_pq_shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/linear_to_pq.wgsl").into()),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("linear_to_pq_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("linear_to_pq_pl"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("linear_to_pq_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
// Rgba16Float (not Unorm) so no TEXTURE_FORMAT_16BIT_NORM feature is needed; PQ/HLG
// values are in [0,1] where f16 has ~11 effective bits — ample for 10-bit output.
format: wgpu::TextureFormat::Rgba16Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("linear_to_pq_sampler"),
..Default::default()
});
let mode_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("linear_to_pq_mode"),
size: 16, // vec4<u32>
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let enc_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("hdr_enc_texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let enc_texture_view = enc_texture.create_view(&Default::default());
let padded_bytes_per_row = align_256(width * 8); // Rgba16Unorm = 8 bytes/texel
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("hdr_enc_staging"),
size: (padded_bytes_per_row * height) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
Self {
width,
height,
pipeline,
bind_group_layout,
sampler,
mode_buf,
enc_texture_view,
enc_texture,
staging,
padded_bytes_per_row,
}
}
/// Encode the composited HDR texture (`hdr_view`, Rgba16Float linear) to YUV420P10LE planes.
pub fn render_to_yuv10(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hdr_view: &wgpu::TextureView,
mode: HdrExportMode,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let mode_code: u32 = if matches!(mode, HdrExportMode::Hlg) { 1 } else { 0 };
queue.write_buffer(&self.mode_buf, 0, bytemuck::cast_slice(&[mode_code, 0u32, 0, 0]));
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("linear_to_pq_bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(hdr_view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) },
wgpu::BindGroupEntry { binding: 2, resource: self.mode_buf.as_entire_binding() },
],
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("hdr_frame_encoder"),
});
{
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("linear_to_pq_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.enc_texture_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &bind_group, &[]);
rp.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &self.enc_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &self.staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(self.padded_bytes_per_row),
rows_per_image: Some(self.height),
},
},
wgpu::Extent3d { width: self.width, height: self.height, depth_or_array_layers: 1 },
);
queue.submit(Some(encoder.finish()));
// Synchronous map + wait.
let slice = self.staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
let _ = device.poll(wgpu::PollType::wait_indefinitely());
let _ = rx.recv();
let w = self.width as usize;
let h = self.height as usize;
let mapped = slice.get_mapped_range();
// Un-pad rows; decode f16 → f32 into a tight RGBA buffer.
let mut rgba = vec![0f32; w * h * 4];
let row_bytes = w * 8;
for row in 0..h {
let src = row * self.padded_bytes_per_row as usize;
let dst = row * w * 4;
let bytes = &mapped[src..src + row_bytes];
for px in 0..w * 4 {
let half = u16::from_le_bytes([bytes[px * 2], bytes[px * 2 + 1]]);
rgba[dst + px] = f16_to_f32(half);
}
}
drop(mapped);
self.staging.unmap();
rgba_to_yuv420p10le(&rgba, w, h)
}
}
/// Decode an IEEE 754 half-float. Inputs are in [0,1] so the inf/NaN paths don't occur in practice.
fn f16_to_f32(h: u16) -> f32 {
let sign = (h >> 15) & 1;
let exp = (h >> 10) & 0x1f;
let mant = h & 0x3ff;
let v = if exp == 0 {
(mant as f32) * 2f32.powi(-24) // subnormal
} else if exp == 31 {
if mant == 0 { f32::INFINITY } else { f32::NAN }
} else {
(1.0 + mant as f32 / 1024.0) * 2f32.powi(exp as i32 - 15)
};
if sign == 1 { -v } else { v }
}
/// BT.2020 non-constant-luminance R'G'B'→Y'CbCr, limited range, 4:2:0, 10-bit little-endian.
/// Input R'G'B' is already gamma-encoded (PQ/HLG) in [0,1].
fn rgba_to_yuv420p10le(rgba: &[f32], w: usize, h: usize) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
const KR: f32 = 0.2627;
const KB: f32 = 0.0593;
let kg = 1.0 - KR - KB;
let luma = |r: f32, g: f32, b: f32| KR * r + kg * g + KB * b;
// 10-bit limited: Y' [64,940] (scale 876), Cb/Cr center 512, excursion ±0.5 → scale 896.
let pack_y = |y: f32| ((y * 876.0 + 64.0).round().clamp(0.0, 1023.0)) as u16;
let pack_c = |c: f32| ((c * 896.0 + 512.0).round().clamp(0.0, 1023.0)) as u16;
let mut y_plane = vec![0u8; w * h * 2];
for j in 0..h {
for i in 0..w {
let p = (j * w + i) * 4;
let y10 = pack_y(luma(rgba[p], rgba[p + 1], rgba[p + 2]));
let o = (j * w + i) * 2;
y_plane[o] = (y10 & 0xff) as u8;
y_plane[o + 1] = (y10 >> 8) as u8;
}
}
let (cw, ch) = (w / 2, h / 2);
let mut u_plane = vec![0u8; cw * ch * 2];
let mut v_plane = vec![0u8; cw * ch * 2];
for j in 0..ch {
for i in 0..cw {
let (mut cb, mut cr) = (0.0f32, 0.0f32);
for dy in 0..2 {
for dx in 0..2 {
let p = ((j * 2 + dy) * w + (i * 2 + dx)) * 4;
let (r, g, b) = (rgba[p], rgba[p + 1], rgba[p + 2]);
let yy = luma(r, g, b);
cb += (b - yy) / (2.0 * (1.0 - KB));
cr += (r - yy) / (2.0 * (1.0 - KR));
}
}
let cb10 = pack_c(cb / 4.0);
let cr10 = pack_c(cr / 4.0);
let o = (j * cw + i) * 2;
u_plane[o] = (cb10 & 0xff) as u8;
u_plane[o + 1] = (cb10 >> 8) as u8;
v_plane[o] = (cr10 & 0xff) as u8;
v_plane[o + 1] = (cr10 >> 8) as u8;
}
}
(y_plane, u_plane, v_plane)
}

View File

@ -11,6 +11,7 @@ pub mod readback_pipeline;
pub mod perf_metrics; pub mod perf_metrics;
pub mod cpu_yuv_converter; pub mod cpu_yuv_converter;
pub mod gpu_yuv; pub mod gpu_yuv;
pub mod hdr_frame;
use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress};
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
@ -52,6 +53,8 @@ pub struct VideoExportState {
width: u32, width: u32,
/// Export height in pixels /// Export height in pixels
height: u32, height: u32,
/// HDR output mode — HDR uses a synchronous 10-bit path instead of the async RGBA pipeline.
hdr: lightningbeam_core::export::HdrExportMode,
/// Channel to send rendered frames to encoder thread /// Channel to send rendered frames to encoder thread
frame_tx: Option<Sender<VideoFrameMessage>>, frame_tx: Option<Sender<VideoFrameMessage>>,
/// HDR GPU resources for compositing pipeline (effects, color conversion) /// HDR GPU resources for compositing pipeline (effects, color conversion)
@ -854,6 +857,7 @@ impl ExportOrchestrator {
width: u32, width: u32,
height: u32, height: u32,
) -> (std::thread::JoinHandle<()>, VideoExportState) { ) -> (std::thread::JoinHandle<()>, VideoExportState) {
let hdr = settings.hdr;
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames);
}); });
@ -866,6 +870,7 @@ impl ExportOrchestrator {
framerate, framerate,
width, width,
height, height,
hdr,
frame_tx: Some(frame_tx), frame_tx: Some(frame_tx),
gpu_resources: None, gpu_resources: None,
readback_pipeline: None, readback_pipeline: None,
@ -890,7 +895,10 @@ impl ExportOrchestrator {
framerate: f64, framerate: f64,
output_path: &std::path::Path, output_path: &std::path::Path,
) -> Option<ZeroCopyVideo> { ) -> Option<ZeroCopyVideo> {
if !matches!(settings.codec, lightningbeam_core::export::VideoCodec::H264) { // Zero-copy is 8-bit H.264 only; HDR needs the 10-bit HEVC software path.
if settings.hdr.is_hdr()
|| !matches!(settings.codec, lightningbeam_core::export::VideoCodec::H264)
{
return None; return None;
} }
let encoder = match gpu_video_encoder::encoder::ZeroCopyEncoder::new( let encoder = match gpu_video_encoder::encoder::ZeroCopyEncoder::new(
@ -1243,6 +1251,43 @@ impl ExportOrchestrator {
let width = state.width; let width = state.width;
let height = state.height; let height = state.height;
// HDR path: synchronous 10-bit render (composite → PQ/HLG → readback → 10-bit YUV), one
// frame per call. Bypasses the SDR async RGBA pipeline (which is 8-bit only).
if state.hdr.is_hdr() {
if state.gpu_resources.is_none() {
println!("🎬 [VIDEO EXPORT] Initializing HDR GPU resources {}x{} ({})", width, height, state.hdr.name());
state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, width, height));
}
if state.current_frame < state.total_frames {
let timestamp = state.start_time + (state.current_frame as f64 / state.framerate);
let gpu_resources = state.gpu_resources.as_mut().unwrap();
let (y, u, v) = video_exporter::render_frame_to_yuv10_hdr(
document, timestamp, width, height,
device, queue, renderer, image_cache, video_manager,
gpu_resources, state.hdr, raster_store,
)?;
if let Some(tx) = &state.frame_tx {
tx.send(VideoFrameMessage::Frame {
frame_num: state.current_frame,
timestamp,
y_plane: y,
u_plane: u,
v_plane: v,
}).map_err(|_| "Failed to send HDR frame")?;
}
state.current_frame += 1;
}
if state.current_frame >= state.total_frames {
println!("🎬 [VIDEO EXPORT] HDR complete: {} frames", state.total_frames);
if let Some(tx) = state.frame_tx.take() {
tx.send(VideoFrameMessage::Done).ok();
}
state.gpu_resources = None;
return Ok(false);
}
return Ok(true);
}
// Initialize GPU resources and readback pipeline on first frame // Initialize GPU resources and readback pipeline on first frame
if state.gpu_resources.is_none() { if state.gpu_resources.is_none() {
println!("🎬 [VIDEO EXPORT] Initializing HDR GPU + async pipeline {}x{}", width, height); println!("🎬 [VIDEO EXPORT] Initializing HDR GPU + async pipeline {}x{}", width, height);

View File

@ -0,0 +1,78 @@
// Linear-HDR PQ/HLG BT.2020 encode (for 10-bit HDR video export).
//
// Input: the compositor's Rgba16Float HDR accumulator PREMULTIPLIED scene-linear, BT.709
// primaries, graphics white = 1.0, HDR highlights > 1.0.
// Output: gamma-encoded R'G'B' in BT.2020 primaries, PQ (mode 0) or HLG (mode 1), to an
// Rgba16Unorm target. A later CPU pass does only BT.2020 R'G'B'Y'CbCr (no transfer) + 4:2:0 + 10-bit.
//
// This is the encode inverse of panes/shaders/nv12_blit.wgsl's decode (203-nit PQ white; HLG
// reference white at signal 0.75), so a decodeencode round-trip is the identity.
@group(0) @binding(0) var input_tex: texture_2d<f32>;
@group(0) @binding(1) var input_sampler: sampler;
@group(0) @binding(2) var<uniform> params: vec4<u32>; // .x = mode (0 = PQ, 1 = HLG)
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput;
let x = f32((vertex_index & 1u) << 1u);
let y = f32(vertex_index & 2u);
out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
out.uv = vec2<f32>(x, y);
return out;
}
// BT.709 BT.2020 primaries, linear light (ITU-R BT.2087).
fn bt709_to_bt2020(c: vec3<f32>) -> vec3<f32> {
let r = 0.627404 * c.r + 0.329283 * c.g + 0.043313 * c.b;
let g = 0.069097 * c.r + 0.919540 * c.g + 0.011362 * c.b;
let b = 0.016391 * c.r + 0.088013 * c.g + 0.895595 * c.b;
return vec3<f32>(r, g, b);
}
// SMPTE ST 2084 (PQ) OETF: scene-linear (white = 1.0 = 203 nits) PQ code [0,1].
fn pq_oetf(lin: vec3<f32>) -> vec3<f32> {
let nits = max(lin, vec3<f32>(0.0)) * 203.0;
let ln = min(nits / 10000.0, vec3<f32>(1.0));
let m1 = 0.1593017578125;
let m2 = 78.84375;
let c1 = 0.8359375;
let c2 = 18.8515625;
let c3 = 18.6875;
let lm = pow(ln, vec3<f32>(m1));
return pow((vec3<f32>(c1) + c2 * lm) / (vec3<f32>(1.0) + c3 * lm), vec3<f32>(m2));
}
// ARIB STD-B67 (HLG) OETF: scene-linear (white = 1.0) HLG signal [0,1]. Reference white maps to
// signal 0.75 (matching the decode's /0.26496256 normalization). Display OOTF omitted (scene-referred).
fn hlg_oetf(lin: vec3<f32>) -> vec3<f32> {
let a = 0.17883277;
let b = 0.28466892;
let c = 0.55991073;
let e = clamp(lin * 0.26496256, vec3<f32>(0.0), vec3<f32>(1.0));
let lo = sqrt(3.0 * e);
let hi = a * log(12.0 * e - vec3<f32>(b)) + vec3<f32>(c);
return select(lo, hi, e > vec3<f32>(1.0 / 12.0));
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Compositor stores PREMULTIPLIED linear; unpremultiply to straight (video is opaque, a1).
let texel = textureSample(input_tex, input_sampler, in.uv);
let a = texel.a;
let straight = select(texel.rgb / a, vec3<f32>(0.0), a <= 0.0);
let bt2020 = max(bt709_to_bt2020(straight), vec3<f32>(0.0));
var enc: vec3<f32>;
if params.x == 1u {
enc = hlg_oetf(bt2020);
} else {
enc = pq_oetf(bt2020);
}
return vec4<f32>(enc, 1.0);
}

View File

@ -90,6 +90,8 @@ pub struct ExportGpuResources {
/// texture copy each frame instead of a full Vello render + 2 passes/submits. `None` until the /// texture copy each frame instead of a full Vello render + 2 passes/submits. `None` until the
/// first frame; invalidated on resize. /// first frame; invalidated on resize.
cached_bg_hdr: Option<wgpu::Texture>, cached_bg_hdr: Option<wgpu::Texture>,
/// HDR encode pipeline (linear→PQ/HLG BT.2020 → 10-bit YUV). Lazily built on the first HDR frame.
hdr_pipeline: Option<super::hdr_frame::HdrFramePipeline>,
} }
impl ExportGpuResources { impl ExportGpuResources {
@ -306,6 +308,7 @@ impl ExportGpuResources {
canvas_blit, canvas_blit,
raster_cache: std::collections::HashMap::new(), raster_cache: std::collections::HashMap::new(),
cached_bg_hdr: None, cached_bg_hdr: None,
hdr_pipeline: None,
} }
} }
@ -1369,6 +1372,56 @@ fn fault_in_raster_for_frame(
} }
} }
/// Render one frame as 10-bit HDR YUV420P10LE planes (BT.2020 + PQ/HLG). Synchronous: composites,
/// runs the linear→PQ/HLG GPU pass, reads it back, and CPU-converts to 10-bit YUV. Used by the
/// HDR export path instead of the async readback pipeline.
#[allow(clippy::too_many_arguments)]
pub fn render_frame_to_yuv10_hdr(
document: &mut Document,
timestamp: f64,
width: u32,
height: u32,
device: &wgpu::Device,
queue: &wgpu::Queue,
renderer: &mut vello::Renderer,
image_cache: &mut ImageCache,
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
gpu_resources: &mut ExportGpuResources,
hdr_mode: lightningbeam_core::export::HdrExportMode,
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
use vello::kurbo::Affine;
document.current_time = timestamp;
fault_in_raster_for_frame(document, raster_store);
let base_transform = if document.width > 0.0 && document.height > 0.0 {
Affine::scale_non_uniform(width as f64 / document.width, height as f64 / document.height)
} else {
Affine::IDENTITY
};
// Export composites on a separate device → force software video frames.
if let Ok(mut vm) = video_manager.lock() {
vm.set_render_hardware_ok(false);
}
let composite_result = render_document_for_compositing(
document, base_transform, image_cache, video_manager, None, None, false,
);
composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, false)?;
if gpu_resources.hdr_pipeline.is_none() {
gpu_resources.hdr_pipeline = Some(super::hdr_frame::HdrFramePipeline::new(device, width, height));
}
let planes = gpu_resources
.hdr_pipeline
.as_ref()
.unwrap()
.render_to_yuv10(device, queue, &gpu_resources.hdr_texture_view, hdr_mode);
Ok(planes)
}
pub fn render_frame_to_gpu_rgba( pub fn render_frame_to_gpu_rgba(
document: &mut Document, document: &mut Document,
timestamp: f64, timestamp: f64,