Skip to main content

ff_encode/trim/
trimmer.rs

1//! Stream-copy trimming — cut a media file to a time range without re-encoding.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::error::EncodeError;
7
8use super::trim_inner;
9
10/// Trim a media file to a time range using stream copy (no re-encode).
11///
12/// Uses [`avformat_seek_file`] to seek to the start point, then copies packets
13/// until the presentation timestamp exceeds the end point.  All streams
14/// (video, audio, subtitles) are copied verbatim from the input.
15///
16/// # Example
17///
18/// ```ignore
19/// use ff_encode::StreamCopyTrimmer;
20///
21/// StreamCopyTrimmer::new("input.mp4", 2.0, 7.0, "output.mp4")
22///     .run()?;
23/// ```
24///
25/// [`avformat_seek_file`]: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html
26pub struct StreamCopyTrimmer {
27    input: PathBuf,
28    output: PathBuf,
29    start_sec: f64,
30    end_sec: f64,
31}
32
33impl StreamCopyTrimmer {
34    /// Create a new `StreamCopyTrimmer`.
35    ///
36    /// `start_sec` and `end_sec` are absolute timestamps in seconds measured
37    /// from the start of the source file.  [`run`](Self::run) returns
38    /// [`EncodeError::InvalidConfig`] if `start_sec >= end_sec`.
39    pub fn new(
40        input: impl Into<PathBuf>,
41        start_sec: f64,
42        end_sec: f64,
43        output: impl Into<PathBuf>,
44    ) -> Self {
45        Self {
46            input: input.into(),
47            output: output.into(),
48            start_sec,
49            end_sec,
50        }
51    }
52
53    /// Execute the trim operation.
54    ///
55    /// # Errors
56    ///
57    /// - [`EncodeError::InvalidConfig`] if `start_sec >= end_sec`.
58    /// - [`EncodeError::Ffmpeg`] if any FFmpeg API call fails.
59    pub fn run(self) -> Result<(), EncodeError> {
60        if self.start_sec >= self.end_sec {
61            return Err(EncodeError::InvalidConfig {
62                reason: format!(
63                    "start_sec ({}) must be less than end_sec ({})",
64                    self.start_sec, self.end_sec
65                ),
66            });
67        }
68        log::debug!(
69            "stream copy trim start input={} output={} start_sec={} end_sec={}",
70            self.input.display(),
71            self.output.display(),
72            self.start_sec,
73            self.end_sec,
74        );
75        trim_inner::run_trim(&self.input, &self.output, self.start_sec, self.end_sec)
76    }
77}
78
79// ── StreamCopyTrim ────────────────────────────────────────────────────────────
80
81/// Trim a media file to a time range using stream copy (no re-encode).
82///
83/// Equivalent to [`StreamCopyTrimmer`] but accepts [`Duration`] for `start` and
84/// `end` instead of raw seconds, and returns
85/// [`EncodeError::MediaOperationFailed`] when the time range is invalid.
86///
87/// # Example
88///
89/// ```ignore
90/// use ff_encode::StreamCopyTrim;
91/// use std::time::Duration;
92///
93/// StreamCopyTrim::new(
94///     "input.mp4",
95///     Duration::from_secs(2),
96///     Duration::from_secs(7),
97///     "output.mp4",
98/// )
99/// .run()?;
100/// ```
101pub struct StreamCopyTrim {
102    input: PathBuf,
103    start: Duration,
104    end: Duration,
105    output: PathBuf,
106}
107
108impl StreamCopyTrim {
109    /// Create a new `StreamCopyTrim`.
110    ///
111    /// `start` and `end` are absolute timestamps measured from the start of
112    /// the source file.  [`run`](Self::run) returns
113    /// [`EncodeError::MediaOperationFailed`] if `start >= end`.
114    pub fn new(
115        input: impl Into<PathBuf>,
116        start: Duration,
117        end: Duration,
118        output: impl Into<PathBuf>,
119    ) -> Self {
120        Self {
121            input: input.into(),
122            start,
123            end,
124            output: output.into(),
125        }
126    }
127
128    /// Execute the trim operation.
129    ///
130    /// # Errors
131    ///
132    /// - [`EncodeError::MediaOperationFailed`] if `start >= end`.
133    /// - [`EncodeError::Ffmpeg`] if any FFmpeg API call fails.
134    pub fn run(self) -> Result<(), EncodeError> {
135        if self.start >= self.end {
136            return Err(EncodeError::MediaOperationFailed {
137                reason: format!(
138                    "start ({:?}) must be less than end ({:?})",
139                    self.start, self.end
140                ),
141            });
142        }
143        let start_sec = self.start.as_secs_f64();
144        let end_sec = self.end.as_secs_f64();
145        log::debug!(
146            "stream copy trim start input={} output={} start_sec={start_sec} end_sec={end_sec}",
147            self.input.display(),
148            self.output.display(),
149        );
150        trim_inner::run_trim(&self.input, &self.output, start_sec, end_sec)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn stream_copy_trimmer_should_reject_start_greater_than_end() {
160        let result = StreamCopyTrimmer::new("input.mp4", 7.0, 2.0, "output.mp4").run();
161        assert!(
162            matches!(result, Err(EncodeError::InvalidConfig { .. })),
163            "expected InvalidConfig for start > end, got {result:?}"
164        );
165    }
166
167    #[test]
168    fn stream_copy_trimmer_should_reject_equal_start_and_end() {
169        let result = StreamCopyTrimmer::new("input.mp4", 5.0, 5.0, "output.mp4").run();
170        assert!(
171            matches!(result, Err(EncodeError::InvalidConfig { .. })),
172            "expected InvalidConfig for start == end, got {result:?}"
173        );
174    }
175
176    #[test]
177    fn stream_copy_trim_should_reject_start_greater_than_end() {
178        let result = StreamCopyTrim::new(
179            "input.mp4",
180            Duration::from_secs(7),
181            Duration::from_secs(2),
182            "output.mp4",
183        )
184        .run();
185        assert!(
186            matches!(result, Err(EncodeError::MediaOperationFailed { .. })),
187            "expected MediaOperationFailed for start > end, got {result:?}"
188        );
189    }
190
191    #[test]
192    fn stream_copy_trim_should_reject_equal_start_and_end() {
193        let result = StreamCopyTrim::new(
194            "input.mp4",
195            Duration::from_secs(5),
196            Duration::from_secs(5),
197            "output.mp4",
198        )
199        .run();
200        assert!(
201            matches!(result, Err(EncodeError::MediaOperationFailed { .. })),
202            "expected MediaOperationFailed for start == end, got {result:?}"
203        );
204    }
205}