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