Skip to main content

ff_decode/image/
decoder_inner.rs

1//! Internal image decoder implementation using FFmpeg.
2//!
3//! This module contains the low-level decoder logic that directly interacts
4//! with FFmpeg's C API through the ff-sys crate. It is not exposed publicly.
5
6// Allow unsafe code in this module as it's necessary for FFmpeg FFI
7#![allow(unsafe_code)]
8// Allow specific clippy lints for FFmpeg FFI code
9#![allow(clippy::similar_names)]
10#![allow(clippy::too_many_lines)]
11#![allow(clippy::cast_sign_loss)]
12#![allow(clippy::cast_possible_truncation)]
13#![allow(clippy::cast_possible_wrap)]
14#![allow(clippy::module_name_repetitions)]
15#![allow(clippy::ptr_as_ptr)]
16#![allow(clippy::doc_markdown)]
17#![allow(clippy::unnecessary_cast)]
18#![allow(clippy::cast_precision_loss)]
19#![allow(clippy::cast_lossless)]
20
21use std::ffi::CStr;
22use std::path::Path;
23
24use ff_format::time::{Rational, Timestamp};
25use ff_format::{PixelFormat, PooledBuffer, VideoFrame};
26use ff_sys::{
27    AVCodecID, AVMediaType_AVMEDIA_TYPE_VIDEO, AVPixelFormat, Frame, InputFormatContext, Packet,
28};
29
30use crate::error::DecodeError;
31use crate::shared::guards_inner::open_input_ctx;
32
33// ImageDecoderInner
34
35/// Internal state for the image decoder.
36///
37/// Holds raw FFmpeg pointers and is responsible for proper cleanup in `Drop`.
38pub(crate) struct ImageDecoderInner {
39    /// Format context for reading the image file.
40    format_ctx: InputFormatContext,
41    /// Codec context for decoding the image.
42    codec_ctx: ff_sys::CodecContext,
43    /// Video stream index in the format context.
44    stream_index: usize,
45    /// Reusable packet for reading from file.
46    packet: Packet,
47    /// Reusable frame for decoding.
48    frame: Frame,
49}
50
51// SAFETY: `ImageDecoderInner` owns all FFmpeg contexts exclusively.
52//         FFmpeg contexts are not safe for concurrent access (not Sync),
53//         but ownership transfer between threads is safe.
54unsafe impl Send for ImageDecoderInner {}
55
56impl ImageDecoderInner {
57    /// Opens an image file and prepares the decoder.
58    ///
59    /// Performs the full FFmpeg initialization sequence:
60    /// 1. `avformat_open_input`
61    /// 2. `avformat_find_stream_info`
62    /// 3. `av_find_best_stream(AVMEDIA_TYPE_VIDEO)`
63    /// 4. `avcodec_find_decoder`
64    /// 5. `avcodec_alloc_context3`
65    /// 6. `avcodec_parameters_to_context`
66    /// 7. `avcodec_open2`
67    pub(crate) fn new(path: &Path) -> Result<Self, DecodeError> {
68        ff_sys::ensure_initialized();
69
70        // 1. avformat_open_input
71        let mut ctx = open_input_ctx(path)?;
72
73        // 2. avformat_find_stream_info
74        ctx.find_stream_info().map_err(|e| DecodeError::Ffmpeg {
75            code: e.code(),
76            message: format!(
77                "Failed to find stream info: {}",
78                ff_sys::av_error_string(e.code())
79            ),
80        })?;
81
82        // 3. Find the video stream.
83        let (stream_index, codec_id) =
84            Self::find_video_stream(&ctx).ok_or_else(|| DecodeError::NoVideoStream {
85                path: path.to_path_buf(),
86            })?;
87
88        // 4. avcodec_find_decoder
89        // SAFETY: codec_id comes from FFmpeg.
90        // SAFETY: avcodec_get_name is safe for any codec ID value and returns a static C string.
91        let codec_name = unsafe {
92            let name_ptr = ff_sys::avcodec_get_name(codec_id);
93            if name_ptr.is_null() {
94                String::from("unknown")
95            } else {
96                CStr::from_ptr(name_ptr).to_string_lossy().into_owned()
97            }
98        };
99        let codec =
100            ff_sys::Codec::find_decoder(codec_id).ok_or_else(|| DecodeError::UnsupportedCodec {
101                codec: format!("{codec_name} (codec_id={codec_id:?})"),
102            })?;
103
104        // 5. avcodec_alloc_context3 (freed on drop by CodecContext).
105        let mut codec_ctx =
106            ff_sys::CodecContext::new(Some(codec)).map_err(|e| DecodeError::Ffmpeg {
107                code: e.code(),
108                message: format!(
109                    "Failed to allocate codec context: {}",
110                    ff_sys::av_error_string(e.code())
111                ),
112            })?;
113
114        // 6. avcodec_parameters_to_context
115        let codecpar = ctx
116            .stream(stream_index)
117            .ok_or_else(|| DecodeError::NoVideoStream {
118                path: path.to_path_buf(),
119            })?
120            .codecpar();
121        codec_ctx
122            .apply_parameters(&codecpar)
123            .map_err(|e| DecodeError::Ffmpeg {
124                code: e.code(),
125                message: format!(
126                    "Failed to copy codec parameters: {}",
127                    ff_sys::av_error_string(e.code())
128                ),
129            })?;
130
131        // 7. avcodec_open2
132        codec_ctx
133            .open_codec(codec)
134            .map_err(|e| DecodeError::Ffmpeg {
135                code: e.code(),
136                message: format!(
137                    "Failed to open codec: {}",
138                    ff_sys::av_error_string(e.code())
139                ),
140            })?;
141
142        // Allocate packet and frame (owned; free on drop, including on an early
143        // return from a later `?` — the packet frees itself if the frame alloc fails).
144        let packet = Packet::new().map_err(|e| DecodeError::Ffmpeg {
145            code: e.code(),
146            message: format!(
147                "Failed to allocate packet: {}",
148                ff_sys::av_error_string(e.code())
149            ),
150        })?;
151        let frame = Frame::new().map_err(|e| DecodeError::Ffmpeg {
152            code: e.code(),
153            message: format!(
154                "Failed to allocate frame: {}",
155                ff_sys::av_error_string(e.code())
156            ),
157        })?;
158
159        Ok(Self {
160            format_ctx: ctx,
161            codec_ctx,
162            stream_index,
163            packet,
164            frame,
165        })
166    }
167
168    /// Returns the image width in pixels.
169    pub(crate) fn width(&self) -> u32 {
170        self.codec_ctx.width() as u32
171    }
172
173    /// Returns the image height in pixels.
174    pub(crate) fn height(&self) -> u32 {
175        self.codec_ctx.height() as u32
176    }
177
178    /// Decodes the image, consuming `self` and returning a [`VideoFrame`].
179    ///
180    /// Follows the sequence:
181    /// 1. `av_read_frame`
182    /// 2. `avcodec_send_packet`
183    /// 3. `avcodec_receive_frame`
184    /// 4. Convert to [`VideoFrame`]
185    pub(crate) fn decode(mut self) -> Result<VideoFrame, DecodeError> {
186        // 1. av_read_frame
187        if let Err(e) = self.format_ctx.read_frame(&mut self.packet) {
188            let ret = e.code();
189            return Err(DecodeError::Ffmpeg {
190                code: ret,
191                message: format!("Failed to read frame: {}", ff_sys::av_error_string(ret)),
192            });
193        }
194
195        // 2. avcodec_send_packet
196        let send_result = self.codec_ctx.send_packet(&self.packet);
197        self.packet.unref();
198        if let Err(e) = send_result {
199            return Err(DecodeError::Ffmpeg {
200                code: e.code(),
201                message: format!(
202                    "Failed to send packet to decoder: {}",
203                    ff_sys::av_error_string(e.code())
204                ),
205            });
206        }
207
208        // 3. avcodec_receive_frame
209        match self
210            .codec_ctx
211            .receive_frame(&mut self.frame)
212            .map_err(|e| DecodeError::Ffmpeg {
213                code: e.code(),
214                message: format!(
215                    "Failed to receive decoded frame: {}",
216                    ff_sys::av_error_string(e.code())
217                ),
218            })? {
219            ff_sys::ReceiveOutcome::Frame => {}
220            // Preserve the pre-migration behaviour: a bare EAGAIN/EOF from this
221            // single receive was surfaced as a `Ffmpeg` error with that raw code.
222            ff_sys::ReceiveOutcome::NeedInput => {
223                return Err(DecodeError::Ffmpeg {
224                    code: ff_sys::error_codes::EAGAIN,
225                    message: format!(
226                        "Failed to receive decoded frame: {}",
227                        ff_sys::av_error_string(ff_sys::error_codes::EAGAIN)
228                    ),
229                });
230            }
231            ff_sys::ReceiveOutcome::Drained => {
232                return Err(DecodeError::Ffmpeg {
233                    code: ff_sys::error_codes::EOF,
234                    message: format!(
235                        "Failed to receive decoded frame: {}",
236                        ff_sys::av_error_string(ff_sys::error_codes::EOF)
237                    ),
238                });
239            }
240        }
241
242        // 4. Convert to VideoFrame.
243        // SAFETY: frame is valid and contains decoded image data.
244        let video_frame = unsafe { self.av_frame_to_video_frame(&self.frame)? };
245        Ok(video_frame)
246    }
247
248    /// Finds the first video stream in the format context.
249    fn find_video_stream(format_ctx: &InputFormatContext) -> Option<(usize, AVCodecID)> {
250        for stream in format_ctx.streams() {
251            let codecpar = stream.codecpar();
252            if codecpar.codec_type() == AVMediaType_AVMEDIA_TYPE_VIDEO {
253                return Some((stream.index() as usize, codecpar.codec_id()));
254            }
255        }
256        None
257    }
258
259    /// Maps an `AVPixelFormat` value to our [`PixelFormat`] enum.
260    ///
261    /// Image decoders commonly produce YUVJ formats (full-range YUV), which
262    /// have the same plane layout as the corresponding YUV formats but with a
263    /// different color range flag.  We map them to their YUV equivalents here
264    /// and rely on the colour-range metadata to distinguish them if needed.
265    fn convert_pixel_format(fmt: AVPixelFormat) -> PixelFormat {
266        if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P
267            || fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ420P
268        {
269            PixelFormat::Yuv420p
270        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV422P
271            || fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ422P
272        {
273            PixelFormat::Yuv422p
274        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV444P
275            || fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ444P
276        {
277            PixelFormat::Yuv444p
278        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_RGB24 {
279            PixelFormat::Rgb24
280        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_BGR24 {
281            PixelFormat::Bgr24
282        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA {
283            PixelFormat::Rgba
284        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_BGRA {
285            PixelFormat::Bgra
286        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_GRAY8 {
287            PixelFormat::Gray8
288        } else {
289            log::warn!(
290                "pixel_format unsupported, falling back to Rgb24 requested={fmt} fallback=Rgb24"
291            );
292            PixelFormat::Rgb24
293        }
294    }
295
296    /// Converts a decoded owned [`Frame`] to a [`VideoFrame`].
297    ///
298    /// Scalar fields are read through accessors; the plane data copy in
299    /// [`extract_planes_and_strides`](Self::extract_planes_and_strides) reads each
300    /// plane through [`Frame::copy_plane_rows`].
301    ///
302    /// # Safety
303    ///
304    /// `frame` must hold a fully decoded image whose pixel format the copy expects.
305    unsafe fn av_frame_to_video_frame(&self, frame: &Frame) -> Result<VideoFrame, DecodeError> {
306        let width = frame.width() as u32;
307        let height = frame.height() as u32;
308        let format = Self::convert_pixel_format(frame.format());
309
310        // Extract timestamp (images often have no meaningful PTS).
311        let pts = frame.pts();
312        let timestamp = if pts == ff_sys::AV_NOPTS_VALUE {
313            Timestamp::default()
314        } else {
315            match self.format_ctx.stream(self.stream_index) {
316                Some(stream) => {
317                    let time_base = stream.time_base();
318                    Timestamp::new(
319                        pts,
320                        Rational::new(time_base.num as i32, time_base.den as i32),
321                    )
322                }
323                None => Timestamp::default(),
324            }
325        };
326
327        // SAFETY: `format` is derived from `frame.format()`, so it matches the frame.
328        let (planes, strides) =
329            unsafe { Self::extract_planes_and_strides(frame, width, height, format)? };
330
331        // Images are always key frames.
332        VideoFrame::new(planes, strides, width, height, format, timestamp, true).map_err(|e| {
333            DecodeError::Ffmpeg {
334                code: 0,
335                message: format!("Failed to create VideoFrame: {e}"),
336            }
337        })
338    }
339
340    /// Extracts pixel data from a decoded [`Frame`] into [`PooledBuffer`] planes.
341    ///
342    /// Copies data row-by-row to strip any FFmpeg padding from line strides.
343    ///
344    /// # Safety
345    ///
346    /// `frame` must be a valid, fully decoded frame with `format` matching the
347    /// actual pixel format of the frame.
348    unsafe fn extract_planes_and_strides(
349        frame: &Frame,
350        width: u32,
351        height: u32,
352        format: PixelFormat,
353    ) -> Result<(Vec<PooledBuffer>, Vec<usize>), DecodeError> {
354        let w = width as usize;
355        let h = height as usize;
356        let mut planes: Vec<PooledBuffer> = Vec::new();
357        let mut strides: Vec<usize> = Vec::new();
358
359        // Copies plane `i` (`rows` x `row_bytes`, packed into `buf` at `row_bytes`
360        // stride) and returns `true` when the plane was present. A `false` result
361        // (null / absent plane) leaves `buf` zero-filled — the caller decides
362        // whether that is an error (a required plane) or acceptable (a chroma
363        // plane).
364        // SAFETY: the caller (this `unsafe fn`) guarantees `format` and the
365        //         per-plane geometry match the frame, so `copy_plane_rows` reads
366        //         within each plane and writes within `buf`.
367        let copy_plane = |i: usize, buf: &mut [u8], rows: usize, row_bytes: usize| unsafe {
368            frame
369                .copy_plane_rows(i, buf, row_bytes, rows, row_bytes)
370                .is_some()
371        };
372
373        match format {
374            PixelFormat::Rgba | PixelFormat::Bgra => {
375                let row_w = w * 4;
376                let mut buf = vec![0u8; row_w * h];
377                if !copy_plane(0, &mut buf, h, row_w) {
378                    return Err(DecodeError::Ffmpeg {
379                        code: 0,
380                        message: "Null plane data for packed format".to_string(),
381                    });
382                }
383                planes.push(PooledBuffer::standalone(buf));
384                strides.push(row_w);
385            }
386            PixelFormat::Rgb24 | PixelFormat::Bgr24 => {
387                let row_w = w * 3;
388                let mut buf = vec![0u8; row_w * h];
389                if !copy_plane(0, &mut buf, h, row_w) {
390                    return Err(DecodeError::Ffmpeg {
391                        code: 0,
392                        message: "Null plane data for packed format".to_string(),
393                    });
394                }
395                planes.push(PooledBuffer::standalone(buf));
396                strides.push(row_w);
397            }
398            PixelFormat::Gray8 => {
399                let mut buf = vec![0u8; w * h];
400                if !copy_plane(0, &mut buf, h, w) {
401                    return Err(DecodeError::Ffmpeg {
402                        code: 0,
403                        message: "Null plane data for Gray8".to_string(),
404                    });
405                }
406                planes.push(PooledBuffer::standalone(buf));
407                strides.push(w);
408            }
409            PixelFormat::Yuv420p | PixelFormat::Nv12 | PixelFormat::Nv21 => {
410                // Y plane (full size).
411                let mut y_buf = vec![0u8; w * h];
412                if !copy_plane(0, &mut y_buf, h, w) {
413                    return Err(DecodeError::Ffmpeg {
414                        code: 0,
415                        message: "Null Y plane".to_string(),
416                    });
417                }
418                planes.push(PooledBuffer::standalone(y_buf));
419                strides.push(w);
420
421                if matches!(format, PixelFormat::Nv12 | PixelFormat::Nv21) {
422                    // Interleaved UV plane (half height); a null plane stays zeroed.
423                    let uv_h = h / 2;
424                    let mut uv_buf = vec![0u8; w * uv_h];
425                    copy_plane(1, &mut uv_buf, uv_h, w);
426                    planes.push(PooledBuffer::standalone(uv_buf));
427                    strides.push(w);
428                } else {
429                    // YUV 4:2:0 — separate U and V planes (half width, half height).
430                    let uv_w = w / 2;
431                    let uv_h = h / 2;
432                    for plane_idx in 1..=2usize {
433                        let mut uv_buf = vec![0u8; uv_w * uv_h];
434                        copy_plane(plane_idx, &mut uv_buf, uv_h, uv_w);
435                        planes.push(PooledBuffer::standalone(uv_buf));
436                        strides.push(uv_w);
437                    }
438                }
439            }
440            PixelFormat::Yuv422p => {
441                // Y plane (full size), U and V planes (half width, full height).
442                let uv_w = w / 2;
443                let plane_dims = [(w, h), (uv_w, h), (uv_w, h)];
444                for (plane_idx, (pw, ph)) in plane_dims.iter().enumerate() {
445                    let mut buf = vec![0u8; pw * ph];
446                    copy_plane(plane_idx, &mut buf, *ph, *pw);
447                    planes.push(PooledBuffer::standalone(buf));
448                    strides.push(*pw);
449                }
450            }
451            PixelFormat::Yuv444p => {
452                // All three planes are full size.
453                for plane_idx in 0..3usize {
454                    let mut buf = vec![0u8; w * h];
455                    copy_plane(plane_idx, &mut buf, h, w);
456                    planes.push(PooledBuffer::standalone(buf));
457                    strides.push(w);
458                }
459            }
460            _ => {
461                return Err(DecodeError::Ffmpeg {
462                    code: 0,
463                    message: format!("Unsupported pixel format for image decoding: {format:?}"),
464                });
465            }
466        }
467
468        Ok((planes, strides))
469    }
470}
471
472// All fields own their FFmpeg resources (`Frame`, `Packet`, `CodecContext`,
473// `InputFormatContext`) and free themselves on drop, so no manual `Drop` impl is
474// required.
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn convert_pixel_format_yuv420p_should_map_to_yuv420p() {
482        assert_eq!(
483            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P),
484            PixelFormat::Yuv420p
485        );
486    }
487
488    #[test]
489    fn convert_pixel_format_yuvj420p_should_map_to_yuv420p() {
490        assert_eq!(
491            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ420P),
492            PixelFormat::Yuv420p
493        );
494    }
495
496    #[test]
497    fn convert_pixel_format_rgb24_should_map_to_rgb24() {
498        assert_eq!(
499            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_RGB24),
500            PixelFormat::Rgb24
501        );
502    }
503
504    #[test]
505    fn convert_pixel_format_rgba_should_map_to_rgba() {
506        assert_eq!(
507            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA),
508            PixelFormat::Rgba
509        );
510    }
511
512    #[test]
513    fn convert_pixel_format_gray8_should_map_to_gray8() {
514        assert_eq!(
515            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_GRAY8),
516            PixelFormat::Gray8
517        );
518    }
519
520    #[test]
521    fn unsupported_codec_error_should_include_codec_name() {
522        let codec_id = ff_sys::AVCodecID_AV_CODEC_ID_PNG;
523        // SAFETY: avcodec_get_name is safe for any codec ID value and returns a static C string.
524        let codec_name = unsafe {
525            let name_ptr = ff_sys::avcodec_get_name(codec_id);
526            if name_ptr.is_null() {
527                String::from("unknown")
528            } else {
529                std::ffi::CStr::from_ptr(name_ptr)
530                    .to_string_lossy()
531                    .into_owned()
532            }
533        };
534        let error = crate::error::DecodeError::UnsupportedCodec {
535            codec: format!("{codec_name} (codec_id={codec_id:?})"),
536        };
537        let msg = error.to_string();
538        assert!(msg.contains("png"), "expected codec name in error: {msg}");
539        assert!(
540            msg.contains("codec_id="),
541            "expected codec_id in error: {msg}"
542        );
543    }
544}