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