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}