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