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