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