Skip to main content

mediadecode_ffmpeg/convert/
mod.rs

1//! Conversion helpers from FFmpeg `AVFrame` / `AVPacket` to the
2//! `mediadecode` types parameterized by [`crate::Ffmpeg`] and
3//! [`crate::FfmpegBuffer`].
4//!
5//! The video-frame conversion is **zero-copy**: each plane is exposed
6//! as an `FfmpegBuffer` view into the underlying `AVBufferRef`, so the
7//! FFmpeg-allocated pixel memory is shared between the source frame
8//! and the produced `VideoFrame`. Cloning the resulting `VideoFrame`
9//! bumps refcounts; dropping releases them.
10use core::ptr::{addr_of, read_unaligned};
11
12use derive_more::{IsVariant, TryUnwrap, Unwrap};
13use ffmpeg_next::ffi::{
14  AV_NOPTS_VALUE, AVChromaLocation, AVColorPrimaries, AVColorRange, AVColorSpace,
15  AVColorTransferCharacteristic, AVFrame, AVPictureType, AVSubtitleType, av_buffer_alloc,
16};
17use mediadecode::{
18  PixelFormat, Timebase, Timestamp,
19  color::{ChromaLocation, ColorInfo, ColorMatrix, ColorPrimaries, ColorRange, ColorTransfer},
20  frame::{AudioFrame, Dimensions, Plane, Rect, SubtitleFrame, VideoFrame},
21  subtitle::{Bitmap as SubtitleBitmap, SubtitlePayload, Text as SubtitleText},
22};
23use mediaframe::audio::ChannelLayoutDescription;
24use smol_str::SmolStr;
25
26use crate::{
27  FfmpegBuffer, boundary,
28  extras::{AudioFrameExtra, PictureType, SideDataEntry, SubtitleFrameExtra, VideoFrameExtra},
29  pixdesc,
30  sample_format::SampleFormat,
31};
32
33/// Payload for [`ConvertError::UnsupportedPixelFormat`].
34///
35/// The frame's pixel format isn't in the closed CPU-format set this
36/// crate supports for safe per-plane access.
37#[derive(Debug, Clone)]
38pub struct UnsupportedPixelFormat {
39  format: PixelFormat,
40  raw: i32,
41  name: Option<SmolStr>,
42}
43
44impl UnsupportedPixelFormat {
45  /// Constructs an `UnsupportedPixelFormat` payload.
46  #[inline]
47  pub const fn new(format: PixelFormat, raw: i32, name: Option<SmolStr>) -> Self {
48    Self { format, raw, name }
49  }
50
51  /// The unified vocabulary's answer for [`Self::raw`].
52  ///
53  /// [`PixelFormat::None`] whenever the raw integer has no mapping — a
54  /// hardware surface, a Bayer mosaic, a format FFmpeg gained after
55  /// this build. That is a *value*, not a failed lookup, and it is
56  /// deliberately not made to carry the integer: [`Self::raw`] and
57  /// [`Self::name`] are where the identity survives.
58  #[inline]
59  pub const fn format(&self) -> &PixelFormat {
60    &self.format
61  }
62  /// The raw `AVFrame.format` integer, exactly as FFmpeg wrote it.
63  ///
64  /// Present at every tier — it costs one `i32` — because it is the
65  /// only field that is always available and always precise. Without
66  /// it the message for the fall-through case says `None` and names
67  /// nothing at all.
68  #[inline]
69  pub const fn raw(&self) -> i32 {
70    self.raw
71  }
72  /// FFmpeg's own name for [`Self::raw`] (`av_get_pix_fmt_name`), when
73  /// libavutil has one.
74  ///
75  /// `None` for an integer libavutil does not describe — a corrupt
76  /// read, or a format from a newer library than the one linked.
77  #[inline]
78  pub fn name(&self) -> Option<&str> {
79    self.name.as_deref()
80  }
81}
82
83impl core::fmt::Display for UnsupportedPixelFormat {
84  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
85    match &self.name {
86      Some(name) => write!(
87        f,
88        "convert: unsupported pixel format {:?} (AVPixelFormat {} = {name:?})",
89        self.format, self.raw
90      ),
91      None => write!(
92        f,
93        "convert: unsupported pixel format {:?} (AVPixelFormat {}, unnamed by libavutil)",
94        self.format, self.raw
95      ),
96    }
97  }
98}
99
100/// Payload for [`ConvertError::InvalidPlaneLayout`].
101///
102/// A plane reported `linesize <= 0` or otherwise inconsistent layout.
103#[derive(Debug, Clone, Copy)]
104pub struct InvalidPlaneLayout {
105  plane: usize,
106}
107
108impl InvalidPlaneLayout {
109  /// Constructs an `InvalidPlaneLayout` payload.
110  #[inline]
111  pub const fn new(plane: usize) -> Self {
112    Self { plane }
113  }
114  /// Plane index.
115  #[inline]
116  pub const fn plane(&self) -> usize {
117    self.plane
118  }
119}
120
121impl core::fmt::Display for InvalidPlaneLayout {
122  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
123    write!(f, "convert: invalid layout on plane {}", self.plane)
124  }
125}
126
127/// Payload for [`ConvertError::BufferAcquireFailed`].
128///
129/// Failed to acquire an `AVBufferRef` for a plane (out of memory, or
130/// the frame's `data[i]` pointer doesn't lie inside any of `buf[]`).
131#[derive(Debug, Clone, Copy)]
132pub struct BufferAcquireFailed {
133  plane: usize,
134}
135
136impl BufferAcquireFailed {
137  /// Constructs a `BufferAcquireFailed` payload.
138  #[inline]
139  pub const fn new(plane: usize) -> Self {
140    Self { plane }
141  }
142  /// Plane index whose buffer couldn't be acquired.
143  #[inline]
144  pub const fn plane(&self) -> usize {
145    self.plane
146  }
147}
148
149impl core::fmt::Display for BufferAcquireFailed {
150  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
151    write!(
152      f,
153      "convert: could not acquire buffer ref for plane {}",
154      self.plane
155    )
156  }
157}
158
159/// Errors from [`av_frame_to_video_frame`].
160#[derive(Debug, Clone, IsVariant, Unwrap, TryUnwrap)]
161#[non_exhaustive]
162#[unwrap(ref, ref_mut)]
163#[try_unwrap(ref, ref_mut)]
164pub enum ConvertError {
165  /// `av_frame` was null.
166  NullFrame,
167  /// The frame's pixel format isn't in the closed CPU-format set this
168  /// crate supports for safe per-plane access.
169  UnsupportedPixelFormat(UnsupportedPixelFormat),
170  /// A plane reported `linesize <= 0` or otherwise inconsistent layout.
171  InvalidPlaneLayout(InvalidPlaneLayout),
172  /// Failed to acquire an `AVBufferRef` for a plane (out of memory, or
173  /// the frame's `data[i]` pointer doesn't lie inside any of `buf[]`).
174  BufferAcquireFailed(BufferAcquireFailed),
175}
176
177impl core::fmt::Display for ConvertError {
178  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
179    match self {
180      Self::NullFrame => write!(f, "convert: AVFrame pointer was null"),
181      Self::UnsupportedPixelFormat(p) => core::fmt::Display::fmt(p, f),
182      Self::InvalidPlaneLayout(p) => core::fmt::Display::fmt(p, f),
183      Self::BufferAcquireFailed(p) => core::fmt::Display::fmt(p, f),
184    }
185  }
186}
187
188impl core::error::Error for ConvertError {}
189
190/// Builds [`ConvertError::UnsupportedPixelFormat`] for a frame whose raw
191/// format integer this crate will not deliver.
192///
193/// Both refusal sites go through here so the raw id and the name are
194/// never gathered at one of them and forgotten at the other.
195fn unsupported_pixel_format(format: PixelFormat, raw: i32) -> ConvertError {
196  ConvertError::UnsupportedPixelFormat(UnsupportedPixelFormat::new(
197    format,
198    raw,
199    crate::ffi::pix_fmt_name(raw),
200  ))
201}
202
203/// Safe wrapper around [`av_frame_to_video_frame`] taking a borrowed
204/// [`ffmpeg::Frame`](ffmpeg_next::Frame). Recommended entry point for
205/// most callers — equivalent to passing `frame.as_ptr()` to the
206/// unsafe variant, but the FFmpeg side keeps the frame alive for the
207/// duration of the call so the safety contract is satisfied
208/// internally.
209pub fn video_frame_from(
210  frame: &ffmpeg_next::Frame,
211  time_base: Timebase,
212) -> Result<VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, FfmpegBuffer>, ConvertError> {
213  // SAFETY: `&frame` keeps the AVFrame alive for the duration of this
214  // call; the unsafe convert just reads through the pointer.
215  unsafe { av_frame_to_video_frame(frame.as_ptr(), time_base) }
216}
217
218/// Safe wrapper around [`av_frame_to_audio_frame`] taking a borrowed
219/// [`ffmpeg::frame::Audio`](ffmpeg_next::frame::Audio).
220pub fn audio_frame_from(
221  frame: &ffmpeg_next::frame::Audio,
222  time_base: Timebase,
223) -> Result<
224  AudioFrame<SampleFormat, ChannelLayoutDescription, AudioFrameExtra, FfmpegBuffer>,
225  ConvertError,
226> {
227  // SAFETY: `&frame` keeps the AVFrame alive for the duration of this
228  // call.
229  unsafe { av_frame_to_audio_frame(frame.as_ptr(), time_base) }
230}
231
232/// Safe wrapper around [`av_subtitle_to_subtitle_frame`] taking a
233/// borrowed [`ffmpeg::Subtitle`](ffmpeg_next::Subtitle).
234pub fn subtitle_frame_from(
235  subtitle: &ffmpeg_next::Subtitle,
236  time_base: Timebase,
237) -> Result<SubtitleFrame<SubtitleFrameExtra, FfmpegBuffer>, ConvertError> {
238  // SAFETY: `&subtitle` keeps the AVSubtitle alive for the duration
239  // of this call.
240  unsafe { av_subtitle_to_subtitle_frame(subtitle.as_ptr(), time_base) }
241}
242
243/// Converts an FFmpeg `AVFrame` (CPU-side, post-`av_hwframe_transfer_data`
244/// or from a software decoder) into a `mediadecode::VideoFrame`
245/// parameterized by [`crate::Ffmpeg`] / [`crate::FfmpegBuffer`].
246///
247/// `time_base` is the source stream's time base, used to label
248/// `pts`/`duration` as mediatime [`Timestamp`]s.
249///
250/// # Safety
251///
252/// `av_frame` must be a live `*const AVFrame` for the duration of this
253/// call. The frame's `buf[]` references are not consumed; the produced
254/// `VideoFrame` holds its own refcounts on each underlying buffer.
255pub unsafe fn av_frame_to_video_frame(
256  av_frame: *const AVFrame,
257  time_base: Timebase,
258) -> Result<VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, FfmpegBuffer>, ConvertError> {
259  if av_frame.is_null() {
260    return Err(ConvertError::NullFrame);
261  }
262  // We deliberately never form `&*av_frame` — `AVFrame` contains
263  // bindgen-enum fields (`pict_type`, `color_primaries`, `colorspace`,
264  // `color_trc`, `color_range`, `chroma_location`, and an embedded
265  // `AVChannelLayout` whose `order` is also enum-typed). If FFmpeg
266  // (or a hostile decoder) writes a value outside our bindgen's
267  // discriminant set, the `&AVFrame` reference itself would be
268  // immediate UB before any field access. Working through the raw
269  // pointer with field-by-field reads (and `addr_of!` for the
270  // enum-typed fields) sidesteps this whole class.
271
272  // Non-enum primitives are safe to read via `(*av_frame).field`
273  // because validity for `i32`/`i64`/pointer types is just
274  // "initialized bytes"; the surrounding struct's enum fields don't
275  // contaminate this read.
276  let format_raw = unsafe { (*av_frame).format };
277  let width_raw = unsafe { (*av_frame).width };
278  let height_raw = unsafe { (*av_frame).height };
279  let pts_raw = unsafe { (*av_frame).pts };
280  let duration_raw = unsafe { (*av_frame).duration };
281  let pix_fmt = boundary::from_av_pixel_format(format_raw);
282  let width = width_raw.max(0) as u32;
283  let height = height_raw.max(0) as u32;
284
285  // Build planes. Reject any format whose planes we can't safely
286  // extract — HWACCEL surfaces, Bayer mosaics, paletted, and sub-byte
287  // bitstream packings — before touching plane memory. Without a
288  // deliverable layout we'd be reading garbage `linesize * height`
289  // bytes.
290  if !pixdesc::is_deliverable(&pix_fmt) {
291    return Err(unsupported_pixel_format(pix_fmt, format_raw));
292  }
293  // The per-plane row count and visible (tight) byte width come from
294  // `pixdesc::plane_geometry`, which derives them from libavutil's own
295  // `av_image_fill_linesizes` / `av_image_fill_plane_sizes` for this
296  // exact `(format, width, height)` — correct by construction for every
297  // deliverable CPU format. For a deliverable format `plane_geometry`
298  // only returns `None` on out-of-range dimensions; treat that as an
299  // unsupported frame rather than guessing a layout.
300  let geom = match pixdesc::plane_geometry(&pix_fmt, width as usize, height as usize) {
301    Some(g) => g,
302    None => return Err(unsupported_pixel_format(pix_fmt, format_raw)),
303  };
304
305  let mut planes_out: [Plane<FfmpegBuffer>; 4] = [
306    plane_placeholder()?,
307    plane_placeholder()?,
308    plane_placeholder()?,
309    plane_placeholder()?,
310  ];
311  let mut plane_count: u8 = 0;
312
313  // The loop body indexes `planes_out`, the AVFrame's `linesize`, and
314  // its `data` array all by `plane_idx`. None of these are slices we
315  // can iterate via `iter_mut().enumerate()` — `linesize` / `data` are
316  // raw `[T; 8]` fields read through `(*av_frame).field[plane_idx]`,
317  // and `planes_out` is also indexed by the same key for symmetry —
318  // so the index-based loop is the natural shape. The descriptor's
319  // `count` (`1..=4`) bounds the loop to exactly the planes this format
320  // populates.
321  #[allow(clippy::needless_range_loop)]
322  for plane_idx in 0..geom.count {
323    // Read per-plane fields through the raw pointer (no `&AVFrame`
324    // formed). `linesize` is `[c_int; 8]` and `data` is `[*mut u8; 8]`.
325    let linesize = unsafe { (*av_frame).linesize[plane_idx] };
326    if linesize <= 0 {
327      // `plane_idx < geom.count`, so this plane must be populated; a
328      // zero linesize means the decoder left an expected plane unset,
329      // and a negative linesize is FFmpeg's vertical-flip convention
330      // (which our safe accessors refuse). Either way the layout is
331      // unusable.
332      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
333        plane_idx,
334      )));
335    }
336    let data_ptr = unsafe { (*av_frame).data[plane_idx] };
337    if data_ptr.is_null() {
338      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
339        plane_idx,
340      )));
341    }
342    let plane_h = geom.height[plane_idx];
343    let row_bytes = geom.row_bytes[plane_idx];
344    if row_bytes > linesize as usize {
345      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
346        plane_idx,
347      )));
348    }
349    // Safe-API stance for stride padding:
350    //
351    // Each row in the AVBufferRef is `linesize` bytes wide but only the
352    // first `row_bytes` of them are guaranteed-initialized (the
353    // codec's actual output). The remaining `linesize - row_bytes`
354    // bytes per row are FFmpeg-allocator scratch — `av_malloc`'d, not
355    // necessarily written by the decoder. Exposing those bytes as
356    // part of an `&[u8]` slice is UB even if no consumer reads them.
357    //
358    // - When `linesize == row_bytes` (no padding), zero-copy: refcount
359    //   the AVBufferRef and expose the full plane.
360    // - When `linesize > row_bytes`, we copy each row tightly into a
361    //   fresh AVBufferRef and expose that — `stride` becomes
362    //   `row_bytes` and the buffer's length is `row_bytes * plane_h`
363    //   with every byte initialized.
364    let (view, exported_stride) = if (linesize as usize) == row_bytes {
365      let plane_bytes =
366        (plane_h)
367          .checked_mul(linesize as usize)
368          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
369            plane_idx,
370          )))?;
371      let buf = unsafe { find_backing_buffer(av_frame, data_ptr, plane_bytes) }.ok_or(
372        ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)),
373      )?;
374      // Plain address subtraction (avoids `offset_from`'s
375      // strict-provenance requirement; the pointers are independent
376      // C-side casts).
377      let offset = unsafe { (data_ptr as usize).wrapping_sub((*buf).data as usize) };
378      // SAFETY: `buf` is non-null and live; offset + plane_bytes <= buf.size
379      // by find_backing_buffer's check.
380      let view = unsafe { FfmpegBuffer::from_ref_view(buf, offset, plane_bytes) }.ok_or(
381        ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)),
382      )?;
383      (view, linesize as u32)
384    } else {
385      let total_bytes = row_bytes
386        .checked_mul(plane_h)
387        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
388          plane_idx,
389        )))?;
390      // Bound-check the readable extent in the source AVBufferRef
391      // BEFORE we start dereferencing per-row offsets. The zero-copy
392      // branch above did this implicitly by passing `plane_bytes` to
393      // `find_backing_buffer`; the copy branch must do the same — a
394      // buggy or hostile decoder/filter could hand us a `data_ptr`
395      // backed by a buffer too small for `(plane_h - 1) * linesize +
396      // row_bytes`, in which case `from_raw_parts` on the last few
397      // rows would form a slice over invalid memory (immediate UB,
398      // before any read).
399      let last_row_offset = (plane_h.saturating_sub(1))
400        .checked_mul(linesize as usize)
401        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
402          plane_idx,
403        )))?;
404      let readable_extent =
405        last_row_offset
406          .checked_add(row_bytes)
407          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
408            plane_idx,
409          )))?;
410      // `find_backing_buffer` confirms the AVBufferRef in `(*av_frame).buf[]`
411      // that contains `data_ptr` covers at least `readable_extent`
412      // bytes from the data pointer. We don't need the returned ptr;
413      // we just need the existence guarantee.
414      unsafe { find_backing_buffer(av_frame, data_ptr, readable_extent) }.ok_or(
415        ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)),
416      )?;
417      let mut packed: std::vec::Vec<u8> = std::vec::Vec::new();
418      packed
419        .try_reserve_exact(total_bytes)
420        .map_err(|_| ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)))?;
421      for row_idx in 0..plane_h {
422        let row_offset =
423          (row_idx)
424            .checked_mul(linesize as usize)
425            .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
426              plane_idx,
427            )))?;
428        // SAFETY: bounds-checked above via `find_backing_buffer`;
429        // `row_offset + row_bytes <= readable_extent <= buf.size`.
430        // Each per-row slice is the part the decoder writes
431        // (initialized).
432        let row_slice =
433          unsafe { core::slice::from_raw_parts(data_ptr.add(row_offset) as *const u8, row_bytes) };
434        packed.extend_from_slice(row_slice);
435      }
436      let buf = FfmpegBuffer::copy_from_slice(&packed).ok_or(ConvertError::BufferAcquireFailed(
437        BufferAcquireFailed::new(plane_idx),
438      ))?;
439      (buf, row_bytes as u32)
440    };
441
442    planes_out[plane_idx] = Plane::new(view, exported_stride);
443    plane_count = (plane_idx + 1) as u8;
444  }
445
446  // pts / duration / time_base
447  let pts = if pts_raw != AV_NOPTS_VALUE {
448    Some(Timestamp::new(pts_raw, time_base))
449  } else {
450    None
451  };
452  let duration = if duration_raw > 0 {
453    Some(Timestamp::new(duration_raw, time_base))
454  } else {
455    None
456  };
457
458  // Visible rect (FFmpeg crop).
459  let visible_rect = unsafe { build_visible_rect(av_frame, width, height) };
460
461  // Color metadata (the universal cross-backend bits). We read each
462  // bindgen enum-typed field through a raw `i32` window — even
463  // referencing an out-of-range enum value is UB before any cast can
464  // run, so we never let Rust assume the field actually inhabits the
465  // enum's discriminant set. FFmpeg version skew or a buggy decoder
466  // can put unknown values into these fields.
467
468  // SAFETY: `av_frame` points at a live AVFrame; `addr_of!` computes
469  // the address without forming a reference, and `read_unaligned::<i32>`
470  // is sound because each of these enum types has the layout of
471  // `c_int` (i32) per FFmpeg's bindgen output.
472  let color_primaries_raw =
473    unsafe { read_unaligned(addr_of!((*av_frame).color_primaries) as *const i32) };
474  let color_trc_raw = unsafe { read_unaligned(addr_of!((*av_frame).color_trc) as *const i32) };
475  let colorspace_raw = unsafe { read_unaligned(addr_of!((*av_frame).colorspace) as *const i32) };
476  let color_range_raw = unsafe { read_unaligned(addr_of!((*av_frame).color_range) as *const i32) };
477  let chroma_location_raw =
478    unsafe { read_unaligned(addr_of!((*av_frame).chroma_location) as *const i32) };
479  let color = ColorInfo::UNSPECIFIED
480    .with_primaries(map_primaries(color_primaries_raw))
481    .with_transfer(map_transfer(color_trc_raw))
482    .with_matrix(map_matrix(colorspace_raw))
483    .with_range(map_range_for(&pix_fmt, color_range_raw))
484    .with_chroma_location(map_chroma_loc(chroma_location_raw));
485
486  // Backend-specific extras.
487  let extra = unsafe { build_video_frame_extra(av_frame) };
488
489  // pix_fmt is already mediadecode::PixelFormat thanks to the boundary
490  // function above, so we just pass it through.
491  let mut out = VideoFrame::new(
492    Dimensions::new(width, height),
493    pix_fmt,
494    planes_out,
495    plane_count,
496    extra,
497  )
498  .with_pts(pts)
499  .with_duration(duration)
500  .with_color(color);
501  if let Some(r) = visible_rect {
502    out = out.with_visible_rect(Some(r));
503  }
504  Ok(out)
505}
506
507fn plane_placeholder() -> Result<Plane<FfmpegBuffer>, ConvertError> {
508  // Allocate a zero-byte AVBufferRef as a placeholder for unused plane
509  // slots. `[Plane<B>; 4]` requires four populated entries; we only
510  // expose `plane_count` of them through `VideoFrame::planes()`.
511  let raw = unsafe { av_buffer_alloc(0) };
512  // `av_buffer_alloc(0)` is allowed to return null on some platforms;
513  // fall back to allocating 1 byte if so.
514  let raw = if raw.is_null() {
515    unsafe { av_buffer_alloc(1) }
516  } else {
517    raw
518  };
519  if raw.is_null() {
520    // Truly OOM. Return an error by way of a poisoned plane.
521    return Err(ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(
522      4,
523    )));
524  }
525  let buf = unsafe { FfmpegBuffer::take(raw) }.ok_or(ConvertError::BufferAcquireFailed(
526    BufferAcquireFailed::new(4),
527  ))?;
528  Ok(Plane::new(buf, 0))
529}
530
531/// # Safety
532/// `av_frame` must be a live `*const AVFrame` for the duration of this
533/// call. The function reads only `crop_*` fields through the raw
534/// pointer — it never forms `&AVFrame`, so unrelated invalid enum
535/// fields elsewhere in the struct don't matter.
536unsafe fn build_visible_rect(av_frame: *const AVFrame, width: u32, height: u32) -> Option<Rect> {
537  let crop_left = unsafe { (*av_frame).crop_left } as u32;
538  let crop_top = unsafe { (*av_frame).crop_top } as u32;
539  let crop_right = unsafe { (*av_frame).crop_right } as u32;
540  let crop_bottom = unsafe { (*av_frame).crop_bottom } as u32;
541  if crop_left == 0 && crop_top == 0 && crop_right == 0 && crop_bottom == 0 {
542    return None;
543  }
544  let x = crop_left;
545  let y = crop_top;
546  let w = width.saturating_sub(crop_left).saturating_sub(crop_right);
547  let h = height.saturating_sub(crop_top).saturating_sub(crop_bottom);
548  Some(Rect::new(x, y, w, h))
549}
550
551/// # Safety
552/// `av_frame` must be a live `*const AVFrame` for the duration of this
553/// call. Reads each individual field through the raw pointer; never
554/// forms a `&AVFrame` reference.
555unsafe fn build_video_frame_extra(av_frame: *const AVFrame) -> VideoFrameExtra {
556  let mut out = VideoFrameExtra::default();
557  // SAR.
558  let sar_num = unsafe { (*av_frame).sample_aspect_ratio.num };
559  let sar_den = unsafe { (*av_frame).sample_aspect_ratio.den };
560  if sar_num > 0 && sar_den > 0 && (sar_num != 1 || sar_den != 1) {
561    out.set_sample_aspect_ratio(Some((sar_num as u32, sar_den as u32)));
562  }
563  // Picture type — read raw to avoid bindgen-enum UB if FFmpeg writes
564  // an out-of-range value (version skew / hostile decoder).
565
566  // SAFETY: `av_frame` is live; reading `pict_type` as `i32` matches
567  // the bindgen enum's underlying `c_int` storage.
568  let pict_type_raw = unsafe { read_unaligned(addr_of!((*av_frame).pict_type) as *const i32) };
569  out.set_picture_type(map_picture_type_raw(pict_type_raw));
570  // Key frame and interlace flags. AVFrame.flags has dedicated bits
571  // for these in recent FFmpeg; the deprecated fields (key_frame,
572  // interlaced_frame, top_field_first) still mirror them.
573  let flags = unsafe { (*av_frame).flags };
574  out.set_key_frame(flags & ffmpeg_next::ffi::AV_FRAME_FLAG_KEY != 0);
575  out.set_interlaced(flags & ffmpeg_next::ffi::AV_FRAME_FLAG_INTERLACED != 0);
576  out.set_top_field_first(flags & ffmpeg_next::ffi::AV_FRAME_FLAG_TOP_FIELD_FIRST != 0);
577  // Best-effort timestamp.
578  let bet = unsafe { (*av_frame).best_effort_timestamp };
579  if bet != AV_NOPTS_VALUE {
580    out.set_best_effort_timestamp(Some(bet));
581  }
582  // Side data — passthrough as raw bytes.
583  out.set_side_data(unsafe { collect_side_data(av_frame) });
584  out
585}
586
587/// Maximum number of `AVFrameSideData` entries we will copy out of
588/// a single AVFrame. Realistic streams attach a handful (mastering
589/// display, content light level, dynamic HDR metadata, S12M
590/// timecodes, A53 captions, …) — usually < 8. The cap exists so a
591/// crafted stream can't drive the safe converter into a long
592/// per-frame entry-allocation loop.
593pub(crate) const SIDE_DATA_MAX_ENTRIES: usize = 64;
594/// Per-AVFrame total side-data byte cap. HDR / dynamic-metadata
595/// payloads are typically a few hundred bytes; A53 captions can run
596/// to a few kilobytes; SEI dumps in pathological streams have been
597/// observed in the tens of kilobytes. 256 KiB is two orders of
598/// magnitude over the realistic upper bound while still bounded
599/// enough that an attacker-driven OOM via metadata is impossible.
600pub(crate) const SIDE_DATA_MAX_TOTAL_BYTES: usize = 256 * 1024;
601
602/// Maximum number of `AVSubtitleRect` entries we copy from a single
603/// AVSubtitle. Realistic subtitles attach 1–4 rects per cue; 64
604/// gives two orders of magnitude of headroom.
605const SUBTITLE_MAX_RECTS: usize = 64;
606/// Per-rect text/ASS payload byte cap. ASS lines exceeding this
607/// are unrealistic; the cap exists to defeat a malicious decoder
608/// attaching a multi-megabyte "subtitle" string.
609const SUBTITLE_MAX_TEXT_BYTES_PER_RECT: usize = 64 * 1024;
610/// Total text/ASS payload byte cap across all rects of a single
611/// AVSubtitle, including newline separators.
612const SUBTITLE_MAX_TEXT_TOTAL_BYTES: usize = 256 * 1024;
613/// Per-rect bitmap (`linesize * height`) byte cap. DVB / PGS
614/// subtitles realistically run to ~256 KiB on full-HD overlays;
615/// 16 MiB is two orders of magnitude over.
616const SUBTITLE_MAX_BITMAP_BYTES_PER_RECT: usize = 16 * 1024 * 1024;
617/// Total bitmap byte cap across all rects of a single AVSubtitle.
618const SUBTITLE_MAX_BITMAP_TOTAL_BYTES: usize = 32 * 1024 * 1024;
619
620/// Bounded counterpart to `CStr::from_ptr(p).to_bytes()`. Reads at
621/// most `cap + 1` bytes from `ptr` looking for a NUL terminator;
622/// returns `Some(slice)` of the bytes preceding the NUL on success,
623/// or `None` if no NUL was found within the window (the input was
624/// either too long or missing its required terminator entirely).
625///
626/// `CStr::from_ptr` walks until it hits a NUL — a valid-but-
627/// pathological string makes that scan unbounded, and a missing
628/// NUL is an outright UB precondition violation. This helper bounds
629/// both at `cap + 1` bytes.
630///
631/// # Safety
632/// `ptr` must be non-null and valid for reads of at least
633/// `min(cap + 1, length-until-NUL)` bytes. FFmpeg subtitle/text
634/// pointers satisfy this when `(*rect).text` / `.ass` is non-null
635/// (per FFmpeg's contract — though the contract itself doesn't
636/// bound the length).
637unsafe fn bounded_cstr_bytes<'a>(ptr: *const core::ffi::c_char, cap: usize) -> Option<&'a [u8]> {
638  // Read up to `cap + 1` bytes; the +1 lets a string exactly `cap`
639  // bytes long (with a NUL at index `cap`) succeed.
640  let max = cap.saturating_add(1);
641  for i in 0..max {
642    // SAFETY: Caller guarantees `ptr` is valid for reads of bytes
643    // until the NUL or `max`. We stop at the first NUL within the
644    // window.
645    let byte = unsafe { *(ptr.add(i) as *const u8) };
646    if byte == 0 {
647      // SAFETY: `ptr` is valid for `i` byte reads (we just walked
648      // them above). The slice doesn't include the NUL.
649      return Some(unsafe { core::slice::from_raw_parts(ptr as *const u8, i) });
650    }
651  }
652  // No NUL found within `cap + 1` bytes — input is too long or
653  // missing its terminator. Reject.
654  None
655}
656
657/// # Safety
658/// `av_frame` must be a live `*const AVFrame`. The function reads
659/// `nb_side_data` and `side_data[]` through the raw pointer; each
660/// `AVFrameSideData.type_` is read raw (it's a bindgen enum), and
661/// each `data` payload is bounds-checked before slicing.
662///
663/// Memory-safety stance: this function is called on every decoded
664/// frame, on data the decoder controls. Side-data is bounded by
665/// [`SIDE_DATA_MAX_ENTRIES`] entries and [`SIDE_DATA_MAX_TOTAL_BYTES`]
666/// total bytes; once either cap is reached we stop copying further
667/// entries and a `tracing::warn!` is emitted at most once per call.
668/// Allocations use `try_reserve_exact` so OOM surfaces as a dropped
669/// entry rather than a process abort.
670unsafe fn collect_side_data(av_frame: *const AVFrame) -> std::vec::Vec<SideDataEntry> {
671  // Read `nb_side_data` as the bindgen `c_int` and clamp non-
672  // positive values BEFORE casting to `usize`. A negative value
673  // (corrupt / version-skew decoder output) cast directly to
674  // `usize` becomes a huge positive count and would walk OOB
675  // memory below; treat it as "no side data".
676  let nb_side_data_raw = unsafe { (*av_frame).nb_side_data };
677  let side_data = unsafe { (*av_frame).side_data };
678  if nb_side_data_raw <= 0 || side_data.is_null() {
679    return Vec::new();
680  }
681  let count_raw = nb_side_data_raw as usize;
682  let count = count_raw.min(SIDE_DATA_MAX_ENTRIES);
683  if count_raw > SIDE_DATA_MAX_ENTRIES {
684    tracing::warn!(
685      cap = SIDE_DATA_MAX_ENTRIES,
686      requested = count_raw,
687      "mediadecode-ffmpeg: AVFrame.nb_side_data exceeds entry cap; truncating",
688    );
689  }
690  let mut out: Vec<SideDataEntry> = Vec::new();
691  if out.try_reserve_exact(count).is_err() {
692    return Vec::new();
693  }
694  let mut total_bytes: usize = 0;
695  for i in 0..count {
696    let sd = unsafe { *side_data.add(i) };
697    if sd.is_null() {
698      continue;
699    }
700    // `AVFrameSideData.type_` is `AVFrameSideDataType` — bindgen
701    // enum. Read raw to avoid forming an invalid value if FFmpeg
702    // writes an unknown discriminant (version skew).
703    let kind = unsafe { read_unaligned(addr_of!((*sd).type_) as *const i32) };
704    let size = unsafe { (*sd).size };
705    let data_ptr = unsafe { (*sd).data };
706    let data_slice = if size == 0 || data_ptr.is_null() {
707      Vec::new()
708    } else {
709      // Byte-budget check: stop copying further side-data entries
710      // once we've reached the per-frame cap. Earlier entries
711      // already in `out` stay; later entries are dropped.
712      let projected = total_bytes.saturating_add(size);
713      if projected > SIDE_DATA_MAX_TOTAL_BYTES {
714        tracing::warn!(
715          cap = SIDE_DATA_MAX_TOTAL_BYTES,
716          projected,
717          "mediadecode-ffmpeg: AVFrame side-data byte cap reached; dropping remaining entries",
718        );
719        break;
720      }
721      total_bytes = projected;
722      // Fallible copy. `try_reserve_exact` lets OOM surface as a
723      // dropped entry rather than a process abort.
724      let mut buf: Vec<u8> = Vec::new();
725      if buf.try_reserve_exact(size).is_err() {
726        continue;
727      }
728      // SAFETY: `data_ptr` is documented as valid for `size` bytes
729      // per FFmpeg's AVFrameSideData contract.
730      let src = unsafe { core::slice::from_raw_parts(data_ptr, size) };
731      buf.extend_from_slice(src);
732      buf
733    };
734    out.push(SideDataEntry::new(kind, data_slice));
735  }
736  out
737}
738
739/// Locate the `AVBufferRef` in `(*av_frame).buf[]` that backs
740/// `data_ptr`, confirming the requested `bytes` fit inside the buffer.
741/// Returns `None` on no match, null/empty `buf` entries, or any
742/// arithmetic that would overflow `usize`.
743///
744/// # Safety
745/// `av_frame` must be a live `*const AVFrame`. Reads `buf[]` (an
746/// array of pointers — no bindgen-enum validity hazards).
747unsafe fn find_backing_buffer(
748  av_frame: *const AVFrame,
749  data_ptr: *const u8,
750  bytes: usize,
751) -> Option<*mut ffmpeg_next::ffi::AVBufferRef> {
752  let buf_array_len = unsafe { (*av_frame).buf.len() };
753  for i in 0..buf_array_len {
754    let buf = unsafe { (*av_frame).buf[i] };
755    if buf.is_null() {
756      continue;
757    }
758    let buf_data = unsafe { (*buf).data as *const u8 };
759    let buf_size = unsafe { (*buf).size };
760    if buf_data.is_null() {
761      continue;
762    }
763    let start = buf_data as usize;
764    let Some(end) = start.checked_add(buf_size) else {
765      continue;
766    };
767    let dp = data_ptr as usize;
768    let Some(dp_end) = dp.checked_add(bytes) else {
769      continue;
770    };
771    if dp >= start && dp_end <= end {
772      return Some(buf);
773    }
774  }
775  None
776}
777
778fn map_primaries(raw: i32) -> ColorPrimaries {
779  match raw {
780    x if x == AVColorPrimaries::AVCOL_PRI_BT709 as i32 => ColorPrimaries::Bt709,
781    x if x == AVColorPrimaries::AVCOL_PRI_UNSPECIFIED as i32 => ColorPrimaries::Unspecified,
782    x if x == AVColorPrimaries::AVCOL_PRI_BT470M as i32 => ColorPrimaries::Bt470M,
783    x if x == AVColorPrimaries::AVCOL_PRI_BT470BG as i32 => ColorPrimaries::Bt470Bg,
784    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE170M as i32 => ColorPrimaries::Smpte170M,
785    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE240M as i32 => ColorPrimaries::Smpte240M,
786    x if x == AVColorPrimaries::AVCOL_PRI_FILM as i32 => ColorPrimaries::Film,
787    x if x == AVColorPrimaries::AVCOL_PRI_BT2020 as i32 => ColorPrimaries::Bt2020,
788    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE428 as i32 => ColorPrimaries::SmpteSt428,
789    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE431 as i32 => ColorPrimaries::SmpteRp431,
790    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE432 as i32 => ColorPrimaries::SmpteEg432,
791    x if x == AVColorPrimaries::AVCOL_PRI_EBU3213 as i32 => ColorPrimaries::Ebu3213E,
792    _ => ColorPrimaries::Unspecified,
793  }
794}
795
796fn map_transfer(raw: i32) -> ColorTransfer {
797  match raw {
798    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT709 as i32 => ColorTransfer::Bt709,
799    x if x == AVColorTransferCharacteristic::AVCOL_TRC_UNSPECIFIED as i32 => {
800      ColorTransfer::Unspecified
801    }
802    x if x == AVColorTransferCharacteristic::AVCOL_TRC_GAMMA22 as i32 => ColorTransfer::Gamma22,
803    x if x == AVColorTransferCharacteristic::AVCOL_TRC_GAMMA28 as i32 => ColorTransfer::Gamma28,
804    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE170M as i32 => ColorTransfer::Smpte170M,
805    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE240M as i32 => ColorTransfer::Smpte240M,
806    x if x == AVColorTransferCharacteristic::AVCOL_TRC_LINEAR as i32 => ColorTransfer::Linear,
807    x if x == AVColorTransferCharacteristic::AVCOL_TRC_LOG as i32 => ColorTransfer::Log100,
808    x if x == AVColorTransferCharacteristic::AVCOL_TRC_LOG_SQRT as i32 => ColorTransfer::Log316,
809    x if x == AVColorTransferCharacteristic::AVCOL_TRC_IEC61966_2_4 as i32 => {
810      ColorTransfer::Iec6196624
811    }
812    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT1361_ECG as i32 => {
813      ColorTransfer::Bt1361Ecg
814    }
815    x if x == AVColorTransferCharacteristic::AVCOL_TRC_IEC61966_2_1 as i32 => {
816      ColorTransfer::Iec6196621
817    }
818    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT2020_10 as i32 => {
819      ColorTransfer::Bt2020_10Bit
820    }
821    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT2020_12 as i32 => {
822      ColorTransfer::Bt2020_12Bit
823    }
824    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084 as i32 => {
825      ColorTransfer::SmpteSt2084Pq
826    }
827    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE428 as i32 => ColorTransfer::SmpteSt428,
828    x if x == AVColorTransferCharacteristic::AVCOL_TRC_ARIB_STD_B67 as i32 => {
829      ColorTransfer::AribStdB67Hlg
830    }
831    _ => ColorTransfer::Unspecified,
832  }
833}
834
835fn map_matrix(raw: i32) -> ColorMatrix {
836  match raw {
837    x if x == AVColorSpace::AVCOL_SPC_BT709 as i32 => ColorMatrix::Bt709,
838    x if x == AVColorSpace::AVCOL_SPC_BT2020_NCL as i32 => ColorMatrix::Bt2020Ncl,
839    x if x == AVColorSpace::AVCOL_SPC_SMPTE170M as i32 => ColorMatrix::Bt601,
840    x if x == AVColorSpace::AVCOL_SPC_BT470BG as i32 => ColorMatrix::Bt601,
841    x if x == AVColorSpace::AVCOL_SPC_SMPTE240M as i32 => ColorMatrix::Smpte240m,
842    x if x == AVColorSpace::AVCOL_SPC_FCC as i32 => ColorMatrix::Fcc,
843    x if x == AVColorSpace::AVCOL_SPC_YCGCO as i32 => ColorMatrix::YCgCo,
844    _ => ColorMatrix::Bt709, // ColorMatrix has no Unspecified; Bt709 is FFmpeg's height>=720 default
845  }
846}
847
848fn map_range(raw: i32) -> ColorRange {
849  match raw {
850    x if x == AVColorRange::AVCOL_RANGE_JPEG as i32 => ColorRange::Full,
851    x if x == AVColorRange::AVCOL_RANGE_MPEG as i32 => ColorRange::Limited,
852    _ => ColorRange::Unspecified,
853  }
854}
855
856/// `true` for the JPEG-range planar YUV (`yuvj*`) formats. These are
857/// **full-range by definition** — the `j` is FFmpeg's marker for an
858/// MJPEG/JPEG-family full-swing signal — so their color range is a
859/// property of the format itself, not something the frame's
860/// `color_range` field needs to (or reliably does) carry.
861fn is_yuvj(pix_fmt: &PixelFormat) -> bool {
862  matches!(
863    pix_fmt,
864    PixelFormat::Yuvj411p
865      | PixelFormat::Yuvj420p
866      | PixelFormat::Yuvj422p
867      | PixelFormat::Yuvj440p
868      | PixelFormat::Yuvj444p
869  )
870}
871
872/// Derives the delivered [`ColorRange`] from the frame's `color_range`
873/// field, honoring the range a pixel format *implies*.
874///
875/// A `yuvj*` frame is JPEG full-range by definition, but its
876/// `AVFrame.color_range` is frequently `AVCOL_RANGE_UNSPECIFIED` (the
877/// MJPEG/JPEG decode paths don't always stamp it). Deriving the range
878/// purely from that field would mislabel a full-range frame as
879/// `Unspecified` (which downstream YUV→RGB conversion reads as the
880/// Limited-swing default) — a silent decode-correctness regression. So
881/// for the `yuvj*` family we force [`ColorRange::Full`] regardless of
882/// the field. Every other format defers entirely to `color_range`.
883fn map_range_for(pix_fmt: &PixelFormat, color_range_raw: i32) -> ColorRange {
884  if is_yuvj(pix_fmt) {
885    return ColorRange::Full;
886  }
887  map_range(color_range_raw)
888}
889
890fn map_chroma_loc(raw: i32) -> ChromaLocation {
891  match raw {
892    x if x == AVChromaLocation::AVCHROMA_LOC_LEFT as i32 => ChromaLocation::Left,
893    x if x == AVChromaLocation::AVCHROMA_LOC_CENTER as i32 => ChromaLocation::Center,
894    x if x == AVChromaLocation::AVCHROMA_LOC_TOPLEFT as i32 => ChromaLocation::TopLeft,
895    x if x == AVChromaLocation::AVCHROMA_LOC_TOP as i32 => ChromaLocation::Top,
896    x if x == AVChromaLocation::AVCHROMA_LOC_BOTTOMLEFT as i32 => ChromaLocation::BottomLeft,
897    x if x == AVChromaLocation::AVCHROMA_LOC_BOTTOM as i32 => ChromaLocation::Bottom,
898    _ => ChromaLocation::Unspecified,
899  }
900}
901
902/// Converts an FFmpeg audio `AVFrame` into a `mediadecode::AudioFrame`.
903///
904/// The plane payloads are zero-copy views into the source frame's
905/// `AVBufferRef` entries (the corresponding `data[i]` is always
906/// covered by exactly one of `buf[i]` per FFmpeg's contract). Channel
907/// counts above 8 (which would spill into `extended_buf`) are clamped
908/// to 8 — the rare cases where this matters can read the source
909/// `AVFrame` directly.
910///
911/// # Safety
912///
913/// `av_frame` must be a live `*const AVFrame` for the duration of this
914/// call and must describe an audio frame (`format` is an
915/// `AVSampleFormat`, `nb_samples > 0`, and `data[]` / `buf[]` populated).
916pub unsafe fn av_frame_to_audio_frame(
917  av_frame: *const AVFrame,
918  time_base: Timebase,
919) -> Result<
920  AudioFrame<SampleFormat, ChannelLayoutDescription, AudioFrameExtra, FfmpegBuffer>,
921  ConvertError,
922> {
923  if av_frame.is_null() {
924    return Err(ConvertError::NullFrame);
925  }
926  // Same stance as `av_frame_to_video_frame`: never form `&AVFrame`.
927  // Read every field through the raw pointer; for `ch_layout` (which
928  // contains an `order: AVChannelOrder` enum) we hand the raw pointer
929  // straight into
930  // `channel_layout::channel_layout_description_from_raw_ptr`,
931  // which validates `order` as `i32` before constructing any
932  // `AVChannelOrder` value.
933  let format_raw = unsafe { (*av_frame).format };
934  let sample_rate_raw = unsafe { (*av_frame).sample_rate };
935  let nb_samples_raw = unsafe { (*av_frame).nb_samples };
936  let pts_raw = unsafe { (*av_frame).pts };
937  let duration_raw = unsafe { (*av_frame).duration };
938  let bet_raw = unsafe { (*av_frame).best_effort_timestamp };
939
940  let sample_format = SampleFormat::from_raw(format_raw);
941  let sample_rate = sample_rate_raw.max(0) as u32;
942  let nb_samples = nb_samples_raw.max(0) as u32;
943
944  // SAFETY: `av_frame` is a live `*const AVFrame`; passing the
945  // address of the embedded ch_layout as `*const AVChannelLayout`
946  // is sound because `addr_of!` doesn't form a reference.
947  let ch_layout_ptr = unsafe { addr_of!((*av_frame).ch_layout) };
948  let channel_layout =
949    unsafe { crate::channel_layout::channel_layout_description_from_raw_ptr(ch_layout_ptr) };
950  let channel_count_full = channel_layout.channels();
951  let channel_count = channel_count_full.min(255) as u8;
952
953  // Plane count: 1 for packed, channel_count for planar.
954  let is_planar = sample_format.is_planar();
955  let plane_count_full = if is_planar { channel_count as usize } else { 1 };
956  // mediadecode's `AudioFrame` carries up to 8 plane slots
957  // (matching `AV_NUM_DATA_POINTERS`). Planar audio with more than
958  // 8 channels uses `AVFrame.extended_data[]` / `extended_buf[]`,
959  // which we don't yet plumb through. Refuse the frame rather than
960  // silently truncating to the first 8 channels and returning an
961  // `AudioFrame` whose advertised `channel_count` exceeds its
962  // populated plane count.
963  if plane_count_full > 8 {
964    return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(8)));
965  }
966  let plane_count = plane_count_full as u8;
967
968  // Per-plane size in bytes. For audio, FFmpeg only sets `linesize[0]`;
969  // every planar plane has the same size, every packed buffer is the
970  // total size for all channels. Validate against the format's
971  // expected minimum so a hostile/buggy decoder can't smuggle a
972  // shrunk linesize past us (which would let consumers read past
973  // valid bytes when they trust `nb_samples`).
974  let linesize0 = unsafe { (*av_frame).linesize[0] };
975  if nb_samples > 0 && linesize0 <= 0 {
976    return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
977  }
978  let plane_bytes = linesize0.max(0) as usize;
979  if nb_samples > 0 {
980    let bytes_per_sample = sample_format
981      .bytes_per_sample()
982      .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?
983      as usize;
984    let expected_per_plane = if is_planar {
985      // Planar: each plane carries `nb_samples * bytes_per_sample`.
986      (nb_samples as usize)
987        .checked_mul(bytes_per_sample)
988        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?
989    } else {
990      // Packed: the single plane interleaves all channels.
991      (nb_samples as usize)
992        .checked_mul(bytes_per_sample)
993        .and_then(|x| x.checked_mul(channel_count.max(1) as usize))
994        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?
995    };
996    if plane_bytes < expected_per_plane {
997      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
998    }
999  }
1000
1001  let mut planes_out: [Plane<FfmpegBuffer>; 8] = [
1002    audio_plane_placeholder()?,
1003    audio_plane_placeholder()?,
1004    audio_plane_placeholder()?,
1005    audio_plane_placeholder()?,
1006    audio_plane_placeholder()?,
1007    audio_plane_placeholder()?,
1008    audio_plane_placeholder()?,
1009    audio_plane_placeholder()?,
1010  ];
1011
1012  // Same rationale as in the video path — index-by-key over three
1013  // unrelated raw arrays (`planes_out`, `(*av_frame).data`, and the
1014  // implicit per-plane bookkeeping); no slice iteration applies.
1015  #[allow(clippy::needless_range_loop)]
1016  for plane_idx in 0..plane_count as usize {
1017    let data_ptr = unsafe { (*av_frame).data[plane_idx] };
1018    if data_ptr.is_null() {
1019      // A null plane in a planar layout (or the sole plane in a
1020      // packed layout) means the decoder produced an incomplete
1021      // frame — surface as an error rather than returning a frame
1022      // whose `planes()` exposes empty placeholder channels for
1023      // the missing data.
1024      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1025        plane_idx,
1026      )));
1027    }
1028    let buf = unsafe { find_audio_backing_buffer(av_frame, data_ptr, plane_bytes) }.ok_or(
1029      ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)),
1030    )?;
1031    // See `av_frame_to_video_frame` for the rationale on plain
1032    // address subtraction over `offset_from`.
1033    let offset = unsafe { (data_ptr as usize).wrapping_sub((*buf).data as usize) };
1034    // SAFETY: `buf` is non-null and live; offset + plane_bytes <= buf.size
1035    // by find_audio_backing_buffer's bounds check.
1036    let view = unsafe { FfmpegBuffer::from_ref_view(buf, offset, plane_bytes) }.ok_or(
1037      ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)),
1038    )?;
1039    planes_out[plane_idx] = Plane::new(view, plane_bytes as u32);
1040  }
1041
1042  let pts = if pts_raw != AV_NOPTS_VALUE {
1043    Some(Timestamp::new(pts_raw, time_base))
1044  } else {
1045    None
1046  };
1047  let duration = if duration_raw > 0 {
1048    Some(Timestamp::new(duration_raw, time_base))
1049  } else {
1050    None
1051  };
1052
1053  let mut extra = AudioFrameExtra::default();
1054  if bet_raw != AV_NOPTS_VALUE {
1055    extra.set_best_effort_timestamp(Some(bet_raw));
1056  }
1057  // SAFETY: caller upholds liveness for the duration of the call;
1058  // collect_side_data reads enum-typed `type_` raw and bounds-checks
1059  // each entry's data slice.
1060  extra.set_side_data(unsafe { collect_side_data(av_frame) });
1061
1062  Ok(
1063    AudioFrame::new(
1064      sample_rate,
1065      nb_samples,
1066      channel_count,
1067      sample_format,
1068      channel_layout,
1069      planes_out,
1070      plane_count,
1071      extra,
1072    )
1073    .with_pts(pts)
1074    .with_duration(duration),
1075  )
1076}
1077
1078fn audio_plane_placeholder() -> Result<Plane<FfmpegBuffer>, ConvertError> {
1079  let raw = unsafe { av_buffer_alloc(1) };
1080  if raw.is_null() {
1081    return Err(ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(
1082      8,
1083    )));
1084  }
1085  let buf = unsafe { FfmpegBuffer::take(raw) }.ok_or(ConvertError::BufferAcquireFailed(
1086    BufferAcquireFailed::new(8),
1087  ))?;
1088  Ok(Plane::new(buf, 0))
1089}
1090
1091/// The `AVBufferRef` in `(*av_frame).buf[]` that backs `data_ptr` for
1092/// `bytes` bytes, or `None` when none of them does.
1093///
1094/// # Safety
1095/// `av_frame` must be a live `*const AVFrame`.
1096pub(crate) unsafe fn find_audio_backing_buffer(
1097  av_frame: *const AVFrame,
1098  data_ptr: *const u8,
1099  bytes: usize,
1100) -> Option<*mut ffmpeg_next::ffi::AVBufferRef> {
1101  // Audio frames pack each plane into a separate AVBufferRef in buf[].
1102  // Same scan as the video path — finds whichever buffer's data range
1103  // contains data_ptr. Overflow-safe arithmetic per
1104  // `find_backing_buffer`'s rationale.
1105  let buf_array_len = unsafe { (*av_frame).buf.len() };
1106  for i in 0..buf_array_len {
1107    let buf = unsafe { (*av_frame).buf[i] };
1108    if buf.is_null() {
1109      continue;
1110    }
1111    let buf_data = unsafe { (*buf).data as *const u8 };
1112    let buf_size = unsafe { (*buf).size };
1113    if buf_data.is_null() {
1114      continue;
1115    }
1116    let start = buf_data as usize;
1117    let Some(end) = start.checked_add(buf_size) else {
1118      continue;
1119    };
1120    let dp = data_ptr as usize;
1121    let Some(dp_end) = dp.checked_add(bytes) else {
1122      continue;
1123    };
1124    if dp >= start && dp_end <= end {
1125      return Some(buf);
1126    }
1127  }
1128  None
1129}
1130
1131/// Converts an FFmpeg `AVSubtitle` into a `mediadecode::SubtitleFrame`.
1132///
1133/// Strategy:
1134/// - If the subtitle contains any text/ASS rects, produce a
1135///   [`SubtitlePayload::Text`] whose buffer is the concatenation of
1136///   their UTF-8 contents (newline-separated).
1137/// - Otherwise, if the subtitle contains bitmap rects, produce a
1138///   [`SubtitlePayload::Bitmap`] with one [`mediadecode::subtitle::BitmapRegion`]
1139///   per rect (paletted indices and RGBA palette copied into fresh
1140///   refcounted FfmpegBuffers, since `AVSubtitleRect` data is not
1141///   refcounted).
1142/// - An empty subtitle (no rects) becomes an empty `Text` payload.
1143///
1144/// `time_base` is the source stream's time base, used to label
1145/// `pts` / `duration`. The duration is computed as
1146/// `(end_display_time - start_display_time)` in milliseconds, then
1147/// rescaled into `time_base`.
1148///
1149/// # Safety
1150///
1151/// `av_subtitle` must be a live `*const AVSubtitle` for the duration
1152/// of this call; the rect array (`av_subtitle.rects`) must be valid
1153/// for `av_subtitle.num_rects` entries.
1154pub unsafe fn av_subtitle_to_subtitle_frame(
1155  av_subtitle: *const ffmpeg_next::ffi::AVSubtitle,
1156  time_base: Timebase,
1157) -> Result<SubtitleFrame<SubtitleFrameExtra, FfmpegBuffer>, ConvertError> {
1158  if av_subtitle.is_null() {
1159    return Err(ConvertError::NullFrame);
1160  }
1161  // Same stance as `av_frame_to_video_frame`: never form `&AVSubtitle`
1162  // or `&AVSubtitleRect` (both contain `type_: AVSubtitleType` enum
1163  // fields). Read every field through the raw pointer.
1164
1165  let mut text_chunks: std::vec::Vec<u8> = std::vec::Vec::new();
1166  let mut bitmap_regions: std::vec::Vec<mediadecode::subtitle::BitmapRegion<FfmpegBuffer>> =
1167    std::vec::Vec::new();
1168
1169  let count_raw = unsafe { (*av_subtitle).num_rects } as usize;
1170  let rects_ptr = unsafe { (*av_subtitle).rects };
1171  // Defensive: `num_rects > 0` with `rects == null` would be a malformed
1172  // AVSubtitle, but a hostile decoder could produce one — bail rather
1173  // than dereferencing.
1174  if count_raw > 0 && rects_ptr.is_null() {
1175    return Err(ConvertError::NullFrame);
1176  }
1177  // Cap rect count, total text bytes, and total bitmap bytes
1178  // against decoder-controlled metadata. Realistic subtitles carry
1179  // a handful of rects (typically 1–4 per displayed cue), text
1180  // payloads in the low kilobytes (ASS lines), and bitmap
1181  // payloads in the low hundreds of KiB (DVB / PGS). These caps
1182  // are two orders of magnitude over realistic ceilings; their
1183  // job is to bound a malicious / corrupt stream's allocation
1184  // budget, not to limit legitimate use.
1185  let count = count_raw.min(SUBTITLE_MAX_RECTS);
1186  if count_raw > SUBTITLE_MAX_RECTS {
1187    tracing::warn!(
1188      cap = SUBTITLE_MAX_RECTS,
1189      requested = count_raw,
1190      "mediadecode-ffmpeg: AVSubtitle.num_rects exceeds rect cap; truncating",
1191    );
1192  }
1193  let mut text_total_bytes: usize = 0;
1194  let mut bitmap_total_bytes: usize = 0;
1195
1196  let text_kind = AVSubtitleType::SUBTITLE_TEXT as i32;
1197  let ass_kind = AVSubtitleType::SUBTITLE_ASS as i32;
1198  let bitmap_kind = AVSubtitleType::SUBTITLE_BITMAP as i32;
1199  for i in 0..count {
1200    // SAFETY: rects_ptr is non-null (checked above) and points to
1201    // num_rects valid `*mut AVSubtitleRect` entries per FFmpeg's
1202    // contract; `i < count == num_rects`, so the offset is in-bounds.
1203    let rect_ptr = unsafe { *rects_ptr.add(i) };
1204    if rect_ptr.is_null() {
1205      continue;
1206    }
1207    // Read `type_` raw — avoid forming `&AVSubtitleRect` (which
1208    // would require type_ to be a valid AVSubtitleType variant).
1209    // SAFETY: `rect_ptr` is a live `*mut AVSubtitleRect`; `addr_of!`
1210    // computes the field address without forming a reference;
1211    // reading as `i32` matches the bindgen enum's `c_int` storage.
1212    let rect_type_raw = unsafe { read_unaligned(addr_of!((*rect_ptr).type_) as *const i32) };
1213    // Pre-read primitive fields we'll use later (no `&AVSubtitleRect`
1214    // ever formed).
1215    let rect_text_ptr = unsafe { (*rect_ptr).text };
1216    let rect_ass_ptr = unsafe { (*rect_ptr).ass };
1217    let rect_data0_ptr = unsafe { (*rect_ptr).data[0] };
1218    let rect_data1_ptr = unsafe { (*rect_ptr).data[1] };
1219    let rect_linesize0 = unsafe { (*rect_ptr).linesize[0] };
1220    let rect_w = unsafe { (*rect_ptr).w };
1221    let rect_h = unsafe { (*rect_ptr).h };
1222    let rect_x = unsafe { (*rect_ptr).x };
1223    let rect_y = unsafe { (*rect_ptr).y };
1224
1225    match rect_type_raw {
1226      x if x == text_kind && !rect_text_ptr.is_null() => {
1227        // SAFETY: `text` is documented as a 0-terminated UTF-8
1228        // string, owned by FFmpeg for the lifetime of the AVSubtitle.
1229        // We use a *bounded* NUL search instead of `CStr::from_ptr`
1230        // — the latter walks until it finds a NUL, which a valid-
1231        // but-pathological string makes unbounded, and a missing
1232        // NUL violates the `CStr::from_ptr` precondition outright.
1233        // `bounded_cstr_bytes` searches at most
1234        // `SUBTITLE_MAX_TEXT_BYTES_PER_RECT + 1` bytes; if no NUL
1235        // is found inside that window the rect is rejected.
1236        let bytes = unsafe { bounded_cstr_bytes(rect_text_ptr, SUBTITLE_MAX_TEXT_BYTES_PER_RECT) }
1237          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?;
1238        // The cap is now enforced inside `bounded_cstr_bytes` (no
1239        // NUL within `cap + 1` ⇒ rejection); a redundant length
1240        // check is unnecessary but kept as documentation.
1241        if bytes.len() > SUBTITLE_MAX_TEXT_BYTES_PER_RECT {
1242          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
1243        }
1244        let separator = if text_chunks.is_empty() { 0 } else { 1 };
1245        let projected = text_total_bytes
1246          .saturating_add(bytes.len())
1247          .saturating_add(separator);
1248        if projected > SUBTITLE_MAX_TEXT_TOTAL_BYTES {
1249          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
1250        }
1251        if separator == 1 {
1252          text_chunks.push(b'\n');
1253        }
1254        text_chunks.extend_from_slice(bytes);
1255        text_total_bytes = projected;
1256      }
1257      x if x == ass_kind && !rect_ass_ptr.is_null() => {
1258        // SAFETY: `ass` is documented as 0-terminated UTF-8.
1259        // Same bounded-scan rationale as the TEXT branch above.
1260        let bytes = unsafe { bounded_cstr_bytes(rect_ass_ptr, SUBTITLE_MAX_TEXT_BYTES_PER_RECT) }
1261          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?;
1262        if bytes.len() > SUBTITLE_MAX_TEXT_BYTES_PER_RECT {
1263          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
1264        }
1265        let separator = if text_chunks.is_empty() { 0 } else { 1 };
1266        let projected = text_total_bytes
1267          .saturating_add(bytes.len())
1268          .saturating_add(separator);
1269        if projected > SUBTITLE_MAX_TEXT_TOTAL_BYTES {
1270          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
1271        }
1272        if separator == 1 {
1273          text_chunks.push(b'\n');
1274        }
1275        text_chunks.extend_from_slice(bytes);
1276        text_total_bytes = projected;
1277      }
1278      x if x == bitmap_kind => {
1279        // Bitmap region. data[0] = paletted indices, data[1] = RGBA
1280        // palette (256 entries × 4 bytes = 1024 bytes). Both are
1281        // owned by FFmpeg and not refcounted; copy into fresh buffers.
1282        let w = rect_w.max(0) as u32;
1283        let h = rect_h.max(0) as u32;
1284        let stride = rect_linesize0.max(0) as u32;
1285        if rect_data0_ptr.is_null() || stride == 0 || h == 0 {
1286          continue;
1287        }
1288        // `checked_mul` so a corrupt rect can't drive
1289        // `from_raw_parts` to an address-space-spanning length (UB
1290        // even before any deref).
1291        let data_len = (stride as usize)
1292          .checked_mul(h as usize)
1293          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?;
1294        // Per-rect bitmap byte cap (defends against a single
1295        // attacker rect larger than realistic DVB / PGS subtitles
1296        // by a wide margin).
1297        if data_len > SUBTITLE_MAX_BITMAP_BYTES_PER_RECT {
1298          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
1299        }
1300        let projected_total = bitmap_total_bytes.saturating_add(data_len);
1301        if projected_total > SUBTITLE_MAX_BITMAP_TOTAL_BYTES {
1302          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
1303        }
1304        // SAFETY: data[0] is valid for `linesize[0] * h` bytes per
1305        // FFmpeg's contract; the multiplication is checked above.
1306        let data_slice = unsafe { core::slice::from_raw_parts(rect_data0_ptr, data_len) };
1307        let data_buf = FfmpegBuffer::copy_from_slice(data_slice).ok_or(
1308          ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(0)),
1309        )?;
1310        let palette_len = 256 * 4;
1311        let palette_buf = if rect_data1_ptr.is_null() {
1312          FfmpegBuffer::copy_from_slice(&[]).ok_or(ConvertError::BufferAcquireFailed(
1313            BufferAcquireFailed::new(1),
1314          ))?
1315        } else {
1316          // SAFETY: palette buffer is 256*4 bytes per FFmpeg's contract.
1317          let p = unsafe { core::slice::from_raw_parts(rect_data1_ptr, palette_len) };
1318          FfmpegBuffer::copy_from_slice(p).ok_or(ConvertError::BufferAcquireFailed(
1319            BufferAcquireFailed::new(1),
1320          ))?
1321        };
1322        bitmap_regions.push(mediadecode::subtitle::BitmapRegion::new(
1323          rect_x.max(0) as u32,
1324          rect_y.max(0) as u32,
1325          w,
1326          h,
1327          stride,
1328          data_buf,
1329          palette_buf,
1330        ));
1331        bitmap_total_bytes = projected_total;
1332      }
1333      _ => {}
1334    }
1335  }
1336
1337  let payload = if !text_chunks.is_empty() {
1338    let buf = FfmpegBuffer::copy_from_slice(&text_chunks).ok_or(
1339      ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(0)),
1340    )?;
1341    SubtitlePayload::Text(SubtitleText::new(buf, None))
1342  } else if !bitmap_regions.is_empty() {
1343    SubtitlePayload::Bitmap(SubtitleBitmap::new(bitmap_regions))
1344  } else {
1345    // No rects (or only `None`-typed) — empty text payload.
1346    let buf = FfmpegBuffer::copy_from_slice(&[]).ok_or(ConvertError::BufferAcquireFailed(
1347      BufferAcquireFailed::new(0),
1348    ))?;
1349    SubtitlePayload::Text(SubtitleText::new(buf, None))
1350  };
1351
1352  let sub_pts = unsafe { (*av_subtitle).pts };
1353  let pts = if sub_pts != AV_NOPTS_VALUE {
1354    Some(Timestamp::new(sub_pts, time_base))
1355  } else {
1356    None
1357  };
1358
1359  let extra = SubtitleFrameExtra::new(unsafe { (*av_subtitle).start_display_time }, unsafe {
1360    (*av_subtitle).end_display_time
1361  });
1362
1363  Ok(SubtitleFrame::new(payload, extra).with_pts(pts))
1364}
1365
1366fn map_picture_type_raw(raw: i32) -> PictureType {
1367  match raw {
1368    x if x == AVPictureType::AV_PICTURE_TYPE_I as i32 => PictureType::I,
1369    x if x == AVPictureType::AV_PICTURE_TYPE_P as i32 => PictureType::P,
1370    x if x == AVPictureType::AV_PICTURE_TYPE_B as i32 => PictureType::B,
1371    x if x == AVPictureType::AV_PICTURE_TYPE_S as i32 => PictureType::S,
1372    x if x == AVPictureType::AV_PICTURE_TYPE_SI as i32 => PictureType::Si,
1373    x if x == AVPictureType::AV_PICTURE_TYPE_SP as i32 => PictureType::Sp,
1374    x if x == AVPictureType::AV_PICTURE_TYPE_BI as i32 => PictureType::Bi,
1375    _ => PictureType::Unspecified,
1376  }
1377}
1378
1379#[cfg(test)]
1380mod tests;