moq-mux 0.9.7

Media muxers and demuxers for MoQ
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
//! Stateful per-frame fragmenting on one continuous decode timeline.

use std::time::Duration;

use crate::container::Frame;

use super::Fragment;
use super::export::{apply_codec_durations, infer_missing_duration};

/// A frame waiting for its duration, with its decode-timeline boundary preserved.
pub(super) struct Pending {
	frame: Frame,
	group_start: bool,
}

/// Cuts a live stream of frames into one moof+mdat fragment each, on one continuous
/// decode timeline.
///
/// This is the per-frame counterpart to [`Muxer::fragment`](super::Muxer::fragment), for a
/// consumer that stores media per encoded frame: LL-HLS Partial Segments are cut at
/// stored-object boundaries, so one fragment per group would make a whole GOP (~1s) the
/// smallest addressable unit, coarser than a typical `PART-TARGET`. Calling `fragment` once
/// per frame instead re-anchors every fragment at its own frame, which collapses the
/// composition offsets to zero (losing B-frame presentation order) and times every sample
/// by the catalog cadence rather than its real successor.
///
/// The fragmenter owns everything a per-frame caller would otherwise have to reproduce: the
/// `tfdt` decode timeline advances by exactly the durations written into the preceding
/// `trun`s, the moof sequence numbers count up on their own, and a frame that carries no
/// [`duration`](Frame::duration) is held until its successor arrives to time it when that is
/// safe. Audio can always infer this way. Video rejects missing durations by default because
/// presentation timestamps cannot reveal decode duration when frames are reordered; a caller
/// that knows its video is presentation ordered can opt into inference with
/// [`fragment::MissingDuration`](super::fragment::MissingDuration). Feed frames in decode
/// order with [`push`](Self::push) as they arrive, and [`flush`](Self::flush) at the end:
///
/// ```no_run
/// # fn example(muxer: &moq_mux::container::fmp4::Muxer, frames: Vec<moq_mux::container::Frame>) -> moq_mux::Result<()> {
/// let mut fragmenter = muxer.fragmenter(moq_mux::container::fmp4::fragment::Config::default());
/// for frame in frames {
///     for fragment in fragmenter.push(frame)? {
///         // store fragment.data; fragment.duration and .independent describe the Part
///     }
/// }
/// if let Some(fragment) = fragmenter.flush()? {
///     // the final frame, timed by its stated duration or the catalog cadence
/// }
/// # Ok(())
/// # }
/// ```
///
/// Use [`push_group`](Self::push_group) for the first frame after an actual MoQ group
/// boundary. A mid-group sync sample still uses [`push`](Self::push), even though its
/// [`keyframe`](Frame::keyframe) flag is set.
///
/// A frame that states its own duration (CMAF input, an Opus TOC byte, or a publisher that
/// sets [`Frame::duration`]) emits with no lookahead. If the preceding frame was waiting for
/// this one to determine its duration, the same `push` returns both fragments in decode order.
/// A successor passed to [`push_group`](Self::push_group) is never used as a duration bound,
/// since the publisher may have paused across the boundary; the pending frame then takes the
/// catalog cadence, exactly like the last frame of a
/// [`Muxer::fragment`](super::Muxer::fragment) call.
///
/// Each explicit group start re-anchors the decode timeline at its presentation time. When the
/// durations tile the timeline this is a no-op, and when they don't (a publisher pause, a
/// variable frame rate with no stated durations) it keeps `tfdt` truthful instead of letting
/// the composition offsets grow without bound.
pub struct Fragmenter {
	/// The `tfhd` track id, matching the one the muxer's init segment declares.
	pub(super) track_id: u32,
	/// The media timescale fragments are expressed in, matching the init segment.
	pub(super) timescale: moq_net::Timescale,
	/// Fallback duration for a frame with no stated duration and no usable successor.
	pub(super) default_frame: Duration,
	/// Video fragments are independent only at a GOP boundary; audio always is.
	pub(super) is_video: bool,
	/// True for Opus audio, whose packets state their own duration in the TOC byte.
	pub(super) opus: bool,
	/// Whether a missing duration can be inferred from the successor's presentation time.
	pub(super) infer_missing: bool,
	/// A duration-less frame held until its successor arrives to time it.
	pub(super) pending: Option<Pending>,
	/// The next fragment's decode time in ticks at `timescale`; `None` before the first frame.
	pub(super) dts: Option<u64>,
	/// The next moof sequence number.
	pub(super) sequence: u32,
}

impl Fragmenter {
	/// Feed the next frame in decode order, returning every fragment that became ready.
	///
	/// Returns no fragments when the frame must wait for a successor, one in the normal case,
	/// or two when the incoming frame both times a pending frame and states its own duration.
	/// The final pending frame is retrieved with [`flush`](Self::flush).
	pub fn push(&mut self, frame: Frame) -> crate::Result<Vec<Fragment>> {
		self.push_inner(frame, false)
	}

	/// Feed the first frame after an actual MoQ group boundary.
	///
	/// The prior duration-less frame takes the catalog cadence, and this frame re-anchors the
	/// decode timeline at its presentation time. Use [`push`](Self::push) for every other frame,
	/// including a mid-group sync sample.
	pub fn push_group(&mut self, frame: Frame) -> crate::Result<Vec<Fragment>> {
		self.push_inner(frame, true)
	}

	/// Feed one frame with the caller's explicit group-boundary knowledge.
	fn push_inner(&mut self, mut frame: Frame, group_start: bool) -> crate::Result<Vec<Fragment>> {
		// Opus states its duration in the TOC byte; read it now so such a frame never waits.
		apply_codec_durations(std::slice::from_mut(&mut frame), self.opus);
		if !stated(&frame) && !self.infer_missing {
			return Err(super::Error::MissingVideoDuration.into());
		}

		// Build the next state separately so a failure while encoding the second ready frame
		// does not consume the pending frame or advance the timeline without returning output.
		let mut next = self.snapshot();
		let mut fragments = Vec::new();

		if let Some(mut pending) = next.pending.take() {
			// The incoming frame is the successor that times the pending one. A group boundary
			// is never a duration (the publisher may have paused across it), so the pending frame
			// then takes the catalog cadence.
			let successor = (!group_start).then_some(&frame);
			infer_missing_duration(&mut pending.frame, successor, next.default_frame, next.timescale)?;
			fragments.push(next.emit(pending.frame, pending.group_start)?);
		}

		if stated(&frame) {
			fragments.push(next.emit(frame, group_start)?);
		} else {
			next.pending = Some(Pending { frame, group_start });
		}

		*self = next;
		Ok(fragments)
	}

	/// Emit the pending frame at the end of the stream.
	///
	/// A duration-less tail takes the catalog cadence; a stated duration is preserved. Consumes
	/// the fragmenter because there is no successor left to time anything by. Returns `None`
	/// when every pushed frame was already emitted.
	pub fn flush(mut self) -> crate::Result<Option<Fragment>> {
		let Some(mut pending) = self.pending.take() else {
			return Ok(None);
		};
		infer_missing_duration(&mut pending.frame, None, self.default_frame, self.timescale)?;
		Ok(Some(self.emit(pending.frame, pending.group_start)?))
	}

	/// Encode one frame as its own fragment and advance the timeline past it.
	fn emit(&mut self, frame: Frame, group_start: bool) -> crate::Result<Fragment> {
		let pts = super::base_ticks(&frame, self.timescale)?;
		// A group may open after a gap the durations didn't cover, so each group re-anchors
		// the decode timeline at its first frame's presentation time. When
		// the durations tile, the accumulated time already equals it and this is a no-op.
		let dts = match self.dts {
			Some(dts) if !group_start => dts,
			_ => pts,
		};

		let info = super::FragmentInfo {
			track_id: self.track_id,
			timescale: self.timescale,
			sequence_number: self.sequence,
		};
		let ticks = frame
			.duration
			.map(|duration| super::trun_duration(duration, self.timescale))
			.transpose()?
			.unwrap_or(0);
		// Advance by the value the trun stores, so the next tfdt continues exactly what
		// this fragment claimed.
		let next_dts = dts.checked_add(u64::from(ticks)).ok_or(super::Error::PtsOverflow)?;
		let data = super::encode_at(info, dts, std::slice::from_ref(&frame))?;
		self.sequence = self.sequence.wrapping_add(1);
		self.dts = Some(next_dts);

		Ok(Fragment {
			data,
			init: false,
			// Audio has no keyframes, so every audio fragment is independent; video is
			// independent only at a GOP boundary. Matches what the exporter advertises.
			independent: !self.is_video || frame.keyframe,
			// Describe the exact duration written into trun, including its timescale
			// quantization, so playlist metadata and media advance by the same amount.
			duration: f64::from(ticks) / self.timescale.as_u64() as f64,
		})
	}

	/// Copy the small timeline state so a push that produces two fragments commits atomically.
	fn snapshot(&self) -> Self {
		Self {
			track_id: self.track_id,
			timescale: self.timescale,
			default_frame: self.default_frame,
			is_video: self.is_video,
			opus: self.opus,
			infer_missing: self.infer_missing,
			pending: self.pending.as_ref().map(|pending| Pending {
				frame: pending.frame.clone(),
				group_start: pending.group_start,
			}),
			dts: self.dts,
			sequence: self.sequence,
		}
	}
}

/// Whether the frame states a usable duration of its own.
fn stated(frame: &Frame) -> bool {
	frame.duration.is_some_and(|duration| !duration.is_zero())
}

#[cfg(test)]
mod tests {
	use bytes::Bytes;
	use hang::catalog::{AudioConfig, VideoCodec, VideoConfig};
	use moq_net::Timestamp;

	use super::super::Muxer;
	use super::*;

	// A 30 fps Legacy VP8 rendition: no description needed, so the muxer builds without media.
	fn video_muxer() -> Muxer {
		let mut config = VideoConfig::new(VideoCodec::VP8);
		config.framerate = Some(30.0);
		Muxer::video(&config).unwrap()
	}

	// Explicitly assert that duration-less video is presentation ordered before inferring from
	// successor PTS. The default rejects it because a later B-frame can invalidate the gap.
	fn infer_video_durations() -> super::super::fragment::Config {
		super::super::fragment::Config {
			missing_duration: super::super::fragment::MissingDuration::InferFromPresentationTime,
		}
	}

	// One frame at the muxer's own 30_000 timescale, so a frame period is exactly 1000 ticks.
	fn tick_frame(pts: u64, keyframe: bool) -> Frame {
		Frame {
			timestamp: Timestamp::from_scale(pts, 30_000).unwrap(),
			payload: Bytes::from_static(&[0xDE, 0xAD]),
			keyframe,
			duration: Some(Timestamp::from_scale(1_000, 30_000).unwrap()),
		}
	}

	// A duration-less frame, i.e. VFR or Legacy/LOC input.
	fn untimed_frame(pts: u64, keyframe: bool) -> Frame {
		Frame {
			duration: None,
			..tick_frame(pts, keyframe)
		}
	}

	/// The moof sequence number a fragment carries.
	fn sequence(fragment: &Fragment) -> u32 {
		use mp4_atom::DecodeMaybe;

		let mut cursor = std::io::Cursor::new(fragment.data.as_ref());
		while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
			if let mp4_atom::Any::Moof(moof) = atom {
				return moof.mfhd.sequence_number;
			}
		}
		panic!("no moof");
	}

	/// Unwrap the one fragment expected from this push.
	fn one(mut fragments: Vec<Fragment>) -> Fragment {
		assert_eq!(fragments.len(), 1);
		fragments.pop().unwrap()
	}

	// One fragment per frame over a reordered (I, P, B) run: every frame states its duration,
	// so each push emits immediately with no lookahead, tfdt walks the decode timeline, and
	// each cts carries the reorder, so presentation order survives being cut up.
	#[test]
	fn stated_durations_emit_with_no_lookahead() {
		let muxer = video_muxer();
		let timescale = moq_net::Timescale::new(30_000).unwrap();
		let mut fragmenter = muxer.fragmenter(Default::default());
		let input = [tick_frame(0, true), tick_frame(3_000, false), tick_frame(1_000, false)];

		let fragments: Vec<Fragment> = input
			.iter()
			.map(|frame| one(fragmenter.push(frame.clone()).unwrap()))
			.collect();

		let timelines: Vec<_> = fragments.iter().map(|f| super::super::timeline(&f.data)).collect();
		assert_eq!(
			timelines,
			vec![(0, vec![0]), (1_000, vec![2_000]), (2_000, vec![-1_000])],
			"tfdt advances one frame period while cts carries the reorder"
		);

		for (fragment, expected) in fragments.iter().zip(&input) {
			let decoded = super::super::decode(fragment.data.clone(), timescale).unwrap();
			assert_eq!(decoded.len(), 1);
			assert_eq!(decoded[0].timestamp, expected.timestamp, "pts survives the reorder");
		}

		assert!(
			muxer.fragmenter(Default::default()).flush().unwrap().is_none(),
			"nothing pushed, nothing pending"
		);
		assert!(fragmenter.flush().unwrap().is_none(), "every frame was already emitted");
	}

	// A duration-less frame waits one push for its real successor, instead of falling back to
	// the catalog cadence the way a one-frame `Muxer::fragment` call must. This is the drift
	// the fragmenter exists to avoid: a real 1500-tick cadence differs from the catalog's
	// 1000-tick 30 fps default.
	#[test]
	fn a_pending_frame_is_timed_by_its_real_successor() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());
		let input = [
			untimed_frame(0, true),
			untimed_frame(1_500, false),
			untimed_frame(3_000, false),
		];

		assert!(
			fragmenter.push(input[0].clone()).unwrap().is_empty(),
			"no duration and no successor yet"
		);
		let first = one(fragmenter.push(input[1].clone()).unwrap());
		let second = one(fragmenter.push(input[2].clone()).unwrap());
		let last = fragmenter.flush().unwrap().expect("the final pending frame");

		let durations: Vec<_> = [&first, &second, &last]
			.iter()
			.map(|f| super::super::sample_durations(&f.data))
			.collect();
		assert_eq!(
			durations,
			vec![vec![Some(1_500)], vec![Some(1_500)], vec![Some(1_000)]],
			"timed by the successor; only the flushed tail takes the catalog cadence"
		);

		// tfdt advances by exactly what the preceding trun claimed, so the fragments tile.
		let tfdts: Vec<u64> = [&first, &second, &last]
			.iter()
			.map(|f| super::super::timeline(&f.data).0)
			.collect();
		assert_eq!(tfdts, vec![0, 1_500, 3_000]);
	}

	// LOC permits each frame to carry its own timescale. Normalize the successor before
	// subtraction so a scale change does not discard the real PTS gap for the pending frame.
	#[test]
	fn a_successor_with_another_scale_times_the_pending_frame() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());
		assert!(fragmenter.push(untimed_frame(0, true)).unwrap().is_empty());

		let successor = Frame {
			timestamp: Timestamp::from_micros(50_000).unwrap(),
			duration: None,
			..tick_frame(0, false)
		};
		let first = one(fragmenter.push(successor).unwrap());

		assert_eq!(super::super::sample_durations(&first.data), vec![Some(1_500)]);
	}

	// Convert both instants to an exact common scale only after finding that scale. Converting
	// the 1.5-second successor to the predecessor's 1 Hz scale first would erase this gap.
	#[test]
	fn a_coarse_predecessor_does_not_quantize_the_successor_gap() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());
		let first = Frame {
			timestamp: Timestamp::from_secs(1).unwrap(),
			duration: None,
			..tick_frame(0, true)
		};
		let successor = Frame {
			timestamp: Timestamp::from_scale(3, 2).unwrap(),
			duration: None,
			..tick_frame(0, false)
		};

		assert!(fragmenter.push(first).unwrap().is_empty());
		let first = one(fragmenter.push(successor).unwrap());

		assert_eq!(super::super::sample_durations(&first.data), vec![Some(15_000)]);
	}

	// Reduce the rational difference directly against the output scale. The LCM of these
	// legal coprime timestamp scales exceeds the Timestamp range even though the gap is one
	// second and needs only 30,000 output ticks.
	#[test]
	fn a_simple_gap_does_not_require_a_representable_lcm() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());
		let start_scale = 4_000_000_007;
		let end_scale = 4_000_000_009;
		let first = Frame {
			timestamp: Timestamp::from_scale(0, start_scale).unwrap(),
			duration: None,
			..tick_frame(0, true)
		};
		let successor = Frame {
			timestamp: Timestamp::from_scale(end_scale, end_scale).unwrap(),
			duration: None,
			..tick_frame(0, false)
		};

		assert!(fragmenter.push(first).unwrap().is_empty());
		let first = one(fragmenter.push(successor).unwrap());

		assert_eq!(super::super::sample_durations(&first.data), vec![Some(30_000)]);
	}

	// Keep the catalog fallback in its native precision too. Converting 1/30 second to the
	// frame's 1 Hz timestamp scale would turn a positive tail duration into zero.
	#[test]
	fn a_coarse_tail_timestamp_keeps_the_catalog_fallback() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());
		let tail = Frame {
			timestamp: Timestamp::from_secs(1).unwrap(),
			duration: None,
			..tick_frame(0, true)
		};

		assert!(fragmenter.push(tail).unwrap().is_empty());
		let tail = fragmenter.flush().unwrap().expect("the pending tail");

		assert_eq!(super::super::sample_durations(&tail.data), vec![Some(1_000)]);
	}

	// A stated frame that arrives behind a pending frame is already ready too. Returning both
	// keeps the live edge available even when no later frame arrives and flush is not called.
	#[test]
	fn a_stated_frame_emits_with_the_pending_fragment() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());
		assert!(fragmenter.push(untimed_frame(0, true)).unwrap().is_empty());

		let stated = Frame {
			duration: Some(Timestamp::from_scale(1_000, 30_000).unwrap()),
			..tick_frame(1_500, false)
		};
		let fragments = fragmenter.push(stated).unwrap();
		assert_eq!(fragments.len(), 2);
		let first = &fragments[0];
		let second = &fragments[1];

		assert_eq!(super::super::sample_durations(&first.data), vec![Some(1_500)]);
		assert_eq!(super::super::sample_durations(&second.data), vec![Some(1_000)]);
		assert!(fragmenter.flush().unwrap().is_none());
	}

	// The first successor PTS in an I, P, B decode-order run looks like a long I-frame duration,
	// but the later B-frame proves that it was a composition offset. Reject before emitting it.
	#[test]
	fn durationless_reordered_video_is_rejected_by_default() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(Default::default());
		let input = [
			untimed_frame(0, true),
			untimed_frame(3_000, false),
			untimed_frame(1_000, false),
		];

		let err = fragmenter.push(input[0].clone()).unwrap_err();
		assert!(matches!(
			err,
			crate::Error::Cmaf(super::super::Error::MissingVideoDuration)
		));
	}

	// A positive frame duration must remain positive at the chosen output timescale. A zero
	// trun duration would keep tfdt stationary while Fragment::duration still advances.
	#[test]
	fn a_coarse_timescale_rejects_a_sub_tick_duration() {
		let muxer = video_muxer().with_timescale(moq_net::Timescale::SECOND).unwrap();
		let mut fragmenter = muxer.fragmenter(Default::default());

		let err = fragmenter.push(tick_frame(0, true)).unwrap_err();
		assert!(matches!(
			err,
			crate::Error::Cmaf(super::super::Error::SampleDurationTooSmall(1))
		));
	}

	// Flooring every 1/24-second frame to 41 milliseconds would make the stateful decode
	// timeline lose 16 milliseconds per second, so reject an incompatible override.
	#[test]
	fn fragmenter_rejects_an_inexact_sample_duration() {
		let muxer = video_muxer().with_timescale(moq_net::Timescale::MILLI).unwrap();
		let mut fragmenter = muxer.fragmenter(Default::default());
		let input_scale = moq_net::Timescale::new(24).unwrap();
		let frame = Frame {
			timestamp: Timestamp::new(0, input_scale).unwrap(),
			duration: Some(Timestamp::new(1, input_scale).unwrap()),
			..tick_frame(0, true)
		};

		let err = fragmenter.push(frame).unwrap_err();
		assert!(matches!(
			err,
			crate::Error::Cmaf(super::super::Error::SampleDurationInexact(1_000))
		));
	}

	// An EXT-X-PART needs a DURATION and an INDEPENDENT flag. The duration has to be the
	// resolved one written into the trun, not the raw PTS gap: the caller has nothing to read
	// for the stream's last frame and would disagree with the media it is describing.
	#[test]
	fn fragments_carry_the_part_metadata() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());

		let mut fragments = Vec::new();
		for (pts, keyframe) in [(0u64, true), (1_500, false), (3_000, false)] {
			fragments.extend(fragmenter.push(untimed_frame(pts, keyframe)).unwrap());
		}
		fragments.extend(fragmenter.flush().unwrap());

		assert!(!fragments.iter().any(|f| f.init), "these are media fragments");
		assert_eq!(
			fragments.iter().map(|f| f.independent).collect::<Vec<_>>(),
			vec![true, false, false],
			"video is independent only at a GOP boundary"
		);

		// 1500/30000 and 1000/30000: the trun durations, not the 0.05 gap for all three.
		let durations: Vec<_> = fragments.iter().map(|f| (f.duration * 1e6).round() as u64).collect();
		assert_eq!(durations, vec![50_000, 50_000, 33_333], "microseconds");
	}

	// Audio has no keyframes, so every audio part can start a segment. Opus states its own
	// duration in the TOC byte, so no packet ever waits for a successor.
	#[test]
	fn audio_fragments_are_always_independent() {
		let config = AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
		let muxer = Muxer::audio(&config).unwrap();
		let mut fragmenter = muxer.fragmenter(Default::default());

		// Two 20 ms Opus packets; the TOC byte states their duration.
		let packet = Bytes::from_static(&[0x78, 0x00, 0x00, 0x00]);
		for micros in [0u64, 20_000] {
			let frame = Frame {
				timestamp: Timestamp::from_micros(micros).unwrap(),
				payload: packet.clone(),
				keyframe: false,
				duration: None,
			};
			let fragment = one(fragmenter.push(frame).unwrap());
			assert!(fragment.independent, "audio fragments are always independent");
			assert!((fragment.duration - 0.02).abs() < 1e-9, "the 20 ms TOC duration");
		}
	}

	// A keyframe may open a new group, and a group boundary is never a duration: the publisher
	// may have paused across it (the 2405 second sample of moq-dev/moq.pro#814). The pending
	// frame takes the catalog cadence, and the new group re-anchors the decode timeline at its
	// keyframe's presentation time rather than pretending the stream was continuous.
	#[test]
	fn a_new_group_does_not_time_the_pending_frame() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(infer_video_durations());
		// A 40 minute pause between the first group and the second, in 30 kHz ticks.
		let paused_until = 2_405 * 30_000;

		assert!(fragmenter.push(untimed_frame(0, true)).unwrap().is_empty());
		let first = one(fragmenter.push(untimed_frame(1_500, false)).unwrap());
		let second = one(fragmenter.push_group(untimed_frame(paused_until, true)).unwrap());
		let third = fragmenter.flush().unwrap().expect("the keyframe itself");

		assert_eq!(super::super::sample_durations(&first.data), vec![Some(1_500)]);
		assert_eq!(
			super::super::sample_durations(&second.data),
			vec![Some(1_000)],
			"the pause is a discontinuity, not a 2405 second sample"
		);
		assert_eq!(
			super::super::timeline(&third.data).0,
			paused_until,
			"the new group re-anchors at its keyframe's presentation time"
		);
	}

	// A group may contain another sync sample. Its keyframe bit makes it independently
	// decodable, but only push_group is allowed to reset the continuous decode timeline.
	#[test]
	fn a_mid_group_keyframe_does_not_reanchor_the_timeline() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(Default::default());

		let first = one(fragmenter.push(tick_frame(0, true)).unwrap());
		let second = one(fragmenter.push(tick_frame(1_000, false)).unwrap());
		let sync = one(fragmenter.push(tick_frame(5_000, true)).unwrap());
		let next_group = one(fragmenter.push_group(tick_frame(10_000, true)).unwrap());

		assert_eq!(super::super::timeline(&first.data).0, 0);
		assert_eq!(super::super::timeline(&second.data).0, 1_000);
		assert_eq!(super::super::timeline(&sync.data).0, 2_000);
		assert_eq!(super::super::timeline(&next_group.data).0, 10_000);
		assert!(
			sync.independent,
			"the mid-group sync sample stays independently decodable"
		);
	}

	// The mfhd sequence number is informative, but a per-frame consumer still needs each
	// fragment distinguishable, so the fragmenter numbers them itself.
	#[test]
	fn fragments_number_consecutively() {
		let muxer = video_muxer();
		let mut fragmenter = muxer.fragmenter(Default::default());

		let sequences: Vec<u32> = (0..3)
			.map(|i| {
				let fragment = one(fragmenter.push(tick_frame(i * 1_000, i == 0)).unwrap());
				sequence(&fragment)
			})
			.collect();

		assert_eq!(sequences, vec![0, 1, 2]);
	}

	// A single stated-duration frame comes out byte-identical to the self-anchored
	// `Muxer::fragment` encoding: same anchor, same trun, same sequence number.
	#[test]
	fn one_frame_matches_muxer_fragment() {
		let muxer = video_muxer();
		let frame = tick_frame(5_000, true);

		let batch = muxer.fragment(0, std::slice::from_ref(&frame)).unwrap();
		let pushed = one(muxer.fragmenter(Default::default()).push(frame).unwrap());
		assert_eq!(pushed.data, batch);
	}
}