diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index 2016a59..6d06263 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -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.add_space(8.0); diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/hdr_frame.rs b/lightningbeam-ui/lightningbeam-editor/src/export/hdr_frame.rs new file mode 100644 index 0000000..4d78c50 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/export/hdr_frame.rs @@ -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 + 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, Vec, Vec) { + 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, Vec, Vec) { + 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) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 0777a11..337843b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -11,6 +11,7 @@ pub mod readback_pipeline; pub mod perf_metrics; pub mod cpu_yuv_converter; pub mod gpu_yuv; +pub mod hdr_frame; use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; use lightningbeam_core::document::Document; @@ -52,6 +53,8 @@ pub struct VideoExportState { width: u32, /// Export height in pixels 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 frame_tx: Option>, /// HDR GPU resources for compositing pipeline (effects, color conversion) @@ -854,6 +857,7 @@ impl ExportOrchestrator { width: u32, height: u32, ) -> (std::thread::JoinHandle<()>, VideoExportState) { + let hdr = settings.hdr; let handle = std::thread::spawn(move || { Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); }); @@ -866,6 +870,7 @@ impl ExportOrchestrator { framerate, width, height, + hdr, frame_tx: Some(frame_tx), gpu_resources: None, readback_pipeline: None, @@ -890,7 +895,10 @@ impl ExportOrchestrator { framerate: f64, output_path: &std::path::Path, ) -> Option { - 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; } let encoder = match gpu_video_encoder::encoder::ZeroCopyEncoder::new( @@ -1243,6 +1251,43 @@ impl ExportOrchestrator { let width = state.width; 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 if state.gpu_resources.is_none() { println!("🎬 [VIDEO EXPORT] Initializing HDR GPU + async pipeline {}x{}", width, height); diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/shaders/linear_to_pq.wgsl b/lightningbeam-ui/lightningbeam-editor/src/export/shaders/linear_to_pq.wgsl new file mode 100644 index 0000000..6b71562 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/export/shaders/linear_to_pq.wgsl @@ -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 decode→encode round-trip is the identity. + +@group(0) @binding(0) var input_tex: texture_2d; +@group(0) @binding(1) var input_sampler: sampler; +@group(0) @binding(2) var params: vec4; // .x = mode (0 = PQ, 1 = HLG) + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@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(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +// BT.709 → BT.2020 primaries, linear light (ITU-R BT.2087). +fn bt709_to_bt2020(c: vec3) -> vec3 { + 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(r, g, b); +} + +// SMPTE ST 2084 (PQ) OETF: scene-linear (white = 1.0 = 203 nits) → PQ code [0,1]. +fn pq_oetf(lin: vec3) -> vec3 { + let nits = max(lin, vec3(0.0)) * 203.0; + let ln = min(nits / 10000.0, vec3(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(m1)); + return pow((vec3(c1) + c2 * lm) / (vec3(1.0) + c3 * lm), vec3(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) -> vec3 { + let a = 0.17883277; + let b = 0.28466892; + let c = 0.55991073; + let e = clamp(lin * 0.26496256, vec3(0.0), vec3(1.0)); + let lo = sqrt(3.0 * e); + let hi = a * log(12.0 * e - vec3(b)) + vec3(c); + return select(lo, hi, e > vec3(1.0 / 12.0)); +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // Compositor stores PREMULTIPLIED linear; unpremultiply to straight (video is opaque, a≈1). + let texel = textureSample(input_tex, input_sampler, in.uv); + let a = texel.a; + let straight = select(texel.rgb / a, vec3(0.0), a <= 0.0); + + let bt2020 = max(bt709_to_bt2020(straight), vec3(0.0)); + var enc: vec3; + if params.x == 1u { + enc = hlg_oetf(bt2020); + } else { + enc = pq_oetf(bt2020); + } + return vec4(enc, 1.0); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 85587b1..15f432e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -90,6 +90,8 @@ pub struct ExportGpuResources { /// texture copy each frame instead of a full Vello render + 2 passes/submits. `None` until the /// first frame; invalidated on resize. cached_bg_hdr: Option, + /// HDR encode pipeline (linear→PQ/HLG BT.2020 → 10-bit YUV). Lazily built on the first HDR frame. + hdr_pipeline: Option, } impl ExportGpuResources { @@ -306,6 +308,7 @@ impl ExportGpuResources { canvas_blit, raster_cache: std::collections::HashMap::new(), 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>, + gpu_resources: &mut ExportGpuResources, + hdr_mode: lightningbeam_core::export::HdrExportMode, + raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, +) -> Result<(Vec, Vec, Vec), 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( document: &mut Document, timestamp: f64,