Export honesty: real lossy WebP, working ProRes, VP8+audio container
Three cases where an export produced something that didn't match what the UI offered: - WebP quality slider was a no-op: image 0.25's WebP encoder is lossless-only, so the slider did nothing and files were needlessly large. Encode lossy WebP via ffmpeg's libwebp instead (already linked); the quality knob is now real and alpha is preserved as YUVA420P. Test asserts a lossy VP8 chunk + that quality changes file size. - ProRes 422 always failed to open: the SDR path fed prores_ks 8-bit YUV420P, but it requires 10-bit 4:2:2. Add a CpuYuv422P10Converter (RGBA→YUV422P10LE, BT.709) and route ProRes through the existing async pipeline in CPU mode; setup_video_encoder now emits YUV422P10LE + prores_ks HQ profile and encode_frame handles 4:2:2 chroma. Test guards that the encoder opens. - VP8+audio failed at mux: the parallel path wrote the temp video to a hardcoded .mp4, which VP8 can't live in. Derive the temp container from the codec (VP8/VP9 → .webm).
This commit is contained in:
parent
c373af461e
commit
53ffb7d528
|
|
@ -101,6 +101,94 @@ impl CpuYuvConverter {
|
|||
}
|
||||
}
|
||||
|
||||
/// CPU RGBA→YUV422P10LE converter (10-bit, 4:2:2) via swscale, for ProRes 422 export.
|
||||
///
|
||||
/// ProRes (`prores_ks`) requires a 10-bit 4:2:2 input; the SDR pipeline otherwise produces 8-bit
|
||||
/// 4:2:0. Source is still 8-bit RGBA (bit-depth is promoted, not conjured), which is normal for
|
||||
/// SDR ProRes. BT.709 with the requested range, matching the encoder's color tags.
|
||||
pub struct CpuYuv422P10Converter {
|
||||
width: u32,
|
||||
height: u32,
|
||||
scaler: ffmpeg::software::scaling::Context,
|
||||
rgba_frame: ffmpeg::frame::Video,
|
||||
yuv_frame: ffmpeg::frame::Video,
|
||||
}
|
||||
|
||||
impl CpuYuv422P10Converter {
|
||||
pub fn new(width: u32, height: u32, full_range: bool) -> Result<Self, String> {
|
||||
let mut scaler = ffmpeg::software::scaling::Context::get(
|
||||
ffmpeg::format::Pixel::RGBA, width, height,
|
||||
ffmpeg::format::Pixel::YUV422P10LE, width, height,
|
||||
ffmpeg::software::scaling::Flags::BILINEAR,
|
||||
)
|
||||
.map_err(|e| format!("Failed to create YUV422P10 swscale context: {}", e))?;
|
||||
|
||||
// BT.709, requested output range (matches setup_video_encoder's SDR tags). No safe
|
||||
// ffmpeg-next wrapper for sws_setColorspaceDetails, so this is the raw call (as in
|
||||
// CpuYuvConverter::new above).
|
||||
unsafe {
|
||||
let coeffs = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709 as i32);
|
||||
let dst_range = if full_range { 1 } else { 0 };
|
||||
let one = 1 << 16;
|
||||
ffmpeg::ffi::sws_setColorspaceDetails(
|
||||
scaler.as_mut_ptr(),
|
||||
coeffs, 1,
|
||||
coeffs, dst_range,
|
||||
0, one, one,
|
||||
);
|
||||
}
|
||||
|
||||
let rgba_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
|
||||
let yuv_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV422P10LE, width, height);
|
||||
Ok(Self { width, height, scaler, rgba_frame, yuv_frame })
|
||||
}
|
||||
|
||||
/// Convert packed RGBA (width*height*4) to tight YUV422P10LE planes (little-endian, 2 bytes per
|
||||
/// sample): Y is width×height, U and V are (width/2)×height. Planes are returned tight (stride
|
||||
/// padding stripped) to match what `encode_frame` expects.
|
||||
pub fn convert(&mut self, rgba_data: &[u8]) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
|
||||
let expected = (self.width * self.height * 4) as usize;
|
||||
assert_eq!(rgba_data.len(), expected,
|
||||
"RGBA data size mismatch: expected {} bytes, got {}", expected, rgba_data.len());
|
||||
|
||||
// Copy RGBA into the source frame honoring its stride (may be padded).
|
||||
let row_bytes = (self.width * 4) as usize;
|
||||
let src_stride = self.rgba_frame.stride(0);
|
||||
{
|
||||
let dst = self.rgba_frame.data_mut(0);
|
||||
for row in 0..self.height as usize {
|
||||
let s = row * row_bytes;
|
||||
let d = row * src_stride;
|
||||
dst[d..d + row_bytes].copy_from_slice(&rgba_data[s..s + row_bytes]);
|
||||
}
|
||||
}
|
||||
|
||||
self.scaler
|
||||
.run(&self.rgba_frame, &mut self.yuv_frame)
|
||||
.map_err(|e| format!("YUV422P10 swscale conversion failed: {}", e))?;
|
||||
|
||||
// Extract each plane tight (2 bytes/sample). Y: width samples/row × height rows.
|
||||
// Chroma (4:2:2): width/2 samples/row × height rows.
|
||||
let extract = |frame: &ffmpeg::frame::Video, idx: usize, samples_w: usize, rows: usize| {
|
||||
let bytes_per_row = samples_w * 2;
|
||||
let stride = frame.stride(idx);
|
||||
let data = frame.data(idx);
|
||||
let mut out = Vec::with_capacity(bytes_per_row * rows);
|
||||
for row in 0..rows {
|
||||
let start = row * stride;
|
||||
out.extend_from_slice(&data[start..start + bytes_per_row]);
|
||||
}
|
||||
out
|
||||
};
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
let y_plane = extract(&self.yuv_frame, 0, w, h);
|
||||
let u_plane = extract(&self.yuv_frame, 1, w / 2, h);
|
||||
let v_plane = extract(&self.yuv_frame, 2, w / 2, h);
|
||||
|
||||
Ok((y_plane, u_plane, v_plane))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -131,6 +219,20 @@ mod tests {
|
|||
assert_eq!(v.len(), (1920 / 2) * (1080 / 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yuv422p10_output_sizes() {
|
||||
// Use a width that forces swscale linesize padding (not a multiple of 32/64) to exercise
|
||||
// the stride-stripping extraction.
|
||||
let (w, h) = (1000u32, 720u32);
|
||||
let mut c = CpuYuv422P10Converter::new(w, h, false).unwrap();
|
||||
let rgba = vec![0u8; (w * h * 4) as usize];
|
||||
let (y, u, v) = c.convert(&rgba).unwrap();
|
||||
// 10-bit → 2 bytes/sample. Y full res; U/V half width, full height (4:2:2).
|
||||
assert_eq!(y.len(), (w * h * 2) as usize);
|
||||
assert_eq!(u.len(), ((w / 2) * h * 2) as usize);
|
||||
assert_eq!(v.len(), ((w / 2) * h * 2) as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "RGBA data size mismatch")]
|
||||
fn test_wrong_input_size_panics() {
|
||||
|
|
|
|||
|
|
@ -45,13 +45,179 @@ pub fn save_rgba_image(
|
|||
encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}"))
|
||||
}
|
||||
ImageFormat::WebP => {
|
||||
if allow_transparency {
|
||||
img.save(path).map_err(|e| format!("WebP save failed: {e}"))
|
||||
} else {
|
||||
let flat = flatten_alpha(img);
|
||||
flat.save(path).map_err(|e| format!("WebP save failed: {e}"))
|
||||
// `image` 0.25's WebP encoder is lossless-only, which ignored the quality slider and
|
||||
// produced needlessly large files. Encode lossy WebP via ffmpeg's libwebp instead so
|
||||
// the quality control is real; alpha is preserved (as YUVA420P) when requested.
|
||||
save_webp_ffmpeg(pixels, width, height, quality, allow_transparency, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a single frame as lossy WebP via ffmpeg's `libwebp` encoder.
|
||||
///
|
||||
/// `quality` is libwebp's 0–100 quality factor. When `allow_transparency` is true the source is
|
||||
/// converted to YUVA420P so libwebp keeps the alpha channel; otherwise it's flattened onto black
|
||||
/// and converted to YUV420P. Uses swscale's default BT.601 conversion (matching a plain
|
||||
/// `ffmpeg -i in.png out.webp`).
|
||||
fn save_webp_ffmpeg(
|
||||
pixels: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
quality: u8,
|
||||
allow_transparency: bool,
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
ffmpeg::init().map_err(|e| format!("Failed to initialize ffmpeg: {e}"))?;
|
||||
|
||||
let codec = ffmpeg::encoder::find_by_name("libwebp")
|
||||
.or_else(|| ffmpeg::encoder::find(ffmpeg::codec::Id::WEBP))
|
||||
.ok_or("libwebp encoder not available in this ffmpeg build")?;
|
||||
|
||||
// Flatten onto black up front when alpha isn't wanted, so the source is fully opaque.
|
||||
let src_rgba: Vec<u8> = if allow_transparency {
|
||||
pixels.to_vec()
|
||||
} else {
|
||||
let mut v = pixels.to_vec();
|
||||
for px in v.chunks_exact_mut(4) {
|
||||
let a = px[3] as u32;
|
||||
px[0] = (px[0] as u32 * a / 255) as u8;
|
||||
px[1] = (px[1] as u32 * a / 255) as u8;
|
||||
px[2] = (px[2] as u32 * a / 255) as u8;
|
||||
px[3] = 255;
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
let dst_pix = if allow_transparency {
|
||||
ffmpeg::format::Pixel::YUVA420P
|
||||
} else {
|
||||
ffmpeg::format::Pixel::YUV420P
|
||||
};
|
||||
|
||||
// RGBA → YUV(A)420P (swscale defaults: BT.601, limited range — what libwebp expects).
|
||||
let mut scaler = ffmpeg::software::scaling::Context::get(
|
||||
ffmpeg::format::Pixel::RGBA, width, height,
|
||||
dst_pix, width, height,
|
||||
ffmpeg::software::scaling::Flags::BILINEAR,
|
||||
)
|
||||
.map_err(|e| format!("Failed to create swscale context: {e}"))?;
|
||||
|
||||
let mut src = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
|
||||
// Copy row-by-row honoring the frame's stride (may exceed width*4 due to alignment padding).
|
||||
let stride = src.stride(0);
|
||||
let row_bytes = (width * 4) as usize;
|
||||
{
|
||||
let dst = src.data_mut(0);
|
||||
for y in 0..height as usize {
|
||||
let s = y * row_bytes;
|
||||
let d = y * stride;
|
||||
dst[d..d + row_bytes].copy_from_slice(&src_rgba[s..s + row_bytes]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut yuv = ffmpeg::frame::Video::new(dst_pix, width, height);
|
||||
scaler.run(&src, &mut yuv).map_err(|e| format!("swscale conversion failed: {e}"))?;
|
||||
yuv.set_pts(Some(0));
|
||||
|
||||
let mut octx = ffmpeg::format::output(&path)
|
||||
.map_err(|e| format!("Failed to create WebP output: {e}"))?;
|
||||
|
||||
let mut enc = ffmpeg::codec::Context::new_with_codec(codec)
|
||||
.encoder()
|
||||
.video()
|
||||
.map_err(|e| format!("Failed to create WebP encoder: {e}"))?;
|
||||
enc.set_width(width);
|
||||
enc.set_height(height);
|
||||
enc.set_format(dst_pix);
|
||||
enc.set_time_base(ffmpeg::Rational(1, 1));
|
||||
|
||||
// libwebp private options: quality 0–100, lossy.
|
||||
let mut opts = ffmpeg::Dictionary::new();
|
||||
opts.set("quality", &quality.to_string());
|
||||
opts.set("lossless", "0");
|
||||
let mut enc = enc
|
||||
.open_with(opts)
|
||||
.map_err(|e| format!("Failed to open libwebp encoder: {e}"))?;
|
||||
|
||||
{
|
||||
let mut stream = octx.add_stream(codec)
|
||||
.map_err(|e| format!("Failed to add WebP stream: {e}"))?;
|
||||
stream.set_parameters(&enc);
|
||||
stream.set_time_base(ffmpeg::Rational(1, 1));
|
||||
}
|
||||
|
||||
octx.write_header().map_err(|e| format!("Failed to write WebP header: {e}"))?;
|
||||
enc.send_frame(&yuv).map_err(|e| format!("Failed to send WebP frame: {e}"))?;
|
||||
enc.send_eof().map_err(|e| format!("Failed to flush WebP encoder: {e}"))?;
|
||||
|
||||
let mut packet = ffmpeg::Packet::empty();
|
||||
while enc.receive_packet(&mut packet).is_ok() {
|
||||
packet.set_stream(0);
|
||||
packet
|
||||
.write_interleaved(&mut octx)
|
||||
.map_err(|e| format!("Failed to write WebP packet: {e}"))?;
|
||||
}
|
||||
|
||||
octx.write_trailer().map_err(|e| format!("Failed to finalize WebP: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightningbeam_core::export::ImageFormat;
|
||||
|
||||
/// A gradient RGBA image so the encoder has real content to quantize/compress.
|
||||
fn gradient(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut px = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
px.push((x * 255 / width.max(1)) as u8);
|
||||
px.push((y * 255 / height.max(1)) as u8);
|
||||
px.push(128);
|
||||
px.push(255);
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
/// The ffmpeg libwebp path must produce a valid *lossy* WebP (RIFF/WEBP container with a
|
||||
/// `VP8 ` chunk — lossless would be `VP8L`), and the quality knob must actually change size.
|
||||
#[test]
|
||||
fn webp_export_is_real_lossy() {
|
||||
let (w, h) = (96u32, 64u32);
|
||||
let px = gradient(w, h);
|
||||
let dir = std::env::temp_dir();
|
||||
let lo = dir.join("lb_webp_q10_test.webp");
|
||||
let hi = dir.join("lb_webp_q95_test.webp");
|
||||
|
||||
save_webp_ffmpeg(&px, w, h, 10, false, &lo).expect("low-quality webp encode");
|
||||
save_webp_ffmpeg(&px, w, h, 95, false, &hi).expect("high-quality webp encode");
|
||||
|
||||
let lo_bytes = std::fs::read(&lo).unwrap();
|
||||
let hi_bytes = std::fs::read(&hi).unwrap();
|
||||
|
||||
// RIFF....WEBP container.
|
||||
assert_eq!(&lo_bytes[0..4], b"RIFF", "not a RIFF container");
|
||||
assert_eq!(&lo_bytes[8..12], b"WEBP", "not a WEBP file");
|
||||
// Lossy VP8 chunk (`VP8 ` with trailing space), NOT lossless `VP8L`.
|
||||
assert_eq!(&lo_bytes[12..16], b"VP8 ", "expected lossy VP8, got {:?}", &lo_bytes[12..16]);
|
||||
// The quality knob is honored: q10 is meaningfully smaller than q95.
|
||||
assert!(lo_bytes.len() < hi_bytes.len(),
|
||||
"quality ignored: q10 {} bytes >= q95 {} bytes", lo_bytes.len(), hi_bytes.len());
|
||||
|
||||
std::fs::remove_file(&lo).ok();
|
||||
std::fs::remove_file(&hi).ok();
|
||||
}
|
||||
|
||||
/// The format enum still advertises a quality control for WebP (now that it works).
|
||||
#[test]
|
||||
fn webp_has_quality() {
|
||||
assert!(ImageFormat::WebP.has_quality());
|
||||
assert!(ImageFormat::Jpeg.has_quality());
|
||||
assert!(!ImageFormat::Png.has_quality());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ pub struct VideoExportState {
|
|||
readback_pipeline: Option<readback_pipeline::ReadbackPipeline>,
|
||||
/// CPU YUV converter for RGBA→YUV420p conversion
|
||||
cpu_yuv_converter: Option<cpu_yuv_converter::CpuYuvConverter>,
|
||||
/// ProRes 422 export: forces the CPU (RGBA) readback path and converts to 10-bit 4:2:2 instead
|
||||
/// of 8-bit 4:2:0. `true` only for `VideoCodec::ProRes422` (SDR).
|
||||
prores: bool,
|
||||
/// CPU RGBA→YUV422P10LE converter, used only on the ProRes path.
|
||||
cpu_yuv422p10: Option<cpu_yuv_converter::CpuYuv422P10Converter>,
|
||||
/// Frames that have been submitted to GPU but not yet encoded
|
||||
frames_in_flight: usize,
|
||||
/// Next frame number to send to encoder (for ordering)
|
||||
|
|
@ -1101,6 +1106,8 @@ impl ExportOrchestrator {
|
|||
let hdr = settings.hdr;
|
||||
let fit = settings.fit;
|
||||
let full_range = settings.color_range.is_full();
|
||||
let prores = matches!(settings.codec, lightningbeam_core::export::VideoCodec::ProRes422)
|
||||
&& !hdr.is_hdr();
|
||||
let handle = std::thread::spawn(move || {
|
||||
Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames);
|
||||
});
|
||||
|
|
@ -1120,6 +1127,8 @@ impl ExportOrchestrator {
|
|||
gpu_resources: None,
|
||||
readback_pipeline: None,
|
||||
cpu_yuv_converter: None,
|
||||
prores,
|
||||
cpu_yuv422p10: None,
|
||||
frames_in_flight: 0,
|
||||
next_frame_to_encode: 0,
|
||||
perf_metrics: Some(perf_metrics::ExportMetrics::new()),
|
||||
|
|
@ -1350,7 +1359,10 @@ impl ExportOrchestrator {
|
|||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.mp4", timestamp));
|
||||
// Use the codec's real container for the temp video, not a hardcoded .mp4 — VP8 isn't a
|
||||
// valid MP4 codec, so an .mp4 temp made `write_header` fail for any VP8+audio export.
|
||||
let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.{}",
|
||||
timestamp, video_settings.codec.container_format()));
|
||||
let temp_audio_path = temp_dir.join(format!("lightningbeam_audio_{}.{}",
|
||||
timestamp,
|
||||
match audio_settings.format {
|
||||
|
|
@ -1560,24 +1572,34 @@ impl ExportOrchestrator {
|
|||
// Enable GPU YUV only when the encoder's YUV420P planes are tight (no linesize
|
||||
// padding) — then the packed GPU planes copy in without row misalignment.
|
||||
// Otherwise fall back to RGBA readback + CPU swscale.
|
||||
let gpu_yuv_tight = std::env::var("LB_DISABLE_GPU_YUV").is_err() && {
|
||||
// ProRes needs 10-bit 4:2:2 (built on the CPU from the RGBA readback), so it forces the
|
||||
// RGBA path — the GPU YUV converter only produces 8-bit 4:2:0.
|
||||
let gpu_yuv_tight = !state.prores && std::env::var("LB_DISABLE_GPU_YUV").is_err() && {
|
||||
let probe = ffmpeg_next::frame::Video::new(
|
||||
ffmpeg_next::format::Pixel::YUV420P, width, height,
|
||||
);
|
||||
probe.stride(0) == width as usize && probe.stride(1) == (width / 2) as usize
|
||||
};
|
||||
if !gpu_yuv_tight {
|
||||
if !gpu_yuv_tight && !state.prores {
|
||||
println!("🎬 [VIDEO EXPORT] YUV planes are padded at {width}x{height}; using CPU YUV path");
|
||||
}
|
||||
state.readback_pipeline = Some(readback_pipeline::ReadbackPipeline::new(device, queue, width, height, gpu_yuv_tight, state.full_range));
|
||||
state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?);
|
||||
if state.prores {
|
||||
state.cpu_yuv422p10 = Some(cpu_yuv_converter::CpuYuv422P10Converter::new(width, height, state.full_range)?);
|
||||
println!("🎬 [VIDEO EXPORT] ProRes 422: 10-bit 4:2:2 (YUV422P10LE) CPU converter initialized");
|
||||
} else {
|
||||
state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?);
|
||||
}
|
||||
println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized");
|
||||
println!("🚀 [CPU YUV] swscale converter initialized");
|
||||
}
|
||||
|
||||
let pipeline = state.readback_pipeline.as_mut().unwrap();
|
||||
let gpu_resources = state.gpu_resources.as_mut().unwrap();
|
||||
let cpu_converter = state.cpu_yuv_converter.as_mut().unwrap();
|
||||
// Exactly one of these is present: cpu_yuv422p10 on the ProRes path, cpu_converter on the
|
||||
// SDR fallback path (or neither is used when the GPU YUV converter is active).
|
||||
let mut cpu_converter = state.cpu_yuv_converter.as_mut();
|
||||
let mut cpu_yuv422p10 = state.cpu_yuv422p10.as_mut();
|
||||
let mut metrics = state.perf_metrics.as_mut();
|
||||
|
||||
// Poll for completed async readbacks (non-blocking)
|
||||
|
|
@ -1604,12 +1626,17 @@ impl ExportOrchestrator {
|
|||
let data = pipeline.extract_rgba_data(result.buffer_id);
|
||||
let extraction_end = Instant::now();
|
||||
|
||||
// YUV planes: GPU-converted (just slice) or CPU swscale fallback (timed).
|
||||
// YUV planes: ProRes 10-bit 4:2:2, else GPU-converted (just slice), else CPU
|
||||
// swscale 8-bit 4:2:0 fallback (timed).
|
||||
let conversion_start = Instant::now();
|
||||
let (y, u, v) = if pipeline.is_yuv_mode() {
|
||||
let (y, u, v) = if let Some(conv) = cpu_yuv422p10.as_deref_mut() {
|
||||
conv.convert(&data)?
|
||||
} else if pipeline.is_yuv_mode() {
|
||||
pipeline.split_yuv(&data)
|
||||
} else {
|
||||
cpu_converter.convert(&data)?
|
||||
cpu_converter.as_deref_mut()
|
||||
.ok_or("SDR export missing its CPU YUV converter")?
|
||||
.convert(&data)?
|
||||
};
|
||||
let conversion_end = Instant::now();
|
||||
|
||||
|
|
@ -1698,6 +1725,7 @@ impl ExportOrchestrator {
|
|||
state.gpu_resources = None;
|
||||
state.readback_pipeline = None;
|
||||
state.cpu_yuv_converter = None;
|
||||
state.cpu_yuv422p10 = None;
|
||||
state.perf_metrics = None;
|
||||
return Ok(false);
|
||||
}
|
||||
|
|
@ -1947,6 +1975,8 @@ impl ExportOrchestrator {
|
|||
// 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 if matches!(settings.codec, VideoCodec::ProRes422) {
|
||||
ffmpeg_next::format::Pixel::YUV422P10LE // ProRes 422: 10-bit 4:2:2
|
||||
} else {
|
||||
ffmpeg_next::format::Pixel::YUV420P
|
||||
};
|
||||
|
|
@ -2061,8 +2091,17 @@ impl ExportOrchestrator {
|
|||
|
||||
// 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 };
|
||||
// Sample size + chroma subsampling depend on the pixel format:
|
||||
// YUV420P → 8-bit, 4:2:0 (chroma = w/2 × h/2)
|
||||
// YUV420P10LE → 10-bit, 4:2:0 (chroma = w/2 × h/2)
|
||||
// YUV422P10LE → 10-bit, 4:2:2 (chroma = w/2 × h, full-height) [ProRes]
|
||||
use ffmpeg_next::format::Pixel;
|
||||
let (sample_bytes, chroma_h_div) = match pixel_format {
|
||||
Pixel::YUV420P => (1usize, 2usize),
|
||||
Pixel::YUV420P10LE => (2usize, 2usize),
|
||||
Pixel::YUV422P10LE => (2usize, 1usize),
|
||||
_ => (1usize, 2usize),
|
||||
};
|
||||
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);
|
||||
|
|
@ -2077,8 +2116,8 @@ impl ExportOrchestrator {
|
|||
};
|
||||
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);
|
||||
copy_plane(&mut video_frame, 1, u_plane, w / 2, h / chroma_h_div);
|
||||
copy_plane(&mut video_frame, 2, v_plane, w / 2, h / chroma_h_div);
|
||||
|
||||
// Set PTS (presentation timestamp) in encoder's time base
|
||||
// Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000)
|
||||
|
|
|
|||
|
|
@ -616,9 +616,12 @@ pub fn setup_video_encoder(
|
|||
// Configure encoder parameters BEFORE opening (critical!)
|
||||
encoder.set_width(aligned_width);
|
||||
encoder.set_height(aligned_height);
|
||||
// HDR encodes 10-bit BT.2020 (limited range); SDR keeps 8-bit full-range BT.709.
|
||||
// ProRes needs 10-bit 4:2:2; HDR needs 10-bit 4:2:0 BT.2020; other SDR is 8-bit 4:2:0.
|
||||
let is_prores = codec_id == ffmpeg::codec::Id::PRORES;
|
||||
if hdr.is_hdr() {
|
||||
encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE);
|
||||
} else if is_prores {
|
||||
encoder.set_format(ffmpeg::format::Pixel::YUV422P10LE);
|
||||
} else {
|
||||
encoder.set_format(ffmpeg::format::Pixel::YUV420P);
|
||||
}
|
||||
|
|
@ -650,6 +653,10 @@ pub fn setup_video_encoder(
|
|||
});
|
||||
color_opts.set("color_primaries", "bt709");
|
||||
color_opts.set("color_trc", "bt709");
|
||||
if is_prores {
|
||||
// prores_ks profile: 3 = HQ (4:2:2 10-bit). Matches the YUV422P10LE frames we feed.
|
||||
color_opts.set("profile", "3");
|
||||
}
|
||||
}
|
||||
|
||||
println!("📐 Video dimensions: {}×{} (aligned to {}×{}){}",
|
||||
|
|
@ -1433,6 +1440,30 @@ mod tests {
|
|||
assert!(v[0] > 128, "V value: {}", v[0]);
|
||||
}
|
||||
|
||||
/// ProRes must actually open with the 10-bit 4:2:2 format we now feed it. Before the fix the
|
||||
/// SDR path handed prores_ks 8-bit YUV420P and `open` failed every time — so this opening
|
||||
/// successfully is the regression guard for "ProRes export always errored".
|
||||
#[test]
|
||||
fn prores_encoder_opens_with_yuv422p10() {
|
||||
ffmpeg::init().unwrap();
|
||||
// Skip cleanly if this ffmpeg build lacks a ProRes encoder (rather than false-fail).
|
||||
if ffmpeg::encoder::find(ffmpeg::codec::Id::PRORES).is_none()
|
||||
&& ffmpeg::encoder::find_by_name("prores_ks").is_none()
|
||||
{
|
||||
eprintln!("prores encoder not present in this ffmpeg build; skipping");
|
||||
return;
|
||||
}
|
||||
let r = setup_video_encoder(
|
||||
ffmpeg::codec::Id::PRORES,
|
||||
640, 480, 30.0, 20_000,
|
||||
lightningbeam_core::export::HdrExportMode::Sdr,
|
||||
false,
|
||||
);
|
||||
assert!(r.is_ok(), "ProRes encoder failed to open: {:?}", r.err());
|
||||
let (encoder, _codec) = r.unwrap();
|
||||
assert_eq!(encoder.format(), ffmpeg::format::Pixel::YUV422P10LE);
|
||||
}
|
||||
|
||||
// NOTE: `rgba_to_yuv420p` rounds dimensions up to multiples of 16 (H.264
|
||||
// macroblock alignment), so its plane lengths are the aligned sizes, not the
|
||||
// tight input dimensions. The former `test_rgba_to_yuv420p_dimensions` and
|
||||
|
|
|
|||
Loading…
Reference in New Issue