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