Skip to main content

media_pp/elements/sink/muxer/
segmented_file_muxer.rs

1use std::{
2    path::PathBuf,
3    sync::{Arc, Mutex},
4    time::{Duration, Instant},
5};
6
7use crate::pp_log::{PpLog, pp_info};
8use ffmpeg_next as ffmpeg;
9
10use super::file_muxer::{FileMuxer, FileMuxerError};
11use crate::{
12    buffer::MediaBuffer,
13    contract::{InputContract, MediaKind, PortContract},
14    control::{ControlMsg, SeekRejectReason},
15    element::{Element, ElementType, Sink, element_pp_log},
16    error::Result,
17};
18
19/// How a [`SegmentedFileMuxer`] decides a segment is done and it's time to
20/// cut to a new file.
21#[derive(Debug, Clone, Copy)]
22pub enum SegmentPolicy {
23    /// Roughly this long per segment. "Roughly" because the actual cut
24    /// only happens once this much time has elapsed *and* the video
25    /// track's own next packet is a keyframe (see
26    /// [`SegmentedFileMuxer::open`]'s own docs) — a segment can run a bit
27    /// longer than requested if keyframes are sparse.
28    Duration(Duration),
29    /// Roughly this many bytes per segment, counted as the packets this
30    /// group writes.
31    ///
32    /// "Roughly" twice over. The cut waits for the same keyframe the
33    /// duration policy does, so a segment overruns by however much is
34    /// written between reaching the figure and the next one — the size
35    /// equivalent of a GOP. And the count is of packet payloads, where a
36    /// file also carries its container: headers, an index, and per-frame
37    /// framing, so the file on disk is somewhat larger than the figure
38    /// asked for rather than exactly it.
39    ///
40    /// Neither is worth correcting for. What this is for is keeping files
41    /// under a limit something downstream imposes, and a caller who needs
42    /// to be sure has to leave headroom for the GOP overrun anyway.
43    Size(u64),
44}
45
46/// One track's fixed description — everything [`SegmentedFileMuxer::open`]
47/// needs to re-add it to a fresh [`FileMuxer`] on every rotation.
48/// `parameters` is cloned each time ([`FileMuxer::add_stream`] consumes its
49/// own copy); the original stays here as the template.
50struct StreamDef {
51    name: Arc<str>,
52    parameters: ffmpeg::codec::Parameters,
53    time_base: ffmpeg::Rational,
54    /// Whether this is the track [`SegmentGroup::maybe_rotate`] waits for
55    /// a keyframe on before actually cutting — see
56    /// [`SegmentedFileMuxer::open`]'s own docs.
57    is_video: bool,
58}
59
60/// Builds a [`SegmentedFileMuxer`] the same two-phase way as a plain
61/// [`FileMuxer`] (every track's shape must be known before the first byte
62/// is written) — `create` picks the rotation policy and how segments get
63/// named, `add_stream` registers each track exactly like
64/// [`FileMuxer::add_stream`], `open` writes the first segment's header and
65/// returns one [`Sink`] per track.
66///
67/// ```no_run
68/// # use std::{path::PathBuf, time::Duration};
69/// # use media_pp::ffmpeg;
70/// # use media_pp::elements::{
71/// #     AudioCodec, SegmentPolicy, SegmentedFileMuxer, SwAudioEncoder,
72/// #     SwAudioEncoderOptions, SwEncoder, SwEncoderOptions, VideoCodec,
73/// # };
74/// # fn main() -> media_pp::Result<()> {
75/// # let video_time_base = ffmpeg::Rational(1, 30);
76/// # let audio_time_base = ffmpeg::Rational(1, 48_000);
77/// # let video_encoder = SwEncoder::new("video", SwEncoderOptions {
78/// #     codec: VideoCodec::H264,
79/// #     width: 640,
80/// #     height: 360,
81/// #     time_base: video_time_base,
82/// #     frame_rate: ffmpeg::Rational(30, 1),
83/// #     bit_rate: 2_000_000,
84/// #     gop_size: 30,
85/// #     max_b_frames: None,
86/// # })?;
87/// # let audio_encoder = SwAudioEncoder::new("audio", SwAudioEncoderOptions {
88/// #     codec: AudioCodec::Aac,
89/// #     sample_rate: 48_000,
90/// #     channels: 2,
91/// #     time_base: audio_time_base,
92/// #     bit_rate: 128_000,
93/// # })?;
94/// let mut muxer = SegmentedFileMuxer::create(
95///     SegmentPolicy::Duration(Duration::from_secs(600)),
96///     |index| PathBuf::from(format!("rec_{index:04}.mp4")),
97/// );
98/// muxer.add_stream("video", video_encoder.parameters(), video_time_base);
99/// muxer.add_stream("audio", audio_encoder.parameters(), audio_time_base);
100/// let mut sinks = muxer.open()?;
101/// # Ok(())
102/// # }
103/// ```
104pub struct SegmentedFileMuxer {
105    policy: SegmentPolicy,
106    naming: Box<dyn FnMut(u64) -> PathBuf + Send>,
107    streams: Vec<StreamDef>,
108}
109
110impl SegmentedFileMuxer {
111    /// `naming(index)` names each segment file, `index` starting at `0` —
112    /// called once up front for the first segment and again on every
113    /// rotation. Typically a closure building a path from a fixed
114    /// directory/prefix (e.g. `|i| dir.join(format!("rec_{i:04}.mp4"))`);
115    /// a timestamp-based scheme works just as well since `index` is only
116    /// ever used to *call* this, never to build the path itself.
117    pub fn create(
118        policy: SegmentPolicy,
119        naming: impl FnMut(u64) -> PathBuf + Send + 'static,
120    ) -> Self {
121        Self {
122            policy,
123            naming: Box::new(naming),
124            streams: Vec::new(),
125        }
126    }
127
128    /// Registers one more track every segment file will hold — same
129    /// contract as [`FileMuxer::add_stream`] (same order rules, same
130    /// `name`/`parameters`/`time_base` meaning), except this can't fail:
131    /// nothing here touches ffmpeg yet, it's only recorded for
132    /// [`SegmentedFileMuxer::open`] (and every later rotation) to replay.
133    ///
134    /// Whichever stream's `parameters.medium()` is
135    /// [`ffmpeg::media::Type::Video`] (at most one is expected) becomes
136    /// the keyframe-gating track described in [`SegmentedFileMuxer::open`]'s
137    /// own docs — no separate flag to pass.
138    pub fn add_stream(
139        &mut self,
140        name: impl Into<String>,
141        parameters: ffmpeg::codec::Parameters,
142        time_base: ffmpeg::Rational,
143    ) {
144        let is_video = parameters.medium() == ffmpeg::media::Type::Video;
145        self.streams.push(StreamDef {
146            name: name.into().into(),
147            parameters,
148            time_base,
149            is_video,
150        });
151    }
152
153    /// Writes the first segment's header and returns one [`Sink`] per
154    /// track, in the order [`SegmentedFileMuxer::add_stream`] added them —
155    /// same shape as [`FileMuxer::open`]. All returned `Sink`s share one
156    /// rotation lock: a track's `consume` blocks while another track (on
157    /// its own thread) is mid-rotation, same tradeoff [`FileMuxer`]'s own
158    /// shared file lock already makes.
159    ///
160    /// **Rotation timing**: once a segment has run at least as long as the
161    /// configured [`SegmentPolicy`], the cut happens on the *video*
162    /// track's next keyframe (found via `add_stream`'s `parameters` — see
163    /// its own docs) — not immediately, and not on an arbitrary packet —
164    /// so every segment file is independently decodable from its own
165    /// first frame, the same way a real segment/HLS muxer cuts. A segment
166    /// can therefore run somewhat longer than requested if keyframes are
167    /// sparse; there's no hard cap. If no track's `parameters.medium()`
168    /// was `Video` (an audio-only recording), any packet on any track is
169    /// an equally valid cut point, so rotation happens as soon as the
170    /// policy is due.
171    ///
172    /// The final segment is finalized the same way a plain [`FileMuxer`]
173    /// finalizes its one file: once every track has reported `Eos` *or*
174    /// [`ControlMsg::Stop`] (see [`FileMuxer::open`]'s own docs) — a
175    /// rotation mid-recording reuses that exact mechanism to close the
176    /// outgoing segment before opening the next one.
177    pub fn open(mut self) -> Result<Vec<Box<dyn Sink>>> {
178        // Captured before `self.streams` moves into `GroupState` below —
179        // the name and medium of each track, in order, for building every
180        // `SegmentedTrackSink` afterward.
181        let tracks: Vec<(Arc<str>, Option<MediaKind>)> = self
182            .streams
183            .iter()
184            .map(|s| (s.name.clone(), MediaKind::packet_for(s.parameters.medium())))
185            .collect();
186        let path = (self.naming)(0);
187        let current_sinks = open_segment(&self.streams, path)?;
188        let group = Arc::new(SegmentGroup {
189            policy: self.policy,
190            naming: Mutex::new(self.naming),
191            state: Mutex::new(GroupState {
192                streams: self.streams,
193                current_sinks,
194                segment_index: 0,
195                segment_started: Instant::now(),
196                segment_bytes: 0,
197                segment_origin: None,
198            }),
199        });
200        Ok(tracks
201            .into_iter()
202            .enumerate()
203            .map(|(index, (name, kind))| -> Box<dyn Sink> {
204                Box::new(SegmentedTrackSink {
205                    pp_log: element_pp_log(ElementType::SegmentedFileMuxer, &name, None),
206                    name,
207                    track_index: index,
208                    kind,
209                    group: group.clone(),
210                })
211            })
212            .collect())
213    }
214}
215
216fn open_segment(streams: &[StreamDef], path: PathBuf) -> Result<Vec<Box<dyn Sink>>> {
217    let mut muxer = FileMuxer::create(&path)?;
218    for stream in streams {
219        muxer.add_stream(
220            stream.name.to_string(),
221            stream.parameters.clone(),
222            stream.time_base,
223        )?;
224    }
225    muxer.open()
226}
227
228struct GroupState {
229    /// This group's fixed track descriptions — moved in here (rather than
230    /// sitting on [`SegmentGroup`] directly) purely so `Mutex<GroupState>`
231    /// covers it too: [`ffmpeg::codec::Parameters`] wraps a raw pointer and
232    /// isn't `Sync`, so a field of this type living outside any `Mutex`
233    /// would make `SegmentGroup` itself `!Sync` and unable to cross
234    /// threads inside the `Arc` every [`SegmentedTrackSink`] holds.
235    /// `Mutex<T>` only ever needs `T: Send` (already true here — see
236    /// `ffmpeg-next`'s own `unsafe impl Send for Parameters`), never
237    /// `T: Sync`, which is exactly what sidesteps that.
238    streams: Vec<StreamDef>,
239    /// The currently-open segment's own per-track sinks, in the same
240    /// order as `streams` — index-aligned with
241    /// [`SegmentedTrackSink::track_index`].
242    current_sinks: Vec<Box<dyn Sink>>,
243    /// What this segment has been given so far, for [`SegmentPolicy::Size`]
244    /// — every track's packets, since they all land in the one file.
245    segment_bytes: u64,
246    segment_index: u64,
247    segment_started: Instant,
248    /// Where this segment's timeline starts, as `(track, timestamp)` in that
249    /// track's own time base — see [`SegmentGroup::rebase`].
250    segment_origin: Option<(usize, i64)>,
251}
252
253/// Shared between every [`SegmentedTrackSink`] [`SegmentedFileMuxer::open`]
254/// hands out — one rotation lock around the whole group of tracks, so a
255/// rotation triggered by one track's packet arrival is atomic with respect
256/// to every other track (either all of them are still writing into the
257/// outgoing segment, or all of them are already writing into the new one —
258/// never a mix).
259struct SegmentGroup {
260    policy: SegmentPolicy,
261    naming: Mutex<Box<dyn FnMut(u64) -> PathBuf + Send>>,
262    state: Mutex<GroupState>,
263}
264
265impl SegmentGroup {
266    /// Called for every `Packet` on every track, before it's written.
267    /// Rotates first if this is the packet that should trigger it (see
268    /// [`SegmentedFileMuxer::open`]'s own docs), then writes into whichever
269    /// segment is current by the time this returns.
270    fn consume_packet(
271        &self,
272        track_index: usize,
273        packet: Arc<ffmpeg::Packet>,
274        pp_log: &PpLog,
275    ) -> Result<()> {
276        let mut state = self.state.lock().unwrap();
277        // Counted before the check, so a segment that is already over its
278        // size cuts at this keyframe rather than one packet later.
279        state.segment_bytes += packet.size() as u64;
280        if state.segment_origin.is_none()
281            && let Some(dts) = packet.dts()
282        {
283            state.segment_origin = Some((track_index, dts));
284        }
285        let due = match self.policy {
286            SegmentPolicy::Duration(after) => state.segment_started.elapsed() >= after,
287            SegmentPolicy::Size(bytes) => state.segment_bytes >= bytes,
288        };
289        let mut rotated_to = None;
290        if due {
291            let has_video = state.streams.iter().any(|s| s.is_video);
292            let this_is_video = state.streams[track_index].is_video;
293            let should_cut = if has_video {
294                this_is_video && packet.is_key()
295            } else {
296                true
297            };
298            if should_cut {
299                for sink in state.current_sinks.iter_mut() {
300                    sink.control(ControlMsg::Stop)?;
301                }
302                let index = state.segment_index + 1;
303                let path = (self.naming.lock().unwrap())(index);
304                state.current_sinks = open_segment(&state.streams, path)?;
305                state.segment_index = index;
306                state.segment_started = Instant::now();
307                // This packet is the first of the new segment, so what was
308                // counted for it above belongs to that one.
309                state.segment_bytes = packet.size() as u64;
310                // This packet opens the new segment, so its timeline starts
311                // here — see [`SegmentGroup::rebase`].
312                state.segment_origin = packet.dts().map(|dts| (track_index, dts));
313                rotated_to = Some(index);
314            }
315        }
316        let packet = Self::rebase(&state, track_index, packet);
317        let result = state.current_sinks[track_index].consume(MediaBuffer::Packet(packet));
318        // Formatting and emitting happen off the group lock: every track's
319        // `consume_packet` contends for it, so nothing that isn't required
320        // to be serialized with the rotation belongs inside it.
321        drop(state);
322        if let Some(index) = rotated_to {
323            pp_info!(pp_log: pp_log, "rotated segment_index={index}");
324        }
325        result
326    }
327
328    /// Moves a packet onto its segment's own timeline, so each file starts
329    /// near zero instead of carrying on from where the last one stopped.
330    ///
331    /// # One origin for every track, not one each
332    ///
333    /// Zeroing each track against its own first packet would start both at
334    /// exactly zero and pull them apart by however much they were
335    /// interleaved — up to an audio packet, which is audible. So the origin
336    /// is whichever packet opened the segment, converted into each track's
337    /// own time base, and every track is moved by that same instant.
338    ///
339    /// The rotation happens on a video keyframe, so a track interleaved just
340    /// behind it can carry a packet fractionally older than that origin.
341    /// Those clamp to zero rather than going negative, which no muxer
342    /// accepts; it costs those few packets their spacing and nothing after
343    /// them.
344    ///
345    /// # What this trades away
346    ///
347    /// Concatenating the segments back into one file no longer works by
348    /// appending them, since each starts at zero again. That is the choice
349    /// this makes: the files are for playing one at a time, which is what a
350    /// split recording is for, and a player opening the third of them should
351    /// not find two segments' worth of nothing in front of it.
352    fn rebase(
353        state: &GroupState,
354        track_index: usize,
355        packet: Arc<ffmpeg::Packet>,
356    ) -> Arc<ffmpeg::Packet> {
357        let Some((origin_track, origin)) = state.segment_origin else {
358            return packet;
359        };
360        let origin = if origin_track == track_index {
361            origin
362        } else {
363            // SAFETY: plain arithmetic on two rationals; it reads nothing
364            // through a pointer.
365            unsafe {
366                ffmpeg::ffi::av_rescale_q(
367                    origin,
368                    state.streams[origin_track].time_base.into(),
369                    state.streams[track_index].time_base.into(),
370                )
371            }
372        };
373        if origin == 0 {
374            return packet;
375        }
376        let moved = |value: i64| (value - origin).max(0);
377        let mut rebased = (*packet).clone();
378        rebased.set_pts(packet.pts().map(moved));
379        rebased.set_dts(packet.dts().map(moved));
380        Arc::new(rebased)
381    }
382
383    /// One track's own natural `Eos` — forwarded into whatever segment is
384    /// current, same as [`SegmentGroup::consume_packet`] but without a
385    /// rotation check (ending is ending, not a cut point).
386    fn finish_eos(&self, track_index: usize) -> Result<()> {
387        let mut state = self.state.lock().unwrap();
388        state.current_sinks[track_index].consume(MediaBuffer::Eos)
389    }
390
391    /// One track's own [`ControlMsg::Stop`] — same as
392    /// [`SegmentGroup::finish_eos`], just forwarded as `Stop` instead of
393    /// `Eos` (matters for `FileMuxer`'s own docs on `Stop` finalizing a
394    /// container even though it otherwise means "abandon, don't drain").
395    fn finish_stop(&self, track_index: usize) -> Result<()> {
396        let mut state = self.state.lock().unwrap();
397        state.current_sinks[track_index].control(ControlMsg::Stop)
398    }
399}
400
401/// One track's own [`Sink`] — a lightweight handle sharing a
402/// [`SegmentGroup`] with every other track [`SegmentedFileMuxer::open`]
403/// returned alongside it.
404struct SegmentedTrackSink {
405    pp_log: PpLog,
406    name: Arc<str>,
407    track_index: usize,
408    /// The medium this track was registered for; `None` for one this
409    /// crate does not model, which then declares nothing.
410    kind: Option<MediaKind>,
411    group: Arc<SegmentGroup>,
412}
413
414impl Element for SegmentedTrackSink {
415    fn name(&self) -> Arc<str> {
416        self.name.clone()
417    }
418
419    fn element_type(&self) -> ElementType {
420        ElementType::SegmentedFileMuxer
421    }
422
423    fn pp_log(&self) -> &PpLog {
424        &self.pp_log
425    }
426
427    fn pp_log_mut(&mut self) -> &mut PpLog {
428        &mut self.pp_log
429    }
430}
431
432impl Sink for SegmentedTrackSink {
433    /// Same as FileMuxer: encoded packets only, cut into segments on keyframes.
434    fn input_contract(&self) -> InputContract {
435        match self.kind {
436            Some(kind) => InputContract::Fixed(PortContract::packet(kind)),
437            None => InputContract::Unknown,
438        }
439    }
440
441    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
442        match buf {
443            MediaBuffer::Packet(packet) => {
444                self.group
445                    .consume_packet(self.track_index, packet, &self.pp_log)
446            }
447            MediaBuffer::Eos => self.group.finish_eos(self.track_index),
448            // The `FileMuxer` each rotated segment wraps already rejects
449            // this — matching its own `FileMuxerStreamSink::consume` here
450            // instead of silently no-op'ing keeps that protection visible
451            // through the rotation wrapper instead of swallowing it.
452            other => Err(FileMuxerError::UnsupportedBuffer(other.kind()).into()),
453        }
454    }
455
456    fn control(&mut self, msg: ControlMsg) -> Result<()> {
457        if let ControlMsg::CheckSeek(context) = &msg {
458            context.reject(
459                self.element_type(),
460                self.name(),
461                SeekRejectReason::ElementNotSeekable,
462            );
463        }
464        if msg == ControlMsg::Stop {
465            self.group.finish_stop(self.track_index)?;
466        }
467        Ok(())
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::{
475        elements::{SwEncoder, SwEncoderOptions, TestVideoOptions, TestVideoSource, VideoCodec},
476        pipeline::Pipeline,
477    };
478
479    /// Opens an H.264 encoder for these tests, preferring the non-GPL
480    /// `OpenH264` but falling back to the GPL `H264` (`libx264`) — a
481    /// contributor's own ffmpeg build is more likely to carry `libx264`
482    /// (most distro packages enable it) than the Cisco `libopenh264`
483    /// library, and either one exercises the segment-rotation logic these
484    /// tests actually check. `None` only when neither is available.
485    fn open_h264_encoder(name: &str, mut options: SwEncoderOptions) -> Option<SwEncoder> {
486        [VideoCodec::OpenH264, VideoCodec::H264]
487            .into_iter()
488            .find_map(|codec| {
489                options.codec = codec;
490                SwEncoder::new(name, options).ok()
491            })
492    }
493
494    /// Drives a real `TestVideoSource -> SwEncoder -> SegmentedFileMuxer`
495    /// chain for a few real seconds with a short rotation policy, then
496    /// checks every segment file it produced: each one has to be a real,
497    /// independently-readable `.mp4` whose very first packet is a
498    /// keyframe — proof the cut actually waited for one (see
499    /// `SegmentedFileMuxer::open`'s own docs on why that matters: cutting
500    /// on an arbitrary packet would leave a segment starting mid-GOP,
501    /// undecodable from its own frame 0).
502    #[test]
503    fn rotates_into_multiple_valid_keyframe_aligned_segments() {
504        let video_options = TestVideoOptions {
505            width: 160,
506            height: 120,
507            framerate: ffmpeg::Rational::new(15, 1),
508        };
509        let video_source = TestVideoSource::new("video", video_options);
510        let time_base = video_source.time_base();
511        let Some(encoder) = open_h264_encoder(
512            "encoder",
513            SwEncoderOptions {
514                codec: VideoCodec::OpenH264,
515                width: video_options.width,
516                height: video_options.height,
517                time_base,
518                frame_rate: video_options.framerate,
519                bit_rate: 200_000,
520                // Short on purpose (~0.5s @ 15fps) — this test needs
521                // several real keyframes to show up quickly, not the
522                // ~2s default every other caller uses.
523                gop_size: 8,
524                max_b_frames: None,
525            },
526        ) else {
527            eprintln!("skipping: no H.264 encoder available (openh264 or libx264)");
528            return;
529        };
530
531        let dir = std::env::temp_dir();
532        let prefix = format!("segmented_mp4_test_{}", std::process::id());
533        let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
534        let recorded_paths = paths.clone();
535
536        let mut muxer = SegmentedFileMuxer::create(
537            SegmentPolicy::Duration(Duration::from_millis(300)),
538            move |index| {
539                let path = dir.join(format!("{prefix}_{index:03}.mp4"));
540                recorded_paths.lock().unwrap().push(path.clone());
541                path
542            },
543        );
544        muxer.add_stream("video", encoder.parameters(), time_base);
545        let mut sinks = muxer.open().expect("open must succeed");
546        let sink = sinks.pop().expect("exactly one stream was added");
547
548        let pipeline = Pipeline::new("segmented-test", video_source, |source, ctx| {
549            let branch = ctx.branch().pipe(encoder).to(sink)?;
550            ctx.attach(source, 0, branch)?;
551            Ok(())
552        })
553        .expect("test pipeline wiring must succeed");
554        pipeline.run().unwrap();
555        std::thread::sleep(Duration::from_secs(3));
556        pipeline.stop();
557        pipeline.bus().log_events();
558
559        let paths = paths.lock().unwrap().clone();
560        assert!(
561            paths.len() >= 2,
562            "expected at least 2 segments, got {}: {paths:?}",
563            paths.len()
564        );
565
566        for path in &paths {
567            let mut input = ffmpeg::format::input(path)
568                .unwrap_or_else(|error| panic!("segment {path:?} must be readable: {error}"));
569            assert_eq!(
570                input.streams().count(),
571                1,
572                "segment {path:?} should have exactly one stream"
573            );
574            let mut packet = ffmpeg::Packet::empty();
575            if packet.read(&mut input).is_err() {
576                panic!("segment {path:?} has no packets at all");
577            }
578            assert!(
579                packet.is_key(),
580                "segment {path:?}'s first packet must be a keyframe"
581            );
582        }
583
584        for path in &paths {
585            std::fs::remove_file(path).ok();
586        }
587    }
588
589    /// Every segment starts its own timeline rather than carrying on from
590    /// where the last one stopped.
591    ///
592    /// Without this the third file of a long recording opens with two
593    /// segments' worth of nothing in front of it, which is not what a player
594    /// handed one file should show.
595    #[test]
596    fn each_segment_starts_its_own_timeline() {
597        let video_options = TestVideoOptions {
598            width: 160,
599            height: 120,
600            framerate: ffmpeg::Rational::new(15, 1),
601        };
602        let video_source = TestVideoSource::new("video", video_options);
603        let time_base = video_source.time_base();
604        let Some(encoder) = open_h264_encoder(
605            "encoder",
606            SwEncoderOptions {
607                codec: VideoCodec::OpenH264,
608                width: video_options.width,
609                height: video_options.height,
610                time_base,
611                frame_rate: video_options.framerate,
612                bit_rate: 200_000,
613                gop_size: 8,
614                max_b_frames: None,
615            },
616        ) else {
617            eprintln!("skipping: no H.264 encoder available (openh264 or libx264)");
618            return;
619        };
620
621        let dir = std::env::temp_dir();
622        let prefix = format!("segmented_rebase_test_{}", std::process::id());
623        let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
624        let recorded_paths = paths.clone();
625        let mut muxer = SegmentedFileMuxer::create(
626            SegmentPolicy::Duration(Duration::from_millis(300)),
627            move |index| {
628                let path = dir.join(format!("{prefix}_{index:03}.mp4"));
629                recorded_paths.lock().unwrap().push(path.clone());
630                path
631            },
632        );
633        muxer.add_stream("video", encoder.parameters(), time_base);
634        let mut sinks = muxer.open().expect("open must succeed");
635        let sink = sinks.pop().expect("exactly one stream was added");
636        let pipeline = Pipeline::new("segmented-rebase-test", video_source, |source, ctx| {
637            let branch = ctx.branch().pipe(encoder).to(sink)?;
638            ctx.attach(source, 0, branch)?;
639            Ok(())
640        })
641        .expect("test pipeline wiring must succeed");
642        pipeline.run().unwrap();
643        std::thread::sleep(Duration::from_secs(3));
644        pipeline.stop();
645        pipeline.bus().log_events();
646
647        let paths = paths.lock().unwrap().clone();
648        assert!(paths.len() >= 3, "expected several segments, got {paths:?}");
649        for path in &paths {
650            let mut input = ffmpeg::format::input(path)
651                .unwrap_or_else(|error| panic!("segment {path:?} must be readable: {error}"));
652            let mut packet = ffmpeg::Packet::empty();
653            assert!(
654                packet.read(&mut input).is_ok(),
655                "segment {path:?} has no packets at all"
656            );
657            let dts = packet.dts().expect("a written packet carries a dts");
658            // Its own start, not the recording's: a segment a minute in would
659            // otherwise open somewhere around a minute.
660            assert_eq!(
661                dts, 0,
662                "segment {path:?} should start at zero, not at {dts}"
663            );
664        }
665        for path in &paths {
666            std::fs::remove_file(path).ok();
667        }
668    }
669
670    /// The size policy cuts on the same keyframe the duration one does, and
671    /// the segments it produces are as independently readable.
672    ///
673    /// The figure is deliberately small so a three-second run crosses it
674    /// several times. What is asserted is that it rotated *and* that every
675    /// segment still starts at a keyframe — the overrun past the figure is
676    /// the GOP the cut waits for, which `SegmentPolicy::Size` documents and
677    /// which is why no assertion here compares a file's length to it.
678    #[test]
679    fn the_size_policy_rotates_and_still_waits_for_a_keyframe() {
680        let video_options = TestVideoOptions {
681            width: 160,
682            height: 120,
683            framerate: ffmpeg::Rational::new(15, 1),
684        };
685        let video_source = TestVideoSource::new("video", video_options);
686        let time_base = video_source.time_base();
687        let Some(encoder) = open_h264_encoder(
688            "encoder",
689            SwEncoderOptions {
690                codec: VideoCodec::OpenH264,
691                width: video_options.width,
692                height: video_options.height,
693                time_base,
694                frame_rate: video_options.framerate,
695                bit_rate: 200_000,
696                gop_size: 8,
697                max_b_frames: None,
698            },
699        ) else {
700            eprintln!("skipping: no H.264 encoder available (openh264 or libx264)");
701            return;
702        };
703
704        let dir = std::env::temp_dir();
705        let prefix = format!("segmented_size_test_{}", std::process::id());
706        let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
707        let recorded_paths = paths.clone();
708
709        let mut muxer = SegmentedFileMuxer::create(SegmentPolicy::Size(3_000), move |index| {
710            let path = dir.join(format!("{prefix}_{index:03}.mp4"));
711            recorded_paths.lock().unwrap().push(path.clone());
712            path
713        });
714        muxer.add_stream("video", encoder.parameters(), time_base);
715        let mut sinks = muxer.open().expect("open must succeed");
716        let sink = sinks.pop().expect("exactly one stream was added");
717
718        let pipeline = Pipeline::new("segmented-size-test", video_source, |source, ctx| {
719            let branch = ctx.branch().pipe(encoder).to(sink)?;
720            ctx.attach(source, 0, branch)?;
721            Ok(())
722        })
723        .expect("test pipeline wiring must succeed");
724        pipeline.run().unwrap();
725        std::thread::sleep(Duration::from_secs(3));
726        pipeline.stop();
727        pipeline.bus().log_events();
728
729        let paths = paths.lock().unwrap().clone();
730        assert!(
731            paths.len() >= 2,
732            "expected at least 2 segments, got {}: {paths:?}",
733            paths.len()
734        );
735
736        for path in &paths {
737            let mut input = ffmpeg::format::input(path)
738                .unwrap_or_else(|error| panic!("segment {path:?} must be readable: {error}"));
739            let mut packet = ffmpeg::Packet::empty();
740            if packet.read(&mut input).is_err() {
741                panic!("segment {path:?} has no packets at all");
742            }
743            assert!(
744                packet.is_key(),
745                "segment {path:?}'s first packet must be a keyframe"
746            );
747        }
748
749        for path in &paths {
750            std::fs::remove_file(path).ok();
751        }
752    }
753
754    /// A misrouted `Audio` buffer used to be silently dropped by
755    /// `SegmentedTrackSink::consume`. The `FileMuxer` each segment wraps
756    /// already rejects this via `FileMuxerError::UnsupportedBuffer` — the
757    /// rotation wrapper must surface that instead of swallowing it.
758    #[test]
759    fn rejects_a_buffer_type_the_wrapped_file_muxer_does_not_accept() {
760        let video_options = TestVideoOptions {
761            width: 160,
762            height: 120,
763            framerate: ffmpeg::Rational::new(15, 1),
764        };
765        let video_source = TestVideoSource::new("video", video_options);
766        let time_base = video_source.time_base();
767        let Some(encoder) = open_h264_encoder(
768            "encoder",
769            SwEncoderOptions {
770                codec: VideoCodec::OpenH264,
771                width: video_options.width,
772                height: video_options.height,
773                time_base,
774                frame_rate: video_options.framerate,
775                bit_rate: 200_000,
776                gop_size: 8,
777                max_b_frames: None,
778            },
779        ) else {
780            eprintln!("skipping: no H.264 encoder available (openh264 or libx264)");
781            return;
782        };
783
784        let path = std::env::temp_dir().join(format!(
785            "segmented_mp4_reject_test_{}.mp4",
786            std::process::id()
787        ));
788        let recorded_path = path.clone();
789        let mut muxer = SegmentedFileMuxer::create(
790            SegmentPolicy::Duration(Duration::from_secs(3600)),
791            move |_index| recorded_path.clone(),
792        );
793        muxer.add_stream("video", encoder.parameters(), time_base);
794        let mut sinks = muxer.open().expect("open must succeed");
795        let mut sink = sinks.pop().expect("exactly one stream was added");
796
797        let error = sink
798            .consume(MediaBuffer::Audio(Arc::new(ffmpeg::frame::Audio::empty())))
799            .expect_err("an Audio buffer must be rejected, not silently dropped");
800        assert!(
801            matches!(
802                error,
803                crate::error::Error::FileMuxerError(FileMuxerError::UnsupportedBuffer("Audio"))
804            ),
805            "unexpected error: {error:?}"
806        );
807
808        drop(sink);
809        std::fs::remove_file(&path).ok();
810    }
811
812    /// Regression test against a leaked file handle on the *previous*
813    /// segment specifically: a rotation has to fully close (write the
814    /// trailer, drop the underlying `FileMuxer` for that segment) the
815    /// outgoing file the moment it cuts — not defer that until the whole
816    /// recording later stops. Proven by reading the first segment back
817    /// *while the pipeline is still running* (recording into the second
818    /// one) — if `SegmentGroup::consume_packet` kept anything from the old
819    /// segment alive past the cut, this would find it still unreadable
820    /// (or, on Windows, fail to even open for read at all due to a
821    /// lingering write lock).
822    #[test]
823    fn old_segment_is_released_immediately_not_deferred_until_the_whole_recording_stops() {
824        let video_options = TestVideoOptions {
825            width: 160,
826            height: 120,
827            framerate: ffmpeg::Rational::new(15, 1),
828        };
829        let video_source = TestVideoSource::new("video", video_options);
830        let time_base = video_source.time_base();
831        let Some(encoder) = open_h264_encoder(
832            "encoder",
833            SwEncoderOptions {
834                codec: VideoCodec::OpenH264,
835                width: video_options.width,
836                height: video_options.height,
837                time_base,
838                frame_rate: video_options.framerate,
839                bit_rate: 200_000,
840                gop_size: 8, // ~0.5s @ 15fps — see the other test's own note
841                max_b_frames: None,
842            },
843        ) else {
844            eprintln!("skipping: no H.264 encoder available (openh264 or libx264)");
845            return;
846        };
847
848        let dir = std::env::temp_dir();
849        let prefix = format!("segmented_mp4_release_test_{}", std::process::id());
850        let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
851        let recorded_paths = paths.clone();
852
853        let mut muxer = SegmentedFileMuxer::create(
854            SegmentPolicy::Duration(Duration::from_millis(300)),
855            move |index| {
856                let path = dir.join(format!("{prefix}_{index:03}.mp4"));
857                recorded_paths.lock().unwrap().push(path.clone());
858                path
859            },
860        );
861        muxer.add_stream("video", encoder.parameters(), time_base);
862        let mut sinks = muxer.open().expect("open must succeed");
863        let sink = sinks.pop().expect("exactly one stream was added");
864
865        let pipeline = Pipeline::new("segmented-release-test", video_source, |source, ctx| {
866            let branch = ctx.branch().pipe(encoder).to(sink)?;
867            ctx.attach(source, 0, branch)?;
868            Ok(())
869        })
870        .expect("test pipeline wiring must succeed");
871        pipeline.run().unwrap();
872
873        // Wait (bounded) for at least one rotation — the pipeline is
874        // deliberately still running past this point.
875        let waited = Instant::now();
876        loop {
877            if paths.lock().unwrap().len() >= 2 {
878                break;
879            }
880            assert!(
881                waited.elapsed() < Duration::from_secs(5),
882                "no rotation happened within 5s"
883            );
884            std::thread::sleep(Duration::from_millis(50));
885        }
886
887        let first_segment = paths.lock().unwrap()[0].clone();
888        let mut input = ffmpeg::format::input(&first_segment).unwrap_or_else(|error| {
889            panic!("segment 0 must already be readable while still recording segment 1: {error}")
890        });
891        let mut packet = ffmpeg::Packet::empty();
892        assert!(
893            packet.read(&mut input).is_ok(),
894            "segment 0 must have packets"
895        );
896        drop(input);
897
898        pipeline.stop();
899        pipeline.bus().log_events();
900
901        for path in paths.lock().unwrap().iter() {
902            std::fs::remove_file(path).ok();
903        }
904    }
905}