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}