diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 3d90c88..33bfdc9 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -288,6 +288,38 @@ impl ColorRange { } } +/// HDR output mode for video export. SDR encodes BT.709 8-bit as before; the HDR modes encode +/// 10-bit BT.2020 with the PQ (HDR10) or HLG transfer, preserving super-white highlights from the +/// linear compositor. HDR requires a 10-bit codec (HEVC Main10) — the exporter forces H.265. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum HdrExportMode { + #[default] + Sdr, + /// PQ (SMPTE ST 2084) — HDR10. Graphics white at 203 nits (matches the compositor convention). + Pq, + /// HLG (ARIB STD-B67) — broadcast HDR, also displayable as SDR. + Hlg, +} + +impl HdrExportMode { + pub fn is_hdr(&self) -> bool { !matches!(self, HdrExportMode::Sdr) } + pub fn name(&self) -> &'static str { + match self { + HdrExportMode::Sdr => "SDR (BT.709, 8-bit)", + HdrExportMode::Pq => "HDR10 / PQ (BT.2020, 10-bit)", + HdrExportMode::Hlg => "HLG (BT.2020, 10-bit)", + } + } + /// FFmpeg transfer-characteristic name for the color tags. + pub fn transfer_name(&self) -> &'static str { + match self { + HdrExportMode::Sdr => "bt709", + HdrExportMode::Pq => "smpte2084", + HdrExportMode::Hlg => "arib-std-b67", + } + } +} + /// Video quality presets #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VideoQuality { @@ -349,6 +381,10 @@ pub struct VideoExportSettings { #[serde(default)] pub color_range: ColorRange, + /// HDR output mode. HDR forces 10-bit HEVC (BT.2020 + PQ/HLG); SDR is the default. + #[serde(default)] + pub hdr: HdrExportMode, + /// Audio settings (None = no audio) pub audio: Option, @@ -368,6 +404,7 @@ impl Default for VideoExportSettings { framerate: 60.0, quality: VideoQuality::High, color_range: ColorRange::Limited, + hdr: HdrExportMode::Sdr, audio: Some(AudioExportSettings::high_quality_aac()), start_time: 0.0, end_time: 60.0, diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index b89ca54..0777a11 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -1556,13 +1556,18 @@ impl ExportOrchestrator { // Initialize FFmpeg ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; - // Convert codec enum to FFmpeg codec ID - let codec_id = match settings.codec { - VideoCodec::H264 => ffmpeg_next::codec::Id::H264, - VideoCodec::H265 => ffmpeg_next::codec::Id::HEVC, - VideoCodec::VP8 => ffmpeg_next::codec::Id::VP8, - VideoCodec::VP9 => ffmpeg_next::codec::Id::VP9, - VideoCodec::ProRes422 => ffmpeg_next::codec::Id::PRORES, + // Convert codec enum to FFmpeg codec ID. HDR requires 10-bit HEVC (Main10), so force HEVC + // regardless of the chosen codec when an HDR mode is selected. + let codec_id = if settings.hdr.is_hdr() { + ffmpeg_next::codec::Id::HEVC + } else { + match settings.codec { + VideoCodec::H264 => ffmpeg_next::codec::Id::H264, + VideoCodec::H265 => ffmpeg_next::codec::Id::HEVC, + VideoCodec::VP8 => ffmpeg_next::codec::Id::VP8, + VideoCodec::VP9 => ffmpeg_next::codec::Id::VP9, + VideoCodec::ProRes422 => ffmpeg_next::codec::Id::PRORES, + } }; // Get bitrate from quality settings @@ -1605,8 +1610,16 @@ impl ExportOrchestrator { height, framerate, bitrate_kbps, + settings.hdr, )?; + // Pixel format the encoder frames are built in (matches setup_video_encoder). + let pixel_format = if settings.hdr.is_hdr() { + ffmpeg_next::format::Pixel::YUV420P10LE + } else { + ffmpeg_next::format::Pixel::YUV420P + }; + // Create output file let mut output = ffmpeg_next::format::output(&output_path) .map_err(|e| format!("Failed to create output file: {}", e))?; @@ -1635,6 +1648,7 @@ impl ExportOrchestrator { width, height, timestamp, + pixel_format, )?; // Send progress update for first frame @@ -1662,6 +1676,7 @@ impl ExportOrchestrator { width, height, timestamp, + pixel_format, )?; frames_encoded += 1; @@ -1706,29 +1721,33 @@ impl ExportOrchestrator { width: u32, height: u32, timestamp: f64, + pixel_format: ffmpeg_next::format::Pixel, ) -> Result<(), String> { - // YUV planes already converted by GPU (no CPU conversion needed) + // YUV planes already converted (8-bit YUV420P, or 10-bit YUV420P10LE for HDR). - // Create FFmpeg video frame - let mut video_frame = ffmpeg_next::frame::Video::new( - ffmpeg_next::format::Pixel::YUV420P, - width, - height, - ); + // Create FFmpeg video frame in the encoder's pixel format. + let mut video_frame = ffmpeg_next::frame::Video::new(pixel_format, width, height); - // Copy YUV planes to frame - // Use safe slice copy - LLVM optimizes this to memcpy, same performance as copy_nonoverlapping - let y_dest = video_frame.data_mut(0); - let y_len = y_plane.len().min(y_dest.len()); - y_dest[..y_len].copy_from_slice(&y_plane[..y_len]); - - let u_dest = video_frame.data_mut(1); - let u_len = u_plane.len().min(u_dest.len()); - u_dest[..u_len].copy_from_slice(&u_plane[..u_len]); - - let v_dest = video_frame.data_mut(2); - let v_len = v_plane.len().min(v_dest.len()); - v_dest[..v_len].copy_from_slice(&v_plane[..v_len]); + // Copy each plane row-by-row honoring the frame's stride (10-bit / arbitrary widths can have + // row padding that a flat copy would misalign). `bytes_per_row` = samples × sample size. + let ten_bit = matches!(pixel_format, ffmpeg_next::format::Pixel::YUV420P10LE); + let sample_bytes = if ten_bit { 2usize } else { 1usize }; + let copy_plane = |frame: &mut ffmpeg_next::frame::Video, idx: usize, src: &[u8], w: usize, h: usize| { + let bytes_per_row = w * sample_bytes; + let stride = frame.stride(idx); + let dst = frame.data_mut(idx); + for row in 0..h { + let s = row * bytes_per_row; + let d = row * stride; + let n = bytes_per_row.min(src.len().saturating_sub(s)).min(dst.len().saturating_sub(d)); + if n == 0 { break; } + dst[d..d + n].copy_from_slice(&src[s..s + n]); + } + }; + let (w, h) = (width as usize, height as usize); + copy_plane(&mut video_frame, 0, y_plane, w, h); + copy_plane(&mut video_frame, 1, u_plane, w / 2, h / 2); + copy_plane(&mut video_frame, 2, v_plane, w / 2, h / 2); // Set PTS (presentation timestamp) in encoder's time base // Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000) diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index bb10917..85587b1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -528,6 +528,7 @@ pub fn setup_video_encoder( height: u32, framerate: f64, bitrate_kbps: u32, + hdr: lightningbeam_core::export::HdrExportMode, ) -> Result<(ffmpeg::encoder::Video, ffmpeg::Codec), String> { // Try to find codec by ID first println!("🔍 Looking for codec: {:?}", codec_id); @@ -583,31 +584,41 @@ pub fn setup_video_encoder( // Configure encoder parameters BEFORE opening (critical!) encoder.set_width(aligned_width); encoder.set_height(aligned_height); - encoder.set_format(ffmpeg::format::Pixel::YUV420P); + // HDR encodes 10-bit BT.2020 (limited range); SDR keeps 8-bit full-range BT.709. + if hdr.is_hdr() { + encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE); + } else { + encoder.set_format(ffmpeg::format::Pixel::YUV420P); + } encoder.set_time_base(ffmpeg::Rational(1, (framerate * 1000.0) as i32)); encoder.set_frame_rate(Some(ffmpeg::Rational(framerate as i32, 1))); encoder.set_bit_rate((bitrate_kbps * 1000) as usize); encoder.set_gop(framerate as u32); // 1 second GOP - // Tag the color metadata so players interpret the YUV correctly. Our - // RGB→YUV conversion uses the BT.709 matrix with FULL-range (0–255) luma - // and no transfer applied to the already-sRGB-encoded RGB. Tagging this - // as full-range BT.709 (matrix/primaries/transfer) prevents the level/ - // hue shift that occurs when a player assumes limited-range or BT.601. - // colorspace (matrix) and range have safe setters; primaries and trc are - // generic AVCodecContext options set via the open dictionary below. - encoder.set_colorspace(ffmpeg::color::Space::BT709); - encoder.set_color_range(ffmpeg::color::Range::JPEG); // full range - - println!("📐 Video dimensions: {}×{} (aligned to {}×{} for H.264)", - width, height, aligned_width, aligned_height); - - // Open encoder with codec (like working MP3 export). color_primaries and - // color_trc have no typed setter on the encoder, so pass them as generic - // AVCodecContext options (BT.709) through the open dictionary. + // Tag the color metadata so players interpret the YUV correctly. + // SDR: our RGB→YUV uses the BT.709 matrix with FULL-range (0–255) luma and no transfer applied + // to the already-sRGB-encoded RGB, so tag full-range BT.709 to avoid level/hue shifts. + // HDR: BT.2020 non-constant-luminance matrix, LIMITED range (standard for HDR10/HLG), with the + // PQ or HLG transfer; the 10-bit YUV is produced from PQ/HLG-encoded BT.2020 RGB. let mut color_opts = ffmpeg::Dictionary::new(); - color_opts.set("color_primaries", "bt709"); - color_opts.set("color_trc", "bt709"); + if hdr.is_hdr() { + encoder.set_colorspace(ffmpeg::color::Space::BT2020NCL); + encoder.set_color_range(ffmpeg::color::Range::MPEG); // limited + color_opts.set("color_primaries", "bt2020"); + color_opts.set("color_trc", hdr.transfer_name()); + // HEVC 10-bit profile (the only HDR-capable codec we wire up). + color_opts.set("profile", "main10"); + } else { + encoder.set_colorspace(ffmpeg::color::Space::BT709); + encoder.set_color_range(ffmpeg::color::Range::JPEG); // full range + color_opts.set("color_primaries", "bt709"); + color_opts.set("color_trc", "bt709"); + } + + println!("📐 Video dimensions: {}×{} (aligned to {}×{}){}", + width, height, aligned_width, aligned_height, + if hdr.is_hdr() { " [HDR 10-bit BT.2020]" } else { "" }); + let encoder = encoder .open_as_with(codec, color_opts) .map_err(|e| format!("Failed to open video encoder: {}", e))?;