Skip to main content

media_pp\elements\sink\muxer/
mp4_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    control::ControlMsg,
13    element::{Element, ElementType, Sink, element_pp_log},
14    error::Result,
15};
16
17/// Errors specific to `Mp4Muxer`. Converts into the crate-wide `Error` via
18/// `?` (see [`crate::error::Error`]).
19#[derive(Debug, ThisError)]
20pub enum Mp4MuxerError {
21    #[error("Mp4Muxer stream sinks only accept Packet or Eos buffers, got {0}")]
22    UnsupportedBuffer(&'static str),
23
24    #[error("ffmpeg error: {0}")]
25    Ffmpeg(#[from] ffmpeg::Error),
26}
27
28/// One track registered via [`Mp4Muxer::add_stream`], waiting for
29/// [`Mp4Muxer::open`] to turn it into a real [`Mp4MuxerStreamSink`] — its
30/// `name` becomes that sink's own [`Element::name`]/`pp_log` identity, and
31/// `input_time_base` is what every `Packet` it receives already carries
32/// `pts`/`dts` in (the same one its upstream encoder was opened with).
33struct PendingStream {
34    name: Arc<str>,
35    input_time_base: ffmpeg::Rational,
36}
37
38/// Builds an MP4 (or any other container ffmpeg infers from `path`'s
39/// extension) with one or more tracks, then opens it into one [`Sink`] per
40/// track. Two-phase on purpose: a container's header has to describe
41/// every stream's codec parameters up front — `avformat_write_header`
42/// can't run until every [`Mp4Muxer::add_stream`] this file will ever hold
43/// has already happened — so there's no way to make this a single
44/// long-lived `Sink` that tracks attach to one at a time as their encoders
45/// come online (contrast [`crate::elements::AudioMixer`], whose inputs
46/// *can* attach at any time — it has no "known shape before the first
47/// byte" constraint the way a container header does).
48///
49/// ```ignore
50/// let mut muxer = Mp4Muxer::create("out.mp4")?;
51/// muxer.add_stream("video", video_encoder.parameters(), video_time_base)?;
52/// muxer.add_stream("audio", audio_encoder.parameters(), audio_time_base)?;
53/// let mut sinks = muxer.open()?; // writes the header
54/// let audio_sink = sinks.pop().unwrap();
55/// let video_sink = sinks.pop().unwrap();
56/// ```
57pub struct Mp4Muxer {
58    output: ffmpeg::format::context::Output,
59    streams: Vec<PendingStream>,
60}
61
62impl Mp4Muxer {
63    /// Allocates the output file. No header is written yet — nothing is on
64    /// disk in a readable shape until [`Mp4Muxer::open`] runs.
65    pub fn create(path: impl AsRef<Path>) -> Result<Self> {
66        let output = ffmpeg::format::output(&path).map_err(Mp4MuxerError::from)?;
67        Ok(Self {
68            output,
69            streams: Vec::new(),
70        })
71    }
72
73    /// Registers one more track this file will hold. `parameters`/
74    /// `time_base` describe it — typically
75    /// [`crate::elements::SwEncoder::parameters`]/the same `time_base`
76    /// passed to its own `SwEncoderOptions` (or the
77    /// [`crate::elements::SwAudioEncoder`] equivalents). `name` becomes
78    /// this track's own [`Element::name`]/`pp_log` identity once
79    /// [`Mp4Muxer::open`] turns it into a `Sink` — pick something that
80    /// tells multiple tracks apart in logs/[`crate::bus::BusEvent`]s,
81    /// e.g. `"video"`/`"audio"`.
82    ///
83    /// Add streams in the same order the caller will treat
84    /// [`Mp4Muxer::open`]'s returned `Vec` — index 0 is whichever stream
85    /// was added first, and so on.
86    pub fn add_stream(
87        &mut self,
88        name: impl Into<String>,
89        parameters: ffmpeg::codec::Parameters,
90        time_base: ffmpeg::Rational,
91    ) -> Result<()> {
92        let mut stream = self
93            .output
94            .add_stream(parameters.id())
95            .map_err(Mp4MuxerError::from)?;
96        stream.set_time_base(time_base);
97        stream.set_parameters(parameters);
98        self.streams.push(PendingStream {
99            name: name.into().into(),
100            input_time_base: time_base,
101        });
102        Ok(())
103    }
104
105    /// Writes the container header — every [`Mp4Muxer::add_stream`] call
106    /// this file will ever get must already have happened — and returns
107    /// one [`Sink`] per track, in the order [`Mp4Muxer::add_stream`] added
108    /// them.
109    ///
110    /// All returned `Sink`s write into the same underlying file behind a
111    /// shared lock: packets from independently-threaded branches (e.g. a
112    /// video encode chain and an audio encode chain, each on their own
113    /// [`crate::queue::Queue`]) can arrive concurrently, and neither
114    /// `av_interleaved_write_frame` nor `av_write_trailer` is safe to call
115    /// from multiple threads against the same file at once. They also
116    /// share one trailer: it's written once every track has reported
117    /// itself done — via `Eos` *or* [`ControlMsg::Stop`], either meaning
118    /// "this track is finished" rather than "abandon the whole file" —
119    /// not on whichever track finishes first, which would silently
120    /// truncate whatever the other track(s) still had left to write. A
121    /// single-track file (e.g. `screen_record`/`audio_record`) degenerates
122    /// to finalizing on that one track's own `Eos`/`Stop`, same as before
123    /// this type supported more than one.
124    ///
125    /// A caller driving multiple tracks from independent
126    /// [`crate::pipeline::Pipeline`]s (today's architecture: one
127    /// `SourceElement` per pipeline, so a live video capture and a live
128    /// audio capture are necessarily two separate pipelines) is
129    /// responsible for stopping all of them — the file's trailer only
130    /// gets written once every track has actually reported done, so
131    /// stopping only one pipeline while another keeps running leaves the
132    /// file un-finalized (and unplayable) until the rest catch up too.
133    pub fn open(mut self) -> Result<Vec<Box<dyn Sink>>> {
134        self.output.write_header().map_err(Mp4MuxerError::from)?;
135        let total = self.streams.len();
136        let shared = Arc::new(Mp4MuxerShared {
137            state: Mutex::new(MuxerState {
138                output: self.output,
139                done: 0,
140                finished: false,
141            }),
142            total,
143        });
144        Ok(self
145            .streams
146            .into_iter()
147            .enumerate()
148            .map(|(index, stream)| -> Box<dyn Sink> {
149                Box::new(Mp4MuxerStreamSink {
150                    pp_log: element_pp_log(ElementType::Mp4Muxer, &stream.name, None),
151                    name: stream.name,
152                    shared: shared.clone(),
153                    stream_index: index,
154                    input_time_base: stream.input_time_base,
155                    done: false,
156                })
157            })
158            .collect())
159    }
160}
161
162struct MuxerState {
163    output: ffmpeg::format::context::Output,
164    /// How many tracks have reported themselves finished (`Eos` or
165    /// `Stop`) — the trailer is written once this reaches
166    /// [`Mp4MuxerShared::total`], not on the first one (see
167    /// [`Mp4Muxer::open`]'s own docs for why).
168    done: usize,
169    /// Set once the trailer has been written. Each
170    /// [`Mp4MuxerStreamSink`]'s own `done` flag already prevents
171    /// double-counting *that* track's contribution to `done`; this
172    /// additionally guards [`Mp4MuxerShared::write_packet`] against
173    /// writing into a file whose trailer has already closed it.
174    finished: bool,
175}
176
177/// Shared between every [`Mp4MuxerStreamSink`] [`Mp4Muxer::open`] hands
178/// out for the same file — one lock around the whole
179/// [`ffmpeg::format::context::Output`] so concurrent tracks never
180/// interleave two writes against it (see [`Mp4Muxer::open`]'s own docs).
181struct Mp4MuxerShared {
182    state: Mutex<MuxerState>,
183    total: usize,
184}
185
186impl Mp4MuxerShared {
187    fn write_packet(
188        &self,
189        stream_index: usize,
190        input_time_base: ffmpeg::Rational,
191        packet: &ffmpeg::Packet,
192    ) -> Result<()> {
193        let mut state = self.state.lock().unwrap();
194        if state.finished {
195            return Ok(());
196        }
197        // Cloned, not mutated in place — `Arc<Packet>` may be shared with
198        // another branch (e.g. a `PacketCounter` off the same `Tee`),
199        // which must not see this stream's `set_stream`/rescaled
200        // timestamps.
201        let mut packet = packet.clone();
202        let output_time_base = state
203            .output
204            .stream(stream_index)
205            .expect("stream was added in Mp4Muxer::add_stream")
206            .time_base();
207        packet.rescale_ts(input_time_base, output_time_base);
208        packet.set_stream(stream_index);
209        packet.set_position(-1);
210        packet
211            .write_interleaved(&mut state.output)
212            .map_err(Mp4MuxerError::from)?;
213        Ok(())
214    }
215
216    /// One track reporting itself done (`Eos` or `Stop`) — writes the
217    /// trailer exactly once, only once every track has called this.
218    fn finish_track(&self) -> Result<()> {
219        let mut state = self.state.lock().unwrap();
220        state.done += 1;
221        if state.finished || state.done < self.total {
222            return Ok(());
223        }
224        state.finished = true;
225        state.output.write_trailer().map_err(Mp4MuxerError::from)?;
226        Ok(())
227    }
228}
229
230/// One track's own [`Sink`] — a lightweight handle sharing a
231/// `Mp4MuxerShared` with every other track [`Mp4Muxer::open`] returned
232/// alongside it. See [`Mp4Muxer::open`]'s own docs for the
233/// finalize-once-every-track-is-done contract this relies on.
234pub struct Mp4MuxerStreamSink {
235    pp_log: PpLog,
236    name: Arc<str>,
237    shared: Arc<Mp4MuxerShared>,
238    stream_index: usize,
239    input_time_base: ffmpeg::Rational,
240    /// Set once this sink has contributed to
241    /// [`Mp4MuxerShared::finish_track`] — guards against double-counting
242    /// if both a natural `Eos` and a later `Stop` arrive for the same
243    /// track.
244    done: bool,
245}
246
247impl Mp4MuxerStreamSink {
248    fn finish(&mut self) -> Result<()> {
249        if self.done {
250            return Ok(());
251        }
252        self.done = true;
253        self.shared
254            .finish_track()
255            .inspect_err(|error| pp_error!(self, "write_trailer failed: {error}"))
256    }
257}
258
259impl Element for Mp4MuxerStreamSink {
260    fn name(&self) -> Arc<str> {
261        self.name.clone()
262    }
263
264    fn element_type(&self) -> ElementType {
265        ElementType::Mp4Muxer
266    }
267
268    fn pp_log(&self) -> &PpLog {
269        &self.pp_log
270    }
271
272    fn pp_log_mut(&mut self) -> &mut PpLog {
273        &mut self.pp_log
274    }
275}
276
277impl Sink for Mp4MuxerStreamSink {
278    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
279        match buf {
280            MediaBuffer::Packet(packet) => self
281                .shared
282                .write_packet(self.stream_index, self.input_time_base, &packet)
283                .inspect_err(|error| pp_error!(self, "write_interleaved failed: {error}")),
284            MediaBuffer::Eos => self.finish(),
285            other => Err(Mp4MuxerError::UnsupportedBuffer(other.kind()).into()),
286        }
287    }
288
289    fn control(&mut self, msg: ControlMsg) -> Result<()> {
290        // Terminal, nothing to forward. `Stop` still contributes to this
291        // track's own "done" count — see `Mp4Muxer::open`'s own docs on
292        // why the trailer waits for every track rather than finalizing on
293        // whichever stops first.
294        if msg == ControlMsg::Stop {
295            self.finish()?;
296        }
297        Ok(())
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::element::Source;
305    use crate::elements::{AudioCodec, SwAudioEncoder, SwAudioEncoderOptions};
306
307    fn open_aac_encoder(sample_rate: u32, channels: u16) -> SwAudioEncoder {
308        SwAudioEncoder::new(
309            "encoder",
310            SwAudioEncoderOptions {
311                codec: AudioCodec::Aac,
312                sample_rate,
313                channels,
314                time_base: ffmpeg::Rational::new(1, sample_rate as i32),
315                bit_rate: 64_000,
316            },
317        )
318        .expect("aac encoder must be available")
319    }
320
321    fn silent_frame(
322        sample_rate: u32,
323        channels: u16,
324        samples: usize,
325        pts: i64,
326    ) -> ffmpeg::frame::Audio {
327        let mut frame = ffmpeg::frame::Audio::new(
328            ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
329            samples,
330            ffmpeg::ChannelLayout::default(channels as i32),
331        );
332        frame.set_rate(sample_rate);
333        frame.set_pts(Some(pts));
334        // `frame::Audio::new` doesn't zero its buffer — leaving it
335        // uninitialized risks the encoder reading garbage bytes as NaN/Inf
336        // floats (`avcodec_send_frame` then rejects the frame outright).
337        frame.data_mut(0).fill(0);
338        frame
339    }
340
341    /// One track, driven end to end (encode -> mux -> write_trailer on
342    /// `Eos`), still produces a real, playable file — the single-track
343    /// case `Mp4Muxer` degenerates to.
344    #[test]
345    fn single_track_still_produces_a_playable_file() {
346        let mut encoder = open_aac_encoder(48000, 1);
347
348        let dir = std::env::temp_dir();
349        let path = dir.join(format!("mp4_muxer_single_test_{}.mp4", std::process::id()));
350
351        let mut muxer = Mp4Muxer::create(&path).expect("mp4 muxer must open");
352        muxer
353            .add_stream(
354                "audio",
355                encoder.parameters(),
356                ffmpeg::Rational::new(1, 48000),
357            )
358            .expect("add_stream must succeed");
359        let mut sinks = muxer.open().expect("open must write the header");
360        assert_eq!(sinks.len(), 1);
361        encoder.src_pads()[0].link(sinks.pop().unwrap());
362
363        for tick in 0..20i64 {
364            encoder
365                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
366                    48000,
367                    1,
368                    960,
369                    tick * 960,
370                ))))
371                .expect("consume must succeed");
372        }
373        encoder
374            .consume(MediaBuffer::Eos)
375            .expect("eos must flush cleanly");
376        drop(encoder);
377
378        let input = ffmpeg::format::input(&path).expect("muxed file must be readable back");
379        assert_eq!(input.streams().count(), 1);
380        std::fs::remove_file(&path).ok();
381    }
382
383    /// Regression test against a leaked file handle: dropping every track
384    /// `Sink` without ever sending `Eos`/`Stop` (simulating a `Pipeline`
385    /// just getting dropped mid-recording, e.g. the process is tearing
386    /// down) must still release the underlying file — no stray clone of
387    /// the shared `Arc` (or the `ffmpeg::format::context::Output` it
388    /// guards) left holding it open. Windows won't let an open file be
389    /// deleted, so a successful `remove_file` here is direct proof
390    /// nothing lingered; on a build where that isn't already guaranteed
391    /// by construction, this would instead hang or fail with a sharing
392    /// violation.
393    #[test]
394    fn dropping_every_sink_without_eos_or_stop_still_releases_the_file() {
395        let encoder = open_aac_encoder(48000, 1);
396
397        let dir = std::env::temp_dir();
398        let path = dir.join(format!("mp4_muxer_drop_test_{}.mp4", std::process::id()));
399
400        let mut muxer = Mp4Muxer::create(&path).expect("mp4 muxer must open");
401        muxer
402            .add_stream(
403                "audio",
404                encoder.parameters(),
405                ffmpeg::Rational::new(1, 48000),
406            )
407            .expect("add_stream must succeed");
408        let sinks = muxer.open().expect("open must write the header");
409
410        // No `Eos`/`Stop`, no trailer — just drop everything, on purpose.
411        drop(sinks);
412        drop(encoder);
413
414        std::fs::remove_file(&path)
415            .expect("file handle must be released once every sink is dropped");
416    }
417
418    /// Two independent tracks (standing in for a real video+audio pair —
419    /// `Mp4Muxer` treats every stream as an opaque `codec::Parameters`, so
420    /// two AAC tracks at different sample rates exercise the same
421    /// stream-index/trailer-timing machinery a real video+audio pair
422    /// would) muxed into one file. Track `a` reaches `Eos` well before
423    /// track `b` does — proving the trailer isn't written until *both*
424    /// report done, not on whichever finishes first (which would
425    /// silently truncate whichever track was still running).
426    #[test]
427    fn muxes_two_independent_tracks_without_finalizing_early() {
428        let mut encoder_a = open_aac_encoder(48000, 2);
429        let mut encoder_b = open_aac_encoder(44100, 1);
430
431        let dir = std::env::temp_dir();
432        let path = dir.join(format!("mp4_muxer_multi_test_{}.mp4", std::process::id()));
433
434        let mut muxer = Mp4Muxer::create(&path).expect("mp4 muxer must open");
435        muxer
436            .add_stream("a", encoder_a.parameters(), ffmpeg::Rational::new(1, 48000))
437            .expect("add_stream a");
438        muxer
439            .add_stream("b", encoder_b.parameters(), ffmpeg::Rational::new(1, 44100))
440            .expect("add_stream b");
441        let mut sinks = muxer.open().expect("open must write the header");
442        assert_eq!(sinks.len(), 2);
443        let sink_b = sinks.pop().unwrap();
444        let sink_a = sinks.pop().unwrap();
445        encoder_a.src_pads()[0].link(sink_a);
446        encoder_b.src_pads()[0].link(sink_b);
447
448        for tick in 0..10i64 {
449            encoder_a
450                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
451                    48000,
452                    2,
453                    960,
454                    tick * 960,
455                ))))
456                .expect("consume must succeed");
457        }
458        // Track `a` finishes here — well before track `b` has written
459        // anything at all.
460        encoder_a
461            .consume(MediaBuffer::Eos)
462            .expect("eos must flush cleanly");
463
464        for tick in 0..10i64 {
465            encoder_b
466                .consume(MediaBuffer::Audio(Arc::new(silent_frame(
467                    44100,
468                    1,
469                    882,
470                    tick * 882,
471                ))))
472                .expect("consume must succeed");
473        }
474        encoder_b
475            .consume(MediaBuffer::Eos)
476            .expect("eos must flush cleanly");
477
478        drop(encoder_a);
479        drop(encoder_b);
480
481        let mut input = ffmpeg::format::input(&path).expect("muxed file must be readable back");
482        assert_eq!(input.streams().count(), 2, "expected two tracks");
483
484        let mut counts = [0usize; 2];
485        let mut packet = ffmpeg::Packet::empty();
486        while packet.read(&mut input).is_ok() {
487            counts[packet.stream()] += 1;
488            packet = ffmpeg::Packet::empty();
489        }
490        assert!(counts[0] > 0, "track a has no packets: {counts:?}");
491        assert!(
492            counts[1] > 0,
493            "track b has no packets: {counts:?} — trailer was written before track b finished"
494        );
495        std::fs::remove_file(&path).ok();
496    }
497}