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