From 53ffb7d5283b17b2599dddf57f042bf186ed3775 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 9 Jul 2026 06:55:23 -0400 Subject: [PATCH] Export honesty: real lossy WebP, working ProRes, VP8+audio container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/export/cpu_yuv_converter.rs | 102 ++++++++++ .../src/export/image_exporter.rs | 176 +++++++++++++++++- .../lightningbeam-editor/src/export/mod.rs | 63 +++++-- .../src/export/video_exporter.rs | 33 +++- 4 files changed, 356 insertions(+), 18 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs index bfd4271..54630b4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs @@ -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 { + 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, Vec, Vec), 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() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs index 9352bfe..0695eb1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs @@ -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 = 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 { + 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()); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index a493f79..803abfd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -70,6 +70,11 @@ pub struct VideoExportState { readback_pipeline: Option, /// CPU YUV converter for RGBA→YUV420p conversion cpu_yuv_converter: Option, + /// 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, /// 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) diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index b27694f..8a99d2a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -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