ff-decode 0.13.0

Video and audio decoding - the Rust way
Documentation
//! Integration tests for OpenEXR image-sequence decoding.
//!
//! These tests rely on FFmpeg having been built with `--enable-decoder=exr`.
//! All tests skip gracefully when the decoder is absent.

use std::path::{Path, PathBuf};

use ff_decode::{DecodeError, HardwareAccel, VideoDecoder};
use ff_format::PixelFormat;

mod fixtures;
use fixtures::assets_dir;

// ── Helpers ───────────────────────────────────────────────────────────────────

/// RAII guard that deletes a directory tree on drop.
struct TempDir(PathBuf);

impl TempDir {
    fn new(name: &str) -> Self {
        let dir = std::env::temp_dir().join(name);
        std::fs::create_dir_all(&dir).expect("failed to create temp dir");
        Self(dir)
    }

    fn path(&self) -> &Path {
        &self.0
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

/// Attempts to create an EXR image sequence in `dir` from the test PNG fixture.
///
/// Uses the `ffmpeg` CLI to convert the PNG to `gbrpf32le` EXR. Returns the
/// printf-style pattern path (e.g. `dir/frame%04d.exr`) on success, or `None`
/// when the EXR encoder is not available in this FFmpeg build.
fn make_exr_sequence(dir: &Path, count: usize) -> Option<PathBuf> {
    let src = assets_dir().join("img/hello-triangle.png");
    for i in 1..=count {
        let dst = dir.join(format!("frame{i:04}.exr"));
        let status = std::process::Command::new("ffmpeg")
            .args([
                "-y",
                "-i",
                src.to_str()?,
                "-vf",
                "format=gbrpf32le",
                "-frames:v",
                "1",
                dst.to_str()?,
            ])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .ok()?;
        if !status.success() {
            return None; // EXR encoder not available
        }
    }
    Some(dir.join("frame%04d.exr"))
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[test]
fn exr_sequence_should_decode_all_frames() {
    let tmp = TempDir::new("ff_decode_exr_seq_count");
    let pattern = match make_exr_sequence(tmp.path(), 3) {
        Some(p) => p,
        None => {
            println!("Skipping: EXR encoder not available in this FFmpeg build");
            return;
        }
    };

    let mut decoder = match VideoDecoder::open(&pattern)
        .hardware_accel(HardwareAccel::None)
        .build()
    {
        Ok(d) => d,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let mut count = 0usize;
    loop {
        match decoder.decode_one() {
            Ok(Some(_)) => count += 1,
            Ok(None) => break,
            Err(e) => panic!("decode error: {e}"),
        }
    }

    assert_eq!(count, 3, "expected 3 frames from a 3-image EXR sequence");
}

#[test]
fn exr_sequence_frame_should_have_gbrpf32le_pixel_format() {
    let tmp = TempDir::new("ff_decode_exr_seq_fmt");
    let pattern = match make_exr_sequence(tmp.path(), 1) {
        Some(p) => p,
        None => {
            println!("Skipping: EXR encoder not available in this FFmpeg build");
            return;
        }
    };

    let mut decoder = match VideoDecoder::open(&pattern)
        .hardware_accel(HardwareAccel::None)
        .build()
    {
        Ok(d) => d,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = match decoder.decode_one() {
        Ok(Some(f)) => f,
        Ok(None) => panic!("expected at least one frame"),
        Err(e) => panic!("decode error: {e}"),
    };

    assert_eq!(
        frame.format(),
        PixelFormat::Gbrpf32le,
        "EXR frames should decode as gbrpf32le"
    );
}

#[test]
fn exr_sequence_frame_should_have_positive_dimensions() {
    let tmp = TempDir::new("ff_decode_exr_seq_dims");
    let pattern = match make_exr_sequence(tmp.path(), 1) {
        Some(p) => p,
        None => {
            println!("Skipping: EXR encoder not available in this FFmpeg build");
            return;
        }
    };

    let mut decoder = match VideoDecoder::open(&pattern)
        .hardware_accel(HardwareAccel::None)
        .build()
    {
        Ok(d) => d,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = match decoder.decode_one() {
        Ok(Some(f)) => f,
        Ok(None) => panic!("expected at least one frame"),
        Err(e) => panic!("decode error: {e}"),
    };

    assert!(frame.width() > 0, "frame width must be positive");
    assert!(frame.height() > 0, "frame height must be positive");
}

#[test]
fn exr_sequence_frame_should_have_three_planes() {
    let tmp = TempDir::new("ff_decode_exr_seq_planes");
    let pattern = match make_exr_sequence(tmp.path(), 1) {
        Some(p) => p,
        None => {
            println!("Skipping: EXR encoder not available in this FFmpeg build");
            return;
        }
    };

    let mut decoder = match VideoDecoder::open(&pattern)
        .hardware_accel(HardwareAccel::None)
        .build()
    {
        Ok(d) => d,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = match decoder.decode_one() {
        Ok(Some(f)) => f,
        Ok(None) => panic!("expected at least one frame"),
        Err(e) => panic!("decode error: {e}"),
    };

    // gbrpf32le: three planes (G, B, R), each 32-bit float
    assert_eq!(frame.num_planes(), 3, "gbrpf32le must have 3 planes");
    for i in 0..3 {
        let plane = frame.plane(i).expect("plane {i} must exist");
        assert!(!plane.is_empty(), "plane {i} must contain data");
    }
}

#[test]
fn exr_missing_decoder_should_return_decoder_unavailable_not_panic() {
    // This test verifies that when an EXR file is opened but the EXR decoder
    // is not compiled into FFmpeg, we get DecoderUnavailable (not UnsupportedCodec,
    // not a panic, not a crash).
    //
    // We create a temporary directory with a single EXR frame. If EXR encoding
    // is also unavailable we cannot create the file and the test is vacuous —
    // it still exercises the DecoderUnavailable code path conceptually.
    let tmp = TempDir::new("ff_decode_exr_seq_nocodec");

    // Try to build an EXR file. If the encoder is absent we cannot produce a
    // valid EXR file to open, so we skip.
    let pattern = match make_exr_sequence(tmp.path(), 1) {
        Some(p) => p,
        None => {
            println!("Skipping: EXR encoder not available — cannot create test fixture");
            return;
        }
    };

    match VideoDecoder::open(&pattern)
        .hardware_accel(HardwareAccel::None)
        .build()
    {
        Ok(_) => {
            // EXR decoder IS available — the test is vacuous but passes.
        }
        Err(DecodeError::DecoderUnavailable { .. }) => {
            // Correctly surfaced as DecoderUnavailable.
        }
        Err(e) => panic!("unexpected error when opening EXR sequence: {e}"),
    }
}

#[test]
fn png_sequence_still_works_after_exr_support() {
    // Regression guard: adding EXR support must not break PNG sequences.
    let tmp = TempDir::new("ff_decode_exr_regression_png");
    let src = assets_dir().join("img/hello-triangle.png");

    for i in 1..=2u32 {
        let dst = tmp.path().join(format!("frame{i:04}.png"));
        std::fs::copy(&src, &dst).expect("failed to copy PNG");
    }
    let pattern = tmp.path().join("frame%04d.png");

    let mut decoder = match VideoDecoder::open(&pattern)
        .hardware_accel(HardwareAccel::None)
        .build()
    {
        Ok(d) => d,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let mut count = 0usize;
    loop {
        match decoder.decode_one() {
            Ok(Some(_)) => count += 1,
            Ok(None) => break,
            Err(e) => panic!("decode error: {e}"),
        }
    }

    assert_eq!(count, 2, "PNG sequence must still decode 2 frames");
}