Skip to main content

mp4_edit/atom/container/
moov.rs

1use std::{fmt::Debug, ops::RangeBounds, time::Duration};
2
3use bon::bon;
4
5use crate::{
6    atom::{
7        atom_ref::{self, unwrap_atom_data},
8        hdlr::HandlerType,
9        mdhd::MDHD,
10        mvhd::MVHD,
11        AtomHeader, MovieHeaderAtom, TrakAtomRef, TrakAtomRefMut, UserDataAtomRefMut, TRAK, UDTA,
12    },
13    Atom, AtomData, FourCC,
14};
15
16pub const MOOV: FourCC = FourCC::new(b"moov");
17
18#[derive(Debug, Clone, Copy)]
19pub struct MoovAtomRef<'a>(pub(crate) atom_ref::AtomRef<'a>);
20
21impl<'a> MoovAtomRef<'a> {
22    pub fn children(&self) -> impl Iterator<Item = &'a Atom> {
23        self.0.children()
24    }
25
26    pub fn header(&self) -> Option<&'a MovieHeaderAtom> {
27        let atom = self.children().find(|a| a.header.atom_type == MVHD)?;
28        match atom.data.as_ref()? {
29            AtomData::MovieHeader(data) => Some(data),
30            _ => None,
31        }
32    }
33
34    /// Iterate through TRAK atoms
35    pub fn into_tracks_iter(self) -> impl Iterator<Item = TrakAtomRef<'a>> {
36        self.children()
37            .filter(|a| a.header.atom_type == TRAK)
38            .map(TrakAtomRef::new)
39    }
40
41    /// Iterate through TRAK atoms with handler type Audio
42    pub fn into_audio_track_iter(self) -> impl Iterator<Item = TrakAtomRef<'a>> {
43        self.into_tracks_iter().filter(|trak| {
44            matches!(
45                trak.media()
46                    .handler_reference()
47                    .map(|hdlr| &hdlr.handler_type),
48                Some(HandlerType::Audio)
49            )
50        })
51    }
52
53    /// Calculates the next track id based on existing track ids.
54    ///
55    /// The returned id will be the greater of `len(tracks)+1` or `max(tracks(id))+1`.
56    pub fn next_track_id(&self) -> u32 {
57        self.children()
58            .filter(|a| a.header.atom_type == TRAK)
59            .map(TrakAtomRef::new)
60            .fold(1, |id, trak| {
61                (trak.track_id().unwrap_or_default() + 1).max(id)
62            })
63    }
64}
65
66#[derive(Debug)]
67pub struct MoovAtomRefMut<'a>(pub(crate) atom_ref::AtomRefMut<'a>);
68
69impl<'a> MoovAtomRefMut<'a> {
70    pub fn as_ref(&self) -> MoovAtomRef<'_> {
71        MoovAtomRef(self.0.as_ref())
72    }
73
74    pub fn into_ref(self) -> MoovAtomRef<'a> {
75        MoovAtomRef(self.0.into_ref())
76    }
77
78    pub fn children(&mut self) -> impl Iterator<Item = &'_ mut Atom> {
79        self.0.children()
80    }
81
82    /// Finds or inserts MVHD atom
83    pub fn header(&mut self) -> &'_ mut MovieHeaderAtom {
84        unwrap_atom_data!(
85            self.0.find_or_insert_child(MVHD).call(),
86            AtomData::MovieHeader,
87        )
88    }
89
90    /// Finds or inserts UDTA atom
91    pub fn user_data(&mut self) -> UserDataAtomRefMut<'_> {
92        UserDataAtomRefMut(
93            self.0
94                .find_or_insert_child(UDTA)
95                .insert_after(vec![TRAK, MVHD])
96                .call(),
97        )
98    }
99
100    pub fn tracks(&mut self) -> impl Iterator<Item = TrakAtomRefMut<'_>> {
101        self.0
102            .children()
103            .filter(|a| a.header.atom_type == TRAK)
104            .map(TrakAtomRefMut::new)
105    }
106
107    /// Iterate through TRAK atoms with handler type Audio
108    pub fn audio_tracks(&mut self) -> impl Iterator<Item = TrakAtomRefMut<'_>> {
109        self.tracks().filter(|trak| {
110            matches!(
111                trak.as_ref()
112                    .media()
113                    .handler_reference()
114                    .map(|hdlr| &hdlr.handler_type),
115                Some(HandlerType::Audio)
116            )
117        })
118    }
119
120    /// Retains only the TRAK atoms specified by the predicate
121    pub fn tracks_retain<P>(&mut self, mut pred: P) -> &mut Self
122    where
123        P: FnMut(TrakAtomRef) -> bool,
124    {
125        self.0
126             .0
127            .children
128            .retain(|a| a.header.atom_type != TRAK || pred(TrakAtomRef::new(a)));
129        self
130    }
131}
132
133#[cfg(feature = "experimental-trim")]
134#[bon]
135impl<'a> MoovAtomRefMut<'a> {
136    /// Trim duration from tracks.
137    ///
138    /// NOTE: This is meant to be applied to the input metadata.
139    ///
140    /// See also [`Self::retain_duration`].
141    #[builder(finish_fn(name = "trim"), builder_type = TrimDuration)]
142    pub fn trim_duration(
143        &mut self,
144        from_start: Option<Duration>,
145        from_end: Option<Duration>,
146    ) -> &mut Self {
147        use std::ops::Bound;
148        let start_duration = from_start.map(|d| (Bound::Unbounded, Bound::Included(d)));
149        let end_duration = from_end.map(|d| {
150            let d = self.header().duration().saturating_sub(d);
151            (Bound::Included(d), Bound::Unbounded)
152        });
153        let trim_ranges = vec![start_duration, end_duration]
154            .into_iter()
155            .flatten()
156            .collect::<Vec<_>>();
157        self.trim_duration_ranges(&trim_ranges)
158    }
159
160    /// Retains given duration range, trimming everything before and after.
161    ///
162    /// NOTE: This is meant to be applied to the input metadata.
163    ///
164    /// See also [`Self::trim_duration`].
165    #[builder(finish_fn(name = "retain"), builder_type = RetainDuration)]
166    pub fn retain_duration(
167        &mut self,
168        from_offset: Option<Duration>,
169        duration: Duration,
170    ) -> &mut Self {
171        use std::ops::Bound;
172        let trim_ranges = vec![
173            (
174                Bound::Unbounded,
175                Bound::Excluded(from_offset.unwrap_or_default()),
176            ),
177            (
178                Bound::Included(from_offset.unwrap_or_default() + duration),
179                Bound::Unbounded,
180            ),
181        ];
182        self.trim_duration_ranges(&trim_ranges)
183    }
184
185    fn trim_duration_ranges<R>(&mut self, trim_ranges: &[R]) -> &mut Self
186    where
187        R: RangeBounds<Duration> + Clone + Debug,
188    {
189        let movie_timescale = u64::from(self.header().timescale);
190        let mut remaining_audio_duration = None;
191        let remaining_duration = self
192            .tracks()
193            .map(|mut trak| {
194                let handler_type = trak
195                    .as_ref()
196                    .media()
197                    .handler_reference()
198                    .map(|hdlr| hdlr.handler_type.clone());
199                let remaining_duration = trak.trim_duration(movie_timescale, trim_ranges);
200                if let Some(HandlerType::Audio) = handler_type {
201                    if remaining_audio_duration.is_none() {
202                        remaining_audio_duration = Some(remaining_duration);
203                    }
204                }
205                remaining_duration
206            })
207            .max();
208        // trust the first audio track's reported duration or fall back to the longest reported duration
209        if let Some(remaining_duration) = remaining_audio_duration.or(remaining_duration) {
210            self.header().update_duration(|_| remaining_duration);
211        }
212        self
213    }
214}
215
216#[cfg(feature = "experimental-trim")]
217#[bon]
218impl<'a, 'b, S: trim_duration::State> TrimDuration<'a, 'b, S> {
219    #[builder(finish_fn(name = "trim"), builder_type = TrimDurationRanges)]
220    fn ranges<R>(
221        self,
222        #[builder(start_fn)] ranges: impl IntoIterator<Item = R>,
223    ) -> &'b mut MoovAtomRefMut<'a>
224    where
225        R: RangeBounds<Duration> + Clone + Debug,
226        S::FromEnd: trim_duration::IsUnset,
227        S::FromStart: trim_duration::IsUnset,
228    {
229        self.self_receiver
230            .trim_duration_ranges(&ranges.into_iter().collect::<Vec<_>>())
231    }
232}
233
234#[bon]
235impl<'a> MoovAtomRefMut<'a> {
236    /// Adds trak atom to moov
237    #[builder]
238    pub fn add_track(
239        &mut self,
240        #[builder(default = Vec::new())] children: Vec<Atom>,
241    ) -> TrakAtomRefMut<'_> {
242        let trak = Atom::builder()
243            .header(AtomHeader::new(*TRAK))
244            .children(children)
245            .build();
246        let index = self.0.get_insert_position().after(vec![TRAK, MDHD]).call();
247        TrakAtomRefMut(self.0.insert_child(index, trak))
248    }
249}
250
251#[cfg(feature = "experimental-trim")]
252#[cfg(test)]
253mod trim_tests {
254    use std::ops::Bound;
255    use std::time::Duration;
256
257    use bon::Builder;
258
259    use crate::{
260        atom::{
261            container::MOOV,
262            ftyp::{FileTypeAtom, FTYP},
263            hdlr::{HandlerReferenceAtom, HandlerType},
264            mvhd::{MovieHeaderAtom, MVHD},
265            stsc::SampleToChunkEntry,
266            trak::trim_tests::{
267                create_test_track, create_test_track_builder, CreateTestTrackBuilder,
268            },
269            util::scaled_duration,
270            Atom, AtomHeader,
271        },
272        parser::Metadata,
273        FourCC,
274    };
275
276    #[bon::builder(finish_fn(name = "build"))]
277    fn create_test_metadata(
278        #[builder(field)] tracks: Vec<Atom>,
279        #[builder(getter)] movie_timescale: u32,
280        #[builder(getter)] duration: Duration,
281    ) -> Metadata {
282        let atoms = vec![
283            Atom::builder()
284                .header(AtomHeader::new(*FTYP))
285                .data(
286                    FileTypeAtom::builder()
287                        .major_brand(*b"isom")
288                        .minor_version(512)
289                        .compatible_brands(
290                            vec![*b"isom", *b"iso2", *b"mp41"]
291                                .into_iter()
292                                .map(FourCC::from)
293                                .collect::<Vec<_>>(),
294                        )
295                        .build(),
296                )
297                .build(),
298            Atom::builder()
299                .header(AtomHeader::new(*MOOV))
300                .children(Vec::from_iter(
301                    std::iter::once(
302                        // Movie header (mvhd)
303                        Atom::builder()
304                            .header(AtomHeader::new(*MVHD))
305                            .data(
306                                MovieHeaderAtom::builder()
307                                    .timescale(movie_timescale)
308                                    .duration(scaled_duration(duration, movie_timescale as u64))
309                                    .next_track_id(2)
310                                    .build(),
311                            )
312                            .build(),
313                    )
314                    .chain(tracks.into_iter()),
315                ))
316                .build(),
317        ];
318
319        Metadata::new(atoms.into())
320    }
321
322    impl<S> CreateTestMetadataBuilder<S>
323    where
324        S: create_test_metadata_builder::State,
325        S::MovieTimescale: create_test_metadata_builder::IsSet,
326        S::Duration: create_test_metadata_builder::IsSet,
327    {
328        fn track<CTBS>(mut self, track: CreateTestTrackBuilder<CTBS>) -> Self
329        where
330            CTBS: create_test_track_builder::State,
331            CTBS::MovieTimescale: create_test_track_builder::IsUnset,
332            CTBS::MediaTimescale: create_test_track_builder::IsSet,
333            CTBS::Duration: create_test_track_builder::IsUnset,
334        {
335            self.tracks.push(
336                track
337                    .movie_timescale(*self.get_movie_timescale())
338                    .duration(self.get_duration().clone())
339                    .build(),
340            );
341            self
342        }
343    }
344
345    fn test_moov_trim_duration(mut metadata: Metadata, test_case: TrimDurationTestCase) {
346        let movie_timescale = test_case.movie_timescale;
347        let media_timescale = test_case.media_timescale;
348
349        // Perform the trim operation with the given trim ranges
350        metadata
351            .moov_mut()
352            .trim_duration()
353            .ranges(
354                test_case
355                    .ranges
356                    .into_iter()
357                    .map(|r| (r.start_bound, r.end_bound))
358                    .collect::<Vec<_>>(),
359            )
360            .trim();
361
362        // Verify movie header duration was updated
363        let new_movie_duration = metadata.moov().header().map(|h| h.duration).unwrap_or(0);
364        let expected_movie_duration = scaled_duration(
365            test_case.expected_remaining_duration,
366            movie_timescale as u64,
367        );
368        assert_eq!(
369            new_movie_duration, expected_movie_duration,
370            "Movie duration should match expected",
371        );
372
373        // Verify track header duration was updated
374        let new_track_duration = metadata
375            .moov()
376            .into_tracks_iter()
377            .next()
378            .and_then(|t| t.header().map(|h| h.duration))
379            .unwrap_or(0);
380        let expected_track_duration = scaled_duration(
381            test_case.expected_remaining_duration,
382            movie_timescale as u64,
383        );
384        assert_eq!(
385            new_track_duration, expected_track_duration,
386            "Track duration should match expected",
387        );
388
389        // Verify media header duration was updated
390        let new_media_duration = metadata
391            .moov()
392            .into_tracks_iter()
393            .next()
394            .map(|t| t.media().header().map(|h| h.duration).unwrap_or(0))
395            .unwrap_or(0);
396        let expected_media_duration = scaled_duration(
397            test_case.expected_remaining_duration,
398            media_timescale as u64,
399        );
400        assert_eq!(
401            new_media_duration, expected_media_duration,
402            "Media duration should match expected",
403        );
404
405        // Verify sample table structure is still valid
406        let track = metadata.moov().into_tracks_iter().next().unwrap();
407        let stbl = track.media().media_information().sample_table();
408
409        // Validate that all required sample table atoms exist
410        let stts = stbl
411            .time_to_sample()
412            .expect("Time-to-sample atom should exist");
413        let stsc = stbl
414            .sample_to_chunk()
415            .expect("Sample-to-chunk atom should exist");
416        let stsz = stbl.sample_size().expect("Sample-size atom should exist");
417        let stco = stbl.chunk_offset().expect("Chunk-offset atom should exist");
418
419        // Calculate total samples from sample sizes
420        let total_samples = stsz.sample_count() as u32;
421        if test_case.expected_remaining_duration != Duration::ZERO {
422            assert!(total_samples > 0, "Sample table should have samples",);
423        }
424
425        // Validate time-to-sample consistency
426        let stts_total_samples: u32 = stts.entries.iter().map(|entry| entry.sample_count).sum();
427        assert_eq!(
428            stts_total_samples, total_samples,
429            "Time-to-sample total samples should match sample size count",
430        );
431
432        // Validate sample-to-chunk references
433        let chunk_count = stco.chunk_count() as u32;
434        assert!(chunk_count > 0, "Should have at least one chunk",);
435
436        // Verify all chunk references in stsc are valid
437        for entry in stsc.entries.iter() {
438            assert!(
439                entry.first_chunk >= 1 && entry.first_chunk <= chunk_count,
440                "Sample-to-chunk first_chunk {} should be between 1 and {}",
441                entry.first_chunk,
442                chunk_count,
443            );
444            assert!(
445                entry.samples_per_chunk > 0,
446                "Sample-to-chunk samples_per_chunk should be > 0",
447            );
448        }
449
450        // Verify expected duration consistency with time-to-sample
451        let total_duration: u64 = stts
452            .entries
453            .iter()
454            .map(|entry| entry.sample_count as u64 * entry.sample_duration as u64)
455            .sum();
456        let expected_duration_scaled = scaled_duration(
457            test_case.expected_remaining_duration,
458            media_timescale as u64,
459        );
460
461        assert_eq!(
462            total_duration, expected_duration_scaled,
463            "Sample table total duration should match the expected duration",
464        );
465    }
466
467    #[derive(Debug, Builder)]
468    struct TrimDurationRange {
469        start_bound: Bound<Duration>,
470        end_bound: Bound<Duration>,
471    }
472
473    #[derive(Debug)]
474    struct TrimDurationTestCase {
475        movie_timescale: u32,
476        media_timescale: u32,
477        original_duration: Duration,
478        ranges: Vec<TrimDurationRange>,
479        expected_remaining_duration: Duration,
480    }
481
482    #[bon::bon]
483    impl TrimDurationTestCase {
484        #[builder]
485        pub fn new(
486            #[builder(field)] ranges: Vec<TrimDurationRange>,
487            #[builder(default = 1_000)] movie_timescale: u32,
488            #[builder(default = 10_000)] media_timescale: u32,
489            original_duration: Duration,
490            expected_remaining_duration: Duration,
491        ) -> Self {
492            assert!(
493                ranges.len() > 0,
494                "test case must include at least one range"
495            );
496
497            Self {
498                movie_timescale,
499                media_timescale,
500                original_duration,
501                ranges,
502                expected_remaining_duration,
503            }
504        }
505    }
506
507    impl<S> TrimDurationTestCaseBuilder<S>
508    where
509        S: trim_duration_test_case_builder::State,
510    {
511        fn range(mut self, range: TrimDurationRange) -> Self {
512            self.ranges.push(range);
513            self
514        }
515    }
516
517    macro_rules! test_moov_trim_duration {
518        ($(
519            $name:ident {
520                $(
521                    @tracks( $($track:expr),*, ),
522                )?
523                $($field:ident: $value:expr),+$(,)?
524            } $(,)?
525        )* $(,)?) => {
526            $(
527                test_moov_trim_duration!(@single $name {
528                    $(
529                        @tracks( $($track),*, ),
530                    )?
531                    $($field: $value),+
532                });
533            )*
534        };
535
536        (@single $name:ident {
537            $($field:ident: $value:expr),+$(,)?
538        } $(,)?) => {
539            test_moov_trim_duration!(@single $name {
540                @tracks(
541                    |media_timescale| create_test_track().media_timescale(media_timescale),
542                ),
543                $($field: $value),+
544            });
545        };
546
547        (@single $name:ident {
548            @tracks($($track:expr),+,),
549            $($field:ident: $value:expr),+
550        } $(,)?) => {
551            test_moov_trim_duration!(@fn_def $name {
552                @tracks($($track),+),
553                $($field: $value),+
554            });
555        };
556
557        (@fn_def $name:ident {
558            @tracks($($track:expr),+),
559            $($field:ident: $value:expr),+$(,)?
560        } $(,)?) => {
561            #[test]
562            fn $name() {
563                let movie_timescale = 1_000;
564                let media_timescale = 10_000;
565
566                let test_case = TrimDurationTestCase::builder().
567                    $($field($value)).+.
568                    build();
569
570                // Create fresh metadata for each test case
571                let metadata = create_test_metadata()
572                    .movie_timescale(movie_timescale)
573                    .duration(test_case.original_duration).
574                    $(
575                        track(
576                            ($track)(media_timescale)
577                        )
578                    ).+.
579                    build();
580
581                test_moov_trim_duration(metadata, test_case);
582            }
583        };
584    }
585
586    test_moov_trim_duration!(
587        trim_start_2_seconds {
588            original_duration: Duration::from_secs(10),
589            range: TrimDurationRange::builder()
590                    .start_bound(Bound::Included(Duration::ZERO))
591                    .end_bound(Bound::Included(Duration::from_secs(2)))
592                    .build(),
593            expected_remaining_duration: Duration::from_secs(8),
594        }
595        trim_end_2_seconds {
596            original_duration: Duration::from_secs(10),
597            range: TrimDurationRange::builder()
598                    .start_bound(Bound::Included(Duration::from_secs(8)))
599                    .end_bound(Bound::Included(Duration::from_secs(10)))
600                    .build(),
601            expected_remaining_duration: Duration::from_secs(8),
602        }
603        trim_middle_2_seconds {
604            original_duration: Duration::from_secs(10),
605            range: TrimDurationRange::builder()
606                    .start_bound(Bound::Included(Duration::from_secs(4)))
607                    .end_bound(Bound::Included(Duration::from_secs(6)))
608                    .build(),
609            expected_remaining_duration: Duration::from_secs(8),
610        }
611        trim_middle_included_start_2_seconds {
612            original_duration: Duration::from_secs(10),
613            range: TrimDurationRange::builder()
614                    .start_bound(Bound::Included(Duration::from_secs(2)))
615                    .end_bound(Bound::Included(Duration::from_secs(4)))
616                    .build(),
617            expected_remaining_duration: Duration::from_secs(8),
618        }
619        trim_middle_excluded_start_2_seconds {
620            original_duration: Duration::from_millis(10_000),
621            range: TrimDurationRange::builder()
622                    .start_bound(Bound::Excluded(Duration::from_millis(1_999)))
623                    .end_bound(Bound::Included(Duration::from_millis(4_000)))
624                    .build(),
625            expected_remaining_duration: Duration::from_millis(8_000),
626        }
627        trim_middle_excluded_end_2_seconds {
628            original_duration: Duration::from_secs(10),
629            range: TrimDurationRange::builder()
630                    .start_bound(Bound::Included(Duration::from_secs(1)))
631                    .end_bound(Bound::Excluded(Duration::from_secs(3)))
632                    .build(),
633            expected_remaining_duration: Duration::from_secs(8),
634        }
635        trim_start_unbounded_5_seconds {
636            original_duration: Duration::from_secs(10),
637            range: TrimDurationRange::builder()
638                    .start_bound(Bound::Unbounded)
639                    .end_bound(Bound::Included(Duration::from_secs(5)))
640                    .build(),
641            expected_remaining_duration: Duration::from_secs(5),
642        }
643        trim_end_unbounded_6_seconds {
644            original_duration: Duration::from_secs(100),
645            range: TrimDurationRange::builder()
646                    .start_bound(Bound::Included(Duration::from_secs(94)))
647                    .end_bound(Bound::Unbounded)
648                    .build(),
649            expected_remaining_duration: Duration::from_secs(94),
650        }
651        trim_start_and_end_20_seconds {
652            original_duration: Duration::from_secs(100),
653            range: TrimDurationRange::builder()
654                    .start_bound(Bound::Unbounded)
655                    .end_bound(Bound::Excluded(Duration::from_secs(20)))
656                    .build(),
657            range: TrimDurationRange::builder()
658                    .start_bound(Bound::Included(Duration::from_secs(80)))
659                    .end_bound(Bound::Unbounded)
660                    .build(),
661            expected_remaining_duration: Duration::from_secs(60),
662        }
663        trim_first_and_last_chunk {
664            @tracks(
665                |media_timescale| create_test_track().stsc_entries(vec![
666                    // 1 sample per second
667                    SampleToChunkEntry::builder()
668                        .first_chunk(1)
669                        .samples_per_chunk(20)
670                        .sample_description_index(1)
671                        .build(),
672                    SampleToChunkEntry::builder()
673                        .first_chunk(2)
674                        .samples_per_chunk(60)
675                        .sample_description_index(2)
676                        .build(),
677                    SampleToChunkEntry::builder()
678                        .first_chunk(3)
679                        .samples_per_chunk(20)
680                        .sample_description_index(3)
681                        .build(),
682                ]).media_timescale(media_timescale),
683            ),
684            original_duration: Duration::from_secs(100),
685            range: TrimDurationRange::builder()
686                    .start_bound(Bound::Unbounded)
687                    .end_bound(Bound::Excluded(Duration::from_secs(20)))
688                    .build(),
689            range: TrimDurationRange::builder()
690                    .start_bound(Bound::Included(Duration::from_secs(80)))
691                    .end_bound(Bound::Unbounded)
692                    .build(),
693            expected_remaining_duration: Duration::from_secs(60),
694        }
695        trim_first_and_20s_multi_track {
696            @tracks(
697                |media_timescale| create_test_track().stsc_entries(vec![
698                    // 1 sample per second
699                    SampleToChunkEntry::builder()
700                        .first_chunk(1)
701                        .samples_per_chunk(20)
702                        .sample_description_index(1)
703                        .build(),
704                    SampleToChunkEntry::builder()
705                        .first_chunk(2)
706                        .samples_per_chunk(60)
707                        .sample_description_index(2)
708                        .build(),
709                    SampleToChunkEntry::builder()
710                        .first_chunk(3)
711                        .samples_per_chunk(20)
712                        .sample_description_index(3)
713                        .build(),
714                ]).media_timescale(media_timescale),
715                |_| create_test_track().handler_reference(
716                    HandlerReferenceAtom::builder()
717                        .handler_type(HandlerType::Text).build(),
718                ).media_timescale(666_666),
719            ),
720            original_duration: Duration::from_secs(100),
721            range: TrimDurationRange::builder()
722                    .start_bound(Bound::Unbounded)
723                    .end_bound(Bound::Excluded(Duration::from_secs(20)))
724                    .build(),
725            range: TrimDurationRange::builder()
726                    .start_bound(Bound::Included(Duration::from_secs(80)))
727                    .end_bound(Bound::Unbounded)
728                    .build(),
729            expected_remaining_duration: Duration::from_secs(60),
730        }
731        // TODO: unbounded trim should return an error since that would erase all content
732    );
733}