znippy-plugin-media 0.9.0

Media (image/audio/video) metadata plugin for znippy — pure-Rust, airgap-friendly
//! Data-driven tests: parse small committed fixtures and assert the extracted
//! dimensions / duration / codecs / tags as real values (not just "it ran").
//!
//! Fixtures (`tests/fixtures/`) are generated deterministically:
//! - `tiny.png`  — 4x2 RGB (PIL)
//! - `exif.jpg`  — 8x6 JPEG with EXIF make/model/orientation/datetime (PIL)
//! - `tiny.wav`  — 0.1 s sine, 8000 Hz mono s16 (ffmpeg)
//! - `tiny.mp3`  — 0.2 s sine, 44100 Hz stereo, title/artist/album tags (ffmpeg)
//! - `tiny.mp4`  — 16x16 H.264 @10fps + AAC (ffmpeg)
//! - `tiny.webm` — 16x16 VP8 + Vorbis 44100 Hz (ffmpeg)

use super::*;
use crate::native::NativeMediaPlugin;
use znippy_common::plugin::{ArchiveTypePlugin, ExtensionValue};

const PNG: &[u8] = include_bytes!("../tests/fixtures/tiny.png");
const JPG: &[u8] = include_bytes!("../tests/fixtures/exif.jpg");
const WAV: &[u8] = include_bytes!("../tests/fixtures/tiny.wav");
const MP3: &[u8] = include_bytes!("../tests/fixtures/tiny.mp3");
const MP4: &[u8] = include_bytes!("../tests/fixtures/tiny.mp4");
const WEBM: &[u8] = include_bytes!("../tests/fixtures/tiny.webm");

#[test]
fn png_dimensions_and_color() {
    let m = extract_media_metadata("a/tiny.png", PNG).expect("png parses");
    assert_eq!(m.kind, Some(MediaKind::Image));
    assert_eq!(m.format.as_deref(), Some("png"));
    assert_eq!(m.width, Some(4));
    assert_eq!(m.height, Some(2));
    assert_eq!(m.color.as_deref(), Some("rgb8"));
}

#[test]
fn jpeg_exif_camera_orientation_datetime() {
    let m = extract_media_metadata("photos/exif.jpg", JPG).expect("jpg parses");
    assert_eq!(m.kind, Some(MediaKind::Image));
    assert_eq!(m.width, Some(8));
    assert_eq!(m.height, Some(6));
    // EXIF written by the fixture generator.
    let cam = m.camera.expect("camera present");
    assert!(cam.contains("ACME"), "camera make in {cam:?}");
    assert!(cam.contains("ZoomCam"), "camera model in {cam:?}");
    assert_eq!(m.orientation, Some(6));
    assert_eq!(m.datetime.as_deref(), Some("2021-01-02 03:04:05"));
}

#[test]
fn wav_duration_rate_channels() {
    let m = extract_media_metadata("snd/tiny.wav", WAV).expect("wav parses");
    assert_eq!(m.kind, Some(MediaKind::Audio));
    assert_eq!(m.format.as_deref(), Some("wav"));
    assert_eq!(m.sample_rate, Some(8000));
    assert_eq!(m.channels, Some(1));
    // 0.1 s ± codec rounding.
    let d = m.duration_ms.expect("duration");
    assert!((80..=140).contains(&d), "wav duration_ms={d}");
}

#[test]
fn mp3_tags_and_properties() {
    let m = extract_media_metadata("music/tiny.mp3", MP3).expect("mp3 parses");
    assert_eq!(m.kind, Some(MediaKind::Audio));
    assert_eq!(m.format.as_deref(), Some("mpeg"));
    assert_eq!(m.sample_rate, Some(44100));
    assert_eq!(m.channels, Some(2));
    assert_eq!(m.title.as_deref(), Some("T1"));
    assert_eq!(m.artist.as_deref(), Some("A1"));
    assert_eq!(m.album.as_deref(), Some("Alb1"));
    assert!(m.duration_ms.unwrap_or(0) > 0);
}

#[test]
fn mp4_video_dims_codecs_framerate() {
    let m = extract_media_metadata("vid/tiny.mp4", MP4).expect("mp4 parses");
    assert_eq!(m.kind, Some(MediaKind::Video));
    assert_eq!(m.format.as_deref(), Some("mp4"));
    assert_eq!(m.width, Some(16));
    assert_eq!(m.height, Some(16));
    assert_eq!(m.video_codec.as_deref(), Some("h264"));
    assert_eq!(m.audio_codec.as_deref(), Some("aac"));
    assert_eq!(m.framerate.as_deref(), Some("10.000"));
    assert!(m.duration_ms.unwrap_or(0) > 0);
}

#[test]
fn webm_matroska_dims_codecs_audio() {
    let m = extract_media_metadata("vid/tiny.webm", WEBM).expect("webm parses");
    assert_eq!(m.kind, Some(MediaKind::Video));
    assert_eq!(m.format.as_deref(), Some("webm"));
    assert_eq!(m.width, Some(16));
    assert_eq!(m.height, Some(16));
    assert!(m.video_codec.as_deref().unwrap_or("").contains("vp8"), "vcodec={:?}", m.video_codec);
    assert!(
        m.audio_codec.as_deref().unwrap_or("").contains("vorbis"),
        "acodec={:?}",
        m.audio_codec
    );
    assert_eq!(m.sample_rate, Some(44100));
}

// ── plugin trait surface ────────────────────────────────────────────────────

#[test]
fn plugin_meta_and_matching() {
    let p = NativeMediaPlugin::new();
    assert_eq!(p.name(), "media");
    assert_eq!(p.type_id(), MEDIA_TYPE_ID);
    let meta = p.meta();
    assert!(meta.aliases.contains(&"image".to_string()));
    assert!(meta.aliases.contains(&"audio".to_string()));
    assert!(meta.aliases.contains(&"video".to_string()));
    assert!(p.matches_path("x/y.PNG"), "case-insensitive match");
    assert!(p.matches_path("a.mp4"));
    assert!(!p.matches_path("a.tar.gz"));
}

#[test]
fn plugin_schema_matches_row_keys() {
    let p = NativeMediaPlugin::new();
    let fields: Vec<String> = p.schema_fields().iter().map(|f| f.name().clone()).collect();
    // numeric columns must be declared UInt32 so they stay queryable as numbers
    let by_name = |n: &str| p.schema_fields().iter().find(|f| f.name() == n).cloned();
    use znippy_common::arrow::datatypes::DataType;
    for num in ["width", "height", "duration_ms", "sample_rate", "channels", "bitrate", "orientation"] {
        assert_eq!(by_name(num).unwrap().data_type(), &DataType::UInt32, "{num} is UInt32");
    }

    // A parsed row only carries keys present in the schema.
    let row = p.extract_metadata("vid/tiny.mp4", MP4).expect("row");
    for k in row.fields.keys() {
        assert!(fields.contains(k), "row key {k} not in schema");
    }
    // width landed as a typed U32 value.
    assert_eq!(row.fields.get("width"), Some(&ExtensionValue::U32(16)));
    assert_eq!(
        row.fields.get("media_kind"),
        Some(&ExtensionValue::Str("video".to_string()))
    );
}

#[test]
fn non_media_path_is_ignored() {
    assert!(extract_media_metadata("readme.txt", b"hello").is_none());
    assert!(!is_media_path("foo.crate"));
}