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