Skip to main content

mediadecode_ffmpeg/
demuxer.rs

1//! [`mediadecode::demuxer::Demuxer`] impl backed by `libavformat`.
2//!
3//! Opens a container — from a path, or from any `Read + Seek` reader
4//! through a custom `AVIOContext` — reads its track table once, and
5//! then hands packets out one at a time in interleaved file order.
6//!
7//! # What normalization this layer does
8//!
9//! libavformat's track table is not quite the one the demux tier
10//! promises, and the gap is entirely about attachments:
11//!
12//! - **Cover art is an attachment, not video.** A still image in an
13//!   MP3, FLAC or MP4 arrives as a video stream carrying
14//!   `AV_DISPOSITION_ATTACHED_PIC`. This layer maps it to
15//!   [`TrackKind::Attachment`], so the `Video` arm carries true motion
16//!   video and nothing else.
17//! - **A font's bytes are not in the packet stream at all.** An
18//!   `AVMEDIA_TYPE_ATTACHMENT` stream never produces a packet; its
19//!   payload lives in `AVCodecParameters.extradata`. This layer
20//!   synthesizes the packet at open time.
21//! - **Cover art's packet is hoisted.** libavformat parks the real
22//!   packet in `AVStream.attached_pic`; some demuxers also emit it in
23//!   the packet stream, some do not. This layer takes it from
24//!   `attached_pic` at open time and drops the duplicate if it ever
25//!   arrives, so the count is exactly one either way.
26//!
27//! Both kinds are queued at open — every attachment track, without
28//! exception, or the open fails. That is what makes the face's "exactly
29//! one packet, before any timed packet" true *by construction* here:
30//! the queue is complete and drains before the first `av_read_frame`
31//! call ever runs, so no packet on an attachment track can be anything
32//! but a duplicate, and no seek can move a packet that was never on the
33//! timeline.
34//!
35//! # Seeking
36//!
37//! `seek` converts the target to `AV_TIME_BASE` units and calls
38//! `avformat_seek_file` over the window `[i64::MIN, target]`, which is
39//! FFmpeg's backward convention: the landing point is the nearest
40//! keyframe at or before the target, never after. `avformat_seek_file`
41//! flushes libavformat's own buffers; this layer clears the EOF latch
42//! it set itself, and deliberately does **not** touch the attachment
43//! bookkeeping — an attachment already handed out is never handed out
44//! again, and one not yet handed out is still owed.
45
46use std::{
47  collections::VecDeque,
48  ffi::{CStr, c_int},
49  io::{Read, Seek},
50  mem,
51  num::NonZeroI32,
52  path::Path,
53  ptr::{addr_of, read_unaligned},
54  sync::Arc,
55};
56
57use derive_more::{IsVariant, TryUnwrap, Unwrap};
58use ffmpeg_next::{
59  Packet, Rational,
60  ffi::{
61    AV_DISPOSITION_ATTACHED_PIC, AV_DISPOSITION_TIMED_THUMBNAILS, AV_NOPTS_VALUE, AVDictionary,
62    av_dict_get,
63  },
64  format::{self, context::Input},
65  media,
66};
67use mediadecode::{
68  Timebase, Timestamp,
69  demuxer::{
70    AttachmentPacket, AttachmentTrackPacket, AttachmentTrackParams, AudioTrackPacket,
71    AudioTrackParams, DataTrackPacket, DataTrackParams, DemuxedPacket, Demuxer,
72    SubtitleTrackPacket, SubtitleTrackParams, TrackIndex, TrackInfo, TrackKind, TrackParams,
73    UnknownTrackParams, VideoTrackPacket, VideoTrackParams,
74  },
75};
76use smol_str::SmolStr;
77
78use crate::{
79  Ffmpeg, FfmpegBuffer, boundary,
80  buffer::PacketBufferError,
81  codec_id::CodecId,
82  extras::{AttachmentPacketExtra, TrackExtra},
83  reader_guard::{GuardedReader, PanicLatch},
84  sample_format::SampleFormat,
85};
86
87/// One microsecond — the timebase `avformat_seek_file` expects when no
88/// reference stream is named (`stream_index == -1`).
89fn av_time_base_q() -> Timebase {
90  Timebase::new(1, NonZeroI32::new(1_000_000).expect("1e6 is non-zero"))
91}
92
93/// `mediadecode::demuxer::Demuxer` impl wrapping `ffmpeg::format::context::Input`.
94///
95/// Construction is deliberately not on the trait — see [`Self::open`]
96/// and [`Self::open_reader`].
97pub struct FfmpegDemuxer {
98  input: Input,
99  tracks: Vec<TrackInfo<Ffmpeg>>,
100  pending: VecDeque<(
101    TrackIndex,
102    AttachmentPacket<AttachmentPacketExtra, FfmpegBuffer>,
103  )>,
104  /// `true` once this session has answered `Ok(None)`. Only then does
105  /// [`Self::seek`] clear the `AVIOContext`'s EOF latch — clearing it
106  /// unconditionally would also erase a genuine sticky I/O error, which
107  /// `Input::seek` goes out of its way to preserve.
108  eof: bool,
109  /// Set for a session opened over a caller's reader: where a panic
110  /// raised inside that reader is recorded. `None` for a path-opened
111  /// session, which runs no caller code.
112  reader_panic: Option<Arc<PanicLatch>>,
113}
114
115impl FfmpegDemuxer {
116  /// Opens a container from a filesystem path.
117  ///
118  /// Runs `avformat_open_input` followed by
119  /// `avformat_find_stream_info`, then builds the track table and
120  /// captures every attachment payload.
121  ///
122  /// Call [`ffmpeg_next::init`] once before the first open if you want
123  /// FFmpeg's logging and network protocols configured; probing a local
124  /// container does not require it.
125  pub fn open<P: AsRef<Path> + ?Sized>(path: &P) -> Result<Self, DemuxError> {
126    Self::from_input(format::input(path)?)
127  }
128
129  /// Opens a container from any `Read + Seek` byte source, through a
130  /// custom `AVIOContext`.
131  ///
132  /// `Seek` is mandatory and not negotiable: MP4 files routinely put
133  /// `moov` at the end, so a reader that cannot go backwards cannot be
134  /// probed at all — and the seek law on the face would be
135  /// unimplementable.
136  ///
137  /// `filename` is a probe hint, not a path: libavformat uses its
138  /// extension to break ties between formats whose byte signatures are
139  /// ambiguous. Pass `None` when there is nothing to hint with.
140  ///
141  /// # A panicking reader
142  ///
143  /// libavformat drives the reader from `extern "C"` callbacks, where a
144  /// panic would abort the process rather than unwind. Every call into
145  /// `reader` therefore runs under `catch_unwind`: a panic becomes an
146  /// I/O error for libavformat and surfaces here — or from the next
147  /// [`next_packet`](Demuxer::next_packet) / [`seek`](Demuxer::seek) —
148  /// as [`DemuxError::ReaderPanic`], carrying the panic's message. The
149  /// session is terminal from that point: the `AVIOContext`'s error
150  /// state is sticky and the reader's own state is unknown.
151  pub fn open_reader<R: Read + Seek + Send + 'static>(
152    reader: R,
153    filename: Option<&str>,
154  ) -> Result<Self, DemuxError> {
155    let (guarded, latch) = GuardedReader::new(reader);
156    let io = format::context::StreamIo::from_read_seek(guarded)?;
157    let input = format::input_from_stream(io, filename, None)
158      .map_err(|e| reader_panic(&latch).unwrap_or(DemuxError::Ffmpeg(e)))?;
159    // A panic libavformat tolerated (a failed probe it recovered from)
160    // still poisoned the reader; the session must not open over it.
161    if let Some(panicked) = reader_panic(&latch) {
162      return Err(panicked);
163    }
164    let mut demuxer = Self::from_input(input)?;
165    demuxer.reader_panic = Some(latch);
166    Ok(demuxer)
167  }
168
169  /// Borrows the wrapped `ffmpeg::format::context::Input` — for
170  /// `av_dump_format`, container-level metadata, chapters, and anything
171  /// else the portable track table has no seat for.
172  #[cfg_attr(not(tarpaulin), inline(always))]
173  pub const fn input(&self) -> &Input {
174    &self.input
175  }
176
177  fn from_input(input: Input) -> Result<Self, DemuxError> {
178    let (tracks, pending) = build_tracks(&input)?;
179    Ok(Self {
180      input,
181      tracks,
182      pending,
183      eof: false,
184      reader_panic: None,
185    })
186  }
187
188  /// The error a panicked reader owes this session, if one panicked.
189  fn panicked(&self) -> Option<DemuxError> {
190    self.reader_panic.as_deref().and_then(reader_panic)
191  }
192}
193
194/// Names the stream a payload failure happened on. Shared by all five
195/// delivery arms so the failure cannot be swallowed on one of them.
196fn on_stream<T>(
197  stream_index: usize,
198  result: Result<Option<T>, PacketBufferError>,
199) -> Result<Option<T>, DemuxError> {
200  result.map_err(|source| DemuxError::PacketBuffer(PacketBuffer::new(stream_index, source)))
201}
202
203/// Turns a latched reader panic into the error that names it.
204fn reader_panic(latch: &PanicLatch) -> Option<DemuxError> {
205  latch
206    .message()
207    .map(|message| DemuxError::ReaderPanic(ReaderPanic::new(message)))
208}
209
210impl Demuxer for FfmpegDemuxer {
211  type Adapter = Ffmpeg;
212  type Buffer = FfmpegBuffer;
213  type Error = DemuxError;
214
215  fn tracks(&self) -> &[TrackInfo<Ffmpeg>] {
216    &self.tracks
217  }
218
219  fn take_tracks(&mut self) -> Vec<TrackInfo<Ffmpeg>> {
220    mem::take(&mut self.tracks)
221  }
222
223  fn next_packet(&mut self) -> Result<Option<DemuxedPacket<Ffmpeg, FfmpegBuffer>>, DemuxError> {
224    // A latched reader panic is terminal, and terminal starts here. The
225    // queue is filled at open and owes nothing to the reader, so a pull
226    // that drained it would answer `Ok` to a caller the session has
227    // already told the truth to — `seek` can latch a panic while
228    // attachments are still queued.
229    if let Some(panicked) = self.panicked() {
230      return Err(panicked);
231    }
232
233    // The attachment queue drains first and drains completely, which is
234    // the whole of "exactly one packet, before any timed packet": no
235    // `av_read_frame` has run yet when the last one leaves.
236    if let Some((track, packet)) = self.pending.pop_front() {
237      return Ok(Some(DemuxedPacket::Attachment(AttachmentTrackPacket::new(
238        track, packet,
239      ))));
240    }
241
242    loop {
243      let mut packet = Packet::empty();
244      let read = packet.read(&mut self.input);
245      // A panicking reader reported an ordinary I/O error to C, and
246      // libavformat may answer that with the error, with EOF (a stream
247      // it cannot read looks finished), or with a packet it had already
248      // buffered. None of those are the file's word, so the latch is
249      // consulted whatever the outcome was.
250      if let Some(panicked) = self.panicked() {
251        return Err(panicked);
252      }
253      match read {
254        Ok(()) => {}
255        Err(ffmpeg_next::Error::Eof) => {
256          self.eof = true;
257          return Ok(None);
258        }
259        // A demuxer can resync past a corrupt packet, and
260        // `AVERROR_INVALIDDATA` is not latched into the `AVIOContext`,
261        // so reading again makes progress. Every other error is sticky
262        // and is surfaced.
263        Err(ffmpeg_next::Error::InvalidData) => continue,
264        Err(e) => return Err(DemuxError::Ffmpeg(e)),
265      }
266
267      let index = packet.stream();
268      // A packet for a stream the table does not describe cannot be
269      // placed. libavformat does not produce these, but the index comes
270      // from C and indexes a `Vec`.
271      let Some(info) = self.tracks.get(index) else {
272        continue;
273      };
274      let track = TrackIndex::new(index);
275      let time_base = info.timebase();
276
277      // A payload that is there and cannot be referenced is an error,
278      // never a silently dropped packet: `Ok(None)` below means the
279      // packet carried nothing, and that is the only thing that reads
280      // the next one.
281      let built = match info.kind() {
282        TrackKind::Video => on_stream(
283          index,
284          boundary::video_packet_from_ffmpeg_in(&packet, time_base),
285        )?
286        .map(|packet| DemuxedPacket::Video(VideoTrackPacket::new(track, packet))),
287        TrackKind::Audio => on_stream(
288          index,
289          boundary::audio_packet_from_ffmpeg_in(&packet, time_base),
290        )?
291        .map(|packet| DemuxedPacket::Audio(AudioTrackPacket::new(track, packet))),
292        TrackKind::Subtitle => on_stream(
293          index,
294          boundary::subtitle_packet_from_ffmpeg_in(&packet, time_base),
295        )?
296        .map(|packet| DemuxedPacket::Subtitle(SubtitleTrackPacket::new(track, packet))),
297        TrackKind::Data => on_stream(
298          index,
299          boundary::data_packet_from_ffmpeg_in(&packet, time_base),
300        )?
301        .map(|packet| DemuxedPacket::Data(DataTrackPacket::new(track, packet))),
302        // Every attachment track's one packet was queued at open time,
303        // so anything arriving on one now is the duplicate some
304        // demuxers emit for cover art. Drop it — the contract is
305        // exactly one, and the one has already left.
306        TrackKind::Attachment => continue,
307        // The roster of arms is five; a track nothing can name has no
308        // arm and its packets are not delivered.
309        TrackKind::Unknown => continue,
310      };
311
312      // `None` here means the packet carried no payload — an empty
313      // packet, which some demuxers emit as a marker. Nothing to
314      // deliver; read the next one.
315      if let Some(out) = built {
316        return Ok(Some(out));
317      }
318    }
319  }
320
321  fn seek(&mut self, target: Timestamp) -> Result<(), DemuxError> {
322    let ts = target.rescale_to(av_time_base_q()).pts();
323    // Only our own EOF latch is cleared, and only before the seek —
324    // the seek machinery gates on `eof_reached`, so clearing it
325    // afterwards would be too late.
326    if self.eof {
327      self.input.clear_eof();
328      self.eof = false;
329    }
330    // `..ts` is how ffmpeg-next spells the seek window: it reads only
331    // the endpoint, and `avformat_seek_file`'s `max_ts` is inclusive,
332    // so the window is `[i64::MIN, ts]`. FFmpeg picks the closest seek
333    // point inside it — the nearest keyframe at or before the target.
334    // Never after: a decoder started past the target has no reference
335    // frame.
336    let sought = self.input.seek(ts, ..ts);
337    if let Some(panicked) = self.panicked() {
338      return Err(panicked);
339    }
340    sought?;
341    Ok(())
342  }
343}
344
345/// Payload for [`DemuxError::AttachmentAlloc`].
346///
347/// FFmpeg refused a buffer allocation while capturing an attachment's
348/// payload at open time.
349#[derive(thiserror::Error, Debug, Clone)]
350#[error("out of memory capturing the attachment payload for stream {stream_index}")]
351pub struct AttachmentAlloc {
352  stream_index: usize,
353}
354
355impl AttachmentAlloc {
356  /// Constructs an `AttachmentAlloc` payload.
357  #[cfg_attr(not(tarpaulin), inline(always))]
358  pub const fn new(stream_index: usize) -> Self {
359    Self { stream_index }
360  }
361  /// The `AVStream.index` whose payload could not be captured.
362  #[cfg_attr(not(tarpaulin), inline(always))]
363  pub const fn stream_index(&self) -> usize {
364    self.stream_index
365  }
366}
367
368/// Payload for [`DemuxError::ParametersMissing`].
369///
370/// Codec parameters arrived that were never allocated.
371///
372/// `ffmpeg_next::codec::Parameters` has safe constructors that hand
373/// back a null-backed value when FFmpeg's allocation failed, and they
374/// report nothing. Copying from one dereferences null, so it is
375/// refused where it arrives — at construction, and again in the
376/// copier — rather than crashing later somewhere that has forgotten
377/// the allocator ever failed.
378#[derive(thiserror::Error, Debug, Clone)]
379#[error("the codec parameters for stream {stream_index} were never allocated")]
380pub struct ParametersMissing {
381  stream_index: usize,
382}
383
384impl ParametersMissing {
385  /// Constructs a `ParametersMissing` payload.
386  #[cfg_attr(not(tarpaulin), inline(always))]
387  pub const fn new(stream_index: usize) -> Self {
388    Self { stream_index }
389  }
390  /// The `AVStream.index` the parameters were offered for.
391  #[cfg_attr(not(tarpaulin), inline(always))]
392  pub const fn stream_index(&self) -> usize {
393    self.stream_index
394  }
395}
396
397/// Payload for [`DemuxError::ParametersAlloc`].
398///
399/// Codec parameters for a track could not be allocated.
400#[derive(thiserror::Error, Debug, Clone)]
401#[error("out of memory allocating the codec parameters for stream {stream_index}")]
402pub struct ParametersAlloc {
403  stream_index: usize,
404}
405
406impl ParametersAlloc {
407  /// Constructs a `ParametersAlloc` payload.
408  #[cfg_attr(not(tarpaulin), inline(always))]
409  pub const fn new(stream_index: usize) -> Self {
410    Self { stream_index }
411  }
412  /// The `AVStream.index` whose parameters could not be copied.
413  #[cfg_attr(not(tarpaulin), inline(always))]
414  pub const fn stream_index(&self) -> usize {
415    self.stream_index
416  }
417}
418
419/// Payload for [`DemuxError::ParametersCopy`].
420///
421/// Copying a track's codec parameters failed part way.
422#[derive(thiserror::Error, Debug, Clone)]
423#[error("the codec parameters for stream {stream_index} could not be copied: {source}")]
424pub struct ParametersCopy {
425  stream_index: usize,
426  #[source]
427  source: ffmpeg_next::Error,
428}
429
430impl ParametersCopy {
431  /// Constructs a `ParametersCopy` payload.
432  #[cfg_attr(not(tarpaulin), inline(always))]
433  pub const fn new(stream_index: usize, source: ffmpeg_next::Error) -> Self {
434    Self {
435      stream_index,
436      source,
437    }
438  }
439  /// The `AVStream.index` whose parameters could not be copied.
440  #[cfg_attr(not(tarpaulin), inline(always))]
441  pub const fn stream_index(&self) -> usize {
442    self.stream_index
443  }
444  /// What FFmpeg said.
445  #[cfg_attr(not(tarpaulin), inline(always))]
446  pub const fn source(&self) -> &ffmpeg_next::Error {
447    &self.source
448  }
449}
450
451/// Payload for [`DemuxError::PacketBuffer`].
452///
453/// A packet's payload could not be referenced — the bytes are there
454/// and this layer could not carry them.
455///
456/// Never raised for a packet that simply has no payload: an empty
457/// packet is a marker some demuxers emit, and it is skipped in
458/// silence. Distinguishing the two is what keeps a refcount failure
459/// under memory pressure from looking like the file's own word and
460/// dropping real compressed bytes.
461#[derive(thiserror::Error, Debug, Clone)]
462#[error("stream {stream_index}: {source}")]
463pub struct PacketBuffer {
464  stream_index: usize,
465  #[source]
466  source: PacketBufferError,
467}
468
469impl PacketBuffer {
470  /// Constructs a `PacketBuffer` payload.
471  #[cfg_attr(not(tarpaulin), inline(always))]
472  pub const fn new(stream_index: usize, source: PacketBufferError) -> Self {
473    Self {
474      stream_index,
475      source,
476    }
477  }
478  /// The `AVStream.index` the packet belongs to.
479  #[cfg_attr(not(tarpaulin), inline(always))]
480  pub const fn stream_index(&self) -> usize {
481    self.stream_index
482  }
483  /// What went wrong.
484  #[cfg_attr(not(tarpaulin), inline(always))]
485  pub const fn source(&self) -> &PacketBufferError {
486    &self.source
487  }
488}
489
490/// Payload for [`DemuxError::ReaderPanic`].
491///
492/// The `Read + Seek` source given to [`FfmpegDemuxer::open_reader`]
493/// panicked inside a libavformat callback.
494///
495/// The panic was caught before it could cross the `extern "C"`
496/// boundary and abort the process; this is what it said. The session
497/// is terminal — every later call reports the same panic.
498#[derive(thiserror::Error, Debug, Clone)]
499#[error("the reader panicked: {message}")]
500pub struct ReaderPanic {
501  message: SmolStr,
502}
503
504impl ReaderPanic {
505  /// Constructs a `ReaderPanic` payload.
506  #[cfg_attr(not(tarpaulin), inline(always))]
507  pub const fn new(message: SmolStr) -> Self {
508    Self { message }
509  }
510  /// What the panic payload said.
511  #[cfg_attr(not(tarpaulin), inline(always))]
512  pub fn message(&self) -> &str {
513    self.message.as_str()
514  }
515}
516
517/// Errors from [`FfmpegDemuxer`].
518#[derive(thiserror::Error, Debug, Clone, IsVariant, Unwrap, TryUnwrap)]
519#[unwrap(ref, ref_mut)]
520#[try_unwrap(ref, ref_mut)]
521pub enum DemuxError {
522  /// The wrapped libavformat call reported an error — open, read or
523  /// seek.
524  #[error(transparent)]
525  Ffmpeg(#[from] ffmpeg_next::Error),
526
527  /// FFmpeg refused a buffer allocation while capturing an
528  /// attachment's payload at open time.
529  #[error(transparent)]
530  AttachmentAlloc(#[from] AttachmentAlloc),
531
532  /// Codec parameters arrived that were never allocated.
533  #[error(transparent)]
534  ParametersMissing(#[from] ParametersMissing),
535
536  /// Codec parameters for a track could not be allocated.
537  #[error(transparent)]
538  ParametersAlloc(#[from] ParametersAlloc),
539
540  /// Copying a track's codec parameters failed part way.
541  #[error(transparent)]
542  ParametersCopy(#[from] ParametersCopy),
543
544  /// A packet's payload could not be referenced — the bytes are there
545  /// and this layer could not carry them.
546  #[error(transparent)]
547  PacketBuffer(#[from] PacketBuffer),
548
549  /// The `Read + Seek` source given to
550  /// [`FfmpegDemuxer::open_reader`] panicked inside a libavformat
551  /// callback.
552  #[error(transparent)]
553  ReaderPanic(#[from] ReaderPanic),
554}
555
556// ---------------------------------------------------------------------------
557//  Track-table construction.
558// ---------------------------------------------------------------------------
559
560type BuiltTracks = (
561  Vec<TrackInfo<Ffmpeg>>,
562  VecDeque<(
563    TrackIndex,
564    AttachmentPacket<AttachmentPacketExtra, FfmpegBuffer>,
565  )>,
566);
567
568fn build_tracks(input: &Input) -> Result<BuiltTracks, DemuxError> {
569  let count = input.streams().len();
570  let mut tracks = Vec::with_capacity(count);
571  let mut pending = VecDeque::new();
572
573  for stream in input.streams() {
574    let index = stream.index();
575    // `AVStream.index` is the stream's position in `ic->streams[]` and
576    // libavformat keeps the two identical. The demux tier makes
577    // `TrackIndex` mean "position in `tracks()`", so the two agree by
578    // construction — but only if they really are dense and in order,
579    // which is cheap to insist on rather than assume.
580    debug_assert_eq!(
581      index,
582      tracks.len(),
583      "AVStream indices are dense and ordered"
584    );
585
586    let parameters = stream.parameters();
587    let par = unsafe { parameters.as_ptr() };
588    // Never read `AVCodecParameters.codec_type` / `.codec_id` as their
589    // bindgen enums: a value outside this build's discriminant set is
590    // UB the moment it exists. The medium goes through `Parameters`
591    // (which does construct the enum, but only from
592    // `AVMediaType`'s tiny, stable set) and the codec id is read as the
593    // raw integer it is on the wire.
594    let medium = parameters.medium();
595    let codec =
596      CodecId::from_raw(unsafe { read_unaligned(addr_of!((*par).codec_id).cast::<i32>()) });
597
598    let disposition = unsafe { (*stream.as_ptr()).disposition };
599    let attached_pic = is_attachment_disposition(disposition);
600
601    let time_base = rational_to_timebase(stream.time_base());
602    let raw_duration = stream.duration();
603    let duration = (raw_duration != AV_NOPTS_VALUE && raw_duration > 0)
604      .then(|| Timestamp::new(raw_duration, time_base));
605    let raw_start = stream.start_time();
606    let frames = stream.frames();
607
608    let params = if attached_pic {
609      // Cover art. A still image in a video-shaped slot is an
610      // attachment by every property that matters, and the `Video` arm
611      // is reserved for motion video.
612      TrackParams::Attachment(AttachmentTrackParams::new(codec))
613    } else {
614      match medium {
615        media::Type::Video => TrackParams::Video(VideoTrackParams::new(
616          codec,
617          unsafe { (*par).width }.max(0) as u32,
618          unsafe { (*par).height }.max(0) as u32,
619          boundary::from_av_pixel_format(unsafe { (*par).format }),
620          rate_to_timebase(stream.avg_frame_rate()),
621        )),
622        media::Type::Audio => {
623          let ch_layout = unsafe { std::ptr::addr_of!((*par).ch_layout) };
624          // SAFETY: `par` is a live `*const AVCodecParameters` for the
625          // life of `parameters`; the helper validates `order` as an
626          // `i32` before constructing any `AVChannelOrder`.
627          let channel_layout =
628            unsafe { crate::channel_layout::channel_layout_description_from_raw_ptr(ch_layout) };
629          TrackParams::Audio(AudioTrackParams::new(
630            codec,
631            unsafe { (*par).sample_rate }.max(0) as u32,
632            channel_layout.channels().min(255) as u8,
633            SampleFormat::from_raw(unsafe { (*par).format }),
634            channel_layout,
635          ))
636        }
637        media::Type::Subtitle => TrackParams::Subtitle(SubtitleTrackParams::new(codec)),
638        media::Type::Data => TrackParams::Data(DataTrackParams::new(codec)),
639        media::Type::Attachment => TrackParams::Attachment(AttachmentTrackParams::new(codec)),
640        media::Type::Unknown => TrackParams::Unknown(UnknownTrackParams::new(codec)),
641      }
642    };
643
644    let extra = TrackExtra::new(
645      index as i32,
646      crate::extras::clone_parameters(&parameters, index)?,
647    )?
648    .with_disposition(disposition)
649    .with_start_time((raw_start != AV_NOPTS_VALUE).then_some(raw_start))
650    .with_frame_count((frames > 0).then_some(frames));
651
652    // SAFETY: `stream` keeps the `AVStream` — and so its metadata
653    // dictionary — live across both reads. The dictionary is read
654    // through `av_dict_get` rather than through
655    // `DictionaryRef::get`: see [`metadata_text`].
656    let metadata = unsafe { (*stream.as_ptr()).metadata };
657    let info = TrackInfo::new(time_base, params, extra)
658      .with_duration(duration)
659      .with_filename(unsafe { metadata_text(metadata, c"filename") })
660      .with_mime_type(unsafe { metadata_text(metadata, c"mimetype") });
661
662    // Capture the attachment payload now, so the queue is complete
663    // before a single timed packet has been read. Every attachment
664    // track leaves this loop with exactly one packet queued, or the
665    // open fails: that is what makes "exactly one packet, before any
666    // timed packet" a property of the construction rather than a
667    // promise the pull loop has to keep.
668    if info.kind() == TrackKind::Attachment {
669      let packet = if attached_pic {
670        // SAFETY: `stream` keeps the format context (and so the
671        // `AVStream`) live; `attached_pic` is an `AVPacket` embedded by
672        // value, and `addr_of!` reaches it without forming a reference
673        // to the stream.
674        let pkt = unsafe { std::ptr::addr_of!((*stream.as_ptr()).attached_pic) };
675        unsafe { attached_pic_payload(pkt, index) }?
676      } else {
677        extradata_payload(&stream)?
678      };
679      pending.push_back((TrackIndex::new(index), packet));
680    }
681
682    tracks.push(info);
683  }
684
685  Ok((tracks, pending))
686}
687
688/// Whether a stream's disposition makes it an **attachment** — a
689/// payload with no place on the timeline — rather than a timed track.
690///
691/// `AV_DISPOSITION_ATTACHED_PIC` alone says "cover art": one still
692/// image, parked in `AVStream.attached_pic`, no timeline. But FFmpeg
693/// pairs it with `AV_DISPOSITION_TIMED_THUMBNAILS` for a different
694/// thing entirely — "the stream is sparse, and contains thumbnail
695/// images, often corresponding to chapter markers", a flag its own
696/// header documents as *only ever* used together with `ATTACHED_PIC`.
697/// Such a stream has many images and every one of them has a
698/// timestamp.
699///
700/// Classifying that as an attachment loses all but the first: the
701/// attachment contract is exactly one packet, so the queue takes the
702/// parked copy and the delivery loop drops every timed packet on the
703/// track. It goes to the **`Video`** arm instead. That does not
704/// contradict "cover art is an attachment, not video" — the reason
705/// behind that ruling is that a single still with no timeline must not
706/// look like a motion track, and a timed-thumbnail stream *is* on the
707/// timeline. It is sparse video: a codec id, a frame size, a pixel
708/// format and packets with timestamps, which is everything a consumer
709/// needs to decode the images. The `Data` arm was the alternative and
710/// is worse: it would strand encoded pictures in an arm that names no
711/// decoder.
712///
713/// The bits are tested against the raw `AVStream.disposition` rather
714/// than through `ffmpeg_next`'s `Disposition`, which mints no
715/// `TIMED_THUMBNAILS` constant at all — its `from_bits_truncate` drops
716/// every bit this build of the wrapper has no name for, which is how
717/// the distinction went missing in the first place.
718const fn is_attachment_disposition(disposition: c_int) -> bool {
719  disposition & AV_DISPOSITION_ATTACHED_PIC != 0
720    && disposition & AV_DISPOSITION_TIMED_THUMBNAILS == 0
721}
722
723/// Upper bound on the NUL search in [`metadata_text`].
724///
725/// Generous by four orders of magnitude for a filename or a MIME type,
726/// and there only so that a value libavutil did not terminate cannot
727/// turn the walk into an unbounded read — the same discipline
728/// [`crate::channel_layout`] and the pixel-format namer follow. A value
729/// longer than this is refused rather than truncated: a truncated
730/// filename is a different filename.
731const METADATA_VALUE_MAX_BYTES: usize = 64 * 1024;
732
733/// Reads one entry out of a container's metadata dictionary as text
734/// this crate can own.
735///
736/// **Why not `DictionaryRef::get`.** ffmpeg-next 9.0.0 builds its
737/// `&str` with `from_utf8_unchecked`
738/// (`src/util/dictionary/immutable.rs`), and FFmpeg does not validate
739/// demuxed metadata as UTF-8 — an ID3 frame, a Matroska attachment
740/// name or a MOV atom carries whatever bytes the file carries. A
741/// `filename` holding a stray `0x80` would therefore have produced a
742/// `&str` that is not UTF-8: undefined behaviour the moment it exists,
743/// before `SmolStr` ever copies it.
744///
745/// Invalid bytes are replaced (`U+FFFD`), not refused. This is
746/// *identity* metadata — the name a font was attached under, the MIME
747/// type declared for a cover — and a file that names its attachment in
748/// some legacy codepage is still a file worth opening. The replacement
749/// characters say plainly that the container's bytes were not text.
750///
751/// # Safety
752///
753/// `dict` must be null or a live `*const AVDictionary` for the
754/// duration of this call.
755unsafe fn metadata_text(dict: *const AVDictionary, key: &CStr) -> Option<SmolStr> {
756  if dict.is_null() {
757    return None;
758  }
759  // SAFETY: `dict` is live per the contract above and `key` is a
760  // NUL-terminated C string by construction; `av_dict_get` reads both
761  // and returns a borrowed entry owned by the dictionary.
762  let entry = unsafe { av_dict_get(dict, key.as_ptr(), std::ptr::null(), 0) };
763  if entry.is_null() {
764    return None;
765  }
766  // SAFETY: a non-null entry is a live `AVDictionaryEntry` for as long
767  // as the dictionary is not modified, which it is not here.
768  let value = unsafe { (*entry).value };
769  if value.is_null() {
770    return None;
771  }
772  for len in 0..METADATA_VALUE_MAX_BYTES {
773    // SAFETY: `value` is a NUL-terminated string libavutil allocated
774    // with `av_strdup`; the walk reads at most one byte past the last
775    // value byte and stops at the terminator.
776    if unsafe { *value.add(len).cast::<u8>() } == 0 {
777      // SAFETY: the `len` bytes below the terminator were just walked,
778      // so the slice is in bounds and initialised.
779      let bytes = unsafe { std::slice::from_raw_parts(value.cast::<u8>(), len) };
780      return Some(SmolStr::new(std::string::String::from_utf8_lossy(bytes)));
781    }
782  }
783  None
784}
785
786/// Wraps `AVStream.attached_pic` — the real packet libavformat parsed
787/// for a cover-art stream — as this track's one attachment packet.
788///
789/// A stream that declares cover art but parks no payload still gets a
790/// packet: an empty one, marked `synthesized`, because the contract is
791/// one packet per attachment track and a consumer that sees an empty
792/// payload learns something true about the file. The alternative shipped
793/// once — waiting for the payload to arrive as a packet later — and it
794/// cannot hold: nothing stops a timed packet, or a seek, from coming
795/// first, so the track's packet would arrive out of order or never.
796///
797/// Measured before it was written: across MP3 (ID3 APIC), M4A (`covr`),
798/// FLAC (`METADATA_BLOCK_PICTURE`) and Matroska (an `image/*`
799/// attachment), every stream libavformat gives
800/// `AV_DISPOSITION_ATTACHED_PIC` also carries the parked packet —
801/// `ff_add_attached_pic` sets the disposition and fills
802/// `attached_pic` in the same breath. The empty case is the honest
803/// answer to a state this build's demuxers do not produce, not a
804/// fallback anything relies on.
805///
806/// # Safety
807///
808/// `pkt` must be a live `*const AVPacket` — in practice the
809/// `attached_pic` embedded in the `AVStream` at `index` — for the
810/// duration of this call.
811unsafe fn attached_pic_payload(
812  pkt: *const ffmpeg_next::ffi::AVPacket,
813  index: usize,
814) -> Result<AttachmentPacket<AttachmentPacketExtra, FfmpegBuffer>, DemuxError> {
815  // SAFETY: `pkt` is live per the contract above.
816  let captured = unsafe { crate::buffer::payload_of(pkt) }
817    .map_err(|source| DemuxError::PacketBuffer(PacketBuffer::new(index, source)))?;
818  let extra = AttachmentPacketExtra::new(index as i32);
819  Ok(match captured {
820    Some(payload) => {
821      // The hoisted packet's own flags, through the same raw reader the
822      // five boundary conversions use. FFmpeg marks an attached picture
823      // `AV_PKT_FLAG_KEY` — a still image is a keyframe if anything is
824      // — and building this one with empty flags dropped that, along
825      // with `CORRUPT` and every other bit the packet really carried.
826      // SAFETY: `pkt` points at the live embedded `AVPacket`.
827      let flags = unsafe { boundary::md_flags_from_av_packet(pkt) }
828        .map_err(|source| DemuxError::PacketBuffer(PacketBuffer::new(index, source)))?;
829      AttachmentPacket::new(payload, extra).with_flags(flags)
830    }
831    // Nothing was parked, so there are no flags to read: an empty set
832    // is the honest answer for a packet this layer invented.
833    None => AttachmentPacket::new(
834      FfmpegBuffer::copy_from_slice(&[])
835        .ok_or(DemuxError::AttachmentAlloc(AttachmentAlloc::new(index)))?,
836      extra.with_synthesized(true),
837    ),
838  })
839}
840
841/// Builds an attachment payload out of a track's codec extradata — the
842/// only place a font's bytes ever live, since an
843/// `AVMEDIA_TYPE_ATTACHMENT` stream produces no packets at all.
844///
845/// A track with no extradata still gets a packet, with an empty
846/// payload: the contract is one packet per attachment track, and a
847/// consumer that sees an empty one learns something true about the
848/// file. Only an allocation failure is an error.
849fn extradata_payload(
850  stream: &ffmpeg_next::format::stream::Stream<'_>,
851) -> Result<AttachmentPacket<AttachmentPacketExtra, FfmpegBuffer>, DemuxError> {
852  let index = stream.index();
853  let parameters = stream.parameters();
854  // SAFETY: `parameters` keeps the `AVCodecParameters` live;
855  // `extradata` / `extradata_size` are public fields.
856  let par = unsafe { parameters.as_ptr() };
857  let ptr = unsafe { (*par).extradata };
858  let len = unsafe { (*par).extradata_size }.max(0) as usize;
859  let bytes: &[u8] = if ptr.is_null() || len == 0 {
860    &[]
861  } else {
862    // SAFETY: libavformat guarantees `extradata` is readable for
863    // `extradata_size` bytes (plus its padding) while the parameters
864    // live, and the slice is consumed before this function returns.
865    unsafe { std::slice::from_raw_parts(ptr, len) }
866  };
867  let payload = FfmpegBuffer::copy_from_slice(bytes)
868    .ok_or(DemuxError::AttachmentAlloc(AttachmentAlloc::new(index)))?;
869  Ok(AttachmentPacket::new(
870    payload,
871    AttachmentPacketExtra::new(index as i32).with_synthesized(true),
872  ))
873}
874
875/// A stream's `AVRational` timebase as a [`Timebase`]. A zero or
876/// negative denominator is clamped to 1 rather than refused: a
877/// malformed timebase makes the track's timestamps meaningless, not the
878/// file unreadable, and every other track still demuxes.
879fn rational_to_timebase(value: Rational) -> Timebase {
880  Timebase::new(
881    value.numerator(),
882    NonZeroI32::new(value.denominator().max(1)).expect("clamped to at least 1"),
883  )
884}
885
886/// A frame *rate* as a rate-shaped [`Timebase`] (`30000/1001` for
887/// 29.97 fps), or `None` when the container declares none.
888fn rate_to_timebase(value: Rational) -> Option<Timebase> {
889  let (num, den) = (value.numerator(), value.denominator());
890  (num > 0 && den > 0).then(|| Timebase::new(num, NonZeroI32::new(den).expect("checked above")))
891}
892
893#[cfg(test)]
894mod tests {
895  use ffmpeg_next::ffi::{av_dict_free, av_dict_set};
896
897  use ffmpeg_next::codec::Parameters;
898
899  use super::*;
900  use crate::extras::TrackExtra;
901
902  /// Builds a dictionary holding one entry whose *value* is the given
903  /// raw bytes. The bytes go in as a C string, which is all
904  /// `av_dict_set` promises to copy — FFmpeg never asks whether they
905  /// are UTF-8, which is the whole point of the lane below.
906  fn dict_with(key: &CStr, value: &[u8]) -> *mut AVDictionary {
907    let mut dict: *mut AVDictionary = std::ptr::null_mut();
908    let mut terminated = value.to_vec();
909    terminated.push(0);
910    let rc = unsafe {
911      av_dict_set(
912        &mut dict,
913        key.as_ptr(),
914        terminated.as_ptr().cast::<std::ffi::c_char>(),
915        0,
916      )
917    };
918    assert!(rc >= 0, "av_dict_set failed: {rc}");
919    dict
920  }
921
922  #[test]
923  fn metadata_that_is_not_utf8_is_read_lossily_not_unsoundly() {
924    // The bytes a real container can hold: a Latin-1 "café.ttf" whose
925    // 0xE9 is not valid UTF-8 on its own. Read through
926    // `DictionaryRef::get` this produced a `&str` that violates the
927    // type's invariant — undefined behaviour before `SmolStr` ever
928    // copied it.
929    let raw = b"caf\xE9.ttf".to_vec();
930    assert!(
931      std::str::from_utf8(&raw).is_err(),
932      "the source bytes really are not UTF-8",
933    );
934    let dict = dict_with(c"filename", &raw);
935    let text = unsafe { metadata_text(dict, c"filename") }.expect("the entry exists");
936    assert_eq!(text.as_str(), "caf\u{FFFD}.ttf");
937    // A key the dictionary does not hold, and a null dictionary, are
938    // both simply absent.
939    assert_eq!(unsafe { metadata_text(dict, c"mimetype") }, None);
940    assert_eq!(
941      unsafe { metadata_text(std::ptr::null(), c"filename") },
942      None
943    );
944    unsafe { av_dict_free(&mut { dict }) };
945  }
946
947  #[test]
948  fn valid_metadata_survives_unchanged() {
949    let dict = dict_with(c"mimetype", b"application/x-truetype-font");
950    assert_eq!(
951      unsafe { metadata_text(dict, c"mimetype") }.as_deref(),
952      Some("application/x-truetype-font"),
953    );
954    unsafe { av_dict_free(&mut { dict }) };
955  }
956
957  #[test]
958  fn an_unterminated_length_is_refused_rather_than_truncated() {
959    // Nothing libavutil produces is this long; the cap exists so a
960    // value it did not terminate cannot walk off the end. A value that
961    // reaches the cap is absent, never a prefix of itself.
962    let long = vec![b'a'; METADATA_VALUE_MAX_BYTES + 1];
963    let dict = dict_with(c"filename", &long);
964    assert_eq!(unsafe { metadata_text(dict, c"filename") }, None);
965    unsafe { av_dict_free(&mut { dict }) };
966  }
967
968  /// A reader that panics with a payload whose destructor panics in
969  /// turn. Both panics are safe code; the second one is what used to
970  /// leave the guard and enter the `extern "C"` AVIO callback.
971  struct PanicsWithAHostilePayload;
972
973  struct PanicOnDrop;
974
975  impl Drop for PanicOnDrop {
976    fn drop(&mut self) {
977      panic!("and the payload went too");
978    }
979  }
980
981  impl std::io::Read for PanicsWithAHostilePayload {
982    fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
983      std::panic::panic_any(PanicOnDrop);
984    }
985  }
986
987  impl std::io::Seek for PanicsWithAHostilePayload {
988    fn seek(&mut self, _pos: std::io::SeekFrom) -> std::io::Result<u64> {
989      std::panic::panic_any(PanicOnDrop);
990    }
991  }
992
993  #[test]
994  fn a_reader_panic_with_a_hostile_payload_does_not_abort_the_process() {
995    // In its own process, because the assertion *is* the process: a
996    // parent that sees the child exit cleanly has seen the abort not
997    // happen. The guard caught the reader's panic and then dropped its
998    // payload outside `catch_unwind`, so a payload whose `Drop` panics
999    // sent that second panic straight out of `read` and into C —
1000    // through the very guard that exists to stop it.
1001    crate::fault_subprocess::in_subprocess(
1002      "demuxer::tests::a_reader_panic_with_a_hostile_payload_does_not_abort_the_process",
1003      || {
1004        let previous = std::panic::take_hook();
1005        std::panic::set_hook(Box::new(|_| {}));
1006        let opened = FfmpegDemuxer::open_reader(PanicsWithAHostilePayload, Some("x.mkv"));
1007        std::panic::set_hook(previous);
1008        match opened {
1009          Err(DemuxError::ReaderPanic(_)) => {}
1010          Err(other) => panic!("expected ReaderPanic, got {other:?}"),
1011          Ok(_) => panic!("a reader that only panics cannot open a container"),
1012        }
1013      },
1014    );
1015  }
1016
1017  #[test]
1018  fn codec_parameters_that_cannot_be_allocated_are_named() {
1019    // `Parameters::new` does not check `avcodec_parameters_alloc`, and
1020    // `clone_from` dereferences the result immediately: under a failed
1021    // allocation the shipped clone would write through null.
1022    crate::fault_subprocess::in_subprocess(
1023      "demuxer::tests::codec_parameters_that_cannot_be_allocated_are_named",
1024      || {
1025        let source = Parameters::new();
1026        assert!(
1027          !unsafe { source.as_ptr() }.is_null(),
1028          "the source allocates before the cap goes on",
1029        );
1030        crate::fault_subprocess::cap_ffmpeg_allocations(1);
1031        let refused = crate::extras::clone_parameters(&source, 4);
1032        crate::fault_subprocess::uncap_ffmpeg_allocations();
1033        assert!(
1034          matches!(
1035            refused,
1036            Err(DemuxError::ParametersAlloc(ref p)) if p.stream_index() == 4
1037          ),
1038          "expected ParametersAlloc, got {:?}",
1039          refused.map(|_| ()),
1040        );
1041        // And with the cap lifted the same copy succeeds, so the
1042        // refusal was the allocator's answer and not a broken helper.
1043        crate::extras::clone_parameters(&source, 4).expect("an uncapped copy");
1044      },
1045    );
1046  }
1047
1048  #[test]
1049  fn the_public_track_extra_copies_are_checked_too() {
1050    // The helper protected `build_tracks` and nothing else: `TrackExtra`
1051    // derived `Clone` and `Default` over `ffmpeg_next`'s `Parameters`,
1052    // whose clone dereferences an unchecked allocation — so safe public
1053    // code could still reach the SIGSEGV by copying a track row. The
1054    // derives are gone; what replaces them answers.
1055    crate::fault_subprocess::in_subprocess(
1056      "demuxer::tests::the_public_track_extra_copies_are_checked_too",
1057      || {
1058        let source = Parameters::new();
1059        assert!(!unsafe { source.as_ptr() }.is_null(), "allocated uncapped");
1060        let extra = TrackExtra::new(
1061          6,
1062          crate::extras::clone_parameters(&source, 6).expect("uncapped"),
1063        )
1064        .expect("real parameters");
1065
1066        crate::fault_subprocess::cap_ffmpeg_allocations(1);
1067        let cloned = extra.try_clone().map(|_| ());
1068        let handed = extra.clone_parameters().map(|_| ());
1069        crate::fault_subprocess::uncap_ffmpeg_allocations();
1070
1071        assert!(
1072          matches!(cloned, Err(DemuxError::ParametersAlloc(ref p)) if p.stream_index() == 6),
1073          "TrackExtra::try_clone: {cloned:?}",
1074        );
1075        assert!(
1076          matches!(handed, Err(DemuxError::ParametersAlloc(ref p)) if p.stream_index() == 6),
1077          "TrackExtra::clone_parameters: {handed:?}",
1078        );
1079
1080        // And both work once the allocator does.
1081        extra.try_clone().expect("an uncapped row copy");
1082        extra.clone_parameters().expect("an uncapped handoff");
1083      },
1084    );
1085  }
1086
1087  #[test]
1088  fn parameters_that_never_allocated_are_refused_at_the_door() {
1089    // The route the destination check could not see. A safe
1090    // `Parameters::new()` under a failed allocation hands back a
1091    // null-backed value and says nothing; the copier then allocated its
1092    // own destination happily — the allocator having recovered by
1093    // then — and called `avcodec_parameters_copy(out, NULL)`, which
1094    // dereferences its source. Same crash, one recovery later, still
1095    // from safe public code.
1096    crate::fault_subprocess::in_subprocess(
1097      "demuxer::tests::parameters_that_never_allocated_are_refused_at_the_door",
1098      || {
1099        // The cap is on *while the source is built* — that is the whole
1100        // difference from the destination lane.
1101        crate::fault_subprocess::cap_ffmpeg_allocations(1);
1102        let never_allocated = Parameters::new();
1103        crate::fault_subprocess::uncap_ffmpeg_allocations();
1104        assert!(
1105          unsafe { never_allocated.as_ptr() }.is_null(),
1106          "the safe constructor really does hand back a null-backed value",
1107        );
1108
1109        // The door: a `TrackExtra` cannot exist over it, so the copy
1110        // methods have nothing to be asked on.
1111        let refused = TrackExtra::new(9, never_allocated);
1112        let Err(DemuxError::ParametersMissing(p)) = refused.map(|_| ()) else {
1113          panic!("a null-backed source must not become a track row");
1114        };
1115        assert_eq!(p.stream_index(), 9);
1116
1117        // And the copier refuses it too, so the invariant is not the
1118        // only thing standing between this and a null dereference.
1119        let never_allocated = {
1120          crate::fault_subprocess::cap_ffmpeg_allocations(1);
1121          let p = Parameters::new();
1122          crate::fault_subprocess::uncap_ffmpeg_allocations();
1123          p
1124        };
1125        assert!(matches!(
1126          crate::extras::clone_parameters(&never_allocated, 9).map(|_| ()),
1127          Err(DemuxError::ParametersMissing(p)) if p.stream_index() == 9,
1128        ));
1129
1130        // A row built over real parameters still copies both ways, so
1131        // the refusal is about the null and nothing else.
1132        let real = Parameters::new();
1133        let extra = TrackExtra::new(9, real).expect("real parameters");
1134        extra.try_clone().expect("row copy");
1135        extra.clone_parameters().expect("handoff");
1136      },
1137    );
1138  }
1139
1140  #[cfg(feature = "resample")]
1141  #[test]
1142  fn a_spec_read_from_parameters_that_never_allocated_is_absent() {
1143    // The same trap at another public door, found by the sweep:
1144    // `ResampleSpec::from_parameters` asks `parameters.medium()`
1145    // first, and *that* dereferences the pointer inside ffmpeg-next
1146    // before any code of ours runs.
1147    crate::fault_subprocess::in_subprocess(
1148      "demuxer::tests::a_spec_read_from_parameters_that_never_allocated_is_absent",
1149      || {
1150        crate::fault_subprocess::cap_ffmpeg_allocations(1);
1151        let never_allocated = Parameters::new();
1152        crate::fault_subprocess::uncap_ffmpeg_allocations();
1153        assert!(unsafe { never_allocated.as_ptr() }.is_null());
1154        assert_eq!(
1155          crate::ResampleSpec::from_parameters(&never_allocated),
1156          None,
1157          "parameters that do not exist describe no audio",
1158        );
1159      },
1160    );
1161  }
1162
1163  #[test]
1164  fn codec_parameters_whose_copy_fails_are_named() {
1165    // The other leg: the destination allocates, and the deep copy of
1166    // the extradata does not. `clone_from` discards that return value,
1167    // so the shipped clone handed back parameters missing the very
1168    // bytes a decoder needs to open — and said nothing.
1169    crate::fault_subprocess::in_subprocess(
1170      "demuxer::tests::codec_parameters_whose_copy_fails_are_named",
1171      || {
1172        const EXTRADATA: usize = 8 * 1024 * 1024;
1173        let mut source = Parameters::new();
1174        // SAFETY: `source` owns a live `AVCodecParameters`; the buffer
1175        // comes from FFmpeg's allocator and is handed to it, so
1176        // `avcodec_parameters_free` releases it with the rest.
1177        unsafe {
1178          let par = source.as_mut_ptr();
1179          let extradata = ffmpeg_next::ffi::av_mallocz(EXTRADATA) as *mut u8;
1180          assert!(!extradata.is_null(), "av_mallocz");
1181          (*par).extradata = extradata;
1182          (*par).extradata_size = EXTRADATA as i32;
1183        }
1184
1185        // Big enough for the destination `AVCodecParameters`, far too
1186        // small for its extradata.
1187        crate::fault_subprocess::cap_ffmpeg_allocations(64 * 1024);
1188        let refused = crate::extras::clone_parameters(&source, 2);
1189        crate::fault_subprocess::uncap_ffmpeg_allocations();
1190        match refused {
1191          Err(DemuxError::ParametersCopy(p)) => assert_eq!(p.stream_index(), 2),
1192          Err(other) => panic!("expected ParametersCopy, got {other:?}"),
1193          Ok(_) => panic!("a copy that could not copy the extradata must not succeed"),
1194        }
1195        crate::extras::clone_parameters(&source, 2).expect("an uncapped copy");
1196      },
1197    );
1198  }
1199
1200  #[test]
1201  fn a_timed_thumbnail_stream_is_not_an_attachment() {
1202    // `TIMED_THUMBNAILS` is documented as only ever appearing beside
1203    // `ATTACHED_PIC`, so testing the picture bit alone reads a sparse
1204    // chapter-thumbnail track as cover art — and the attachment
1205    // contract then delivers exactly one of its images and drops the
1206    // rest, every one of which had a timestamp.
1207    assert!(
1208      is_attachment_disposition(AV_DISPOSITION_ATTACHED_PIC),
1209      "a plain attached picture is still an attachment",
1210    );
1211    assert!(
1212      !is_attachment_disposition(AV_DISPOSITION_ATTACHED_PIC | AV_DISPOSITION_TIMED_THUMBNAILS),
1213      "a timed-thumbnail stream is a timed track, whatever else it is flagged",
1214    );
1215    // Neither bit, and the other bits that ride along, change nothing.
1216    assert!(!is_attachment_disposition(0));
1217    assert!(!is_attachment_disposition(AV_DISPOSITION_TIMED_THUMBNAILS));
1218    assert!(is_attachment_disposition(
1219      AV_DISPOSITION_ATTACHED_PIC | ffmpeg_next::ffi::AV_DISPOSITION_DEFAULT
1220    ));
1221    // And the reason the raw bits are read at all: the wrapper's own
1222    // flag set cannot express the distinction.
1223    assert!(
1224      ffmpeg_next::format::stream::Disposition::from_bits(AV_DISPOSITION_TIMED_THUMBNAILS)
1225        .is_none(),
1226      "ffmpeg_next mints no TIMED_THUMBNAILS bit — from_bits_truncate would drop it silently",
1227    );
1228  }
1229
1230  #[test]
1231  fn an_uncapturable_cover_still_gets_its_one_packet() {
1232    // The state the shipped `AwaitingPacket` fallback existed for: a
1233    // stream that declares cover art and parks no payload. The fallback
1234    // waited for a packet that may never come, and let timed packets —
1235    // and seeks — go first, which the face forbids. The track now gets
1236    // its one packet at open like every other attachment track: empty,
1237    // and marked as this layer's own work.
1238    //
1239    // Not reachable from a file: across MP3, M4A, FLAC and Matroska,
1240    // every ATTACHED_PIC stream libavformat produces carries the parked
1241    // packet, because `ff_add_attached_pic` sets the disposition and
1242    // fills it in the same call. A zeroed `AVPacket` is exactly what
1243    // `attached_pic` would hold if one ever did not.
1244    let empty: ffmpeg_next::ffi::AVPacket = unsafe { std::mem::zeroed() };
1245    let packet = unsafe { attached_pic_payload(&empty, 7) }
1246      .expect("an unparked cover is a degenerate track, not an unreadable file");
1247    assert!(packet.data().as_ref().is_empty());
1248    assert!(
1249      packet.extra().synthesized(),
1250      "nothing in the container handed this payload over",
1251    );
1252    assert_eq!(packet.extra().stream_index(), 7);
1253  }
1254
1255  #[test]
1256  fn a_zero_denominator_timebase_is_clamped_not_refused() {
1257    // A malformed timebase makes one track's timestamps meaningless.
1258    // It must not make the file unreadable — every other track still
1259    // demuxes, and the caller can see the 1/1 for what it is.
1260    let tb = rational_to_timebase(Rational::new(1, 0));
1261    assert_eq!(tb.den().get(), 1);
1262    assert_eq!(tb.num(), 1);
1263  }
1264
1265  #[test]
1266  fn a_declared_frame_rate_becomes_a_rate_shaped_timebase() {
1267    let ntsc = rate_to_timebase(Rational::new(30_000, 1001)).expect("declared");
1268    assert_eq!((ntsc.num(), ntsc.den().get()), (30_000, 1001));
1269    assert_eq!(
1270      rate_to_timebase(Rational::new(0, 1)),
1271      None,
1272      "0 fps is absent"
1273    );
1274    assert_eq!(
1275      rate_to_timebase(Rational::new(30, 0)),
1276      None,
1277      "no denominator"
1278    );
1279  }
1280
1281  #[test]
1282  fn the_seek_timebase_is_microseconds() {
1283    // `avformat_seek_file` with `stream_index == -1` takes AV_TIME_BASE
1284    // units; a target expressed in anything else has to arrive there.
1285    let tb = av_time_base_q();
1286    assert_eq!((tb.num(), tb.den().get()), (1, 1_000_000));
1287    let target = Timestamp::new(1_500, Timebase::new(1, NonZeroI32::new(1000).expect("ms")));
1288    assert_eq!(target.rescale_to(tb).pts(), 1_500_000);
1289  }
1290}