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