Skip to main content

media_pp/elements/sink/muxer/
file_muxer.rs

1use std::{
2    path::Path,
3    sync::{Arc, Mutex},
4};
5
6use crate::pp_log::{PpLog, pp_error};
7use ffmpeg_next as ffmpeg;
8use thiserror::Error as ThisError;
9
10use crate::{
11    buffer::MediaBuffer,
12    contract::{InputContract, MediaKind, PortContract},
13    control::{ControlMsg, SeekRejectReason},
14    element::{Element, ElementType, Sink, element_pp_log},
15    error::Result,
16};
17
18/// Errors specific to `FileMuxer`. Converts into the crate-wide `Error` via
19/// `?` (see [`crate::error::Error`]).
20#[derive(Debug, ThisError)]
21pub enum FileMuxerError {
22    /// A stream sink received a buffer other than a packet or end-of-stream.
23    #[error("FileMuxer stream sinks only accept Packet or Eos buffers, got {0}")]
24    UnsupportedBuffer(&'static str),
25
26    /// FFmpeg rejected muxer creation, packet writing, or finalization.
27    #[error("ffmpeg error: {0}")]
28    Ffmpeg(#[from] ffmpeg::Error),
29}
30
31/// One track registered via [`FileMuxer::add_stream`], waiting for
32/// [`FileMuxer::open`] to turn it into a real [`FileMuxerStreamSink`] — its
33/// `name` becomes that sink's own [`Element::name`]/`pp_log` identity, and
34/// `input_time_base` is what every `Packet` it receives already carries
35/// `pts`/`dts` in (the same one its upstream encoder was opened with).
36struct PendingStream {
37    name: Arc<str>,
38    input_time_base: ffmpeg::Rational,
39    /// Taken from the parameters this track was registered with, so its
40    /// sink can refuse the other medium's packets at wiring time.
41    kind: Option<MediaKind>,
42}
43
44/// Builds one container file with one or more tracks, then opens it into
45/// one [`Sink`] per track.
46///
47/// Which container is the path's own: `format::output` asks FFmpeg to guess
48/// a muxer from the file name, so `.mp4` gets MP4 and `.mkv` gets Matroska
49/// out of the same type. Nothing here is MP4-specific.
50///
51/// Two-phase on purpose: a container's header has to describe
52/// every stream's codec parameters up front — `avformat_write_header`
53/// can't run until every [`FileMuxer::add_stream`] this file will ever hold
54/// has already happened — so there's no way to make this a single
55/// long-lived `Sink` that tracks attach to one at a time as their encoders
56/// come online (contrast [`crate::elements::AudioMixer`], whose inputs
57/// *can* attach at any time — it has no "known shape before the first
58/// byte" constraint the way a container header does).
59///
60/// ```no_run
61/// # use media_pp::ffmpeg;
62/// # use media_pp::elements::{
63/// #     AudioCodec, FileMuxer, SwAudioEncoder, SwAudioEncoderOptions, SwEncoder,
64/// #     SwEncoderOptions, VideoCodec,
65/// # };
66/// # fn main() -> media_pp::Result<()> {
67/// # let video_time_base = ffmpeg::Rational(1, 30);
68/// # let audio_time_base = ffmpeg::Rational(1, 48_000);
69/// # let video_encoder = SwEncoder::new("video", SwEncoderOptions {
70/// #     codec: VideoCodec::H264,
71/// #     width: 640,
72/// #     height: 360,
73/// #     time_base: video_time_base,
74/// #     frame_rate: ffmpeg::Rational(30, 1),
75/// #     bit_rate: 2_000_000,
76/// #     gop_size: 30,
77/// #     max_b_frames: None,
78/// # })?;
79/// # let audio_encoder = SwAudioEncoder::new("audio", SwAudioEncoderOptions {
80/// #     codec: AudioCodec::Aac,
81/// #     sample_rate: 48_000,
82/// #     channels: 2,
83/// #     time_base: audio_time_base,
84/// #     bit_rate: 128_000,
85/// # })?;
86/// let mut muxer = FileMuxer::create("out.mp4")?;
87/// muxer.add_stream("video", video_encoder.parameters(), video_time_base)?;
88/// muxer.add_stream("audio", audio_encoder.parameters(), audio_time_base)?;
89/// let mut sinks = muxer.open()?; // writes the header
90/// let audio_sink = sinks.pop().unwrap();
91/// let video_sink = sinks.pop().unwrap();
92/// # Ok(())
93/// # }
94/// ```
95pub struct FileMuxer {
96    output: ffmpeg::format::context::Output,
97    streams: Vec<PendingStream>,
98}
99
100impl FileMuxer {
101    /// Allocates the output file. No header is written yet — nothing is on
102    /// disk in a readable shape until [`FileMuxer::open`] runs.
103    pub fn create(path: impl AsRef<Path>) -> Result<Self> {
104        let output = ffmpeg::format::output(&path).map_err(FileMuxerError::from)?;
105        Ok(Self {
106            output,
107            streams: Vec::new(),
108        })
109    }
110
111    /// Registers one more track this file will hold. `parameters`/
112    /// `time_base` describe it — typically
113    /// [`crate::elements::SwEncoder::parameters`]/the same `time_base`
114    /// passed to its own `SwEncoderOptions` (or the
115    /// [`crate::elements::SwAudioEncoder`] equivalents). `name` becomes
116    /// this track's own [`Element::name`]/`pp_log` identity once
117    /// [`FileMuxer::open`] turns it into a `Sink` — pick something that
118    /// tells multiple tracks apart in logs/[`crate::bus::BusEvent`]s,
119    /// e.g. `"video"`/`"audio"`.
120    ///
121    /// Add streams in the same order the caller will treat
122    /// [`FileMuxer::open`]'s returned `Vec` — index 0 is whichever stream
123    /// was added first, and so on.
124    pub fn add_stream(
125        &mut self,
126        name: impl Into<String>,
127        parameters: ffmpeg::codec::Parameters,
128        time_base: ffmpeg::Rational,
129    ) -> Result<()> {
130        let mut stream = self
131            .output
132            .add_stream(parameters.id())
133            .map_err(FileMuxerError::from)?;
134        let kind = MediaKind::packet_for(parameters.medium());
135        stream.set_time_base(time_base);
136        stream.set_parameters(parameters);
137        self.streams.push(PendingStream {
138            name: name.into().into(),
139            input_time_base: time_base,
140            kind,
141        });
142        Ok(())
143    }
144
145    /// Writes the container header — every [`FileMuxer::add_stream`] call
146    /// this file will ever get must already have happened — and returns
147    /// one [`Sink`] per track, in the order [`FileMuxer::add_stream`] added
148    /// them.
149    ///
150    /// All returned `Sink`s write into the same underlying file behind a
151    /// shared lock: packets from independently-threaded branches (e.g. a
152    /// video encode chain and an audio encode chain, each on their own
153    /// [`crate::queue::Queue`]) can arrive concurrently, and neither
154    /// `av_interleaved_write_frame` nor `av_write_trailer` is safe to call
155    /// from multiple threads against the same file at once. They also
156    /// share one trailer: it's written once every track has reported
157    /// itself done — via `Eos` *or* [`ControlMsg::Stop`], either meaning
158    /// "this track is finished" rather than "abandon the whole file" —
159    /// not on whichever track finishes first, which would silently
160    /// truncate whatever the other track(s) still had left to write. A
161    /// single-track file (e.g. `screen_record_software`/`audio_record`) degenerates
162    /// to finalizing on that one track's own `Eos`/`Stop`, same as before
163    /// this type supported more than one.
164    ///
165    /// A caller driving multiple tracks from independent
166    /// [`crate::pipeline::Pipeline`]s (today's architecture: one
167    /// `SourceElement` per pipeline, so a live video capture and a live
168    /// audio capture are necessarily two separate pipelines) is
169    /// responsible for stopping all of them — the file's trailer only
170    /// gets written once every track has actually reported done, so
171    /// stopping only one pipeline while another keeps running leaves the
172    /// file un-finalized (and unplayable) until the rest catch up too.
173    pub fn open(mut self) -> Result<Vec<Box<dyn Sink>>> {
174        self.output.write_header().map_err(FileMuxerError::from)?;
175        let total = self.streams.len();
176        let shared = Arc::new(FileMuxerShared {
177            state: Mutex::new(MuxerState {
178                output: self.output,
179                done: 0,
180                finished: false,
181            }),
182            total,
183        });
184        Ok(self
185            .streams
186            .into_iter()
187            .enumerate()
188            .map(|(index, stream)| -> Box<dyn Sink> {
189                Box::new(FileMuxerStreamSink {
190                    pp_log: element_pp_log(ElementType::FileMuxer, &stream.name, None),
191                    name: stream.name,
192                    shared: shared.clone(),
193                    stream_index: index,
194                    input_time_base: stream.input_time_base,
195                    kind: stream.kind,
196                    done: false,
197                })
198            })
199            .collect())
200    }
201}
202
203struct MuxerState {
204    output: ffmpeg::format::context::Output,
205    /// How many tracks have reported themselves finished (`Eos` or
206    /// `Stop`) — the trailer is written once this reaches
207    /// [`FileMuxerShared::total`], not on the first one (see
208    /// [`FileMuxer::open`]'s own docs for why).
209    done: usize,
210    /// Set once the trailer has been written. Each
211    /// [`FileMuxerStreamSink`]'s own `done` flag already prevents
212    /// double-counting *that* track's contribution to `done`; this
213    /// additionally guards [`FileMuxerShared::write_packet`] against
214    /// writing into a file whose trailer has already closed it.
215    finished: bool,
216}
217
218/// Shared between every [`FileMuxerStreamSink`] [`FileMuxer::open`] hands
219/// out for the same file — one lock around the whole
220/// [`ffmpeg::format::context::Output`] so concurrent tracks never
221/// interleave two writes against it (see [`FileMuxer::open`]'s own docs).
222struct FileMuxerShared {
223    state: Mutex<MuxerState>,
224    total: usize,
225}
226
227impl FileMuxerShared {
228    fn write_packet(
229        &self,
230        stream_index: usize,
231        input_time_base: ffmpeg::Rational,
232        packet: &ffmpeg::Packet,
233    ) -> Result<()> {
234        let mut state = self.state.lock().unwrap();
235        if state.finished {
236            return Ok(());
237        }
238        // Cloned, not mutated in place — `Arc<Packet>` may be shared with
239        // another branch (e.g. a `PacketCounter` off the same `Tee`),
240        // which must not see this stream's `set_stream`/rescaled
241        // timestamps.
242        let mut packet = packet.clone();
243        let output_time_base = state
244            .output
245            .stream(stream_index)
246            .expect("stream was added in FileMuxer::add_stream")
247            .time_base();
248        packet.rescale_ts(input_time_base, output_time_base);
249        packet.set_stream(stream_index);
250        packet.set_position(-1);
251        packet
252            .write_interleaved(&mut state.output)
253            .map_err(FileMuxerError::from)?;
254        Ok(())
255    }
256
257    /// One track reporting itself done (`Eos` or `Stop`) — writes the
258    /// trailer exactly once, only once every track has called this.
259    fn finish_track(&self) -> Result<()> {
260        let mut state = self.state.lock().unwrap();
261        state.done += 1;
262        if state.finished || state.done < self.total {
263            return Ok(());
264        }
265        state.finished = true;
266        state.output.write_trailer().map_err(FileMuxerError::from)?;
267        Ok(())
268    }
269}
270
271/// One track's own [`Sink`] — a lightweight handle sharing a
272/// `FileMuxerShared` with every other track [`FileMuxer::open`] returned
273/// alongside it. See [`FileMuxer::open`]'s own docs for the
274/// finalize-once-every-track-is-done contract this relies on.
275pub struct FileMuxerStreamSink {
276    pp_log: PpLog,
277    name: Arc<str>,
278    shared: Arc<FileMuxerShared>,
279    stream_index: usize,
280    input_time_base: ffmpeg::Rational,
281    /// The medium this track was registered for; `None` for one this
282    /// crate does not model, which then declares nothing.
283    kind: Option<MediaKind>,
284    /// Set once this sink has contributed to
285    /// [`FileMuxerShared::finish_track`] — guards against double-counting
286    /// if both a natural `Eos` and a later `Stop` arrive for the same
287    /// track.
288    done: bool,
289}
290
291impl FileMuxerStreamSink {
292    fn finish(&mut self) -> Result<()> {
293        if self.done {
294            return Ok(());
295        }
296        self.done = true;
297        self.shared
298            .finish_track()
299            .inspect_err(|error| pp_error!(self, "write_trailer failed: {error}"))
300    }
301}
302
303impl Element for FileMuxerStreamSink {
304    fn name(&self) -> Arc<str> {
305        self.name.clone()
306    }
307
308    fn element_type(&self) -> ElementType {
309        ElementType::FileMuxer
310    }
311
312    fn pp_log(&self) -> &PpLog {
313        &self.pp_log
314    }
315
316    fn pp_log_mut(&mut self) -> &mut PpLog {
317        &mut self.pp_log
318    }
319}
320
321impl Sink for FileMuxerStreamSink {
322    /// A muxer interleaves already-encoded data; it has no encoder of
323    /// its own, so a decoded frame has no route through it. The medium is
324    /// this track's own, so a video encoder wired into the audio track is
325    /// refused rather than writing a file no player can make sense of.
326    fn input_contract(&self) -> InputContract {
327        match self.kind {
328            Some(kind) => InputContract::Fixed(PortContract::packet(kind)),
329            None => InputContract::Unknown,
330        }
331    }
332
333    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
334        match buf {
335            MediaBuffer::Packet(packet) => self
336                .shared
337                .write_packet(self.stream_index, self.input_time_base, &packet)
338                .inspect_err(|error| pp_error!(self, "write_interleaved failed: {error}")),
339            MediaBuffer::Eos => self.finish(),
340            other => Err(FileMuxerError::UnsupportedBuffer(other.kind()).into()),
341        }
342    }
343
344    fn control(&mut self, msg: ControlMsg) -> Result<()> {
345        if let ControlMsg::CheckSeek(context) = &msg {
346            context.reject(
347                self.element_type(),
348                self.name(),
349                SeekRejectReason::ElementNotSeekable,
350            );
351        }
352        // Terminal, nothing to forward. `Stop` still contributes to this
353        // track's own "done" count — see `FileMuxer::open`'s own docs on
354        // why the trailer waits for every track rather than finalizing on
355        // whichever stops first.
356        if msg == ControlMsg::Stop {
357            self.finish()?;
358        }
359        Ok(())
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::control::{SeekCheckContext, SeekRejectReason};
367    use crate::element::Source;
368    use crate::elements::{AudioCodec, SwAudioEncoder, SwAudioEncoderOptions};
369
370    fn open_aac_encoder(sample_rate: u32, channels: u16) -> SwAudioEncoder {
371        SwAudioEncoder::new(
372            "encoder",
373            SwAudioEncoderOptions {
374                codec: AudioCodec::Aac,
375                sample_rate,
376                channels,
377                time_base: ffmpeg::Rational::new(1, sample_rate as i32),
378                bit_rate: 64_000,
379            },
380        )
381        .expect("aac encoder must be available")
382    }
383
384    /// `None` where the build has no `libopus`, so a stripped FFmpeg skips
385    /// rather than failing on something it was never going to have.
386    fn open_opus_encoder(sample_rate: u32, channels: u16) -> Option<SwAudioEncoder> {
387        SwAudioEncoder::new(
388            "encoder",
389            SwAudioEncoderOptions {
390                codec: AudioCodec::Opus,
391                sample_rate,
392                channels,
393                time_base: ffmpeg::Rational::new(1, sample_rate as i32),
394                bit_rate: 64_000,
395            },
396        )
397        .ok()
398    }
399
400    fn silent_frame(
401        sample_rate: u32,
402        channels: u16,
403        samples: usize,
404        pts: i64,
405    ) -> ffmpeg::frame::Audio {
406        let mut frame = ffmpeg::frame::Audio::new(
407            ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
408            samples,
409            ffmpeg::ChannelLayout::default(channels as i32),
410        );
411        frame.set_rate(sample_rate);
412        frame.set_pts(Some(pts));
413        // `frame::Audio::new` doesn't zero its buffer — leaving it
414        // uninitialized risks the encoder reading garbage bytes as NaN/Inf
415        // floats (`avcodec_send_frame` then rejects the frame outright).
416        frame.data_mut(0).fill(0);
417        frame
418    }
419
420    #[test]
421    fn seek_check_rejects_a_muxer_track() {
422        let encoder = open_aac_encoder(48000, 1);
423        let path = std::env::temp_dir().join(format!(
424            "file_muxer_seek_check_test_{}.mp4",
425            std::process::id()
426        ));
427        let mut muxer = FileMuxer::create(&path).expect("create muxer");
428        muxer
429            .add_stream(
430                "audio",
431                encoder.parameters(),
432                ffmpeg::Rational::new(1, 48000),
433            )
434            .expect("add stream");
435        let mut sink = muxer.open().expect("open muxer").pop().unwrap();
436        let context = Arc::new(SeekCheckContext::new());
437
438        sink.control(ControlMsg::CheckSeek(Arc::clone(&context)))
439            .expect("check control");
440
441        let error = context.result().expect_err("muxer must reject seek");
442        assert_eq!(error.rejections().len(), 1);
443        assert_eq!(
444            error.rejections()[0].reason,
445            SeekRejectReason::ElementNotSeekable
446        );
447        sink.control(ControlMsg::Stop).expect("finalize muxer");
448        std::fs::remove_file(path).ok();
449    }
450
451    /// One track, driven end to end (encode -> mux -> write_trailer on
452    /// `Eos`), still produces a real, playable file — the single-track
453    /// case `FileMuxer` degenerates to.
454    #[test]
455    fn single_track_still_produces_a_playable_file() {
456        let mut encoder = open_aac_encoder(48000, 1);
457
458        let dir = std::env::temp_dir();
459        let path = dir.join(format!("file_muxer_single_test_{}.mp4", std::process::id()));
460
461        let mut muxer = FileMuxer::create(&path).expect("the muxer must open");
462        muxer
463            .add_stream(
464                "audio",
465                encoder.parameters(),
466                ffmpeg::Rational::new(1, 48000),
467            )
468            .expect("add_stream must succeed");
469        let mut sinks = muxer.open().expect("open must write the header");
470        assert_eq!(sinks.len(), 1);
471        encoder.src_pads()[0].link(sinks.pop().unwrap());
472
473        for tick in 0..20i64 {
474            encoder
475                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
476                    48000,
477                    1,
478                    960,
479                    tick * 960,
480                ))))
481                .expect("consume must succeed");
482        }
483        encoder
484            .consume(MediaBuffer::Eos)
485            .expect("eos must flush cleanly");
486        drop(encoder);
487
488        let input = ffmpeg::format::input(&path).expect("muxed file must be readable back");
489        assert_eq!(input.streams().count(), 1);
490        std::fs::remove_file(&path).ok();
491    }
492
493    /// Regression test against a leaked file handle: dropping every track
494    /// `Sink` without ever sending `Eos`/`Stop` (simulating a `Pipeline`
495    /// just getting dropped mid-recording, e.g. the process is tearing
496    /// down) must still release the underlying file — no stray clone of
497    /// the shared `Arc` (or the `ffmpeg::format::context::Output` it
498    /// guards) left holding it open. Windows won't let an open file be
499    /// deleted, so a successful `remove_file` here is direct proof
500    /// nothing lingered; on a build where that isn't already guaranteed
501    /// by construction, this would instead hang or fail with a sharing
502    /// violation.
503    #[test]
504    fn dropping_every_sink_without_eos_or_stop_still_releases_the_file() {
505        let encoder = open_aac_encoder(48000, 1);
506
507        let dir = std::env::temp_dir();
508        let path = dir.join(format!("file_muxer_drop_test_{}.mp4", std::process::id()));
509
510        let mut muxer = FileMuxer::create(&path).expect("the muxer must open");
511        muxer
512            .add_stream(
513                "audio",
514                encoder.parameters(),
515                ffmpeg::Rational::new(1, 48000),
516            )
517            .expect("add_stream must succeed");
518        let sinks = muxer.open().expect("open must write the header");
519
520        // No `Eos`/`Stop`, no trailer — just drop everything, on purpose.
521        drop(sinks);
522        drop(encoder);
523
524        std::fs::remove_file(&path)
525            .expect("file handle must be released once every sink is dropped");
526    }
527
528    /// Two independent tracks (standing in for a real video+audio pair —
529    /// `FileMuxer` treats every stream as an opaque `codec::Parameters`, so
530    /// two AAC tracks at different sample rates exercise the same
531    /// stream-index/trailer-timing machinery a real video+audio pair
532    /// would) muxed into one file. Track `a` reaches `Eos` well before
533    /// track `b` does — proving the trailer isn't written until *both*
534    /// report done, not on whichever finishes first (which would
535    /// silently truncate whichever track was still running).
536    #[test]
537    fn muxes_two_independent_tracks_without_finalizing_early() {
538        let mut encoder_a = open_aac_encoder(48000, 2);
539        let mut encoder_b = open_aac_encoder(44100, 1);
540
541        let dir = std::env::temp_dir();
542        let path = dir.join(format!("file_muxer_multi_test_{}.mp4", std::process::id()));
543
544        let mut muxer = FileMuxer::create(&path).expect("the muxer must open");
545        muxer
546            .add_stream("a", encoder_a.parameters(), ffmpeg::Rational::new(1, 48000))
547            .expect("add_stream a");
548        muxer
549            .add_stream("b", encoder_b.parameters(), ffmpeg::Rational::new(1, 44100))
550            .expect("add_stream b");
551        let mut sinks = muxer.open().expect("open must write the header");
552        assert_eq!(sinks.len(), 2);
553        let sink_b = sinks.pop().unwrap();
554        let sink_a = sinks.pop().unwrap();
555        encoder_a.src_pads()[0].link(sink_a);
556        encoder_b.src_pads()[0].link(sink_b);
557
558        for tick in 0..10i64 {
559            encoder_a
560                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
561                    48000,
562                    2,
563                    960,
564                    tick * 960,
565                ))))
566                .expect("consume must succeed");
567        }
568        // Track `a` finishes here — well before track `b` has written
569        // anything at all.
570        encoder_a
571            .consume(MediaBuffer::Eos)
572            .expect("eos must flush cleanly");
573
574        for tick in 0..10i64 {
575            encoder_b
576                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
577                    44100,
578                    1,
579                    882,
580                    tick * 882,
581                ))))
582                .expect("consume must succeed");
583        }
584        encoder_b
585            .consume(MediaBuffer::Eos)
586            .expect("eos must flush cleanly");
587
588        drop(encoder_a);
589        drop(encoder_b);
590
591        let mut input = ffmpeg::format::input(&path).expect("muxed file must be readable back");
592        assert_eq!(input.streams().count(), 2, "expected two tracks");
593
594        let mut counts = [0usize; 2];
595        let mut packet = ffmpeg::Packet::empty();
596        while packet.read(&mut input).is_ok() {
597            counts[packet.stream()] += 1;
598            packet = ffmpeg::Packet::empty();
599        }
600        assert!(counts[0] > 0, "track a has no packets: {counts:?}");
601        assert!(
602            counts[1] > 0,
603            "track b has no packets: {counts:?} — trailer was written before track b finished"
604        );
605        std::fs::remove_file(&path).ok();
606    }
607
608    /// The container comes from the path's own extension, not from this
609    /// type's name.
610    ///
611    /// `format::output` asks FFmpeg to guess a muxer from the filename, so
612    /// this writes Matroska for a `.mkv` as readily as MP4 for a `.mp4` — the
613    /// name says what it was written for, not what it is limited to. Worth a
614    /// test rather than a comment: it is the difference between "we would
615    /// have to add a muxer" and "name the file .mkv", and nothing else here
616    /// would have caught the day it stopped being true.
617    ///
618    /// Written with real packets rather than a header and a trailer alone: a
619    /// Matroska file with no cluster in it is not something FFmpeg will read
620    /// back, so an empty one would fail here for a reason that has nothing to
621    /// do with which muxer was picked.
622    #[test]
623    fn the_container_follows_the_path_extension() {
624        let mut encoder = open_aac_encoder(48000, 1);
625
626        let dir = std::env::temp_dir();
627        let path = dir.join(format!("muxer_container_test_{}.mkv", std::process::id()));
628        let _ = std::fs::remove_file(&path);
629
630        let mut muxer = FileMuxer::create(&path).expect("the muxer must open a .mkv path");
631        muxer
632            .add_stream(
633                "audio",
634                encoder.parameters(),
635                ffmpeg::Rational::new(1, 48000),
636            )
637            .expect("add_stream must succeed");
638        let mut sinks = muxer.open().expect("open must write the header");
639        encoder.src_pads()[0].link(sinks.pop().expect("exactly one stream was added"));
640
641        for tick in 0..20i64 {
642            encoder
643                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
644                    48000,
645                    1,
646                    960,
647                    tick * 960,
648                ))))
649                .expect("consume must succeed");
650        }
651        encoder
652            .consume(MediaBuffer::Eos)
653            .expect("eos must flush cleanly");
654        drop(encoder);
655
656        let input = ffmpeg::format::input(&path).expect("the file must be readable");
657        let format = input.format();
658        assert!(
659            format.name().contains("matroska"),
660            "a .mkv path should have produced Matroska, got {:?}",
661            format.name()
662        );
663        assert_eq!(input.streams().count(), 1);
664        std::fs::remove_file(&path).ok();
665    }
666
667    /// A video track's codec extradata has to be in the container's header
668    /// for Matroska, which writes `CodecPrivate` up front. MP4 does not need
669    /// it there — `avcC` is written in the trailer, and the mov muxer will
670    /// take the Annex-B SPS/PPS out of the packets themselves — so a
671    /// video encoder opened without `AV_CODEC_FLAG_GLOBAL_HEADER` produces
672    /// a working `.mp4` and an `avformat_write_header` that fails with
673    /// `INVALIDDATA` for `.mkv`.
674    ///
675    /// Which is the whole reason this test exists at the muxer rather than at
676    /// the encoder: the flag is the encoder's, and nothing but a non-MP4
677    /// container ever notices it is missing.
678    #[test]
679    fn a_video_track_carries_its_extradata_into_a_matroska_header() {
680        use crate::elements::{SwEncoder, SwEncoderOptions, VideoCodec};
681
682        let time_base = ffmpeg::Rational::new(1, 30);
683        let options = |codec| SwEncoderOptions {
684            codec,
685            width: 320,
686            height: 180,
687            time_base,
688            frame_rate: ffmpeg::Rational::new(30, 1),
689            bit_rate: 400_000,
690            gop_size: 30,
691            max_b_frames: None,
692        };
693        // Either software H.264 will do; a build carrying neither is one this
694        // cannot be asked about, so it skips the way a hardware test does.
695        let Some(encoder) = [VideoCodec::OpenH264, VideoCodec::H264]
696            .into_iter()
697            .find_map(|codec| SwEncoder::new("video", options(codec)).ok())
698        else {
699            eprintln!("skipping: this FFmpeg build has no libopenh264 or libx264");
700            return;
701        };
702
703        let dir = std::env::temp_dir();
704        let path = dir.join(format!("file_muxer_mkv_video_{}.mkv", std::process::id()));
705        let _ = std::fs::remove_file(&path);
706
707        let mut muxer = FileMuxer::create(&path).expect("the muxer must open");
708        muxer
709            .add_stream("video", encoder.parameters(), time_base)
710            .expect("add_stream must succeed");
711        // `open` is the whole assertion: it is `avformat_write_header`, and
712        // that is what refuses a video track it has no `CodecPrivate` for.
713        // Nothing is read back afterwards because a Matroska file with no
714        // cluster in it is not readable at all — proving that would mean
715        // encoding frames, which is a different element's contract.
716        let sinks = muxer
717            .open()
718            .expect("Matroska must accept a video track's header");
719        drop(sinks);
720        std::fs::remove_file(&path).ok();
721    }
722
723    /// Opus in Matroska reads back as Opus.
724    ///
725    /// Not a guard on the global-header flag, which is what it was written to
726    /// check: FFmpeg's `libopus` wrapper writes `OpusHead` into `extradata`
727    /// whether or not the flag is set, so this passes with the flag reverted.
728    /// Unlike the video track above, Opus was never at risk here.
729    ///
730    /// Kept because the pairing is worth an actual check rather than an
731    /// assumption from the AAC one: `libopus` is an external library, the
732    /// only audio codec here that a build can be missing, and Matroska is the
733    /// container that writes a `CodecPrivate` for it up front.
734    ///
735    /// Skips where the build has no `libopus`, which is a real configuration
736    /// — which is also why the application probes for it rather than
737    /// offering it blind.
738    #[test]
739    fn opus_carries_its_own_header_into_matroska() {
740        // 48 kHz because libopus takes nothing else.
741        let Some(mut encoder) = open_opus_encoder(48_000, 2) else {
742            eprintln!("skipping: this FFmpeg build has no libopus");
743            return;
744        };
745
746        let dir = std::env::temp_dir();
747        let path = dir.join(format!("file_muxer_opus_mkv_{}.mkv", std::process::id()));
748        let _ = std::fs::remove_file(&path);
749
750        let mut muxer = FileMuxer::create(&path).expect("the muxer must open");
751        muxer
752            .add_stream(
753                "audio",
754                encoder.parameters(),
755                ffmpeg::Rational::new(1, 48_000),
756            )
757            .expect("add_stream must succeed");
758        let mut sinks = muxer
759            .open()
760            .expect("Matroska must accept an Opus track's header");
761        encoder.src_pads()[0].link(sinks.pop().expect("exactly one stream was added"));
762
763        for tick in 0..20i64 {
764            encoder
765                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
766                    48_000,
767                    2,
768                    960,
769                    tick * 960,
770                ))))
771                .expect("consume must succeed");
772        }
773        encoder
774            .consume(MediaBuffer::Eos)
775            .expect("eos must flush cleanly");
776        drop(encoder);
777
778        let input = ffmpeg::format::input(&path).expect("the file must be readable");
779        assert!(
780            input.format().name().contains("matroska"),
781            "got {:?}",
782            input.format().name()
783        );
784        let stream = input
785            .streams()
786            .best(ffmpeg::media::Type::Audio)
787            .expect("the file must hold the audio track it was given");
788        assert_eq!(
789            stream.parameters().id(),
790            ffmpeg::codec::Id::OPUS,
791            "the track must read back as Opus, not as whatever the header defaulted to"
792        );
793        std::fs::remove_file(&path).ok();
794    }
795
796    /// Packets whose `dts` and `pts` differ, remuxed, still differ.
797    ///
798    /// A B-frame is coded from frames on both sides of it, so a container
799    /// holding any carries its packets in decode order and their timestamps
800    /// stop being the same number. A muxer that wrote `pts` into both — or
801    /// that reordered on the way through — produces a file whose decode order
802    /// no longer matches its timestamps, and every player of it either stalls
803    /// or shows the frames in the wrong order.
804    ///
805    /// The ordinary fixture cannot show this: `libopenh264` emits no
806    /// B-frames. This one is MPEG-4 Part 2 for that reason alone — see
807    /// `test_support::synthesize_reordered`.
808    #[test]
809    fn a_reordered_stream_keeps_its_decode_order_through_the_muxer() {
810        use crate::elements::FileDemuxer;
811        use crate::pipeline::Pipeline;
812
813        let fixture = crate::test_support::synthesize_reordered("reordered", 3.0);
814        let source = fixture.path.to_string_lossy().into_owned();
815
816        // The fixture has to actually be reordered, or the rest of this
817        // asserts nothing. Measured rather than assumed: whether an encoder
818        // honours `max_b_frames` is the encoder's business, not this crate's.
819        let arriving = timestamps(&source);
820        assert!(
821            arriving.iter().any(|(dts, pts)| dts != pts),
822            "the fixture carries no reordering to preserve: {:?}",
823            &arriving[..arriving.len().min(8)]
824        );
825
826        let path = std::env::temp_dir().join("media-pp-reordered-remux.mp4");
827        let _ = std::fs::remove_file(&path);
828        let (demuxer, streams) = FileDemuxer::open("demuxer", &source).expect("open the fixture");
829        let video = streams
830            .iter()
831            .find(|stream| stream.kind == ffmpeg::media::Type::Video)
832            .expect("the fixture has video")
833            .index;
834        let parameters = demuxer.stream_parameters(video).expect("video parameters");
835        let time_base = demuxer.stream_time_base(video).expect("video time base");
836
837        let mut muxer = FileMuxer::create(&path).expect("create the remux");
838        muxer
839            .add_stream("video", parameters, time_base)
840            .expect("add the video stream");
841        let sink = muxer
842            .open()
843            .expect("write the header")
844            .pop()
845            .expect("one stream was added");
846
847        let pipeline = Pipeline::new("remux", demuxer, move |source, context| {
848            let branch = context.branch().to(sink)?;
849            context.attach(source, video, branch)?;
850            Ok(())
851        })
852        .expect("wire the remux");
853        pipeline.run().expect("run the remux");
854        for event in pipeline.bus().iter() {
855            if matches!(event, crate::bus::BusEvent::Eos { .. }) {
856                break;
857            }
858        }
859        pipeline.stop();
860
861        let written = timestamps(&path.to_string_lossy());
862        assert_eq!(
863            written.len(),
864            arriving.len(),
865            "every packet that came in has to come out"
866        );
867        assert!(
868            written.iter().any(|(dts, pts)| dts != pts),
869            "the reordering did not survive being written"
870        );
871        assert!(
872            written.windows(2).all(|pair| pair[0].0 <= pair[1].0),
873            "decode order is what `dts` is for and it has to keep rising: {:?}",
874            &written[..written.len().min(8)]
875        );
876        assert_eq!(
877            written, arriving,
878            "a remux copies packets; it does not restamp them"
879        );
880        std::fs::remove_file(&path).ok();
881    }
882
883    /// Every video packet's `(dts, pts)`, in the order the container holds
884    /// them.
885    fn timestamps(path: &str) -> Vec<(i64, i64)> {
886        let mut input = ffmpeg::format::input(path).expect("the file opens");
887        let video = input
888            .streams()
889            .find(|stream| stream.parameters().medium() == ffmpeg::media::Type::Video)
890            .expect("it has video")
891            .index();
892        input
893            .packets()
894            .filter(|(stream, _)| stream.index() == video)
895            .filter_map(|(_, packet)| Some((packet.dts()?, packet.pts()?)))
896            .collect()
897    }
898
899    /// What a written file's timeline looks like from outside it.
900    #[derive(Debug, Clone, Copy)]
901    struct Shape {
902        video_frames: usize,
903        video_seconds: f64,
904        audio_seconds: f64,
905        video_start: f64,
906        audio_start: f64,
907    }
908
909    impl Shape {
910        fn video_fps(self) -> f64 {
911            self.video_frames as f64 / self.video_seconds
912        }
913    }
914
915    /// Reads one back, the way anything that plays it would.
916    fn shape_of(path: &str) -> Shape {
917        let input = ffmpeg::format::input(path).expect("the file opens");
918        let stream = |medium| {
919            input
920                .streams()
921                .find(|stream| stream.parameters().medium() == medium)
922                .unwrap_or_else(|| panic!("{path} carries no {medium:?}"))
923        };
924        let seconds = |stream: &ffmpeg::format::stream::Stream<'_>| {
925            stream.duration() as f64 * f64::from(stream.time_base())
926        };
927        let start = |stream: &ffmpeg::format::stream::Stream<'_>| {
928            stream.start_time() as f64 * f64::from(stream.time_base())
929        };
930        let video = stream(ffmpeg::media::Type::Video);
931        let audio = stream(ffmpeg::media::Type::Audio);
932        Shape {
933            video_frames: video.frames() as usize,
934            video_seconds: seconds(&video),
935            audio_seconds: seconds(&audio),
936            video_start: start(&video),
937            audio_start: start(&audio),
938        }
939    }
940
941    /// A file decoded, re-encoded and written back keeps the shape it had.
942    ///
943    /// The path a recording really takes, and the one every defect found in
944    /// this crate's timeline handling has lived on. What makes it worth a
945    /// test of its own is that each of those was invisible from inside: every
946    /// element returned `Ok`, every buffer went where it was sent, and the
947    /// file that came out was the wrong length, or its sound no longer sat
948    /// against its picture. None of that can be seen without reading the
949    /// result back.
950    ///
951    /// The tolerances are deliberately loose. What this is watching for is a
952    /// stream that lost or gained *time* — an encoder dropping samples, a
953    /// muxer restamping a track, a time base that means something different
954    /// at each end — not the frame or two an encoder is entitled to hold.
955    #[test]
956    fn a_transcoded_file_keeps_the_shape_of_what_went_in() {
957        use crate::elements::{FileDemuxer, SwDecoder, SwEncoder, SwEncoderOptions, VideoCodec};
958        use crate::pipeline::Pipeline;
959
960        let fixture = crate::test_support::synthesize("transcode-shape", 4.0, 44_100);
961        let source_path = fixture.path.to_string_lossy().into_owned();
962        let arriving = shape_of(&source_path);
963
964        let path = std::env::temp_dir().join("media-pp-transcode-shape.mp4");
965        let _ = std::fs::remove_file(&path);
966
967        let (demuxer, streams) = FileDemuxer::open("demuxer", &source_path).expect("open");
968        let index = |medium| {
969            streams
970                .iter()
971                .find(|stream| stream.kind == medium)
972                .unwrap_or_else(|| panic!("the fixture carries no {medium:?}"))
973                .index
974        };
975        let video = index(ffmpeg::media::Type::Video);
976        let audio = index(ffmpeg::media::Type::Audio);
977        let video_decoder = SwDecoder::new(
978            "video-decoder",
979            demuxer.stream_parameters(video).expect("video parameters"),
980        )
981        .expect("open the video decoder");
982        let audio_decoder = SwDecoder::new(
983            "audio-decoder",
984            demuxer.stream_parameters(audio).expect("audio parameters"),
985        )
986        .expect("open the audio decoder");
987
988        // Re-encoded at the same rates it arrived with, so a difference in
989        // the result is this crate's doing rather than a conversion's.
990        let width = 320;
991        let height = 240;
992        // The container's own unit, not `1/frame_rate`: what the decoder
993        // hands over is stamped in whatever the container counts in, and an
994        // encoder told a different unit writes those same numbers meaning
995        // something else. Which is not hypothetical — the first version of
996        // this test said `1/30` and produced 121 frames across 34 minutes.
997        let video_time_base = demuxer.stream_time_base(video).expect("video time base");
998        let video_encoder = SwEncoder::new(
999            "video-encoder",
1000            SwEncoderOptions {
1001                codec: VideoCodec::OpenH264,
1002                width,
1003                height,
1004                time_base: video_time_base,
1005                frame_rate: ffmpeg::Rational::new(30, 1),
1006                bit_rate: 800_000,
1007                gop_size: 30,
1008                max_b_frames: None,
1009            },
1010        )
1011        .expect("open the video encoder");
1012        // Written at 48kHz from a 44.1kHz source, which is what a recording
1013        // really does — a file's own rate is rarely the one everything else
1014        // in the graph runs at. It also puts a rate conversion inside what
1015        // this measures, and an audio path losing a fraction of every frame
1016        // to one is a defect this crate has actually had.
1017        let audio_encoder = open_aac_encoder(48_000, 2);
1018
1019        let mut muxer = FileMuxer::create(&path).expect("create the output");
1020        muxer
1021            .add_stream("video", video_encoder.parameters(), video_time_base)
1022            .expect("add the video stream");
1023        muxer
1024            .add_stream(
1025                "audio",
1026                audio_encoder.parameters(),
1027                audio_encoder.time_base(),
1028            )
1029            .expect("add the audio stream");
1030        let mut sinks = muxer.open().expect("write the header");
1031        let audio_sink = sinks.pop().expect("audio was added second");
1032        let video_sink = sinks.pop().expect("video was added first");
1033
1034        let scaler = crate::elements::SwScaler::new(
1035            "to-yuv",
1036            ffmpeg::format::Pixel::YUV420P,
1037            width,
1038            height,
1039            ffmpeg::software::scaling::Flags::BILINEAR,
1040        );
1041
1042        let pipeline = Pipeline::new("transcode", demuxer, move |source, context| {
1043            let picture = context
1044                .branch()
1045                .pipe(video_decoder)
1046                .pipe(scaler)
1047                .pipe(video_encoder)
1048                .to(video_sink)?;
1049            context.attach(source, video, picture)?;
1050            let sound = context
1051                .branch()
1052                .pipe(audio_decoder)
1053                .pipe(audio_encoder)
1054                .to(audio_sink)?;
1055            context.attach(source, audio, sound)?;
1056            Ok(())
1057        })
1058        .expect("wire the transcode");
1059        pipeline.run().expect("run the transcode");
1060        for event in pipeline.bus().iter() {
1061            if matches!(event, crate::bus::BusEvent::Eos { .. }) {
1062                break;
1063            }
1064        }
1065        pipeline.stop();
1066
1067        let written = shape_of(&path.to_string_lossy());
1068        eprintln!("SHAPE in : {arriving:?} fps={:.3}", arriving.video_fps());
1069        eprintln!("SHAPE out: {written:?} fps={:.3}", written.video_fps());
1070
1071        assert!(
1072            (written.video_fps() - arriving.video_fps()).abs() < 0.5,
1073            "the picture came out at a different rate: {:.3} in, {:.3} out",
1074            arriving.video_fps(),
1075            written.video_fps()
1076        );
1077        assert!(
1078            (written.video_seconds - arriving.video_seconds).abs() < 0.25,
1079            "the picture came out a different length: {:.3}s in, {:.3}s out",
1080            arriving.video_seconds,
1081            written.video_seconds
1082        );
1083        assert!(
1084            (written.audio_seconds - written.video_seconds).abs() < 0.25,
1085            "the sound and the picture came out different lengths: \
1086             {:.3}s of sound against {:.3}s of picture",
1087            written.audio_seconds,
1088            written.video_seconds
1089        );
1090        assert!(
1091            (written.audio_start - written.video_start).abs()
1092                <= (arriving.audio_start - arriving.video_start).abs() + 0.05,
1093            "the two tracks no longer start where they did: \
1094             in {:.3}s/{:.3}s, out {:.3}s/{:.3}s",
1095            arriving.video_start,
1096            arriving.audio_start,
1097            written.video_start,
1098            written.audio_start
1099        );
1100        std::fs::remove_file(&path).ok();
1101    }
1102}