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