From 9411145ce9fedd7b96531358ae22cae5d6fbd097 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 26 Jun 2026 00:36:44 -0400 Subject: [PATCH] export: H.264 color range toggle (fix dark/oversaturated output) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-copy VAAPI encoder emitted full-range BT.709 NV12 but wrote no color tags, so players assumed limited range and stretched it — the H.264 output looked dark and oversaturated (preview and VP9/software were fine). - Rgba2Nv12 takes `full_range` and applies the matching Y/chroma scale+offset (limited 16-235 / full 0-255) via a uniform; the encoder sets color_range + bt709 colorspace/primaries/transfer tags to match. ffprobe-confirmed. - New ColorRange { Limited, Full } on VideoExportSettings (default Limited, the universally-correct choice; serde(default) so saved settings still load), surfaced as a "Color range" dropdown in advanced export settings for H.264. The swscale software fallback still emits Limited regardless of the toggle (Full only affects the VAAPI zero-copy path). --- .../gpu-video-encoder/src/encoder.rs | 15 +++- .../gpu-video-encoder/src/render_nv12.rs | 80 ++++++++++++++----- .../gpu-video-encoder/tests/zerocopy.rs | 2 +- .../tests/zerocopy_encode.rs | 2 +- .../lightningbeam-core/src/export.rs | 28 +++++++ .../lightningbeam-editor/src/export/dialog.rs | 16 +++- .../lightningbeam-editor/src/export/mod.rs | 1 + 7 files changed, 122 insertions(+), 22 deletions(-) diff --git a/lightningbeam-ui/gpu-video-encoder/src/encoder.rs b/lightningbeam-ui/gpu-video-encoder/src/encoder.rs index 1e42a82..e334bac 100644 --- a/lightningbeam-ui/gpu-video-encoder/src/encoder.rs +++ b/lightningbeam-ui/gpu-video-encoder/src/encoder.rs @@ -42,15 +42,17 @@ unsafe impl Send for ZeroCopyEncoder {} impl ZeroCopyEncoder { /// Build a zero-copy `h264_vaapi` encoder writing to `output_path` (container inferred /// from the extension, e.g. `.mp4`). `Err` if VAAPI/the device is unavailable. + #[allow(clippy::too_many_arguments)] pub fn new( width: u32, height: u32, framerate: i32, bitrate_kbps: u32, output_path: &Path, + full_range: bool, ) -> Result { let drm = vk_device::create()?; - let renderer = Rgba2Nv12::new(&drm.device); + let renderer = Rgba2Nv12::new(&drm.device, full_range); unsafe { let mut hw_device = crate::vaapi::create_device()?; let name = CString::new("h264_vaapi").unwrap(); @@ -66,6 +68,17 @@ impl ZeroCopyEncoder { (*enc).framerate = ff::AVRational { num: framerate, den: 1 }; (*enc).pix_fmt = ff::AVPixelFormat::AV_PIX_FMT_VAAPI; (*enc).bit_rate = (bitrate_kbps as i64) * 1000; + // Color signalling for the H.264 VUI. The Rgba2Nv12 shader produces BT.709 luma/chroma + // in the matching range; without these tags players assume limited range and a + // full-range stream looks dark + oversaturated. + (*enc).color_range = if full_range { + ff::AVColorRange::AVCOL_RANGE_JPEG + } else { + ff::AVColorRange::AVCOL_RANGE_MPEG + }; + (*enc).colorspace = ff::AVColorSpace::AVCOL_SPC_BT709; + (*enc).color_primaries = ff::AVColorPrimaries::AVCOL_PRI_BT709; + (*enc).color_trc = ff::AVColorTransferCharacteristic::AVCOL_TRC_BT709; let frames_ref = ff::av_hwframe_ctx_alloc(hw_device); { diff --git a/lightningbeam-ui/gpu-video-encoder/src/render_nv12.rs b/lightningbeam-ui/gpu-video-encoder/src/render_nv12.rs index 7e3ccab..025435a 100644 --- a/lightningbeam-ui/gpu-video-encoder/src/render_nv12.rs +++ b/lightningbeam-ui/gpu-video-encoder/src/render_nv12.rs @@ -2,29 +2,61 @@ //! surface's plane textures (R8 Y, RG8 UV). Render targets (not compute storage) so it //! works with the DMA-BUF-imported plane images, which aren't storage-writable. //! -//! BT.709 full-range, matching `nv12::cpu_reference` and the encoder's color tags. +//! BT.709 matrix; the Y/chroma scale+offset are chosen by `full_range` — limited (TV, 16–235) +//! is the H.264 convention most players assume, full (PC, 0–255) needs the matching color tag. /// Converts a bound RGBA texture into a Y plane (R8) and a UV plane (RG8) via two passes. pub struct Rgba2Nv12 { y_pipeline: wgpu::RenderPipeline, uv_pipeline: wgpu::RenderPipeline, bgl: wgpu::BindGroupLayout, + params_buf: wgpu::Buffer, } impl Rgba2Nv12 { - pub fn new(device: &wgpu::Device) -> Self { + /// `full_range`: true → full/PC range (Y 0–255, no scaling); false → limited/TV range + /// (Y 16–235, chroma 16–240), the H.264 default. The encoder must tag the stream to match. + pub fn new(device: &wgpu::Device, full_range: bool) -> Self { + // (y_offset, y_scale, chroma_offset, chroma_scale). The shader applies these to the + // BT.709 luma/chroma dot products. + let params: [f32; 4] = if full_range { + [0.0, 1.0, 128.0 / 255.0, 1.0] + } else { + [16.0 / 255.0, 219.0 / 255.0, 128.0 / 255.0, 224.0 / 255.0] + }; + let params_bytes: Vec = params.iter().flat_map(|f| f.to_le_bytes()).collect(); + let params_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("rgba2nv12_params"), + size: params_bytes.len() as u64, + usage: wgpu::BufferUsages::UNIFORM, + mapped_at_creation: true, + }); + params_buf.slice(..).get_mapped_range_mut().copy_from_slice(¶ms_bytes); + params_buf.unmap(); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("rgba2nv12_bgl"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, - count: None, - }], + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("rgba2nv12_pl"), @@ -65,6 +97,7 @@ impl Rgba2Nv12 { y_pipeline: mk("y_fs", wgpu::TextureFormat::R8Unorm), uv_pipeline: mk("uv_fs", wgpu::TextureFormat::Rg8Unorm), bgl, + params_buf, } } @@ -80,10 +113,16 @@ impl Rgba2Nv12 { let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("rgba2nv12_bg"), layout: &self.bgl, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(rgba_view), - }], + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(rgba_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.params_buf.as_entire_binding(), + }, + ], }); for (pipeline, view) in [(&self.y_pipeline, y_view), (&self.uv_pipeline, uv_view)] { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -110,6 +149,8 @@ impl Rgba2Nv12 { const SHADER: &str = r#" @group(0) @binding(0) var input_rgba: texture_2d; +// (y_offset, y_scale, chroma_offset, chroma_scale) — selects limited vs full range. +@group(0) @binding(1) var params: vec4; // Fullscreen triangle. @vertex @@ -127,7 +168,8 @@ fn load(p: vec2) -> vec3 { @fragment fn y_fs(@builtin(position) pos: vec4) -> @location(0) vec4 { let c = load(vec2(i32(pos.x), i32(pos.y))); - let y = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + let luma = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + let y = params.x + params.y * luma; return vec4(y, 0.0, 0.0, 1.0); } @@ -138,8 +180,10 @@ fn uv_fs(@builtin(position) pos: vec4) -> @location(0) vec4 { let sy = 2 * i32(pos.y); let a = (load(vec2(sx, sy)) + load(vec2(sx + 1, sy)) + load(vec2(sx, sy + 1)) + load(vec2(sx + 1, sy + 1))) * 0.25; - let u = -0.1146 * a.r - 0.3854 * a.g + 0.5000 * a.b + 0.5; - let v = 0.5000 * a.r - 0.4542 * a.g - 0.0458 * a.b + 0.5; + let cb = -0.1146 * a.r - 0.3854 * a.g + 0.5000 * a.b; + let cr = 0.5000 * a.r - 0.4542 * a.g - 0.0458 * a.b; + let u = params.z + params.w * cb; + let v = params.z + params.w * cr; return vec4(u, v, 0.0, 1.0); } "#; diff --git a/lightningbeam-ui/gpu-video-encoder/tests/zerocopy.rs b/lightningbeam-ui/gpu-video-encoder/tests/zerocopy.rs index dd2152c..2234091 100644 --- a/lightningbeam-ui/gpu-video-encoder/tests/zerocopy.rs +++ b/lightningbeam-ui/gpu-video-encoder/tests/zerocopy.rs @@ -54,7 +54,7 @@ fn zerocopy_real_frame_render() { wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); - let conv = render_nv12::Rgba2Nv12::new(&drm.device); + let conv = render_nv12::Rgba2Nv12::new(&drm.device, true); let src_view = src.create_view(&Default::default()); let y_view = imported.y().create_view(&Default::default()); let uv_view = imported.uv().create_view(&Default::default()); diff --git a/lightningbeam-ui/gpu-video-encoder/tests/zerocopy_encode.rs b/lightningbeam-ui/gpu-video-encoder/tests/zerocopy_encode.rs index 69e8ca9..749bca8 100644 --- a/lightningbeam-ui/gpu-video-encoder/tests/zerocopy_encode.rs +++ b/lightningbeam-ui/gpu-video-encoder/tests/zerocopy_encode.rs @@ -10,7 +10,7 @@ fn zerocopy_encode_h264() { let (w, h) = (640u32, 480u32); let out = std::env::temp_dir().join("gpu_video_encoder_zerocopy.mp4"); let _ = std::fs::remove_file(&out); - let mut enc = match ZeroCopyEncoder::new(w, h, 30, 4000, &out) { + let mut enc = match ZeroCopyEncoder::new(w, h, 30, 4000, &out, false) { Ok(e) => e, Err(e) => { eprintln!("[zc-encode] unavailable, skipping: {e}"); diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 4bef658..3d90c88 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -265,6 +265,29 @@ impl VideoCodec { } } +/// YUV color range for the encoded video (currently H.264). Limited/TV (16–235) is what nearly +/// every player assumes; Full/PC (0–255) keeps a bit more precision but only looks right in players +/// that honor the full-range tag — so Limited is the safe default. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ColorRange { + Limited, + Full, +} + +impl Default for ColorRange { + fn default() -> Self { ColorRange::Limited } +} + +impl ColorRange { + pub fn is_full(&self) -> bool { matches!(self, ColorRange::Full) } + pub fn name(&self) -> &'static str { + match self { + ColorRange::Limited => "Limited (TV, 16–235)", + ColorRange::Full => "Full (PC, 0–255)", + } + } +} + /// Video quality presets #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VideoQuality { @@ -322,6 +345,10 @@ pub struct VideoExportSettings { /// Video quality pub quality: VideoQuality, + /// YUV color range (H.264 only; ignored by other codecs). + #[serde(default)] + pub color_range: ColorRange, + /// Audio settings (None = no audio) pub audio: Option, @@ -340,6 +367,7 @@ impl Default for VideoExportSettings { height: None, framerate: 60.0, quality: VideoQuality::High, + color_range: ColorRange::Limited, audio: Some(AudioExportSettings::high_quality_aac()), start_time: 0.0, end_time: 60.0, diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index 11eb248..2016a59 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -6,7 +6,7 @@ use eframe::egui; use lightningbeam_core::export::{ AudioExportSettings, AudioFormat, ImageExportSettings, ImageFormat, - VideoExportSettings, VideoCodec, VideoQuality, + VideoExportSettings, VideoCodec, VideoQuality, ColorRange, }; use std::path::PathBuf; @@ -527,6 +527,20 @@ impl ExportDialog { }); }); + // Color range applies to H.264 (the VAAPI zero-copy encoder honors it). Limited/TV is the + // compatible default; Full/PC only looks right in players that read the full-range tag. + if matches!(self.video_settings.codec, VideoCodec::H264) { + ui.horizontal(|ui| { + ui.label("Color range:"); + egui::ComboBox::from_id_salt("video_color_range") + .selected_text(self.video_settings.color_range.name()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.video_settings.color_range, ColorRange::Limited, ColorRange::Limited.name()); + ui.selectable_value(&mut self.video_settings.color_range, ColorRange::Full, ColorRange::Full.name()); + }); + }); + } + ui.checkbox(&mut self.include_audio, "Include Audio"); ui.add_space(8.0); diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 62381bc..b89ca54 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -899,6 +899,7 @@ impl ExportOrchestrator { framerate.round() as i32, settings.quality.bitrate_kbps(), output_path, + settings.color_range.is_full(), ) { Ok(e) => e, Err(e) => {