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(¶meters, 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}