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:
Skyler Lehmkuhl 2026-07-09 06:55:23 -04:00
parent c373af461e
commit 53ffb7d528
4 changed files with 356 additions and 18 deletions

View File

@ -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() {

View File

@ -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 0100 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 0100, 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());
}
}

View File

@ -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)

View File

@ -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