Skip to main content

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}