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