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;
10mod import;
11mod muxer;
12
13pub use export::*;
14pub use import::*;
15pub use muxer::*;
16
17#[cfg(test)]
18mod export_test;
19#[cfg(test)]
20mod import_test;
21
22use std::task::Poll;
23
24use bytes::Bytes;
25use hang::catalog::{AudioCodec, AudioConfig, VideoCodec, VideoConfig};
26use mp4_atom::Atom;
27
28use moq_net::Timestamp;
29
30use crate::container::{Container, Frame};
31
32#[derive(Debug, Clone, thiserror::Error)]
33#[non_exhaustive]
34pub enum Error {
35	#[error("mp4: {0}")]
36	Mp4(std::sync::Arc<mp4_atom::Error>),
37
38	#[error("moq: {0}")]
39	Moq(#[from] moq_net::Error),
40
41	#[error("flac: {0}")]
42	Flac(#[from] crate::codec::flac::Error),
43
44	#[error("opus: {0}")]
45	Opus(#[from] crate::codec::opus::Error),
46
47	#[error("missing keyframe: a group must open on a keyframe")]
48	MissingKeyframe(#[from] crate::container::MissingKeyframe),
49
50	#[error("timestamp overflow")]
51	TimestampOverflow(#[from] moq_net::TimeOverflow),
52
53	#[error("no traf in moof")]
54	NoTraf,
55
56	#[error("no tfdt in traf")]
57	NoTfdt,
58
59	#[error("PTS overflow")]
60	PtsOverflow,
61
62	#[error("missing moof")]
63	NoMoof,
64
65	#[error("missing mdat")]
66	NoMdat,
67
68	#[error("missing moov")]
69	NoMoov,
70
71	#[error("no tracks in moov")]
72	NoTracks,
73
74	#[error("multiple tracks in moov, use Trak instead")]
75	MultipleTracks,
76
77	#[error("can't synthesize CMAF init for {0}")]
78	UnsupportedSynthesis(String),
79
80	#[error("subtitle tracks are not supported")]
81	UnsupportedSubtitle,
82
83	#[error("unknown track handler: {0:?}")]
84	UnknownTrackHandler([u8; 4]),
85
86	#[error("missing codec")]
87	MissingCodec,
88
89	#[error("multiple codecs")]
90	MultipleCodecs,
91
92	#[error("unknown codec: {0:?}")]
93	UnknownCodec(mp4_atom::FourCC),
94
95	#[error("unsupported codec: {0:?}")]
96	UnsupportedCodec(Box<mp4_atom::Codec>),
97
98	#[error("unsupported codec: MPEG2")]
99	UnsupportedMpeg2,
100
101	#[error("duplicate moof")]
102	DuplicateMoof,
103
104	#[error("missing trun")]
105	MissingTrun,
106
107	#[error("missing tfdt")]
108	MissingTfdt,
109
110	#[error("video codec {0} needs a description (codec config record) to synthesize a CMAF init")]
111	MissingVideoDescription(String),
112
113	#[error("video track {0} missing in catalog")]
114	MissingVideoTrack(String),
115
116	#[error("audio track {0} missing in catalog")]
117	MissingAudioTrack(String),
118
119	#[error("invalid data offset")]
120	InvalidDataOffset,
121
122	#[error("unknown track {0}")]
123	UnknownTrack(u32),
124
125	#[error("no keyframe at start of group")]
126	NoKeyframe,
127
128	#[error("track sample range {start}..{end} is out of bounds of mdat (len {len})")]
129	SampleRangeOutOfBounds { start: usize, end: usize, len: usize },
130
131	#[error("no catalog snapshot")]
132	NoCatalogSnapshot,
133
134	#[error("encode_fragment called with no frames")]
135	NoFrames,
136
137	#[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")]
138	MissingAudioDescription(String),
139
140	#[error("multi-sample fragment has a non-final sample with no duration; DTS is unrecoverable")]
141	MissingSampleDuration,
142}
143
144impl From<mp4_atom::Error> for Error {
145	fn from(err: mp4_atom::Error) -> Self {
146		Error::Mp4(std::sync::Arc::new(err))
147	}
148}
149
150pub type Result<T> = std::result::Result<T, Error>;
151
152/// CMAF container: encodes/decodes a single track's moof+mdat fragments.
153///
154/// Build from a CMAF init segment with [`Wire::from_init`], or wrap a
155/// pre-extracted [`mp4_atom::Trak`] directly with [`Wire::new`].
156///
157/// The [`mp4_atom::Trak`] is heap-allocated so that embedding `Wire`
158/// in other enums (e.g. [`catalog::hang::Container`](crate::catalog::hang::Container))
159/// doesn't bloat unrelated variants.
160pub struct Wire {
161	trak: Box<mp4_atom::Trak>,
162}
163
164impl Wire {
165	/// Wrap an already-parsed track.
166	pub fn new(trak: mp4_atom::Trak) -> Self {
167		Self { trak: Box::new(trak) }
168	}
169
170	/// Parse a CMAF init segment (ftyp+moov), extracting the single track.
171	pub fn from_init(init_data: &[u8]) -> Result<Self> {
172		use mp4_atom::DecodeMaybe;
173
174		let mut cursor = std::io::Cursor::new(init_data);
175		while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
176			if let mp4_atom::Any::Moov(mut moov) = atom {
177				return match moov.trak.len() {
178					1 => Ok(Self::new(moov.trak.remove(0))),
179					0 => Err(Error::NoTracks),
180					_ => Err(Error::MultipleTracks),
181				};
182			}
183		}
184		Err(Error::NoMoov)
185	}
186
187	pub fn trak(&self) -> &mp4_atom::Trak {
188		&self.trak
189	}
190}
191
192impl Container for Wire {
193	type Error = Error;
194
195	fn write(&self, group: &mut moq_net::group::Producer, frames: &[Frame]) -> std::result::Result<(), Self::Error> {
196		let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
197		let track_id = self.trak.tkhd.track_id;
198		encode(group, frames, timescale, track_id)
199	}
200
201	fn poll_read(
202		&self,
203		group: &mut moq_net::group::Consumer,
204		waiter: &kio::Waiter,
205	) -> Poll<std::result::Result<Option<Vec<Frame>>, Self::Error>> {
206		use std::task::ready;
207
208		let Some(frame) = ready!(group.poll_read_frame(waiter)?) else {
209			return Poll::Ready(Ok(None));
210		};
211
212		let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
213		Poll::Ready(Ok(Some(decode(frame.payload, timescale)?)))
214	}
215}
216
217pub(crate) fn decode(data: Bytes, timescale: moq_net::Timescale) -> Result<Vec<Frame>> {
218	use mp4_atom::DecodeMaybe;
219
220	let mut cursor = std::io::Cursor::new(&data);
221	let mut moof = None;
222	let mut mdat_data = None;
223
224	while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
225		match atom {
226			mp4_atom::Any::Moof(m) => moof = Some(m),
227			mp4_atom::Any::Mdat(m) => mdat_data = Some(m.data),
228			_ => {}
229		}
230	}
231
232	let moof = moof.ok_or(Error::NoMoof)?;
233	let mdat_data = mdat_data.ok_or(Error::NoMdat)?;
234	let traf = moof.traf.first().ok_or(Error::NoTraf)?;
235	let tfdt = traf.tfdt.as_ref().ok_or(Error::NoTfdt)?;
236	let base_dts = tfdt.base_media_decode_time;
237
238	let default_size = traf.tfhd.default_sample_size;
239	let default_duration = traf.tfhd.default_sample_duration;
240
241	// DTS is reconstructed by accumulating each sample's duration. A non-final sample
242	// with no resolvable duration would leave every following sample stuck at the same
243	// DTS, silently collapsing their timestamps, so reject that fragment instead.
244	let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum();
245
246	let mut frames = Vec::new();
247	let mut offset = 0usize;
248	let mut dts = base_dts;
249	let mut sample_index = 0usize;
250
251	for trun in &traf.trun {
252		for entry in &trun.entries {
253			let size = entry.size.or(default_size).unwrap_or(0) as usize;
254			let end = offset + size;
255
256			if end > mdat_data.len() {
257				return Err(Error::SampleRangeOutOfBounds {
258					start: offset,
259					end,
260					len: mdat_data.len(),
261				});
262			}
263
264			let cts = entry.cts.unwrap_or_default() as i64;
265			let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?;
266			// Preserve the fmp4 track's native scale through the pipeline.
267			let timestamp = Timestamp::new(pts, timescale)?;
268			let payload = Bytes::copy_from_slice(&mdat_data[offset..end]);
269			let flags = entry.flags.unwrap_or(0);
270			// depends_on_no_other (bits 24-25 == 0x2) means keyframe
271			let keyframe = (flags >> 24) & 0x3 == 0x2;
272
273			// Carry the sample-duration through at the track's scale when present, so
274			// the jitter buffer can use it and an exporter can write it back.
275			let sample_duration = entry.duration.or(default_duration).filter(|d| *d != 0);
276
277			// The last sample needs no duration (nothing follows it to time), but any
278			// earlier sample without one makes the rest of the fragment's DTS ambiguous.
279			let is_last = sample_index + 1 == total_samples;
280			if sample_duration.is_none() && !is_last {
281				return Err(Error::MissingSampleDuration);
282			}
283
284			let duration = sample_duration
285				.map(|d| Timestamp::new(d as u64, timescale))
286				.transpose()?;
287
288			frames.push(Frame {
289				timestamp,
290				payload,
291				keyframe,
292				duration,
293			});
294
295			offset = end;
296			dts += sample_duration.unwrap_or(0) as u64;
297			sample_index += 1;
298		}
299	}
300
301	Ok(frames)
302}
303
304pub(crate) fn encode(
305	group: &mut moq_net::group::Producer,
306	frames: &[Frame],
307	timescale: moq_net::Timescale,
308	track_id: u32,
309) -> Result<()> {
310	if frames.is_empty() {
311		return Ok(());
312	}
313
314	let sequence_number = group.frame_count() as u32;
315	let bytes = encode_fragment(track_id, timescale, sequence_number, frames)?;
316	// The fragment may carry several samples; the net frame's timestamp is the
317	// fragment's earliest presentation time so a relay can order it.
318	let mut writer = group.create_frame(moq_net::frame::Info {
319		size: bytes.len() as u64,
320		timestamp: frames[0].timestamp,
321	})?;
322	writer.write(bytes)?;
323	writer.finish()?;
324
325	Ok(())
326}
327
328/// Encode a single-traf moof+mdat fragment from a sequence of frames.
329///
330/// Performs the two-pass encoding required by ISO/IEC 14496-12: encode once
331/// to learn the moof size, then again with `trun.data_offset` pointing past
332/// the moof and mdat header. The DTS of the first frame is computed at the
333/// caller-supplied `timescale`.
334///
335/// Returns an empty `Bytes` when `frames` is empty.
336pub(crate) fn encode_fragment(
337	track_id: u32,
338	timescale: moq_net::Timescale,
339	sequence_number: u32,
340	frames: &[Frame],
341) -> Result<Bytes> {
342	use mp4_atom::Encode;
343
344	if frames.is_empty() {
345		return Ok(Bytes::new());
346	}
347
348	// Re-express the first frame's timestamp at the target track's scale. When the
349	// importer preserved the source scale (the common passthrough case), this is a
350	// no-op; otherwise it's a single rescale rather than the legacy `micros * scale
351	// / 1_000_000` round-trip.
352	let base_dts = frames[0].timestamp.as_scale(timescale) as u64;
353	let mut dts = base_dts;
354
355	let entries: Vec<_> = frames
356		.iter()
357		.map(|f| {
358			let flags = if f.keyframe { 0x0200_0000 } else { 0x0001_0000 };
359			// Write the sample-duration back at the track's scale when we know it, so
360			// fMP4 -> fMP4 round-trips it. Frames without one stay byte-identical.
361			let duration = f.duration.map(|d| d.as_scale(timescale) as u32);
362			let pts = f.timestamp.as_scale(timescale) as i128;
363			let cts = pts - i128::from(dts);
364			let cts = i32::try_from(cts).map_err(|_| Error::PtsOverflow)?;
365
366			// Frame timestamps are PTS while sample order is decode order. Author DTS
367			// by accumulating durations and store PTS-DTS as the signed CTS.
368			if let Some(duration) = duration {
369				dts = dts.checked_add(u64::from(duration)).ok_or(Error::PtsOverflow)?;
370			}
371
372			Ok(mp4_atom::TrunEntry {
373				duration,
374				size: Some(f.payload.len() as u32),
375				flags: Some(flags),
376				cts: (cts != 0).then_some(cts),
377			})
378		})
379		.collect::<Result<_>>()?;
380
381	let mdat_data: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
382
383	let build_moof = |data_offset| mp4_atom::Moof {
384		mfhd: mp4_atom::Mfhd { sequence_number },
385		traf: vec![mp4_atom::Traf {
386			tfhd: mp4_atom::Tfhd {
387				track_id,
388				..Default::default()
389			},
390			tfdt: Some(mp4_atom::Tfdt {
391				base_media_decode_time: base_dts,
392			}),
393			trun: vec![mp4_atom::Trun {
394				data_offset: Some(data_offset),
395				entries: entries.clone(),
396			}],
397			..Default::default()
398		}],
399	};
400
401	// First pass to learn the moof size.
402	let mut buf = Vec::new();
403	build_moof(0).encode(&mut buf)?;
404	let moof_size = buf.len();
405
406	// Second pass with data_offset = moof_size + 8 (mdat header).
407	buf.clear();
408	build_moof((moof_size + 8) as i32).encode(&mut buf)?;
409
410	let mdat = mp4_atom::Mdat { data: mdat_data };
411	mdat.encode(&mut buf)?;
412
413	Ok(Bytes::from(buf))
414}
415
416/// Synthesize a CMAF `Trak` for a video rendition that has no init segment.
417///
418/// Used by the fMP4 exporter when its source is a `Container::Legacy` track
419/// (Avc3/Hev1/etc. importers that publish raw codec bitstreams). H.264/H.265
420/// need their out-of-band configuration record (`description`), e.g. because the
421/// Avc1 / Hvc1 transform has finished building it from inline parameter sets.
422/// VP8 carries no out-of-band config, so `description` is `None` for it.
423pub(crate) fn synthesize_video_trak(
424	track_id: u32,
425	timescale: u64,
426	config: &VideoConfig,
427	description: Option<&[u8]>,
428) -> Result<mp4_atom::Trak> {
429	let width = config.coded_width.unwrap_or(0) as u16;
430	let height = config.coded_height.unwrap_or(0) as u16;
431	let visual = mp4_atom::Visual {
432		data_reference_index: 1,
433		width,
434		height,
435		..Default::default()
436	};
437
438	// Codecs that carry an out-of-band config record require `description`.
439	let require_description = || description.ok_or_else(|| Error::MissingVideoDescription(config.codec.to_string()));
440
441	let sample_entry = match &config.codec {
442		VideoCodec::H264(_) => {
443			let mut cursor = std::io::Cursor::new(require_description()?);
444			let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::from)?;
445			mp4_atom::Codec::from(mp4_atom::Avc1 {
446				visual,
447				avcc,
448				..Default::default()
449			})
450		}
451		VideoCodec::H265(h265) => {
452			let mut cursor = std::io::Cursor::new(require_description()?);
453			let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::from)?;
454			// `in_band` (catalog) ↔ hev1 sample entry; otherwise hvc1.
455			if h265.in_band {
456				mp4_atom::Codec::from(mp4_atom::Hev1 {
457					visual,
458					hvcc,
459					..Default::default()
460				})
461			} else {
462				mp4_atom::Codec::from(mp4_atom::Hvc1 {
463					visual,
464					hvcc,
465					..Default::default()
466				})
467			}
468		}
469		VideoCodec::AV1(av1) => mp4_atom::Codec::from(mp4_atom::Av01 {
470			visual,
471			av1c: crate::codec::av1::av1c_from_av1(av1),
472			..Default::default()
473		}),
474		VideoCodec::VP8 => mp4_atom::Codec::from(mp4_atom::Vp08 {
475			visual,
476			vpcc: crate::codec::vp8::vpcc(),
477			..Default::default()
478		}),
479		VideoCodec::VP9(vp9) => mp4_atom::Codec::from(mp4_atom::Vp09 {
480			visual,
481			vpcc: crate::codec::vp9::vpcc(vp9),
482			..Default::default()
483		}),
484		other => return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", other))),
485	};
486
487	Ok(build_video_trak(track_id, timescale, sample_entry, width, height))
488}
489
490/// Synthesize a CMAF `Trak` for an audio rendition that has no init segment.
491pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &AudioConfig) -> Result<mp4_atom::Trak> {
492	use mp4_atom::Decode;
493
494	let audio = mp4_atom::Audio {
495		data_reference_index: 1,
496		channel_count: config.channel_count as u16,
497		sample_size: 16,
498		sample_rate: mp4_atom::FixedPoint::from(config.sample_rate as u16),
499	};
500
501	let sample_entry = match &config.codec {
502		AudioCodec::Opus => {
503			let pre_skip = match &config.description {
504				Some(description) => {
505					let mut description = description.as_ref();
506					crate::codec::opus::Config::parse(&mut description)?.pre_skip
507				}
508				None => 0,
509			};
510			mp4_atom::Codec::from(mp4_atom::Opus {
511				audio,
512				dops: mp4_atom::Dops {
513					output_channel_count: config.channel_count as u8,
514					pre_skip,
515					input_sample_rate: config.sample_rate,
516					output_gain: 0,
517				},
518				btrt: None,
519			})
520		}
521		AudioCodec::AAC(_) => {
522			// The catalog `description` is the AudioSpecificConfig (set by the TS
523			// importer via aac::Config::encode, or carried over from a CMAF source).
524			// mp4_atom models the esds DecoderSpecific as the parsed
525			// AudioSpecificConfig, so decode the blob back into that shape.
526			let description = config
527				.description
528				.as_ref()
529				.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
530			let mut cursor = std::io::Cursor::new(description.as_ref());
531			let dec_specific = mp4_atom::esds::DecoderSpecific::decode(&mut cursor)?;
532
533			let bitrate = config.bitrate.unwrap_or(0) as u32;
534			mp4_atom::Codec::from(mp4_atom::Mp4a {
535				audio,
536				esds: mp4_atom::Esds {
537					es_desc: mp4_atom::esds::EsDescriptor {
538						// ISO/IEC 14496-14 §5.6: ES_ID is 0 in an MP4 file (the track id carries identity).
539						es_id: 0,
540						dec_config: mp4_atom::esds::DecoderConfig {
541							object_type_indication: 0x40, // MPEG-4 AAC
542							stream_type: 0x05,            // audio
543							up_stream: 0,
544							buffer_size_db: Default::default(),
545							max_bitrate: bitrate,
546							avg_bitrate: bitrate,
547							dec_specific,
548						},
549						sl_config: Default::default(),
550					},
551				},
552				btrt: None,
553				taic: None,
554			})
555		}
556		AudioCodec::Flac => {
557			// The catalog `description` is the FLAC header (`fLaC` marker + STREAMINFO).
558			// Parse it back into the STREAMINFO fields the `dfLa` box stores.
559			let description = config
560				.description
561				.as_ref()
562				.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
563			let info = crate::codec::flac::Config::parse(&mut description.as_ref())?;
564
565			let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo {
566				minimum_block_size: info.min_block_size,
567				maximum_block_size: info.max_block_size,
568				// Frame sizes are 24-bit; clamp defensively so the conversion can't fail.
569				minimum_frame_size: info.min_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
570				maximum_frame_size: info.max_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
571				sample_rate: info.sample_rate,
572				num_channels_minus_one: info.channel_count.saturating_sub(1) as u8,
573				bits_per_sample_minus_one: info.bits_per_sample.saturating_sub(1) as u8,
574				number_of_interchannel_samples: info.total_samples,
575				md5_checksum: info.md5.to_vec(),
576			};
577
578			mp4_atom::Codec::from(mp4_atom::Flac {
579				audio,
580				dfla: mp4_atom::Dfla {
581					blocks: vec![stream_info],
582				},
583			})
584		}
585		other => return Err(Error::UnsupportedSynthesis(format!("audio codec {:?}", other))),
586	};
587
588	Ok(build_audio_trak(track_id, timescale, sample_entry))
589}
590
591fn build_video_trak(
592	track_id: u32,
593	timescale: u64,
594	sample_entry: mp4_atom::Codec,
595	width: u16,
596	height: u16,
597) -> mp4_atom::Trak {
598	mp4_atom::Trak {
599		tkhd: mp4_atom::Tkhd {
600			track_id,
601			enabled: true,
602			width: mp4_atom::FixedPoint::from(width),
603			height: mp4_atom::FixedPoint::from(height),
604			..Default::default()
605		},
606		mdia: build_mdia(timescale, b"vide", true, sample_entry),
607		..Default::default()
608	}
609}
610
611fn build_audio_trak(track_id: u32, timescale: u64, sample_entry: mp4_atom::Codec) -> mp4_atom::Trak {
612	mp4_atom::Trak {
613		tkhd: mp4_atom::Tkhd {
614			track_id,
615			enabled: true,
616			..Default::default()
617		},
618		mdia: build_mdia(timescale, b"soun", false, sample_entry),
619		..Default::default()
620	}
621}
622
623fn build_mdia(timescale: u64, handler: &[u8; 4], is_video: bool, sample_entry: mp4_atom::Codec) -> mp4_atom::Mdia {
624	mp4_atom::Mdia {
625		mdhd: mp4_atom::Mdhd {
626			timescale: timescale as u32,
627			..Default::default()
628		},
629		hdlr: mp4_atom::Hdlr {
630			handler: mp4_atom::FourCC::new(handler),
631			name: String::new(),
632		},
633		minf: mp4_atom::Minf {
634			vmhd: is_video.then(mp4_atom::Vmhd::default),
635			smhd: (!is_video).then(mp4_atom::Smhd::default),
636			dinf: mp4_atom::Dinf {
637				dref: mp4_atom::Dref {
638					urls: vec![mp4_atom::Url::default()],
639				},
640			},
641			stbl: mp4_atom::Stbl {
642				stsd: mp4_atom::Stsd {
643					codecs: vec![sample_entry],
644				},
645				..Default::default()
646			},
647			..Default::default()
648		},
649	}
650}
651
652/// Default video timescale when the catalog doesn't supply one.
653///
654/// Used by the fMP4 exporter when synthesizing an init segment for a
655/// Legacy or LOC source: prefer `framerate * 1000` (so each frame has an
656/// integer duration), falling back to 90 kHz (the MPEG-TS convention).
657pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 {
658	if let Some(fps) = config.framerate {
659		(fps * 1000.0) as u64
660	} else {
661		90000
662	}
663}
664
665#[cfg(test)]
666mod tests {
667	use super::*;
668
669	fn ts(micros: u64) -> Timestamp {
670		Timestamp::from_micros(micros).unwrap()
671	}
672
673	#[test]
674	fn decode_reads_trun_sample_duration() {
675		use mp4_atom::Encode;
676
677		// Microsecond timescale so each tick maps 1:1 to the Timestamp's µs.
678		// decode() walks the mdat by sample size and ignores data_offset, so a
679		// hand-built moof+mdat with explicit per-sample durations is enough.
680		let timescale = moq_net::Timescale::MICRO;
681		let moof = mp4_atom::Moof {
682			mfhd: mp4_atom::Mfhd { sequence_number: 0 },
683			traf: vec![mp4_atom::Traf {
684				tfhd: mp4_atom::Tfhd {
685					track_id: 1,
686					..Default::default()
687				},
688				tfdt: Some(mp4_atom::Tfdt {
689					base_media_decode_time: 0,
690				}),
691				trun: vec![mp4_atom::Trun {
692					data_offset: Some(0),
693					entries: vec![
694						mp4_atom::TrunEntry {
695							size: Some(2),
696							duration: Some(33_333),
697							..Default::default()
698						},
699						mp4_atom::TrunEntry {
700							size: Some(2),
701							duration: Some(33_333),
702							..Default::default()
703						},
704					],
705				}],
706				..Default::default()
707			}],
708		};
709
710		let mut buf = Vec::new();
711		moof.encode(&mut buf).unwrap();
712		mp4_atom::Mdat {
713			data: vec![0xDE, 0xAD, 0xBE, 0xEF],
714		}
715		.encode(&mut buf)
716		.unwrap();
717
718		let frames = decode(Bytes::from(buf), timescale).unwrap();
719		assert_eq!(frames.len(), 2);
720		assert_eq!(frames[0].timestamp, ts(0));
721		assert_eq!(frames[0].duration, Some(ts(33_333)));
722		assert_eq!(frames[1].timestamp, ts(33_333));
723		assert_eq!(frames[1].duration, Some(ts(33_333)));
724	}
725
726	#[test]
727	fn duration_round_trips_through_encode() {
728		// A frame with a known duration must survive encode -> decode.
729		let timescale = moq_net::Timescale::MICRO;
730		let input = vec![Frame {
731			timestamp: ts(0),
732			payload: Bytes::from_static(&[0xDE, 0xAD]),
733			keyframe: true,
734			duration: Some(ts(33_333)),
735		}];
736
737		let fragment = encode_fragment(1, timescale, 0, &input).unwrap();
738		let frames = decode(fragment, timescale).unwrap();
739
740		assert_eq!(frames.len(), 1);
741		assert_eq!(frames[0].duration, Some(ts(33_333)));
742	}
743
744	#[test]
745	fn reordered_pts_round_trips_with_cts() {
746		let timescale = moq_net::Timescale::new(1_000_000).unwrap();
747		let input = vec![
748			Frame {
749				timestamp: ts(0),
750				payload: Bytes::from_static(&[0x00]),
751				keyframe: true,
752				duration: Some(ts(33_000)),
753			},
754			Frame {
755				timestamp: ts(99_000),
756				payload: Bytes::from_static(&[0x01]),
757				keyframe: false,
758				duration: Some(ts(33_000)),
759			},
760			Frame {
761				timestamp: ts(33_000),
762				payload: Bytes::from_static(&[0x02]),
763				keyframe: false,
764				duration: Some(ts(33_000)),
765			},
766		];
767
768		let fragment = encode_fragment(1, timescale, 0, &input).unwrap();
769		let frames = decode(fragment, timescale).unwrap();
770
771		assert_eq!(frames.len(), input.len());
772		for (actual, expected) in frames.iter().zip(&input) {
773			assert_eq!(actual.timestamp, expected.timestamp);
774			assert_eq!(actual.duration, expected.duration);
775			assert_eq!(actual.payload, expected.payload);
776		}
777	}
778
779	#[test]
780	fn decode_without_duration_reports_none() {
781		// encode_fragment writes no sample-duration for a duration-less frame,
782		// so decode must report None (and output stays byte-identical to before).
783		let timescale = moq_net::Timescale::new(90_000).unwrap();
784		let frames = vec![Frame {
785			timestamp: ts(0),
786			payload: Bytes::from_static(&[0xDE, 0xAD]),
787			keyframe: true,
788			duration: None,
789		}];
790
791		let fragment = encode_fragment(1, timescale, 0, &frames).unwrap();
792		let frames = decode(fragment, timescale).unwrap();
793
794		assert_eq!(frames.len(), 1);
795		assert_eq!(frames[0].duration, None);
796	}
797
798	#[test]
799	fn decode_zero_duration_reports_none() {
800		use mp4_atom::Encode;
801
802		let timescale = moq_net::Timescale::new(24_000).unwrap();
803		let moof = mp4_atom::Moof {
804			mfhd: mp4_atom::Mfhd { sequence_number: 0 },
805			traf: vec![mp4_atom::Traf {
806				tfhd: mp4_atom::Tfhd {
807					track_id: 1,
808					default_sample_duration: Some(0),
809					default_sample_size: Some(2),
810					..Default::default()
811				},
812				tfdt: Some(mp4_atom::Tfdt {
813					base_media_decode_time: 2_000,
814				}),
815				trun: vec![mp4_atom::Trun {
816					data_offset: Some(0),
817					entries: vec![mp4_atom::TrunEntry {
818						size: None,
819						duration: None,
820						..Default::default()
821					}],
822				}],
823				..Default::default()
824			}],
825		};
826
827		let mut buf = Vec::new();
828		moof.encode(&mut buf).unwrap();
829		mp4_atom::Mdat { data: vec![0xDE, 0xAD] }.encode(&mut buf).unwrap();
830
831		let frames = decode(Bytes::from(buf), timescale).unwrap();
832		assert_eq!(frames.len(), 1);
833		assert_eq!(frames[0].timestamp.as_micros(), 83_333);
834		assert_eq!(frames[0].duration, None);
835	}
836}