export: HDR encoder scaffolding — 10-bit HEVC + BT.2020/PQ/HLG tags (Stage B pt 1)

Settings + encoder side of HDR video export (frame data still SDR until pt 2):
- core: HdrExportMode {Sdr (default), Pq, Hlg} on VideoExportSettings (serde
  default), with transfer-name helpers.
- setup_video_encoder takes the mode: HDR → YUV420P10LE, BT.2020 NCL matrix,
  limited range, color_primaries=bt2020, color_trc=smpte2084/arib-std-b67,
  profile=main10. SDR path unchanged (8-bit full-range BT.709).
- run_video_encoder forces HEVC when HDR is selected (the only 10-bit codec wired
  up). encode_frame is parameterized by pixel format and now copies planes
  row-by-row honoring stride (10-bit / non-aligned widths can have row padding).

Dormant: no UI exposes the mode yet and the frame data is still 8-bit SDR, so
selecting HDR is not yet wired. The render→PQ/HLG 10-bit frame path is pt 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 04:32:32 -04:00
parent 28b14b2ad0
commit 41e4f3b12b
3 changed files with 113 additions and 46 deletions

View File

@ -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 /// Video quality presets
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VideoQuality { pub enum VideoQuality {
@ -349,6 +381,10 @@ pub struct VideoExportSettings {
#[serde(default)] #[serde(default)]
pub color_range: ColorRange, 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) /// Audio settings (None = no audio)
pub audio: Option<AudioExportSettings>, pub audio: Option<AudioExportSettings>,
@ -368,6 +404,7 @@ impl Default for VideoExportSettings {
framerate: 60.0, framerate: 60.0,
quality: VideoQuality::High, quality: VideoQuality::High,
color_range: ColorRange::Limited, color_range: ColorRange::Limited,
hdr: HdrExportMode::Sdr,
audio: Some(AudioExportSettings::high_quality_aac()), audio: Some(AudioExportSettings::high_quality_aac()),
start_time: 0.0, start_time: 0.0,
end_time: 60.0, end_time: 60.0,

View File

@ -1556,13 +1556,18 @@ impl ExportOrchestrator {
// Initialize FFmpeg // Initialize FFmpeg
ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
// Convert codec enum to FFmpeg codec ID // Convert codec enum to FFmpeg codec ID. HDR requires 10-bit HEVC (Main10), so force HEVC
let codec_id = match settings.codec { // 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::H264 => ffmpeg_next::codec::Id::H264,
VideoCodec::H265 => ffmpeg_next::codec::Id::HEVC, VideoCodec::H265 => ffmpeg_next::codec::Id::HEVC,
VideoCodec::VP8 => ffmpeg_next::codec::Id::VP8, VideoCodec::VP8 => ffmpeg_next::codec::Id::VP8,
VideoCodec::VP9 => ffmpeg_next::codec::Id::VP9, VideoCodec::VP9 => ffmpeg_next::codec::Id::VP9,
VideoCodec::ProRes422 => ffmpeg_next::codec::Id::PRORES, VideoCodec::ProRes422 => ffmpeg_next::codec::Id::PRORES,
}
}; };
// Get bitrate from quality settings // Get bitrate from quality settings
@ -1605,8 +1610,16 @@ impl ExportOrchestrator {
height, height,
framerate, framerate,
bitrate_kbps, 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 // Create output file
let mut output = ffmpeg_next::format::output(&output_path) let mut output = ffmpeg_next::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?; .map_err(|e| format!("Failed to create output file: {}", e))?;
@ -1635,6 +1648,7 @@ impl ExportOrchestrator {
width, width,
height, height,
timestamp, timestamp,
pixel_format,
)?; )?;
// Send progress update for first frame // Send progress update for first frame
@ -1662,6 +1676,7 @@ impl ExportOrchestrator {
width, width,
height, height,
timestamp, timestamp,
pixel_format,
)?; )?;
frames_encoded += 1; frames_encoded += 1;
@ -1706,29 +1721,33 @@ impl ExportOrchestrator {
width: u32, width: u32,
height: u32, height: u32,
timestamp: f64, timestamp: f64,
pixel_format: ffmpeg_next::format::Pixel,
) -> Result<(), String> { ) -> 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 // Create FFmpeg video frame in the encoder's pixel format.
let mut video_frame = ffmpeg_next::frame::Video::new( let mut video_frame = ffmpeg_next::frame::Video::new(pixel_format, width, height);
ffmpeg_next::format::Pixel::YUV420P,
width,
height,
);
// Copy YUV planes to frame // Copy each plane row-by-row honoring the frame's stride (10-bit / arbitrary widths can have
// Use safe slice copy - LLVM optimizes this to memcpy, same performance as copy_nonoverlapping // row padding that a flat copy would misalign). `bytes_per_row` = samples × sample size.
let y_dest = video_frame.data_mut(0); let ten_bit = matches!(pixel_format, ffmpeg_next::format::Pixel::YUV420P10LE);
let y_len = y_plane.len().min(y_dest.len()); let sample_bytes = if ten_bit { 2usize } else { 1usize };
y_dest[..y_len].copy_from_slice(&y_plane[..y_len]); 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 u_dest = video_frame.data_mut(1); let stride = frame.stride(idx);
let u_len = u_plane.len().min(u_dest.len()); let dst = frame.data_mut(idx);
u_dest[..u_len].copy_from_slice(&u_plane[..u_len]); for row in 0..h {
let s = row * bytes_per_row;
let v_dest = video_frame.data_mut(2); let d = row * stride;
let v_len = v_plane.len().min(v_dest.len()); let n = bytes_per_row.min(src.len().saturating_sub(s)).min(dst.len().saturating_sub(d));
v_dest[..v_len].copy_from_slice(&v_plane[..v_len]); 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 // Set PTS (presentation timestamp) in encoder's time base
// Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000) // Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000)

View File

@ -528,6 +528,7 @@ pub fn setup_video_encoder(
height: u32, height: u32,
framerate: f64, framerate: f64,
bitrate_kbps: u32, bitrate_kbps: u32,
hdr: lightningbeam_core::export::HdrExportMode,
) -> Result<(ffmpeg::encoder::Video, ffmpeg::Codec), String> { ) -> Result<(ffmpeg::encoder::Video, ffmpeg::Codec), String> {
// Try to find codec by ID first // Try to find codec by ID first
println!("🔍 Looking for codec: {:?}", codec_id); println!("🔍 Looking for codec: {:?}", codec_id);
@ -583,31 +584,41 @@ pub fn setup_video_encoder(
// Configure encoder parameters BEFORE opening (critical!) // Configure encoder parameters BEFORE opening (critical!)
encoder.set_width(aligned_width); encoder.set_width(aligned_width);
encoder.set_height(aligned_height); encoder.set_height(aligned_height);
// 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_format(ffmpeg::format::Pixel::YUV420P);
}
encoder.set_time_base(ffmpeg::Rational(1, (framerate * 1000.0) as i32)); 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_frame_rate(Some(ffmpeg::Rational(framerate as i32, 1)));
encoder.set_bit_rate((bitrate_kbps * 1000) as usize); encoder.set_bit_rate((bitrate_kbps * 1000) as usize);
encoder.set_gop(framerate as u32); // 1 second GOP encoder.set_gop(framerate as u32); // 1 second GOP
// Tag the color metadata so players interpret the YUV correctly. Our // Tag the color metadata so players interpret the YUV correctly.
// RGB→YUV conversion uses the BT.709 matrix with FULL-range (0255) luma // SDR: our RGB→YUV uses the BT.709 matrix with FULL-range (0255) luma and no transfer applied
// and no transfer applied to the already-sRGB-encoded RGB. Tagging this // to the already-sRGB-encoded RGB, so tag full-range BT.709 to avoid level/hue shifts.
// as full-range BT.709 (matrix/primaries/transfer) prevents the level/ // HDR: BT.2020 non-constant-luminance matrix, LIMITED range (standard for HDR10/HLG), with the
// hue shift that occurs when a player assumes limited-range or BT.601. // PQ or HLG transfer; the 10-bit YUV is produced from PQ/HLG-encoded BT.2020 RGB.
// colorspace (matrix) and range have safe setters; primaries and trc are let mut color_opts = ffmpeg::Dictionary::new();
// generic AVCodecContext options set via the open dictionary below. 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_colorspace(ffmpeg::color::Space::BT709);
encoder.set_color_range(ffmpeg::color::Range::JPEG); // full range 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.
let mut color_opts = ffmpeg::Dictionary::new();
color_opts.set("color_primaries", "bt709"); color_opts.set("color_primaries", "bt709");
color_opts.set("color_trc", "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 let encoder = encoder
.open_as_with(codec, color_opts) .open_as_with(codec, color_opts)
.map_err(|e| format!("Failed to open video encoder: {}", e))?; .map_err(|e| format!("Failed to open video encoder: {}", e))?;