Skip to main content

alpha2_native/
convert.rs

1//! Frame conversion, colour mapping, and codec options.
2
3use crate::capabilities::{NativeEncoderClass, encoder_class};
4use alpha2_ffmpeg_bridge::{DecodedFrame, DisplayRotation};
5use std::ffi::{CStr, CString};
6
7use alpha2_media::{
8    ChromaLocation, ColorMatrix, ColorMetadata, ColorPrimaries, ColorRange, Container, DecodeError,
9    DecodedVideoFrame, DynamicRange, EncodeError, Orientation, PixelFormat, Plane, Rect, Rotation,
10    SampleAspectRatio, TransferFunction, YuvFormat,
11};
12use alpha2_time::{MediaTime, TICKS_PER_SECOND};
13use rsmpeg::avcodec::AVCodec;
14use rsmpeg::avutil::{AVDictionary, AVFrame, AVRational, av_rescale_q, ra};
15use rsmpeg::ffi;
16use rsmpeg::swscale::SwsContext;
17
18#[allow(clippy::too_many_lines)]
19pub(crate) fn convert_video_frame(
20    frame: &DecodedFrame,
21    codec: ffi::AVCodecID,
22    time_base: AVRational,
23    color: ColorMetadata,
24    scaler: &mut FrameScaler,
25) -> Result<DecodedVideoFrame, DecodeError> {
26    let width = u32::try_from(frame.width).map_err(|_| DecodeError::OutOfRange)?;
27    let height = u32::try_from(frame.height).map_err(|_| DecodeError::OutOfRange)?;
28    let pts = if frame.best_effort_timestamp == ffi::AV_NOPTS_VALUE {
29        frame.pts
30    } else {
31        frame.best_effort_timestamp
32    };
33    let visible_rect = Rect {
34        x: u32::try_from(frame.crop_left).unwrap_or(0),
35        y: u32::try_from(frame.crop_top).unwrap_or(0),
36        width: width.saturating_sub(
37            u32::try_from(frame.crop_left.saturating_add(frame.crop_right)).unwrap_or(0),
38        ),
39        height: height.saturating_sub(
40            u32::try_from(frame.crop_top.saturating_add(frame.crop_bottom)).unwrap_or(0),
41        ),
42    };
43    // YCbCr layouts the renderer samples go as planes (shared when fully written, else copied); the
44    // GPU applies subsampling, depth, crop, rotation and aspect ratio.
45    if let Some(planes) = frame.yuv_planes().map_err(adapter_error)? {
46        let layout = planes.layout;
47        return Ok(DecodedVideoFrame {
48            pts: timestamp(pts, time_base).ok_or(DecodeError::OutOfRange)?,
49            dts: None,
50            duration: timestamp(frame.duration, time_base),
51            visible_rect,
52            sample_aspect_ratio: declared_sar(frame.sample_aspect_ratio).unwrap_or_default(),
53            orientation: Orientation::UPRIGHT,
54            pixel_format: PixelFormat::Yuv(YuvFormat {
55                chroma_shift_x: layout.chroma_shift_x,
56                chroma_shift_y: layout.chroma_shift_y,
57                bit_depth: layout.bit_depth,
58                interleaved_chroma: layout.interleaved_chroma,
59                msb_aligned: layout.msb_aligned,
60                alpha: false,
61            }),
62            chroma_location: decoded_chroma_location(codec, frame.chroma_location),
63            color: Some(color),
64            planes: planes
65                .planes
66                .into_iter()
67                .map(|plane| Plane {
68                    bytes: plane.bytes,
69                    stride: plane.stride,
70                    width: plane.width,
71                    height: plane.height,
72                })
73                .collect(),
74        });
75    }
76    // Converting allocates a whole frame, so refuse unsupported sizes first.
77    if !alpha2_media::frame_size_supported(width, height) {
78        return Err(DecodeError::Unsupported(format!(
79            "a {width}x{height} frame is larger than {} pixels or {} on a side",
80            alpha2_media::MAX_FRAME_PIXELS,
81            alpha2_media::MAX_FRAME_DIMENSION
82        )));
83    }
84    let (pixel_format, planes, color) = if converts_like_rgba8(color) {
85        (
86            PixelFormat::Rgba8,
87            vec![rgba8_plane(frame, color, scaler)?],
88            color,
89        )
90    } else {
91        full_resolution_planes(frame, color, scaler)?
92    };
93    Ok(DecodedVideoFrame {
94        pts: timestamp(pts, time_base).ok_or(DecodeError::OutOfRange)?,
95        dts: None,
96        duration: timestamp(frame.duration, time_base),
97        visible_rect,
98        sample_aspect_ratio: declared_sar(frame.sample_aspect_ratio).unwrap_or_default(),
99        orientation: Orientation::UPRIGHT,
100        pixel_format,
101        // Neither layout is subsampled.
102        chroma_location: ChromaLocation::Unspecified,
103        color: Some(color),
104        planes,
105    })
106}
107
108/// Whether an unsampleable frame of `color` may reach the renderer as 8-bit sRGB RGBA: SDR BT.709,
109/// where conversion is matrix and range alone. Other frames keep their components so the renderer
110/// can still tone-map, convert gamut, or refuse them.
111fn converts_like_rgba8(color: ColorMetadata) -> bool {
112    !color.is_hdr() && color.primaries == ColorPrimaries::Bt709
113}
114
115/// Converts `frame` to one plane of straight 8-bit RGBA with `color`'s matrix and range.
116fn rgba8_plane(
117    frame: &AVFrame,
118    color: ColorMetadata,
119    scaler: &mut FrameScaler,
120) -> Result<Plane, DecodeError> {
121    let width = u32::try_from(frame.width).map_err(|_| DecodeError::OutOfRange)?;
122    let height = u32::try_from(frame.height).map_err(|_| DecodeError::OutOfRange)?;
123    let active = scaler.for_frame(frame, ffi::AV_PIX_FMT_RGBA)?;
124    configure_yuv_to_rgba(active, color).map_err(adapter_error)?;
125    let mut bytes = vec![0; packed_len(width, height, 4)?];
126    alpha2_ffmpeg_bridge::scale_frame_packed(active, frame, ffi::AV_PIX_FMT_RGBA, &mut bytes)
127        .map_err(adapter_error)?;
128    Ok(Plane {
129        bytes: bytes.into(),
130        stride: width.saturating_mul(4),
131        width,
132        height,
133    })
134}
135
136/// Repacks `frame` into 4:4:4 planes at its own depth (alpha last) without converting color, tagged
137/// with the identity matrix. Staying at its own depth avoids swscale's inexact depth rescaling.
138fn full_resolution_planes(
139    frame: &AVFrame,
140    color: ColorMetadata,
141    scaler: &mut FrameScaler,
142) -> Result<(PixelFormat, Vec<Plane>, ColorMetadata), DecodeError> {
143    let width = u32::try_from(frame.width).map_err(|_| DecodeError::OutOfRange)?;
144    let height = u32::try_from(frame.height).map_err(|_| DecodeError::OutOfRange)?;
145    let profile = alpha2_ffmpeg_bridge::sample_profile(frame.format)
146        .ok_or_else(|| DecodeError::Unsupported("unsupported FFmpeg pixel format".into()))?;
147    let depth = match profile.bit_depth {
148        0..=8 => 8,
149        9..=10 => 10,
150        11..=12 => 12,
151        _ => 16,
152    };
153    let target = planar_444_format(profile.is_rgb, profile.has_alpha, depth);
154    let active = scaler.for_frame(frame, target)?;
155    if !profile.is_rgb {
156        // Same matrix and range on both sides: swscale changes only depth, subsampling and layout.
157        let coefficients = sws_coefficients(color.matrix);
158        let full = color.range == ColorRange::Full;
159        alpha2_ffmpeg_bridge::configure_colorspace(active, coefficients, full, coefficients, full)
160            .map_err(adapter_error)?;
161    }
162    let plane_count = if profile.has_alpha { 4 } else { 3 };
163    let sample_bytes = if depth > 8 { 2 } else { 1 };
164    let plane_len = packed_len(width, height, sample_bytes)?;
165    let mut bytes = vec![0; plane_len * plane_count];
166    alpha2_ffmpeg_bridge::scale_frame_packed(active, frame, target, &mut bytes)
167        .map_err(adapter_error)?;
168    let bytes = bytes::Bytes::from(bytes);
169    let stride = width.saturating_mul(u32::try_from(sample_bytes).unwrap_or(2));
170    let plane = |index: usize| Plane {
171        bytes: bytes.slice(index * plane_len..(index + 1) * plane_len),
172        stride,
173        width,
174        height,
175    };
176    // `gbrp` stores green, blue, red; the identity matrix reads red, green, blue.
177    let order: &[usize] = if profile.is_rgb {
178        &[2, 0, 1]
179    } else {
180        &[0, 1, 2]
181    };
182    let mut planes = order.iter().map(|&index| plane(index)).collect::<Vec<_>>();
183    if profile.has_alpha {
184        planes.push(plane(3));
185    }
186    let format = YuvFormat::planar(0, 0, depth);
187    let format = if profile.has_alpha {
188        format.with_alpha()
189    } else {
190        format
191    };
192    let color = if profile.is_rgb {
193        ColorMetadata {
194            matrix: ColorMatrix::Rgb,
195            range: ColorRange::Full,
196            ..color
197        }
198    } else {
199        color
200    };
201    Ok((PixelFormat::Yuv(format), planes, color))
202}
203
204/// The planar 4:4:4 format for RGB or YCbCr, with or without alpha, at
205/// `depth` in native byte order.
206const fn planar_444_format(rgb: bool, alpha: bool, depth: u8) -> ffi::AVPixelFormat {
207    match (rgb, alpha, depth) {
208        (true, false, 8) => ffi::AV_PIX_FMT_GBRP,
209        (true, true, 8) => ffi::AV_PIX_FMT_GBRAP,
210        (true, false, 10) => ffi::AV_PIX_FMT_GBRP10LE,
211        (true, true, 10) => ffi::AV_PIX_FMT_GBRAP10LE,
212        (true, false, 12) => ffi::AV_PIX_FMT_GBRP12LE,
213        (true, true, 12) => ffi::AV_PIX_FMT_GBRAP12LE,
214        (true, false, _) => ffi::AV_PIX_FMT_GBRP16LE,
215        (true, true, _) => ffi::AV_PIX_FMT_GBRAP16LE,
216        (false, false, 8) => ffi::AV_PIX_FMT_YUV444P,
217        (false, true, 8) => ffi::AV_PIX_FMT_YUVA444P,
218        (false, false, 10) => ffi::AV_PIX_FMT_YUV444P10LE,
219        (false, true, 10) => ffi::AV_PIX_FMT_YUVA444P10LE,
220        (false, false, 12) => ffi::AV_PIX_FMT_YUV444P12LE,
221        (false, true, 12) => ffi::AV_PIX_FMT_YUVA444P12LE,
222        (false, false, _) => ffi::AV_PIX_FMT_YUV444P16LE,
223        (false, true, _) => ffi::AV_PIX_FMT_YUVA444P16LE,
224    }
225}
226
227/// The scaler a stream's frames convert with, kept while size and format stay the same.
228///
229/// `sws_getCachedContext` would remake it every frame here, because `FFmpeg` rewrites the stored
230/// flags and source format during init (full chroma interpolation, `RGB0`-style alpha twins).
231#[derive(Default)]
232pub(crate) struct FrameScaler {
233    context: Option<(ScalerShape, SwsContext)>,
234    made: u64,
235}
236
237#[derive(Clone, Copy, Debug, Eq, PartialEq)]
238struct ScalerShape {
239    width: i32,
240    height: i32,
241    source: ffi::AVPixelFormat,
242    target: ffi::AVPixelFormat,
243}
244
245impl FrameScaler {
246    /// The scaler from `frame`'s size and format to `target`, remade only when those change.
247    fn for_frame(
248        &mut self,
249        frame: &AVFrame,
250        target: ffi::AVPixelFormat,
251    ) -> Result<&mut SwsContext, DecodeError> {
252        let shape = ScalerShape {
253            width: frame.width,
254            height: frame.height,
255            source: frame.format,
256            target,
257        };
258        if self
259            .context
260            .as_ref()
261            .is_none_or(|(made_for, _)| *made_for != shape)
262        {
263            self.context = None;
264            let context = SwsContext::get_context(
265                frame.width,
266                frame.height,
267                frame.format,
268                frame.width,
269                frame.height,
270                target,
271                ffi::SWS_BILINEAR,
272                None,
273                None,
274                None,
275            )
276            .ok_or_else(|| DecodeError::Unsupported("unsupported FFmpeg pixel format".into()))?;
277            self.made += 1;
278            self.context = Some((shape, context));
279        }
280        self.context
281            .as_mut()
282            .map(|(_, context)| context)
283            .ok_or_else(|| DecodeError::Unsupported("unsupported FFmpeg pixel format".into()))
284    }
285
286    #[cfg(test)]
287    pub(crate) const fn made(&self) -> u64 {
288        self.made
289    }
290
291    #[cfg(test)]
292    pub(crate) const fn is_empty(&self) -> bool {
293        self.context.is_none()
294    }
295}
296
297fn packed_len(width: u32, height: u32, sample_bytes: usize) -> Result<usize, DecodeError> {
298    usize::try_from(width)
299        .ok()
300        .and_then(|width| {
301            usize::try_from(height)
302                .ok()
303                .and_then(|height| width.checked_mul(height))
304        })
305        .and_then(|pixels| pixels.checked_mul(sample_bytes))
306        .ok_or(DecodeError::OutOfRange)
307}
308
309/// Where the chroma of a `codec` stream or frame sits, given what `FFmpeg` reports.
310///
311/// H.264 infers location type 0 (left) when the SPS declares none (E.2.1), but `FFmpeg` reports
312/// unspecified, which the renderer would sample centred. HEVC already reports left.
313pub(crate) fn decoded_chroma_location(
314    codec: ffi::AVCodecID,
315    location: ffi::AVChromaLocation,
316) -> ChromaLocation {
317    if codec == ffi::AV_CODEC_ID_H264 && location == ffi::AVCHROMA_LOC_UNSPECIFIED {
318        ChromaLocation::Left
319    } else {
320        chroma_location(location)
321    }
322}
323
324pub(crate) fn chroma_location(location: ffi::AVChromaLocation) -> ChromaLocation {
325    match location {
326        ffi::AVCHROMA_LOC_LEFT => ChromaLocation::Left,
327        ffi::AVCHROMA_LOC_CENTER => ChromaLocation::Center,
328        ffi::AVCHROMA_LOC_TOPLEFT => ChromaLocation::TopLeft,
329        _ => ChromaLocation::Unspecified,
330    }
331}
332
333/// Sets `scaler` to convert full-range RGB into `color`'s YCbCr matrix and range.
334pub(crate) fn configure_rgba_to_yuv(
335    scaler: &mut SwsContext,
336    color: ColorMetadata,
337) -> Result<(), alpha2_ffmpeg_bridge::BridgeError> {
338    alpha2_ffmpeg_bridge::configure_colorspace(
339        scaler,
340        i32::try_from(ffi::SWS_CS_ITU709).expect("color coefficient fits i32"),
341        true,
342        sws_coefficients(color.matrix),
343        color.range == ColorRange::Full,
344    )
345}
346
347/// Sets `scaler` to convert `color`'s YCbCr matrix and range into full-range RGB.
348pub(crate) fn configure_yuv_to_rgba(
349    scaler: &mut SwsContext,
350    color: ColorMetadata,
351) -> Result<(), alpha2_ffmpeg_bridge::BridgeError> {
352    alpha2_ffmpeg_bridge::configure_colorspace(
353        scaler,
354        sws_coefficients(color.matrix),
355        color.range == ColorRange::Full,
356        i32::try_from(ffi::SWS_CS_ITU709).expect("color coefficient fits i32"),
357        true,
358    )
359}
360
361pub(crate) fn sws_coefficients(matrix: ColorMatrix) -> i32 {
362    let value = match matrix {
363        ColorMatrix::Bt601 => ffi::SWS_CS_ITU601,
364        ColorMatrix::Bt709 | ColorMatrix::Rgb => ffi::SWS_CS_ITU709,
365        ColorMatrix::Bt2020Ncl => ffi::SWS_CS_BT2020,
366    };
367    i32::try_from(value).expect("color coefficient fits i32")
368}
369
370/// The engine's tick clock as an `FFmpeg` time base.
371pub(crate) fn tick_base() -> AVRational {
372    ra(
373        1,
374        i32::try_from(TICKS_PER_SECOND).expect("tick rate fits i32"),
375    )
376}
377
378/// `frame` as an encoder `AVFrame` built from its own planes, or `None` when it is not I420.
379///
380/// # Errors
381/// For an I420 frame that is cropped or not `width`x`height`.
382pub(crate) fn direct_yuv420p_frame(
383    frame: &DecodedVideoFrame,
384    width: i32,
385    height: i32,
386) -> Result<Option<AVFrame>, EncodeError> {
387    if frame.pixel_format != PixelFormat::Yuv(YuvFormat::I420) {
388        return Ok(None);
389    }
390    if frame.planes.len() != 3
391        || frame.visible_rect.x != 0
392        || frame.visible_rect.y != 0
393        || frame.planes[0].width != u32::try_from(width).unwrap_or(0)
394        || frame.planes[0].height != u32::try_from(height).unwrap_or(0)
395    {
396        return Err(EncodeError::Adapter(
397            "direct YUV encoder input must contain one uncropped coded frame".into(),
398        ));
399    }
400    alpha2_ffmpeg_bridge::yuv420p_frame(
401        &frame.planes[0].bytes,
402        frame.planes[0].stride,
403        &frame.planes[1].bytes,
404        frame.planes[1].stride,
405        &frame.planes[2].bytes,
406        frame.planes[2].stride,
407        width,
408        height,
409    )
410    .map(Some)
411    .map_err(encode_adapter_error)
412}
413
414/// Turns a decoded YUV frame of any layout into the encoder's 8-bit 4:2:0.
415///
416/// `Y'CbCr` keeps its codes and colour, changing only depth and chroma resolution. RGB planes
417/// (identity matrix) are converted into the output's matrix and range like RGBA, gamut untouched.
418/// Alpha is dropped. One stream's frames share one scaler.
419pub(crate) struct Yuv420pConverter {
420    output: ColorMetadata,
421    scaler: Option<(ffi::AVPixelFormat, u32, u32, SwsContext)>,
422}
423
424impl Yuv420pConverter {
425    /// A converter into 4:2:0 of `output`'s matrix and range.
426    pub(crate) const fn new(output: ColorMetadata) -> Self {
427        Self {
428            output,
429            scaler: None,
430        }
431    }
432
433    /// `frame` as YUV420P, `None` for a frame that is not YUV.
434    ///
435    /// # Errors
436    /// For a cropped frame, a size other than the encoder's, or a layout `FFmpeg` cannot represent.
437    pub(crate) fn convert(
438        &mut self,
439        frame: &DecodedVideoFrame,
440        width: i32,
441        height: i32,
442    ) -> Result<Option<AVFrame>, EncodeError> {
443        let PixelFormat::Yuv(format) = frame.pixel_format else {
444            return Ok(None);
445        };
446        let rgb = frame
447            .color
448            .is_some_and(|color| color.matrix == ColorMatrix::Rgb);
449        if format == YuvFormat::I420 && !rgb {
450            return direct_yuv420p_frame(frame, width, height);
451        }
452        let (width, height) = (
453            u32::try_from(width).unwrap_or(0),
454            u32::try_from(height).unwrap_or(0),
455        );
456        let stored = if format.interleaved_chroma { 2 } else { 3 };
457        if frame.visible_rect.x != 0
458            || frame.visible_rect.y != 0
459            || frame.planes.len() < stored
460            || frame.planes[0].width != width
461            || frame.planes[0].height != height
462        {
463            return Err(EncodeError::Adapter(
464                "YUV encoder input must contain one uncropped frame of the encoder's size".into(),
465            ));
466        }
467        let (source_format, planes) = if rgb {
468            if format.chroma_shift_x != 0
469                || format.chroma_shift_y != 0
470                || format.interleaved_chroma
471                || format.msb_aligned
472                || !matches!(format.bit_depth, 8 | 10 | 12 | 16)
473            {
474                return Err(EncodeError::Adapter(format!(
475                    "no FFmpeg pixel format stores red, green, and blue as {format:?}"
476                )));
477            }
478            // `gbrp` stores green, blue, red; the identity matrix's planes are red, green, blue.
479            let planes = [1, 2, 0]
480                .iter()
481                .map(|&index: &usize| (&frame.planes[index].bytes[..], frame.planes[index].stride))
482                .collect::<Vec<_>>();
483            (planar_444_format(true, false, format.bit_depth), planes)
484        } else {
485            let layout = alpha2_ffmpeg_bridge::YuvPlaneLayout {
486                chroma_shift_x: format.chroma_shift_x,
487                chroma_shift_y: format.chroma_shift_y,
488                bit_depth: format.bit_depth,
489                interleaved_chroma: format.interleaved_chroma,
490                msb_aligned: format.msb_aligned,
491            };
492            let source_format =
493                alpha2_ffmpeg_bridge::yuv_pixel_format(layout).ok_or_else(|| {
494                    EncodeError::Adapter(format!("no FFmpeg pixel format stores {format:?}"))
495                })?;
496            let planes = frame.planes[..stored]
497                .iter()
498                .map(|plane| (&plane.bytes[..], plane.stride))
499                .collect::<Vec<_>>();
500            (source_format, planes)
501        };
502        let source = alpha2_ffmpeg_bridge::planar_frame(source_format, &planes, width, height)
503            .map_err(encode_adapter_error)?;
504        if self
505            .scaler
506            .as_ref()
507            .is_none_or(|(cached, w, h, _)| (*cached, *w, *h) != (source_format, width, height))
508        {
509            let (signed_width, signed_height) = (
510                i32::try_from(width).map_err(|_| EncodeError::Adapter("width overflow".into()))?,
511                i32::try_from(height)
512                    .map_err(|_| EncodeError::Adapter("height overflow".into()))?,
513            );
514            let mut scaler = SwsContext::get_context(
515                signed_width,
516                signed_height,
517                source_format,
518                signed_width,
519                signed_height,
520                ffi::AV_PIX_FMT_YUV420P,
521                ffi::SWS_BILINEAR,
522                None,
523                None,
524                None,
525            )
526            .ok_or_else(|| EncodeError::Adapter("cannot create a YUV to YUV420P scaler".into()))?;
527            if rgb {
528                configure_rgba_to_yuv(&mut scaler, self.output).map_err(encode_adapter_error)?;
529            }
530            self.scaler = Some((source_format, width, height, scaler));
531        }
532        let Some((_, _, _, scaler)) = self.scaler.as_mut() else {
533            unreachable!("the scaler was just made");
534        };
535        let mut output = AVFrame::new();
536        output.set_format(ffi::AV_PIX_FMT_YUV420P);
537        output.set_width(source.width);
538        output.set_height(source.height);
539        output.get_buffer(1).map_err(encode_adapter_error)?;
540        scaler
541            .scale_frame(&source, 0, source.height, &mut output)
542            .map_err(encode_adapter_error)?;
543        Ok(Some(output))
544    }
545}
546
547/// `value` in `base` as engine ticks; `None` for `AV_NOPTS_VALUE` or an invalid base.
548pub(crate) fn timestamp(value: i64, base: AVRational) -> Option<MediaTime> {
549    (value != ffi::AV_NOPTS_VALUE && base.den > 0)
550        .then(|| MediaTime::from_ticks(av_rescale_q(value, base, tick_base())))
551}
552
553/// A declared pixel aspect ratio in lowest terms, or `None` when undeclared (`FFmpeg` uses a
554/// numerator of zero or less, and 0/1 by default).
555pub(crate) fn declared_sar(value: AVRational) -> Option<SampleAspectRatio> {
556    SampleAspectRatio::new(
557        u64::try_from(value.num).ok()?,
558        u64::try_from(value.den).ok()?,
559    )
560}
561
562/// A stream's pixel aspect ratio by `av_guess_sample_aspect_ratio`'s rule: the container's, else
563/// the pictures' (codec parameters, or a decoded picture's own), else square.
564pub(crate) fn guess_sar(container: AVRational, pictures: AVRational) -> SampleAspectRatio {
565    declared_sar(container)
566        .or_else(|| declared_sar(pictures))
567        .unwrap_or_default()
568}
569
570/// The rotation a stream's legacy `rotate` metadata tag declares, `Deg0` without one.
571pub(crate) fn stream_rotation(metadata: Option<rsmpeg::avutil::AVDictionaryRef<'_>>) -> Rotation {
572    let Some(metadata) = metadata else {
573        return Rotation::Deg0;
574    };
575    let Some(entry) = metadata.get(c"rotate", None, 0) else {
576        return Rotation::Deg0;
577    };
578    match entry
579        .value()
580        .to_string_lossy()
581        .parse::<i32>()
582        .unwrap_or(0)
583        .rem_euclid(360)
584    {
585        90 => Rotation::Deg90,
586        180 => Rotation::Deg180,
587        270 => Rotation::Deg270,
588        _ => Rotation::Deg0,
589    }
590}
591
592/// The clockwise [`Rotation`] for `FFmpeg`'s counter-clockwise display angle.
593///
594/// `FFmpeg`'s angle turns the other way, so `-display_rotation 90` shows turned 270 degrees.
595pub(crate) fn rotation_from_display(counter_clockwise: f64) -> Option<Rotation> {
596    let normalized = (-counter_clockwise).rem_euclid(360.0);
597    if !(0.5..359.5).contains(&normalized) {
598        Some(Rotation::Deg0)
599    } else if (normalized - 90.0).abs() < 0.5 {
600        Some(Rotation::Deg90)
601    } else if (normalized - 180.0).abs() < 0.5 {
602        Some(Rotation::Deg180)
603    } else if (normalized - 270.0).abs() < 0.5 {
604        Some(Rotation::Deg270)
605    } else {
606        None
607    }
608}
609
610/// The [`Orientation`] a display matrix declares; `None` for an angle that is not a multiple of 90.
611///
612/// `FFmpeg` reads a mirroring matrix as rotation then horizontal flip, which equals flipping before
613/// the opposite turn: Exif `Orientation` 5 is a mirror turned 270 degrees.
614pub(crate) fn orientation_from_display(display: DisplayRotation) -> Option<Orientation> {
615    if display.flipped {
616        rotation_from_display(-display.counter_clockwise_degrees).map(|rotation| Orientation {
617            rotation,
618            mirror: true,
619        })
620    } else {
621        rotation_from_display(display.counter_clockwise_degrees).map(Orientation::rotated)
622    }
623}
624
625/// The display matrix for `orientation`, the inverse of [`orientation_from_display`];
626/// `None` when shown as stored.
627pub(crate) fn display_of(orientation: Orientation) -> Option<DisplayRotation> {
628    if orientation == Orientation::default() {
629        return None;
630    }
631    let clockwise = match orientation.rotation {
632        Rotation::Deg0 => 0.0,
633        Rotation::Deg90 => 90.0,
634        Rotation::Deg180 => 180.0,
635        Rotation::Deg270 => 270.0,
636    };
637    Some(DisplayRotation {
638        counter_clockwise_degrees: if orientation.mirror {
639            clockwise
640        } else {
641            -clockwise
642        },
643        flipped: orientation.mirror,
644    })
645}
646
647/// `hvc1`: an HEVC sample entry with all parameter sets in its `hvcC`.
648const HVC1: u32 = u32::from_le_bytes(*b"hvc1");
649/// `hev1`: parameter sets may also be in the stream.
650const HEV1: u32 = u32::from_le_bytes(*b"hev1");
651
652/// The `FFmpeg` muxer that writes `container`.
653pub(crate) const fn muxer_name(container: Container) -> &'static CStr {
654    match container {
655        Container::Mp4 => c"mp4",
656        Container::Mov => c"mov",
657        Container::Mkv => c"matroska",
658        Container::Webm => c"webm",
659    }
660}
661
662/// The options `container`'s muxer writes its header with.
663///
664/// Matroska and `WebM` get `fflags=+bitexact`, since otherwise `matroskaenc` writes random track
665/// and segment UIDs and a versioned writing application. MP4 and MOV are deterministic already.
666pub(crate) fn muxer_header_options(container: Container) -> Option<AVDictionary> {
667    matches!(container, Container::Mkv | Container::Webm)
668        .then(|| AVDictionary::new(c"fflags", c"+bitexact", 0))
669}
670
671/// The codec tag a stream of `codec_id` carrying `tag` is written to `container` with.
672///
673/// MP4 and MOV: encoded HEVC is `hvc1` (what browsers and Apple players expect), a copied HEVC
674/// stream keeps its entry, other copied streams are cleared for the muxer to pick, and encoded
675/// streams keep the encoder's tag. Matroska and `WebM` always clear it, since a foreign fourcc can
676/// make the muxer refuse the stream.
677pub(crate) fn container_codec_tag(
678    container: Container,
679    codec_id: ffi::AVCodecID,
680    tag: u32,
681    copied: bool,
682) -> u32 {
683    if matches!(container, Container::Mkv | Container::Webm) {
684        0
685    } else if codec_id == ffi::AV_CODEC_ID_HEVC {
686        if tag == HVC1 || tag == HEV1 {
687            tag
688        } else {
689            HVC1
690        }
691    } else if copied {
692        0
693    } else {
694        tag
695    }
696}
697
698pub(crate) fn color_metadata(
699    parameters: &rsmpeg::avcodec::AVCodecParameters,
700) -> Option<ColorMetadata> {
701    map_color(
702        parameters.color_primaries,
703        parameters.color_trc,
704        parameters.color_space,
705        parameters.color_range,
706    )
707}
708
709pub(crate) fn frame_color_metadata(frame: &AVFrame) -> Option<ColorMetadata> {
710    map_color(
711        frame.color_primaries,
712        frame.color_trc,
713        frame.colorspace,
714        frame.color_range,
715    )
716}
717
718/// The color `FFmpeg`'s tags describe, `None` when all four are unspecified.
719///
720/// An unrecognized tag, or an unspecified one beside others, reads as its BT.709 limited-range
721/// default; PQ and HLG make the color HDR.
722pub(crate) fn map_color(
723    primaries: ffi::AVColorPrimaries,
724    transfer: ffi::AVColorTransferCharacteristic,
725    matrix: ffi::AVColorSpace,
726    range: ffi::AVColorRange,
727) -> Option<ColorMetadata> {
728    if primaries == ffi::AVCOL_PRI_UNSPECIFIED
729        && transfer == ffi::AVCOL_TRC_UNSPECIFIED
730        && matrix == ffi::AVCOL_SPC_UNSPECIFIED
731        && range == ffi::AVCOL_RANGE_UNSPECIFIED
732    {
733        return None;
734    }
735    let primaries = match primaries {
736        ffi::AVCOL_PRI_BT2020 => ColorPrimaries::Bt2020,
737        ffi::AVCOL_PRI_SMPTE170M | ffi::AVCOL_PRI_BT470BG => ColorPrimaries::Bt601,
738        _ => ColorPrimaries::Bt709,
739    };
740    let transfer = match transfer {
741        ffi::AVCOL_TRC_SMPTE2084 => TransferFunction::Pq,
742        ffi::AVCOL_TRC_ARIB_STD_B67 => TransferFunction::Hlg,
743        ffi::AVCOL_TRC_IEC61966_2_1 => TransferFunction::Srgb,
744        _ => TransferFunction::Bt709,
745    };
746    let matrix = match matrix {
747        ffi::AVCOL_SPC_BT2020_NCL => ColorMatrix::Bt2020Ncl,
748        ffi::AVCOL_SPC_SMPTE170M | ffi::AVCOL_SPC_BT470BG => ColorMatrix::Bt601,
749        ffi::AVCOL_SPC_RGB => ColorMatrix::Rgb,
750        _ => ColorMatrix::Bt709,
751    };
752    Some(ColorMetadata {
753        primaries,
754        transfer,
755        matrix,
756        range: if range == ffi::AVCOL_RANGE_JPEG {
757            ColorRange::Full
758        } else {
759            ColorRange::Limited
760        },
761        dynamic_range: if matches!(transfer, TransferFunction::Pq | TransferFunction::Hlg) {
762            DynamicRange::Hdr
763        } else {
764            DynamicRange::Sdr
765        },
766    })
767}
768
769/// Options every video decoder opens with.
770///
771/// `flags=+unaligned` lets `FFmpeg` crop software frames exactly; without it a small left crop
772/// (under 64 columns of 8-bit 4:2:0) is dropped to keep planes aligned. Hardware frames keep their
773/// crop as the visible rectangle either way.
774pub(crate) fn video_decoder_options(threads: i64) -> AVDictionary {
775    AVDictionary::new_int(c"threads", threads, 0).set(c"flags", c"+unaligned", 0)
776}
777
778/// The `FFmpeg` pixel format of `layout`.
779pub(crate) fn pixel_format(layout: alpha2_media::PixelLayout) -> ffi::AVPixelFormat {
780    match layout.ffmpeg_name() {
781        "yuva420p" => ffi::AV_PIX_FMT_YUVA420P,
782        "yuv420p10le" => ffi::AV_PIX_FMT_YUV420P10LE,
783        "yuva420p10le" => ffi::AV_PIX_FMT_YUVA420P10LE,
784        "yuv422p" => ffi::AV_PIX_FMT_YUV422P,
785        "yuva422p" => ffi::AV_PIX_FMT_YUVA422P,
786        "yuv422p10le" => ffi::AV_PIX_FMT_YUV422P10LE,
787        "yuva422p10le" => ffi::AV_PIX_FMT_YUVA422P10LE,
788        "yuv444p" => ffi::AV_PIX_FMT_YUV444P,
789        "yuva444p" => ffi::AV_PIX_FMT_YUVA444P,
790        "yuv444p10le" => ffi::AV_PIX_FMT_YUV444P10LE,
791        "yuva444p10le" => ffi::AV_PIX_FMT_YUVA444P10LE,
792        "p010le" => ffi::AV_PIX_FMT_P010LE,
793        _ => ffi::AV_PIX_FMT_YUV420P,
794    }
795}
796
797/// Visible bytes per row and rows of each plane of `layout`, in `FFmpeg`'s plane order.
798#[must_use]
799pub(crate) fn frame_planes(
800    layout: alpha2_media::PixelLayout,
801    width: u32,
802    height: u32,
803) -> Vec<(usize, usize)> {
804    layout
805        .planes(width, height)
806        .into_iter()
807        .map(|(plane_width, rows, bytes)| ((plane_width * bytes) as usize, rows as usize))
808        .collect()
809}
810
811/// A reusable encoder frame for `width` x `height` pictures of `layout`, its planes in
812/// the layout's storage order.
813///
814/// # Errors
815/// Returns an error for a size that does not fit a frame or a failed allocation.
816pub fn packed_frame(
817    width: u32,
818    height: u32,
819    layout: alpha2_media::PixelLayout,
820) -> Result<alpha2_ffmpeg_bridge::Yuv420pFrameBuffer, alpha2_ffmpeg_bridge::BridgeError> {
821    let (coded_width, coded_height) = (
822        i32::try_from(width)
823            .map_err(|_| alpha2_ffmpeg_bridge::BridgeError::InvalidInput("width"))?,
824        i32::try_from(height)
825            .map_err(|_| alpha2_ffmpeg_bridge::BridgeError::InvalidInput("height"))?,
826    );
827    if layout.is_yuv420p() {
828        return alpha2_ffmpeg_bridge::Yuv420pFrameBuffer::new(coded_width, coded_height);
829    }
830    alpha2_ffmpeg_bridge::Yuv420pFrameBuffer::with_format(
831        coded_width,
832        coded_height,
833        pixel_format(layout),
834        &frame_planes(layout, width, height),
835    )
836}
837
838pub(crate) fn prores_options(layout: alpha2_media::PixelLayout) -> Vec<(&'static CStr, String)> {
839    let mut options = vec![
840        (
841            c"profile",
842            if layout.alpha { "4444" } else { "hq" }.to_owned(),
843        ),
844        (c"vendor", "apl0".to_owned()),
845    ];
846    if layout.alpha {
847        options.push((c"alpha_bits", "16".to_owned()));
848    }
849    options
850}
851
852pub(crate) fn video_encoder_options(
853    color: ColorMetadata,
854    codec: &AVCodec,
855    on_hardware: bool,
856    threads: i64,
857    width: u32,
858) -> AVDictionary {
859    let mut options = color_options(color);
860    // A hybrid encoder is a software transform unless told otherwise, and only hybrid encoders have
861    // `hw_encoding`, so setting it elsewhere would trip `reject_unused_encode_options`.
862    if encoder_class(codec) == NativeEncoderClass::Hybrid {
863        options = options.set_int(c"hw_encoding", i64::from(on_hardware), 0);
864    }
865    if on_hardware {
866        return options;
867    }
868    let name = codec.name().to_string_lossy();
869    for (key, value) in software_encoder_profile(&name, width, threads) {
870        options = set_encoder_option(options, key, &value);
871    }
872    options.set_int(c"threads", threads, 0)
873}
874
875/// Speed and structure options per software encoder, fixed so exports are reproducible.
876///
877/// - `libvpx-vp9`: `good` deadline, `cpu-used` 4, row multithreading, one tile column per 256
878///   pixels of width, alt-ref frames over a 25-frame look-ahead. Same bytes at any thread count.
879/// - `libsvtav1`: preset 8. SVT-AV1 sizes threads from a parallelism level, set to what it would
880///   pick for `threads` cores. Above level 1 picture order depends on thread timing, so
881///   [`deterministic_encode`] forces level 1.
882fn software_encoder_profile(name: &str, width: u32, threads: i64) -> Vec<(&'static CStr, String)> {
883    match name {
884        "libvpx-vp9" => vec![
885            (c"deadline", "good".into()),
886            (c"cpu-used", "4".into()),
887            (c"row-mt", "1".into()),
888            (c"tile-columns", vp9_tile_columns_log2(width).to_string()),
889            (c"lag-in-frames", "25".into()),
890            (c"auto-alt-ref", "1".into()),
891        ],
892        "libsvtav1" => vec![
893            (c"preset", "8".into()),
894            (
895                c"svtav1-params",
896                format!(
897                    "lp={}",
898                    if deterministic_encode() {
899                        1
900                    } else {
901                        svt_av1_parallelism(threads)
902                    }
903                ),
904            ),
905        ],
906        _ => Vec::new(),
907    }
908}
909
910/// Whether `ALPHA2_DETERMINISTIC_ENCODE=1` asks for byte-identical software encodes at the cost of
911/// speed. Only `libsvtav1` needs it.
912pub(crate) fn deterministic_encode() -> bool {
913    std::env::var_os("ALPHA2_DETERMINISTIC_ENCODE").is_some_and(|value| value == "1")
914}
915
916/// libvpx `tile-columns`: log2 of the tile columns for `width`, each at least 256 pixels.
917pub(crate) fn vp9_tile_columns_log2(width: u32) -> u32 {
918    (width / 256).max(1).ilog2().min(6)
919}
920
921/// SVT-AV1's parallelism level for `threads` workers, matching its own default for that many cores
922/// (1 to 6 for 1, 2, 3-5, 6-11, 12-23, and 24+ cores).
923pub(crate) fn svt_av1_parallelism(threads: i64) -> u32 {
924    match threads {
925        ..=1 => 1,
926        2 => 2,
927        3..=5 => 3,
928        6..=11 => 4,
929        12..=23 => 5,
930        _ => 6,
931    }
932}
933
934/// Sets `key` to `value` in an encoder's options. A list-valued option already set
935/// (`svtav1-params`, `:`-separated pairs) is appended to.
936pub(crate) fn set_encoder_option(options: AVDictionary, key: &CStr, value: &str) -> AVDictionary {
937    let value = CString::new(value).unwrap_or_default();
938    if key == c"svtav1-params" && options.get(key, None, 0).is_some() {
939        let appended = CString::new(format!(":{}", value.to_string_lossy())).unwrap_or_default();
940        options.set(key, &appended, ffi::AV_DICT_APPEND)
941    } else {
942        options.set(key, &value, 0)
943    }
944}
945
946/// Returns the worker budget for one standalone software video codec context.
947///
948/// Capped at 16 workers; by default two cores are reserved for non-codec work. Exports split up to
949/// 64 workers across contexts with [`codec_thread_allocation`].
950#[must_use]
951pub fn codec_thread_budget() -> i64 {
952    i64::try_from(configured_codec_thread_budget().min(16)).unwrap_or(1)
953}
954
955/// The software-codec worker budget `ALPHA2_FFMPEG_THREADS` sets ([`codec_thread_override`]).
956///
957/// # Errors
958/// Returns the setting when it is not a positive count; the default then applies, and callers
959/// reporting the budget's origin should not call it explicit.
960pub fn configured_codec_thread_override() -> Result<Option<usize>, String> {
961    codec_thread_override(std::env::var_os("ALPHA2_FFMPEG_THREADS").as_deref())
962}
963
964/// The worker budget `setting` names: `None` unset, or a positive count clamped to 64.
965///
966/// # Errors
967/// Returns the setting, lossily as text, when it is not a positive count.
968pub fn codec_thread_override(setting: Option<&std::ffi::OsStr>) -> Result<Option<usize>, String> {
969    let Some(setting) = setting else {
970        return Ok(None);
971    };
972    setting
973        .to_str()
974        .and_then(|value| value.parse::<usize>().ok())
975        .filter(|value| *value > 0)
976        .map(|value| Some(value.min(64)))
977        .ok_or_else(|| setting.to_string_lossy().into_owned())
978}
979
980fn configured_codec_thread_budget() -> usize {
981    configured_codec_thread_override()
982        .ok()
983        .flatten()
984        .unwrap_or_else(|| {
985            codec_thread_budget_for(
986                std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get),
987            )
988        })
989        .clamp(1, 64)
990}
991
992/// Codec workers for an export on `hardware_threads`: all but
993/// [`codec_thread_reserve`], at least one.
994pub(crate) fn codec_thread_budget_for(hardware_threads: usize) -> usize {
995    hardware_threads
996        .saturating_sub(codec_thread_reserve(hardware_threads))
997        .max(1)
998}
999
1000/// Hardware threads left to the renderer, coordinator and system: two on 16+
1001/// threads, one on 6-15, none below.
1002///
1003/// On a 4-core/8-thread laptop with two 4K software H.264 decodes, reserving two (and an encoder
1004/// floor of four) left `h264_mf` exports waiting on decode and about 50 % slower per frame.
1005pub(crate) const fn codec_thread_reserve(hardware_threads: usize) -> usize {
1006    match hardware_threads {
1007        16.. => 2,
1008        6..=15 => 1,
1009        _ => 0,
1010    }
1011}
1012
1013/// What one export needs from the shared software-codec worker budget.
1014#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1015pub struct CodecThreadRequest {
1016    /// Decoder contexts the pool may run at once.
1017    pub decode_concurrency: usize,
1018    /// Whether decode sessions produce device-resident frames.
1019    pub hardware_decode: bool,
1020    /// Luma samples in the largest video source, or zero when unknown.
1021    pub source_frame_pixels: u64,
1022    /// Whether the video encoder draws on this budget.
1023    pub software_video_encoder: bool,
1024    /// Workers a software encoder runs regardless of the offer, when the picture
1025    /// fixes them (`OpenH264`'s one thread per slice). The split reserves exactly
1026    /// that and gives decode the rest.
1027    pub encoder_fixed_threads: Option<usize>,
1028}
1029
1030/// Luma samples one software decoder worker is sized to carry.
1031const DECODE_PIXELS_PER_THREAD: u64 = 2_000_000;
1032/// Smallest worker count a software decode session is given.
1033const MIN_SOFTWARE_DECODER_THREADS: usize = 2;
1034/// Largest worker count a software decode session is given. Frame threading stops paying above this
1035/// on one stream, and extra workers only contend with the encoder.
1036const MAX_SOFTWARE_DECODER_THREADS: usize = 6;
1037/// Workers the software video encoder keeps on budgets of 12 or more; a third of
1038/// smaller budgets ([`encoder_thread_floor`]).
1039const ENCODER_THREAD_FLOOR: usize = 4;
1040
1041/// The encoder's floor: four, or a third of a budget under 12 so small machines'
1042/// decoders are not starved.
1043const fn encoder_thread_floor(total_threads: usize) -> usize {
1044    let third = total_threads / 3;
1045    if third < ENCODER_THREAD_FLOOR {
1046        if third == 0 { 1 } else { third }
1047    } else {
1048        ENCODER_THREAD_FLOOR
1049    }
1050}
1051/// `FFmpeg` workers a hardware decode session opens with, whatever the budget.
1052///
1053/// Hardware sessions decode on the device; workers only parse and submit, and each extra frame
1054/// thread holds back a packet that short cuts fill and drop. On a discrete GPU, an even share (22
1055/// workers) took nearly twice as long on a many-cut project as two to four. One worker doubles a
1056/// long source's decode time. The count changes no output byte. `VideoToolbox` and D3D12VA use
1057/// the same count unmeasured.
1058pub const HARDWARE_DECODER_THREADS: usize = 2;
1059
1060/// One export-wide software-codec allocation: `decoder_threads` per software decoder,
1061/// `hardware_decoder_threads` per hardware one, `encoder_threads` for the software encoder.
1062#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1063pub struct CodecThreadAllocation {
1064    /// Total software codec workers available to this export.
1065    pub total_threads: usize,
1066    /// Number of decoder contexts allowed to execute concurrently.
1067    pub decode_concurrency: usize,
1068    /// `FFmpeg` workers per software decoder context, including a hardware session's fallback.
1069    pub decoder_threads: i64,
1070    /// `FFmpeg` workers a hardware decode session opens with.
1071    pub hardware_decoder_threads: i64,
1072    /// `FFmpeg` worker count assigned to the software video encoder, or zero for hardware.
1073    pub encoder_threads: i64,
1074    /// Workers this allocation actually hands out, decoders plus encoder.
1075    pub allocated_threads: usize,
1076}
1077
1078/// Divides the export CPU budget across concurrently active codec contexts.
1079///
1080/// The budget is an export-wide cap and each context needs a worker, so decoder concurrency is
1081/// reduced first. Hardware video encoding does not consume this budget.
1082///
1083/// With a software encoder, which paces the run, software decoders are sized to their frame area
1084/// and the encoder takes the remainder above a floor. With a hardware encoder the even split stays,
1085/// since cutting decode workers only made decode fall behind the renderer.
1086///
1087/// Hardware decode sessions open with [`HARDWARE_DECODER_THREADS`], but their share is still
1088/// reserved for a software fallback, which also keeps the encoder's worker count (and output)
1089/// independent of how sources decode.
1090#[must_use]
1091pub fn codec_thread_allocation(request: CodecThreadRequest) -> CodecThreadAllocation {
1092    codec_thread_allocation_within(request, configured_codec_thread_budget())
1093}
1094
1095/// [`codec_thread_allocation`] out of a budget of `configured` workers.
1096pub(crate) fn codec_thread_allocation_within(
1097    request: CodecThreadRequest,
1098    configured: usize,
1099) -> CodecThreadAllocation {
1100    let encoder_slots = usize::from(request.software_video_encoder);
1101    let decode_concurrency = request
1102        .decode_concurrency
1103        .max(1)
1104        .min(configured.saturating_sub(encoder_slots).max(1));
1105    let total_threads = configured.max(decode_concurrency.saturating_add(encoder_slots));
1106    // Reserve the encoder floor before sizing decode, so a small budget
1107    // starves the stage with slack.
1108    let fixed_encoder = request
1109        .encoder_fixed_threads
1110        .filter(|_| request.software_video_encoder)
1111        .map(|threads| threads.max(1));
1112    let encoder_floor = match fixed_encoder {
1113        Some(threads) => threads.min(total_threads.saturating_sub(decode_concurrency).max(1)),
1114        None if request.software_video_encoder => encoder_thread_floor(total_threads)
1115            .min(total_threads.saturating_sub(decode_concurrency).max(1)),
1116        None => 0,
1117    };
1118    let decode_budget = total_threads
1119        .saturating_sub(encoder_floor)
1120        .max(decode_concurrency);
1121    let even_share = if fixed_encoder.is_some() {
1122        // The encoder takes what it runs; decode shares the rest.
1123        (decode_budget / decode_concurrency).max(1)
1124    } else {
1125        let contexts = decode_concurrency.saturating_add(encoder_slots).max(1);
1126        (total_threads / contexts).max(1)
1127    };
1128    let decoder_threads = if request.hardware_decode || !request.software_video_encoder {
1129        // A hardware-decode fallback keeps the even share, and with a hardware encoder nothing
1130        // could use what decode gives up.
1131        even_share
1132    } else {
1133        // Never above the even share: on a small budget one large source would otherwise leave the
1134        // encoder fewer workers than a decoder.
1135        software_decoder_threads(request.source_frame_pixels)
1136            .clamp(1, (decode_budget / decode_concurrency).max(1))
1137            .min(even_share)
1138    };
1139    let decode_assigned = decoder_threads.saturating_mul(decode_concurrency);
1140    // Taking the remainder rather than the floor keeps the total inside the budget
1141    // when decode could not yield.
1142    let encoder_threads = match fixed_encoder {
1143        Some(threads) => threads,
1144        None if request.software_video_encoder => {
1145            total_threads.saturating_sub(decode_assigned).max(1)
1146        }
1147        None => 0,
1148    };
1149    CodecThreadAllocation {
1150        total_threads,
1151        decode_concurrency,
1152        decoder_threads: i64::try_from(decoder_threads).unwrap_or(1).clamp(1, 64),
1153        hardware_decoder_threads: i64::try_from(HARDWARE_DECODER_THREADS.min(total_threads))
1154            .unwrap_or(1)
1155            .max(1),
1156        encoder_threads: i64::try_from(encoder_threads).unwrap_or(0).clamp(0, 64),
1157        allocated_threads: decode_assigned.saturating_add(encoder_threads),
1158    }
1159}
1160
1161/// Workers one software decode session needs for a frame of this area. Zero (unprobed) takes the
1162/// minimum, since decoders are not the stage with the deadline.
1163fn software_decoder_threads(source_frame_pixels: u64) -> usize {
1164    usize::try_from(source_frame_pixels.div_ceil(DECODE_PIXELS_PER_THREAD))
1165        .unwrap_or(MAX_SOFTWARE_DECODER_THREADS)
1166        .clamp(MIN_SOFTWARE_DECODER_THREADS, MAX_SOFTWARE_DECODER_THREADS)
1167}
1168
1169/// The option names an `FFmpeg` open left unconsumed in its dictionary.
1170pub(crate) fn unused_option_names(options: Option<AVDictionary>) -> Option<String> {
1171    let options = options?;
1172    let names = options
1173        .iter()
1174        .map(|entry| entry.key().to_string_lossy().into_owned())
1175        .collect::<Vec<_>>();
1176    (!names.is_empty()).then(|| names.join(", "))
1177}
1178
1179pub(crate) fn reject_unused_decode_options(
1180    options: Option<AVDictionary>,
1181) -> Result<(), DecodeError> {
1182    if let Some(names) = unused_option_names(options) {
1183        Err(DecodeError::Adapter(format!(
1184            "FFmpeg decoder did not consume options: {names}"
1185        )))
1186    } else {
1187        Ok(())
1188    }
1189}
1190
1191pub(crate) fn reject_unused_encode_options(
1192    options: Option<AVDictionary>,
1193) -> Result<(), EncodeError> {
1194    if let Some(names) = unused_option_names(options) {
1195        Err(EncodeError::Adapter(format!(
1196            "FFmpeg encoder did not consume options: {names}"
1197        )))
1198    } else {
1199        Ok(())
1200    }
1201}
1202
1203/// The tags an encoder writes for `color`, as `FFmpeg` (ITU-T H.273) numbers them.
1204///
1205/// The single table behind [`color_options`], a copy's source check, and
1206/// the export bitstream filter.
1207pub(crate) fn color_tags(
1208    color: ColorMetadata,
1209) -> (
1210    ffi::AVColorPrimaries,
1211    ffi::AVColorTransferCharacteristic,
1212    ffi::AVColorSpace,
1213    ffi::AVColorRange,
1214) {
1215    (
1216        match color.primaries {
1217            ColorPrimaries::Bt601 => ffi::AVCOL_PRI_SMPTE170M,
1218            ColorPrimaries::Bt709 => ffi::AVCOL_PRI_BT709,
1219            ColorPrimaries::Bt2020 => ffi::AVCOL_PRI_BT2020,
1220        },
1221        match color.transfer {
1222            TransferFunction::Bt709 => ffi::AVCOL_TRC_BT709,
1223            TransferFunction::Srgb => ffi::AVCOL_TRC_IEC61966_2_1,
1224            TransferFunction::Pq => ffi::AVCOL_TRC_SMPTE2084,
1225            TransferFunction::Hlg => ffi::AVCOL_TRC_ARIB_STD_B67,
1226        },
1227        match color.matrix {
1228            ColorMatrix::Bt601 => ffi::AVCOL_SPC_SMPTE170M,
1229            ColorMatrix::Bt709 => ffi::AVCOL_SPC_BT709,
1230            ColorMatrix::Bt2020Ncl => ffi::AVCOL_SPC_BT2020_NCL,
1231            ColorMatrix::Rgb => ffi::AVCOL_SPC_RGB,
1232        },
1233        match color.range {
1234            ColorRange::Limited => ffi::AVCOL_RANGE_MPEG,
1235            ColorRange::Full => ffi::AVCOL_RANGE_JPEG,
1236        },
1237    )
1238}
1239
1240/// The colour options an encoder opens with, from [`color_tags`], so encoder fields, the `colr` box
1241/// and the bitstream filter's VUI ([`crate::bitstream`]) agree.
1242pub(crate) fn color_options(color: ColorMetadata) -> AVDictionary {
1243    let (primaries, transfer, matrix, range) = color_tags(color);
1244    AVDictionary::new_int(c"color_primaries", i64::from(primaries), 0)
1245        .set_int(c"color_trc", i64::from(transfer), 0)
1246        .set_int(c"colorspace", i64::from(matrix), 0)
1247        .set_int(c"color_range", i64::from(range), 0)
1248}
1249
1250/// The device fault in a failed native GPU call's status, if `error` is one.
1251fn gpu_fault(error: &dyn std::any::Any) -> Option<alpha2_media::GpuFault> {
1252    error
1253        .downcast_ref::<alpha2_ffmpeg_bridge::BridgeError>()
1254        .and_then(alpha2_ffmpeg_bridge::BridgeError::gpu_fault)
1255}
1256
1257/// `error` as a decoder adapter failure, keeping a GPU fault typed ([`DecodeError::Gpu`]).
1258pub(crate) fn adapter_error(error: impl std::fmt::Display + 'static) -> DecodeError {
1259    match gpu_fault(&error) {
1260        Some(fault) => DecodeError::Gpu {
1261            fault,
1262            message: error.to_string(),
1263        },
1264        None => DecodeError::Adapter(error.to_string()),
1265    }
1266}
1267
1268/// `error` as an encoder adapter failure, keeping a GPU fault typed ([`EncodeError::Gpu`]).
1269pub(crate) fn encode_adapter_error(error: impl std::fmt::Display + 'static) -> EncodeError {
1270    match gpu_fault(&error) {
1271        Some(fault) => EncodeError::Gpu {
1272            fault,
1273            message: error.to_string(),
1274        },
1275        None => EncodeError::Adapter(error.to_string()),
1276    }
1277}
1278
1279pub(crate) fn encode_adapter_error_at(
1280    context: &str,
1281    error: impl std::fmt::Display + 'static,
1282) -> EncodeError {
1283    match gpu_fault(&error) {
1284        Some(fault) => EncodeError::Gpu {
1285            fault,
1286            message: format!("{context}: {error}"),
1287        },
1288        None => EncodeError::Adapter(format!("{context}: {error}")),
1289    }
1290}