Skip to main content

ff_remux/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::RemuxError;
7
8use super::trim_inner::{self, BsfSpec};
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_remux::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    bsf: BsfSpec,
32}
33
34impl StreamCopyTrimmer {
35    /// Create a new `StreamCopyTrimmer`.
36    ///
37    /// `start_sec` and `end_sec` are absolute timestamps in seconds measured
38    /// from the start of the source file.  [`run`](Self::run) returns
39    /// [`RemuxError::InvalidConfig`] if `start_sec >= end_sec`.
40    pub fn new(
41        input: impl Into<PathBuf>,
42        start_sec: f64,
43        end_sec: f64,
44        output: impl Into<PathBuf>,
45    ) -> Self {
46        Self {
47            input: input.into(),
48            output: output.into(),
49            start_sec,
50            end_sec,
51            bsf: BsfSpec::default(),
52        }
53    }
54
55    /// Applies a bitstream filter chain to every video stream.
56    ///
57    /// `spec` is the syntax `ffmpeg -bsf` takes: a comma-separated chain whose
58    /// elements may carry options, e.g. `"dump_extra"` or
59    /// `"h264_metadata=level=40,extract_extradata"`.
60    ///
61    /// This is only for filters `FFmpeg` does **not** apply on its own. libavformat
62    /// already inserts the filter a container requires — copying H.264 from MP4 into
63    /// MPEG-TS produces Annex B with nothing set here — so this exists for the
64    /// explicit ones (`extract_extradata`, `dump_extra`, the `*_metadata` family).
65    /// See ADR-0011.
66    ///
67    /// An unregistered or malformed spec fails in [`run`](Self::run) with
68    /// [`RemuxError::InvalidConfig`].
69    #[must_use]
70    pub fn video_bsf(mut self, spec: impl Into<String>) -> Self {
71        self.bsf.video = Some(spec.into());
72        self
73    }
74
75    /// Applies a bitstream filter chain to every audio stream.
76    ///
77    /// See [`video_bsf`](Self::video_bsf) for the spec syntax and when to reach for
78    /// this at all.
79    #[must_use]
80    pub fn audio_bsf(mut self, spec: impl Into<String>) -> Self {
81        self.bsf.audio = Some(spec.into());
82        self
83    }
84
85    /// Execute the trim operation.
86    ///
87    /// # Errors
88    ///
89    /// - [`RemuxError::InvalidConfig`] if `start_sec >= end_sec`.
90    /// - [`RemuxError::Ffmpeg`] if any `FFmpeg` API call fails.
91    pub fn run(self) -> Result<(), RemuxError> {
92        if self.start_sec >= self.end_sec {
93            return Err(RemuxError::InvalidConfig {
94                reason: format!(
95                    "start_sec ({}) must be less than end_sec ({})",
96                    self.start_sec, self.end_sec
97                ),
98            });
99        }
100        log::debug!(
101            "stream copy trim start input={} output={} start_sec={} end_sec={}",
102            self.input.display(),
103            self.output.display(),
104            self.start_sec,
105            self.end_sec,
106        );
107        trim_inner::run_trim(
108            &self.input,
109            &self.output,
110            self.start_sec,
111            self.end_sec,
112            &self.bsf,
113        )
114    }
115}
116
117// StreamCopyTrim
118
119/// Trim a media file to a time range using stream copy (no re-encode).
120///
121/// Equivalent to [`StreamCopyTrimmer`] but accepts [`Duration`] for `start` and
122/// `end` instead of raw seconds, and returns
123/// [`RemuxError::OperationFailed`] when the time range is invalid.
124///
125/// # Example
126///
127/// ```ignore
128/// use ff_remux::StreamCopyTrim;
129/// use std::time::Duration;
130///
131/// StreamCopyTrim::new(
132///     "input.mp4",
133///     Duration::from_secs(2),
134///     Duration::from_secs(7),
135///     "output.mp4",
136/// )
137/// .run()?;
138/// ```
139pub struct StreamCopyTrim {
140    input: PathBuf,
141    start: Duration,
142    end: Duration,
143    output: PathBuf,
144    bsf: BsfSpec,
145}
146
147impl StreamCopyTrim {
148    /// Create a new `StreamCopyTrim`.
149    ///
150    /// `start` and `end` are absolute timestamps measured from the start of
151    /// the source file.  [`run`](Self::run) returns
152    /// [`RemuxError::OperationFailed`] if `start >= end`.
153    pub fn new(
154        input: impl Into<PathBuf>,
155        start: Duration,
156        end: Duration,
157        output: impl Into<PathBuf>,
158    ) -> Self {
159        Self {
160            input: input.into(),
161            start,
162            end,
163            output: output.into(),
164            bsf: BsfSpec::default(),
165        }
166    }
167
168    /// Applies a bitstream filter chain to every video stream.
169    ///
170    /// See [`StreamCopyTrimmer::video_bsf`] for the spec syntax and when it is needed.
171    #[must_use]
172    pub fn video_bsf(mut self, spec: impl Into<String>) -> Self {
173        self.bsf.video = Some(spec.into());
174        self
175    }
176
177    /// Applies a bitstream filter chain to every audio stream.
178    ///
179    /// See [`StreamCopyTrimmer::video_bsf`] for the spec syntax and when it is needed.
180    #[must_use]
181    pub fn audio_bsf(mut self, spec: impl Into<String>) -> Self {
182        self.bsf.audio = Some(spec.into());
183        self
184    }
185
186    /// Execute the trim operation.
187    ///
188    /// # Errors
189    ///
190    /// - [`RemuxError::OperationFailed`] if `start >= end`.
191    /// - [`RemuxError::Ffmpeg`] if any `FFmpeg` API call fails.
192    pub fn run(self) -> Result<(), RemuxError> {
193        if self.start >= self.end {
194            return Err(RemuxError::OperationFailed {
195                reason: format!(
196                    "start ({:?}) must be less than end ({:?})",
197                    self.start, self.end
198                ),
199            });
200        }
201        let start_sec = self.start.as_secs_f64();
202        let end_sec = self.end.as_secs_f64();
203        log::debug!(
204            "stream copy trim start input={} output={} start_sec={start_sec} end_sec={end_sec}",
205            self.input.display(),
206            self.output.display(),
207        );
208        trim_inner::run_trim(&self.input, &self.output, start_sec, end_sec, &self.bsf)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn stream_copy_trimmer_should_reject_start_greater_than_end() {
218        let result = StreamCopyTrimmer::new("input.mp4", 7.0, 2.0, "output.mp4").run();
219        assert!(
220            matches!(result, Err(RemuxError::InvalidConfig { .. })),
221            "expected InvalidConfig for start > end, got {result:?}"
222        );
223    }
224
225    #[test]
226    fn stream_copy_trimmer_should_reject_equal_start_and_end() {
227        let result = StreamCopyTrimmer::new("input.mp4", 5.0, 5.0, "output.mp4").run();
228        assert!(
229            matches!(result, Err(RemuxError::InvalidConfig { .. })),
230            "expected InvalidConfig for start == end, got {result:?}"
231        );
232    }
233
234    #[test]
235    fn stream_copy_trim_should_reject_start_greater_than_end() {
236        let result = StreamCopyTrim::new(
237            "input.mp4",
238            Duration::from_secs(7),
239            Duration::from_secs(2),
240            "output.mp4",
241        )
242        .run();
243        assert!(
244            matches!(result, Err(RemuxError::OperationFailed { .. })),
245            "expected MediaOperationFailed for start > end, got {result:?}"
246        );
247    }
248
249    #[test]
250    fn stream_copy_trim_should_reject_equal_start_and_end() {
251        let result = StreamCopyTrim::new(
252            "input.mp4",
253            Duration::from_secs(5),
254            Duration::from_secs(5),
255            "output.mp4",
256        )
257        .run();
258        assert!(
259            matches!(result, Err(RemuxError::OperationFailed { .. })),
260            "expected MediaOperationFailed for start == end, got {result:?}"
261        );
262    }
263}