Skip to main content

moq_mux/container/fmp4/
mod.rs

1//! Fragmented MP4 (fMP4 / CMAF).
2//!
3//! A widely supported file format that's also a viable wire format.
4//! Each moq frame carries one moof+mdat fragment, optionally with
5//! several samples packed inside. [`Wire`] is the wire-level
6//! container; [`Import`] parses external fMP4 streams and [`Export`]
7//! produces them.
8
9mod export;
10pub mod fragment;
11mod fragmenter;
12mod import;
13mod muxer;
14
15pub use export::*;
16pub use fragmenter::*;
17pub use import::*;
18pub use muxer::*;
19
20/// Re-export of the [`mp4_atom`] crate, whose types appear in this module's public API.
21/// A major version bump of `mp4_atom` is a breaking change for moq-mux.
22pub use mp4_atom;
23
24#[cfg(test)]
25mod export_test;
26#[cfg(test)]
27mod import_test;
28
29use std::{task::Poll, time::Duration};
30
31use bytes::Bytes;
32use hang::catalog::{AudioCodec, AudioConfig, VideoCodec, VideoConfig};
33use mp4_atom::Atom;
34
35use moq_net::Timestamp;
36
37use crate::container::{Container, Frame};
38
39#[derive(Debug, Clone, thiserror::Error)]
40#[non_exhaustive]
41pub enum Error {
42	#[error("mp4: {0}")]
43	Mp4(std::sync::Arc<mp4_atom::Error>),
44
45	#[error("moq: {0}")]
46	Moq(#[from] moq_net::Error),
47
48	#[error("flac: {0}")]
49	Flac(#[from] crate::codec::flac::Error),
50
51	#[error("opus: {0}")]
52	Opus(#[from] crate::codec::opus::Error),
53
54	#[error("missing keyframe: a group must open on a keyframe")]
55	MissingKeyframe(#[from] crate::container::MissingKeyframe),
56
57	/// An explicit video endpoint precedes its last frame.
58	#[error("{0}")]
59	InvalidEnd(#[from] crate::container::InvalidEnd),
60
61	/// A frame's timestamp sits below the live edge earlier groups reached.
62	#[error("{0}")]
63	TimestampRewind(#[from] crate::container::TimestampRewind),
64
65	#[error("timestamp overflow")]
66	TimestampOverflow(#[from] moq_net::TimeOverflow),
67
68	#[error("no traf in moof")]
69	NoTraf,
70
71	#[error("no tfdt in traf")]
72	NoTfdt,
73
74	#[error("PTS overflow")]
75	PtsOverflow,
76
77	#[error("missing moof")]
78	NoMoof,
79
80	#[error("missing mdat")]
81	NoMdat,
82
83	#[error("missing moov")]
84	NoMoov,
85
86	#[error("no tracks in moov")]
87	NoTracks,
88
89	#[error("multiple tracks in moov, use Trak instead")]
90	MultipleTracks,
91
92	#[error("can't synthesize CMAF init for {0}")]
93	UnsupportedSynthesis(String),
94
95	#[error("subtitle tracks are not supported")]
96	UnsupportedSubtitle,
97
98	#[error("unknown track handler: {0:?}")]
99	UnknownTrackHandler([u8; 4]),
100
101	#[error("missing codec")]
102	MissingCodec,
103
104	#[error("multiple codecs")]
105	MultipleCodecs,
106
107	#[error("unknown codec: {0:?}")]
108	UnknownCodec(mp4_atom::FourCC),
109
110	#[error("unsupported codec: {0:?}")]
111	UnsupportedCodec(Box<mp4_atom::Codec>),
112
113	#[error("unsupported codec: MPEG2")]
114	UnsupportedMpeg2,
115
116	/// An AAC sample entry needs DecoderSpecificInfo to identify its codec profile.
117	#[error("AAC sample entry missing DecoderSpecificInfo")]
118	MissingDecoderSpecific,
119
120	#[error("duplicate moof")]
121	DuplicateMoof,
122
123	/// A second moov would re-declare the track set the first one already published.
124	#[error("duplicate moov")]
125	DuplicateMoov,
126
127	#[error("missing trun")]
128	MissingTrun,
129
130	#[error("missing tfdt")]
131	MissingTfdt,
132
133	#[error("video codec {0} needs a description (codec config record) to synthesize a CMAF init")]
134	MissingVideoDescription(String),
135
136	#[error("video track {0} missing in catalog")]
137	MissingVideoTrack(String),
138
139	/// A synthesized video track has no usable encoded dimensions.
140	#[error("missing video dimensions for codec: {0}")]
141	MissingVideoDimensions(String),
142
143	#[error("audio track {0} missing in catalog")]
144	MissingAudioTrack(String),
145
146	#[error("invalid data offset")]
147	InvalidDataOffset,
148
149	#[error("unknown track {0}")]
150	UnknownTrack(u32),
151
152	#[error("no keyframe at start of group")]
153	NoKeyframe,
154
155	#[error("track sample range {start}..{end} is out of bounds of mdat (len {len})")]
156	SampleRangeOutOfBounds { start: usize, end: usize, len: usize },
157
158	#[error("no catalog snapshot")]
159	NoCatalogSnapshot,
160
161	#[error("encode_fragment called with no frames")]
162	NoFrames,
163
164	#[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")]
165	MissingAudioDescription(String),
166
167	#[error("multi-sample fragment has a non-final sample with no duration; DTS is unrecoverable")]
168	MissingSampleDuration,
169
170	/// `mdhd.timescale` is a 32-bit field, so a larger scale would reach the init segment
171	/// truncated while the fragments kept the full value, putting them on different timelines.
172	#[error("timescale {0} does not fit the 32-bit mdhd field")]
173	TimescaleTooLarge(u64),
174
175	/// A `Cmaf` rendition's init passes through from the catalog at its own scale, so
176	/// [`Muxer::with_timescale`] can't move it without desynchronising it from the fragments.
177	#[error("a CMAF rendition's timescale comes from its init segment and can't be overridden")]
178	TimescaleOverride,
179
180	/// A sample duration is a 32-bit `trun` field. A larger value would wrap in the media
181	/// while the fragment metadata kept the full duration, putting them on different timelines.
182	#[error("sample duration {0} does not fit the 32-bit trun field")]
183	SampleDurationTooLarge(u64),
184
185	/// A positive sample duration must occupy at least one tick in the output timescale.
186	#[error("sample duration is shorter than one tick at timescale {0}")]
187	SampleDurationTooSmall(u64),
188
189	/// Repeatedly flooring fractional sample ticks would make the decode timeline drift.
190	#[error("sample duration is not exactly representable at timescale {0}")]
191	SampleDurationInexact(u64),
192
193	/// Presentation timestamps do not reveal decode duration for reordered video.
194	#[error("duration-less video needs stated durations or presentation-ordered inference")]
195	MissingVideoDuration,
196
197	/// Each fragment restates its decode time, so a stale one lands two different samples on
198	/// the same timestamp. ffmpeg writes one when `frag_every_frame` interleaves audio and
199	/// video; `-frag_duration` cuts just as finely and stamps the fragments correctly.
200	#[error(
201		"track {track}: fragment decode time {decode_time:?} does not advance past {previous:?}; \
202		 ffmpeg repeats a tfdt when frag_every_frame interleaves audio and video, use -frag_duration instead"
203	)]
204	NonMonotonicDecodeTime {
205		/// The track containing the rejected fragment.
206		track: u32,
207		/// The rejected fragment's decode time in the track's timescale.
208		decode_time: moq_net::Timestamp,
209		/// The preceding fragment's decode time in the track's timescale.
210		previous: moq_net::Timestamp,
211	},
212}
213
214impl From<mp4_atom::Error> for Error {
215	fn from(err: mp4_atom::Error) -> Self {
216		Error::Mp4(std::sync::Arc::new(err))
217	}
218}
219
220pub type Result<T> = std::result::Result<T, Error>;
221
222/// The media carried by a CMAF track.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub(crate) enum Kind {
225	Audio,
226	Video,
227}
228
229impl Kind {
230	fn from_handler(handler: mp4_atom::FourCC) -> Result<Self> {
231		match handler.as_ref() {
232			b"soun" => Ok(Self::Audio),
233			b"vide" => Ok(Self::Video),
234			b"sbtl" => Err(Error::UnsupportedSubtitle),
235			_ => Err(Error::UnknownTrackHandler(handler.into())),
236		}
237	}
238
239	/// The publisher priority for this kind of media.
240	fn priority(self) -> u8 {
241		match self {
242			Self::Video => hang::catalog::PRIORITY.video,
243			Self::Audio => hang::catalog::PRIORITY.audio,
244		}
245	}
246}
247
248/// CMAF container: encodes/decodes a single track's moof+mdat fragments.
249///
250/// Build from a CMAF init segment with [`Wire::from_init`], or wrap a
251/// pre-extracted [`mp4_atom::Trak`] directly with [`Wire::new`].
252///
253/// The [`mp4_atom::Trak`] is heap-allocated so that embedding `Wire`
254/// in other enums (e.g. [`catalog::hang::Container`](crate::catalog::hang::Container))
255/// doesn't bloat unrelated variants.
256pub struct Wire {
257	trak: Box<mp4_atom::Trak>,
258}
259
260impl Wire {
261	/// Wrap an already-parsed track.
262	pub fn new(trak: mp4_atom::Trak) -> Self {
263		Self { trak: Box::new(trak) }
264	}
265
266	/// Parse a CMAF init segment (ftyp+moov), extracting the single track.
267	pub fn from_init(init_data: &[u8]) -> Result<Self> {
268		use mp4_atom::DecodeMaybe;
269
270		let mut cursor = std::io::Cursor::new(init_data);
271		while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
272			if let mp4_atom::Any::Moov(mut moov) = atom {
273				return match moov.trak.len() {
274					1 => Ok(Self::new(moov.trak.remove(0))),
275					0 => Err(Error::NoTracks),
276					_ => Err(Error::MultipleTracks),
277				};
278			}
279		}
280		Err(Error::NoMoov)
281	}
282
283	pub fn trak(&self) -> &mp4_atom::Trak {
284		&self.trak
285	}
286
287	/// The media kind declared by the track handler.
288	fn kind(&self) -> Result<Kind> {
289		Kind::from_handler(self.trak.mdia.hdlr.handler)
290	}
291}
292
293impl Container for Wire {
294	fn kind(&self) -> crate::container::Kind {
295		match self.trak.mdia.hdlr.handler.as_ref() {
296			b"soun" => crate::container::Kind::Audio,
297			b"vide" => crate::container::Kind::Video,
298			_ => crate::container::Kind::Data,
299		}
300	}
301
302	type Error = Error;
303
304	fn write(&self, group: &mut moq_net::group::Producer, frames: &[Frame]) -> std::result::Result<(), Self::Error> {
305		let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
306		let track_id = self.trak.tkhd.track_id;
307		encode(
308			group,
309			frames,
310			FragmentInfo {
311				timescale,
312				track_id,
313				sequence_number: group.frame_count() as u32,
314				kind: self.kind()?,
315			},
316		)
317	}
318
319	fn poll_read(
320		&self,
321		group: &mut moq_net::group::Consumer,
322		waiter: &kio::Waiter,
323	) -> Poll<std::result::Result<Option<Vec<Frame>>, Self::Error>> {
324		use std::task::ready;
325
326		let Some(frame) = ready!(group.poll_read_frame(waiter)?) else {
327			return Poll::Ready(Ok(None));
328		};
329
330		let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
331		Poll::Ready(Ok(Some(decode(frame.payload, timescale, self.kind()?)?)))
332	}
333}
334
335/// Decode one moof+mdat fragment into its samples.
336///
337/// `Kind::Audio` says the track's samples are all independently decodable. Packagers flag every
338/// audio sample a sync sample, which as a [`Frame::keyframe`] would open a group per sample,
339/// so an audio sample decodes with the bit clear and the consumer marks the group start.
340pub(crate) fn decode(data: Bytes, timescale: moq_net::Timescale, kind: Kind) -> Result<Vec<Frame>> {
341	use mp4_atom::DecodeMaybe;
342
343	let mut cursor = std::io::Cursor::new(&data);
344	let mut moof = None;
345	let mut mdat_data = None;
346
347	while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
348		match atom {
349			mp4_atom::Any::Moof(m) => moof = Some(m),
350			mp4_atom::Any::Mdat(m) => mdat_data = Some(m.data),
351			_ => {}
352		}
353	}
354
355	let moof = moof.ok_or(Error::NoMoof)?;
356	let mdat_data = mdat_data.ok_or(Error::NoMdat)?;
357	let traf = moof.traf.first().ok_or(Error::NoTraf)?;
358	let tfdt = traf.tfdt.as_ref().ok_or(Error::NoTfdt)?;
359	let base_dts = tfdt.base_media_decode_time;
360
361	let default_size = traf.tfhd.default_sample_size;
362	let default_duration = traf.tfhd.default_sample_duration;
363
364	// DTS is reconstructed by accumulating each sample's duration. A non-final sample
365	// with no resolvable duration would leave every following sample stuck at the same
366	// DTS, silently collapsing their timestamps, so reject that fragment instead.
367	let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum();
368
369	let mut frames = Vec::new();
370	let mut offset = 0usize;
371	let mut dts = base_dts;
372	let mut sample_index = 0usize;
373
374	for trun in &traf.trun {
375		for entry in &trun.entries {
376			let size = entry.size.or(default_size).unwrap_or(0) as usize;
377			let end = offset + size;
378
379			if end > mdat_data.len() {
380				return Err(Error::SampleRangeOutOfBounds {
381					start: offset,
382					end,
383					len: mdat_data.len(),
384				});
385			}
386
387			let cts = entry.cts.unwrap_or_default() as i64;
388			let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?;
389			// Preserve the fmp4 track's native scale through the pipeline.
390			let timestamp = Timestamp::new(pts, timescale)?;
391			let payload = Bytes::copy_from_slice(&mdat_data[offset..end]);
392			let flags = entry.flags.unwrap_or(0);
393			// depends_on_no_other (bits 24-25 == 0x2) means keyframe
394			let keyframe = kind == Kind::Video && (flags >> 24) & 0x3 == 0x2;
395
396			// Carry the sample-duration through at the track's scale when present, so
397			// the jitter buffer can use it and an exporter can write it back.
398			let sample_duration = entry.duration.or(default_duration).filter(|d| *d != 0);
399
400			// The last sample needs no duration (nothing follows it to time), but any
401			// earlier sample without one makes the rest of the fragment's DTS ambiguous.
402			let is_last = sample_index + 1 == total_samples;
403			if sample_duration.is_none() && !is_last {
404				return Err(Error::MissingSampleDuration);
405			}
406
407			let duration = sample_duration
408				.map(|d| Timestamp::new(d as u64, timescale))
409				.transpose()?;
410
411			frames.push(Frame {
412				timestamp,
413				payload,
414				keyframe,
415				duration,
416			});
417
418			offset = end;
419			dts += sample_duration.unwrap_or(0) as u64;
420			sample_index += 1;
421		}
422	}
423
424	Ok(frames)
425}
426
427fn encode(group: &mut moq_net::group::Producer, frames: &[Frame], info: FragmentInfo) -> Result<()> {
428	if frames.is_empty() {
429		return Ok(());
430	}
431
432	let bytes = encode_fragment(info, frames)?;
433	// The fragment may carry several samples; the net frame's timestamp is the
434	// fragment's earliest presentation time so a relay can order it.
435	let mut writer = group.create_frame(moq_net::frame::Info {
436		size: bytes.len() as u64,
437		timestamp: frames[0].timestamp,
438	})?;
439	writer.write(bytes)?;
440	writer.finish()?;
441
442	Ok(())
443}
444
445/// Which track a fragment belongs to, and where it sits in that track.
446///
447/// Bundled rather than passed positionally because `track_id` and `sequence_number` are both
448/// `u32`, so a swapped pair would still compile.
449#[derive(Debug, Clone, Copy)]
450pub(crate) struct FragmentInfo {
451	/// The `tfhd` track id, which must match the one the init segment declares.
452	pub track_id: u32,
453	/// The track's media timescale, which every timestamp is re-expressed at.
454	pub timescale: moq_net::Timescale,
455	/// The `mfhd` sequence number, informative only.
456	pub sequence_number: u32,
457	/// Audio tracks carry only sync samples: the [`Frame::keyframe`]
458	/// bit only marks the group start there, so it must not decide the sample flags.
459	pub kind: Kind,
460}
461
462/// Encode a single-traf moof+mdat fragment anchored at its own first frame.
463///
464/// The fragment stands alone: its `tfdt` is `frames[0]`'s presentation time, so it decodes
465/// without reference to whatever came before it. To cut a stream into one fragment per
466/// frame, on a single continuous decode timeline, use [`Fragmenter`].
467///
468/// Returns an empty `Bytes` when `frames` is empty.
469pub(crate) fn encode_fragment(info: FragmentInfo, frames: &[Frame]) -> Result<Bytes> {
470	let Some(first) = frames.first() else {
471		return Ok(Bytes::new());
472	};
473	encode_at(info, base_ticks(first, info.timescale)?, frames)
474}
475
476/// A frame's presentation time as a tick count at the track's timescale.
477///
478/// When the importer preserved the source scale (the common passthrough case) this is a no-op;
479/// otherwise it's a single rescale rather than the legacy `micros * scale / 1_000_000`
480/// round-trip.
481fn base_ticks(frame: &Frame, timescale: moq_net::Timescale) -> Result<u64> {
482	timestamp_ticks(frame.timestamp, timescale)
483}
484
485/// Round an absolute timestamp to its nearest tick at the output scale.
486fn timestamp_ticks(timestamp: Timestamp, timescale: moq_net::Timescale) -> Result<u64> {
487	let source_scale = u128::from(timestamp.scale().as_u64());
488	let scaled = u128::from(timestamp.value()) * u128::from(timescale.as_u64());
489	let ticks = (scaled + source_scale / 2) / source_scale;
490	u64::try_from(ticks).map_err(|_| Error::PtsOverflow)
491}
492
493/// Encode a single-traf moof+mdat fragment whose decode timeline starts at `base_dts` ticks.
494///
495/// Performs the two-pass encoding required by ISO/IEC 14496-12: encode once
496/// to learn the moof size, then again with `trun.data_offset` pointing past
497/// the moof and mdat header.
498///
499/// `base_dts` is already at the track's timescale, so it is the same unit the `trun` sample
500/// durations are written in. That's what lets [`Fragmenter`] carry a timeline across
501/// calls without a rounding step between them.
502///
503/// Frames arrive in decode order carrying presentation timestamps. DTS is authored by
504/// accumulating sample durations from `base_dts`, and each sample stores `PTS - DTS` as its
505/// signed composition offset, so a reordered frame keeps its presentation time even when the
506/// fragment holds a single sample.
507///
508/// Returns an empty `Bytes` when `frames` is empty.
509fn encode_at(info: FragmentInfo, base_dts: u64, frames: &[Frame]) -> Result<Bytes> {
510	let FragmentInfo {
511		track_id,
512		timescale,
513		sequence_number,
514		kind,
515	} = info;
516
517	use mp4_atom::Encode;
518
519	if frames.is_empty() {
520		return Ok(Bytes::new());
521	}
522
523	let mut dts = base_dts;
524
525	let entries: Vec<_> = frames
526		.iter()
527		.map(|f| {
528			let flags = if kind == Kind::Audio || f.keyframe {
529				0x0200_0000
530			} else {
531				0x0001_0000
532			};
533			// Write the sample-duration back at the track's scale when we know it, so
534			// fMP4 -> fMP4 round-trips it. Frames without one stay byte-identical.
535			let duration = f.duration.map(|d| trun_duration(d, timescale)).transpose()?;
536			let pts = i128::from(timestamp_ticks(f.timestamp, timescale)?);
537			let cts = pts - i128::from(dts);
538			let cts = i32::try_from(cts).map_err(|_| Error::PtsOverflow)?;
539
540			// Frame timestamps are PTS while sample order is decode order. Author DTS
541			// by accumulating durations and store PTS-DTS as the signed CTS.
542			if let Some(duration) = duration {
543				dts = dts.checked_add(u64::from(duration)).ok_or(Error::PtsOverflow)?;
544			}
545
546			Ok(mp4_atom::TrunEntry {
547				duration,
548				size: Some(f.payload.len() as u32),
549				flags: Some(flags),
550				cts: (cts != 0).then_some(cts),
551			})
552		})
553		.collect::<Result<_>>()?;
554
555	let mdat_data: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
556
557	let build_moof = |data_offset| mp4_atom::Moof {
558		mfhd: mp4_atom::Mfhd { sequence_number },
559		traf: vec![mp4_atom::Traf {
560			tfhd: mp4_atom::Tfhd {
561				track_id,
562				..Default::default()
563			},
564			tfdt: Some(mp4_atom::Tfdt {
565				base_media_decode_time: base_dts,
566			}),
567			trun: vec![mp4_atom::Trun {
568				data_offset: Some(data_offset),
569				entries: entries.clone(),
570			}],
571			..Default::default()
572		}],
573	};
574
575	// First pass to learn the moof size.
576	let mut buf = Vec::new();
577	build_moof(0).encode(&mut buf)?;
578	let moof_size = buf.len();
579
580	// Second pass with data_offset = moof_size + 8 (mdat header).
581	buf.clear();
582	build_moof((moof_size + 8) as i32).encode(&mut buf)?;
583
584	let mdat = mp4_atom::Mdat { data: mdat_data };
585	mdat.encode(&mut buf)?;
586
587	Ok(Bytes::from(buf))
588}
589
590/// Convert a duration to the exact value stored in a 32-bit `trun` sample-duration field.
591fn trun_duration(duration: Timestamp, timescale: moq_net::Timescale) -> Result<u32> {
592	let source_timescale = duration.scale().as_u64();
593	let output_timescale = timescale.as_u64();
594	let scaled = u128::from(duration.value()) * u128::from(output_timescale);
595	let ticks = scaled / u128::from(source_timescale);
596	if !duration.is_zero() && ticks == 0 {
597		return Err(Error::SampleDurationTooSmall(timescale.as_u64()));
598	}
599	if scaled % u128::from(source_timescale) != 0 {
600		return Err(Error::SampleDurationInexact(output_timescale));
601	}
602	u32::try_from(ticks).map_err(|_| Error::SampleDurationTooLarge(u64::try_from(ticks).unwrap_or(u64::MAX)))
603}
604
605/// Synthesize a CMAF `Trak` for a video rendition that has no init segment.
606///
607/// Used by the fMP4 exporter when its source is a `Container::Legacy` track
608/// (Avc3/Hev1/etc. importers that publish raw codec bitstreams). H.264/H.265
609/// need their out-of-band configuration record (`description`), e.g. because the
610/// Avc1 / Hvc1 transform has finished building it from inline parameter sets.
611/// VP8 carries no out-of-band config, so `description` is `None` for it.
612pub(crate) fn synthesize_video_trak(
613	track_id: u32,
614	timescale: u64,
615	config: &VideoConfig,
616	description: Option<&[u8]>,
617) -> Result<mp4_atom::Trak> {
618	if !matches!(
619		config.codec,
620		VideoCodec::H264(_) | VideoCodec::H265(_) | VideoCodec::AV1(_) | VideoCodec::VP8 | VideoCodec::VP9(_)
621	) {
622		return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", config.codec)));
623	}
624
625	let width = u16::try_from(
626		config
627			.coded_width
628			.ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?,
629	)
630	.map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?;
631	let height = u16::try_from(
632		config
633			.coded_height
634			.ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?,
635	)
636	.map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?;
637	if width == 0 || height == 0 {
638		return Err(Error::MissingVideoDimensions(config.codec.to_string()));
639	}
640	let visual = mp4_atom::Visual {
641		data_reference_index: 1,
642		width,
643		height,
644		..Default::default()
645	};
646
647	// Codecs that carry an out-of-band config record require `description`.
648	let require_description = || description.ok_or_else(|| Error::MissingVideoDescription(config.codec.to_string()));
649
650	let sample_entry = match &config.codec {
651		VideoCodec::H264(_) => {
652			let mut cursor = std::io::Cursor::new(require_description()?);
653			let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::from)?;
654			mp4_atom::Codec::from(mp4_atom::Avc1 {
655				visual,
656				avcc,
657				..Default::default()
658			})
659		}
660		VideoCodec::H265(h265) => {
661			let mut cursor = std::io::Cursor::new(require_description()?);
662			let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::from)?;
663			// `in_band` (catalog) ↔ hev1 sample entry; otherwise hvc1.
664			if h265.in_band {
665				mp4_atom::Codec::from(mp4_atom::Hev1 {
666					visual,
667					hvcc,
668					..Default::default()
669				})
670			} else {
671				mp4_atom::Codec::from(mp4_atom::Hvc1 {
672					visual,
673					hvcc,
674					..Default::default()
675				})
676			}
677		}
678		VideoCodec::AV1(av1) => mp4_atom::Codec::from(mp4_atom::Av01 {
679			visual,
680			av1c: crate::codec::av1::av1c_from_av1(av1),
681			..Default::default()
682		}),
683		VideoCodec::VP8 => mp4_atom::Codec::from(mp4_atom::Vp08 {
684			visual,
685			vpcc: crate::codec::vp8::vpcc(),
686			..Default::default()
687		}),
688		VideoCodec::VP9(vp9) => mp4_atom::Codec::from(mp4_atom::Vp09 {
689			visual,
690			vpcc: crate::codec::vp9::vpcc(vp9),
691			..Default::default()
692		}),
693		other => unreachable!("unsupported codecs rejected before geometry synthesis: {other:?}"),
694	};
695
696	Ok(build_video_trak(
697		track_id,
698		mdhd_timescale(timescale)?,
699		sample_entry,
700		width,
701		height,
702	))
703}
704
705/// Synthesize a CMAF `Trak` for an audio rendition that has no init segment.
706pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &AudioConfig) -> Result<mp4_atom::Trak> {
707	use mp4_atom::Decode;
708
709	let audio = mp4_atom::Audio {
710		data_reference_index: 1,
711		channel_count: config.channel_count as u16,
712		sample_size: 16,
713		sample_rate: mp4_atom::FixedPoint::from(config.sample_rate as u16),
714	};
715
716	let sample_entry = match &config.codec {
717		AudioCodec::Opus => {
718			let head = match &config.description {
719				Some(description) => {
720					let head = crate::codec::opus::Config::parse(&mut description.as_ref())?;
721					// dOps shares OpusHead's family 0 layout; a mapping table would need writing too.
722					if head.mapping_family != 0 {
723						return Err(crate::codec::opus::Error::UnsupportedMappingFamily(head.mapping_family).into());
724					}
725					head
726				}
727				None => crate::codec::opus::Config::new(config.sample_rate, config.channel_count),
728			};
729			mp4_atom::Codec::from(mp4_atom::Opus {
730				audio,
731				dops: mp4_atom::Dops {
732					output_channel_count: config.channel_count as u8,
733					pre_skip: head.pre_skip,
734					input_sample_rate: head.sample_rate,
735					output_gain: head.output_gain,
736				},
737				btrt: None,
738			})
739		}
740		AudioCodec::AAC(_) => {
741			// The catalog `description` is the AudioSpecificConfig (set by the TS
742			// importer via aac::Config::encode, or carried over from a CMAF source).
743			// mp4_atom models the esds DecoderSpecific as the parsed
744			// AudioSpecificConfig, so decode the blob back into that shape.
745			let description = config
746				.description
747				.as_ref()
748				.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
749			let mut cursor = std::io::Cursor::new(description.as_ref());
750			let dec_specific = mp4_atom::esds::DecoderSpecific::decode(&mut cursor)?;
751
752			// Safari and AVFoundation reject an AAC track whose DecoderConfigDescriptor is all
753			// zeros: they endOfStream("decode") on the *init* append, which leaves the
754			// ManagedMediaSource ended and every later audio and video append failing. The
755			// catalog bitrate is optional and real publishers omit it, so fall back to the
756			// AAC-LC values ffmpeg and the iTunes encoders write. A zero or wider-than-u32
757			// bitrate takes the fallback as well, since writing it back would rebuild the
758			// all-zero descriptor this exists to avoid.
759			let bitrate = config.bitrate.and_then(|b| u32::try_from(b).ok()).filter(|b| *b > 0);
760			let (max_bitrate, avg_bitrate) = match bitrate {
761				Some(bitrate) => (bitrate, bitrate),
762				None => (256_000, 128_000),
763			};
764			mp4_atom::Codec::from(mp4_atom::Mp4a {
765				audio,
766				esds: mp4_atom::Esds {
767					es_desc: mp4_atom::esds::EsDescriptor {
768						// ISO/IEC 14496-14 §5.6: ES_ID is 0 in an MP4 file (the track id carries identity).
769						es_id: 0,
770						dec_config: mp4_atom::esds::DecoderConfig {
771							object_type_indication: 0x40, // MPEG-4 AAC
772							stream_type: 0x05,            // audio
773							up_stream: 0,
774							// 24 KiB, the decoder buffer those same encoders declare.
775							buffer_size_db: mp4_atom::u24::from([0x00, 0x60, 0x00]),
776							max_bitrate,
777							avg_bitrate,
778							dec_specific: Some(dec_specific),
779						},
780						sl_config: Default::default(),
781					},
782				},
783				btrt: None,
784				taic: None,
785			})
786		}
787		AudioCodec::Flac => {
788			// The catalog `description` is the FLAC header (`fLaC` marker + STREAMINFO).
789			// Parse it back into the STREAMINFO fields the `dfLa` box stores.
790			let description = config
791				.description
792				.as_ref()
793				.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
794			let info = crate::codec::flac::Config::parse(&mut description.as_ref())?;
795
796			let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo {
797				minimum_block_size: info.min_block_size,
798				maximum_block_size: info.max_block_size,
799				// Frame sizes are 24-bit; clamp defensively so the conversion can't fail.
800				minimum_frame_size: info.min_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
801				maximum_frame_size: info.max_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
802				sample_rate: info.sample_rate,
803				num_channels_minus_one: info.channel_count.saturating_sub(1) as u8,
804				bits_per_sample_minus_one: info.bits_per_sample.saturating_sub(1) as u8,
805				number_of_interchannel_samples: info.total_samples,
806				md5_checksum: info.md5.to_vec(),
807			};
808
809			mp4_atom::Codec::from(mp4_atom::Flac {
810				audio,
811				dfla: mp4_atom::Dfla {
812					blocks: vec![stream_info],
813				},
814			})
815		}
816		other => return Err(Error::UnsupportedSynthesis(format!("audio codec {:?}", other))),
817	};
818
819	Ok(build_audio_trak(track_id, mdhd_timescale(timescale)?, sample_entry))
820}
821
822/// All-ones: the ISO/IEC 14496-12 spelling of "duration not known up front", which is always
823/// the case for the live fragmented streams synthesized here. Zero reads as an empty file to a
824/// strict parser, and VLC refuses to play one.
825const UNKNOWN_DURATION: u64 = u64::MAX;
826
827fn build_video_trak(
828	track_id: u32,
829	timescale: u32,
830	sample_entry: mp4_atom::Codec,
831	width: u16,
832	height: u16,
833) -> mp4_atom::Trak {
834	mp4_atom::Trak {
835		tkhd: mp4_atom::Tkhd {
836			track_id,
837			enabled: true,
838			// track_in_movie. A player is entitled to skip a track the presentation doesn't
839			// claim, and the flags field is 0x000001 rather than 0x000003 without it.
840			in_movie: true,
841			duration: UNKNOWN_DURATION,
842			width: mp4_atom::FixedPoint::from(width),
843			height: mp4_atom::FixedPoint::from(height),
844			..Default::default()
845		},
846		mdia: build_mdia(timescale, b"vide", true, sample_entry),
847		..Default::default()
848	}
849}
850
851fn build_audio_trak(track_id: u32, timescale: u32, sample_entry: mp4_atom::Codec) -> mp4_atom::Trak {
852	mp4_atom::Trak {
853		tkhd: mp4_atom::Tkhd {
854			track_id,
855			enabled: true,
856			in_movie: true,
857			duration: UNKNOWN_DURATION,
858			// Full volume (8.8 fixed point). The default is 0, which is a muted track, and
859			// only an audio track carries a meaningful value.
860			volume: mp4_atom::FixedPoint::from(1),
861			..Default::default()
862		},
863		mdia: build_mdia(timescale, b"soun", false, sample_entry),
864		..Default::default()
865	}
866}
867
868/// Assemble a fragmented init segment (ftyp + moov) around already-built traks.
869///
870/// `ftyp` is the one a passed-through CMAF init carried, if any; otherwise a plain `isom` one
871/// is synthesized. The `mvhd` is ours either way, so it declares the unknown duration and the
872/// movie timescale of the assembled init rather than of whatever source a trak came from.
873/// [`extract_init`] normalizes a passed-through trak to match.
874pub(crate) fn encode_init(
875	ftyp: Option<mp4_atom::Ftyp>,
876	mut traks: Vec<mp4_atom::Trak>,
877	trexs: Vec<mp4_atom::Trex>,
878) -> Result<Bytes> {
879	use mp4_atom::Encode;
880
881	let ftyp = ftyp.unwrap_or(mp4_atom::Ftyp {
882		major_brand: b"isom".into(),
883		minor_version: 0x200,
884		compatible_brands: vec![b"isom".into(), b"iso6".into(), b"mp41".into()],
885	});
886	// Hang places every decoded Opus sample one pre-skip before its packet timestamp.
887	// dOps alone describes decoder priming; ISO BMFF needs an edit to express that
888	// presentation shift. Rebuild it after the output track timescales are settled.
889	for trak in &mut traks {
890		if let [mp4_atom::Codec::Opus(opus)] = trak.mdia.minf.stbl.stsd.codecs.as_slice() {
891			let skip = u64::from(opus.dops.pre_skip) * u64::from(trak.mdia.mdhd.timescale) / 48_000;
892			trak.edts = (skip != 0).then(|| mp4_atom::Edts {
893				elst: Some(mp4_atom::Elst {
894					entries: vec![mp4_atom::ElstEntry {
895						// A live edit's duration is implicit in the fragments that follow.
896						segment_duration: 0,
897						media_time: Some(skip),
898						media_rate: mp4_atom::FixedPoint::from(1),
899					}],
900				}),
901			});
902		}
903	}
904	let timescale = traks.first().map(|t| t.mdia.mdhd.timescale).unwrap_or(1000);
905	let next_track_id = traks.iter().map(|t| t.tkhd.track_id).max().unwrap_or(0) + 1;
906
907	let moov = mp4_atom::Moov {
908		mvhd: mp4_atom::Mvhd {
909			timescale,
910			duration: UNKNOWN_DURATION,
911			// Normal playback rate and full volume (16.16 and 8.8 fixed point). Both default
912			// to 0, which declares a stopped, muted presentation.
913			rate: mp4_atom::FixedPoint::from(1),
914			volume: mp4_atom::FixedPoint::from(1),
915			// The next id a track could take, so it must be past every one already present.
916			next_track_id,
917			..Default::default()
918		},
919		trak: traks,
920		mvex: (!trexs.is_empty()).then(|| mp4_atom::Mvex {
921			trex: trexs,
922			..Default::default()
923		}),
924		..Default::default()
925	};
926
927	let mut buf = Vec::new();
928	ftyp.encode(&mut buf)?;
929	moov.encode(&mut buf)?;
930	Ok(Bytes::from(buf))
931}
932
933/// Narrow a media timescale to the 32-bit `mdhd` field, rejecting what would truncate.
934///
935/// `moq_net::Timescale` permits the whole QUIC varint range, so a caller-supplied scale can be
936/// wider than the field. Truncating would put the init segment and the fragments on different
937/// timelines, silently, so refuse instead.
938fn mdhd_timescale(timescale: u64) -> Result<u32> {
939	u32::try_from(timescale).map_err(|_| Error::TimescaleTooLarge(timescale))
940}
941
942fn build_mdia(timescale: u32, handler: &[u8; 4], is_video: bool, sample_entry: mp4_atom::Codec) -> mp4_atom::Mdia {
943	mp4_atom::Mdia {
944		mdhd: mp4_atom::Mdhd {
945			timescale,
946			..Default::default()
947		},
948		hdlr: mp4_atom::Hdlr {
949			handler: mp4_atom::FourCC::new(handler),
950			name: String::new(),
951		},
952		minf: mp4_atom::Minf {
953			vmhd: is_video.then(mp4_atom::Vmhd::default),
954			smhd: (!is_video).then(mp4_atom::Smhd::default),
955			dinf: mp4_atom::Dinf {
956				dref: mp4_atom::Dref {
957					urls: vec![mp4_atom::Url::default()],
958				},
959			},
960			stbl: mp4_atom::Stbl {
961				stsd: mp4_atom::Stsd {
962					codecs: vec![sample_entry],
963				},
964				..Default::default()
965			},
966			..Default::default()
967		},
968	}
969}
970
971/// Default video timescale when the catalog doesn't supply one.
972///
973/// Used by the fMP4 exporter when synthesizing an init segment for a Legacy or LOC source.
974/// Prefer `framerate * 1000`, then the common NTSC denominator, an exact scale for the
975/// nanosecond-rounded cadence, and finally the highest safe approximate scale.
976///
977/// A framerate that scales to less than one tick takes the fallback too: zero and negative land
978/// on 0 through `as u64`, which is not a timescale anything accepts, and a non-finite one is
979/// filtered out before the cast, since infinity would saturate to `u64::MAX`, past the varint
980/// range a `Timescale` holds.
981pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 {
982	usable_video_framerate(config)
983		.and_then(select_video_timescale)
984		.unwrap_or(90_000)
985}
986
987/// A finite catalog framerate with a synthesized scale and duration that fit their MP4 fields.
988pub(crate) fn usable_video_framerate(config: &VideoConfig) -> Option<f64> {
989	config
990		.framerate
991		.filter(|fps| fps.is_finite() && *fps > 0.0 && (*fps * 1000.0) as u64 > 0)
992		.filter(|fps| select_video_timescale(*fps).is_some())
993}
994
995/// Choose a scale that represents the rounded cadence without overflowing `trun` duration.
996fn select_video_timescale(framerate: f64) -> Option<u64> {
997	let preferred = (framerate * 1000.0) as u64;
998
999	let frame = Duration::from_secs_f64(1.0 / framerate);
1000	for timescale in [preferred, (framerate * 1001.0).round() as u64] {
1001		if duration_fits_trun(frame, timescale) {
1002			return Some(timescale);
1003		}
1004	}
1005
1006	const NANOS_PER_SECOND: u128 = 1_000_000_000;
1007	let exact = NANOS_PER_SECOND / gcd(frame.as_nanos(), NANOS_PER_SECOND);
1008	let exact = u64::try_from(exact).ok()?;
1009	if duration_fits_trun(frame, exact) {
1010		return Some(exact);
1011	}
1012	if let Some(timescale) = rational_timescale(frame) {
1013		return Some(timescale);
1014	}
1015
1016	let max_scale = u128::from(u32::MAX)
1017		.checked_mul(NANOS_PER_SECOND)?
1018		.checked_div(frame.as_nanos())?
1019		.min(u128::from(u32::MAX));
1020	let max_scale = u64::try_from(max_scale).ok()?;
1021	duration_fits_trun(frame, max_scale).then_some(max_scale)
1022}
1023
1024/// Find a small rational scale whose rounded cadence stays within one nanosecond.
1025fn rational_timescale(duration: Duration) -> Option<u64> {
1026	const NANOS_PER_SECOND: u128 = 1_000_000_000;
1027	let mut numerator = duration.as_nanos();
1028	let mut denominator = NANOS_PER_SECOND;
1029	let (mut previous_ticks, mut ticks) = (0_u128, 1_u128);
1030	let (mut previous_scale, mut scale) = (1_u128, 0_u128);
1031
1032	while denominator != 0 {
1033		let coefficient = numerator / denominator;
1034		let next_ticks = coefficient.checked_mul(ticks)?.checked_add(previous_ticks)?;
1035		let next_scale = coefficient.checked_mul(scale)?.checked_add(previous_scale)?;
1036		if next_ticks > u128::from(u32::MAX) || next_scale > u128::from(u32::MAX) {
1037			break;
1038		}
1039
1040		let candidate = u64::try_from(next_scale).ok()?;
1041		if duration_fits_trun(duration, candidate) {
1042			return Some(candidate);
1043		}
1044
1045		(previous_ticks, ticks) = (ticks, next_ticks);
1046		(previous_scale, scale) = (scale, next_scale);
1047		(numerator, denominator) = (denominator, numerator % denominator);
1048	}
1049
1050	None
1051}
1052
1053/// Whether the scale represents the rounded cadence and keeps its sample duration 32-bit.
1054fn duration_fits_trun(duration: Duration, timescale: u64) -> bool {
1055	timescale > 0 && rounded_duration_ticks(duration, timescale).is_some_and(|ticks| u32::try_from(ticks).is_ok())
1056}
1057
1058/// Convert a rounded duration to ticks when it is within one nanosecond of an exact tick.
1059fn rounded_duration_ticks(duration: Duration, timescale: u64) -> Option<u64> {
1060	const NANOS_PER_SECOND: u128 = 1_000_000_000;
1061	let scaled = duration.as_nanos().checked_mul(u128::from(timescale))?;
1062	let rounded = scaled.checked_add(NANOS_PER_SECOND / 2)? / NANOS_PER_SECOND;
1063	let exact = rounded.checked_mul(NANOS_PER_SECOND)?;
1064	if scaled.abs_diff(exact) > u128::from(timescale) {
1065		return None;
1066	}
1067	u64::try_from(rounded).ok()
1068}
1069
1070/// Greatest common divisor for reducing exact timestamp ratios.
1071fn gcd(mut a: u128, mut b: u128) -> u128 {
1072	while b != 0 {
1073		(a, b) = (b, a % b);
1074	}
1075	a
1076}
1077
1078/// The decode timeline a fragment actually carries: its `tfdt` and each sample's composition
1079/// offset. This is what a player reads, as opposed to the PTS [`decode`] reconstructs from it.
1080#[cfg(test)]
1081pub(crate) fn timeline(fragment: &Bytes) -> (u64, Vec<i32>) {
1082	let traf = first_traf(fragment);
1083	let cts = traf.trun[0].entries.iter().map(|e| e.cts.unwrap_or_default()).collect();
1084	(traf.tfdt.as_ref().unwrap().base_media_decode_time, cts)
1085}
1086
1087/// How long each sample in a fragment claims to last, as written into its `trun`.
1088#[cfg(test)]
1089pub(crate) fn sample_durations(fragment: &Bytes) -> Vec<Option<u32>> {
1090	first_traf(fragment).trun[0]
1091		.entries
1092		.iter()
1093		.map(|e| e.duration)
1094		.collect()
1095}
1096
1097#[cfg(test)]
1098fn first_traf(fragment: &Bytes) -> mp4_atom::Traf {
1099	use mp4_atom::DecodeMaybe;
1100
1101	let mut cursor = std::io::Cursor::new(fragment.as_ref());
1102	while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
1103		if let mp4_atom::Any::Moof(moof) = atom {
1104			return moof.traf.into_iter().next().expect("a traf");
1105		}
1106	}
1107	panic!("no moof");
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112	use super::*;
1113
1114	fn ts(micros: u64) -> Timestamp {
1115		Timestamp::from_micros(micros).unwrap()
1116	}
1117
1118	fn info(track_id: u32, timescale: moq_net::Timescale, sequence_number: u32) -> FragmentInfo {
1119		FragmentInfo {
1120			track_id,
1121			timescale,
1122			sequence_number,
1123			kind: Kind::Video,
1124		}
1125	}
1126
1127	// An AAC-LC / 44.1 kHz / stereo AudioSpecificConfig, the catalog `description` shape.
1128	fn aac_config(bitrate: Option<u64>) -> AudioConfig {
1129		let mut config = AudioConfig::new(AudioCodec::AAC(hang::catalog::AAC { profile: 2 }), 44_100, 2);
1130		config.description = Some(Bytes::from_static(&[0x12, 0x10]));
1131		config.bitrate = bitrate;
1132		config
1133	}
1134
1135	/// The moov an encoded init segment carries, so a test asserts on what a player parses
1136	/// rather than on the struct that went in.
1137	fn moov(init: &Bytes) -> mp4_atom::Moov {
1138		use mp4_atom::DecodeMaybe;
1139
1140		let mut cursor = std::io::Cursor::new(init.as_ref());
1141		while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
1142			if let mp4_atom::Any::Moov(moov) = atom {
1143				return moov;
1144			}
1145		}
1146		panic!("no moov");
1147	}
1148
1149	fn dec_config(trak: &mp4_atom::Trak) -> mp4_atom::esds::DecoderConfig {
1150		match &trak.mdia.minf.stbl.stsd.codecs[0] {
1151			mp4_atom::Codec::Mp4a(mp4a) => mp4a.esds.es_desc.dec_config.clone(),
1152			other => panic!("expected mp4a, got {other:?}"),
1153		}
1154	}
1155
1156	// Safari and AVFoundation reject an all-zero DecoderConfigDescriptor on the *init* append,
1157	// which ends the ManagedMediaSource and fails every append after it. The catalog bitrate is
1158	// optional and real publishers omit it, so a synthesized esds must not lean on it.
1159	#[test]
1160	fn synthesized_aac_init_has_non_zero_bitrates() {
1161		let inferred = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap());
1162		assert_ne!(u32::from(inferred.buffer_size_db), 0);
1163		assert_ne!(inferred.max_bitrate, 0);
1164		assert_ne!(inferred.avg_bitrate, 0);
1165
1166		let stated = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(Some(96_000))).unwrap());
1167		assert_eq!(stated.max_bitrate, 96_000, "the catalog's bitrate wins");
1168		assert_eq!(stated.avg_bitrate, 96_000);
1169
1170		// A stated bitrate the field can't carry rebuilds the same all-zero descriptor, so it
1171		// takes the fallback rather than the catalog.
1172		for unusable in [Some(0), Some(u64::from(u32::MAX) + 1)] {
1173			let config = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(unusable)).unwrap());
1174			assert_eq!(config.max_bitrate, inferred.max_bitrate, "{unusable:?}");
1175			assert_eq!(config.avg_bitrate, inferred.avg_bitrate, "{unusable:?}");
1176		}
1177	}
1178
1179	// `framerate * 1000` is 0 for a zero, negative or non-finite catalog framerate, and 0 is not
1180	// a timescale: Timescale::new rejects it, so Muxer::video would fail to build at all.
1181	#[test]
1182	fn default_video_timescale_ignores_an_unusable_framerate() {
1183		let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1184		for unusable in [0.0, -30.0, f64::NAN, f64::INFINITY, 0.0005] {
1185			config.framerate = Some(unusable);
1186			assert_eq!(default_video_timescale(&config), 90_000, "{unusable}");
1187		}
1188
1189		config.framerate = Some(30.0);
1190		assert_eq!(default_video_timescale(&config), 30_000);
1191
1192		config.framerate = Some(30_000.0 / 1001.0);
1193		assert_eq!(default_video_timescale(&config), 30_000);
1194	}
1195
1196	#[test]
1197	fn default_video_timescale_keeps_low_cadence_within_trun() {
1198		let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1199		for framerate in [0.2001, 0.0011] {
1200			config.framerate = Some(framerate);
1201			let timescale = default_video_timescale(&config);
1202			let duration = Duration::from_secs_f64(1.0 / framerate);
1203			let ticks = rounded_duration_ticks(duration, timescale).unwrap();
1204
1205			assert!(timescale <= u64::from(u32::MAX));
1206			assert!(ticks <= u64::from(u32::MAX));
1207		}
1208
1209		assert_eq!(default_video_timescale(&config), 11);
1210	}
1211
1212	// A live fragmented stream's duration isn't known up front. Zero reads as an empty file to a
1213	// strict parser: VLC refuses to play one.
1214	#[test]
1215	fn synthesized_init_declares_unknown_duration() {
1216		let trak = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
1217		assert_eq!(trak.tkhd.duration, u64::MAX);
1218
1219		let init = encode_init(None, vec![trak], Vec::new()).unwrap();
1220		let moov = moov(&init);
1221		assert_eq!(moov.mvhd.duration, u64::MAX);
1222		assert_eq!(moov.trak[0].tkhd.duration, u64::MAX);
1223	}
1224
1225	/// Opus media time counts priming; Hang time places the first kept sample at the
1226	/// first frame timestamp. Both init sources must express that shift as one edit in
1227	/// the output track's timescale, replacing whatever edit the source carried.
1228	#[test]
1229	fn opus_init_maps_hang_timestamps_to_media() {
1230		for timescale in [48_000, 1_000_000] {
1231			for pre_skip in [0, 312, u16::MAX] {
1232				let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
1233				config.description = Some(
1234					crate::codec::opus::Config::new(48_000, 2)
1235						.with_pre_skip(pre_skip)
1236						.encode()
1237						.unwrap(),
1238				);
1239				for source_edit in [false, true] {
1240					let mut trak = synthesize_audio_trak(1, timescale, &config).unwrap();
1241					if source_edit {
1242						trak.edts = Some(mp4_atom::Edts {
1243							elst: Some(mp4_atom::Elst {
1244								entries: vec![mp4_atom::ElstEntry {
1245									media_time: Some(96000),
1246									..Default::default()
1247								}],
1248							}),
1249						});
1250						let mut init = Vec::new();
1251						mp4_atom::Encode::encode(
1252							&mp4_atom::Moov {
1253								trak: vec![trak],
1254								..Default::default()
1255							},
1256							&mut init,
1257						)
1258						.unwrap();
1259						let mut traks = Vec::new();
1260						extract_init(&Bytes::from(init), 1, &mut None, &mut traks, &mut Vec::new()).unwrap();
1261						trak = traks.pop().unwrap();
1262					}
1263					let init = encode_init(None, vec![trak], Vec::new()).unwrap();
1264					let moov = moov(&init);
1265					let trak = &moov.trak[0];
1266					let skip = u64::from(pre_skip) * timescale / 48_000;
1267					if skip == 0 {
1268						assert!(trak.edts.is_none());
1269					} else {
1270						let entries = &trak.edts.as_ref().unwrap().elst.as_ref().unwrap().entries;
1271						assert_eq!(entries.len(), 1);
1272						assert_eq!(entries[0].media_time, Some(skip));
1273						assert_eq!(entries[0].segment_duration, 0, "the live duration is implicit");
1274						assert_eq!(entries[0].media_rate, mp4_atom::FixedPoint::from(1));
1275					}
1276				}
1277			}
1278		}
1279	}
1280
1281	/// Only Opus carries priming: other codecs keep the edit-free init.
1282	#[test]
1283	fn non_opus_init_has_no_edit() {
1284		let trak = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
1285		let init = encode_init(None, vec![trak], Vec::new()).unwrap();
1286		assert!(moov(&init).trak[0].edts.is_none());
1287	}
1288
1289	// The header defaults are all the "off" value: a track the presentation doesn't claim, a
1290	// stopped playback rate, a muted volume, and a next id that collides with the track already
1291	// there. A strict player is entitled to act on any of them.
1292	#[test]
1293	fn synthesized_init_headers_describe_a_playable_presentation() {
1294		let audio = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
1295		let init = encode_init(None, vec![audio], Vec::new()).unwrap();
1296		let moov = moov(&init);
1297
1298		assert_eq!(moov.mvhd.rate.integer(), 1, "normal playback rate");
1299		assert_eq!(moov.mvhd.volume.integer(), 1, "full volume");
1300		assert_eq!(moov.mvhd.next_track_id, 2, "past the only track id");
1301
1302		let tkhd = &moov.trak[0].tkhd;
1303		assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
1304		assert_eq!(tkhd.volume.integer(), 1, "an audio track carries the volume");
1305	}
1306
1307	// A video track's volume stays 0: the field only means something for audio.
1308	#[test]
1309	fn synthesized_video_init_sets_the_track_flags() {
1310		let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1311		config.coded_width = Some(320);
1312		config.coded_height = Some(240);
1313		config.framerate = Some(30.0);
1314		let video = synthesize_video_trak(1, 30_000, &config, None).unwrap();
1315		let init = encode_init(None, vec![video], Vec::new()).unwrap();
1316		let moov = moov(&init);
1317
1318		let tkhd = &moov.trak[0].tkhd;
1319		assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
1320		assert_eq!(tkhd.volume.integer(), 0);
1321	}
1322
1323	#[test]
1324	fn synthesized_video_init_rejects_missing_dimensions() {
1325		let config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1326		let error = synthesize_video_trak(1, 30_000, &config, None).unwrap_err();
1327		assert!(matches!(error, Error::MissingVideoDimensions(_)));
1328	}
1329
1330	#[test]
1331	fn decode_reads_trun_sample_duration() {
1332		use mp4_atom::Encode;
1333
1334		// Microsecond timescale so each tick maps 1:1 to the Timestamp's µs.
1335		// decode() walks the mdat by sample size and ignores data_offset, so a
1336		// hand-built moof+mdat with explicit per-sample durations is enough.
1337		let timescale = moq_net::Timescale::MICRO;
1338		let moof = mp4_atom::Moof {
1339			mfhd: mp4_atom::Mfhd { sequence_number: 0 },
1340			traf: vec![mp4_atom::Traf {
1341				tfhd: mp4_atom::Tfhd {
1342					track_id: 1,
1343					..Default::default()
1344				},
1345				tfdt: Some(mp4_atom::Tfdt {
1346					base_media_decode_time: 0,
1347				}),
1348				trun: vec![mp4_atom::Trun {
1349					data_offset: Some(0),
1350					entries: vec![
1351						mp4_atom::TrunEntry {
1352							size: Some(2),
1353							duration: Some(33_333),
1354							..Default::default()
1355						},
1356						mp4_atom::TrunEntry {
1357							size: Some(2),
1358							duration: Some(33_333),
1359							..Default::default()
1360						},
1361					],
1362				}],
1363				..Default::default()
1364			}],
1365		};
1366
1367		let mut buf = Vec::new();
1368		moof.encode(&mut buf).unwrap();
1369		mp4_atom::Mdat {
1370			data: vec![0xDE, 0xAD, 0xBE, 0xEF],
1371		}
1372		.encode(&mut buf)
1373		.unwrap();
1374
1375		let frames = decode(Bytes::from(buf), timescale, Kind::Video).unwrap();
1376		assert_eq!(frames.len(), 2);
1377		assert_eq!(frames[0].timestamp, ts(0));
1378		assert_eq!(frames[0].duration, Some(ts(33_333)));
1379		assert_eq!(frames[1].timestamp, ts(33_333));
1380		assert_eq!(frames[1].duration, Some(ts(33_333)));
1381	}
1382
1383	#[test]
1384	fn duration_round_trips_through_encode() {
1385		// A frame with a known duration must survive encode -> decode.
1386		let timescale = moq_net::Timescale::MICRO;
1387		let input = vec![Frame {
1388			timestamp: ts(0),
1389			payload: Bytes::from_static(&[0xDE, 0xAD]),
1390			keyframe: true,
1391			duration: Some(ts(33_333)),
1392		}];
1393
1394		let fragment = encode_fragment(info(1, timescale, 0), &input).unwrap();
1395		let frames = decode(fragment, timescale, Kind::Video).unwrap();
1396
1397		assert_eq!(frames.len(), 1);
1398		assert_eq!(frames[0].duration, Some(ts(33_333)));
1399	}
1400
1401	// A trun sample duration is 32 bits. Narrowing silently would make the media claim a
1402	// shorter duration than the metadata returned to the fragmenting consumer.
1403	#[test]
1404	fn encode_fragment_rejects_a_duration_too_large_for_trun() {
1405		let timescale = moq_net::Timescale::new(u64::from(u32::MAX)).unwrap();
1406		let over = u64::from(u32::MAX) + 1;
1407		let frame = Frame {
1408			timestamp: Timestamp::from_scale(0, timescale.as_u64()).unwrap(),
1409			payload: Bytes::from_static(&[0xDE, 0xAD]),
1410			keyframe: true,
1411			duration: Some(Timestamp::from_scale(over, timescale.as_u64()).unwrap()),
1412		};
1413
1414		let err = encode_fragment(info(1, timescale, 0), std::slice::from_ref(&frame)).unwrap_err();
1415		assert!(matches!(err, Error::SampleDurationTooLarge(ticks) if ticks == over));
1416
1417		let largest = Frame {
1418			duration: Some(Timestamp::from_scale(u64::from(u32::MAX), timescale.as_u64()).unwrap()),
1419			..frame
1420		};
1421		let fragment = encode_fragment(info(1, timescale, 0), &[largest]).unwrap();
1422		assert_eq!(sample_durations(&fragment), vec![Some(u32::MAX)]);
1423	}
1424
1425	// A positive duration that becomes zero ticks would leave tfdt stationary while the
1426	// fragment metadata still advances, so a coarse override has to fail explicitly.
1427	#[test]
1428	fn encode_fragment_rejects_a_duration_shorter_than_one_tick() {
1429		let timescale = moq_net::Timescale::SECOND;
1430		let frame = Frame {
1431			timestamp: Timestamp::from_secs(0).unwrap(),
1432			payload: Bytes::from_static(&[0xDE, 0xAD]),
1433			keyframe: true,
1434			duration: Some(Timestamp::from_millis(33).unwrap()),
1435		};
1436
1437		let err = encode_fragment(info(1, timescale, 0), std::slice::from_ref(&frame)).unwrap_err();
1438		assert!(matches!(err, Error::SampleDurationTooSmall(1)));
1439
1440		let one_tick = Frame {
1441			duration: Some(Timestamp::from_secs(1).unwrap()),
1442			..frame
1443		};
1444		let fragment = encode_fragment(info(1, timescale, 0), &[one_tick]).unwrap();
1445		assert_eq!(sample_durations(&fragment), vec![Some(1)]);
1446	}
1447
1448	// Flooring every 1/24-second sample at a 1 kHz output scale would lose 16 ticks per
1449	// second. The caller must choose a scale that represents the duration exactly.
1450	#[test]
1451	fn encode_fragment_rejects_an_inexact_sample_duration() {
1452		let input_scale = moq_net::Timescale::new(24).unwrap();
1453		let output_scale = moq_net::Timescale::MILLI;
1454		let frame = Frame {
1455			timestamp: Timestamp::new(0, input_scale).unwrap(),
1456			payload: Bytes::from_static(&[0xDE, 0xAD]),
1457			keyframe: true,
1458			duration: Some(Timestamp::new(1, input_scale).unwrap()),
1459		};
1460
1461		let err = encode_fragment(info(1, output_scale, 0), std::slice::from_ref(&frame)).unwrap_err();
1462		assert!(matches!(err, Error::SampleDurationInexact(1_000)));
1463
1464		let exact_scale = moq_net::Timescale::new(24_000).unwrap();
1465		let fragment = encode_fragment(info(1, exact_scale, 0), &[frame]).unwrap();
1466		assert_eq!(sample_durations(&fragment), vec![Some(1_000)]);
1467	}
1468
1469	// tfdt is 64 bits. A timestamp rescaled past that range must fail rather than wrap the
1470	// fragment back onto an unrelated point in the presentation.
1471	#[test]
1472	fn encode_fragment_rejects_a_pts_too_large_for_tfdt() {
1473		let timescale = moq_net::Timescale::new(u64::from(u32::MAX)).unwrap();
1474		let frame = Frame {
1475			timestamp: Timestamp::from_secs(1 << 40).unwrap(),
1476			payload: Bytes::from_static(&[0xDE, 0xAD]),
1477			keyframe: true,
1478			duration: None,
1479		};
1480
1481		let err = encode_fragment(info(1, timescale, 0), &[frame]).unwrap_err();
1482		assert!(matches!(err, Error::PtsOverflow));
1483	}
1484
1485	#[test]
1486	fn reordered_pts_round_trips_with_cts() {
1487		let timescale = moq_net::Timescale::new(1_000_000).unwrap();
1488		let input = vec![
1489			Frame {
1490				timestamp: ts(0),
1491				payload: Bytes::from_static(&[0x00]),
1492				keyframe: true,
1493				duration: Some(ts(33_000)),
1494			},
1495			Frame {
1496				timestamp: ts(99_000),
1497				payload: Bytes::from_static(&[0x01]),
1498				keyframe: false,
1499				duration: Some(ts(33_000)),
1500			},
1501			Frame {
1502				timestamp: ts(33_000),
1503				payload: Bytes::from_static(&[0x02]),
1504				keyframe: false,
1505				duration: Some(ts(33_000)),
1506			},
1507		];
1508
1509		let fragment = encode_fragment(info(1, timescale, 0), &input).unwrap();
1510		let frames = decode(fragment, timescale, Kind::Video).unwrap();
1511
1512		assert_eq!(frames.len(), input.len());
1513		for (actual, expected) in frames.iter().zip(&input) {
1514			assert_eq!(actual.timestamp, expected.timestamp);
1515			assert_eq!(actual.duration, expected.duration);
1516			assert_eq!(actual.payload, expected.payload);
1517		}
1518	}
1519
1520	/// Packagers flag every audio sample a sync sample. Decoded as keyframes, those would
1521	/// each open a group, so audio decodes with the bit clear: the group start is the
1522	/// consumer's to mark.
1523	#[test]
1524	fn audio_samples_decode_without_the_keyframe_bit() {
1525		let timescale = moq_net::Timescale::new(48_000).unwrap();
1526		let input: Vec<Frame> = (0..3)
1527			.map(|i| Frame {
1528				timestamp: Timestamp::new(i * 960, timescale).unwrap(),
1529				payload: Bytes::from_static(&[0x00]),
1530				keyframe: i == 0,
1531				duration: Some(Timestamp::new(960, timescale).unwrap()),
1532			})
1533			.collect();
1534		let fragment = encode_fragment(
1535			FragmentInfo {
1536				kind: Kind::Audio,
1537				..info(1, timescale, 0)
1538			},
1539			&input,
1540		)
1541		.unwrap();
1542
1543		// On the wire every sample is a sync sample, whatever the keyframe bit said.
1544		let flags: Vec<u32> = first_traf(&fragment).trun[0]
1545			.entries
1546			.iter()
1547			.map(|entry| entry.flags.unwrap())
1548			.collect();
1549		assert_eq!(flags, vec![0x0200_0000; 3]);
1550
1551		let audio = decode(fragment.clone(), timescale, Kind::Audio).unwrap();
1552		assert!(
1553			audio.iter().all(|frame| !frame.keyframe),
1554			"audio never decodes a keyframe"
1555		);
1556		let video = decode(fragment, timescale, Kind::Video).unwrap();
1557		assert!(
1558			video.iter().all(|frame| frame.keyframe),
1559			"the sync flag is a video keyframe"
1560		);
1561	}
1562
1563	#[test]
1564	fn unsupported_track_handler_is_rejected() {
1565		assert!(matches!(
1566			Kind::from_handler((*b"text").into()),
1567			Err(Error::UnknownTrackHandler(_))
1568		));
1569	}
1570
1571	/// The wire container tells audio from video by the handler its init declares.
1572	#[test]
1573	fn wire_knows_an_audio_track_by_its_handler() {
1574		let audio = Wire::new(synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap());
1575		assert_eq!(audio.kind().unwrap(), Kind::Audio);
1576
1577		let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1578		config.coded_width = Some(320);
1579		config.coded_height = Some(240);
1580		let video = Wire::new(synthesize_video_trak(1, 90_000, &config, None).unwrap());
1581		assert_eq!(video.kind().unwrap(), Kind::Video);
1582	}
1583
1584	#[test]
1585	fn decode_without_duration_reports_none() {
1586		// encode_fragment writes no sample-duration for a duration-less frame,
1587		// so decode must report None (and output stays byte-identical to before).
1588		let timescale = moq_net::Timescale::new(90_000).unwrap();
1589		let frames = vec![Frame {
1590			timestamp: ts(0),
1591			payload: Bytes::from_static(&[0xDE, 0xAD]),
1592			keyframe: true,
1593			duration: None,
1594		}];
1595
1596		let fragment = encode_fragment(info(1, timescale, 0), &frames).unwrap();
1597		let frames = decode(fragment, timescale, Kind::Video).unwrap();
1598
1599		assert_eq!(frames.len(), 1);
1600		assert_eq!(frames[0].duration, None);
1601	}
1602
1603	#[test]
1604	fn decode_zero_duration_reports_none() {
1605		use mp4_atom::Encode;
1606
1607		let timescale = moq_net::Timescale::new(24_000).unwrap();
1608		let moof = mp4_atom::Moof {
1609			mfhd: mp4_atom::Mfhd { sequence_number: 0 },
1610			traf: vec![mp4_atom::Traf {
1611				tfhd: mp4_atom::Tfhd {
1612					track_id: 1,
1613					default_sample_duration: Some(0),
1614					default_sample_size: Some(2),
1615					..Default::default()
1616				},
1617				tfdt: Some(mp4_atom::Tfdt {
1618					base_media_decode_time: 2_000,
1619				}),
1620				trun: vec![mp4_atom::Trun {
1621					data_offset: Some(0),
1622					entries: vec![mp4_atom::TrunEntry {
1623						size: None,
1624						duration: None,
1625						..Default::default()
1626					}],
1627				}],
1628				..Default::default()
1629			}],
1630		};
1631
1632		let mut buf = Vec::new();
1633		moof.encode(&mut buf).unwrap();
1634		mp4_atom::Mdat { data: vec![0xDE, 0xAD] }.encode(&mut buf).unwrap();
1635
1636		let frames = decode(Bytes::from(buf), timescale, Kind::Video).unwrap();
1637		assert_eq!(frames.len(), 1);
1638		assert_eq!(frames[0].timestamp.as_micros(), 83_333);
1639		assert_eq!(frames[0].duration, None);
1640	}
1641}