ff_encode/media_ops/mod.rs
1//! Media stream operations — audio replacement and extraction via stream-copy remux.
2
3mod media_inner;
4
5use std::path::PathBuf;
6
7use crate::error::EncodeError;
8
9/// Replace a video file's audio track with audio from a separate source file.
10///
11/// The video bitstream is copied bit-for-bit (no decode/encode cycle). The
12/// audio track from `audio_input` replaces any existing audio in
13/// `video_input`.
14///
15/// Returns [`EncodeError::MediaOperationFailed`] when no audio stream is found
16/// in `audio_input`, or no video stream is found in `video_input`.
17///
18/// # Example
19///
20/// ```ignore
21/// use ff_encode::AudioReplacement;
22///
23/// AudioReplacement::new("source.mp4", "new_audio.aac", "output.mp4").run()?;
24/// ```
25pub struct AudioReplacement {
26 video_input: PathBuf,
27 audio_input: PathBuf,
28 output: PathBuf,
29}
30
31impl AudioReplacement {
32 /// Create a new `AudioReplacement`.
33 ///
34 /// - `video_input` — source file whose video stream is kept.
35 /// - `audio_input` — source file whose first audio stream is used.
36 /// - `output` — path for the combined output file.
37 pub fn new(
38 video_input: impl Into<PathBuf>,
39 audio_input: impl Into<PathBuf>,
40 output: impl Into<PathBuf>,
41 ) -> Self {
42 Self {
43 video_input: video_input.into(),
44 audio_input: audio_input.into(),
45 output: output.into(),
46 }
47 }
48
49 /// Execute the audio replacement operation.
50 ///
51 /// # Errors
52 ///
53 /// - [`EncodeError::MediaOperationFailed`] if `video_input` has no video
54 /// stream or `audio_input` has no audio stream.
55 /// - [`EncodeError::Ffmpeg`] if any FFmpeg API call fails.
56 pub fn run(self) -> Result<(), EncodeError> {
57 log::debug!(
58 "audio replacement start video_input={} audio_input={} output={}",
59 self.video_input.display(),
60 self.audio_input.display(),
61 self.output.display(),
62 );
63 media_inner::run_audio_replacement(&self.video_input, &self.audio_input, &self.output)
64 }
65}
66
67// ── AudioExtractor ────────────────────────────────────────────────────────────
68
69/// Demux an audio track from a media file and write it to a standalone audio file.
70///
71/// The audio bitstream is stream-copied (no decode/encode cycle). By default
72/// the first audio stream is selected; call [`stream_index`](Self::stream_index)
73/// to pick a specific one.
74///
75/// Returns [`EncodeError::MediaOperationFailed`] when:
76/// - no audio stream is found (or `stream_index` points to a non-audio stream), or
77/// - the audio codec is incompatible with the output container.
78///
79/// # Example
80///
81/// ```ignore
82/// use ff_encode::AudioExtractor;
83///
84/// AudioExtractor::new("source.mp4", "audio.mp3").run()?;
85/// ```
86pub struct AudioExtractor {
87 input: PathBuf,
88 output: PathBuf,
89 stream_index: Option<usize>,
90}
91
92impl AudioExtractor {
93 /// Create a new `AudioExtractor`.
94 ///
95 /// - `input` — source media file.
96 /// - `output` — destination audio file (format auto-detected from extension).
97 pub fn new(input: impl Into<PathBuf>, output: impl Into<PathBuf>) -> Self {
98 Self {
99 input: input.into(),
100 output: output.into(),
101 stream_index: None,
102 }
103 }
104
105 /// Select a specific audio stream by index (0-based over all streams in
106 /// the container). Defaults to the first audio stream when not set.
107 #[must_use]
108 pub fn stream_index(mut self, idx: usize) -> Self {
109 self.stream_index = Some(idx);
110 self
111 }
112
113 /// Execute the audio extraction operation.
114 ///
115 /// # Errors
116 ///
117 /// - [`EncodeError::MediaOperationFailed`] if no audio stream is found,
118 /// the requested stream index is invalid or not audio, or the codec is
119 /// incompatible with the output container.
120 /// - [`EncodeError::Ffmpeg`] if any FFmpeg API call fails.
121 pub fn run(self) -> Result<(), EncodeError> {
122 log::debug!(
123 "audio extraction start input={} output={} stream_index={:?}",
124 self.input.display(),
125 self.output.display(),
126 self.stream_index,
127 );
128 media_inner::run_audio_extraction(&self.input, &self.output, self.stream_index)
129 }
130}
131
132// ── AudioAdder ────────────────────────────────────────────────────────────────
133
134/// Mux an audio track into a silent (or existing) video file.
135///
136/// The video bitstream is stream-copied (no decode/encode cycle). When the
137/// audio source is shorter than the video and [`loop_audio`](Self::loop_audio)
138/// has been called, the audio is looped by re-seeking and advancing the PTS
139/// offset until the video is exhausted.
140///
141/// Returns [`EncodeError::MediaOperationFailed`] when no video stream is found
142/// in `video_input` or no audio stream is found in `audio_input`.
143///
144/// # Example
145///
146/// ```ignore
147/// use ff_encode::AudioAdder;
148///
149/// AudioAdder::new("silent.mp4", "soundtrack.mp3", "output.mp4")
150/// .loop_audio()
151/// .run()?;
152/// ```
153pub struct AudioAdder {
154 video_input: PathBuf,
155 audio_input: PathBuf,
156 output: PathBuf,
157 loop_audio: bool,
158}
159
160impl AudioAdder {
161 /// Create a new `AudioAdder`.
162 ///
163 /// - `video_input` — source file whose video stream is kept.
164 /// - `audio_input` — source file whose first audio stream is used.
165 /// - `output` — path for the combined output file.
166 pub fn new(
167 video_input: impl Into<PathBuf>,
168 audio_input: impl Into<PathBuf>,
169 output: impl Into<PathBuf>,
170 ) -> Self {
171 Self {
172 video_input: video_input.into(),
173 audio_input: audio_input.into(),
174 output: output.into(),
175 loop_audio: false,
176 }
177 }
178
179 /// Loop the audio when it is shorter than the video.
180 ///
181 /// The audio is re-seeked to the start and the PTS offset is advanced each
182 /// time the audio stream is exhausted, until the video ends.
183 #[must_use]
184 pub fn loop_audio(mut self) -> Self {
185 self.loop_audio = true;
186 self
187 }
188
189 /// Execute the audio addition operation.
190 ///
191 /// # Errors
192 ///
193 /// - [`EncodeError::MediaOperationFailed`] if `video_input` has no video
194 /// stream or `audio_input` has no audio stream.
195 /// - [`EncodeError::Ffmpeg`] if any FFmpeg API call fails.
196 pub fn run(self) -> Result<(), EncodeError> {
197 log::debug!(
198 "audio addition start video_input={} audio_input={} output={} loop_audio={}",
199 self.video_input.display(),
200 self.audio_input.display(),
201 self.output.display(),
202 self.loop_audio,
203 );
204 media_inner::run_audio_addition(
205 &self.video_input,
206 &self.audio_input,
207 &self.output,
208 self.loop_audio,
209 )
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn audio_replacement_run_with_nonexistent_video_input_should_fail() {
219 let result =
220 AudioReplacement::new("nonexistent_video.mp4", "nonexistent_audio.mp3", "out.mp4")
221 .run();
222 assert!(
223 result.is_err(),
224 "expected error for nonexistent video input, got Ok(())"
225 );
226 }
227
228 #[test]
229 fn audio_extractor_run_with_nonexistent_input_should_fail() {
230 let result = AudioExtractor::new("nonexistent_input.mp4", "out.mp3").run();
231 assert!(
232 result.is_err(),
233 "expected error for nonexistent input, got Ok(())"
234 );
235 }
236
237 #[test]
238 fn audio_adder_run_with_nonexistent_video_input_should_fail() {
239 let result =
240 AudioAdder::new("nonexistent_video.mp4", "nonexistent_audio.mp3", "out.mp4").run();
241 assert!(
242 result.is_err(),
243 "expected error for nonexistent video input, got Ok(())"
244 );
245 }
246}