Skip to main content

alpha2_ffmpeg_bridge/
container.rs

1//! Container open and close, replacing rsmpeg's constructors.
2//!
3//! rsmpeg's input builder cannot install an interrupt callback before `avformat_open_input`, and
4//! its output `Drop` unwraps `avio_close`, which panics after a failed write such as a full disk.
5
6use std::{
7    ffi::{CStr, CString, c_int, c_void},
8    fs::File,
9    io::Read,
10    mem::ManuallyDrop,
11    ops::{Deref, DerefMut},
12    path::Path,
13    ptr::{self, NonNull},
14    sync::{Arc, Mutex, PoisonError},
15};
16
17use rsmpeg::{
18    UnsafeDerefMut,
19    avcodec::AVPacket,
20    avformat::{AVFormatContextInput, AVFormatContextOutput, AVIOContextContainer},
21    avutil::AVDictionary,
22    error::RsmpegError,
23    ffi,
24};
25
26use crate::BridgeError;
27
28/// Returns whether the interrupted call should stop now.
29type InterruptCheck = Arc<dyn Fn() -> bool + Send + Sync>;
30
31/// `FFmpeg` holds a raw pointer to this, so the input keeps it alive as long as its context.
32#[derive(Default)]
33struct InputInterrupt {
34    check: Mutex<Option<InterruptCheck>>,
35}
36
37impl InputInterrupt {
38    fn requested(&self) -> bool {
39        let check = self
40            .check
41            .lock()
42            .unwrap_or_else(PoisonError::into_inner)
43            .clone();
44        check.is_some_and(|check| check())
45    }
46}
47
48/// Nonzero stops the blocking call with `AVERROR_EXIT`.
49unsafe extern "C" fn interrupt_requested(opaque: *mut c_void) -> c_int {
50    // SAFETY: `opaque` is the owning `DemuxInput`'s `InputInterrupt`, alive until after the context
51    // is closed; only shared access is taken.
52    let interrupt = unsafe { &*opaque.cast::<InputInterrupt>() };
53    c_int::from(interrupt.requested())
54}
55
56/// A container opened for reading with only the named demuxers, only the `file` protocol, and an
57/// interrupt callback.
58///
59/// The context is read through [`Deref`] but cannot be moved out, since `FFmpeg` points at the
60/// input's interrupt state:
61///
62/// ```compile_fail,E0596
63/// # fn steal(mut demux: alpha2_ffmpeg_bridge::DemuxInput, other: rsmpeg::avformat::AVFormatContextInput) {
64/// let stolen = std::mem::replace(&mut *demux, other);
65/// drop(demux);
66/// # }
67/// ```
68pub struct DemuxInput {
69    // Declared first so it closes before the interrupt state is released.
70    input: AVFormatContextInput,
71    interrupt: Arc<InputInterrupt>,
72}
73
74impl DemuxInput {
75    /// Makes blocking I/O on this input stop with `AVERROR_EXIT` once `check` returns true.
76    pub fn interrupt_when(&self, check: impl Fn() -> bool + Send + Sync + 'static) {
77        *self
78            .interrupt
79            .check
80            .lock()
81            .unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(check));
82    }
83
84    /// The `AVStream` index of the best decodable stream of `media_type`, never an attached
85    /// picture such as cover art.
86    ///
87    /// `None` when no stream of the type can be decoded; the input's other streams still may.
88    ///
89    /// # Errors
90    /// Any other `av_find_best_stream` failure.
91    pub fn best_stream(&self, media_type: ffi::AVMediaType) -> Result<Option<usize>, RsmpegError> {
92        let attached = |index: usize| {
93            // bindgen's flag type differs by target, so widen both sides.
94            self.streams().get(index).is_some_and(|stream| {
95                i64::from(stream.disposition) & i64::from(ffi::AV_DISPOSITION_ATTACHED_PIC) != 0
96            })
97        };
98        let find = |wanted: c_int| {
99            let mut decoder: *const ffi::AVCodec = ptr::null();
100            // SAFETY: the context is an open input this value owns; `av_find_best_stream` only
101            // reads it, and writes the decoder it found into `decoder`, a local.
102            let found = unsafe {
103                ffi::av_find_best_stream(
104                    self.input.as_ptr().cast_mut(),
105                    media_type,
106                    wanted,
107                    -1,
108                    &raw mut decoder,
109                    0,
110                )
111            };
112            match found {
113                index if index >= 0 => Ok(usize::try_from(index).ok()),
114                ffi::AVERROR_STREAM_NOT_FOUND | ffi::AVERROR_DECODER_NOT_FOUND => Ok(None),
115                error => Err(RsmpegError::AVError(error)),
116            }
117        };
118        match find(-1)? {
119            Some(index) if attached(index) => {}
120            found => return Ok(found),
121        }
122        for (index, stream) in self.streams().iter().enumerate() {
123            if stream.codecpar().codec_type != media_type || attached(index) {
124                continue;
125            }
126            if let Some(found) = find(c_int::try_from(index).unwrap_or(c_int::MAX))? {
127                return Ok(Some(found));
128            }
129        }
130        Ok(None)
131    }
132
133    /// The next packet in file order, or `None` at the end of the file.
134    ///
135    /// # Errors
136    /// The `FFmpeg` read failure, or `AVERROR_EXIT` when interrupted.
137    pub fn read_packet(&mut self) -> Result<Option<AVPacket>, RsmpegError> {
138        self.input.read_packet()
139    }
140
141    /// Seeks to the key frame at `timestamp` (`stream_index` -1 for `AV_TIME_BASE` units).
142    ///
143    /// # Errors
144    /// The `FFmpeg` seek failure, or `AVERROR_EXIT` when interrupted.
145    pub fn seek(
146        &mut self,
147        stream_index: i32,
148        timestamp: i64,
149        flags: i32,
150    ) -> Result<(), RsmpegError> {
151        self.input.seek(stream_index, timestamp, flags)
152    }
153
154    /// Has the demuxer discard every stream but `index` (`mov` then skips their bytes entirely).
155    pub fn discard_all_but(&mut self, index: usize) {
156        self.keep_only(&[index]);
157    }
158
159    /// [`Self::discard_all_but`] for several streams.
160    pub fn keep_only(&mut self, kept: &[usize]) {
161        for (at, stream) in self.streams().iter().enumerate() {
162            let discard = if kept.contains(&at) {
163                ffi::AVDISCARD_DEFAULT
164            } else {
165                ffi::AVDISCARD_ALL
166            };
167            // SAFETY: the stream belongs to the exclusively borrowed input; `discard` is a plain
168            // field the demuxer reads later.
169            unsafe { (*stream.as_ptr().cast_mut()).discard = discard };
170        }
171    }
172}
173
174impl Deref for DemuxInput {
175    type Target = AVFormatContextInput;
176
177    fn deref(&self) -> &Self::Target {
178        &self.input
179    }
180}
181
182/// Why a path cannot be passed to `FFmpeg` as a UTF-8 C string.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum PathError {
185    /// The path is not valid Unicode.
186    NotUtf8,
187    /// The path holds a NUL byte, where a C string would end.
188    Nul,
189}
190
191impl std::fmt::Display for PathError {
192    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        formatter.write_str(match self {
194            Self::NotUtf8 => "is not UTF-8",
195            Self::Nul => "contains a NUL byte",
196        })
197    }
198}
199
200impl std::error::Error for PathError {}
201
202/// `file:` plus the absolute path, so a name like `take:1.mov` is never read as a protocol.
203///
204/// # Errors
205/// When the path is not UTF-8 or holds a NUL byte.
206pub fn file_url(path: &Path) -> Result<CString, PathError> {
207    // If `absolute` fails, the prefix alone still keeps the name a file.
208    let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
209    let text = absolute.to_str().ok_or(PathError::NotUtf8)?;
210    CString::new(format!("file:{text}")).map_err(|_| PathError::Nul)
211}
212
213/// Why [`open_input`] opened nothing.
214#[derive(Debug)]
215pub enum OpenInputError {
216    /// The path cannot be handed to `FFmpeg`.
217    Path(PathError),
218    /// The file is a container `formats` does not name (the claiming demuxer).
219    Refused(String),
220    /// `FFmpeg` could not open or read it.
221    Ffmpeg(RsmpegError),
222}
223
224impl std::fmt::Display for OpenInputError {
225    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        match self {
227            Self::Path(error) => write!(formatter, "the path {error}"),
228            Self::Refused(format) => write!(formatter, "the file is {format} input"),
229            Self::Ffmpeg(error) => write!(formatter, "{error}"),
230        }
231    }
232}
233
234impl std::error::Error for OpenInputError {}
235
236/// Opens `path` and reads its stream information using only the demuxers in `formats`
237/// (comma-separated `FFmpeg` short names) and only the `file` protocol.
238///
239/// Still images pick their demuxer by signature. Otherwise `image2` claims any file named like an
240/// image before the JPEG demuxer has seen a large JPEG whole, and `formats` leaves `image2` out.
241///
242/// # Errors
243/// [`OpenInputError::Path`] for an unusable path, [`OpenInputError::Refused`] for a container
244/// `formats` leaves out, else the `FFmpeg` failure.
245pub fn open_input(path: &Path, formats: &CStr) -> Result<DemuxInput, OpenInputError> {
246    let url = file_url(path).map_err(OpenInputError::Path)?;
247    let url = url.as_c_str();
248    crate::install_log_capture();
249    let still = still_image_format(path)
250        .filter(|name| allowed_name(name, formats))
251        // SAFETY: `name` is a NUL-terminated demuxer name; the result is null or one of `FFmpeg`'s
252        // static demuxers.
253        .map_or(ptr::null(), |name| unsafe {
254            ffi::av_find_input_format(name.as_ptr())
255        });
256    open_with(url, formats, still).map_err(|failure| match failure {
257        OpenFailure::Open(opened) => refused_format(path, url, formats).map_or(
258            OpenInputError::Ffmpeg(RsmpegError::OpenInputError(opened)),
259            OpenInputError::Refused,
260        ),
261        OpenFailure::Other(error) => error,
262    })
263}
264
265fn still_image_format(path: &Path) -> Option<&'static CStr> {
266    let mut signature = [0_u8; 12];
267    File::open(path).ok()?.read_exact(&mut signature).ok()?;
268    if signature.starts_with(&[0xff, 0xd8, 0xff]) {
269        Some(c"jpeg_pipe")
270    } else if signature.starts_with(b"\x89PNG\r\n\x1a\n") {
271        Some(c"png_pipe")
272    } else if signature.starts_with(b"RIFF") && signature.ends_with(b"WEBP") {
273        Some(c"webp_pipe")
274    } else {
275        None
276    }
277}
278
279enum OpenFailure {
280    Open(c_int),
281    Other(OpenInputError),
282}
283
284/// Opens `url` with `format`, or lets `FFmpeg` pick when it is null.
285fn open_with(
286    url: &CStr,
287    formats: &CStr,
288    format: *const ffi::AVInputFormat,
289) -> Result<DemuxInput, OpenFailure> {
290    let interrupt = Arc::new(InputInterrupt::default());
291    // SAFETY: allocation has no preconditions; a null result is handled below.
292    let mut context = unsafe { ffi::avformat_alloc_context() };
293    if context.is_null() {
294        return Err(OpenFailure::Other(OpenInputError::Ffmpeg(
295            RsmpegError::AVError(ffi::AVERROR(ffi::ENOMEM)),
296        )));
297    }
298    // SAFETY: the context is fresh and unshared. The opaque pointer outlives it: the returned input
299    // keeps the `Arc`, and on failure `avformat_open_input` frees the context first.
300    unsafe {
301        (*context).interrupt_callback = ffi::AVIOInterruptCB {
302            callback: Some(interrupt_requested),
303            opaque: Arc::as_ptr(&interrupt).cast_mut().cast(),
304        };
305    }
306    let options =
307        AVDictionary::new(c"format_whitelist", formats, 0).set(c"protocol_whitelist", c"file", 0);
308    let mut options = options.into_raw().as_ptr();
309    // SAFETY: `context` is fresh, `url` NUL-terminated, `format` null or a static demuxer, and
310    // `options` a dictionary the call may consume; it frees and nulls `context` on failure.
311    let opened = unsafe {
312        ffi::avformat_open_input(&raw mut context, url.as_ptr(), format, &raw mut options)
313    };
314    // SAFETY: `options` is null or the dictionary `avformat_open_input` handed back, which this
315    // owns.
316    unsafe { ffi::av_dict_free(&raw mut options) };
317    if opened < 0 {
318        return Err(OpenFailure::Open(opened));
319    }
320    let Some(context) = NonNull::new(context) else {
321        return Err(OpenFailure::Open(opened));
322    };
323    // SAFETY: `avformat_open_input` succeeded, so `context` is an open input context this now owns;
324    // its `Drop` closes it with `avformat_close_input`.
325    let mut input = unsafe { AVFormatContextInput::from_raw(context) };
326    // SAFETY: the context is open and exclusively owned; null options read no per-stream
327    // dictionaries.
328    let found = unsafe { ffi::avformat_find_stream_info(input.as_mut_ptr(), ptr::null_mut()) };
329    if found < 0 {
330        return Err(OpenFailure::Other(OpenInputError::Ffmpeg(
331            RsmpegError::FindStreamInfoError(found),
332        )));
333    }
334    Ok(DemuxInput { input, interrupt })
335}
336
337/// The demuxer `FFmpeg` would pick for `path` when `formats` does not name it, so a failed open
338/// can say why instead of `EINVAL`.
339fn refused_format(path: &Path, url: &CStr, formats: &CStr) -> Option<String> {
340    const PROBE_BYTES: u64 = 1 << 20;
341    let mut bytes = Vec::new();
342    File::open(path)
343        .ok()?
344        .take(PROBE_BYTES)
345        .read_to_end(&mut bytes)
346        .ok()?;
347    let size = c_int::try_from(bytes.len()).ok()?;
348    bytes.resize(
349        bytes.len() + usize::try_from(ffi::AVPROBE_PADDING_SIZE).ok()?,
350        0,
351    );
352    let probe = ffi::AVProbeData {
353        filename: url.as_ptr(),
354        buf: bytes.as_mut_ptr(),
355        buf_size: size,
356        mime_type: ptr::null(),
357    };
358    let mut score = 0;
359    // SAFETY: `probe` points at `size` readable bytes followed by the zeroed padding `FFmpeg`
360    // requires, and at a NUL-terminated filename; both outlive the call.
361    let format = unsafe { ffi::av_probe_input_format3(&raw const probe, 1, &raw mut score) };
362    if format.is_null() {
363        return None;
364    }
365    // SAFETY: a non-null result is one of `FFmpeg`'s static demuxer descriptions, whose name is a
366    // static NUL-terminated string.
367    let name = unsafe { CStr::from_ptr((*format).name) };
368    (!allowed_name(name, formats)).then(|| name.to_string_lossy().into_owned())
369}
370
371fn allowed_name(name: &CStr, formats: &CStr) -> bool {
372    let allowed = formats.to_string_lossy();
373    name.to_string_lossy()
374        .split(',')
375        .any(|part| allowed.split(',').any(|format| format == part))
376}
377
378/// A container opened for writing whose file is closed without panicking.
379///
380/// [`Self::close`] reports any earlier write error or one reported at `close(2)`; dropping discards
381/// it.
382pub struct OutputFile(AVFormatContextOutput);
383
384/// Why [`OutputFile::create`] created nothing.
385#[derive(Debug)]
386pub enum CreateOutputError {
387    /// The path cannot be handed to `FFmpeg`.
388    Path(PathError),
389    /// There is no such muxer, or the file cannot be opened.
390    Ffmpeg(RsmpegError),
391}
392
393impl std::fmt::Display for CreateOutputError {
394    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        match self {
396            Self::Path(error) => write!(formatter, "the path {error}"),
397            Self::Ffmpeg(error) => write!(formatter, "{error}"),
398        }
399    }
400}
401
402impl std::error::Error for CreateOutputError {}
403
404impl OutputFile {
405    /// Creates the output with the `FFmpeg` muxer `format` (never guessed from the file name) and
406    /// opens `path` through the `file` protocol only.
407    ///
408    /// # Errors
409    /// When the path is unusable, there is no such muxer, or the file cannot be opened.
410    pub fn create(path: &Path, format: &CStr) -> Result<Self, CreateOutputError> {
411        let url = file_url(path).map_err(CreateOutputError::Path)?;
412        let mut options = Some(AVDictionary::new(c"protocol_whitelist", c"file", 0));
413        AVFormatContextOutput::builder()
414            .format_name(format)
415            .filename(&url)
416            .options(&mut options)
417            .build()
418            .map(Self)
419            .map_err(CreateOutputError::Ffmpeg)
420    }
421
422    /// Closes the file, after the trailer.
423    ///
424    /// # Errors
425    /// The I/O error reported on closing.
426    pub fn close(mut self) -> Result<(), BridgeError> {
427        close_io(&mut self.0)
428    }
429}
430
431impl Deref for OutputFile {
432    type Target = AVFormatContextOutput;
433
434    fn deref(&self) -> &Self::Target {
435        &self.0
436    }
437}
438
439impl DerefMut for OutputFile {
440    fn deref_mut(&mut self) -> &mut Self::Target {
441        &mut self.0
442    }
443}
444
445impl Drop for OutputFile {
446    fn drop(&mut self) {
447        let _ = close_io(&mut self.0);
448    }
449}
450
451/// Closes and detaches `output`'s file, if open.
452fn close_io(output: &mut AVFormatContextOutput) -> Result<(), BridgeError> {
453    let Some(io) = output.io_context.take() else {
454        return Ok(());
455    };
456    // SAFETY: the context is live and exclusively borrowed. `pb` is the I/O just taken, which is
457    // closed below; `avformat_free_context` and rsmpeg's `Drop` skip a null `pb`.
458    unsafe {
459        output.deref_mut().pb = ptr::null_mut();
460    }
461    match io {
462        AVIOContextContainer::Url(url) => {
463            // rsmpeg's `Drop` would close it again and unwrap the result.
464            let mut url = ManuallyDrop::new(url);
465            let mut raw = url.as_mut_ptr();
466            // SAFETY: `raw` is the I/O context `avio_open2` opened for this output, closed nowhere
467            // else: its owner is never dropped.
468            let closed = unsafe { ffi::avio_closep(&raw mut raw) };
469            if closed < 0 {
470                return Err(BridgeError::Ffmpeg(RsmpegError::AVError(closed)));
471            }
472            Ok(())
473        }
474        AVIOContextContainer::Custom(custom) => {
475            drop(custom);
476            Ok(())
477        }
478    }
479}
480
481#[cfg(test)]
482#[path = "container_tests.rs"]
483mod tests;