1mod export;
10pub mod fragment;
11mod fragmenter;
12mod import;
13mod muxer;
14
15pub use export::*;
16pub use fragmenter::*;
17pub use import::*;
18pub use muxer::*;
19
20pub use mp4_atom;
23
24#[cfg(test)]
25mod export_test;
26#[cfg(test)]
27mod import_test;
28
29use std::{task::Poll, time::Duration};
30
31use bytes::Bytes;
32use hang::catalog::{AudioCodec, AudioConfig, VideoCodec, VideoConfig};
33use mp4_atom::Atom;
34
35use moq_net::Timestamp;
36
37use crate::container::{Container, Frame};
38
39#[derive(Debug, Clone, thiserror::Error)]
40#[non_exhaustive]
41pub enum Error {
42 #[error("mp4: {0}")]
43 Mp4(std::sync::Arc<mp4_atom::Error>),
44
45 #[error("moq: {0}")]
46 Moq(#[from] moq_net::Error),
47
48 #[error("flac: {0}")]
49 Flac(#[from] crate::codec::flac::Error),
50
51 #[error("opus: {0}")]
52 Opus(#[from] crate::codec::opus::Error),
53
54 #[error("missing keyframe: a group must open on a keyframe")]
55 MissingKeyframe(#[from] crate::container::MissingKeyframe),
56
57 #[error("{0}")]
59 InvalidEnd(#[from] crate::container::InvalidEnd),
60
61 #[error("{0}")]
63 TimestampRewind(#[from] crate::container::TimestampRewind),
64
65 #[error("timestamp overflow")]
66 TimestampOverflow(#[from] moq_net::TimeOverflow),
67
68 #[error("no traf in moof")]
69 NoTraf,
70
71 #[error("no tfdt in traf")]
72 NoTfdt,
73
74 #[error("PTS overflow")]
75 PtsOverflow,
76
77 #[error("missing moof")]
78 NoMoof,
79
80 #[error("missing mdat")]
81 NoMdat,
82
83 #[error("missing moov")]
84 NoMoov,
85
86 #[error("no tracks in moov")]
87 NoTracks,
88
89 #[error("multiple tracks in moov, use Trak instead")]
90 MultipleTracks,
91
92 #[error("can't synthesize CMAF init for {0}")]
93 UnsupportedSynthesis(String),
94
95 #[error("subtitle tracks are not supported")]
96 UnsupportedSubtitle,
97
98 #[error("unknown track handler: {0:?}")]
99 UnknownTrackHandler([u8; 4]),
100
101 #[error("missing codec")]
102 MissingCodec,
103
104 #[error("multiple codecs")]
105 MultipleCodecs,
106
107 #[error("unknown codec: {0:?}")]
108 UnknownCodec(mp4_atom::FourCC),
109
110 #[error("unsupported codec: {0:?}")]
111 UnsupportedCodec(Box<mp4_atom::Codec>),
112
113 #[error("unsupported codec: MPEG2")]
114 UnsupportedMpeg2,
115
116 #[error("AAC sample entry missing DecoderSpecificInfo")]
118 MissingDecoderSpecific,
119
120 #[error("duplicate moof")]
121 DuplicateMoof,
122
123 #[error("duplicate moov")]
125 DuplicateMoov,
126
127 #[error("missing trun")]
128 MissingTrun,
129
130 #[error("missing tfdt")]
131 MissingTfdt,
132
133 #[error("video codec {0} needs a description (codec config record) to synthesize a CMAF init")]
134 MissingVideoDescription(String),
135
136 #[error("video track {0} missing in catalog")]
137 MissingVideoTrack(String),
138
139 #[error("missing video dimensions for codec: {0}")]
141 MissingVideoDimensions(String),
142
143 #[error("audio track {0} missing in catalog")]
144 MissingAudioTrack(String),
145
146 #[error("invalid data offset")]
147 InvalidDataOffset,
148
149 #[error("unknown track {0}")]
150 UnknownTrack(u32),
151
152 #[error("no keyframe at start of group")]
153 NoKeyframe,
154
155 #[error("track sample range {start}..{end} is out of bounds of mdat (len {len})")]
156 SampleRangeOutOfBounds { start: usize, end: usize, len: usize },
157
158 #[error("no catalog snapshot")]
159 NoCatalogSnapshot,
160
161 #[error("encode_fragment called with no frames")]
162 NoFrames,
163
164 #[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")]
165 MissingAudioDescription(String),
166
167 #[error("multi-sample fragment has a non-final sample with no duration; DTS is unrecoverable")]
168 MissingSampleDuration,
169
170 #[error("timescale {0} does not fit the 32-bit mdhd field")]
173 TimescaleTooLarge(u64),
174
175 #[error("a CMAF rendition's timescale comes from its init segment and can't be overridden")]
178 TimescaleOverride,
179
180 #[error("sample duration {0} does not fit the 32-bit trun field")]
183 SampleDurationTooLarge(u64),
184
185 #[error("sample duration is shorter than one tick at timescale {0}")]
187 SampleDurationTooSmall(u64),
188
189 #[error("sample duration is not exactly representable at timescale {0}")]
191 SampleDurationInexact(u64),
192
193 #[error("duration-less video needs stated durations or presentation-ordered inference")]
195 MissingVideoDuration,
196
197 #[error(
201 "track {track}: fragment decode time {decode_time:?} does not advance past {previous:?}; \
202 ffmpeg repeats a tfdt when frag_every_frame interleaves audio and video, use -frag_duration instead"
203 )]
204 NonMonotonicDecodeTime {
205 track: u32,
207 decode_time: moq_net::Timestamp,
209 previous: moq_net::Timestamp,
211 },
212}
213
214impl From<mp4_atom::Error> for Error {
215 fn from(err: mp4_atom::Error) -> Self {
216 Error::Mp4(std::sync::Arc::new(err))
217 }
218}
219
220pub type Result<T> = std::result::Result<T, Error>;
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub(crate) enum Kind {
225 Audio,
226 Video,
227}
228
229impl Kind {
230 fn from_handler(handler: mp4_atom::FourCC) -> Result<Self> {
231 match handler.as_ref() {
232 b"soun" => Ok(Self::Audio),
233 b"vide" => Ok(Self::Video),
234 b"sbtl" => Err(Error::UnsupportedSubtitle),
235 _ => Err(Error::UnknownTrackHandler(handler.into())),
236 }
237 }
238
239 fn priority(self) -> u8 {
241 match self {
242 Self::Video => hang::catalog::PRIORITY.video,
243 Self::Audio => hang::catalog::PRIORITY.audio,
244 }
245 }
246}
247
248pub struct Wire {
257 trak: Box<mp4_atom::Trak>,
258}
259
260impl Wire {
261 pub fn new(trak: mp4_atom::Trak) -> Self {
263 Self { trak: Box::new(trak) }
264 }
265
266 pub fn from_init(init_data: &[u8]) -> Result<Self> {
268 use mp4_atom::DecodeMaybe;
269
270 let mut cursor = std::io::Cursor::new(init_data);
271 while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
272 if let mp4_atom::Any::Moov(mut moov) = atom {
273 return match moov.trak.len() {
274 1 => Ok(Self::new(moov.trak.remove(0))),
275 0 => Err(Error::NoTracks),
276 _ => Err(Error::MultipleTracks),
277 };
278 }
279 }
280 Err(Error::NoMoov)
281 }
282
283 pub fn trak(&self) -> &mp4_atom::Trak {
284 &self.trak
285 }
286
287 fn kind(&self) -> Result<Kind> {
289 Kind::from_handler(self.trak.mdia.hdlr.handler)
290 }
291}
292
293impl Container for Wire {
294 fn kind(&self) -> crate::container::Kind {
295 match self.trak.mdia.hdlr.handler.as_ref() {
296 b"soun" => crate::container::Kind::Audio,
297 b"vide" => crate::container::Kind::Video,
298 _ => crate::container::Kind::Data,
299 }
300 }
301
302 type Error = Error;
303
304 fn write(&self, group: &mut moq_net::group::Producer, frames: &[Frame]) -> std::result::Result<(), Self::Error> {
305 let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
306 let track_id = self.trak.tkhd.track_id;
307 encode(
308 group,
309 frames,
310 FragmentInfo {
311 timescale,
312 track_id,
313 sequence_number: group.frame_count() as u32,
314 kind: self.kind()?,
315 },
316 )
317 }
318
319 fn poll_read(
320 &self,
321 group: &mut moq_net::group::Consumer,
322 waiter: &kio::Waiter,
323 ) -> Poll<std::result::Result<Option<Vec<Frame>>, Self::Error>> {
324 use std::task::ready;
325
326 let Some(frame) = ready!(group.poll_read_frame(waiter)?) else {
327 return Poll::Ready(Ok(None));
328 };
329
330 let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
331 Poll::Ready(Ok(Some(decode(frame.payload, timescale, self.kind()?)?)))
332 }
333}
334
335pub(crate) fn decode(data: Bytes, timescale: moq_net::Timescale, kind: Kind) -> Result<Vec<Frame>> {
341 use mp4_atom::DecodeMaybe;
342
343 let mut cursor = std::io::Cursor::new(&data);
344 let mut moof = None;
345 let mut mdat_data = None;
346
347 while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
348 match atom {
349 mp4_atom::Any::Moof(m) => moof = Some(m),
350 mp4_atom::Any::Mdat(m) => mdat_data = Some(m.data),
351 _ => {}
352 }
353 }
354
355 let moof = moof.ok_or(Error::NoMoof)?;
356 let mdat_data = mdat_data.ok_or(Error::NoMdat)?;
357 let traf = moof.traf.first().ok_or(Error::NoTraf)?;
358 let tfdt = traf.tfdt.as_ref().ok_or(Error::NoTfdt)?;
359 let base_dts = tfdt.base_media_decode_time;
360
361 let default_size = traf.tfhd.default_sample_size;
362 let default_duration = traf.tfhd.default_sample_duration;
363
364 let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum();
368
369 let mut frames = Vec::new();
370 let mut offset = 0usize;
371 let mut dts = base_dts;
372 let mut sample_index = 0usize;
373
374 for trun in &traf.trun {
375 for entry in &trun.entries {
376 let size = entry.size.or(default_size).unwrap_or(0) as usize;
377 let end = offset + size;
378
379 if end > mdat_data.len() {
380 return Err(Error::SampleRangeOutOfBounds {
381 start: offset,
382 end,
383 len: mdat_data.len(),
384 });
385 }
386
387 let cts = entry.cts.unwrap_or_default() as i64;
388 let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?;
389 let timestamp = Timestamp::new(pts, timescale)?;
391 let payload = Bytes::copy_from_slice(&mdat_data[offset..end]);
392 let flags = entry.flags.unwrap_or(0);
393 let keyframe = kind == Kind::Video && (flags >> 24) & 0x3 == 0x2;
395
396 let sample_duration = entry.duration.or(default_duration).filter(|d| *d != 0);
399
400 let is_last = sample_index + 1 == total_samples;
403 if sample_duration.is_none() && !is_last {
404 return Err(Error::MissingSampleDuration);
405 }
406
407 let duration = sample_duration
408 .map(|d| Timestamp::new(d as u64, timescale))
409 .transpose()?;
410
411 frames.push(Frame {
412 timestamp,
413 payload,
414 keyframe,
415 duration,
416 });
417
418 offset = end;
419 dts += sample_duration.unwrap_or(0) as u64;
420 sample_index += 1;
421 }
422 }
423
424 Ok(frames)
425}
426
427fn encode(group: &mut moq_net::group::Producer, frames: &[Frame], info: FragmentInfo) -> Result<()> {
428 if frames.is_empty() {
429 return Ok(());
430 }
431
432 let bytes = encode_fragment(info, frames)?;
433 let mut writer = group.create_frame(moq_net::frame::Info {
436 size: bytes.len() as u64,
437 timestamp: frames[0].timestamp,
438 })?;
439 writer.write(bytes)?;
440 writer.finish()?;
441
442 Ok(())
443}
444
445#[derive(Debug, Clone, Copy)]
450pub(crate) struct FragmentInfo {
451 pub track_id: u32,
453 pub timescale: moq_net::Timescale,
455 pub sequence_number: u32,
457 pub kind: Kind,
460}
461
462pub(crate) fn encode_fragment(info: FragmentInfo, frames: &[Frame]) -> Result<Bytes> {
470 let Some(first) = frames.first() else {
471 return Ok(Bytes::new());
472 };
473 encode_at(info, base_ticks(first, info.timescale)?, frames)
474}
475
476fn base_ticks(frame: &Frame, timescale: moq_net::Timescale) -> Result<u64> {
482 timestamp_ticks(frame.timestamp, timescale)
483}
484
485fn timestamp_ticks(timestamp: Timestamp, timescale: moq_net::Timescale) -> Result<u64> {
487 let source_scale = u128::from(timestamp.scale().as_u64());
488 let scaled = u128::from(timestamp.value()) * u128::from(timescale.as_u64());
489 let ticks = (scaled + source_scale / 2) / source_scale;
490 u64::try_from(ticks).map_err(|_| Error::PtsOverflow)
491}
492
493fn encode_at(info: FragmentInfo, base_dts: u64, frames: &[Frame]) -> Result<Bytes> {
510 let FragmentInfo {
511 track_id,
512 timescale,
513 sequence_number,
514 kind,
515 } = info;
516
517 use mp4_atom::Encode;
518
519 if frames.is_empty() {
520 return Ok(Bytes::new());
521 }
522
523 let mut dts = base_dts;
524
525 let entries: Vec<_> = frames
526 .iter()
527 .map(|f| {
528 let flags = if kind == Kind::Audio || f.keyframe {
529 0x0200_0000
530 } else {
531 0x0001_0000
532 };
533 let duration = f.duration.map(|d| trun_duration(d, timescale)).transpose()?;
536 let pts = i128::from(timestamp_ticks(f.timestamp, timescale)?);
537 let cts = pts - i128::from(dts);
538 let cts = i32::try_from(cts).map_err(|_| Error::PtsOverflow)?;
539
540 if let Some(duration) = duration {
543 dts = dts.checked_add(u64::from(duration)).ok_or(Error::PtsOverflow)?;
544 }
545
546 Ok(mp4_atom::TrunEntry {
547 duration,
548 size: Some(f.payload.len() as u32),
549 flags: Some(flags),
550 cts: (cts != 0).then_some(cts),
551 })
552 })
553 .collect::<Result<_>>()?;
554
555 let mdat_data: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
556
557 let build_moof = |data_offset| mp4_atom::Moof {
558 mfhd: mp4_atom::Mfhd { sequence_number },
559 traf: vec![mp4_atom::Traf {
560 tfhd: mp4_atom::Tfhd {
561 track_id,
562 ..Default::default()
563 },
564 tfdt: Some(mp4_atom::Tfdt {
565 base_media_decode_time: base_dts,
566 }),
567 trun: vec![mp4_atom::Trun {
568 data_offset: Some(data_offset),
569 entries: entries.clone(),
570 }],
571 ..Default::default()
572 }],
573 };
574
575 let mut buf = Vec::new();
577 build_moof(0).encode(&mut buf)?;
578 let moof_size = buf.len();
579
580 buf.clear();
582 build_moof((moof_size + 8) as i32).encode(&mut buf)?;
583
584 let mdat = mp4_atom::Mdat { data: mdat_data };
585 mdat.encode(&mut buf)?;
586
587 Ok(Bytes::from(buf))
588}
589
590fn trun_duration(duration: Timestamp, timescale: moq_net::Timescale) -> Result<u32> {
592 let source_timescale = duration.scale().as_u64();
593 let output_timescale = timescale.as_u64();
594 let scaled = u128::from(duration.value()) * u128::from(output_timescale);
595 let ticks = scaled / u128::from(source_timescale);
596 if !duration.is_zero() && ticks == 0 {
597 return Err(Error::SampleDurationTooSmall(timescale.as_u64()));
598 }
599 if scaled % u128::from(source_timescale) != 0 {
600 return Err(Error::SampleDurationInexact(output_timescale));
601 }
602 u32::try_from(ticks).map_err(|_| Error::SampleDurationTooLarge(u64::try_from(ticks).unwrap_or(u64::MAX)))
603}
604
605pub(crate) fn synthesize_video_trak(
613 track_id: u32,
614 timescale: u64,
615 config: &VideoConfig,
616 description: Option<&[u8]>,
617) -> Result<mp4_atom::Trak> {
618 if !matches!(
619 config.codec,
620 VideoCodec::H264(_) | VideoCodec::H265(_) | VideoCodec::AV1(_) | VideoCodec::VP8 | VideoCodec::VP9(_)
621 ) {
622 return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", config.codec)));
623 }
624
625 let width = u16::try_from(
626 config
627 .coded_width
628 .ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?,
629 )
630 .map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?;
631 let height = u16::try_from(
632 config
633 .coded_height
634 .ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?,
635 )
636 .map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?;
637 if width == 0 || height == 0 {
638 return Err(Error::MissingVideoDimensions(config.codec.to_string()));
639 }
640 let visual = mp4_atom::Visual {
641 data_reference_index: 1,
642 width,
643 height,
644 ..Default::default()
645 };
646
647 let require_description = || description.ok_or_else(|| Error::MissingVideoDescription(config.codec.to_string()));
649
650 let sample_entry = match &config.codec {
651 VideoCodec::H264(_) => {
652 let mut cursor = std::io::Cursor::new(require_description()?);
653 let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::from)?;
654 mp4_atom::Codec::from(mp4_atom::Avc1 {
655 visual,
656 avcc,
657 ..Default::default()
658 })
659 }
660 VideoCodec::H265(h265) => {
661 let mut cursor = std::io::Cursor::new(require_description()?);
662 let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::from)?;
663 if h265.in_band {
665 mp4_atom::Codec::from(mp4_atom::Hev1 {
666 visual,
667 hvcc,
668 ..Default::default()
669 })
670 } else {
671 mp4_atom::Codec::from(mp4_atom::Hvc1 {
672 visual,
673 hvcc,
674 ..Default::default()
675 })
676 }
677 }
678 VideoCodec::AV1(av1) => mp4_atom::Codec::from(mp4_atom::Av01 {
679 visual,
680 av1c: crate::codec::av1::av1c_from_av1(av1),
681 ..Default::default()
682 }),
683 VideoCodec::VP8 => mp4_atom::Codec::from(mp4_atom::Vp08 {
684 visual,
685 vpcc: crate::codec::vp8::vpcc(),
686 ..Default::default()
687 }),
688 VideoCodec::VP9(vp9) => mp4_atom::Codec::from(mp4_atom::Vp09 {
689 visual,
690 vpcc: crate::codec::vp9::vpcc(vp9),
691 ..Default::default()
692 }),
693 other => unreachable!("unsupported codecs rejected before geometry synthesis: {other:?}"),
694 };
695
696 Ok(build_video_trak(
697 track_id,
698 mdhd_timescale(timescale)?,
699 sample_entry,
700 width,
701 height,
702 ))
703}
704
705pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &AudioConfig) -> Result<mp4_atom::Trak> {
707 use mp4_atom::Decode;
708
709 let audio = mp4_atom::Audio {
710 data_reference_index: 1,
711 channel_count: config.channel_count as u16,
712 sample_size: 16,
713 sample_rate: mp4_atom::FixedPoint::from(config.sample_rate as u16),
714 };
715
716 let sample_entry = match &config.codec {
717 AudioCodec::Opus => {
718 let head = match &config.description {
719 Some(description) => {
720 let head = crate::codec::opus::Config::parse(&mut description.as_ref())?;
721 if head.mapping_family != 0 {
723 return Err(crate::codec::opus::Error::UnsupportedMappingFamily(head.mapping_family).into());
724 }
725 head
726 }
727 None => crate::codec::opus::Config::new(config.sample_rate, config.channel_count),
728 };
729 mp4_atom::Codec::from(mp4_atom::Opus {
730 audio,
731 dops: mp4_atom::Dops {
732 output_channel_count: config.channel_count as u8,
733 pre_skip: head.pre_skip,
734 input_sample_rate: head.sample_rate,
735 output_gain: head.output_gain,
736 },
737 btrt: None,
738 })
739 }
740 AudioCodec::AAC(_) => {
741 let description = config
746 .description
747 .as_ref()
748 .ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
749 let mut cursor = std::io::Cursor::new(description.as_ref());
750 let dec_specific = mp4_atom::esds::DecoderSpecific::decode(&mut cursor)?;
751
752 let bitrate = config.bitrate.and_then(|b| u32::try_from(b).ok()).filter(|b| *b > 0);
760 let (max_bitrate, avg_bitrate) = match bitrate {
761 Some(bitrate) => (bitrate, bitrate),
762 None => (256_000, 128_000),
763 };
764 mp4_atom::Codec::from(mp4_atom::Mp4a {
765 audio,
766 esds: mp4_atom::Esds {
767 es_desc: mp4_atom::esds::EsDescriptor {
768 es_id: 0,
770 dec_config: mp4_atom::esds::DecoderConfig {
771 object_type_indication: 0x40, stream_type: 0x05, up_stream: 0,
774 buffer_size_db: mp4_atom::u24::from([0x00, 0x60, 0x00]),
776 max_bitrate,
777 avg_bitrate,
778 dec_specific: Some(dec_specific),
779 },
780 sl_config: Default::default(),
781 },
782 },
783 btrt: None,
784 taic: None,
785 })
786 }
787 AudioCodec::Flac => {
788 let description = config
791 .description
792 .as_ref()
793 .ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
794 let info = crate::codec::flac::Config::parse(&mut description.as_ref())?;
795
796 let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo {
797 minimum_block_size: info.min_block_size,
798 maximum_block_size: info.max_block_size,
799 minimum_frame_size: info.min_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
801 maximum_frame_size: info.max_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
802 sample_rate: info.sample_rate,
803 num_channels_minus_one: info.channel_count.saturating_sub(1) as u8,
804 bits_per_sample_minus_one: info.bits_per_sample.saturating_sub(1) as u8,
805 number_of_interchannel_samples: info.total_samples,
806 md5_checksum: info.md5.to_vec(),
807 };
808
809 mp4_atom::Codec::from(mp4_atom::Flac {
810 audio,
811 dfla: mp4_atom::Dfla {
812 blocks: vec![stream_info],
813 },
814 })
815 }
816 other => return Err(Error::UnsupportedSynthesis(format!("audio codec {:?}", other))),
817 };
818
819 Ok(build_audio_trak(track_id, mdhd_timescale(timescale)?, sample_entry))
820}
821
822const UNKNOWN_DURATION: u64 = u64::MAX;
826
827fn build_video_trak(
828 track_id: u32,
829 timescale: u32,
830 sample_entry: mp4_atom::Codec,
831 width: u16,
832 height: u16,
833) -> mp4_atom::Trak {
834 mp4_atom::Trak {
835 tkhd: mp4_atom::Tkhd {
836 track_id,
837 enabled: true,
838 in_movie: true,
841 duration: UNKNOWN_DURATION,
842 width: mp4_atom::FixedPoint::from(width),
843 height: mp4_atom::FixedPoint::from(height),
844 ..Default::default()
845 },
846 mdia: build_mdia(timescale, b"vide", true, sample_entry),
847 ..Default::default()
848 }
849}
850
851fn build_audio_trak(track_id: u32, timescale: u32, sample_entry: mp4_atom::Codec) -> mp4_atom::Trak {
852 mp4_atom::Trak {
853 tkhd: mp4_atom::Tkhd {
854 track_id,
855 enabled: true,
856 in_movie: true,
857 duration: UNKNOWN_DURATION,
858 volume: mp4_atom::FixedPoint::from(1),
861 ..Default::default()
862 },
863 mdia: build_mdia(timescale, b"soun", false, sample_entry),
864 ..Default::default()
865 }
866}
867
868pub(crate) fn encode_init(
875 ftyp: Option<mp4_atom::Ftyp>,
876 traks: Vec<mp4_atom::Trak>,
877 trexs: Vec<mp4_atom::Trex>,
878) -> Result<Bytes> {
879 use mp4_atom::Encode;
880
881 let ftyp = ftyp.unwrap_or(mp4_atom::Ftyp {
882 major_brand: b"isom".into(),
883 minor_version: 0x200,
884 compatible_brands: vec![b"isom".into(), b"iso6".into(), b"mp41".into()],
885 });
886 let timescale = traks.first().map(|t| t.mdia.mdhd.timescale).unwrap_or(1000);
887 let next_track_id = traks.iter().map(|t| t.tkhd.track_id).max().unwrap_or(0) + 1;
888
889 let moov = mp4_atom::Moov {
890 mvhd: mp4_atom::Mvhd {
891 timescale,
892 duration: UNKNOWN_DURATION,
893 rate: mp4_atom::FixedPoint::from(1),
896 volume: mp4_atom::FixedPoint::from(1),
897 next_track_id,
899 ..Default::default()
900 },
901 trak: traks,
902 mvex: (!trexs.is_empty()).then(|| mp4_atom::Mvex {
903 trex: trexs,
904 ..Default::default()
905 }),
906 ..Default::default()
907 };
908
909 let mut buf = Vec::new();
910 ftyp.encode(&mut buf)?;
911 moov.encode(&mut buf)?;
912 Ok(Bytes::from(buf))
913}
914
915fn mdhd_timescale(timescale: u64) -> Result<u32> {
921 u32::try_from(timescale).map_err(|_| Error::TimescaleTooLarge(timescale))
922}
923
924fn build_mdia(timescale: u32, handler: &[u8; 4], is_video: bool, sample_entry: mp4_atom::Codec) -> mp4_atom::Mdia {
925 mp4_atom::Mdia {
926 mdhd: mp4_atom::Mdhd {
927 timescale,
928 ..Default::default()
929 },
930 hdlr: mp4_atom::Hdlr {
931 handler: mp4_atom::FourCC::new(handler),
932 name: String::new(),
933 },
934 minf: mp4_atom::Minf {
935 vmhd: is_video.then(mp4_atom::Vmhd::default),
936 smhd: (!is_video).then(mp4_atom::Smhd::default),
937 dinf: mp4_atom::Dinf {
938 dref: mp4_atom::Dref {
939 urls: vec![mp4_atom::Url::default()],
940 },
941 },
942 stbl: mp4_atom::Stbl {
943 stsd: mp4_atom::Stsd {
944 codecs: vec![sample_entry],
945 },
946 ..Default::default()
947 },
948 ..Default::default()
949 },
950 }
951}
952
953pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 {
964 usable_video_framerate(config)
965 .and_then(select_video_timescale)
966 .unwrap_or(90_000)
967}
968
969pub(crate) fn usable_video_framerate(config: &VideoConfig) -> Option<f64> {
971 config
972 .framerate
973 .filter(|fps| fps.is_finite() && *fps > 0.0 && (*fps * 1000.0) as u64 > 0)
974 .filter(|fps| select_video_timescale(*fps).is_some())
975}
976
977fn select_video_timescale(framerate: f64) -> Option<u64> {
979 let preferred = (framerate * 1000.0) as u64;
980
981 let frame = Duration::from_secs_f64(1.0 / framerate);
982 for timescale in [preferred, (framerate * 1001.0).round() as u64] {
983 if duration_fits_trun(frame, timescale) {
984 return Some(timescale);
985 }
986 }
987
988 const NANOS_PER_SECOND: u128 = 1_000_000_000;
989 let exact = NANOS_PER_SECOND / gcd(frame.as_nanos(), NANOS_PER_SECOND);
990 let exact = u64::try_from(exact).ok()?;
991 if duration_fits_trun(frame, exact) {
992 return Some(exact);
993 }
994 if let Some(timescale) = rational_timescale(frame) {
995 return Some(timescale);
996 }
997
998 let max_scale = u128::from(u32::MAX)
999 .checked_mul(NANOS_PER_SECOND)?
1000 .checked_div(frame.as_nanos())?
1001 .min(u128::from(u32::MAX));
1002 let max_scale = u64::try_from(max_scale).ok()?;
1003 duration_fits_trun(frame, max_scale).then_some(max_scale)
1004}
1005
1006fn rational_timescale(duration: Duration) -> Option<u64> {
1008 const NANOS_PER_SECOND: u128 = 1_000_000_000;
1009 let mut numerator = duration.as_nanos();
1010 let mut denominator = NANOS_PER_SECOND;
1011 let (mut previous_ticks, mut ticks) = (0_u128, 1_u128);
1012 let (mut previous_scale, mut scale) = (1_u128, 0_u128);
1013
1014 while denominator != 0 {
1015 let coefficient = numerator / denominator;
1016 let next_ticks = coefficient.checked_mul(ticks)?.checked_add(previous_ticks)?;
1017 let next_scale = coefficient.checked_mul(scale)?.checked_add(previous_scale)?;
1018 if next_ticks > u128::from(u32::MAX) || next_scale > u128::from(u32::MAX) {
1019 break;
1020 }
1021
1022 let candidate = u64::try_from(next_scale).ok()?;
1023 if duration_fits_trun(duration, candidate) {
1024 return Some(candidate);
1025 }
1026
1027 (previous_ticks, ticks) = (ticks, next_ticks);
1028 (previous_scale, scale) = (scale, next_scale);
1029 (numerator, denominator) = (denominator, numerator % denominator);
1030 }
1031
1032 None
1033}
1034
1035fn duration_fits_trun(duration: Duration, timescale: u64) -> bool {
1037 timescale > 0 && rounded_duration_ticks(duration, timescale).is_some_and(|ticks| u32::try_from(ticks).is_ok())
1038}
1039
1040fn rounded_duration_ticks(duration: Duration, timescale: u64) -> Option<u64> {
1042 const NANOS_PER_SECOND: u128 = 1_000_000_000;
1043 let scaled = duration.as_nanos().checked_mul(u128::from(timescale))?;
1044 let rounded = scaled.checked_add(NANOS_PER_SECOND / 2)? / NANOS_PER_SECOND;
1045 let exact = rounded.checked_mul(NANOS_PER_SECOND)?;
1046 if scaled.abs_diff(exact) > u128::from(timescale) {
1047 return None;
1048 }
1049 u64::try_from(rounded).ok()
1050}
1051
1052fn gcd(mut a: u128, mut b: u128) -> u128 {
1054 while b != 0 {
1055 (a, b) = (b, a % b);
1056 }
1057 a
1058}
1059
1060#[cfg(test)]
1063pub(crate) fn timeline(fragment: &Bytes) -> (u64, Vec<i32>) {
1064 let traf = first_traf(fragment);
1065 let cts = traf.trun[0].entries.iter().map(|e| e.cts.unwrap_or_default()).collect();
1066 (traf.tfdt.as_ref().unwrap().base_media_decode_time, cts)
1067}
1068
1069#[cfg(test)]
1071pub(crate) fn sample_durations(fragment: &Bytes) -> Vec<Option<u32>> {
1072 first_traf(fragment).trun[0]
1073 .entries
1074 .iter()
1075 .map(|e| e.duration)
1076 .collect()
1077}
1078
1079#[cfg(test)]
1080fn first_traf(fragment: &Bytes) -> mp4_atom::Traf {
1081 use mp4_atom::DecodeMaybe;
1082
1083 let mut cursor = std::io::Cursor::new(fragment.as_ref());
1084 while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
1085 if let mp4_atom::Any::Moof(moof) = atom {
1086 return moof.traf.into_iter().next().expect("a traf");
1087 }
1088 }
1089 panic!("no moof");
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095
1096 fn ts(micros: u64) -> Timestamp {
1097 Timestamp::from_micros(micros).unwrap()
1098 }
1099
1100 fn info(track_id: u32, timescale: moq_net::Timescale, sequence_number: u32) -> FragmentInfo {
1101 FragmentInfo {
1102 track_id,
1103 timescale,
1104 sequence_number,
1105 kind: Kind::Video,
1106 }
1107 }
1108
1109 fn aac_config(bitrate: Option<u64>) -> AudioConfig {
1111 let mut config = AudioConfig::new(AudioCodec::AAC(hang::catalog::AAC { profile: 2 }), 44_100, 2);
1112 config.description = Some(Bytes::from_static(&[0x12, 0x10]));
1113 config.bitrate = bitrate;
1114 config
1115 }
1116
1117 fn moov(init: &Bytes) -> mp4_atom::Moov {
1120 use mp4_atom::DecodeMaybe;
1121
1122 let mut cursor = std::io::Cursor::new(init.as_ref());
1123 while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
1124 if let mp4_atom::Any::Moov(moov) = atom {
1125 return moov;
1126 }
1127 }
1128 panic!("no moov");
1129 }
1130
1131 fn dec_config(trak: &mp4_atom::Trak) -> mp4_atom::esds::DecoderConfig {
1132 match &trak.mdia.minf.stbl.stsd.codecs[0] {
1133 mp4_atom::Codec::Mp4a(mp4a) => mp4a.esds.es_desc.dec_config.clone(),
1134 other => panic!("expected mp4a, got {other:?}"),
1135 }
1136 }
1137
1138 #[test]
1142 fn synthesized_aac_init_has_non_zero_bitrates() {
1143 let inferred = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap());
1144 assert_ne!(u32::from(inferred.buffer_size_db), 0);
1145 assert_ne!(inferred.max_bitrate, 0);
1146 assert_ne!(inferred.avg_bitrate, 0);
1147
1148 let stated = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(Some(96_000))).unwrap());
1149 assert_eq!(stated.max_bitrate, 96_000, "the catalog's bitrate wins");
1150 assert_eq!(stated.avg_bitrate, 96_000);
1151
1152 for unusable in [Some(0), Some(u64::from(u32::MAX) + 1)] {
1155 let config = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(unusable)).unwrap());
1156 assert_eq!(config.max_bitrate, inferred.max_bitrate, "{unusable:?}");
1157 assert_eq!(config.avg_bitrate, inferred.avg_bitrate, "{unusable:?}");
1158 }
1159 }
1160
1161 #[test]
1164 fn default_video_timescale_ignores_an_unusable_framerate() {
1165 let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1166 for unusable in [0.0, -30.0, f64::NAN, f64::INFINITY, 0.0005] {
1167 config.framerate = Some(unusable);
1168 assert_eq!(default_video_timescale(&config), 90_000, "{unusable}");
1169 }
1170
1171 config.framerate = Some(30.0);
1172 assert_eq!(default_video_timescale(&config), 30_000);
1173
1174 config.framerate = Some(30_000.0 / 1001.0);
1175 assert_eq!(default_video_timescale(&config), 30_000);
1176 }
1177
1178 #[test]
1179 fn default_video_timescale_keeps_low_cadence_within_trun() {
1180 let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1181 for framerate in [0.2001, 0.0011] {
1182 config.framerate = Some(framerate);
1183 let timescale = default_video_timescale(&config);
1184 let duration = Duration::from_secs_f64(1.0 / framerate);
1185 let ticks = rounded_duration_ticks(duration, timescale).unwrap();
1186
1187 assert!(timescale <= u64::from(u32::MAX));
1188 assert!(ticks <= u64::from(u32::MAX));
1189 }
1190
1191 assert_eq!(default_video_timescale(&config), 11);
1192 }
1193
1194 #[test]
1197 fn synthesized_init_declares_unknown_duration() {
1198 let trak = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
1199 assert_eq!(trak.tkhd.duration, u64::MAX);
1200
1201 let init = encode_init(None, vec![trak], Vec::new()).unwrap();
1202 let moov = moov(&init);
1203 assert_eq!(moov.mvhd.duration, u64::MAX);
1204 assert_eq!(moov.trak[0].tkhd.duration, u64::MAX);
1205 }
1206
1207 #[test]
1211 fn synthesized_init_headers_describe_a_playable_presentation() {
1212 let audio = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
1213 let init = encode_init(None, vec![audio], Vec::new()).unwrap();
1214 let moov = moov(&init);
1215
1216 assert_eq!(moov.mvhd.rate.integer(), 1, "normal playback rate");
1217 assert_eq!(moov.mvhd.volume.integer(), 1, "full volume");
1218 assert_eq!(moov.mvhd.next_track_id, 2, "past the only track id");
1219
1220 let tkhd = &moov.trak[0].tkhd;
1221 assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
1222 assert_eq!(tkhd.volume.integer(), 1, "an audio track carries the volume");
1223 }
1224
1225 #[test]
1227 fn synthesized_video_init_sets_the_track_flags() {
1228 let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1229 config.coded_width = Some(320);
1230 config.coded_height = Some(240);
1231 config.framerate = Some(30.0);
1232 let video = synthesize_video_trak(1, 30_000, &config, None).unwrap();
1233 let init = encode_init(None, vec![video], Vec::new()).unwrap();
1234 let moov = moov(&init);
1235
1236 let tkhd = &moov.trak[0].tkhd;
1237 assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
1238 assert_eq!(tkhd.volume.integer(), 0);
1239 }
1240
1241 #[test]
1242 fn synthesized_video_init_rejects_missing_dimensions() {
1243 let config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1244 let error = synthesize_video_trak(1, 30_000, &config, None).unwrap_err();
1245 assert!(matches!(error, Error::MissingVideoDimensions(_)));
1246 }
1247
1248 #[test]
1249 fn decode_reads_trun_sample_duration() {
1250 use mp4_atom::Encode;
1251
1252 let timescale = moq_net::Timescale::MICRO;
1256 let moof = mp4_atom::Moof {
1257 mfhd: mp4_atom::Mfhd { sequence_number: 0 },
1258 traf: vec![mp4_atom::Traf {
1259 tfhd: mp4_atom::Tfhd {
1260 track_id: 1,
1261 ..Default::default()
1262 },
1263 tfdt: Some(mp4_atom::Tfdt {
1264 base_media_decode_time: 0,
1265 }),
1266 trun: vec![mp4_atom::Trun {
1267 data_offset: Some(0),
1268 entries: vec![
1269 mp4_atom::TrunEntry {
1270 size: Some(2),
1271 duration: Some(33_333),
1272 ..Default::default()
1273 },
1274 mp4_atom::TrunEntry {
1275 size: Some(2),
1276 duration: Some(33_333),
1277 ..Default::default()
1278 },
1279 ],
1280 }],
1281 ..Default::default()
1282 }],
1283 };
1284
1285 let mut buf = Vec::new();
1286 moof.encode(&mut buf).unwrap();
1287 mp4_atom::Mdat {
1288 data: vec![0xDE, 0xAD, 0xBE, 0xEF],
1289 }
1290 .encode(&mut buf)
1291 .unwrap();
1292
1293 let frames = decode(Bytes::from(buf), timescale, Kind::Video).unwrap();
1294 assert_eq!(frames.len(), 2);
1295 assert_eq!(frames[0].timestamp, ts(0));
1296 assert_eq!(frames[0].duration, Some(ts(33_333)));
1297 assert_eq!(frames[1].timestamp, ts(33_333));
1298 assert_eq!(frames[1].duration, Some(ts(33_333)));
1299 }
1300
1301 #[test]
1302 fn duration_round_trips_through_encode() {
1303 let timescale = moq_net::Timescale::MICRO;
1305 let input = vec![Frame {
1306 timestamp: ts(0),
1307 payload: Bytes::from_static(&[0xDE, 0xAD]),
1308 keyframe: true,
1309 duration: Some(ts(33_333)),
1310 }];
1311
1312 let fragment = encode_fragment(info(1, timescale, 0), &input).unwrap();
1313 let frames = decode(fragment, timescale, Kind::Video).unwrap();
1314
1315 assert_eq!(frames.len(), 1);
1316 assert_eq!(frames[0].duration, Some(ts(33_333)));
1317 }
1318
1319 #[test]
1322 fn encode_fragment_rejects_a_duration_too_large_for_trun() {
1323 let timescale = moq_net::Timescale::new(u64::from(u32::MAX)).unwrap();
1324 let over = u64::from(u32::MAX) + 1;
1325 let frame = Frame {
1326 timestamp: Timestamp::from_scale(0, timescale.as_u64()).unwrap(),
1327 payload: Bytes::from_static(&[0xDE, 0xAD]),
1328 keyframe: true,
1329 duration: Some(Timestamp::from_scale(over, timescale.as_u64()).unwrap()),
1330 };
1331
1332 let err = encode_fragment(info(1, timescale, 0), std::slice::from_ref(&frame)).unwrap_err();
1333 assert!(matches!(err, Error::SampleDurationTooLarge(ticks) if ticks == over));
1334
1335 let largest = Frame {
1336 duration: Some(Timestamp::from_scale(u64::from(u32::MAX), timescale.as_u64()).unwrap()),
1337 ..frame
1338 };
1339 let fragment = encode_fragment(info(1, timescale, 0), &[largest]).unwrap();
1340 assert_eq!(sample_durations(&fragment), vec![Some(u32::MAX)]);
1341 }
1342
1343 #[test]
1346 fn encode_fragment_rejects_a_duration_shorter_than_one_tick() {
1347 let timescale = moq_net::Timescale::SECOND;
1348 let frame = Frame {
1349 timestamp: Timestamp::from_secs(0).unwrap(),
1350 payload: Bytes::from_static(&[0xDE, 0xAD]),
1351 keyframe: true,
1352 duration: Some(Timestamp::from_millis(33).unwrap()),
1353 };
1354
1355 let err = encode_fragment(info(1, timescale, 0), std::slice::from_ref(&frame)).unwrap_err();
1356 assert!(matches!(err, Error::SampleDurationTooSmall(1)));
1357
1358 let one_tick = Frame {
1359 duration: Some(Timestamp::from_secs(1).unwrap()),
1360 ..frame
1361 };
1362 let fragment = encode_fragment(info(1, timescale, 0), &[one_tick]).unwrap();
1363 assert_eq!(sample_durations(&fragment), vec![Some(1)]);
1364 }
1365
1366 #[test]
1369 fn encode_fragment_rejects_an_inexact_sample_duration() {
1370 let input_scale = moq_net::Timescale::new(24).unwrap();
1371 let output_scale = moq_net::Timescale::MILLI;
1372 let frame = Frame {
1373 timestamp: Timestamp::new(0, input_scale).unwrap(),
1374 payload: Bytes::from_static(&[0xDE, 0xAD]),
1375 keyframe: true,
1376 duration: Some(Timestamp::new(1, input_scale).unwrap()),
1377 };
1378
1379 let err = encode_fragment(info(1, output_scale, 0), std::slice::from_ref(&frame)).unwrap_err();
1380 assert!(matches!(err, Error::SampleDurationInexact(1_000)));
1381
1382 let exact_scale = moq_net::Timescale::new(24_000).unwrap();
1383 let fragment = encode_fragment(info(1, exact_scale, 0), &[frame]).unwrap();
1384 assert_eq!(sample_durations(&fragment), vec![Some(1_000)]);
1385 }
1386
1387 #[test]
1390 fn encode_fragment_rejects_a_pts_too_large_for_tfdt() {
1391 let timescale = moq_net::Timescale::new(u64::from(u32::MAX)).unwrap();
1392 let frame = Frame {
1393 timestamp: Timestamp::from_secs(1 << 40).unwrap(),
1394 payload: Bytes::from_static(&[0xDE, 0xAD]),
1395 keyframe: true,
1396 duration: None,
1397 };
1398
1399 let err = encode_fragment(info(1, timescale, 0), &[frame]).unwrap_err();
1400 assert!(matches!(err, Error::PtsOverflow));
1401 }
1402
1403 #[test]
1404 fn reordered_pts_round_trips_with_cts() {
1405 let timescale = moq_net::Timescale::new(1_000_000).unwrap();
1406 let input = vec![
1407 Frame {
1408 timestamp: ts(0),
1409 payload: Bytes::from_static(&[0x00]),
1410 keyframe: true,
1411 duration: Some(ts(33_000)),
1412 },
1413 Frame {
1414 timestamp: ts(99_000),
1415 payload: Bytes::from_static(&[0x01]),
1416 keyframe: false,
1417 duration: Some(ts(33_000)),
1418 },
1419 Frame {
1420 timestamp: ts(33_000),
1421 payload: Bytes::from_static(&[0x02]),
1422 keyframe: false,
1423 duration: Some(ts(33_000)),
1424 },
1425 ];
1426
1427 let fragment = encode_fragment(info(1, timescale, 0), &input).unwrap();
1428 let frames = decode(fragment, timescale, Kind::Video).unwrap();
1429
1430 assert_eq!(frames.len(), input.len());
1431 for (actual, expected) in frames.iter().zip(&input) {
1432 assert_eq!(actual.timestamp, expected.timestamp);
1433 assert_eq!(actual.duration, expected.duration);
1434 assert_eq!(actual.payload, expected.payload);
1435 }
1436 }
1437
1438 #[test]
1442 fn audio_samples_decode_without_the_keyframe_bit() {
1443 let timescale = moq_net::Timescale::new(48_000).unwrap();
1444 let input: Vec<Frame> = (0..3)
1445 .map(|i| Frame {
1446 timestamp: Timestamp::new(i * 960, timescale).unwrap(),
1447 payload: Bytes::from_static(&[0x00]),
1448 keyframe: i == 0,
1449 duration: Some(Timestamp::new(960, timescale).unwrap()),
1450 })
1451 .collect();
1452 let fragment = encode_fragment(
1453 FragmentInfo {
1454 kind: Kind::Audio,
1455 ..info(1, timescale, 0)
1456 },
1457 &input,
1458 )
1459 .unwrap();
1460
1461 let flags: Vec<u32> = first_traf(&fragment).trun[0]
1463 .entries
1464 .iter()
1465 .map(|entry| entry.flags.unwrap())
1466 .collect();
1467 assert_eq!(flags, vec![0x0200_0000; 3]);
1468
1469 let audio = decode(fragment.clone(), timescale, Kind::Audio).unwrap();
1470 assert!(
1471 audio.iter().all(|frame| !frame.keyframe),
1472 "audio never decodes a keyframe"
1473 );
1474 let video = decode(fragment, timescale, Kind::Video).unwrap();
1475 assert!(
1476 video.iter().all(|frame| frame.keyframe),
1477 "the sync flag is a video keyframe"
1478 );
1479 }
1480
1481 #[test]
1482 fn unsupported_track_handler_is_rejected() {
1483 assert!(matches!(
1484 Kind::from_handler((*b"text").into()),
1485 Err(Error::UnknownTrackHandler(_))
1486 ));
1487 }
1488
1489 #[test]
1491 fn wire_knows_an_audio_track_by_its_handler() {
1492 let audio = Wire::new(synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap());
1493 assert_eq!(audio.kind().unwrap(), Kind::Audio);
1494
1495 let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
1496 config.coded_width = Some(320);
1497 config.coded_height = Some(240);
1498 let video = Wire::new(synthesize_video_trak(1, 90_000, &config, None).unwrap());
1499 assert_eq!(video.kind().unwrap(), Kind::Video);
1500 }
1501
1502 #[test]
1503 fn decode_without_duration_reports_none() {
1504 let timescale = moq_net::Timescale::new(90_000).unwrap();
1507 let frames = vec![Frame {
1508 timestamp: ts(0),
1509 payload: Bytes::from_static(&[0xDE, 0xAD]),
1510 keyframe: true,
1511 duration: None,
1512 }];
1513
1514 let fragment = encode_fragment(info(1, timescale, 0), &frames).unwrap();
1515 let frames = decode(fragment, timescale, Kind::Video).unwrap();
1516
1517 assert_eq!(frames.len(), 1);
1518 assert_eq!(frames[0].duration, None);
1519 }
1520
1521 #[test]
1522 fn decode_zero_duration_reports_none() {
1523 use mp4_atom::Encode;
1524
1525 let timescale = moq_net::Timescale::new(24_000).unwrap();
1526 let moof = mp4_atom::Moof {
1527 mfhd: mp4_atom::Mfhd { sequence_number: 0 },
1528 traf: vec![mp4_atom::Traf {
1529 tfhd: mp4_atom::Tfhd {
1530 track_id: 1,
1531 default_sample_duration: Some(0),
1532 default_sample_size: Some(2),
1533 ..Default::default()
1534 },
1535 tfdt: Some(mp4_atom::Tfdt {
1536 base_media_decode_time: 2_000,
1537 }),
1538 trun: vec![mp4_atom::Trun {
1539 data_offset: Some(0),
1540 entries: vec![mp4_atom::TrunEntry {
1541 size: None,
1542 duration: None,
1543 ..Default::default()
1544 }],
1545 }],
1546 ..Default::default()
1547 }],
1548 };
1549
1550 let mut buf = Vec::new();
1551 moof.encode(&mut buf).unwrap();
1552 mp4_atom::Mdat { data: vec![0xDE, 0xAD] }.encode(&mut buf).unwrap();
1553
1554 let frames = decode(Bytes::from(buf), timescale, Kind::Video).unwrap();
1555 assert_eq!(frames.len(), 1);
1556 assert_eq!(frames[0].timestamp.as_micros(), 83_333);
1557 assert_eq!(frames[0].duration, None);
1558 }
1559}