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