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//! `FfmpegBytes`.
4//!
5//! Every plane is **copied once**, here, out of FFmpeg's
6//! `AVBufferRef` and into Rust-owned memory — the
7//! [D-seat amputation contract][law]. Through 0.8 the video path
8//! exported a refcounted *view* into libavcodec's own allocation
9//! whenever the stride happened to be tight, and copied only when it
10//! was padded; a consumer therefore inherited an FFmpeg lifetime it
11//! could not see, on some frames and not others. 0.9 copies both
12//! branches. What is unchanged is the *shape* each branch produces —
13//! a tight plane keeps the decoder's `linesize` as its stride, a
14//! padded one is compacted to `row_bytes` — because that geometry is
15//! what consumers read, and the amputation is about ownership, not
16//! about relaying out the picture.
17//!
18//! # Header fields: the validation-order census
19//!
20//! Every number in this module comes out of an `AVFrame` a file chose
21//! the contents of, and each one is answerable to two questions —
22//! *what judges it*, and *what reads it first*. When the second
23//! precedes the first, the judgement is being made against a value its
24//! own consumer has already laundered, which is not a judgement. That
25//! is not hypothetical: it is how a declared `-1` channel count reached
26//! a ceiling as a legitimate-looking `0`, having been floored by the
27//! very helper the ceiling was supposed to run before.
28//!
29//! So the order is censused rather than assumed. Every raw header field
30//! these three paths read, with its validator and its first consumer:
31//!
32//! | path | field | validator | first consumer | order |
33//! |---|---|---|---|---|
34//! | audio | `nb_samples` | `< 0` → [`InvalidSampleCount`] | the byte product | validator first |
35//! | audio | `ch_layout.nb_channels` | `< 0`, `> 255`, `== 0` with samples → [`UnsupportedChannelCount`] | `channel_layout_description_from_raw_ptr` | **was inverted — hoisted** |
36//! | audio | `format` | `bytes_per_sample()` → [`UnsupportedSampleFormat`] | `is_planar()`, for the plane count | validator first |
37//! | audio | `linesize[0]` | `< 0`, and `== 0` with samples → [`InvalidPlaneLayout`] | `allocated_per_plane` | validator first |
38//! | audio | `sample_rate` | none — censused metadata | `AudioFrame::new` | no geometry rides it |
39//! | audio | `data[i]` | null check, then the backing-buffer proof | the copy | validator first |
40//! | picture | `width` / `height` | `< 0` → [`InvalidDimensions`] | `copy_out_planes`' pixel ceiling | **was inverted — hoisted** |
41//! | picture | `format` | `is_deliverable` → unsupported-format | `plane_geometry` | validator first |
42//! | picture | `linesize[i]` | `<= 0` **and** `< row_bytes[i]` → [`InvalidPlaneLayout`] | its own pass, after the budget and before any copy | validator first |
43//! | picture | `crop_*` | `checked_add` per pair, then `sum < extent` | the rect | validator first |
44//! | picture | `nb_side_data`, entry `size` (still road) | the entry cap and [`FrameLimits::max_image_side_data_bytes`](crate::FrameLimits::max_image_side_data_bytes) | the plane copy, then the side-data copy | **was inverted — hoisted ahead of `copy_out_planes`** |
45//! | picture | colour enums, `pict_type` | the raw `i32` fold, which is total | the fold's own output | the fold *is* the validator |
46//! | packet | `flags` (`AV_PKT_FLAG_TRUSTED`) | [`crate::buffer::TrustedPayload`], both legs | the payload copy | validator first |
47//!
48//! # The open-C-enum sweep, including this crate's own code
49//!
50//! The same discipline, applied to *entry points* rather than fields: a
51//! value read out of FFmpeg memory as a closed Rust enum is undefined
52//! behaviour before any comparison on it can run, and FFmpeg extends
53//! these enums in ABI-compatible releases.
54//!
55//! | caller | entry point | enum | closed by |
56//! |---|---|---|---|
57//! | image / video / audio / subtitle open | `Decoder::{video,audio,subtitle}()` | `AVCodecID`, `AVMediaType` | `find_decoder` (raw `u32`) + `ensure_codec_type` (raw `i32`) |
58//! | track build, attachment classify, resampler spec, `Debug` | `Parameters::medium()` | `AVMediaType` | `boundary::media_kind_of`, a total fold |
59//! | **the pixel-format census** | `av_pix_fmt_desc_get_id` | `AVPixelFormat` | local `c_int` shim |
60//! | **the pixel-format census** | `av_image_get_buffer_size` | `AVPixelFormat` | local `c_int` shim |
61//! | **the sample-format census** | `av_get_bytes_per_sample` | `AVSampleFormat` | local `c_int` shim |
62//! | HW format negotiation | `get_format` callback list | `AVPixelFormat` | walked as `*const i32` |
63//!
64//! # The dimension-vocabulary sweep
65//!
66//! A frame has more than one extent, and a judge that reads the wrong
67//! one is not a judge. `AVFrame.width`/`.height` are the **display**
68//! dims; what gets *allocated* is the **coded** extent on the software
69//! road and the **frames-context pool** on the hardware one. On a
70//! cropped stream they diverge without limit — measured on this build,
71//! an h264 clip carrying SPS cropping shows 32x32 display over a
72//! 1920x1088 coded surface, a 2040x gap.
73//!
74//! Every site that reads a dimension, and which vocabulary it needs:
75//!
76//! | site | reads | sizes what | verdict |
77//! |---|---|---|---|
78//! | `judge_buffer` | `AVFrame.width/height` at `get_buffer2` | the software allocation's **cost** | **correct**: measured, libavcodec hands this hook the frame at *coded* extent (1920x1088, aligned 1920x1090, 2,092,831 bytes), and the footprint prices those aligned dims against `max_frame_bytes`. Logical extent is not this seat's question — `max_pixels` is enforced by `ff_set_dimensions` against the **raw** dims, which is the semantics it has |
79//! | `get_hw_format` | `AVCodecContext.coded_width/height` | the hardware pool | **correct, and new**: the display dims `max_pixels` was checked against are blind to it |
80//! | `judge_hw_transfer` | the frames-context pool dims | the transfer's CPU destination | **was display — repriced** |
81//! | `estimate_transfer_bytes` | the frames-context pool dims | the probe's pending budget | correct already, and its doc named this trap first |
82//! | `drain_into_pending` (two sites) | `AVFrame.width/height` | **nothing** — log fields only | benign |
83//! | `VideoDecoder::width/height` | the decoder's display dims | nothing; a public accessor | correct — display is what a caller is asking for |
84//! | `copy_out_planes` | the converted frame's own extent | the plane copy | correct — a decoded CPU frame's extent *is* its allocation |
85//!
86//! The pattern worth keeping: **the extent to judge is the one the
87//! allocator will use, and it is never assumed — it is read from
88//! whatever structure the allocation is sized from.** Where that
89//! structure cannot be read, the judge fails closed, because an
90//! unprovable extent is not a small one.
91//!
92//! And the capstone the whole series arrives at, which generalises both
93//! tables above:
94//!
95//! > **A judge must dominate the allocator's arithmetic, not the
96//! > payload's.**
97//!
98//! Every ceiling here answers "may this be allocated?", so the number
99//! it compares has to be what the *allocator* will take — not what the
100//! bytes nominally weigh, not what a tight layout would cost, and not
101//! what the header displays. The two differ by under one percent on
102//! ordinary frames, which is precisely why every under-pricing defect
103//! in this release hid behind a shape big enough for the slack not to
104//! show: `nv12` 16x16 is 384 bytes of pixels and a 1,792-byte
105//! allocation, a one-sample eight-channel planar frame is 16 bytes of
106//! samples and 768 allocated, and `yuv420p` 1920x1080 is 3,110,400
107//! against 3,133,696. See [`crate::footprint`], where the pricing lives
108//! and where the estimates are verified against real allocations rather
109//! than argued.
110//!
111//! The last three rows of the enum table above are the class **inside
112//! this crate's own new code**, and the census rows are its sharpest instance: that code
113//! exists precisely to price formats this build's bindings may not
114//! name, and the binding it called handed those ids back as a closed
115//! `AVPixelFormat`. Every future format would have become an invalid
116//! enum value on the way into the pricing meant to handle it — the
117//! census would have been undefined behaviour on exactly its reason for
118//! existing. Writing the discipline down was not enough; it had to be
119//! re-applied to the code that enforces it.
120//!
121//! The still road's side-data judgement is the same lesson one level
122//! up, about passes rather than fields: it was correct, and it ran
123//! after `copy_out_planes`, so an over-budget still had already bought
124//! up to `max_frame_bytes` of plane copies before its annotations were
125//! totalled. It reads only header fields and allocates nothing, so it
126//! now runs with the other free judgements. **Everything a conversion
127//! can refuse is refused before anything it can allocate is
128//! allocated.**
129//!
130//! The picture road's byte ceiling is now judged from the **geometry
131//! alone** — the format's row width times its row count, which no
132//! number the frame chose can influence — so it runs before any stride
133//! is so much as read. Then every stride is judged, in its own pass,
134//! before a single plane is bought: a layout fault is a property of the
135//! frame, knowable before any of it is paid for, and discovering it
136//! three plane allocations in was how a refused frame still cost three
137//! allocations.
138//!
139//! The colour row is the shape to copy: a fold that cannot fail and
140//! maps everything unknown onto a named "not stated" leaves nothing for
141//! an order to get wrong.
142//!
143//! [law]: mediadecode::adapter#the-d-seat-amputation-contract
144use core::ptr::{addr_of, read_unaligned};
145
146use derive_more::{IsVariant, TryUnwrap, Unwrap};
147use ffmpeg_next::ffi::{
148  AV_NOPTS_VALUE, AVChromaLocation, AVColorPrimaries, AVColorRange, AVColorSpace,
149  AVColorTransferCharacteristic, AVFrame, AVFrameSideDataType, AVPictureType, AVSubtitleType,
150};
151use mediadecode::{
152  PixelFormat, Timebase, Timestamp,
153  color::{ChromaLocation, ColorInfo, ColorMatrix, ColorPrimaries, ColorRange, ColorTransfer},
154  frame::{AudioFrame, Dimensions, ImageFrame, Plane, Rect, SubtitleFrame, VideoFrame},
155  subtitle::{Bitmap as SubtitleBitmap, SubtitlePayload, Text as SubtitleText},
156};
157use mediaframe::audio::ChannelLayoutDescription;
158use smol_str::SmolStr;
159
160use crate::{
161  boundary,
162  buffer::FfmpegBytes,
163  extras::{
164    AudioFrameExtra, ImageFrameExtra, ImageOrientation, PictureType, SideDataEntry,
165    SubtitleFrameExtra, VideoFrameExtra,
166  },
167  limits::FrameLimits,
168  pixdesc,
169  sample_format::SampleFormat,
170};
171
172/// Payload for [`ConvertError::UnsupportedPixelFormat`].
173///
174/// The frame's pixel format isn't in the closed CPU-format set this
175/// crate supports for safe per-plane access.
176#[derive(Debug, Clone)]
177pub struct UnsupportedPixelFormat {
178  format: PixelFormat,
179  raw: i32,
180  name: Option<SmolStr>,
181}
182
183impl UnsupportedPixelFormat {
184  /// Constructs an `UnsupportedPixelFormat` payload.
185  #[inline]
186  pub const fn new(format: PixelFormat, raw: i32, name: Option<SmolStr>) -> Self {
187    Self { format, raw, name }
188  }
189
190  /// The unified vocabulary's answer for [`Self::raw`].
191  ///
192  /// [`PixelFormat::None`] whenever the raw integer has no mapping — a
193  /// hardware surface, a Bayer mosaic, a format FFmpeg gained after
194  /// this build. That is a *value*, not a failed lookup, and it is
195  /// deliberately not made to carry the integer: [`Self::raw`] and
196  /// [`Self::name`] are where the identity survives.
197  #[inline]
198  pub const fn format(&self) -> &PixelFormat {
199    &self.format
200  }
201  /// The raw `AVFrame.format` integer, exactly as FFmpeg wrote it.
202  ///
203  /// Present at every tier — it costs one `i32` — because it is the
204  /// only field that is always available and always precise. Without
205  /// it the message for the fall-through case says `None` and names
206  /// nothing at all.
207  #[inline]
208  pub const fn raw(&self) -> i32 {
209    self.raw
210  }
211  /// FFmpeg's own name for [`Self::raw`] (`av_get_pix_fmt_name`), when
212  /// libavutil has one.
213  ///
214  /// `None` for an integer libavutil does not describe — a corrupt
215  /// read, or a format from a newer library than the one linked.
216  #[inline]
217  pub fn name(&self) -> Option<&str> {
218    self.name.as_deref()
219  }
220}
221
222impl core::fmt::Display for UnsupportedPixelFormat {
223  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
224    match &self.name {
225      Some(name) => write!(
226        f,
227        "convert: unsupported pixel format {:?} (AVPixelFormat {} = {name:?})",
228        self.format, self.raw
229      ),
230      None => write!(
231        f,
232        "convert: unsupported pixel format {:?} (AVPixelFormat {}, unnamed by libavutil)",
233        self.format, self.raw
234      ),
235    }
236  }
237}
238
239/// Payload for [`ConvertError::InvalidPlaneLayout`].
240///
241/// A plane reported `linesize <= 0` or otherwise inconsistent layout.
242#[derive(Debug, Clone, Copy)]
243pub struct InvalidPlaneLayout {
244  plane: usize,
245}
246
247impl InvalidPlaneLayout {
248  /// Constructs an `InvalidPlaneLayout` payload.
249  #[inline]
250  pub const fn new(plane: usize) -> Self {
251    Self { plane }
252  }
253  /// Plane index.
254  #[inline]
255  pub const fn plane(&self) -> usize {
256    self.plane
257  }
258}
259
260impl core::fmt::Display for InvalidPlaneLayout {
261  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
262    write!(f, "convert: invalid layout on plane {}", self.plane)
263  }
264}
265
266/// Payload for [`ConvertError::BufferAcquireFailed`].
267///
268/// A plane's `data[i]` does not lie inside any of the frame's own
269/// `buf[]` allocations, so its extent cannot be proved and nothing may
270/// be read from it.
271///
272/// **A fact about the frame, not about the moment.** An exhausted
273/// allocator is [`CarrierAllocFailed`] — the two were one arm once, and
274/// telling them apart is what lets a decoder park a frame worth
275/// re-attempting without parking one that will never convert.
276#[derive(Debug, Clone, Copy)]
277pub struct BufferAcquireFailed {
278  plane: usize,
279}
280
281impl BufferAcquireFailed {
282  /// Constructs a `BufferAcquireFailed` payload.
283  #[inline]
284  pub const fn new(plane: usize) -> Self {
285    Self { plane }
286  }
287  /// Plane index whose buffer couldn't be acquired.
288  #[inline]
289  pub const fn plane(&self) -> usize {
290    self.plane
291  }
292}
293
294/// Payload for [`ConvertError::CarrierAllocFailed`].
295///
296/// The plane's extent was proved and the carrier still could not be
297/// made: a refcount the view lane could not take, a gather or copy the
298/// allocator refused.
299///
300/// **A fact about the moment, not about the frame.** The same frame may
301/// convert perfectly a moment later, which is why the decode roads park
302/// it and re-attempt rather than letting it go.
303#[derive(Debug, Clone, Copy)]
304pub struct CarrierAllocFailed {
305  plane: usize,
306}
307
308impl CarrierAllocFailed {
309  /// Constructs a `CarrierAllocFailed` payload.
310  #[inline]
311  #[must_use]
312  pub const fn new(plane: usize) -> Self {
313    Self { plane }
314  }
315
316  /// Plane index whose carrier could not be allocated.
317  #[inline]
318  #[must_use]
319  pub const fn plane(&self) -> usize {
320    self.plane
321  }
322}
323
324impl core::fmt::Display for CarrierAllocFailed {
325  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
326    write!(
327      f,
328      "convert: could not allocate a carrier for plane {}",
329      self.plane
330    )
331  }
332}
333
334impl std::error::Error for CarrierAllocFailed {}
335
336impl core::fmt::Display for BufferAcquireFailed {
337  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
338    write!(
339      f,
340      "convert: could not acquire buffer ref for plane {}",
341      self.plane
342    )
343  }
344}
345
346/// Payload for [`ConvertError::TooManyPixels`].
347///
348/// A frame declares more pixels than the session's
349/// [`FrameLimits::max_pixels`] allows.
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub struct TooManyPixels {
352  pixels: u64,
353  limit: u64,
354}
355
356impl TooManyPixels {
357  /// Constructs a `TooManyPixels` payload.
358  #[inline]
359  pub const fn new(pixels: u64, limit: u64) -> Self {
360    Self { pixels, limit }
361  }
362  /// The pixel count the frame declared.
363  #[inline]
364  pub const fn pixels(&self) -> u64 {
365    self.pixels
366  }
367  /// The ceiling in force.
368  #[inline]
369  pub const fn limit(&self) -> u64 {
370    self.limit
371  }
372}
373
374impl core::fmt::Display for TooManyPixels {
375  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
376    write!(
377      f,
378      "convert: a {}-pixel frame exceeds the {}-pixel ceiling",
379      self.pixels, self.limit
380    )
381  }
382}
383
384/// Payload for [`ConvertError::FrameTooLarge`].
385///
386/// A frame's planes would export more bytes than the session's
387/// [`FrameLimits::max_frame_bytes`] allows.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub struct FrameTooLarge {
390  bytes: usize,
391  limit: usize,
392}
393
394impl FrameTooLarge {
395  /// Constructs a `FrameTooLarge` payload.
396  #[inline]
397  pub const fn new(bytes: usize, limit: usize) -> Self {
398    Self { bytes, limit }
399  }
400  /// The bytes the frame's planes would have exported.
401  #[inline]
402  pub const fn bytes(&self) -> usize {
403    self.bytes
404  }
405  /// The ceiling in force.
406  #[inline]
407  pub const fn limit(&self) -> usize {
408    self.limit
409  }
410}
411
412impl core::fmt::Display for FrameTooLarge {
413  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
414    write!(
415      f,
416      "convert: a frame exporting {} bytes exceeds the {}-byte ceiling",
417      self.bytes, self.limit
418    )
419  }
420}
421
422/// Payload for [`ConvertError::InvalidSampleCount`].
423///
424/// An audio frame declares a negative `nb_samples`.
425///
426/// Refused rather than floored to zero. A negative count is not an
427/// empty frame — it is a header that cannot be read — and clamping it
428/// turned a malformed frame into a well-formed empty one that a
429/// consumer would have gone on decoding past.
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431pub struct InvalidSampleCount {
432  count: i32,
433}
434
435impl InvalidSampleCount {
436  /// Constructs an `InvalidSampleCount` payload.
437  #[inline]
438  pub const fn new(count: i32) -> Self {
439    Self { count }
440  }
441  /// The count the frame declared.
442  #[inline]
443  pub const fn count(&self) -> i32 {
444    self.count
445  }
446}
447
448impl core::fmt::Display for InvalidSampleCount {
449  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
450    write!(f, "convert: {} is not a sample count", self.count)
451  }
452}
453
454/// Payload for [`ConvertError::UnsupportedSampleFormat`].
455///
456/// The frame's sample format has no byte width — `AV_SAMPLE_FMT_NONE`,
457/// or a format newer than this build names.
458///
459/// Checked **before** the zero-sample shortcut, because a frame with no
460/// readable format is malformed whether or not it carries samples.
461/// Letting an empty one through returned an `AudioFrame` advertising a
462/// format nothing can interpret.
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub struct UnsupportedSampleFormat {
465  raw: i32,
466}
467
468impl UnsupportedSampleFormat {
469  /// Constructs an `UnsupportedSampleFormat` payload.
470  #[inline]
471  pub const fn new(raw: i32) -> Self {
472    Self { raw }
473  }
474  /// The raw `AVFrame.format` integer, exactly as FFmpeg wrote it.
475  #[inline]
476  pub const fn raw(&self) -> i32 {
477    self.raw
478  }
479}
480
481impl core::fmt::Display for UnsupportedSampleFormat {
482  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
483    write!(
484      f,
485      "convert: AVSampleFormat {} has no byte width this build can use",
486      self.raw
487    )
488  }
489}
490
491/// Payload for [`ConvertError::UnsupportedChannelCount`].
492///
493/// A channel count this crate will not carry: more than
494/// [`u8::MAX`], which the portable `AudioFrame` seat cannot hold, or
495/// none at all on a frame that claims samples.
496///
497/// **Refused, never clamped.** Clamping to 255 was silent truncation of
498/// exactly the kind this boundary exists to refuse: a 256-channel
499/// packed frame then computed its byte product from the clipped count
500/// and copied 510 of its 512 bytes, delivering a short buffer that
501/// advertised 255 channels. A short read is not a smaller frame; it is
502/// a wrong one.
503///
504/// The count is carried **signed**, as `AVChannelLayout.nb_channels`
505/// declares it. It has to be: a negative count is one of the things
506/// this arm refuses, and the first version of this refusal read the
507/// count off a materialised layout description that had already
508/// floored it to zero — so `nb_channels == -1` arrived looking like a
509/// legitimate zero-channel frame and was never seen by the guard meant
510/// to catch it.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub struct UnsupportedChannelCount {
513  channels: i32,
514}
515
516impl UnsupportedChannelCount {
517  /// Constructs an `UnsupportedChannelCount` payload.
518  #[inline]
519  pub const fn new(channels: i32) -> Self {
520    Self { channels }
521  }
522  /// The count the frame's layout declared, exactly as it read.
523  #[inline]
524  pub const fn channels(&self) -> i32 {
525    self.channels
526  }
527}
528
529impl core::fmt::Display for UnsupportedChannelCount {
530  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
531    write!(
532      f,
533      "convert: {} channels cannot be carried (1..={} on a frame with samples)",
534      self.channels,
535      u8::MAX,
536    )
537  }
538}
539
540/// Payload for [`ConvertError::InvalidDimensions`].
541///
542/// A picture frame declaring a negative width or height.
543///
544/// The sibling of [`InvalidSampleCount`] on the picture road, and found
545/// by auditing for it: `width` and `height` were floored with `.max(0)`
546/// before anything judged them, so a declared `-1` became `0` and then
547/// sailed through the pixel ceiling (zero pixels is under every
548/// ceiling) to produce a real `VideoFrame` of zero extent. A refusal
549/// delivered as a successful decode, which is the one outcome worse
550/// than an error.
551///
552/// Zero itself is **not** refused here: it is what an unset dimension
553/// reads as, the ceilings and the plane geometry both handle it, and
554/// inventing a refusal for it would be policy this audit has no
555/// evidence for. Only the negative — which cannot be a dimension under
556/// any reading — is named.
557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
558pub struct InvalidDimensions {
559  width: i32,
560  height: i32,
561}
562
563impl InvalidDimensions {
564  /// Constructs an `InvalidDimensions` payload.
565  #[inline]
566  pub const fn new(width: i32, height: i32) -> Self {
567    Self { width, height }
568  }
569  /// The width the frame declared, exactly as it read.
570  #[inline]
571  pub const fn width(&self) -> i32 {
572    self.width
573  }
574  /// The height the frame declared, exactly as it read.
575  #[inline]
576  pub const fn height(&self) -> i32 {
577    self.height
578  }
579}
580
581impl core::fmt::Display for InvalidDimensions {
582  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
583    write!(
584      f,
585      "convert: frame declares dimensions {}x{}, which are not a picture",
586      self.width, self.height,
587    )
588  }
589}
590
591/// Payload for [`ConvertError::ImageSideDataTooLarge`].
592///
593/// A decoded still whose side data exceeds
594/// [`FrameLimits::max_image_side_data_bytes`](crate::FrameLimits::max_image_side_data_bytes).
595///
596/// Refused rather than truncated. The shared stream collector drops
597/// what does not fit and logs it, which on a still is the wrong answer
598/// twice: an ICC profile is the entry most likely to be large and the
599/// one whose loss silently changes the colours, and the drop is
600/// positional, so a big profile pushes the display matrix off the end
601/// and the picture comes back rotated wrong with nothing to say so.
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603pub struct ImageSideDataTooLarge {
604  bytes: usize,
605  limit: usize,
606}
607
608impl ImageSideDataTooLarge {
609  /// Constructs an `ImageSideDataTooLarge` payload.
610  #[inline]
611  pub const fn new(bytes: usize, limit: usize) -> Self {
612    Self { bytes, limit }
613  }
614  /// Bytes the still's side data reached.
615  #[inline]
616  pub const fn bytes(&self) -> usize {
617    self.bytes
618  }
619  /// The ceiling in force.
620  #[inline]
621  pub const fn limit(&self) -> usize {
622    self.limit
623  }
624}
625
626impl core::fmt::Display for ImageSideDataTooLarge {
627  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
628    write!(
629      f,
630      "convert: still side data reaches {} bytes over a ceiling of {}",
631      self.bytes, self.limit,
632    )
633  }
634}
635
636/// Payload for [`ConvertError::ImageSideDataEntries`].
637///
638/// A decoded still declaring more side-data entries than this crate
639/// will walk. The count sibling of [`ImageSideDataTooLarge`], and
640/// refused for the same reason: truncating the list is how the
641/// orientation goes missing.
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub struct ImageSideDataEntries {
644  count: usize,
645  limit: usize,
646}
647
648impl ImageSideDataEntries {
649  /// Constructs an `ImageSideDataEntries` payload.
650  #[inline]
651  pub const fn new(count: usize, limit: usize) -> Self {
652    Self { count, limit }
653  }
654  /// Entries the still declared.
655  #[inline]
656  pub const fn count(&self) -> usize {
657    self.count
658  }
659  /// The cap in force.
660  #[inline]
661  pub const fn limit(&self) -> usize {
662    self.limit
663  }
664}
665
666impl core::fmt::Display for ImageSideDataEntries {
667  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
668    write!(
669      f,
670      "convert: still declares {} side-data entries over a cap of {}",
671      self.count, self.limit,
672    )
673  }
674}
675
676/// Errors from [`av_frame_to_video_frame`].
677#[derive(Debug, Clone, IsVariant, Unwrap, TryUnwrap)]
678#[non_exhaustive]
679#[unwrap(ref, ref_mut)]
680#[try_unwrap(ref, ref_mut)]
681pub enum ConvertError {
682  /// `av_frame` was null.
683  NullFrame,
684  /// The frame declares more pixels than the ceiling allows. Refused
685  /// **before** any plane is allocated.
686  TooManyPixels(TooManyPixels),
687  /// The frame's planes would export more bytes than the ceiling
688  /// allows. Refused **before** any plane is allocated.
689  FrameTooLarge(FrameTooLarge),
690  /// An audio frame declares a negative sample count.
691  InvalidSampleCount(InvalidSampleCount),
692  /// A picture frame declares a negative width or height.
693  InvalidDimensions(InvalidDimensions),
694  /// A decoded still's side data is larger than the ceiling allows.
695  ImageSideDataTooLarge(ImageSideDataTooLarge),
696  /// A decoded still declares more side-data entries than this crate
697  /// will walk.
698  ImageSideDataEntries(ImageSideDataEntries),
699  /// An audio frame's sample format has no byte width.
700  UnsupportedSampleFormat(UnsupportedSampleFormat),
701  /// An audio frame's channel count is one this crate will not carry.
702  UnsupportedChannelCount(UnsupportedChannelCount),
703  /// The frame's pixel format isn't in the closed CPU-format set this
704  /// crate supports for safe per-plane access.
705  UnsupportedPixelFormat(UnsupportedPixelFormat),
706  /// A plane reported `linesize <= 0` or otherwise inconsistent layout.
707  InvalidPlaneLayout(InvalidPlaneLayout),
708  /// A plane's `data[i]` does not lie inside any of the frame's own
709  /// `buf[]` allocations, so its extent cannot be proved.
710  BufferAcquireFailed(BufferAcquireFailed),
711  /// The plane's extent was proved and the carrier still could not be
712  /// made. See [`CarrierAllocFailed`].
713  CarrierAllocFailed(CarrierAllocFailed),
714}
715
716impl ConvertError {
717  /// Whether a decode session should **park** the frame this refusal
718  /// came from and re-attempt it before receiving another.
719  ///
720  /// The same shape as the demux seat, for the same reason: a decoder's
721  /// `receive_frame` advances libavcodec, so a conversion that then
722  /// fails on an allocation would lose a frame nothing can ask for
723  /// again. Only an allocation qualifies — every other arm here is a
724  /// fact about the frame (a format nothing can carry, a layout that
725  /// does not add up, a plane outside its own buffers), and re-offering
726  /// one of those would answer every later receive with the same error.
727  #[inline]
728  pub(crate) const fn parks_in_decode(&self) -> bool {
729    matches!(self, Self::CarrierAllocFailed(_))
730  }
731}
732
733impl core::fmt::Display for ConvertError {
734  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
735    match self {
736      Self::NullFrame => write!(f, "convert: AVFrame pointer was null"),
737      Self::TooManyPixels(p) => core::fmt::Display::fmt(p, f),
738      Self::FrameTooLarge(p) => core::fmt::Display::fmt(p, f),
739      Self::InvalidSampleCount(p) => core::fmt::Display::fmt(p, f),
740      Self::InvalidDimensions(p) => core::fmt::Display::fmt(p, f),
741      Self::ImageSideDataTooLarge(p) => core::fmt::Display::fmt(p, f),
742      Self::ImageSideDataEntries(p) => core::fmt::Display::fmt(p, f),
743      Self::UnsupportedSampleFormat(p) => core::fmt::Display::fmt(p, f),
744      Self::UnsupportedChannelCount(p) => core::fmt::Display::fmt(p, f),
745      Self::UnsupportedPixelFormat(p) => core::fmt::Display::fmt(p, f),
746      Self::InvalidPlaneLayout(p) => core::fmt::Display::fmt(p, f),
747      Self::BufferAcquireFailed(p) => core::fmt::Display::fmt(p, f),
748      Self::CarrierAllocFailed(p) => core::fmt::Display::fmt(p, f),
749    }
750  }
751}
752
753impl core::error::Error for ConvertError {}
754
755/// Builds [`ConvertError::UnsupportedPixelFormat`] for a frame whose raw
756/// format integer this crate will not deliver.
757///
758/// Both refusal sites go through here so the raw id and the name are
759/// never gathered at one of them and forgotten at the other.
760fn unsupported_pixel_format(format: PixelFormat, raw: i32) -> ConvertError {
761  ConvertError::UnsupportedPixelFormat(UnsupportedPixelFormat::new(
762    format,
763    raw,
764    crate::ffi::pix_fmt_name(raw),
765  ))
766}
767
768/// Safe wrapper around [`av_frame_to_video_frame`] taking a borrowed
769/// [`ffmpeg::Frame`](ffmpeg_next::Frame). Recommended entry point for
770/// most callers — equivalent to passing `frame.as_ptr()` to the
771/// unsafe variant, but the FFmpeg side keeps the frame alive for the
772/// duration of the call so the safety contract is satisfied
773/// internally.
774///
775/// **Borrowed source, owned lane.** This road copies, on purpose and
776/// without a lane to choose. `ffmpeg_next`'s frame wrappers lend
777/// `&mut [u8]` through `data_mut` and share their buffers by refcount
778/// with no copy-on-write, so a caller who still holds the frame holds a
779/// mutable alias of every byte a view would read — and both sides are
780/// `Send`, so the two halves need not even be on one thread. No safe
781/// signature that borrows a frame can hand out a window onto it.
782///
783/// The view lane reaches frames the way it is meant to: through a
784/// decoder, which owns the `AVFrame` it decoded into and never lends it
785/// out. A caller holding an `AVFrame` of their own can use the `unsafe`
786/// entry point below, whose contract names the obligation this
787/// signature cannot express.
788/// The lane is not a parameter here, and asking for one does not
789/// compile:
790///
791/// ```compile_fail,E0107
792/// use mediadecode_ffmpeg::{FrameLimits, View, convert::video_frame_from};
793/// let frame = ffmpeg_next::frame::Video::new(ffmpeg_next::format::Pixel::GRAY8, 64, 4);
794/// let _ = video_frame_from::<View>(&frame, mediadecode::Timebase::default(), FrameLimits::default());
795/// ```
796pub fn video_frame_from(
797  frame: &ffmpeg_next::Frame,
798  time_base: Timebase,
799  limits: FrameLimits,
800) -> Result<VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, FfmpegBytes>, ConvertError> {
801  // SAFETY: `&frame` keeps the AVFrame alive for the duration of this
802  // call; the unsafe convert just reads through the pointer, and the
803  // owned lane copies every byte it reads, so nothing outlives the
804  // borrow.
805  unsafe { av_frame_to_video_frame_as::<crate::Owned>(frame.as_ptr(), time_base, limits) }
806}
807
808/// Safe wrapper around [`av_frame_to_audio_frame`] taking a borrowed
809/// [`ffmpeg::frame::Audio`](ffmpeg_next::frame::Audio).
810///
811/// **Borrowed source, owned lane.** This road copies, on purpose and
812/// without a lane to choose. `ffmpeg_next`'s frame wrappers lend
813/// `&mut [u8]` through `data_mut` and share their buffers by refcount
814/// with no copy-on-write, so a caller who still holds the frame holds a
815/// mutable alias of every byte a view would read — and both sides are
816/// `Send`, so the two halves need not even be on one thread. No safe
817/// signature that borrows a frame can hand out a window onto it.
818///
819/// The view lane reaches frames the way it is meant to: through a
820/// decoder, which owns the `AVFrame` it decoded into and never lends it
821/// out. A caller holding an `AVFrame` of their own can use the `unsafe`
822/// entry point below, whose contract names the obligation this
823/// signature cannot express.
824pub fn audio_frame_from(
825  frame: &ffmpeg_next::frame::Audio,
826  time_base: Timebase,
827  limits: FrameLimits,
828) -> Result<
829  AudioFrame<SampleFormat, ChannelLayoutDescription, AudioFrameExtra, FfmpegBytes>,
830  ConvertError,
831> {
832  // SAFETY: `&frame` keeps the AVFrame alive for the duration of this
833  // call, and the owned lane copies what it reads.
834  unsafe { av_frame_to_audio_frame_as::<crate::Owned>(frame.as_ptr(), time_base, limits) }
835}
836
837/// Safe wrapper around [`av_subtitle_to_subtitle_frame`] taking a
838/// borrowed [`ffmpeg::Subtitle`](ffmpeg_next::Subtitle).
839///
840/// Owned-lane, like its siblings — though a subtitle rect is copied on
841/// both lanes anyway (`AVSubtitleRect` has no refcounted buffer), so
842/// here the restriction costs a caller nothing at all.
843pub fn subtitle_frame_from(
844  subtitle: &ffmpeg_next::Subtitle,
845  time_base: Timebase,
846) -> Result<SubtitleFrame<SubtitleFrameExtra, FfmpegBytes>, ConvertError> {
847  // SAFETY: `&subtitle` keeps the AVSubtitle alive for the duration
848  // of this call.
849  unsafe { av_subtitle_to_subtitle_frame_as::<crate::Owned>(subtitle.as_ptr(), time_base) }
850}
851
852/// Converts an FFmpeg `AVFrame` (CPU-side, post-`av_hwframe_transfer_data`
853/// or from a software decoder) into a `mediadecode::VideoFrame`
854/// parameterized by [`crate::Ffmpeg`] / `FfmpegBytes`.
855///
856/// `time_base` is the source stream's time base, used to label
857/// `pts`/`duration` as mediatime [`Timestamp`]s.
858///
859/// # Safety
860///
861/// `av_frame` must be a live `*const AVFrame` for the duration of this
862/// call. The frame's buffers are neither consumed nor referenced —
863/// every byte the produced `VideoFrame` carries is a copy, so the
864/// source frame may be unreffed, reused or dropped the moment this
865/// returns.
866/// * no handle capable of **mutating** the frame's buffers may
867///   outlive this call while the returned carriers do. On the view
868///   lane a plane is a window into `frame`'s own allocation, and
869///   `ffmpeg_next`'s wrappers lend `&mut [u8]` by refcount with no
870///   copy-on-write — so keeping the source frame and writing through
871///   it would race a carrier a consumer is reading. Consume the
872///   frame, or use the owned lane, or use the safe borrowed wrapper
873///   (which is the owned lane for exactly this reason).
874pub(crate) unsafe fn av_frame_to_video_frame_as<C: crate::FfmpegCarrier + crate::CarrierOps>(
875  av_frame: *const AVFrame,
876  time_base: Timebase,
877  limits: FrameLimits,
878) -> Result<VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>, ConvertError> {
879  if av_frame.is_null() {
880    return Err(ConvertError::NullFrame);
881  }
882  // We deliberately never form `&*av_frame` — `AVFrame` contains
883  // bindgen-enum fields (`pict_type`, `color_primaries`, `colorspace`,
884  // `color_trc`, `color_range`, `chroma_location`, and an embedded
885  // `AVChannelLayout` whose `order` is also enum-typed). If FFmpeg
886  // (or a hostile decoder) writes a value outside our bindgen's
887  // discriminant set, the `&AVFrame` reference itself would be
888  // immediate UB before any field access. Working through the raw
889  // pointer with field-by-field reads (and `addr_of!` for the
890  // enum-typed fields) sidesteps this whole class.
891
892  // Non-enum primitives are safe to read via `(*av_frame).field`
893  // because validity for `i32`/`i64`/pointer types is just
894  // "initialized bytes"; the surrounding struct's enum fields don't
895  // contaminate this read.
896  let format_raw = unsafe { (*av_frame).format };
897  let width_raw = unsafe { (*av_frame).width };
898  let height_raw = unsafe { (*av_frame).height };
899  let pts_raw = unsafe { (*av_frame).pts };
900  let duration_raw = unsafe { (*av_frame).duration };
901  // **Judged before anything consumes them.** These were floored with
902  // `.max(0)`, which turned a declared `-1` into `0` — and zero pixels
903  // is under every ceiling, so the frame was built rather than refused.
904  // The same order bug the audio road had with its channel count: the
905  // field's first consumer ran ahead of the field's validator.
906  if width_raw < 0 || height_raw < 0 {
907    return Err(ConvertError::InvalidDimensions(InvalidDimensions::new(
908      width_raw, height_raw,
909    )));
910  }
911  let width = width_raw as u32;
912  let height = height_raw as u32;
913  let pix_fmt = boundary::from_av_pixel_format(format_raw);
914
915  // SAFETY: caller upholds `av_frame`'s liveness for the whole call.
916  let (planes_out, plane_count) = unsafe {
917    copy_out_planes::<C>(
918      av_frame,
919      &pix_fmt,
920      format_raw,
921      width,
922      height,
923      limits,
924      PlaneRoad::Video,
925    )
926  }?;
927
928  // pts / duration / time_base
929  let pts = if pts_raw != AV_NOPTS_VALUE {
930    Some(Timestamp::new(pts_raw, time_base))
931  } else {
932    None
933  };
934  let duration = if duration_raw > 0 {
935    Some(Timestamp::new(duration_raw, time_base))
936  } else {
937    None
938  };
939
940  // Visible rect (FFmpeg crop).
941  let visible_rect = unsafe { build_visible_rect(av_frame, width, height) };
942
943  // Color metadata (the universal cross-backend bits). We read each
944  // bindgen enum-typed field through a raw `i32` window — even
945  // referencing an out-of-range enum value is UB before any cast can
946  // run, so we never let Rust assume the field actually inhabits the
947  // enum's discriminant set. FFmpeg version skew or a buggy decoder
948  // can put unknown values into these fields.
949
950  // SAFETY: `av_frame` points at a live AVFrame; `addr_of!` computes
951  // the address without forming a reference, and `read_unaligned::<i32>`
952  // is sound because each of these enum types has the layout of
953  // `c_int` (i32) per FFmpeg's bindgen output.
954  let color_primaries_raw =
955    unsafe { read_unaligned(addr_of!((*av_frame).color_primaries) as *const i32) };
956  let color_trc_raw = unsafe { read_unaligned(addr_of!((*av_frame).color_trc) as *const i32) };
957  let colorspace_raw = unsafe { read_unaligned(addr_of!((*av_frame).colorspace) as *const i32) };
958  let color_range_raw = unsafe { read_unaligned(addr_of!((*av_frame).color_range) as *const i32) };
959  let chroma_location_raw =
960    unsafe { read_unaligned(addr_of!((*av_frame).chroma_location) as *const i32) };
961  let color = ColorInfo::UNSPECIFIED
962    .with_primaries(map_primaries(color_primaries_raw))
963    .with_transfer(map_transfer(color_trc_raw))
964    .with_matrix(map_matrix(colorspace_raw))
965    .with_range(map_range_for(&pix_fmt, color_range_raw))
966    .with_chroma_location(map_chroma_loc(chroma_location_raw));
967
968  // Backend-specific extras.
969  let extra = unsafe { build_video_frame_extra(av_frame) };
970
971  // pix_fmt is already mediadecode::PixelFormat thanks to the boundary
972  // function above, so we just pass it through.
973  let mut out = VideoFrame::new(
974    Dimensions::new(width, height),
975    pix_fmt,
976    planes_out,
977    plane_count,
978    extra,
979  )
980  .with_pts(pts)
981  .with_duration(duration)
982  .with_color(color);
983  if let Some(r) = visible_rect {
984    out = out.with_visible_rect(Some(r));
985  }
986  Ok(out)
987}
988
989/// Safe wrapper around [`av_frame_to_image_frame`] taking a borrowed
990/// [`ffmpeg::Frame`](ffmpeg_next::Frame).
991///
992/// Owned-lane, for the reason [`video_frame_from`] states: a borrowed
993/// frame cannot be safely viewed.
994pub fn image_frame_from(
995  frame: &ffmpeg_next::Frame,
996  limits: FrameLimits,
997) -> Result<ImageFrame<mediadecode::PixelFormat, ImageFrameExtra, FfmpegBytes>, ConvertError> {
998  // SAFETY: `&frame` keeps the AVFrame alive for the duration of this
999  // call, and the owned lane copies what it reads.
1000  unsafe { av_frame_to_image_frame_as::<crate::Owned>(frame.as_ptr(), limits) }
1001}
1002
1003/// Converts an FFmpeg `AVFrame` holding a decoded **still** into a
1004/// [`mediadecode::frame::ImageFrame`].
1005///
1006/// The same picture geometry as [`av_frame_to_video_frame`] — one
1007/// plane-extraction rule, shared — and none of its timeline. There is
1008/// no `time_base` parameter because there is nothing to label with it:
1009/// a still is not on the timeline, so `ImageFrame` has no `pts` and no
1010/// `duration` seats. Whatever `AVFrame.pts` a one-shot image decoder
1011/// happens to leave behind is an artefact of the packet it was fed,
1012/// not a fact about the picture, and it is deliberately dropped rather
1013/// than carried into a field that would invite a consumer to sort by
1014/// it.
1015///
1016/// `visible_rect` is FFmpeg's crop, exactly as on the video side, and
1017/// it earns its place here: a JPEG's coded dimensions are rounded up
1018/// to its MCU grid, so the crop is what distinguishes the picture from
1019/// the padding the encoder added to reach a multiple of 8 or 16.
1020///
1021/// # Safety
1022///
1023/// `av_frame` must be a live `*const AVFrame` for the duration of this
1024/// call. The frame's buffers are not consumed — every byte the
1025/// produced [`ImageFrame`] carries is a copy.
1026/// * no handle capable of **mutating** the frame's buffers may
1027///   outlive this call while the returned carriers do. On the view
1028///   lane a plane is a window into `frame`'s own allocation, and
1029///   `ffmpeg_next`'s wrappers lend `&mut [u8]` by refcount with no
1030///   copy-on-write — so keeping the source frame and writing through
1031///   it would race a carrier a consumer is reading. Consume the
1032///   frame, or use the owned lane, or use the safe borrowed wrapper
1033///   (which is the owned lane for exactly this reason).
1034pub(crate) unsafe fn av_frame_to_image_frame_as<C: crate::FfmpegCarrier + crate::CarrierOps>(
1035  av_frame: *const AVFrame,
1036  limits: FrameLimits,
1037) -> Result<ImageFrame<mediadecode::PixelFormat, ImageFrameExtra, C::Buffer>, ConvertError> {
1038  if av_frame.is_null() {
1039    return Err(ConvertError::NullFrame);
1040  }
1041  // Same stance as `av_frame_to_video_frame`: never form `&AVFrame`.
1042  // See its comments for why every read here goes through the raw
1043  // pointer, and why the enum-typed fields go through `addr_of!` +
1044  // `read_unaligned::<i32>`.
1045  let format_raw = unsafe { (*av_frame).format };
1046  let width_raw = unsafe { (*av_frame).width };
1047  let height_raw = unsafe { (*av_frame).height };
1048  // **Judged before anything consumes them.** These were floored with
1049  // `.max(0)`, which turned a declared `-1` into `0` — and zero pixels
1050  // is under every ceiling, so the frame was built rather than refused.
1051  // The same order bug the audio road had with its channel count: the
1052  // field's first consumer ran ahead of the field's validator.
1053  if width_raw < 0 || height_raw < 0 {
1054    return Err(ConvertError::InvalidDimensions(InvalidDimensions::new(
1055      width_raw, height_raw,
1056    )));
1057  }
1058  let width = width_raw as u32;
1059  let height = height_raw as u32;
1060  let pix_fmt = boundary::from_av_pixel_format(format_raw);
1061
1062  // **The still's side data is judged here, before a plane is bought.**
1063  // It reads only header fields and allocates nothing, so it is one of
1064  // the free judgements and belongs with them. After the copy it meant
1065  // an over-budget still had already paid for up to `max_frame_bytes`
1066  // of plane copies before its annotations were so much as totalled —
1067  // a correct refusal delivered after the expensive half of the work.
1068  //
1069  // Everything this conversion can refuse is now refused before
1070  // anything it can allocate is allocated.
1071  //
1072  // SAFETY: caller upholds `av_frame`'s liveness for the whole call.
1073  unsafe { measure_image_side_data(av_frame, limits) }?;
1074
1075  // SAFETY: caller upholds `av_frame`'s liveness for the whole call.
1076  let (planes_out, plane_count) = unsafe {
1077    copy_out_planes::<C>(
1078      av_frame,
1079      &pix_fmt,
1080      format_raw,
1081      width,
1082      height,
1083      limits,
1084      PlaneRoad::Still,
1085    )
1086  }?;
1087
1088  // SAFETY: `av_frame` is live; the crop fields are plain integers.
1089  let visible_rect = unsafe { build_visible_rect(av_frame, width, height) };
1090
1091  // SAFETY: `av_frame` points at a live AVFrame; each enum-typed field
1092  // is read through a raw `i32` window rather than as its bindgen enum.
1093  let color_primaries_raw =
1094    unsafe { read_unaligned(addr_of!((*av_frame).color_primaries) as *const i32) };
1095  let color_trc_raw = unsafe { read_unaligned(addr_of!((*av_frame).color_trc) as *const i32) };
1096  let colorspace_raw = unsafe { read_unaligned(addr_of!((*av_frame).colorspace) as *const i32) };
1097  let color_range_raw = unsafe { read_unaligned(addr_of!((*av_frame).color_range) as *const i32) };
1098  let chroma_location_raw =
1099    unsafe { read_unaligned(addr_of!((*av_frame).chroma_location) as *const i32) };
1100  let color = ColorInfo::UNSPECIFIED
1101    .with_primaries(map_primaries(color_primaries_raw))
1102    .with_transfer(map_transfer(color_trc_raw))
1103    .with_matrix(map_matrix(colorspace_raw))
1104    // The `yuvj*` override matters more here than anywhere: cover art
1105    // is overwhelmingly MJPEG, and MJPEG is where a frame's
1106    // `color_range` is routinely left unspecified on a signal that is
1107    // full-range by definition.
1108    .with_range(map_range_for(&pix_fmt, color_range_raw))
1109    .with_chroma_location(map_chroma_loc(chroma_location_raw));
1110
1111  // SAFETY: caller upholds liveness; the collector reads the enum-typed
1112  // `type_` raw and bounds-checks each entry's data slice.
1113  let side_data = unsafe { collect_image_side_data(av_frame, limits) }?;
1114  let extra = ImageFrameExtra::default()
1115    .with_orientation(orientation_of(&side_data))
1116    .with_side_data(side_data);
1117
1118  Ok(
1119    ImageFrame::new(
1120      Dimensions::new(width, height),
1121      pix_fmt,
1122      planes_out,
1123      plane_count,
1124      extra,
1125    )
1126    .with_visible_rect(visible_rect)
1127    .with_color(color),
1128  )
1129}
1130
1131/// The orientation a still's display matrix names, if it carries one.
1132///
1133/// Read out of the side data this crate already collects rather than
1134/// off the `AVFrame` a second time: the entry is there, whole and
1135/// unparsed, and one read is one place for the fact to come from.
1136///
1137/// `None` when the frame carries no display matrix — the ordinary case
1138/// — and also when it carries one this vocabulary cannot read, in
1139/// which case the raw entry stays in the side-data list rather than
1140/// being lost.
1141fn orientation_of(side_data: &[SideDataEntry]) -> Option<ImageOrientation> {
1142  const DISPLAY_MATRIX: i32 = AVFrameSideDataType::AV_FRAME_DATA_DISPLAYMATRIX as i32;
1143  side_data
1144    .iter()
1145    .find(|entry| entry.kind() == DISPLAY_MATRIX)
1146    .and_then(|entry| ImageOrientation::from_display_matrix(entry.data()))
1147}
1148
1149/// Whether the **video** road can deliver `pix_fmt`.
1150///
1151/// Exposed so a consumer — and this crate's own tests — can ask the
1152/// question the still road answers differently. See [`PlaneRoad`].
1153pub fn is_video_deliverable(pix_fmt: &PixelFormat) -> bool {
1154  pixdesc::is_deliverable(pix_fmt)
1155}
1156
1157/// Which plane vocabulary a conversion is working in.
1158///
1159/// The two roads differ by exactly two layouts. A still may be
1160/// paletted (`pal8`, an indexed PNG or BMP — indices in `data[0]`, a
1161/// fixed 1024-byte palette in `data[1]`) or sub-byte packed (`monob` /
1162/// `monow`, a 1-bit PNG — rows of `ceil(width / 8)`); motion video
1163/// keeps refusing both.
1164///
1165/// **The still road was widened, not the shared one, and that was a
1166/// measured choice.** Widening the shared road would have changed what
1167/// every existing video consumer can be handed — `is_supported_cpu_pix_fmt`,
1168/// the HW transfer validation and the video suites all key off the same
1169/// deliverability answer — to serve formats motion video does not
1170/// occur in. The still road is where indexed and 1-bit pictures
1171/// actually arrive, and it is one enum away.
1172///
1173/// Nothing is converted on either road. mediadecode delivers what
1174/// FFmpeg decoded; turning `pal8` into RGB is colconv's job, one tier
1175/// along, and doing it here would be this crate deciding what a
1176/// consumer's pixels should look like.
1177#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1178enum PlaneRoad {
1179  /// Motion video: the shared vocabulary.
1180  Video,
1181  /// A still: the shared vocabulary plus paletted and sub-byte
1182  /// layouts.
1183  Still,
1184}
1185
1186impl PlaneRoad {
1187  fn is_deliverable(self, pix_fmt: &PixelFormat) -> bool {
1188    match self {
1189      Self::Video => pixdesc::is_deliverable(pix_fmt),
1190      Self::Still => pixdesc::is_still_deliverable(pix_fmt),
1191    }
1192  }
1193
1194  fn plane_geometry(
1195    self,
1196    pix_fmt: &PixelFormat,
1197    width: usize,
1198    height: usize,
1199  ) -> Option<pixdesc::PlaneGeometry> {
1200    match self {
1201      Self::Video => pixdesc::plane_geometry(pix_fmt, width, height),
1202      Self::Still => pixdesc::still_plane_geometry(pix_fmt, width, height),
1203    }
1204  }
1205}
1206
1207/// The planes of a CPU-side picture `AVFrame`, copied out.
1208///
1209/// Shared by the video and image households: the geometry of a still
1210/// is the geometry of a picture, and there is one plane-extraction
1211/// rule here rather than two that could drift apart.
1212///
1213/// Returns the four-slot array and how many of its entries are
1214/// populated. Unused slots hold the shared empty carrier.
1215///
1216/// # Safety
1217///
1218/// `av_frame` must be a live `*const AVFrame` for the duration of this
1219/// call, and `format_raw` / `pix_fmt` / `width` / `height` must be the
1220/// values read from it.
1221unsafe fn copy_out_planes<C: crate::FfmpegCarrier + crate::CarrierOps>(
1222  av_frame: *const AVFrame,
1223  pix_fmt: &PixelFormat,
1224  format_raw: i32,
1225  width: u32,
1226  height: u32,
1227  limits: FrameLimits,
1228  road: PlaneRoad,
1229) -> Result<([Plane<C::Buffer>; 4], u8), ConvertError> {
1230  // The pixel ceiling, first of all — before the format is even looked
1231  // up, because a forged `width` / `height` costs nothing to write and
1232  // everything to honour. libavcodec has normally refused such a frame
1233  // already (the same number reaches `AVCodecContext.max_pixels` when a
1234  // decoder is opened from these limits), but this path also converts
1235  // frames the caller produced by other means, so the ceiling is
1236  // enforced on both sides of that door.
1237  let pixels = u64::from(width) * u64::from(height);
1238  if pixels > limits.max_pixels() {
1239    return Err(ConvertError::TooManyPixels(TooManyPixels::new(
1240      pixels,
1241      limits.max_pixels(),
1242    )));
1243  }
1244  // Reject any format whose planes we can't safely extract — HWACCEL
1245  // surfaces, Bayer mosaics, paletted, and sub-byte bitstream
1246  // packings — before touching plane memory. Without a deliverable
1247  // layout we'd be reading garbage `linesize * height` bytes.
1248  if !road.is_deliverable(pix_fmt) {
1249    return Err(unsupported_pixel_format(pix_fmt.clone(), format_raw));
1250  }
1251  // The per-plane row count and visible (tight) byte width come from
1252  // `pixdesc::plane_geometry`, which derives them from libavutil's own
1253  // `av_image_fill_linesizes` / `av_image_fill_plane_sizes` for this
1254  // exact `(format, width, height)` — correct by construction for every
1255  // deliverable CPU format. For a deliverable format `plane_geometry`
1256  // only returns `None` on out-of-range dimensions; treat that as an
1257  // unsupported frame rather than guessing a layout.
1258  let geom = match road.plane_geometry(pix_fmt, width as usize, height as usize) {
1259    Some(g) => g,
1260    None => return Err(unsupported_pixel_format(pix_fmt.clone(), format_raw)),
1261  };
1262
1263  // The byte ceiling, before a single plane is allocated. Totalled over
1264  // what the planes will *actually* export — which needs the stride
1265  // decision, so it is this crate's real allocation figure rather than
1266  // an estimate of it. A first pass to judge, a second to pay: the
1267  // alternative is discovering the frame was too big three plane
1268  // allocations in, which is the shape that OOMs.
1269  // **Judged from the geometry alone — no per-plane frame read at
1270  // all.** Every plane exports the format's own row width times its own
1271  // row count: a tight stride equals that width and a padded one is
1272  // compacted back to it, so the total does not depend on any number
1273  // the frame chose. That makes this the cheapest judgement available,
1274  // which is why it runs before the strides are so much as looked at.
1275  let mut exported: usize = 0;
1276  for plane_idx in 0..geom.count {
1277    let plane_bytes = geom.row_bytes[plane_idx]
1278      .checked_mul(geom.height[plane_idx])
1279      .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1280        plane_idx,
1281      )))?;
1282    exported = exported
1283      .checked_add(plane_bytes)
1284      .ok_or(ConvertError::FrameTooLarge(FrameTooLarge::new(
1285        usize::MAX,
1286        limits.max_frame_bytes(),
1287      )))?;
1288  }
1289  if exported > limits.max_frame_bytes() {
1290    return Err(ConvertError::FrameTooLarge(FrameTooLarge::new(
1291      exported,
1292      limits.max_frame_bytes(),
1293    )));
1294  }
1295
1296  // **Then every stride, before a single plane is copied.** Splitting
1297  // this out of the copy loop is the point: the loop allocates as it
1298  // goes, so a frame refused on plane 2 had already paid for planes 0
1299  // and 1 and thrown them away. A layout fault is a property of the
1300  // frame, knowable before any of it is bought.
1301  //
1302  // An undersized stride used to be treated as a *padded* one here —
1303  // the branch for a stride that is larger — which meant the frame was
1304  // sized from a row width the plane did not have and the real refusal
1305  // was left to the copy. The copy loop keeps its own form of this
1306  // check: one comparison guarding a `from_raw_parts`, and defence in
1307  // depth at a pointer boundary is not duplication.
1308  for plane_idx in 0..geom.count {
1309    // The palette is flat: its size is the format's, and FFmpeg leaves
1310    // its `linesize` at zero deliberately, so there is no stride here
1311    // to judge.
1312    if geom.palette_plane == Some(plane_idx) {
1313      continue;
1314    }
1315    // SAFETY: `av_frame` is live per the contract and `plane_idx` is
1316    // below the descriptor's plane count, so within `linesize`'s eight
1317    // slots.
1318    let linesize = unsafe { (*av_frame).linesize[plane_idx] };
1319    // A zero stride means the decoder left a plane this format
1320    // populates unset; a negative one is FFmpeg's vertical-flip
1321    // convention, which this crate's safe accessors refuse; and one
1322    // below the row width is a plane that does not hold what the format
1323    // says it holds. All three are the same answer.
1324    if linesize <= 0 || (linesize as usize) < geom.row_bytes[plane_idx] {
1325      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1326        plane_idx,
1327      )));
1328    }
1329  }
1330
1331  let mut planes_out: [Plane<C::Buffer>; 4] = std::array::from_fn(|_| plane_placeholder::<C>());
1332  let mut plane_count: u8 = 0;
1333
1334  // The loop body indexes `planes_out`, the AVFrame's `linesize`, and
1335  // its `data` array all by `plane_idx`. None of these are slices we
1336  // can iterate via `iter_mut().enumerate()` — `linesize` / `data` are
1337  // raw `[T; 8]` fields read through `(*av_frame).field[plane_idx]`,
1338  // and `planes_out` is also indexed by the same key for symmetry —
1339  // so the index-based loop is the natural shape. The descriptor's
1340  // `count` (`1..=4`) bounds the loop to exactly the planes this format
1341  // populates.
1342  #[allow(clippy::needless_range_loop)]
1343  for plane_idx in 0..geom.count {
1344    // Read per-plane fields through the raw pointer (no `&AVFrame`
1345    // formed). `linesize` is `[c_int; 8]` and `data` is `[*mut u8; 8]`.
1346    // The palette first: a flat `AVPALETTE_SIZE` run at `data[i]` with
1347    // no linesize of its own. Bounded by the format, so there is
1348    // nothing here for a budget to judge.
1349    if geom.palette_plane == Some(plane_idx) {
1350      let data_ptr = unsafe { (*av_frame).data[plane_idx] };
1351      if data_ptr.is_null() {
1352        return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1353          plane_idx,
1354        )));
1355      }
1356      let bytes = geom.row_bytes[plane_idx];
1357      // SAFETY: `find_backing_buffer` proves the run lies inside one of
1358      // the frame's own live buffers before it is read.
1359      // The palette is a flat `AVPALETTE_SIZE` run whose length is the
1360      // format's, not the file's — fully written, so shareable whole.
1361      //
1362      // SAFETY: non-null and addressing this plane.
1363      let carried =
1364        unsafe { capture_from_backing::<C>(av_frame, data_ptr as *const u8, bytes, plane_idx) }?;
1365      planes_out[plane_idx] = Plane::new(carried, bytes as u32);
1366      plane_count = (plane_idx + 1) as u8;
1367      continue;
1368    }
1369
1370    let linesize = unsafe { (*av_frame).linesize[plane_idx] };
1371    if linesize <= 0 {
1372      // `plane_idx < geom.count`, so this plane must be populated; a
1373      // zero linesize means the decoder left an expected plane unset,
1374      // and a negative linesize is FFmpeg's vertical-flip convention
1375      // (which our safe accessors refuse). Either way the layout is
1376      // unusable.
1377      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1378        plane_idx,
1379      )));
1380    }
1381    let data_ptr = unsafe { (*av_frame).data[plane_idx] };
1382    if data_ptr.is_null() {
1383      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1384        plane_idx,
1385      )));
1386    }
1387    let plane_h = geom.height[plane_idx];
1388    let row_bytes = geom.row_bytes[plane_idx];
1389    if row_bytes > linesize as usize {
1390      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1391        plane_idx,
1392      )));
1393    }
1394    // What the copy may read, and what shape it leaves behind:
1395    //
1396    // Each row in the AVBufferRef is `linesize` bytes wide but only the
1397    // first `row_bytes` of them are guaranteed-initialized (the
1398    // codec's actual output). The remaining `linesize - row_bytes`
1399    // bytes per row are FFmpeg-allocator scratch — `av_malloc`'d, not
1400    // necessarily written by the decoder. Forming an `&[u8]` over those
1401    // bytes is UB even if no consumer reads them, which is why the
1402    // padded branch never touches them.
1403    //
1404    // - When `linesize == row_bytes` (no padding), the plane is one
1405    //   contiguous run and is copied whole; `stride` stays `linesize`.
1406    // - When `linesize > row_bytes`, each row is copied tightly and
1407    //   `stride` becomes `row_bytes`.
1408    //
1409    // Both branches copy in 0.9 — the amputation. The *geometry* is
1410    // untouched: a consumer of a tight plane still reads the decoder's
1411    // own stride, and a padded plane still arrives compacted.
1412    let (data, exported_stride) = if (linesize as usize) == row_bytes {
1413      let plane_bytes =
1414        (plane_h)
1415          .checked_mul(linesize as usize)
1416          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1417            plane_idx,
1418          )))?;
1419      // The bounds proof: the AVBufferRef in `(*av_frame).buf[]` that
1420      // contains `data_ptr` covers at least `plane_bytes` from it. The
1421      // returned pointer is not needed — 0.8 used it to compute a view
1422      // offset; 0.9 only needs the guarantee that the read is in range.
1423      // **The tight plane is the shareable one.** `linesize ==
1424      // row_bytes` means the whole `plane_bytes` run is the decoder's
1425      // own output with nothing between the rows, so a view over it
1426      // exposes no byte that was not written. The owned lane copies it;
1427      // the view lane takes a reference to exactly this range.
1428      //
1429      // SAFETY: `data_ptr` is non-null and addresses this plane.
1430      let carried = unsafe {
1431        capture_from_backing::<C>(av_frame, data_ptr as *const u8, plane_bytes, plane_idx)
1432      }?;
1433      (carried, linesize as u32)
1434    } else {
1435      let total_bytes = row_bytes
1436        .checked_mul(plane_h)
1437        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1438          plane_idx,
1439        )))?;
1440      // Bound-check the readable extent in the source AVBufferRef
1441      // BEFORE we start dereferencing per-row offsets. The contiguous
1442      // branch above does this by passing `plane_bytes` to
1443      // `find_backing_buffer`; the row-wise branch must do the same — a
1444      // buggy or hostile decoder/filter could hand us a `data_ptr`
1445      // backed by a buffer too small for `(plane_h - 1) * linesize +
1446      // row_bytes`, in which case `from_raw_parts` on the last few
1447      // rows would form a slice over invalid memory (immediate UB,
1448      // before any read).
1449      let last_row_offset = (plane_h.saturating_sub(1))
1450        .checked_mul(linesize as usize)
1451        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1452          plane_idx,
1453        )))?;
1454      let readable_extent =
1455        last_row_offset
1456          .checked_add(row_bytes)
1457          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
1458            plane_idx,
1459          )))?;
1460      unsafe { find_backing_buffer(av_frame, data_ptr, readable_extent) }.ok_or(
1461        ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)),
1462      )?;
1463      // **A padded plane is copied on both lanes**, and the view lane
1464      // does not get an exception here. Only the first `row_bytes` of
1465      // each `linesize`-wide row are the decoder's output; the rest is
1466      // allocator scratch nothing wrote. A carrier is an `AsRef<[u8]>`,
1467      // so sharing the padded span would form a slice over
1468      // uninitialised memory — undefined before a consumer reads a byte
1469      // of it, and the same leak the owned lane refused when it stopped
1470      // exporting `linesize`. Stopping at the last row's `row_bytes`
1471      // does not help either: the gaps *between* rows are in the span
1472      // too.
1473      //
1474      // So this is the conditional-sharing rule again, in its second
1475      // place: share where the extent is provably all output, copy
1476      // where it is not.
1477      //
1478      // Written straight into the carrier's allocation, one row at a
1479      // time — **not** staged through a `Vec` first. The staged
1480      // spelling allocated the whole plane twice and copied it twice,
1481      // so a 250 MiB frame peaked at 750 MiB counting FFmpeg's own;
1482      // this leaves the unavoidable 2×. The size was checked against
1483      // the frame ceiling above, before any of this was allocated,
1484      // which is what took the place of the staging `Vec`'s
1485      // `try_reserve_exact`.
1486      debug_assert_eq!(total_bytes, row_bytes * plane_h);
1487      let packed = C::from_rows(plane_h, row_bytes, |row_idx| {
1488        // `row_offset` cannot overflow: `readable_extent` above already
1489        // added `(plane_h - 1) * linesize` to `row_bytes` without
1490        // overflowing, and `row_idx < plane_h`.
1491        let row_offset = row_idx * linesize as usize;
1492        // SAFETY: bounds-checked above via `find_backing_buffer`;
1493        // `row_offset + row_bytes <= readable_extent <= buf.size`.
1494        // Each per-row slice is the part the decoder writes
1495        // (initialized).
1496        unsafe { core::slice::from_raw_parts(data_ptr.add(row_offset) as *const u8, row_bytes) }
1497      })
1498      .ok_or(ConvertError::CarrierAllocFailed(CarrierAllocFailed::new(
1499        plane_idx,
1500      )))?;
1501      (packed, row_bytes as u32)
1502    };
1503
1504    planes_out[plane_idx] = Plane::new(data, exported_stride);
1505    plane_count = (plane_idx + 1) as u8;
1506  }
1507
1508  Ok((planes_out, plane_count))
1509}
1510
1511/// A placeholder for an unused plane slot.
1512///
1513/// `[Plane<D>; 4]` requires four populated entries; only
1514/// `plane_count` of them are exposed through `planes()`. 0.8 gave each
1515/// slot its own one-byte `AVBufferRef` and could fail doing it; the
1516/// shared empty carrier costs one allocation for the process.
1517fn plane_placeholder<C: crate::FfmpegCarrier + crate::CarrierOps>() -> Plane<C::Buffer> {
1518  Plane::new(C::empty(), 0)
1519}
1520
1521/// # Safety
1522/// `av_frame` must be a live `*const AVFrame` for the duration of this
1523/// call. The function reads only `crop_*` fields through the raw
1524/// pointer — it never forms `&AVFrame`, so unrelated invalid enum
1525/// fields elsewhere in the struct don't matter.
1526unsafe fn build_visible_rect(av_frame: *const AVFrame, width: u32, height: u32) -> Option<Rect> {
1527  // The crops are `size_t`. Read as `u64` and kept there: `as u32`
1528  // truncated them, so a crop of `2^32 + 5` arrived as a perfectly
1529  // plausible `5` and the rect that came out was wrong in a way nothing
1530  // could see. The same law as the dimensions above — a number a file
1531  // chooses is judged, not clipped — applied to the one field on this
1532  // road that is pure annotation.
1533  let crop_left = unsafe { (*av_frame).crop_left } as u64;
1534  let crop_top = unsafe { (*av_frame).crop_top } as u64;
1535  let crop_right = unsafe { (*av_frame).crop_right } as u64;
1536  let crop_bottom = unsafe { (*av_frame).crop_bottom } as u64;
1537  if crop_left == 0 && crop_top == 0 && crop_right == 0 && crop_bottom == 0 {
1538    return None;
1539  }
1540  // A crop that does not fit inside the picture is not a crop. FFmpeg's
1541  // own `av_frame_apply_cropping` maintains `left + right < width`, so
1542  // a frame breaking that is malformed — and `saturating_sub` used to
1543  // answer it with a zero-extent rect, which is a claim rather than an
1544  // absence.
1545  //
1546  // The frame is not refused over it: the pixels are still whatever the
1547  // decoder produced, and this field only annotates them. What is
1548  // withheld is the annotation. That is the same stance the colour
1549  // fields take toward a value this build cannot name — say nothing
1550  // rather than say something invented.
1551  // Checked, per pair. These are `size_t` straight off the frame, so
1552  // each one alone can be near `u64::MAX` and `left + right` is a real
1553  // overflow — which panics in debug and *wraps* in release, and a
1554  // wrapped sum passes the extent test and then narrows into a rect
1555  // pointing outside the picture. The refusal has to come before the
1556  // arithmetic can lie, not after it.
1557  let (Some(horizontal), Some(vertical)) = (
1558    crop_left.checked_add(crop_right),
1559    crop_top.checked_add(crop_bottom),
1560  ) else {
1561    return None;
1562  };
1563  // `>=`, not `>`. FFmpeg's own `av_frame_apply_cropping` requires the
1564  // crops to leave something behind, and a sum *equal* to the extent
1565  // leaves a zero-width or zero-height rect — which is not a smaller
1566  // picture, it is the absence of one, asserted as a fact. Withheld
1567  // like any other uninterpretable annotation.
1568  if horizontal >= u64::from(width) || vertical >= u64::from(height) {
1569    return None;
1570  }
1571  // Narrowed only now. Each subtraction is proved non-negative by the
1572  // test above, and all four values are proved strictly below the
1573  // frame's own `u32` extent, so no cast here can truncate.
1574  Some(Rect::new(
1575    crop_left as u32,
1576    crop_top as u32,
1577    (u64::from(width) - horizontal) as u32,
1578    (u64::from(height) - vertical) as u32,
1579  ))
1580}
1581
1582/// # Safety
1583/// `av_frame` must be a live `*const AVFrame` for the duration of this
1584/// call. Reads each individual field through the raw pointer; never
1585/// forms a `&AVFrame` reference.
1586unsafe fn build_video_frame_extra(av_frame: *const AVFrame) -> VideoFrameExtra {
1587  let mut out = VideoFrameExtra::default();
1588  // SAR.
1589  let sar_num = unsafe { (*av_frame).sample_aspect_ratio.num };
1590  let sar_den = unsafe { (*av_frame).sample_aspect_ratio.den };
1591  if sar_num > 0 && sar_den > 0 && (sar_num != 1 || sar_den != 1) {
1592    out.set_sample_aspect_ratio(Some((sar_num as u32, sar_den as u32)));
1593  }
1594  // Picture type — read raw to avoid bindgen-enum UB if FFmpeg writes
1595  // an out-of-range value (version skew / hostile decoder).
1596
1597  // SAFETY: `av_frame` is live; reading `pict_type` as `i32` matches
1598  // the bindgen enum's underlying `c_int` storage.
1599  let pict_type_raw = unsafe { read_unaligned(addr_of!((*av_frame).pict_type) as *const i32) };
1600  out.set_picture_type(map_picture_type_raw(pict_type_raw));
1601  // Key frame and interlace flags. AVFrame.flags has dedicated bits
1602  // for these in recent FFmpeg; the deprecated fields (key_frame,
1603  // interlaced_frame, top_field_first) still mirror them.
1604  let flags = unsafe { (*av_frame).flags };
1605  out.set_key_frame(flags & ffmpeg_next::ffi::AV_FRAME_FLAG_KEY != 0);
1606  out.set_interlaced(flags & ffmpeg_next::ffi::AV_FRAME_FLAG_INTERLACED != 0);
1607  out.set_top_field_first(flags & ffmpeg_next::ffi::AV_FRAME_FLAG_TOP_FIELD_FIRST != 0);
1608  // Best-effort timestamp.
1609  let bet = unsafe { (*av_frame).best_effort_timestamp };
1610  if bet != AV_NOPTS_VALUE {
1611    out.set_best_effort_timestamp(Some(bet));
1612  }
1613  // Side data — passthrough as raw bytes.
1614  out.set_side_data(unsafe { collect_side_data(av_frame) });
1615  out
1616}
1617
1618/// Maximum number of `AVFrameSideData` entries we will copy out of
1619/// a single AVFrame. Realistic streams attach a handful (mastering
1620/// display, content light level, dynamic HDR metadata, S12M
1621/// timecodes, A53 captions, …) — usually < 8. The cap exists so a
1622/// crafted stream can't drive the safe converter into a long
1623/// per-frame entry-allocation loop.
1624pub(crate) const SIDE_DATA_MAX_ENTRIES: usize = 64;
1625/// Per-AVFrame total side-data byte cap. HDR / dynamic-metadata
1626/// payloads are typically a few hundred bytes; A53 captions can run
1627/// to a few kilobytes; SEI dumps in pathological streams have been
1628/// observed in the tens of kilobytes. 256 KiB is two orders of
1629/// magnitude over the realistic upper bound while still bounded
1630/// enough that an attacker-driven OOM via metadata is impossible.
1631pub(crate) const SIDE_DATA_MAX_TOTAL_BYTES: usize = 256 * 1024;
1632
1633/// Maximum number of `AVSubtitleRect` entries we copy from a single
1634/// AVSubtitle. Realistic subtitles attach 1–4 rects per cue; 64
1635/// gives two orders of magnitude of headroom.
1636const SUBTITLE_MAX_RECTS: usize = 64;
1637/// Per-rect text/ASS payload byte cap. ASS lines exceeding this
1638/// are unrealistic; the cap exists to defeat a malicious decoder
1639/// attaching a multi-megabyte "subtitle" string.
1640const SUBTITLE_MAX_TEXT_BYTES_PER_RECT: usize = 64 * 1024;
1641/// Total text/ASS payload byte cap across all rects of a single
1642/// AVSubtitle, including newline separators.
1643const SUBTITLE_MAX_TEXT_TOTAL_BYTES: usize = 256 * 1024;
1644/// Per-rect bitmap (`linesize * height`) byte cap. DVB / PGS
1645/// subtitles realistically run to ~256 KiB on full-HD overlays;
1646/// 16 MiB is two orders of magnitude over.
1647const SUBTITLE_MAX_BITMAP_BYTES_PER_RECT: usize = 16 * 1024 * 1024;
1648/// Total bitmap byte cap across all rects of a single AVSubtitle.
1649const SUBTITLE_MAX_BITMAP_TOTAL_BYTES: usize = 32 * 1024 * 1024;
1650
1651/// Bounded counterpart to `CStr::from_ptr(p).to_bytes()`. Reads at
1652/// most `cap + 1` bytes from `ptr` looking for a NUL terminator;
1653/// returns `Some(slice)` of the bytes preceding the NUL on success,
1654/// or `None` if no NUL was found within the window (the input was
1655/// either too long or missing its required terminator entirely).
1656///
1657/// `CStr::from_ptr` walks until it hits a NUL — a valid-but-
1658/// pathological string makes that scan unbounded, and a missing
1659/// NUL is an outright UB precondition violation. This helper bounds
1660/// both at `cap + 1` bytes.
1661///
1662/// # Safety
1663/// `ptr` must be non-null and valid for reads of at least
1664/// `min(cap + 1, length-until-NUL)` bytes. FFmpeg subtitle/text
1665/// pointers satisfy this when `(*rect).text` / `.ass` is non-null
1666/// (per FFmpeg's contract — though the contract itself doesn't
1667/// bound the length).
1668unsafe fn bounded_cstr_bytes<'a>(ptr: *const core::ffi::c_char, cap: usize) -> Option<&'a [u8]> {
1669  // Read up to `cap + 1` bytes; the +1 lets a string exactly `cap`
1670  // bytes long (with a NUL at index `cap`) succeed.
1671  let max = cap.saturating_add(1);
1672  for i in 0..max {
1673    // SAFETY: Caller guarantees `ptr` is valid for reads of bytes
1674    // until the NUL or `max`. We stop at the first NUL within the
1675    // window.
1676    let byte = unsafe { *(ptr.add(i) as *const u8) };
1677    if byte == 0 {
1678      // SAFETY: `ptr` is valid for `i` byte reads (we just walked
1679      // them above). The slice doesn't include the NUL.
1680      return Some(unsafe { core::slice::from_raw_parts(ptr as *const u8, i) });
1681    }
1682  }
1683  // No NUL found within `cap + 1` bytes — input is too long or
1684  // missing its terminator. Reject.
1685  None
1686}
1687
1688/// # Safety
1689/// `av_frame` must be a live `*const AVFrame`. The function reads
1690/// `nb_side_data` and `side_data[]` through the raw pointer; each
1691/// `AVFrameSideData.type_` is read raw (it's a bindgen enum), and
1692/// each `data` payload is bounds-checked before slicing.
1693///
1694/// Memory-safety stance: this function is called on every decoded
1695/// frame, on data the decoder controls. Side-data is bounded by
1696/// [`SIDE_DATA_MAX_ENTRIES`] entries and [`SIDE_DATA_MAX_TOTAL_BYTES`]
1697/// total bytes; once either cap is reached we stop copying further
1698/// entries and a `tracing::warn!` is emitted at most once per call.
1699/// Allocations use `try_reserve_exact` so OOM surfaces as a dropped
1700/// entry rather than a process abort.
1701unsafe fn collect_side_data(av_frame: *const AVFrame) -> std::vec::Vec<SideDataEntry> {
1702  // Read `nb_side_data` as the bindgen `c_int` and clamp non-
1703  // positive values BEFORE casting to `usize`. A negative value
1704  // (corrupt / version-skew decoder output) cast directly to
1705  // `usize` becomes a huge positive count and would walk OOB
1706  // memory below; treat it as "no side data".
1707  let nb_side_data_raw = unsafe { (*av_frame).nb_side_data };
1708  let side_data = unsafe { (*av_frame).side_data };
1709  if nb_side_data_raw <= 0 || side_data.is_null() {
1710    return Vec::new();
1711  }
1712  let count_raw = nb_side_data_raw as usize;
1713  let count = count_raw.min(SIDE_DATA_MAX_ENTRIES);
1714  if count_raw > SIDE_DATA_MAX_ENTRIES {
1715    tracing::warn!(
1716      cap = SIDE_DATA_MAX_ENTRIES,
1717      requested = count_raw,
1718      "mediadecode-ffmpeg: AVFrame.nb_side_data exceeds entry cap; truncating",
1719    );
1720  }
1721  let mut out: Vec<SideDataEntry> = Vec::new();
1722  if out.try_reserve_exact(count).is_err() {
1723    return Vec::new();
1724  }
1725  let mut total_bytes: usize = 0;
1726  for i in 0..count {
1727    let sd = unsafe { *side_data.add(i) };
1728    if sd.is_null() {
1729      continue;
1730    }
1731    // `AVFrameSideData.type_` is `AVFrameSideDataType` — bindgen
1732    // enum. Read raw to avoid forming an invalid value if FFmpeg
1733    // writes an unknown discriminant (version skew).
1734    let kind = unsafe { read_unaligned(addr_of!((*sd).type_) as *const i32) };
1735    let size = unsafe { (*sd).size };
1736    let data_ptr = unsafe { (*sd).data };
1737    let data_slice = if size == 0 || data_ptr.is_null() {
1738      FfmpegBytes::empty()
1739    } else {
1740      // Byte-budget check: stop copying further side-data entries
1741      // once we've reached the per-frame cap. Earlier entries
1742      // already in `out` stay; later entries are dropped.
1743      let projected = total_bytes.saturating_add(size);
1744      if projected > SIDE_DATA_MAX_TOTAL_BYTES {
1745        tracing::warn!(
1746          cap = SIDE_DATA_MAX_TOTAL_BYTES,
1747          projected,
1748          "mediadecode-ffmpeg: AVFrame side-data byte cap reached; dropping remaining entries",
1749        );
1750        break;
1751      }
1752      total_bytes = projected;
1753      // Staged through a `Vec` first, so `try_reserve_exact` keeps
1754      // *one* of the two payload-sized allocations a dropped entry
1755      // rather than a process abort. The carrier copy that follows is a
1756      // second full allocation of the same size — not a header — and it
1757      // is infallible; what the staging buys is that the first and
1758      // larger risk is reportable and the second is asked for a size
1759      // the allocator has just proved it has. Affordable only because
1760      // side data is capped at `SIDE_DATA_MAX_TOTAL_BYTES`; the plane
1761      // path next door is not small and uses the one-allocation road.
1762      let mut buf: Vec<u8> = Vec::new();
1763      if buf.try_reserve_exact(size).is_err() {
1764        continue;
1765      }
1766      // SAFETY: `data_ptr` is documented as valid for `size` bytes
1767      // per FFmpeg's AVFrameSideData contract.
1768      let src = unsafe { core::slice::from_raw_parts(data_ptr, size) };
1769      buf.extend_from_slice(src);
1770      FfmpegBytes::copy_from_slice(&buf)
1771    };
1772    out.push(SideDataEntry::new(kind, data_slice));
1773  }
1774  out
1775}
1776
1777/// Totals a still's declared side data and judges it, **allocating
1778/// nothing and reading no payload**.
1779///
1780/// Split out of [`collect_image_side_data`] so it can run before the
1781/// planes are copied. It was not enough for the budget to be checked
1782/// before the side data was copied: `av_frame_to_image_frame` buys the
1783/// planes first, so an over-budget still had already paid for up to
1784/// `max_frame_bytes` of plane copies by the time its annotations were
1785/// judged. The refusal was correct and arrived after the expensive half
1786/// of the work.
1787///
1788/// Judging is free here. Every number this pass reads is a header
1789/// field — the entry count, and each entry's declared `size` — and no
1790/// payload is dereferenced. So it belongs at the front, with the other
1791/// free judgements.
1792///
1793/// # Safety
1794///
1795/// `av_frame` must be a live `*const AVFrame`.
1796unsafe fn measure_image_side_data(
1797  av_frame: *const AVFrame,
1798  limits: FrameLimits,
1799) -> Result<usize, ConvertError> {
1800  let nb_side_data_raw = unsafe { (*av_frame).nb_side_data };
1801  let side_data = unsafe { (*av_frame).side_data };
1802  if nb_side_data_raw <= 0 || side_data.is_null() {
1803    return Ok(0);
1804  }
1805  let count = nb_side_data_raw as usize;
1806  if count > SIDE_DATA_MAX_ENTRIES {
1807    return Err(ConvertError::ImageSideDataEntries(
1808      ImageSideDataEntries::new(count, SIDE_DATA_MAX_ENTRIES),
1809    ));
1810  }
1811  let budget = limits.max_image_side_data_bytes();
1812  let mut total: usize = 0;
1813  for i in 0..count {
1814    // The entry *pointer* comes out of the array; the entry itself is
1815    // read only for its declared size. A null slot is skipped exactly
1816    // as the copying pass skips it, so the two totals agree.
1817    let sd = unsafe { *side_data.add(i) };
1818    if sd.is_null() {
1819      continue;
1820    }
1821    let size = unsafe { (*sd).size };
1822    total = total.saturating_add(size);
1823    if total > budget {
1824      return Err(ConvertError::ImageSideDataTooLarge(
1825        ImageSideDataTooLarge::new(total, budget),
1826      ));
1827    }
1828  }
1829  Ok(total)
1830}
1831
1832/// [`collect_side_data`] for the **still** road: budgeted, and it
1833/// refuses rather than truncating.
1834///
1835/// The two roads want different answers to the same overflow. A video
1836/// stream's frame side data is small, repeated, and per-frame, so the
1837/// shared collector's fixed caps and silent drop are a reasonable trade
1838/// — losing one frame's annotation is recoverable, and refusing a
1839/// frame mid-stream is not. A still is decoded once and *is* its
1840/// annotations: the ICC profile that decides its colours and the
1841/// display matrix that decides its orientation both live here, both are
1842/// carried by exactly one frame, and dropping either is not degradation
1843/// but a wrong picture returned as a right one.
1844///
1845/// So this collector takes a budget from [`FrameLimits`] and names its
1846/// refusals. See
1847/// [`DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES`](crate::DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES)
1848/// for why the default is what the parameter road already admits.
1849///
1850/// # Safety
1851///
1852/// `av_frame` must be a live `*const AVFrame`.
1853unsafe fn collect_image_side_data(
1854  av_frame: *const AVFrame,
1855  limits: FrameLimits,
1856) -> Result<std::vec::Vec<SideDataEntry>, ConvertError> {
1857  // Same raw reads as the shared collector: a negative count is
1858  // malformed rather than empty, and the entry `type_` is an open C
1859  // enum read as the integer it is.
1860  let nb_side_data_raw = unsafe { (*av_frame).nb_side_data };
1861  let side_data = unsafe { (*av_frame).side_data };
1862  if nb_side_data_raw <= 0 || side_data.is_null() {
1863    return Ok(Vec::new());
1864  }
1865  let count = nb_side_data_raw as usize;
1866  let budget = limits.max_image_side_data_bytes();
1867  // **The measuring pass, re-run.** It runs earlier too — before the
1868  // planes are bought — and this is the copying pass. Repeating a pair
1869  // of comparisons that guard an allocation is defence in depth, not
1870  // duplication: it keeps this function correct on its own terms rather
1871  // than only in the order it happens to be called in.
1872  let total = unsafe { measure_image_side_data(av_frame, limits) }?;
1873
1874  let mut out: Vec<SideDataEntry> = Vec::new();
1875  if out.try_reserve_exact(count).is_err() {
1876    return Err(ConvertError::ImageSideDataTooLarge(
1877      ImageSideDataTooLarge::new(total, budget),
1878    ));
1879  }
1880  for i in 0..count {
1881    let sd = unsafe { *side_data.add(i) };
1882    if sd.is_null() {
1883      continue;
1884    }
1885    let kind = unsafe { read_unaligned(addr_of!((*sd).type_) as *const i32) };
1886    let size = unsafe { (*sd).size };
1887    let data_ptr = unsafe { (*sd).data };
1888    let payload = if size == 0 || data_ptr.is_null() {
1889      FfmpegBytes::empty()
1890    } else {
1891      // SAFETY: `data_ptr` is documented as valid for `size` bytes per
1892      // FFmpeg's `AVFrameSideData` contract, and the total was proved
1893      // to fit the budget above.
1894      let src = unsafe { core::slice::from_raw_parts(data_ptr, size) };
1895      FfmpegBytes::copy_from_slice(src)
1896    };
1897    out.push(SideDataEntry::new(kind, payload));
1898  }
1899  Ok(out)
1900}
1901
1902/// Locate the `AVBufferRef` in `(*av_frame).buf[]` that backs
1903/// `data_ptr`, confirming the requested `bytes` fit inside the buffer.
1904/// Returns `None` on no match, null/empty `buf` entries, or any
1905/// arithmetic that would overflow `usize`.
1906///
1907/// # Safety
1908/// `av_frame` must be a live `*const AVFrame`. Reads `buf[]` (an
1909/// array of pointers — no bindgen-enum validity hazards).
1910/// Captures `len` bytes at `data_ptr` out of whichever of the frame's
1911/// own buffers backs it.
1912///
1913/// **The proof runs before the capture, on both lanes.**
1914/// [`find_backing_buffer`] establishes that `data_ptr .. +len` lies
1915/// inside one of `(*av_frame).buf[]`; only then is the seam asked for a
1916/// carrier. The owned lane copies those bytes out; the view lane takes
1917/// a reference to the same range. Neither gets to skip the proof,
1918/// because it is written once, here.
1919///
1920/// The `len` a caller passes is therefore a claim about **what is
1921/// initialised**, and each medium computes it differently — see the
1922/// call sites for the per-medium rules.
1923///
1924/// # Safety
1925///
1926/// `av_frame` must be a live `*const AVFrame` and `data_ptr` must point
1927/// into one of its planes.
1928unsafe fn capture_from_backing<C: crate::FfmpegCarrier + crate::CarrierOps>(
1929  av_frame: *const AVFrame,
1930  data_ptr: *const u8,
1931  len: usize,
1932  plane_idx: usize,
1933) -> Result<C::Buffer, ConvertError> {
1934  // SAFETY: the caller upholds `av_frame`'s liveness and `data_ptr`'s
1935  // provenance.
1936  let backing = unsafe { find_backing_buffer(av_frame, data_ptr, len) }.ok_or(
1937    ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(plane_idx)),
1938  )?;
1939  // SAFETY: `backing` is one of the frame's live buffers and was just
1940  // proved to cover `len` bytes from `data_ptr`.
1941  let offset = unsafe { (data_ptr as usize).wrapping_sub((*backing).data as usize) };
1942  // SAFETY: the offset and length were proved to lie inside `backing`.
1943  unsafe { C::capture(backing, offset, len) }.ok_or(ConvertError::CarrierAllocFailed(
1944    CarrierAllocFailed::new(plane_idx),
1945  ))
1946}
1947
1948unsafe fn find_backing_buffer(
1949  av_frame: *const AVFrame,
1950  data_ptr: *const u8,
1951  bytes: usize,
1952) -> Option<*mut ffmpeg_next::ffi::AVBufferRef> {
1953  let buf_array_len = unsafe { (*av_frame).buf.len() };
1954  for i in 0..buf_array_len {
1955    let buf = unsafe { (*av_frame).buf[i] };
1956    if buf.is_null() {
1957      continue;
1958    }
1959    let buf_data = unsafe { (*buf).data as *const u8 };
1960    let buf_size = unsafe { (*buf).size };
1961    if buf_data.is_null() {
1962      continue;
1963    }
1964    let start = buf_data as usize;
1965    let Some(end) = start.checked_add(buf_size) else {
1966      continue;
1967    };
1968    let dp = data_ptr as usize;
1969    let Some(dp_end) = dp.checked_add(bytes) else {
1970      continue;
1971    };
1972    if dp >= start && dp_end <= end {
1973      return Some(buf);
1974    }
1975  }
1976  None
1977}
1978
1979fn map_primaries(raw: i32) -> ColorPrimaries {
1980  match raw {
1981    x if x == AVColorPrimaries::AVCOL_PRI_BT709 as i32 => ColorPrimaries::Bt709,
1982    x if x == AVColorPrimaries::AVCOL_PRI_UNSPECIFIED as i32 => ColorPrimaries::Unspecified,
1983    x if x == AVColorPrimaries::AVCOL_PRI_BT470M as i32 => ColorPrimaries::Bt470M,
1984    x if x == AVColorPrimaries::AVCOL_PRI_BT470BG as i32 => ColorPrimaries::Bt470Bg,
1985    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE170M as i32 => ColorPrimaries::Smpte170M,
1986    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE240M as i32 => ColorPrimaries::Smpte240M,
1987    x if x == AVColorPrimaries::AVCOL_PRI_FILM as i32 => ColorPrimaries::Film,
1988    x if x == AVColorPrimaries::AVCOL_PRI_BT2020 as i32 => ColorPrimaries::Bt2020,
1989    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE428 as i32 => ColorPrimaries::SmpteSt428,
1990    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE431 as i32 => ColorPrimaries::SmpteRp431,
1991    x if x == AVColorPrimaries::AVCOL_PRI_SMPTE432 as i32 => ColorPrimaries::SmpteEg432,
1992    x if x == AVColorPrimaries::AVCOL_PRI_EBU3213 as i32 => ColorPrimaries::Ebu3213E,
1993    _ => ColorPrimaries::Unspecified,
1994  }
1995}
1996
1997fn map_transfer(raw: i32) -> ColorTransfer {
1998  match raw {
1999    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT709 as i32 => ColorTransfer::Bt709,
2000    x if x == AVColorTransferCharacteristic::AVCOL_TRC_UNSPECIFIED as i32 => {
2001      ColorTransfer::Unspecified
2002    }
2003    x if x == AVColorTransferCharacteristic::AVCOL_TRC_GAMMA22 as i32 => ColorTransfer::Gamma22,
2004    x if x == AVColorTransferCharacteristic::AVCOL_TRC_GAMMA28 as i32 => ColorTransfer::Gamma28,
2005    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE170M as i32 => ColorTransfer::Smpte170M,
2006    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE240M as i32 => ColorTransfer::Smpte240M,
2007    x if x == AVColorTransferCharacteristic::AVCOL_TRC_LINEAR as i32 => ColorTransfer::Linear,
2008    x if x == AVColorTransferCharacteristic::AVCOL_TRC_LOG as i32 => ColorTransfer::Log100,
2009    x if x == AVColorTransferCharacteristic::AVCOL_TRC_LOG_SQRT as i32 => ColorTransfer::Log316,
2010    x if x == AVColorTransferCharacteristic::AVCOL_TRC_IEC61966_2_4 as i32 => {
2011      ColorTransfer::Iec6196624
2012    }
2013    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT1361_ECG as i32 => {
2014      ColorTransfer::Bt1361Ecg
2015    }
2016    x if x == AVColorTransferCharacteristic::AVCOL_TRC_IEC61966_2_1 as i32 => {
2017      ColorTransfer::Iec6196621
2018    }
2019    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT2020_10 as i32 => {
2020      ColorTransfer::Bt2020_10Bit
2021    }
2022    x if x == AVColorTransferCharacteristic::AVCOL_TRC_BT2020_12 as i32 => {
2023      ColorTransfer::Bt2020_12Bit
2024    }
2025    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084 as i32 => {
2026      ColorTransfer::SmpteSt2084Pq
2027    }
2028    x if x == AVColorTransferCharacteristic::AVCOL_TRC_SMPTE428 as i32 => ColorTransfer::SmpteSt428,
2029    x if x == AVColorTransferCharacteristic::AVCOL_TRC_ARIB_STD_B67 as i32 => {
2030      ColorTransfer::AribStdB67Hlg
2031    }
2032    _ => ColorTransfer::Unspecified,
2033  }
2034}
2035
2036fn map_matrix(raw: i32) -> ColorMatrix {
2037  match raw {
2038    x if x == AVColorSpace::AVCOL_SPC_BT709 as i32 => ColorMatrix::Bt709,
2039    x if x == AVColorSpace::AVCOL_SPC_BT2020_NCL as i32 => ColorMatrix::Bt2020Ncl,
2040    x if x == AVColorSpace::AVCOL_SPC_SMPTE170M as i32 => ColorMatrix::Bt601,
2041    x if x == AVColorSpace::AVCOL_SPC_BT470BG as i32 => ColorMatrix::Bt601,
2042    x if x == AVColorSpace::AVCOL_SPC_SMPTE240M as i32 => ColorMatrix::Smpte240m,
2043    x if x == AVColorSpace::AVCOL_SPC_FCC as i32 => ColorMatrix::Fcc,
2044    x if x == AVColorSpace::AVCOL_SPC_YCGCO as i32 => ColorMatrix::YCgCo,
2045    _ => ColorMatrix::Bt709, // ColorMatrix has no Unspecified; Bt709 is FFmpeg's height>=720 default
2046  }
2047}
2048
2049fn map_range(raw: i32) -> ColorRange {
2050  match raw {
2051    x if x == AVColorRange::AVCOL_RANGE_JPEG as i32 => ColorRange::Full,
2052    x if x == AVColorRange::AVCOL_RANGE_MPEG as i32 => ColorRange::Limited,
2053    _ => ColorRange::Unspecified,
2054  }
2055}
2056
2057/// `true` for the JPEG-range planar YUV (`yuvj*`) formats. These are
2058/// **full-range by definition** — the `j` is FFmpeg's marker for an
2059/// MJPEG/JPEG-family full-swing signal — so their color range is a
2060/// property of the format itself, not something the frame's
2061/// `color_range` field needs to (or reliably does) carry.
2062fn is_yuvj(pix_fmt: &PixelFormat) -> bool {
2063  matches!(
2064    pix_fmt,
2065    PixelFormat::Yuvj411p
2066      | PixelFormat::Yuvj420p
2067      | PixelFormat::Yuvj422p
2068      | PixelFormat::Yuvj440p
2069      | PixelFormat::Yuvj444p
2070  )
2071}
2072
2073/// Derives the delivered [`ColorRange`] from the frame's `color_range`
2074/// field, honoring the range a pixel format *implies*.
2075///
2076/// A `yuvj*` frame is JPEG full-range by definition, but its
2077/// `AVFrame.color_range` is frequently `AVCOL_RANGE_UNSPECIFIED` (the
2078/// MJPEG/JPEG decode paths don't always stamp it). Deriving the range
2079/// purely from that field would mislabel a full-range frame as
2080/// `Unspecified` (which downstream YUV→RGB conversion reads as the
2081/// Limited-swing default) — a silent decode-correctness regression. So
2082/// for the `yuvj*` family we force [`ColorRange::Full`] regardless of
2083/// the field. Every other format defers entirely to `color_range`.
2084fn map_range_for(pix_fmt: &PixelFormat, color_range_raw: i32) -> ColorRange {
2085  if is_yuvj(pix_fmt) {
2086    return ColorRange::Full;
2087  }
2088  map_range(color_range_raw)
2089}
2090
2091fn map_chroma_loc(raw: i32) -> ChromaLocation {
2092  match raw {
2093    x if x == AVChromaLocation::AVCHROMA_LOC_LEFT as i32 => ChromaLocation::Left,
2094    x if x == AVChromaLocation::AVCHROMA_LOC_CENTER as i32 => ChromaLocation::Center,
2095    x if x == AVChromaLocation::AVCHROMA_LOC_TOPLEFT as i32 => ChromaLocation::TopLeft,
2096    x if x == AVChromaLocation::AVCHROMA_LOC_TOP as i32 => ChromaLocation::Top,
2097    x if x == AVChromaLocation::AVCHROMA_LOC_BOTTOMLEFT as i32 => ChromaLocation::BottomLeft,
2098    x if x == AVChromaLocation::AVCHROMA_LOC_BOTTOM as i32 => ChromaLocation::Bottom,
2099    _ => ChromaLocation::Unspecified,
2100  }
2101}
2102
2103/// Converts an FFmpeg audio `AVFrame` into a `mediadecode::AudioFrame`.
2104///
2105/// Each plane is copied out of the source frame's `AVBufferRef`
2106/// entries into an `FfmpegBytes` (the corresponding `data[i]` is always
2107/// covered by exactly one of `buf[i]` per FFmpeg's contract, which is
2108/// what bounds the read). Channel counts above 8 (which would spill
2109/// into `extended_buf`) are refused rather than clamped — see the
2110/// plane-count check below.
2111///
2112/// # Safety
2113///
2114/// `av_frame` must be a live `*const AVFrame` for the duration of this
2115/// call and must describe an audio frame (`format` is an
2116/// `AVSampleFormat`, `nb_samples > 0`, and `data[]` / `buf[]`
2117/// populated). The frame's buffers are neither consumed nor
2118/// referenced; every byte the produced `AudioFrame` carries is a copy.
2119/// * no handle capable of **mutating** the frame's buffers may
2120///   outlive this call while the returned carriers do. On the view
2121///   lane a plane is a window into `frame`'s own allocation, and
2122///   `ffmpeg_next`'s wrappers lend `&mut [u8]` by refcount with no
2123///   copy-on-write — so keeping the source frame and writing through
2124///   it would race a carrier a consumer is reading. Consume the
2125///   frame, or use the owned lane, or use the safe borrowed wrapper
2126///   (which is the owned lane for exactly this reason).
2127pub(crate) unsafe fn av_frame_to_audio_frame_as<C: crate::FfmpegCarrier + crate::CarrierOps>(
2128  av_frame: *const AVFrame,
2129  time_base: Timebase,
2130  limits: FrameLimits,
2131) -> Result<
2132  AudioFrame<SampleFormat, ChannelLayoutDescription, AudioFrameExtra, C::Buffer>,
2133  ConvertError,
2134> {
2135  if av_frame.is_null() {
2136    return Err(ConvertError::NullFrame);
2137  }
2138  // Same stance as `av_frame_to_video_frame`: never form `&AVFrame`.
2139  // Read every field through the raw pointer; for `ch_layout` (which
2140  // contains an `order: AVChannelOrder` enum) we hand the raw pointer
2141  // straight into
2142  // `channel_layout::channel_layout_description_from_raw_ptr`,
2143  // which validates `order` as `i32` before constructing any
2144  // `AVChannelOrder` value.
2145  let format_raw = unsafe { (*av_frame).format };
2146  let sample_rate_raw = unsafe { (*av_frame).sample_rate };
2147  let nb_samples_raw = unsafe { (*av_frame).nb_samples };
2148  let pts_raw = unsafe { (*av_frame).pts };
2149  let duration_raw = unsafe { (*av_frame).duration };
2150  let bet_raw = unsafe { (*av_frame).best_effort_timestamp };
2151
2152  let sample_format = SampleFormat::from_raw(format_raw);
2153  let sample_rate = sample_rate_raw.max(0) as u32;
2154
2155  // **Every header field is judged here, before a byte of geometry is
2156  // computed — and none of them is clamped.**
2157  //
2158  // A clamp on this road is silent truncation of an attacker-supplied
2159  // number, which is the exact sin this boundary exists to refuse. The
2160  // three that mattered each produced a *well-formed-looking* frame out
2161  // of a malformed one, which is worse than an error: a floored
2162  // negative count became an empty frame a consumer went on decoding
2163  // past, and a clipped channel count made a packed frame compute its
2164  // byte product from 255 when the file said 256 — copying 510 of 512
2165  // bytes and advertising the wrong shape.
2166  //
2167  // The one survivor is `sample_rate`, floored above. Censused and
2168  // kept: it feeds no geometry, no allocation and no copy length — it
2169  // is metadata — and zero is already this crate's "rate unspecified".
2170  // Nothing downstream sizes anything from it.
2171  if nb_samples_raw < 0 {
2172    return Err(ConvertError::InvalidSampleCount(InvalidSampleCount::new(
2173      nb_samples_raw,
2174    )));
2175  }
2176  let nb_samples = nb_samples_raw as u32;
2177
2178  // SAFETY: `av_frame` is a live `*const AVFrame`; passing the
2179  // address of the embedded ch_layout as `*const AVChannelLayout`
2180  // is sound because `addr_of!` doesn't form a reference.
2181  let ch_layout_ptr = unsafe { addr_of!((*av_frame).ch_layout) };
2182
2183  // **The channel count is judged off the raw field, before the layout
2184  // is materialised.** The first version of this guard read it back off
2185  // the `ChannelLayoutDescription`, which was two bugs at once:
2186  //
2187  // * the description stores `nb_channels.max(0) as u32`, so a declared
2188  //   `-1` reached the guard as a legitimate-looking zero and produced
2189  //   a zero-channel frame instead of a refusal — the validator was
2190  //   reading a number its own consumer had already laundered; and
2191  // * materialising runs first. For an `AV_CHANNEL_ORDER_CUSTOM`
2192  //   layout that means rendering the layout's name and walking
2193  //   `nb_channels` map entries into a `Vec` — work proportional to a
2194  //   number this very guard exists to bound, done *before* the bound
2195  //   is applied.
2196  //
2197  // A validator downstream of its own field's first consumer is not a
2198  // validator. The raw signed read comes first, every refusal is stated
2199  // against it, and only a count already proved to be in `0..=255` is
2200  // allowed to drive the description.
2201  //
2202  // SAFETY: `ch_layout_ptr` addresses the frame's live embedded layout.
2203  // `nb_channels` is a plain `c_int`, so a direct field read through the
2204  // raw pointer is sound — the enum-typed `order` beside it is what
2205  // needs `addr_of!` + a raw `i32` read, and that read happens inside
2206  // the description helper below, not here.
2207  let channel_count_raw = unsafe { (*ch_layout_ptr).nb_channels };
2208  if channel_count_raw < 0 {
2209    return Err(ConvertError::UnsupportedChannelCount(
2210      UnsupportedChannelCount::new(channel_count_raw),
2211    ));
2212  }
2213  // Refused before any plane geometry, and refused for packed layouts
2214  // too — which the old `> 8` plane check never reached, because packed
2215  // audio declares one plane whatever its channel count is.
2216  if channel_count_raw > i32::from(u8::MAX) {
2217    return Err(ConvertError::UnsupportedChannelCount(
2218      UnsupportedChannelCount::new(channel_count_raw),
2219    ));
2220  }
2221  // A frame carrying samples across no channels is not an empty frame;
2222  // it is an incoherent one. The packed byte product used to substitute
2223  // 1 here, which invented a channel the file never declared.
2224  if channel_count_raw == 0 && nb_samples > 0 {
2225    return Err(ConvertError::UnsupportedChannelCount(
2226      UnsupportedChannelCount::new(channel_count_raw),
2227    ));
2228  }
2229  let channel_count_full = channel_count_raw as u32;
2230  let channel_count = channel_count_raw as u8;
2231
2232  // Materialised only now, with the count it will report already
2233  // proved to be one this crate can carry. Because the raw field is in
2234  // `0..=255`, the description's own `nb_channels.max(0)` is the
2235  // identity here and its `channels()` equals `channel_count_full`.
2236  let channel_layout =
2237    unsafe { crate::channel_layout::channel_layout_description_from_raw_ptr(ch_layout_ptr) };
2238  debug_assert_eq!(
2239    channel_layout.channels(),
2240    channel_count_full,
2241    "the description must report the count that was judged",
2242  );
2243
2244  // The sample format, **before** the zero-sample shortcut: a frame
2245  // whose format has no byte width is malformed whether or not it
2246  // carries samples, and letting an empty one through returned an
2247  // `AudioFrame` advertising a format nothing can interpret.
2248  let bytes_per_sample =
2249    sample_format
2250      .bytes_per_sample()
2251      .ok_or(ConvertError::UnsupportedSampleFormat(
2252        UnsupportedSampleFormat::new(format_raw),
2253      ))? as usize;
2254
2255  // Plane count: 1 for packed, channel_count for planar.
2256  let is_planar = sample_format.is_planar();
2257  let plane_count_full = if is_planar { channel_count as usize } else { 1 };
2258  // mediadecode's `AudioFrame` carries up to 8 plane slots
2259  // (matching `AV_NUM_DATA_POINTERS`). Planar audio with more than
2260  // 8 channels uses `AVFrame.extended_data[]` / `extended_buf[]`,
2261  // which we don't yet plumb through. Refuse the frame rather than
2262  // silently truncating to the first 8 channels and returning an
2263  // `AudioFrame` whose advertised `channel_count` exceeds its
2264  // populated plane count.
2265  if plane_count_full > 8 {
2266    return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(8)));
2267  }
2268  let plane_count = plane_count_full as u8;
2269
2270  // **Two different numbers, and conflating them was a bug.**
2271  //
2272  // `linesize[0]` is what FFmpeg *allocated* per plane, which
2273  // `av_samples_get_buffer_size` rounds up for alignment — routinely
2274  // 32 or 64 bytes past the samples. The bytes that are *valid* are
2275  // `nb_samples * bytes_per_sample`, per plane when planar and times
2276  // the channel count when packed. Nothing initialises the difference.
2277  //
2278  // Exporting `linesize` therefore did two wrong things at once: it
2279  // formed a `&[u8]` over maybe-uninitialised padding, which is
2280  // undefined behaviour before anything reads it, and it handed that
2281  // padding to a consumer inside a safe `FfmpegBytes` — stale heap,
2282  // leaked through an owned carrier.
2283  //
2284  // So `linesize` is used for exactly one thing below: proving the
2285  // source allocation really is as large as it claims. What is copied
2286  // is the valid product. This is what the resampler's own output path
2287  // has always done (`per_sample * produced`); the decode path now
2288  // agrees with it.
2289  let linesize0 = unsafe { (*av_frame).linesize[0] };
2290  // A negative allocation is incoherent at any sample count, so it is
2291  // refused before the count is consulted rather than floored to zero.
2292  // Zero itself is only refused when the frame claims samples — it is
2293  // the canonical shape of an empty audio frame.
2294  if linesize0 < 0 || (nb_samples > 0 && linesize0 == 0) {
2295    return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2296  }
2297  let allocated_per_plane = linesize0 as usize;
2298  let valid_per_plane = if nb_samples_raw == 0 {
2299    // A header frame: real, and carrying no samples. There is nothing
2300    // valid to export, whatever the allocation says. Reached only for a
2301    // count that is *exactly* zero — a negative one was refused by name
2302    // above rather than floored into this branch.
2303    0
2304  } else {
2305    let valid = if is_planar {
2306      // Planar: each plane carries `nb_samples * bytes_per_sample`.
2307      (nb_samples as usize)
2308        .checked_mul(bytes_per_sample)
2309        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?
2310    } else {
2311      // Packed: the single plane interleaves all channels.
2312      // The **declared** channel count, never a substituted one: it was
2313      // proved above to be in `1..=u8::MAX` on a frame with samples.
2314      (nb_samples as usize)
2315        .checked_mul(bytes_per_sample)
2316        .and_then(|x| x.checked_mul(channel_count_full as usize))
2317        .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?
2318    };
2319    // The allocation must cover the samples the header claims —
2320    // otherwise a shrunk `linesize` would let a consumer that trusts
2321    // `nb_samples` read past what is there.
2322    if allocated_per_plane < valid {
2323      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2324    }
2325    valid
2326  };
2327
2328  // The byte ceiling, before a single plane is allocated. An audio
2329  // frame has no pixels to bound, so this is the whole ceiling here —
2330  // and it is needed: `linesize[0]` is a number from the decoder, and
2331  // the check above only proves it is not *smaller* than the format
2332  // requires. Nothing above bounds it from the other side.
2333  let exported =
2334    valid_per_plane
2335      .checked_mul(plane_count as usize)
2336      .ok_or(ConvertError::FrameTooLarge(FrameTooLarge::new(
2337        usize::MAX,
2338        limits.max_frame_bytes(),
2339      )))?;
2340  if exported > limits.max_frame_bytes() {
2341    return Err(ConvertError::FrameTooLarge(FrameTooLarge::new(
2342      exported,
2343      limits.max_frame_bytes(),
2344    )));
2345  }
2346
2347  // Every slot starts as the shared empty carrier at stride zero, which
2348  // is already exactly what a zero-sample frame's planes should be.
2349  let mut planes_out: [Plane<C::Buffer>; 8] = std::array::from_fn(|_| plane_placeholder::<C>());
2350
2351  // **A zero-sample frame has no planes to validate.** FFmpeg's
2352  // canonical empty audio frame carries a format, a layout and a rate
2353  // with `data[i] == NULL`, `linesize == 0` and no `AVBufferRef` at
2354  // all — there is nothing allocated because there is nothing to hold.
2355  // Running the loop below over it refused the frame on the first null
2356  // pointer, so a header frame mid-stream came back as
2357  // `InvalidPlaneLayout` and interrupted a decode that was going fine.
2358  //
2359  // The declared layout is still reported: `plane_count` stays packed's
2360  // 1 or planar's channel count, and those slots hold the empty carrier
2361  // at stride 0 — a consumer sees the shape it expects, carrying no
2362  // samples, which is what the frame says. No allocation happens; the
2363  // empty carrier is one refcount bump.
2364  //
2365  // Nothing below changes for a frame that does carry samples: the loop
2366  // body is untouched, and this only decides whether it runs at all.
2367  let populated = if valid_per_plane == 0 {
2368    0
2369  } else {
2370    plane_count as usize
2371  };
2372
2373  // Same rationale as in the video path — index-by-key over three
2374  // unrelated raw arrays (`planes_out`, `(*av_frame).data`, and the
2375  // implicit per-plane bookkeeping); no slice iteration applies.
2376  #[allow(clippy::needless_range_loop)]
2377  for plane_idx in 0..populated {
2378    let data_ptr = unsafe { (*av_frame).data[plane_idx] };
2379    if data_ptr.is_null() {
2380      // A null plane in a planar layout (or the sole plane in a
2381      // packed layout) means the decoder produced an incomplete
2382      // frame — surface as an error rather than returning a frame
2383      // whose `planes()` exposes empty placeholder channels for
2384      // the missing data.
2385      return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(
2386        plane_idx,
2387      )));
2388    }
2389    // The bounds proof, against the **allocation**: the plane really is
2390    // as large as its `linesize` claims, and lies inside one of the
2391    // frame's own buffers. This is the only thing `linesize` is used
2392    // for.
2393    let backing = unsafe { find_audio_backing_buffer(av_frame, data_ptr, allocated_per_plane) }
2394      .ok_or(ConvertError::BufferAcquireFailed(BufferAcquireFailed::new(
2395        plane_idx,
2396      )))?;
2397    // Lossless, and provably so rather than by ceiling: this branch runs
2398    // only when `valid_per_plane <= allocated_per_plane`, which is an
2399    // `i32` read from `linesize[0]` proved non-negative above. No plane
2400    // can exceed `i32::MAX` bytes, so nothing here is truncated even if
2401    // a caller raises `max_frame_bytes` past `u32::MAX`.
2402    // **Audio stops at exactly the valid bytes, on both lanes.**
2403    // `linesize[0]` is what `av_samples_get_buffer_size` *allocated*,
2404    // rounded up for alignment; what the decoder wrote is
2405    // `nb_samples * bytes_per_sample` (times the channels when packed).
2406    // The difference is untouched allocator memory — the R5 finding —
2407    // and it is no more exportable through a view than it was through a
2408    // copy: a carrier is an `AsRef<[u8]>`, so the span it names is the
2409    // span a consumer may read, and padding in that span is the same
2410    // information leak whoever formed it.
2411    //
2412    // So the view lane shares the **prefix**, not the plane. Which is
2413    // also why `linesize` is used for exactly one thing here: proving
2414    // the allocation really is as large as it claims.
2415    //
2416    // SAFETY: `backing` is one of the frame's live buffers, proved above
2417    // to cover `allocated_per_plane` bytes from `data_ptr`, and
2418    // `valid_per_plane <= allocated_per_plane`.
2419    let offset = unsafe { (data_ptr as usize).wrapping_sub((*backing).data as usize) };
2420    // SAFETY: the offset and length lie inside `backing` by the proof
2421    // above.
2422    let carried = unsafe { C::capture(backing, offset, valid_per_plane) }.ok_or(
2423      ConvertError::CarrierAllocFailed(CarrierAllocFailed::new(plane_idx)),
2424    )?;
2425    planes_out[plane_idx] = Plane::new(carried, valid_per_plane as u32);
2426  }
2427
2428  let pts = if pts_raw != AV_NOPTS_VALUE {
2429    Some(Timestamp::new(pts_raw, time_base))
2430  } else {
2431    None
2432  };
2433  let duration = if duration_raw > 0 {
2434    Some(Timestamp::new(duration_raw, time_base))
2435  } else {
2436    None
2437  };
2438
2439  let mut extra = AudioFrameExtra::default();
2440  if bet_raw != AV_NOPTS_VALUE {
2441    extra.set_best_effort_timestamp(Some(bet_raw));
2442  }
2443  // SAFETY: caller upholds liveness for the duration of the call;
2444  // collect_side_data reads enum-typed `type_` raw and bounds-checks
2445  // each entry's data slice.
2446  extra.set_side_data(unsafe { collect_side_data(av_frame) });
2447
2448  Ok(
2449    AudioFrame::new(
2450      sample_rate,
2451      nb_samples,
2452      channel_count,
2453      sample_format,
2454      channel_layout,
2455      planes_out,
2456      plane_count,
2457      extra,
2458    )
2459    .with_pts(pts)
2460    .with_duration(duration),
2461  )
2462}
2463
2464/// The `AVBufferRef` in `(*av_frame).buf[]` that backs `data_ptr` for
2465/// `bytes` bytes, or `None` when none of them does.
2466///
2467/// # Safety
2468/// `av_frame` must be a live `*const AVFrame`.
2469pub(crate) unsafe fn find_audio_backing_buffer(
2470  av_frame: *const AVFrame,
2471  data_ptr: *const u8,
2472  bytes: usize,
2473) -> Option<*mut ffmpeg_next::ffi::AVBufferRef> {
2474  // Audio frames pack each plane into a separate AVBufferRef in buf[].
2475  // Same scan as the video path — finds whichever buffer's data range
2476  // contains data_ptr. Overflow-safe arithmetic per
2477  // `find_backing_buffer`'s rationale.
2478  let buf_array_len = unsafe { (*av_frame).buf.len() };
2479  for i in 0..buf_array_len {
2480    let buf = unsafe { (*av_frame).buf[i] };
2481    if buf.is_null() {
2482      continue;
2483    }
2484    let buf_data = unsafe { (*buf).data as *const u8 };
2485    let buf_size = unsafe { (*buf).size };
2486    if buf_data.is_null() {
2487      continue;
2488    }
2489    let start = buf_data as usize;
2490    let Some(end) = start.checked_add(buf_size) else {
2491      continue;
2492    };
2493    let dp = data_ptr as usize;
2494    let Some(dp_end) = dp.checked_add(bytes) else {
2495      continue;
2496    };
2497    if dp >= start && dp_end <= end {
2498      return Some(buf);
2499    }
2500  }
2501  None
2502}
2503
2504/// Converts an FFmpeg `AVSubtitle` into a `mediadecode::SubtitleFrame`.
2505///
2506/// Strategy:
2507/// - If the subtitle contains any text/ASS rects, produce a
2508///   [`SubtitlePayload::Text`] whose buffer is the concatenation of
2509///   their UTF-8 contents (newline-separated).
2510/// - Otherwise, if the subtitle contains bitmap rects, produce a
2511///   [`SubtitlePayload::Bitmap`] with one [`mediadecode::subtitle::BitmapRegion`]
2512///   per rect (paletted indices and RGBA palette copied into fresh
2513///   owned `FfmpegBytes` carriers, since `AVSubtitleRect` data is not
2514///   refcounted and does not outlive the `AVSubtitle`).
2515/// - An empty subtitle (no rects) becomes an empty `Text` payload.
2516///
2517/// `time_base` is the source stream's time base, used to label
2518/// `pts` / `duration`. The duration is computed as
2519/// `(end_display_time - start_display_time)` in milliseconds, then
2520/// rescaled into `time_base`.
2521///
2522/// # Safety
2523///
2524/// `av_subtitle` must be a live `*const AVSubtitle` for the duration
2525/// of this call; the rect array (`av_subtitle.rects`) must be valid
2526/// for `av_subtitle.num_rects` entries.
2527/// * no handle capable of **mutating** the frame's buffers may
2528///   outlive this call while the returned carriers do. On the view
2529///   lane a plane is a window into `frame`'s own allocation, and
2530///   `ffmpeg_next`'s wrappers lend `&mut [u8]` by refcount with no
2531///   copy-on-write — so keeping the source frame and writing through
2532///   it would race a carrier a consumer is reading. Consume the
2533///   frame, or use the owned lane, or use the safe borrowed wrapper
2534///   (which is the owned lane for exactly this reason).
2535pub(crate) unsafe fn av_subtitle_to_subtitle_frame_as<
2536  C: crate::FfmpegCarrier + crate::CarrierOps,
2537>(
2538  av_subtitle: *const ffmpeg_next::ffi::AVSubtitle,
2539  time_base: Timebase,
2540) -> Result<SubtitleFrame<SubtitleFrameExtra, C::Buffer>, ConvertError> {
2541  if av_subtitle.is_null() {
2542    return Err(ConvertError::NullFrame);
2543  }
2544  // Same stance as `av_frame_to_video_frame`: never form `&AVSubtitle`
2545  // or `&AVSubtitleRect` (both contain `type_: AVSubtitleType` enum
2546  // fields). Read every field through the raw pointer.
2547
2548  let mut text_chunks: std::vec::Vec<u8> = std::vec::Vec::new();
2549  let mut bitmap_regions: std::vec::Vec<mediadecode::subtitle::BitmapRegion<C::Buffer>> =
2550    std::vec::Vec::new();
2551
2552  let count_raw = unsafe { (*av_subtitle).num_rects } as usize;
2553  let rects_ptr = unsafe { (*av_subtitle).rects };
2554  // Defensive: `num_rects > 0` with `rects == null` would be a malformed
2555  // AVSubtitle, but a hostile decoder could produce one — bail rather
2556  // than dereferencing.
2557  if count_raw > 0 && rects_ptr.is_null() {
2558    return Err(ConvertError::NullFrame);
2559  }
2560  // Cap rect count, total text bytes, and total bitmap bytes
2561  // against decoder-controlled metadata. Realistic subtitles carry
2562  // a handful of rects (typically 1–4 per displayed cue), text
2563  // payloads in the low kilobytes (ASS lines), and bitmap
2564  // payloads in the low hundreds of KiB (DVB / PGS). These caps
2565  // are two orders of magnitude over realistic ceilings; their
2566  // job is to bound a malicious / corrupt stream's allocation
2567  // budget, not to limit legitimate use.
2568  let count = count_raw.min(SUBTITLE_MAX_RECTS);
2569  if count_raw > SUBTITLE_MAX_RECTS {
2570    tracing::warn!(
2571      cap = SUBTITLE_MAX_RECTS,
2572      requested = count_raw,
2573      "mediadecode-ffmpeg: AVSubtitle.num_rects exceeds rect cap; truncating",
2574    );
2575  }
2576  let mut text_total_bytes: usize = 0;
2577  let mut bitmap_total_bytes: usize = 0;
2578
2579  let text_kind = AVSubtitleType::SUBTITLE_TEXT as i32;
2580  let ass_kind = AVSubtitleType::SUBTITLE_ASS as i32;
2581  let bitmap_kind = AVSubtitleType::SUBTITLE_BITMAP as i32;
2582  for i in 0..count {
2583    // SAFETY: rects_ptr is non-null (checked above) and points to
2584    // num_rects valid `*mut AVSubtitleRect` entries per FFmpeg's
2585    // contract; `i < count == num_rects`, so the offset is in-bounds.
2586    let rect_ptr = unsafe { *rects_ptr.add(i) };
2587    if rect_ptr.is_null() {
2588      continue;
2589    }
2590    // Read `type_` raw — avoid forming `&AVSubtitleRect` (which
2591    // would require type_ to be a valid AVSubtitleType variant).
2592    // SAFETY: `rect_ptr` is a live `*mut AVSubtitleRect`; `addr_of!`
2593    // computes the field address without forming a reference;
2594    // reading as `i32` matches the bindgen enum's `c_int` storage.
2595    let rect_type_raw = unsafe { read_unaligned(addr_of!((*rect_ptr).type_) as *const i32) };
2596    // Pre-read primitive fields we'll use later (no `&AVSubtitleRect`
2597    // ever formed).
2598    let rect_text_ptr = unsafe { (*rect_ptr).text };
2599    let rect_ass_ptr = unsafe { (*rect_ptr).ass };
2600    let rect_data0_ptr = unsafe { (*rect_ptr).data[0] };
2601    let rect_data1_ptr = unsafe { (*rect_ptr).data[1] };
2602    let rect_linesize0 = unsafe { (*rect_ptr).linesize[0] };
2603    let rect_w = unsafe { (*rect_ptr).w };
2604    let rect_h = unsafe { (*rect_ptr).h };
2605    let rect_x = unsafe { (*rect_ptr).x };
2606    let rect_y = unsafe { (*rect_ptr).y };
2607
2608    match rect_type_raw {
2609      x if x == text_kind && !rect_text_ptr.is_null() => {
2610        // SAFETY: `text` is documented as a 0-terminated UTF-8
2611        // string, owned by FFmpeg for the lifetime of the AVSubtitle.
2612        // We use a *bounded* NUL search instead of `CStr::from_ptr`
2613        // — the latter walks until it finds a NUL, which a valid-
2614        // but-pathological string makes unbounded, and a missing
2615        // NUL violates the `CStr::from_ptr` precondition outright.
2616        // `bounded_cstr_bytes` searches at most
2617        // `SUBTITLE_MAX_TEXT_BYTES_PER_RECT + 1` bytes; if no NUL
2618        // is found inside that window the rect is rejected.
2619        let bytes = unsafe { bounded_cstr_bytes(rect_text_ptr, SUBTITLE_MAX_TEXT_BYTES_PER_RECT) }
2620          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?;
2621        // The cap is now enforced inside `bounded_cstr_bytes` (no
2622        // NUL within `cap + 1` ⇒ rejection); a redundant length
2623        // check is unnecessary but kept as documentation.
2624        if bytes.len() > SUBTITLE_MAX_TEXT_BYTES_PER_RECT {
2625          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2626        }
2627        let separator = if text_chunks.is_empty() { 0 } else { 1 };
2628        let projected = text_total_bytes
2629          .saturating_add(bytes.len())
2630          .saturating_add(separator);
2631        if projected > SUBTITLE_MAX_TEXT_TOTAL_BYTES {
2632          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2633        }
2634        if separator == 1 {
2635          text_chunks.push(b'\n');
2636        }
2637        text_chunks.extend_from_slice(bytes);
2638        text_total_bytes = projected;
2639      }
2640      x if x == ass_kind && !rect_ass_ptr.is_null() => {
2641        // SAFETY: `ass` is documented as 0-terminated UTF-8.
2642        // Same bounded-scan rationale as the TEXT branch above.
2643        let bytes = unsafe { bounded_cstr_bytes(rect_ass_ptr, SUBTITLE_MAX_TEXT_BYTES_PER_RECT) }
2644          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?;
2645        if bytes.len() > SUBTITLE_MAX_TEXT_BYTES_PER_RECT {
2646          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2647        }
2648        let separator = if text_chunks.is_empty() { 0 } else { 1 };
2649        let projected = text_total_bytes
2650          .saturating_add(bytes.len())
2651          .saturating_add(separator);
2652        if projected > SUBTITLE_MAX_TEXT_TOTAL_BYTES {
2653          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2654        }
2655        if separator == 1 {
2656          text_chunks.push(b'\n');
2657        }
2658        text_chunks.extend_from_slice(bytes);
2659        text_total_bytes = projected;
2660      }
2661      x if x == bitmap_kind => {
2662        // Bitmap region. data[0] = paletted indices, data[1] = RGBA
2663        // palette (256 entries × 4 bytes = 1024 bytes). Both are
2664        // owned by FFmpeg and not refcounted; copy into fresh buffers.
2665        let w = rect_w.max(0) as u32;
2666        let h = rect_h.max(0) as u32;
2667        let stride = rect_linesize0.max(0) as u32;
2668        if rect_data0_ptr.is_null() || stride == 0 || h == 0 {
2669          continue;
2670        }
2671        // `checked_mul` so a corrupt rect can't drive
2672        // `from_raw_parts` to an address-space-spanning length (UB
2673        // even before any deref).
2674        let data_len = (stride as usize)
2675          .checked_mul(h as usize)
2676          .ok_or(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)))?;
2677        // Per-rect bitmap byte cap (defends against a single
2678        // attacker rect larger than realistic DVB / PGS subtitles
2679        // by a wide margin).
2680        if data_len > SUBTITLE_MAX_BITMAP_BYTES_PER_RECT {
2681          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2682        }
2683        let projected_total = bitmap_total_bytes.saturating_add(data_len);
2684        if projected_total > SUBTITLE_MAX_BITMAP_TOTAL_BYTES {
2685          return Err(ConvertError::InvalidPlaneLayout(InvalidPlaneLayout::new(0)));
2686        }
2687        // SAFETY: data[0] is valid for `linesize[0] * h` bytes per
2688        // FFmpeg's contract; the multiplication is checked above.
2689        let data_slice = unsafe { core::slice::from_raw_parts(rect_data0_ptr, data_len) };
2690        // **A rect is copied on both lanes.** `AVSubtitleRect` has no
2691        // `buf[]`: its `data[]` are plain `av_malloc` allocations owned
2692        // by the `AVSubtitle`, which `avsubtitle_free` releases when
2693        // this call returns. There is no refcount to take, so the view
2694        // lane has nothing to view and says so.
2695        let data_buf = C::from_bytes(data_slice)
2696          .ok_or(ConvertError::CarrierAllocFailed(CarrierAllocFailed::new(0)))?;
2697        let palette_len = 256 * 4;
2698        let palette_buf = if rect_data1_ptr.is_null() {
2699          C::empty()
2700        } else {
2701          // SAFETY: palette buffer is 256*4 bytes per FFmpeg's contract.
2702          let p = unsafe { core::slice::from_raw_parts(rect_data1_ptr, palette_len) };
2703          C::from_bytes(p).ok_or(ConvertError::CarrierAllocFailed(CarrierAllocFailed::new(1)))?
2704        };
2705        bitmap_regions.push(mediadecode::subtitle::BitmapRegion::new(
2706          rect_x.max(0) as u32,
2707          rect_y.max(0) as u32,
2708          w,
2709          h,
2710          stride,
2711          data_buf,
2712          palette_buf,
2713        ));
2714        bitmap_total_bytes = projected_total;
2715      }
2716      _ => {}
2717    }
2718  }
2719
2720  let payload = if !text_chunks.is_empty() {
2721    SubtitlePayload::Text(SubtitleText::new(
2722      C::from_bytes(&text_chunks)
2723        .ok_or(ConvertError::CarrierAllocFailed(CarrierAllocFailed::new(0)))?,
2724      None,
2725    ))
2726  } else if !bitmap_regions.is_empty() {
2727    SubtitlePayload::Bitmap(SubtitleBitmap::new(bitmap_regions))
2728  } else {
2729    // No rects (or only `None`-typed) — empty text payload.
2730    SubtitlePayload::Text(SubtitleText::new(C::empty(), None))
2731  };
2732
2733  let sub_pts = unsafe { (*av_subtitle).pts };
2734  let pts = if sub_pts != AV_NOPTS_VALUE {
2735    Some(Timestamp::new(sub_pts, time_base))
2736  } else {
2737    None
2738  };
2739
2740  let extra = SubtitleFrameExtra::new(unsafe { (*av_subtitle).start_display_time }, unsafe {
2741    (*av_subtitle).end_display_time
2742  });
2743
2744  Ok(SubtitleFrame::new(payload, extra).with_pts(pts))
2745}
2746
2747fn map_picture_type_raw(raw: i32) -> PictureType {
2748  match raw {
2749    x if x == AVPictureType::AV_PICTURE_TYPE_I as i32 => PictureType::I,
2750    x if x == AVPictureType::AV_PICTURE_TYPE_P as i32 => PictureType::P,
2751    x if x == AVPictureType::AV_PICTURE_TYPE_B as i32 => PictureType::B,
2752    x if x == AVPictureType::AV_PICTURE_TYPE_S as i32 => PictureType::S,
2753    x if x == AVPictureType::AV_PICTURE_TYPE_SI as i32 => PictureType::Si,
2754    x if x == AVPictureType::AV_PICTURE_TYPE_SP as i32 => PictureType::Sp,
2755    x if x == AVPictureType::AV_PICTURE_TYPE_BI as i32 => PictureType::Bi,
2756    _ => PictureType::Unspecified,
2757  }
2758}
2759
2760#[cfg(test)]
2761mod tests;
2762
2763/// [`av_frame_to_video_frame_as`] on the **view** lane.
2764///
2765/// # Safety
2766///
2767/// As the crate-private worker: a live source for the duration of the
2768/// call, and — on this lane — no handle capable of mutating its buffers
2769/// may outlive the returned carriers.
2770pub unsafe fn av_frame_to_video_frame(
2771  av_frame: *const AVFrame,
2772  time_base: Timebase,
2773  limits: FrameLimits,
2774) -> Result<VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, crate::FfmpegBuffer>, ConvertError>
2775{
2776  // SAFETY: forwarded verbatim; the caller's obligations are the
2777  // worker's.
2778  unsafe { av_frame_to_video_frame_as::<crate::View>(av_frame, time_base, limits) }
2779}
2780
2781/// [`av_frame_to_video_frame`] on the **owned** lane, which copies every byte it
2782/// reads and therefore has no aliasing obligation.
2783///
2784/// # Safety
2785///
2786/// The source must be live for the duration of the call.
2787pub unsafe fn av_frame_to_owned_video_frame(
2788  av_frame: *const AVFrame,
2789  time_base: Timebase,
2790  limits: FrameLimits,
2791) -> Result<VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, FfmpegBytes>, ConvertError> {
2792  // SAFETY: forwarded verbatim.
2793  unsafe { av_frame_to_video_frame_as::<crate::Owned>(av_frame, time_base, limits) }
2794}
2795
2796/// [`av_frame_to_image_frame_as`] on the **view** lane.
2797///
2798/// # Safety
2799///
2800/// As the crate-private worker: a live source for the duration of the
2801/// call, and — on this lane — no handle capable of mutating its buffers
2802/// may outlive the returned carriers.
2803pub unsafe fn av_frame_to_image_frame(
2804  av_frame: *const AVFrame,
2805  limits: FrameLimits,
2806) -> Result<ImageFrame<mediadecode::PixelFormat, ImageFrameExtra, crate::FfmpegBuffer>, ConvertError>
2807{
2808  // SAFETY: forwarded verbatim; the caller's obligations are the
2809  // worker's.
2810  unsafe { av_frame_to_image_frame_as::<crate::View>(av_frame, limits) }
2811}
2812
2813/// [`av_frame_to_image_frame`] on the **owned** lane, which copies every byte it
2814/// reads and therefore has no aliasing obligation.
2815///
2816/// # Safety
2817///
2818/// The source must be live for the duration of the call.
2819pub unsafe fn av_frame_to_owned_image_frame(
2820  av_frame: *const AVFrame,
2821  limits: FrameLimits,
2822) -> Result<ImageFrame<mediadecode::PixelFormat, ImageFrameExtra, FfmpegBytes>, ConvertError> {
2823  // SAFETY: forwarded verbatim.
2824  unsafe { av_frame_to_image_frame_as::<crate::Owned>(av_frame, limits) }
2825}
2826
2827/// [`av_frame_to_audio_frame_as`] on the **view** lane.
2828///
2829/// # Safety
2830///
2831/// As the crate-private worker: a live source for the duration of the
2832/// call, and — on this lane — no handle capable of mutating its buffers
2833/// may outlive the returned carriers.
2834pub unsafe fn av_frame_to_audio_frame(
2835  av_frame: *const AVFrame,
2836  time_base: Timebase,
2837  limits: FrameLimits,
2838) -> Result<
2839  AudioFrame<SampleFormat, ChannelLayoutDescription, AudioFrameExtra, crate::FfmpegBuffer>,
2840  ConvertError,
2841> {
2842  // SAFETY: forwarded verbatim; the caller's obligations are the
2843  // worker's.
2844  unsafe { av_frame_to_audio_frame_as::<crate::View>(av_frame, time_base, limits) }
2845}
2846
2847/// [`av_frame_to_audio_frame`] on the **owned** lane, which copies every byte it
2848/// reads and therefore has no aliasing obligation.
2849///
2850/// # Safety
2851///
2852/// The source must be live for the duration of the call.
2853pub unsafe fn av_frame_to_owned_audio_frame(
2854  av_frame: *const AVFrame,
2855  time_base: Timebase,
2856  limits: FrameLimits,
2857) -> Result<
2858  AudioFrame<SampleFormat, ChannelLayoutDescription, AudioFrameExtra, FfmpegBytes>,
2859  ConvertError,
2860> {
2861  // SAFETY: forwarded verbatim.
2862  unsafe { av_frame_to_audio_frame_as::<crate::Owned>(av_frame, time_base, limits) }
2863}
2864
2865/// [`av_subtitle_to_subtitle_frame_as`] on the **view** lane.
2866///
2867/// # Safety
2868///
2869/// As the crate-private worker: a live source for the duration of the
2870/// call, and — on this lane — no handle capable of mutating its buffers
2871/// may outlive the returned carriers.
2872pub unsafe fn av_subtitle_to_subtitle_frame(
2873  av_subtitle: *const ffmpeg_next::ffi::AVSubtitle,
2874  time_base: Timebase,
2875) -> Result<SubtitleFrame<SubtitleFrameExtra, crate::FfmpegBuffer>, ConvertError> {
2876  // SAFETY: forwarded verbatim; the caller's obligations are the
2877  // worker's.
2878  unsafe { av_subtitle_to_subtitle_frame_as::<crate::View>(av_subtitle, time_base) }
2879}
2880
2881/// [`av_subtitle_to_subtitle_frame`] on the **owned** lane, which copies every byte it
2882/// reads and therefore has no aliasing obligation.
2883///
2884/// # Safety
2885///
2886/// The source must be live for the duration of the call.
2887pub unsafe fn av_subtitle_to_owned_subtitle_frame(
2888  av_subtitle: *const ffmpeg_next::ffi::AVSubtitle,
2889  time_base: Timebase,
2890) -> Result<SubtitleFrame<SubtitleFrameExtra, FfmpegBytes>, ConvertError> {
2891  // SAFETY: forwarded verbatim.
2892  unsafe { av_subtitle_to_subtitle_frame_as::<crate::Owned>(av_subtitle, time_base) }
2893}