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	/// `mdhd.timescale` is a 32-bit field, so a larger scale would reach the init segment
144	/// truncated while the fragments kept the full value, putting them on different timelines.
145	#[error("timescale {0} does not fit the 32-bit mdhd field")]
146	TimescaleTooLarge(u64),
147}
148
149impl From<mp4_atom::Error> for Error {
150	fn from(err: mp4_atom::Error) -> Self {
151		Error::Mp4(std::sync::Arc::new(err))
152	}
153}
154
155pub type Result<T> = std::result::Result<T, Error>;
156
157/// CMAF container: encodes/decodes a single track's moof+mdat fragments.
158///
159/// Build from a CMAF init segment with [`Wire::from_init`], or wrap a
160/// pre-extracted [`mp4_atom::Trak`] directly with [`Wire::new`].
161///
162/// The [`mp4_atom::Trak`] is heap-allocated so that embedding `Wire`
163/// in other enums (e.g. [`catalog::hang::Container`](crate::catalog::hang::Container))
164/// doesn't bloat unrelated variants.
165pub struct Wire {
166	trak: Box<mp4_atom::Trak>,
167}
168
169impl Wire {
170	/// Wrap an already-parsed track.
171	pub fn new(trak: mp4_atom::Trak) -> Self {
172		Self { trak: Box::new(trak) }
173	}
174
175	/// Parse a CMAF init segment (ftyp+moov), extracting the single track.
176	pub fn from_init(init_data: &[u8]) -> Result<Self> {
177		use mp4_atom::DecodeMaybe;
178
179		let mut cursor = std::io::Cursor::new(init_data);
180		while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
181			if let mp4_atom::Any::Moov(mut moov) = atom {
182				return match moov.trak.len() {
183					1 => Ok(Self::new(moov.trak.remove(0))),
184					0 => Err(Error::NoTracks),
185					_ => Err(Error::MultipleTracks),
186				};
187			}
188		}
189		Err(Error::NoMoov)
190	}
191
192	pub fn trak(&self) -> &mp4_atom::Trak {
193		&self.trak
194	}
195}
196
197impl Container for Wire {
198	type Error = Error;
199
200	fn write(&self, group: &mut moq_net::group::Producer, frames: &[Frame]) -> std::result::Result<(), Self::Error> {
201		let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
202		let track_id = self.trak.tkhd.track_id;
203		encode(group, frames, timescale, track_id)
204	}
205
206	fn poll_read(
207		&self,
208		group: &mut moq_net::group::Consumer,
209		waiter: &kio::Waiter,
210	) -> Poll<std::result::Result<Option<Vec<Frame>>, Self::Error>> {
211		use std::task::ready;
212
213		let Some(frame) = ready!(group.poll_read_frame(waiter)?) else {
214			return Poll::Ready(Ok(None));
215		};
216
217		let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
218		Poll::Ready(Ok(Some(decode(frame.payload, timescale)?)))
219	}
220}
221
222pub(crate) fn decode(data: Bytes, timescale: moq_net::Timescale) -> Result<Vec<Frame>> {
223	use mp4_atom::DecodeMaybe;
224
225	let mut cursor = std::io::Cursor::new(&data);
226	let mut moof = None;
227	let mut mdat_data = None;
228
229	while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
230		match atom {
231			mp4_atom::Any::Moof(m) => moof = Some(m),
232			mp4_atom::Any::Mdat(m) => mdat_data = Some(m.data),
233			_ => {}
234		}
235	}
236
237	let moof = moof.ok_or(Error::NoMoof)?;
238	let mdat_data = mdat_data.ok_or(Error::NoMdat)?;
239	let traf = moof.traf.first().ok_or(Error::NoTraf)?;
240	let tfdt = traf.tfdt.as_ref().ok_or(Error::NoTfdt)?;
241	let base_dts = tfdt.base_media_decode_time;
242
243	let default_size = traf.tfhd.default_sample_size;
244	let default_duration = traf.tfhd.default_sample_duration;
245
246	// DTS is reconstructed by accumulating each sample's duration. A non-final sample
247	// with no resolvable duration would leave every following sample stuck at the same
248	// DTS, silently collapsing their timestamps, so reject that fragment instead.
249	let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum();
250
251	let mut frames = Vec::new();
252	let mut offset = 0usize;
253	let mut dts = base_dts;
254	let mut sample_index = 0usize;
255
256	for trun in &traf.trun {
257		for entry in &trun.entries {
258			let size = entry.size.or(default_size).unwrap_or(0) as usize;
259			let end = offset + size;
260
261			if end > mdat_data.len() {
262				return Err(Error::SampleRangeOutOfBounds {
263					start: offset,
264					end,
265					len: mdat_data.len(),
266				});
267			}
268
269			let cts = entry.cts.unwrap_or_default() as i64;
270			let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?;
271			// Preserve the fmp4 track's native scale through the pipeline.
272			let timestamp = Timestamp::new(pts, timescale)?;
273			let payload = Bytes::copy_from_slice(&mdat_data[offset..end]);
274			let flags = entry.flags.unwrap_or(0);
275			// depends_on_no_other (bits 24-25 == 0x2) means keyframe
276			let keyframe = (flags >> 24) & 0x3 == 0x2;
277
278			// Carry the sample-duration through at the track's scale when present, so
279			// the jitter buffer can use it and an exporter can write it back.
280			let sample_duration = entry.duration.or(default_duration).filter(|d| *d != 0);
281
282			// The last sample needs no duration (nothing follows it to time), but any
283			// earlier sample without one makes the rest of the fragment's DTS ambiguous.
284			let is_last = sample_index + 1 == total_samples;
285			if sample_duration.is_none() && !is_last {
286				return Err(Error::MissingSampleDuration);
287			}
288
289			let duration = sample_duration
290				.map(|d| Timestamp::new(d as u64, timescale))
291				.transpose()?;
292
293			frames.push(Frame {
294				timestamp,
295				payload,
296				keyframe,
297				duration,
298			});
299
300			offset = end;
301			dts += sample_duration.unwrap_or(0) as u64;
302			sample_index += 1;
303		}
304	}
305
306	Ok(frames)
307}
308
309pub(crate) fn encode(
310	group: &mut moq_net::group::Producer,
311	frames: &[Frame],
312	timescale: moq_net::Timescale,
313	track_id: u32,
314) -> Result<()> {
315	if frames.is_empty() {
316		return Ok(());
317	}
318
319	let sequence_number = group.frame_count() as u32;
320	let bytes = encode_fragment(track_id, timescale, sequence_number, frames)?;
321	// The fragment may carry several samples; the net frame's timestamp is the
322	// fragment's earliest presentation time so a relay can order it.
323	let mut writer = group.create_frame(moq_net::frame::Info {
324		size: bytes.len() as u64,
325		timestamp: frames[0].timestamp,
326	})?;
327	writer.write(bytes)?;
328	writer.finish()?;
329
330	Ok(())
331}
332
333/// Encode a single-traf moof+mdat fragment from a sequence of frames.
334///
335/// Performs the two-pass encoding required by ISO/IEC 14496-12: encode once
336/// to learn the moof size, then again with `trun.data_offset` pointing past
337/// the moof and mdat header. The DTS of the first frame is computed at the
338/// caller-supplied `timescale`.
339///
340/// Returns an empty `Bytes` when `frames` is empty.
341pub(crate) fn encode_fragment(
342	track_id: u32,
343	timescale: moq_net::Timescale,
344	sequence_number: u32,
345	frames: &[Frame],
346) -> Result<Bytes> {
347	use mp4_atom::Encode;
348
349	if frames.is_empty() {
350		return Ok(Bytes::new());
351	}
352
353	// Re-express the first frame's timestamp at the target track's scale. When the
354	// importer preserved the source scale (the common passthrough case), this is a
355	// no-op; otherwise it's a single rescale rather than the legacy `micros * scale
356	// / 1_000_000` round-trip.
357	let base_dts = frames[0].timestamp.as_scale(timescale) as u64;
358	let mut dts = base_dts;
359
360	let entries: Vec<_> = frames
361		.iter()
362		.map(|f| {
363			let flags = if f.keyframe { 0x0200_0000 } else { 0x0001_0000 };
364			// Write the sample-duration back at the track's scale when we know it, so
365			// fMP4 -> fMP4 round-trips it. Frames without one stay byte-identical.
366			let duration = f.duration.map(|d| d.as_scale(timescale) as u32);
367			let pts = f.timestamp.as_scale(timescale) as i128;
368			let cts = pts - i128::from(dts);
369			let cts = i32::try_from(cts).map_err(|_| Error::PtsOverflow)?;
370
371			// Frame timestamps are PTS while sample order is decode order. Author DTS
372			// by accumulating durations and store PTS-DTS as the signed CTS.
373			if let Some(duration) = duration {
374				dts = dts.checked_add(u64::from(duration)).ok_or(Error::PtsOverflow)?;
375			}
376
377			Ok(mp4_atom::TrunEntry {
378				duration,
379				size: Some(f.payload.len() as u32),
380				flags: Some(flags),
381				cts: (cts != 0).then_some(cts),
382			})
383		})
384		.collect::<Result<_>>()?;
385
386	let mdat_data: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
387
388	let build_moof = |data_offset| mp4_atom::Moof {
389		mfhd: mp4_atom::Mfhd { sequence_number },
390		traf: vec![mp4_atom::Traf {
391			tfhd: mp4_atom::Tfhd {
392				track_id,
393				..Default::default()
394			},
395			tfdt: Some(mp4_atom::Tfdt {
396				base_media_decode_time: base_dts,
397			}),
398			trun: vec![mp4_atom::Trun {
399				data_offset: Some(data_offset),
400				entries: entries.clone(),
401			}],
402			..Default::default()
403		}],
404	};
405
406	// First pass to learn the moof size.
407	let mut buf = Vec::new();
408	build_moof(0).encode(&mut buf)?;
409	let moof_size = buf.len();
410
411	// Second pass with data_offset = moof_size + 8 (mdat header).
412	buf.clear();
413	build_moof((moof_size + 8) as i32).encode(&mut buf)?;
414
415	let mdat = mp4_atom::Mdat { data: mdat_data };
416	mdat.encode(&mut buf)?;
417
418	Ok(Bytes::from(buf))
419}
420
421/// Synthesize a CMAF `Trak` for a video rendition that has no init segment.
422///
423/// Used by the fMP4 exporter when its source is a `Container::Legacy` track
424/// (Avc3/Hev1/etc. importers that publish raw codec bitstreams). H.264/H.265
425/// need their out-of-band configuration record (`description`), e.g. because the
426/// Avc1 / Hvc1 transform has finished building it from inline parameter sets.
427/// VP8 carries no out-of-band config, so `description` is `None` for it.
428pub(crate) fn synthesize_video_trak(
429	track_id: u32,
430	timescale: u64,
431	config: &VideoConfig,
432	description: Option<&[u8]>,
433) -> Result<mp4_atom::Trak> {
434	let width = config.coded_width.unwrap_or(0) as u16;
435	let height = config.coded_height.unwrap_or(0) as u16;
436	let visual = mp4_atom::Visual {
437		data_reference_index: 1,
438		width,
439		height,
440		..Default::default()
441	};
442
443	// Codecs that carry an out-of-band config record require `description`.
444	let require_description = || description.ok_or_else(|| Error::MissingVideoDescription(config.codec.to_string()));
445
446	let sample_entry = match &config.codec {
447		VideoCodec::H264(_) => {
448			let mut cursor = std::io::Cursor::new(require_description()?);
449			let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::from)?;
450			mp4_atom::Codec::from(mp4_atom::Avc1 {
451				visual,
452				avcc,
453				..Default::default()
454			})
455		}
456		VideoCodec::H265(h265) => {
457			let mut cursor = std::io::Cursor::new(require_description()?);
458			let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::from)?;
459			// `in_band` (catalog) ↔ hev1 sample entry; otherwise hvc1.
460			if h265.in_band {
461				mp4_atom::Codec::from(mp4_atom::Hev1 {
462					visual,
463					hvcc,
464					..Default::default()
465				})
466			} else {
467				mp4_atom::Codec::from(mp4_atom::Hvc1 {
468					visual,
469					hvcc,
470					..Default::default()
471				})
472			}
473		}
474		VideoCodec::AV1(av1) => mp4_atom::Codec::from(mp4_atom::Av01 {
475			visual,
476			av1c: crate::codec::av1::av1c_from_av1(av1),
477			..Default::default()
478		}),
479		VideoCodec::VP8 => mp4_atom::Codec::from(mp4_atom::Vp08 {
480			visual,
481			vpcc: crate::codec::vp8::vpcc(),
482			..Default::default()
483		}),
484		VideoCodec::VP9(vp9) => mp4_atom::Codec::from(mp4_atom::Vp09 {
485			visual,
486			vpcc: crate::codec::vp9::vpcc(vp9),
487			..Default::default()
488		}),
489		other => return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", other))),
490	};
491
492	Ok(build_video_trak(
493		track_id,
494		mdhd_timescale(timescale)?,
495		sample_entry,
496		width,
497		height,
498	))
499}
500
501/// Synthesize a CMAF `Trak` for an audio rendition that has no init segment.
502pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &AudioConfig) -> Result<mp4_atom::Trak> {
503	use mp4_atom::Decode;
504
505	let audio = mp4_atom::Audio {
506		data_reference_index: 1,
507		channel_count: config.channel_count as u16,
508		sample_size: 16,
509		sample_rate: mp4_atom::FixedPoint::from(config.sample_rate as u16),
510	};
511
512	let sample_entry = match &config.codec {
513		AudioCodec::Opus => {
514			let pre_skip = match &config.description {
515				Some(description) => {
516					let mut description = description.as_ref();
517					crate::codec::opus::Config::parse(&mut description)?.pre_skip
518				}
519				None => 0,
520			};
521			mp4_atom::Codec::from(mp4_atom::Opus {
522				audio,
523				dops: mp4_atom::Dops {
524					output_channel_count: config.channel_count as u8,
525					pre_skip,
526					input_sample_rate: config.sample_rate,
527					output_gain: 0,
528				},
529				btrt: None,
530			})
531		}
532		AudioCodec::AAC(_) => {
533			// The catalog `description` is the AudioSpecificConfig (set by the TS
534			// importer via aac::Config::encode, or carried over from a CMAF source).
535			// mp4_atom models the esds DecoderSpecific as the parsed
536			// AudioSpecificConfig, so decode the blob back into that shape.
537			let description = config
538				.description
539				.as_ref()
540				.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
541			let mut cursor = std::io::Cursor::new(description.as_ref());
542			let dec_specific = mp4_atom::esds::DecoderSpecific::decode(&mut cursor)?;
543
544			// Safari and AVFoundation reject an AAC track whose DecoderConfigDescriptor is all
545			// zeros: they endOfStream("decode") on the *init* append, which leaves the
546			// ManagedMediaSource ended and every later audio and video append failing. The
547			// catalog bitrate is optional and real publishers omit it, so fall back to the
548			// AAC-LC values ffmpeg and the iTunes encoders write. A zero or wider-than-u32
549			// bitrate takes the fallback as well, since writing it back would rebuild the
550			// all-zero descriptor this exists to avoid.
551			let bitrate = config.bitrate.and_then(|b| u32::try_from(b).ok()).filter(|b| *b > 0);
552			let (max_bitrate, avg_bitrate) = match bitrate {
553				Some(bitrate) => (bitrate, bitrate),
554				None => (256_000, 128_000),
555			};
556			mp4_atom::Codec::from(mp4_atom::Mp4a {
557				audio,
558				esds: mp4_atom::Esds {
559					es_desc: mp4_atom::esds::EsDescriptor {
560						// ISO/IEC 14496-14 §5.6: ES_ID is 0 in an MP4 file (the track id carries identity).
561						es_id: 0,
562						dec_config: mp4_atom::esds::DecoderConfig {
563							object_type_indication: 0x40, // MPEG-4 AAC
564							stream_type: 0x05,            // audio
565							up_stream: 0,
566							// 24 KiB, the decoder buffer those same encoders declare.
567							buffer_size_db: mp4_atom::u24::from([0x00, 0x60, 0x00]),
568							max_bitrate,
569							avg_bitrate,
570							dec_specific,
571						},
572						sl_config: Default::default(),
573					},
574				},
575				btrt: None,
576				taic: None,
577			})
578		}
579		AudioCodec::Flac => {
580			// The catalog `description` is the FLAC header (`fLaC` marker + STREAMINFO).
581			// Parse it back into the STREAMINFO fields the `dfLa` box stores.
582			let description = config
583				.description
584				.as_ref()
585				.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
586			let info = crate::codec::flac::Config::parse(&mut description.as_ref())?;
587
588			let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo {
589				minimum_block_size: info.min_block_size,
590				maximum_block_size: info.max_block_size,
591				// Frame sizes are 24-bit; clamp defensively so the conversion can't fail.
592				minimum_frame_size: info.min_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
593				maximum_frame_size: info.max_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
594				sample_rate: info.sample_rate,
595				num_channels_minus_one: info.channel_count.saturating_sub(1) as u8,
596				bits_per_sample_minus_one: info.bits_per_sample.saturating_sub(1) as u8,
597				number_of_interchannel_samples: info.total_samples,
598				md5_checksum: info.md5.to_vec(),
599			};
600
601			mp4_atom::Codec::from(mp4_atom::Flac {
602				audio,
603				dfla: mp4_atom::Dfla {
604					blocks: vec![stream_info],
605				},
606			})
607		}
608		other => return Err(Error::UnsupportedSynthesis(format!("audio codec {:?}", other))),
609	};
610
611	Ok(build_audio_trak(track_id, mdhd_timescale(timescale)?, sample_entry))
612}
613
614/// All-ones: the ISO/IEC 14496-12 spelling of "duration not known up front", which is always
615/// the case for the live fragmented streams synthesized here. Zero reads as an empty file to a
616/// strict parser, and VLC refuses to play one.
617const UNKNOWN_DURATION: u64 = u64::MAX;
618
619fn build_video_trak(
620	track_id: u32,
621	timescale: u32,
622	sample_entry: mp4_atom::Codec,
623	width: u16,
624	height: u16,
625) -> mp4_atom::Trak {
626	mp4_atom::Trak {
627		tkhd: mp4_atom::Tkhd {
628			track_id,
629			enabled: true,
630			// track_in_movie. A player is entitled to skip a track the presentation doesn't
631			// claim, and the flags field is 0x000001 rather than 0x000003 without it.
632			in_movie: true,
633			duration: UNKNOWN_DURATION,
634			width: mp4_atom::FixedPoint::from(width),
635			height: mp4_atom::FixedPoint::from(height),
636			..Default::default()
637		},
638		mdia: build_mdia(timescale, b"vide", true, sample_entry),
639		..Default::default()
640	}
641}
642
643fn build_audio_trak(track_id: u32, timescale: u32, sample_entry: mp4_atom::Codec) -> mp4_atom::Trak {
644	mp4_atom::Trak {
645		tkhd: mp4_atom::Tkhd {
646			track_id,
647			enabled: true,
648			in_movie: true,
649			duration: UNKNOWN_DURATION,
650			// Full volume (8.8 fixed point). The default is 0, which is a muted track, and
651			// only an audio track carries a meaningful value.
652			volume: mp4_atom::FixedPoint::from(1),
653			..Default::default()
654		},
655		mdia: build_mdia(timescale, b"soun", false, sample_entry),
656		..Default::default()
657	}
658}
659
660/// Assemble a fragmented init segment (ftyp + moov) around already-built traks.
661///
662/// `ftyp` is the one a passed-through CMAF init carried, if any; otherwise a plain `isom` one
663/// is synthesized. The `mvhd` is ours either way, so it declares the unknown duration and the
664/// movie timescale of the assembled init rather than of whatever source a trak came from.
665/// [`extract_init`] normalizes a passed-through trak to match.
666pub(crate) fn encode_init(
667	ftyp: Option<mp4_atom::Ftyp>,
668	traks: Vec<mp4_atom::Trak>,
669	trexs: Vec<mp4_atom::Trex>,
670) -> Result<Bytes> {
671	use mp4_atom::Encode;
672
673	let ftyp = ftyp.unwrap_or(mp4_atom::Ftyp {
674		major_brand: b"isom".into(),
675		minor_version: 0x200,
676		compatible_brands: vec![b"isom".into(), b"iso6".into(), b"mp41".into()],
677	});
678	let timescale = traks.first().map(|t| t.mdia.mdhd.timescale).unwrap_or(1000);
679	let next_track_id = traks.iter().map(|t| t.tkhd.track_id).max().unwrap_or(0) + 1;
680
681	let moov = mp4_atom::Moov {
682		mvhd: mp4_atom::Mvhd {
683			timescale,
684			duration: UNKNOWN_DURATION,
685			// Normal playback rate and full volume (16.16 and 8.8 fixed point). Both default
686			// to 0, which declares a stopped, muted presentation.
687			rate: mp4_atom::FixedPoint::from(1),
688			volume: mp4_atom::FixedPoint::from(1),
689			// The next id a track could take, so it must be past every one already present.
690			next_track_id,
691			..Default::default()
692		},
693		trak: traks,
694		mvex: (!trexs.is_empty()).then(|| mp4_atom::Mvex {
695			trex: trexs,
696			..Default::default()
697		}),
698		..Default::default()
699	};
700
701	let mut buf = Vec::new();
702	ftyp.encode(&mut buf)?;
703	moov.encode(&mut buf)?;
704	Ok(Bytes::from(buf))
705}
706
707/// Narrow a media timescale to the 32-bit `mdhd` field, rejecting what would truncate.
708///
709/// `moq_net::Timescale` permits the whole QUIC varint range, so a caller-supplied scale can be
710/// wider than the field. Truncating would put the init segment and the fragments on different
711/// timelines, silently, so refuse instead.
712fn mdhd_timescale(timescale: u64) -> Result<u32> {
713	u32::try_from(timescale).map_err(|_| Error::TimescaleTooLarge(timescale))
714}
715
716fn build_mdia(timescale: u32, handler: &[u8; 4], is_video: bool, sample_entry: mp4_atom::Codec) -> mp4_atom::Mdia {
717	mp4_atom::Mdia {
718		mdhd: mp4_atom::Mdhd {
719			timescale,
720			..Default::default()
721		},
722		hdlr: mp4_atom::Hdlr {
723			handler: mp4_atom::FourCC::new(handler),
724			name: String::new(),
725		},
726		minf: mp4_atom::Minf {
727			vmhd: is_video.then(mp4_atom::Vmhd::default),
728			smhd: (!is_video).then(mp4_atom::Smhd::default),
729			dinf: mp4_atom::Dinf {
730				dref: mp4_atom::Dref {
731					urls: vec![mp4_atom::Url::default()],
732				},
733			},
734			stbl: mp4_atom::Stbl {
735				stsd: mp4_atom::Stsd {
736					codecs: vec![sample_entry],
737				},
738				..Default::default()
739			},
740			..Default::default()
741		},
742	}
743}
744
745/// Default video timescale when the catalog doesn't supply one.
746///
747/// Used by the fMP4 exporter when synthesizing an init segment for a
748/// Legacy or LOC source: prefer `framerate * 1000` (so each frame has an
749/// integer duration), falling back to 90 kHz (the MPEG-TS convention).
750///
751/// A framerate that scales to less than one tick takes the fallback too: zero and negative land
752/// on 0 through `as u64`, which is not a timescale anything accepts, and a non-finite one is
753/// filtered out before the cast, since infinity would saturate to `u64::MAX`, past the varint
754/// range a `Timescale` holds. A fractional tick count truncates rather than falling back, so
755/// 30.0005 fps gives 30000; the common broadcast rates (29.97, 59.94, 23.976) all land on a whole
756/// tick already.
757pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 {
758	let ticks = config
759		.framerate
760		.filter(|fps| fps.is_finite())
761		.map(|fps| (fps * 1000.0) as u64);
762	match ticks {
763		Some(ticks) if ticks > 0 => ticks,
764		_ => 90000,
765	}
766}
767
768#[cfg(test)]
769mod tests {
770	use super::*;
771
772	fn ts(micros: u64) -> Timestamp {
773		Timestamp::from_micros(micros).unwrap()
774	}
775
776	// An AAC-LC / 44.1 kHz / stereo AudioSpecificConfig, the catalog `description` shape.
777	fn aac_config(bitrate: Option<u64>) -> AudioConfig {
778		let mut config = AudioConfig::new(AudioCodec::AAC(hang::catalog::AAC { profile: 2 }), 44_100, 2);
779		config.description = Some(Bytes::from_static(&[0x12, 0x10]));
780		config.bitrate = bitrate;
781		config
782	}
783
784	/// The moov an encoded init segment carries, so a test asserts on what a player parses
785	/// rather than on the struct that went in.
786	fn moov(init: &Bytes) -> mp4_atom::Moov {
787		use mp4_atom::DecodeMaybe;
788
789		let mut cursor = std::io::Cursor::new(init.as_ref());
790		while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
791			if let mp4_atom::Any::Moov(moov) = atom {
792				return moov;
793			}
794		}
795		panic!("no moov");
796	}
797
798	fn dec_config(trak: &mp4_atom::Trak) -> mp4_atom::esds::DecoderConfig {
799		match &trak.mdia.minf.stbl.stsd.codecs[0] {
800			mp4_atom::Codec::Mp4a(mp4a) => mp4a.esds.es_desc.dec_config,
801			other => panic!("expected mp4a, got {other:?}"),
802		}
803	}
804
805	// Safari and AVFoundation reject an all-zero DecoderConfigDescriptor on the *init* append,
806	// which ends the ManagedMediaSource and fails every append after it. The catalog bitrate is
807	// optional and real publishers omit it, so a synthesized esds must not lean on it.
808	#[test]
809	fn synthesized_aac_init_has_non_zero_bitrates() {
810		let inferred = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap());
811		assert_ne!(u32::from(inferred.buffer_size_db), 0);
812		assert_ne!(inferred.max_bitrate, 0);
813		assert_ne!(inferred.avg_bitrate, 0);
814
815		let stated = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(Some(96_000))).unwrap());
816		assert_eq!(stated.max_bitrate, 96_000, "the catalog's bitrate wins");
817		assert_eq!(stated.avg_bitrate, 96_000);
818
819		// A stated bitrate the field can't carry rebuilds the same all-zero descriptor, so it
820		// takes the fallback rather than the catalog.
821		for unusable in [Some(0), Some(u64::from(u32::MAX) + 1)] {
822			let config = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(unusable)).unwrap());
823			assert_eq!(config.max_bitrate, inferred.max_bitrate, "{unusable:?}");
824			assert_eq!(config.avg_bitrate, inferred.avg_bitrate, "{unusable:?}");
825		}
826	}
827
828	// `framerate * 1000` is 0 for a zero, negative or non-finite catalog framerate, and 0 is not
829	// a timescale: Timescale::new rejects it, so Muxer::video would fail to build at all.
830	#[test]
831	fn default_video_timescale_ignores_an_unusable_framerate() {
832		let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
833		for unusable in [0.0, -30.0, f64::NAN, f64::INFINITY, 0.0005] {
834			config.framerate = Some(unusable);
835			assert_eq!(default_video_timescale(&config), 90_000, "{unusable}");
836		}
837
838		config.framerate = Some(30.0);
839		assert_eq!(default_video_timescale(&config), 30_000);
840	}
841
842	// A live fragmented stream's duration isn't known up front. Zero reads as an empty file to a
843	// strict parser: VLC refuses to play one.
844	#[test]
845	fn synthesized_init_declares_unknown_duration() {
846		let trak = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
847		assert_eq!(trak.tkhd.duration, u64::MAX);
848
849		let init = encode_init(None, vec![trak], Vec::new()).unwrap();
850		let moov = moov(&init);
851		assert_eq!(moov.mvhd.duration, u64::MAX);
852		assert_eq!(moov.trak[0].tkhd.duration, u64::MAX);
853	}
854
855	// The header defaults are all the "off" value: a track the presentation doesn't claim, a
856	// stopped playback rate, a muted volume, and a next id that collides with the track already
857	// there. A strict player is entitled to act on any of them.
858	#[test]
859	fn synthesized_init_headers_describe_a_playable_presentation() {
860		let audio = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
861		let init = encode_init(None, vec![audio], Vec::new()).unwrap();
862		let moov = moov(&init);
863
864		assert_eq!(moov.mvhd.rate.integer(), 1, "normal playback rate");
865		assert_eq!(moov.mvhd.volume.integer(), 1, "full volume");
866		assert_eq!(moov.mvhd.next_track_id, 2, "past the only track id");
867
868		let tkhd = &moov.trak[0].tkhd;
869		assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
870		assert_eq!(tkhd.volume.integer(), 1, "an audio track carries the volume");
871	}
872
873	// A video track's volume stays 0: the field only means something for audio.
874	#[test]
875	fn synthesized_video_init_sets_the_track_flags() {
876		let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
877		config.framerate = Some(30.0);
878		let video = synthesize_video_trak(1, 30_000, &config, None).unwrap();
879		let init = encode_init(None, vec![video], Vec::new()).unwrap();
880		let moov = moov(&init);
881
882		let tkhd = &moov.trak[0].tkhd;
883		assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
884		assert_eq!(tkhd.volume.integer(), 0);
885	}
886
887	#[test]
888	fn decode_reads_trun_sample_duration() {
889		use mp4_atom::Encode;
890
891		// Microsecond timescale so each tick maps 1:1 to the Timestamp's µs.
892		// decode() walks the mdat by sample size and ignores data_offset, so a
893		// hand-built moof+mdat with explicit per-sample durations is enough.
894		let timescale = moq_net::Timescale::MICRO;
895		let moof = mp4_atom::Moof {
896			mfhd: mp4_atom::Mfhd { sequence_number: 0 },
897			traf: vec![mp4_atom::Traf {
898				tfhd: mp4_atom::Tfhd {
899					track_id: 1,
900					..Default::default()
901				},
902				tfdt: Some(mp4_atom::Tfdt {
903					base_media_decode_time: 0,
904				}),
905				trun: vec![mp4_atom::Trun {
906					data_offset: Some(0),
907					entries: vec![
908						mp4_atom::TrunEntry {
909							size: Some(2),
910							duration: Some(33_333),
911							..Default::default()
912						},
913						mp4_atom::TrunEntry {
914							size: Some(2),
915							duration: Some(33_333),
916							..Default::default()
917						},
918					],
919				}],
920				..Default::default()
921			}],
922		};
923
924		let mut buf = Vec::new();
925		moof.encode(&mut buf).unwrap();
926		mp4_atom::Mdat {
927			data: vec![0xDE, 0xAD, 0xBE, 0xEF],
928		}
929		.encode(&mut buf)
930		.unwrap();
931
932		let frames = decode(Bytes::from(buf), timescale).unwrap();
933		assert_eq!(frames.len(), 2);
934		assert_eq!(frames[0].timestamp, ts(0));
935		assert_eq!(frames[0].duration, Some(ts(33_333)));
936		assert_eq!(frames[1].timestamp, ts(33_333));
937		assert_eq!(frames[1].duration, Some(ts(33_333)));
938	}
939
940	#[test]
941	fn duration_round_trips_through_encode() {
942		// A frame with a known duration must survive encode -> decode.
943		let timescale = moq_net::Timescale::MICRO;
944		let input = vec![Frame {
945			timestamp: ts(0),
946			payload: Bytes::from_static(&[0xDE, 0xAD]),
947			keyframe: true,
948			duration: Some(ts(33_333)),
949		}];
950
951		let fragment = encode_fragment(1, timescale, 0, &input).unwrap();
952		let frames = decode(fragment, timescale).unwrap();
953
954		assert_eq!(frames.len(), 1);
955		assert_eq!(frames[0].duration, Some(ts(33_333)));
956	}
957
958	#[test]
959	fn reordered_pts_round_trips_with_cts() {
960		let timescale = moq_net::Timescale::new(1_000_000).unwrap();
961		let input = vec![
962			Frame {
963				timestamp: ts(0),
964				payload: Bytes::from_static(&[0x00]),
965				keyframe: true,
966				duration: Some(ts(33_000)),
967			},
968			Frame {
969				timestamp: ts(99_000),
970				payload: Bytes::from_static(&[0x01]),
971				keyframe: false,
972				duration: Some(ts(33_000)),
973			},
974			Frame {
975				timestamp: ts(33_000),
976				payload: Bytes::from_static(&[0x02]),
977				keyframe: false,
978				duration: Some(ts(33_000)),
979			},
980		];
981
982		let fragment = encode_fragment(1, timescale, 0, &input).unwrap();
983		let frames = decode(fragment, timescale).unwrap();
984
985		assert_eq!(frames.len(), input.len());
986		for (actual, expected) in frames.iter().zip(&input) {
987			assert_eq!(actual.timestamp, expected.timestamp);
988			assert_eq!(actual.duration, expected.duration);
989			assert_eq!(actual.payload, expected.payload);
990		}
991	}
992
993	#[test]
994	fn decode_without_duration_reports_none() {
995		// encode_fragment writes no sample-duration for a duration-less frame,
996		// so decode must report None (and output stays byte-identical to before).
997		let timescale = moq_net::Timescale::new(90_000).unwrap();
998		let frames = vec![Frame {
999			timestamp: ts(0),
1000			payload: Bytes::from_static(&[0xDE, 0xAD]),
1001			keyframe: true,
1002			duration: None,
1003		}];
1004
1005		let fragment = encode_fragment(1, timescale, 0, &frames).unwrap();
1006		let frames = decode(fragment, timescale).unwrap();
1007
1008		assert_eq!(frames.len(), 1);
1009		assert_eq!(frames[0].duration, None);
1010	}
1011
1012	#[test]
1013	fn decode_zero_duration_reports_none() {
1014		use mp4_atom::Encode;
1015
1016		let timescale = moq_net::Timescale::new(24_000).unwrap();
1017		let moof = mp4_atom::Moof {
1018			mfhd: mp4_atom::Mfhd { sequence_number: 0 },
1019			traf: vec![mp4_atom::Traf {
1020				tfhd: mp4_atom::Tfhd {
1021					track_id: 1,
1022					default_sample_duration: Some(0),
1023					default_sample_size: Some(2),
1024					..Default::default()
1025				},
1026				tfdt: Some(mp4_atom::Tfdt {
1027					base_media_decode_time: 2_000,
1028				}),
1029				trun: vec![mp4_atom::Trun {
1030					data_offset: Some(0),
1031					entries: vec![mp4_atom::TrunEntry {
1032						size: None,
1033						duration: None,
1034						..Default::default()
1035					}],
1036				}],
1037				..Default::default()
1038			}],
1039		};
1040
1041		let mut buf = Vec::new();
1042		moof.encode(&mut buf).unwrap();
1043		mp4_atom::Mdat { data: vec![0xDE, 0xAD] }.encode(&mut buf).unwrap();
1044
1045		let frames = decode(Bytes::from(buf), timescale).unwrap();
1046		assert_eq!(frames.len(), 1);
1047		assert_eq!(frames[0].timestamp.as_micros(), 83_333);
1048		assert_eq!(frames[0].duration, None);
1049	}
1050}