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:
parent
28b14b2ad0
commit
41e4f3b12b
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 (0–255) luma
|
// SDR: our RGB→YUV uses the BT.709 matrix with FULL-range (0–255) 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))?;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue