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:
parent
41e4f3b12b
commit
33dec6f327
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<Sender<VideoFrameMessage>>,
|
||||
/// 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<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;
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<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, a≈1).
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<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 {
|
||||
|
|
@ -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<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(
|
||||
document: &mut Document,
|
||||
timestamp: f64,
|
||||
|
|
|
|||
Loading…
Reference in New Issue