1mod export;
10mod import;
11mod muxer;
12
13pub use export::*;
14pub use import::*;
15pub use muxer::*;
16
17#[cfg(test)]
18mod export_test;
19#[cfg(test)]
20mod import_test;
21
22use std::task::Poll;
23
24use bytes::Bytes;
25use hang::catalog::{AudioCodec, AudioConfig, VideoCodec, VideoConfig};
26use mp4_atom::Atom;
27
28use moq_net::Timestamp;
29
30use crate::container::{Container, Frame};
31
32#[derive(Debug, Clone, thiserror::Error)]
33#[non_exhaustive]
34pub enum Error {
35 #[error("mp4: {0}")]
36 Mp4(std::sync::Arc<mp4_atom::Error>),
37
38 #[error("moq: {0}")]
39 Moq(#[from] moq_net::Error),
40
41 #[error("flac: {0}")]
42 Flac(#[from] crate::codec::flac::Error),
43
44 #[error("missing keyframe: a group must open on a keyframe")]
45 MissingKeyframe(#[from] crate::container::MissingKeyframe),
46
47 #[error("timestamp overflow")]
48 TimestampOverflow(#[from] moq_net::TimeOverflow),
49
50 #[error("no traf in moof")]
51 NoTraf,
52
53 #[error("no tfdt in traf")]
54 NoTfdt,
55
56 #[error("PTS overflow")]
57 PtsOverflow,
58
59 #[error("missing moof")]
60 NoMoof,
61
62 #[error("missing mdat")]
63 NoMdat,
64
65 #[error("missing moov")]
66 NoMoov,
67
68 #[error("no tracks in moov")]
69 NoTracks,
70
71 #[error("multiple tracks in moov, use Trak instead")]
72 MultipleTracks,
73
74 #[error("can't synthesize CMAF init for {0}")]
75 UnsupportedSynthesis(String),
76
77 #[error("subtitle tracks are not supported")]
78 UnsupportedSubtitle,
79
80 #[error("unknown track handler: {0:?}")]
81 UnknownTrackHandler([u8; 4]),
82
83 #[error("missing codec")]
84 MissingCodec,
85
86 #[error("multiple codecs")]
87 MultipleCodecs,
88
89 #[error("unknown codec: {0:?}")]
90 UnknownCodec(mp4_atom::FourCC),
91
92 #[error("unsupported codec: {0:?}")]
93 UnsupportedCodec(Box<mp4_atom::Codec>),
94
95 #[error("unsupported codec: MPEG2")]
96 UnsupportedMpeg2,
97
98 #[error("duplicate moof")]
99 DuplicateMoof,
100
101 #[error("missing trun")]
102 MissingTrun,
103
104 #[error("missing tfdt")]
105 MissingTfdt,
106
107 #[error("video codec {0} needs a description (codec config record) to synthesize a CMAF init")]
108 MissingVideoDescription(String),
109
110 #[error("video track {0} missing in catalog")]
111 MissingVideoTrack(String),
112
113 #[error("audio track {0} missing in catalog")]
114 MissingAudioTrack(String),
115
116 #[error("invalid data offset")]
117 InvalidDataOffset,
118
119 #[error("unknown track {0}")]
120 UnknownTrack(u32),
121
122 #[error("no keyframe at start of group")]
123 NoKeyframe,
124
125 #[error("track sample range {start}..{end} is out of bounds of mdat (len {len})")]
126 SampleRangeOutOfBounds { start: usize, end: usize, len: usize },
127
128 #[error("no catalog snapshot")]
129 NoCatalogSnapshot,
130
131 #[error("encode_fragment called with no frames")]
132 NoFrames,
133
134 #[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")]
135 MissingAudioDescription(String),
136
137 #[error("multi-sample fragment has a non-final sample with no duration; DTS is unrecoverable")]
138 MissingSampleDuration,
139}
140
141impl From<mp4_atom::Error> for Error {
142 fn from(err: mp4_atom::Error) -> Self {
143 Error::Mp4(std::sync::Arc::new(err))
144 }
145}
146
147pub type Result<T> = std::result::Result<T, Error>;
148
149pub struct Wire {
158 trak: Box<mp4_atom::Trak>,
159}
160
161impl Wire {
162 pub fn new(trak: mp4_atom::Trak) -> Self {
164 Self { trak: Box::new(trak) }
165 }
166
167 pub fn from_init(init_data: &[u8]) -> Result<Self> {
169 use mp4_atom::DecodeMaybe;
170
171 let mut cursor = std::io::Cursor::new(init_data);
172 while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
173 if let mp4_atom::Any::Moov(mut moov) = atom {
174 return match moov.trak.len() {
175 1 => Ok(Self::new(moov.trak.remove(0))),
176 0 => Err(Error::NoTracks),
177 _ => Err(Error::MultipleTracks),
178 };
179 }
180 }
181 Err(Error::NoMoov)
182 }
183
184 pub fn trak(&self) -> &mp4_atom::Trak {
185 &self.trak
186 }
187}
188
189impl Container for Wire {
190 type Error = Error;
191
192 fn write(&self, group: &mut moq_net::group::Producer, frames: &[Frame]) -> std::result::Result<(), Self::Error> {
193 let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
194 let track_id = self.trak.tkhd.track_id;
195 encode(group, frames, timescale, track_id)
196 }
197
198 fn poll_read(
199 &self,
200 group: &mut moq_net::group::Consumer,
201 waiter: &kio::Waiter,
202 ) -> Poll<std::result::Result<Option<Vec<Frame>>, Self::Error>> {
203 use std::task::ready;
204
205 let Some(frame) = ready!(group.poll_read_frame(waiter)?) else {
206 return Poll::Ready(Ok(None));
207 };
208
209 let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
210 Poll::Ready(Ok(Some(decode(frame.payload, timescale)?)))
211 }
212}
213
214pub(crate) fn decode(data: Bytes, timescale: moq_net::Timescale) -> Result<Vec<Frame>> {
215 use mp4_atom::DecodeMaybe;
216
217 let mut cursor = std::io::Cursor::new(&data);
218 let mut moof = None;
219 let mut mdat_data = None;
220
221 while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
222 match atom {
223 mp4_atom::Any::Moof(m) => moof = Some(m),
224 mp4_atom::Any::Mdat(m) => mdat_data = Some(m.data),
225 _ => {}
226 }
227 }
228
229 let moof = moof.ok_or(Error::NoMoof)?;
230 let mdat_data = mdat_data.ok_or(Error::NoMdat)?;
231 let traf = moof.traf.first().ok_or(Error::NoTraf)?;
232 let tfdt = traf.tfdt.as_ref().ok_or(Error::NoTfdt)?;
233 let base_dts = tfdt.base_media_decode_time;
234
235 let default_size = traf.tfhd.default_sample_size;
236 let default_duration = traf.tfhd.default_sample_duration;
237
238 let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum();
242
243 let mut frames = Vec::new();
244 let mut offset = 0usize;
245 let mut dts = base_dts;
246 let mut sample_index = 0usize;
247
248 for trun in &traf.trun {
249 for entry in &trun.entries {
250 let size = entry.size.or(default_size).unwrap_or(0) as usize;
251 let end = offset + size;
252
253 if end > mdat_data.len() {
254 return Err(Error::SampleRangeOutOfBounds {
255 start: offset,
256 end,
257 len: mdat_data.len(),
258 });
259 }
260
261 let cts = entry.cts.unwrap_or_default() as i64;
262 let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?;
263 let timestamp = Timestamp::new(pts, timescale)?;
265 let payload = Bytes::copy_from_slice(&mdat_data[offset..end]);
266 let flags = entry.flags.unwrap_or(0);
267 let keyframe = (flags >> 24) & 0x3 == 0x2;
269
270 let sample_duration = entry.duration.or(default_duration).filter(|d| *d != 0);
273
274 let is_last = sample_index + 1 == total_samples;
277 if sample_duration.is_none() && !is_last {
278 return Err(Error::MissingSampleDuration);
279 }
280
281 let duration = sample_duration
282 .map(|d| Timestamp::new(d as u64, timescale))
283 .transpose()?;
284
285 frames.push(Frame {
286 timestamp,
287 payload,
288 keyframe,
289 duration,
290 });
291
292 offset = end;
293 dts += sample_duration.unwrap_or(0) as u64;
294 sample_index += 1;
295 }
296 }
297
298 Ok(frames)
299}
300
301pub(crate) fn encode(
302 group: &mut moq_net::group::Producer,
303 frames: &[Frame],
304 timescale: moq_net::Timescale,
305 track_id: u32,
306) -> Result<()> {
307 if frames.is_empty() {
308 return Ok(());
309 }
310
311 let sequence_number = group.frame_count() as u32;
312 let bytes = encode_fragment(track_id, timescale, sequence_number, frames)?;
313 let mut writer = group.create_frame(moq_net::frame::Info {
316 size: bytes.len() as u64,
317 timestamp: frames[0].timestamp,
318 })?;
319 writer.write(bytes)?;
320 writer.finish()?;
321
322 Ok(())
323}
324
325pub(crate) fn encode_fragment(
334 track_id: u32,
335 timescale: moq_net::Timescale,
336 sequence_number: u32,
337 frames: &[Frame],
338) -> Result<Bytes> {
339 use mp4_atom::Encode;
340
341 if frames.is_empty() {
342 return Ok(Bytes::new());
343 }
344
345 let base_dts = frames[0].timestamp.as_scale(timescale) as u64;
350 let mut dts = base_dts;
351
352 let entries: Vec<_> = frames
353 .iter()
354 .map(|f| {
355 let flags = if f.keyframe { 0x0200_0000 } else { 0x0001_0000 };
356 let duration = f.duration.map(|d| d.as_scale(timescale) as u32);
359 let pts = f.timestamp.as_scale(timescale) as i128;
360 let cts = pts - i128::from(dts);
361 let cts = i32::try_from(cts).map_err(|_| Error::PtsOverflow)?;
362
363 if let Some(duration) = duration {
366 dts = dts.checked_add(u64::from(duration)).ok_or(Error::PtsOverflow)?;
367 }
368
369 Ok(mp4_atom::TrunEntry {
370 duration,
371 size: Some(f.payload.len() as u32),
372 flags: Some(flags),
373 cts: (cts != 0).then_some(cts),
374 })
375 })
376 .collect::<Result<_>>()?;
377
378 let mdat_data: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
379
380 let build_moof = |data_offset| mp4_atom::Moof {
381 mfhd: mp4_atom::Mfhd { sequence_number },
382 traf: vec![mp4_atom::Traf {
383 tfhd: mp4_atom::Tfhd {
384 track_id,
385 ..Default::default()
386 },
387 tfdt: Some(mp4_atom::Tfdt {
388 base_media_decode_time: base_dts,
389 }),
390 trun: vec![mp4_atom::Trun {
391 data_offset: Some(data_offset),
392 entries: entries.clone(),
393 }],
394 ..Default::default()
395 }],
396 };
397
398 let mut buf = Vec::new();
400 build_moof(0).encode(&mut buf)?;
401 let moof_size = buf.len();
402
403 buf.clear();
405 build_moof((moof_size + 8) as i32).encode(&mut buf)?;
406
407 let mdat = mp4_atom::Mdat { data: mdat_data };
408 mdat.encode(&mut buf)?;
409
410 Ok(Bytes::from(buf))
411}
412
413pub(crate) fn synthesize_video_trak(
421 track_id: u32,
422 timescale: u64,
423 config: &VideoConfig,
424 description: Option<&[u8]>,
425) -> Result<mp4_atom::Trak> {
426 let width = config.coded_width.unwrap_or(0) as u16;
427 let height = config.coded_height.unwrap_or(0) as u16;
428 let visual = mp4_atom::Visual {
429 data_reference_index: 1,
430 width,
431 height,
432 ..Default::default()
433 };
434
435 let require_description = || description.ok_or_else(|| Error::MissingVideoDescription(config.codec.to_string()));
437
438 let sample_entry = match &config.codec {
439 VideoCodec::H264(_) => {
440 let mut cursor = std::io::Cursor::new(require_description()?);
441 let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::from)?;
442 mp4_atom::Codec::from(mp4_atom::Avc1 {
443 visual,
444 avcc,
445 ..Default::default()
446 })
447 }
448 VideoCodec::H265(h265) => {
449 let mut cursor = std::io::Cursor::new(require_description()?);
450 let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::from)?;
451 if h265.in_band {
453 mp4_atom::Codec::from(mp4_atom::Hev1 {
454 visual,
455 hvcc,
456 ..Default::default()
457 })
458 } else {
459 mp4_atom::Codec::from(mp4_atom::Hvc1 {
460 visual,
461 hvcc,
462 ..Default::default()
463 })
464 }
465 }
466 VideoCodec::AV1(av1) => mp4_atom::Codec::from(mp4_atom::Av01 {
467 visual,
468 av1c: crate::codec::av1::av1c_from_av1(av1),
469 ..Default::default()
470 }),
471 VideoCodec::VP8 => mp4_atom::Codec::from(mp4_atom::Vp08 {
472 visual,
473 vpcc: crate::codec::vp8::vpcc(),
474 ..Default::default()
475 }),
476 VideoCodec::VP9(vp9) => mp4_atom::Codec::from(mp4_atom::Vp09 {
477 visual,
478 vpcc: crate::codec::vp9::vpcc(vp9),
479 ..Default::default()
480 }),
481 other => return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", other))),
482 };
483
484 Ok(build_video_trak(track_id, timescale, sample_entry, width, height))
485}
486
487pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &AudioConfig) -> Result<mp4_atom::Trak> {
489 use mp4_atom::Decode;
490
491 let audio = mp4_atom::Audio {
492 data_reference_index: 1,
493 channel_count: config.channel_count as u16,
494 sample_size: 16,
495 sample_rate: mp4_atom::FixedPoint::from(config.sample_rate as u16),
496 };
497
498 let sample_entry = match &config.codec {
499 AudioCodec::Opus => mp4_atom::Codec::from(mp4_atom::Opus {
500 audio,
501 dops: mp4_atom::Dops {
502 output_channel_count: config.channel_count as u8,
503 pre_skip: 0,
504 input_sample_rate: config.sample_rate,
505 output_gain: 0,
506 },
507 btrt: None,
508 }),
509 AudioCodec::AAC(_) => {
510 let description = config
515 .description
516 .as_ref()
517 .ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
518 let mut cursor = std::io::Cursor::new(description.as_ref());
519 let dec_specific = mp4_atom::esds::DecoderSpecific::decode(&mut cursor)?;
520
521 let bitrate = config.bitrate.unwrap_or(0) as u32;
522 mp4_atom::Codec::from(mp4_atom::Mp4a {
523 audio,
524 esds: mp4_atom::Esds {
525 es_desc: mp4_atom::esds::EsDescriptor {
526 es_id: 0,
528 dec_config: mp4_atom::esds::DecoderConfig {
529 object_type_indication: 0x40, stream_type: 0x05, up_stream: 0,
532 buffer_size_db: Default::default(),
533 max_bitrate: bitrate,
534 avg_bitrate: bitrate,
535 dec_specific,
536 },
537 sl_config: Default::default(),
538 },
539 },
540 btrt: None,
541 taic: None,
542 })
543 }
544 AudioCodec::Flac => {
545 let description = config
548 .description
549 .as_ref()
550 .ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
551 let info = crate::codec::flac::Config::parse(&mut description.as_ref())?;
552
553 let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo {
554 minimum_block_size: info.min_block_size,
555 maximum_block_size: info.max_block_size,
556 minimum_frame_size: info.min_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
558 maximum_frame_size: info.max_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
559 sample_rate: info.sample_rate,
560 num_channels_minus_one: info.channel_count.saturating_sub(1) as u8,
561 bits_per_sample_minus_one: info.bits_per_sample.saturating_sub(1) as u8,
562 number_of_interchannel_samples: info.total_samples,
563 md5_checksum: info.md5.to_vec(),
564 };
565
566 mp4_atom::Codec::from(mp4_atom::Flac {
567 audio,
568 dfla: mp4_atom::Dfla {
569 blocks: vec![stream_info],
570 },
571 })
572 }
573 other => return Err(Error::UnsupportedSynthesis(format!("audio codec {:?}", other))),
574 };
575
576 Ok(build_audio_trak(track_id, timescale, sample_entry))
577}
578
579fn build_video_trak(
580 track_id: u32,
581 timescale: u64,
582 sample_entry: mp4_atom::Codec,
583 width: u16,
584 height: u16,
585) -> mp4_atom::Trak {
586 mp4_atom::Trak {
587 tkhd: mp4_atom::Tkhd {
588 track_id,
589 enabled: true,
590 width: mp4_atom::FixedPoint::from(width),
591 height: mp4_atom::FixedPoint::from(height),
592 ..Default::default()
593 },
594 mdia: build_mdia(timescale, b"vide", true, sample_entry),
595 ..Default::default()
596 }
597}
598
599fn build_audio_trak(track_id: u32, timescale: u64, sample_entry: mp4_atom::Codec) -> mp4_atom::Trak {
600 mp4_atom::Trak {
601 tkhd: mp4_atom::Tkhd {
602 track_id,
603 enabled: true,
604 ..Default::default()
605 },
606 mdia: build_mdia(timescale, b"soun", false, sample_entry),
607 ..Default::default()
608 }
609}
610
611fn build_mdia(timescale: u64, handler: &[u8; 4], is_video: bool, sample_entry: mp4_atom::Codec) -> mp4_atom::Mdia {
612 mp4_atom::Mdia {
613 mdhd: mp4_atom::Mdhd {
614 timescale: timescale as u32,
615 ..Default::default()
616 },
617 hdlr: mp4_atom::Hdlr {
618 handler: mp4_atom::FourCC::new(handler),
619 name: String::new(),
620 },
621 minf: mp4_atom::Minf {
622 vmhd: is_video.then(mp4_atom::Vmhd::default),
623 smhd: (!is_video).then(mp4_atom::Smhd::default),
624 dinf: mp4_atom::Dinf {
625 dref: mp4_atom::Dref {
626 urls: vec![mp4_atom::Url::default()],
627 },
628 },
629 stbl: mp4_atom::Stbl {
630 stsd: mp4_atom::Stsd {
631 codecs: vec![sample_entry],
632 },
633 ..Default::default()
634 },
635 ..Default::default()
636 },
637 }
638}
639
640pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 {
646 if let Some(fps) = config.framerate {
647 (fps * 1000.0) as u64
648 } else {
649 90000
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 fn ts(micros: u64) -> Timestamp {
658 Timestamp::from_micros(micros).unwrap()
659 }
660
661 #[test]
662 fn decode_reads_trun_sample_duration() {
663 use mp4_atom::Encode;
664
665 let timescale = moq_net::Timescale::MICRO;
669 let moof = mp4_atom::Moof {
670 mfhd: mp4_atom::Mfhd { sequence_number: 0 },
671 traf: vec![mp4_atom::Traf {
672 tfhd: mp4_atom::Tfhd {
673 track_id: 1,
674 ..Default::default()
675 },
676 tfdt: Some(mp4_atom::Tfdt {
677 base_media_decode_time: 0,
678 }),
679 trun: vec![mp4_atom::Trun {
680 data_offset: Some(0),
681 entries: vec![
682 mp4_atom::TrunEntry {
683 size: Some(2),
684 duration: Some(33_333),
685 ..Default::default()
686 },
687 mp4_atom::TrunEntry {
688 size: Some(2),
689 duration: Some(33_333),
690 ..Default::default()
691 },
692 ],
693 }],
694 ..Default::default()
695 }],
696 };
697
698 let mut buf = Vec::new();
699 moof.encode(&mut buf).unwrap();
700 mp4_atom::Mdat {
701 data: vec![0xDE, 0xAD, 0xBE, 0xEF],
702 }
703 .encode(&mut buf)
704 .unwrap();
705
706 let frames = decode(Bytes::from(buf), timescale).unwrap();
707 assert_eq!(frames.len(), 2);
708 assert_eq!(frames[0].timestamp, ts(0));
709 assert_eq!(frames[0].duration, Some(ts(33_333)));
710 assert_eq!(frames[1].timestamp, ts(33_333));
711 assert_eq!(frames[1].duration, Some(ts(33_333)));
712 }
713
714 #[test]
715 fn duration_round_trips_through_encode() {
716 let timescale = moq_net::Timescale::MICRO;
718 let input = vec![Frame {
719 timestamp: ts(0),
720 payload: Bytes::from_static(&[0xDE, 0xAD]),
721 keyframe: true,
722 duration: Some(ts(33_333)),
723 }];
724
725 let fragment = encode_fragment(1, timescale, 0, &input).unwrap();
726 let frames = decode(fragment, timescale).unwrap();
727
728 assert_eq!(frames.len(), 1);
729 assert_eq!(frames[0].duration, Some(ts(33_333)));
730 }
731
732 #[test]
733 fn reordered_pts_round_trips_with_cts() {
734 let timescale = moq_net::Timescale::new(1_000_000).unwrap();
735 let input = vec![
736 Frame {
737 timestamp: ts(0),
738 payload: Bytes::from_static(&[0x00]),
739 keyframe: true,
740 duration: Some(ts(33_000)),
741 },
742 Frame {
743 timestamp: ts(99_000),
744 payload: Bytes::from_static(&[0x01]),
745 keyframe: false,
746 duration: Some(ts(33_000)),
747 },
748 Frame {
749 timestamp: ts(33_000),
750 payload: Bytes::from_static(&[0x02]),
751 keyframe: false,
752 duration: Some(ts(33_000)),
753 },
754 ];
755
756 let fragment = encode_fragment(1, timescale, 0, &input).unwrap();
757 let frames = decode(fragment, timescale).unwrap();
758
759 assert_eq!(frames.len(), input.len());
760 for (actual, expected) in frames.iter().zip(&input) {
761 assert_eq!(actual.timestamp, expected.timestamp);
762 assert_eq!(actual.duration, expected.duration);
763 assert_eq!(actual.payload, expected.payload);
764 }
765 }
766
767 #[test]
768 fn decode_without_duration_reports_none() {
769 let timescale = moq_net::Timescale::new(90_000).unwrap();
772 let frames = vec![Frame {
773 timestamp: ts(0),
774 payload: Bytes::from_static(&[0xDE, 0xAD]),
775 keyframe: true,
776 duration: None,
777 }];
778
779 let fragment = encode_fragment(1, timescale, 0, &frames).unwrap();
780 let frames = decode(fragment, timescale).unwrap();
781
782 assert_eq!(frames.len(), 1);
783 assert_eq!(frames[0].duration, None);
784 }
785
786 #[test]
787 fn decode_zero_duration_reports_none() {
788 use mp4_atom::Encode;
789
790 let timescale = moq_net::Timescale::new(24_000).unwrap();
791 let moof = mp4_atom::Moof {
792 mfhd: mp4_atom::Mfhd { sequence_number: 0 },
793 traf: vec![mp4_atom::Traf {
794 tfhd: mp4_atom::Tfhd {
795 track_id: 1,
796 default_sample_duration: Some(0),
797 default_sample_size: Some(2),
798 ..Default::default()
799 },
800 tfdt: Some(mp4_atom::Tfdt {
801 base_media_decode_time: 2_000,
802 }),
803 trun: vec![mp4_atom::Trun {
804 data_offset: Some(0),
805 entries: vec![mp4_atom::TrunEntry {
806 size: None,
807 duration: None,
808 ..Default::default()
809 }],
810 }],
811 ..Default::default()
812 }],
813 };
814
815 let mut buf = Vec::new();
816 moof.encode(&mut buf).unwrap();
817 mp4_atom::Mdat { data: vec![0xDE, 0xAD] }.encode(&mut buf).unwrap();
818
819 let frames = decode(Bytes::from(buf), timescale).unwrap();
820 assert_eq!(frames.len(), 1);
821 assert_eq!(frames[0].timestamp.as_micros(), 83_333);
822 assert_eq!(frames[0].duration, None);
823 }
824}