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