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;
23use std::ptr;
24
25use ff_format::time::{Rational, Timestamp};
26use ff_format::{PixelFormat, PooledBuffer, VideoFrame};
27use ff_sys::{
28    AVCodecContext, AVCodecID, AVFormatContext, AVFrame, AVMediaType_AVMEDIA_TYPE_VIDEO, AVPacket,
29    AVPixelFormat,
30};
31
32use crate::error::DecodeError;
33
34// ── RAII guards ────────────────────────────────────────────────────────────────
35
36/// RAII guard for `AVFormatContext` to ensure proper cleanup.
37struct AvFormatContextGuard(*mut AVFormatContext);
38
39impl AvFormatContextGuard {
40    unsafe fn new(path: &Path) -> Result<Self, DecodeError> {
41        // SAFETY: Caller ensures FFmpeg is initialized and path is valid
42        let format_ctx = unsafe {
43            ff_sys::avformat::open_input(path).map_err(|e| DecodeError::Ffmpeg {
44                code: e,
45                message: format!("Failed to open file: {}", ff_sys::av_error_string(e)),
46            })?
47        };
48        Ok(Self(format_ctx))
49    }
50
51    const fn as_ptr(&self) -> *mut AVFormatContext {
52        self.0
53    }
54
55    fn into_raw(self) -> *mut AVFormatContext {
56        let ptr = self.0;
57        std::mem::forget(self);
58        ptr
59    }
60}
61
62impl Drop for AvFormatContextGuard {
63    fn drop(&mut self) {
64        if !self.0.is_null() {
65            // SAFETY: self.0 is valid and owned by this guard
66            unsafe {
67                ff_sys::avformat::close_input(&mut (self.0 as *mut _));
68            }
69        }
70    }
71}
72
73/// RAII guard for `AVCodecContext` to ensure proper cleanup.
74struct AvCodecContextGuard(*mut AVCodecContext);
75
76impl AvCodecContextGuard {
77    unsafe fn new(codec: *const ff_sys::AVCodec) -> Result<Self, DecodeError> {
78        // SAFETY: Caller ensures codec pointer is valid
79        let codec_ctx = unsafe {
80            ff_sys::avcodec::alloc_context3(codec).map_err(|e| DecodeError::Ffmpeg {
81                code: e,
82                message: format!("Failed to allocate codec context: {e}"),
83            })?
84        };
85        Ok(Self(codec_ctx))
86    }
87
88    const fn as_ptr(&self) -> *mut AVCodecContext {
89        self.0
90    }
91
92    fn into_raw(self) -> *mut AVCodecContext {
93        let ptr = self.0;
94        std::mem::forget(self);
95        ptr
96    }
97}
98
99impl Drop for AvCodecContextGuard {
100    fn drop(&mut self) {
101        if !self.0.is_null() {
102            // SAFETY: self.0 is valid and owned by this guard
103            unsafe {
104                ff_sys::avcodec::free_context(&mut (self.0 as *mut _));
105            }
106        }
107    }
108}
109
110// ── ImageDecoderInner ─────────────────────────────────────────────────────────
111
112/// Internal state for the image decoder.
113///
114/// Holds raw FFmpeg pointers and is responsible for proper cleanup in `Drop`.
115pub(crate) struct ImageDecoderInner {
116    /// Format context for reading the image file.
117    format_ctx: *mut AVFormatContext,
118    /// Codec context for decoding the image.
119    codec_ctx: *mut AVCodecContext,
120    /// Video stream index in the format context.
121    stream_index: usize,
122    /// Reusable packet for reading from file.
123    packet: *mut AVPacket,
124    /// Reusable frame for decoding.
125    frame: *mut AVFrame,
126}
127
128// SAFETY: `ImageDecoderInner` owns all FFmpeg contexts exclusively.
129//         FFmpeg contexts are not safe for concurrent access (not Sync),
130//         but ownership transfer between threads is safe.
131unsafe impl Send for ImageDecoderInner {}
132
133impl ImageDecoderInner {
134    /// Opens an image file and prepares the decoder.
135    ///
136    /// Performs the full FFmpeg initialization sequence:
137    /// 1. `avformat_open_input`
138    /// 2. `avformat_find_stream_info`
139    /// 3. `av_find_best_stream(AVMEDIA_TYPE_VIDEO)`
140    /// 4. `avcodec_find_decoder`
141    /// 5. `avcodec_alloc_context3`
142    /// 6. `avcodec_parameters_to_context`
143    /// 7. `avcodec_open2`
144    pub(crate) fn new(path: &Path) -> Result<Self, DecodeError> {
145        ff_sys::ensure_initialized();
146
147        // 1. avformat_open_input
148        // SAFETY: Path is valid; AvFormatContextGuard ensures cleanup on error.
149        let format_ctx_guard = unsafe { AvFormatContextGuard::new(path)? };
150        let format_ctx = format_ctx_guard.as_ptr();
151
152        // 2. avformat_find_stream_info
153        // SAFETY: format_ctx is valid and owned by the guard.
154        unsafe {
155            ff_sys::avformat::find_stream_info(format_ctx).map_err(|e| DecodeError::Ffmpeg {
156                code: e,
157                message: format!("Failed to find stream info: {}", ff_sys::av_error_string(e)),
158            })?;
159        }
160
161        // 3. Find the video stream.
162        // SAFETY: format_ctx is valid.
163        let (stream_index, codec_id) =
164            unsafe { Self::find_video_stream(format_ctx) }.ok_or_else(|| {
165                DecodeError::NoVideoStream {
166                    path: path.to_path_buf(),
167                }
168            })?;
169
170        // 4. avcodec_find_decoder
171        // SAFETY: codec_id comes from FFmpeg.
172        // SAFETY: avcodec_get_name is safe for any codec ID value and returns a static C string.
173        let codec_name = unsafe {
174            let name_ptr = ff_sys::avcodec_get_name(codec_id);
175            if name_ptr.is_null() {
176                String::from("unknown")
177            } else {
178                CStr::from_ptr(name_ptr).to_string_lossy().into_owned()
179            }
180        };
181        let codec = unsafe {
182            ff_sys::avcodec::find_decoder(codec_id).ok_or_else(|| {
183                DecodeError::UnsupportedCodec {
184                    codec: format!("{codec_name} (codec_id={codec_id:?})"),
185                }
186            })?
187        };
188
189        // 5. avcodec_alloc_context3
190        // SAFETY: codec pointer is valid; AvCodecContextGuard ensures cleanup.
191        let codec_ctx_guard = unsafe { AvCodecContextGuard::new(codec)? };
192        let codec_ctx = codec_ctx_guard.as_ptr();
193
194        // 6. avcodec_parameters_to_context
195        // SAFETY: All pointers are valid; stream_index was validated above.
196        unsafe {
197            let stream = (*format_ctx).streams.add(stream_index);
198            let codecpar = (*(*stream)).codecpar;
199            ff_sys::avcodec::parameters_to_context(codec_ctx, codecpar).map_err(|e| {
200                DecodeError::Ffmpeg {
201                    code: e,
202                    message: format!(
203                        "Failed to copy codec parameters: {}",
204                        ff_sys::av_error_string(e)
205                    ),
206                }
207            })?;
208        }
209
210        // 7. avcodec_open2
211        // SAFETY: codec_ctx and codec are valid; no hardware acceleration for images.
212        unsafe {
213            ff_sys::avcodec::open2(codec_ctx, codec, ptr::null_mut()).map_err(|e| {
214                DecodeError::Ffmpeg {
215                    code: e,
216                    message: format!("Failed to open codec: {}", ff_sys::av_error_string(e)),
217                }
218            })?;
219        }
220
221        // Allocate packet and frame.
222        // SAFETY: FFmpeg is initialized.
223        let packet = unsafe { ff_sys::av_packet_alloc() };
224        if packet.is_null() {
225            return Err(DecodeError::Ffmpeg {
226                code: 0,
227                message: "Failed to allocate packet".to_string(),
228            });
229        }
230        let frame = unsafe { ff_sys::av_frame_alloc() };
231        if frame.is_null() {
232            unsafe { ff_sys::av_packet_free(&mut (packet as *mut _)) };
233            return Err(DecodeError::Ffmpeg {
234                code: 0,
235                message: "Failed to allocate frame".to_string(),
236            });
237        }
238
239        Ok(Self {
240            format_ctx: format_ctx_guard.into_raw(),
241            codec_ctx: codec_ctx_guard.into_raw(),
242            stream_index,
243            packet,
244            frame,
245        })
246    }
247
248    /// Returns the image width in pixels.
249    pub(crate) fn width(&self) -> u32 {
250        // SAFETY: codec_ctx is valid for the lifetime of `self`.
251        unsafe { (*self.codec_ctx).width as u32 }
252    }
253
254    /// Returns the image height in pixels.
255    pub(crate) fn height(&self) -> u32 {
256        // SAFETY: codec_ctx is valid for the lifetime of `self`.
257        unsafe { (*self.codec_ctx).height as u32 }
258    }
259
260    /// Decodes the image, consuming `self` and returning a [`VideoFrame`].
261    ///
262    /// Follows the sequence:
263    /// 1. `av_read_frame`
264    /// 2. `avcodec_send_packet`
265    /// 3. `avcodec_receive_frame`
266    /// 4. Convert to [`VideoFrame`]
267    pub(crate) fn decode(self) -> Result<VideoFrame, DecodeError> {
268        // 1. av_read_frame
269        // SAFETY: format_ctx and packet are valid.
270        let ret = unsafe { ff_sys::av_read_frame(self.format_ctx, self.packet) };
271        if ret < 0 {
272            return Err(DecodeError::Ffmpeg {
273                code: ret,
274                message: format!("Failed to read frame: {}", ff_sys::av_error_string(ret)),
275            });
276        }
277
278        // 2. avcodec_send_packet
279        // SAFETY: codec_ctx and packet are valid; packet contains image data.
280        let ret = unsafe { ff_sys::avcodec_send_packet(self.codec_ctx, self.packet) };
281        unsafe { ff_sys::av_packet_unref(self.packet) };
282        if ret < 0 {
283            return Err(DecodeError::Ffmpeg {
284                code: ret,
285                message: format!(
286                    "Failed to send packet to decoder: {}",
287                    ff_sys::av_error_string(ret)
288                ),
289            });
290        }
291
292        // 3. avcodec_receive_frame
293        // SAFETY: codec_ctx and frame are valid.
294        let ret = unsafe { ff_sys::avcodec_receive_frame(self.codec_ctx, self.frame) };
295        if ret < 0 {
296            return Err(DecodeError::Ffmpeg {
297                code: ret,
298                message: format!(
299                    "Failed to receive decoded frame: {}",
300                    ff_sys::av_error_string(ret)
301                ),
302            });
303        }
304
305        // 4. Convert to VideoFrame.
306        // SAFETY: frame is valid and contains decoded image data.
307        let video_frame = unsafe { self.av_frame_to_video_frame(self.frame)? };
308        Ok(video_frame)
309    }
310
311    /// Finds the first video stream in the format context.
312    ///
313    /// # Safety
314    ///
315    /// `format_ctx` must be a valid, fully initialized `AVFormatContext`.
316    unsafe fn find_video_stream(format_ctx: *mut AVFormatContext) -> Option<(usize, AVCodecID)> {
317        // SAFETY: Caller ensures format_ctx is valid.
318        unsafe {
319            let nb_streams = (*format_ctx).nb_streams as usize;
320            for i in 0..nb_streams {
321                let stream = (*format_ctx).streams.add(i);
322                let codecpar = (*(*stream)).codecpar;
323                if (*codecpar).codec_type == AVMediaType_AVMEDIA_TYPE_VIDEO {
324                    return Some((i, (*codecpar).codec_id));
325                }
326            }
327        }
328        None
329    }
330
331    /// Maps an `AVPixelFormat` value to our [`PixelFormat`] enum.
332    ///
333    /// Image decoders commonly produce YUVJ formats (full-range YUV), which
334    /// have the same plane layout as the corresponding YUV formats but with a
335    /// different color range flag.  We map them to their YUV equivalents here
336    /// and rely on the colour-range metadata to distinguish them if needed.
337    fn convert_pixel_format(fmt: AVPixelFormat) -> PixelFormat {
338        if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P
339            || fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ420P
340        {
341            PixelFormat::Yuv420p
342        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV422P
343            || fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ422P
344        {
345            PixelFormat::Yuv422p
346        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV444P
347            || fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ444P
348        {
349            PixelFormat::Yuv444p
350        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_RGB24 {
351            PixelFormat::Rgb24
352        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_BGR24 {
353            PixelFormat::Bgr24
354        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA {
355            PixelFormat::Rgba
356        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_BGRA {
357            PixelFormat::Bgra
358        } else if fmt == ff_sys::AVPixelFormat_AV_PIX_FMT_GRAY8 {
359            PixelFormat::Gray8
360        } else {
361            log::warn!(
362                "pixel_format unsupported, falling back to Rgb24 requested={fmt} fallback=Rgb24"
363            );
364            PixelFormat::Rgb24
365        }
366    }
367
368    /// Converts a decoded `AVFrame` to a [`VideoFrame`].
369    ///
370    /// # Safety
371    ///
372    /// `frame` must be a valid, fully decoded `AVFrame` owned by `self`.
373    unsafe fn av_frame_to_video_frame(
374        &self,
375        frame: *const AVFrame,
376    ) -> Result<VideoFrame, DecodeError> {
377        // SAFETY: Caller ensures frame is valid.
378        unsafe {
379            let width = (*frame).width as u32;
380            let height = (*frame).height as u32;
381            let format = Self::convert_pixel_format((*frame).format);
382
383            // Extract timestamp (images often have no meaningful PTS).
384            let pts = (*frame).pts;
385            let timestamp = if pts == ff_sys::AV_NOPTS_VALUE {
386                Timestamp::default()
387            } else {
388                let stream = (*self.format_ctx).streams.add(self.stream_index);
389                let time_base = (*(*stream)).time_base;
390                Timestamp::new(
391                    pts as i64,
392                    Rational::new(time_base.num as i32, time_base.den as i32),
393                )
394            };
395
396            let (planes, strides) = Self::extract_planes_and_strides(frame, width, height, format)?;
397
398            // Images are always key frames.
399            VideoFrame::new(planes, strides, width, height, format, timestamp, true).map_err(|e| {
400                DecodeError::Ffmpeg {
401                    code: 0,
402                    message: format!("Failed to create VideoFrame: {e}"),
403                }
404            })
405        }
406    }
407
408    /// Extracts pixel data from an `AVFrame` into [`PooledBuffer`] planes.
409    ///
410    /// Copies data row-by-row to strip any FFmpeg padding from line strides.
411    ///
412    /// # Safety
413    ///
414    /// `frame` must be a valid, fully decoded `AVFrame` with `format` matching
415    /// the actual pixel format of the frame.
416    unsafe fn extract_planes_and_strides(
417        frame: *const AVFrame,
418        width: u32,
419        height: u32,
420        format: PixelFormat,
421    ) -> Result<(Vec<PooledBuffer>, Vec<usize>), DecodeError> {
422        // SAFETY: Caller ensures frame is valid and format matches.
423        unsafe {
424            let w = width as usize;
425            let h = height as usize;
426            let mut planes: Vec<PooledBuffer> = Vec::new();
427            let mut strides: Vec<usize> = Vec::new();
428
429            match format {
430                PixelFormat::Rgba | PixelFormat::Bgra => {
431                    let bytes_per_pixel = 4_usize;
432                    let stride = (*frame).linesize[0] as usize;
433                    let row_w = w * bytes_per_pixel;
434                    let mut buf = vec![0u8; row_w * h];
435                    let src = (*frame).data[0];
436                    if src.is_null() {
437                        return Err(DecodeError::Ffmpeg {
438                            code: 0,
439                            message: "Null plane data for packed format".to_string(),
440                        });
441                    }
442                    for row in 0..h {
443                        ptr::copy_nonoverlapping(
444                            src.add(row * stride),
445                            buf[row * row_w..].as_mut_ptr(),
446                            row_w,
447                        );
448                    }
449                    planes.push(PooledBuffer::standalone(buf));
450                    strides.push(row_w);
451                }
452                PixelFormat::Rgb24 | PixelFormat::Bgr24 => {
453                    let bytes_per_pixel = 3_usize;
454                    let stride = (*frame).linesize[0] as usize;
455                    let row_w = w * bytes_per_pixel;
456                    let mut buf = vec![0u8; row_w * h];
457                    let src = (*frame).data[0];
458                    if src.is_null() {
459                        return Err(DecodeError::Ffmpeg {
460                            code: 0,
461                            message: "Null plane data for packed format".to_string(),
462                        });
463                    }
464                    for row in 0..h {
465                        ptr::copy_nonoverlapping(
466                            src.add(row * stride),
467                            buf[row * row_w..].as_mut_ptr(),
468                            row_w,
469                        );
470                    }
471                    planes.push(PooledBuffer::standalone(buf));
472                    strides.push(row_w);
473                }
474                PixelFormat::Gray8 => {
475                    let stride = (*frame).linesize[0] as usize;
476                    let mut buf = vec![0u8; w * h];
477                    let src = (*frame).data[0];
478                    if src.is_null() {
479                        return Err(DecodeError::Ffmpeg {
480                            code: 0,
481                            message: "Null plane data for Gray8".to_string(),
482                        });
483                    }
484                    for row in 0..h {
485                        ptr::copy_nonoverlapping(
486                            src.add(row * stride),
487                            buf[row * w..].as_mut_ptr(),
488                            w,
489                        );
490                    }
491                    planes.push(PooledBuffer::standalone(buf));
492                    strides.push(w);
493                }
494                PixelFormat::Yuv420p | PixelFormat::Nv12 | PixelFormat::Nv21 => {
495                    // Y plane (full size).
496                    let y_stride = (*frame).linesize[0] as usize;
497                    let mut y_buf = vec![0u8; w * h];
498                    let y_src = (*frame).data[0];
499                    if y_src.is_null() {
500                        return Err(DecodeError::Ffmpeg {
501                            code: 0,
502                            message: "Null Y plane".to_string(),
503                        });
504                    }
505                    for row in 0..h {
506                        ptr::copy_nonoverlapping(
507                            y_src.add(row * y_stride),
508                            y_buf[row * w..].as_mut_ptr(),
509                            w,
510                        );
511                    }
512                    planes.push(PooledBuffer::standalone(y_buf));
513                    strides.push(w);
514
515                    if matches!(format, PixelFormat::Nv12 | PixelFormat::Nv21) {
516                        // Interleaved UV plane (half height).
517                        let uv_h = h / 2;
518                        let uv_stride = (*frame).linesize[1] as usize;
519                        let mut uv_buf = vec![0u8; w * uv_h];
520                        let uv_src = (*frame).data[1];
521                        if !uv_src.is_null() {
522                            for row in 0..uv_h {
523                                ptr::copy_nonoverlapping(
524                                    uv_src.add(row * uv_stride),
525                                    uv_buf[row * w..].as_mut_ptr(),
526                                    w,
527                                );
528                            }
529                        }
530                        planes.push(PooledBuffer::standalone(uv_buf));
531                        strides.push(w);
532                    } else {
533                        // YUV 4:2:0 — separate U and V planes (half width, half height).
534                        let uv_w = w / 2;
535                        let uv_h = h / 2;
536                        for plane_idx in 1..=2usize {
537                            let uv_stride = (*frame).linesize[plane_idx] as usize;
538                            let mut uv_buf = vec![0u8; uv_w * uv_h];
539                            let uv_src = (*frame).data[plane_idx];
540                            if !uv_src.is_null() {
541                                for row in 0..uv_h {
542                                    ptr::copy_nonoverlapping(
543                                        uv_src.add(row * uv_stride),
544                                        uv_buf[row * uv_w..].as_mut_ptr(),
545                                        uv_w,
546                                    );
547                                }
548                            }
549                            planes.push(PooledBuffer::standalone(uv_buf));
550                            strides.push(uv_w);
551                        }
552                    }
553                }
554                PixelFormat::Yuv422p => {
555                    // Y plane (full size), U and V planes (half width, full height).
556                    let uv_w = w / 2;
557                    let plane_dims = [(w, h), (uv_w, h), (uv_w, h)];
558                    for (plane_idx, (pw, ph)) in plane_dims.iter().enumerate() {
559                        let stride = (*frame).linesize[plane_idx] as usize;
560                        let mut buf = vec![0u8; pw * ph];
561                        let src = (*frame).data[plane_idx];
562                        if !src.is_null() {
563                            for row in 0..*ph {
564                                ptr::copy_nonoverlapping(
565                                    src.add(row * stride),
566                                    buf[row * pw..].as_mut_ptr(),
567                                    *pw,
568                                );
569                            }
570                        }
571                        planes.push(PooledBuffer::standalone(buf));
572                        strides.push(*pw);
573                    }
574                }
575                PixelFormat::Yuv444p => {
576                    // All three planes are full size.
577                    for plane_idx in 0..3usize {
578                        let stride = (*frame).linesize[plane_idx] as usize;
579                        let mut buf = vec![0u8; w * h];
580                        let src = (*frame).data[plane_idx];
581                        if !src.is_null() {
582                            for row in 0..h {
583                                ptr::copy_nonoverlapping(
584                                    src.add(row * stride),
585                                    buf[row * w..].as_mut_ptr(),
586                                    w,
587                                );
588                            }
589                        }
590                        planes.push(PooledBuffer::standalone(buf));
591                        strides.push(w);
592                    }
593                }
594                _ => {
595                    return Err(DecodeError::Ffmpeg {
596                        code: 0,
597                        message: format!("Unsupported pixel format for image decoding: {format:?}"),
598                    });
599                }
600            }
601
602            Ok((planes, strides))
603        }
604    }
605}
606
607impl Drop for ImageDecoderInner {
608    fn drop(&mut self) {
609        // SAFETY: All pointers are exclusively owned by this struct and were
610        // allocated by the corresponding FFmpeg alloc functions.
611        unsafe {
612            if !self.frame.is_null() {
613                ff_sys::av_frame_free(&mut (self.frame as *mut _));
614            }
615            if !self.packet.is_null() {
616                ff_sys::av_packet_free(&mut (self.packet as *mut _));
617            }
618            if !self.codec_ctx.is_null() {
619                ff_sys::avcodec::free_context(&mut (self.codec_ctx as *mut _));
620            }
621            if !self.format_ctx.is_null() {
622                ff_sys::avformat::close_input(&mut (self.format_ctx as *mut _));
623            }
624        }
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn convert_pixel_format_yuv420p_should_map_to_yuv420p() {
634        assert_eq!(
635            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P),
636            PixelFormat::Yuv420p
637        );
638    }
639
640    #[test]
641    fn convert_pixel_format_yuvj420p_should_map_to_yuv420p() {
642        assert_eq!(
643            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUVJ420P),
644            PixelFormat::Yuv420p
645        );
646    }
647
648    #[test]
649    fn convert_pixel_format_rgb24_should_map_to_rgb24() {
650        assert_eq!(
651            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_RGB24),
652            PixelFormat::Rgb24
653        );
654    }
655
656    #[test]
657    fn convert_pixel_format_rgba_should_map_to_rgba() {
658        assert_eq!(
659            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA),
660            PixelFormat::Rgba
661        );
662    }
663
664    #[test]
665    fn convert_pixel_format_gray8_should_map_to_gray8() {
666        assert_eq!(
667            ImageDecoderInner::convert_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_GRAY8),
668            PixelFormat::Gray8
669        );
670    }
671
672    #[test]
673    fn unsupported_codec_error_should_include_codec_name() {
674        let codec_id = ff_sys::AVCodecID_AV_CODEC_ID_PNG;
675        // SAFETY: avcodec_get_name is safe for any codec ID value and returns a static C string.
676        let codec_name = unsafe {
677            let name_ptr = ff_sys::avcodec_get_name(codec_id);
678            if name_ptr.is_null() {
679                String::from("unknown")
680            } else {
681                std::ffi::CStr::from_ptr(name_ptr)
682                    .to_string_lossy()
683                    .into_owned()
684            }
685        };
686        let error = crate::error::DecodeError::UnsupportedCodec {
687            codec: format!("{codec_name} (codec_id={codec_id:?})"),
688        };
689        let msg = error.to_string();
690        assert!(msg.contains("png"), "expected codec name in error: {msg}");
691        assert!(
692            msg.contains("codec_id="),
693            "expected codec_id in error: {msg}"
694        );
695    }
696}