unbundle 5.2.0

Unbundle media files - extract still frames, audio tracks, and subtitles from video files
Documentation
//! Integration tests for scene detection (feature = "scene").
//!
//! These tests require the `scene` feature and test fixtures
//! generated by `tests/fixtures/generate_fixtures.sh`.

#![cfg(feature = "scene")]

use std::path::Path;

use unbundle::{MediaFile, SceneDetectionMode, SceneDetectionOptions};

const SAMPLE_VIDEO: &str = "tests/fixtures/sample_video.mp4";
const SAMPLE_MKV: &str = "tests/fixtures/sample_video.mkv";
const AUDIO_ONLY: &str = "tests/fixtures/audio_only.wav";

fn skip_unless(path: &str) -> bool {
    if !Path::new(path).exists() {
        eprintln!("Skipping: fixture {path} not found");
        return true;
    }
    false
}

#[test]
fn detect_scenes_returns_results() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let scenes = unbundler.video().detect_scenes(None).unwrap();

    // Our small test fixture may or may not have scene changes, but the call
    // should succeed and return a valid Vec<SceneChange>.
    let _ = scenes;
}

#[test]
fn detect_scenes_with_default_config() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let config = SceneDetectionOptions::default();
    assert!(
        (config.threshold - 10.0).abs() < f64::EPSILON,
        "default threshold should be 10.0",
    );
    assert_eq!(config.mode, SceneDetectionMode::Auto);
    assert!(config.max_duration.is_none());
    assert!(config.max_scene_changes.is_none());

    let scenes = unbundler.video().detect_scenes(Some(config)).unwrap();
    // Should succeed regardless of number of scenes.
    let _ = scenes;
}

#[test]
fn detect_scenes_with_low_threshold() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let low = SceneDetectionOptions {
        threshold: 1.0,
        ..SceneDetectionOptions::default()
    };
    let scenes_low = unbundler.video().detect_scenes(Some(low)).unwrap();

    let mut unbundler2 = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let high = SceneDetectionOptions {
        threshold: 99.0,
        ..SceneDetectionOptions::default()
    };
    let scenes_high = unbundler2.video().detect_scenes(Some(high)).unwrap();

    // With a lower threshold we should find >= as many scenes as with a high one.
    assert!(
        scenes_low.len() >= scenes_high.len(),
        "lower threshold ({}) should find >= scenes than higher ({})",
        scenes_low.len(),
        scenes_high.len(),
    );
}

#[test]
fn detect_scenes_scene_change_fields() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let scenes = unbundler
        .video()
        .detect_scenes(Some(SceneDetectionOptions {
            threshold: 1.0,
            ..SceneDetectionOptions::default()
        }))
        .unwrap();

    for scene in &scenes {
        // Score must be positive (above the threshold).
        assert!(scene.score > 0.0, "scene score should be positive");
        // Timestamp and frame_number should be consistent.
        // (We can't check exact values without knowing the fixture, but we
        // can verify they are non-negative.)
        assert!(
            scene.timestamp.as_secs_f64() >= 0.0,
            "timestamp should be non-negative",
        );
    }
}

#[test]
fn detect_scenes_no_video_stream_error() {
    if skip_unless(AUDIO_ONLY) {
        return;
    }

    let mut unbundler = MediaFile::open(AUDIO_ONLY).unwrap();
    let result = unbundler.video().detect_scenes(None);
    assert!(result.is_err(), "should error on audio-only file");
    let err = format!("{}", result.unwrap_err());
    assert!(
        err.contains("video") || err.contains("Video"),
        "error should mention video stream: {err}",
    );
}

#[test]
fn detect_scenes_with_cancellation() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    use unbundle::{CancellationToken, ExtractOptions};

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let token = CancellationToken::new();
    token.cancel();

    let config = ExtractOptions::new().with_cancellation(token);
    let result = unbundler.video().detect_scenes_with_options(None, &config);

    assert!(result.is_err(), "cancelled detection should error");
    let err = format!("{}", result.unwrap_err());
    assert!(
        err.contains("ancelled") || err.contains("cancel"),
        "error should indicate cancellation: {err}",
    );
}

#[test]
fn detect_scenes_mkv_format() {
    if skip_unless(SAMPLE_MKV) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_MKV).unwrap();
    let scenes = unbundler.video().detect_scenes(None).unwrap();
    // Should work on MKV container.
    let _ = scenes;
}

#[test]
fn scene_config_debug() {
    let config = SceneDetectionOptions {
        threshold: 42.0,
        ..SceneDetectionOptions::default()
    };
    let debug = format!("{:?}", config);
    assert!(debug.contains("42"), "Debug should show threshold: {debug}");
}

#[test]
fn detect_scenes_with_max_scene_changes_limit() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let config = SceneDetectionOptions::new()
        .threshold(1.0)
        .max_scene_changes(1);

    let scenes = unbundler.video().detect_scenes(Some(config)).unwrap();
    assert!(scenes.len() <= 1, "should stop at configured scene limit");
}

#[test]
fn detect_scenes_with_max_duration_limit() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let config = SceneDetectionOptions::new().max_duration(std::time::Duration::from_secs(1));
    let scenes = unbundler.video().detect_scenes(Some(config)).unwrap();

    for scene in scenes {
        assert!(
            scene.timestamp <= std::time::Duration::from_secs(1),
            "scene timestamp should be within configured max duration",
        );
    }
}

#[test]
fn detect_scenes_keyframe_mode_runs() {
    if skip_unless(SAMPLE_VIDEO) {
        return;
    }

    let mut unbundler = MediaFile::open(SAMPLE_VIDEO).unwrap();
    let config = SceneDetectionOptions::new()
        .mode(SceneDetectionMode::Keyframes)
        .max_scene_changes(10);

    let scenes = unbundler.video().detect_scenes(Some(config)).unwrap();
    assert!(scenes.len() <= 10);
}

#[test]
fn scene_config_with_aliases_builds() {
    let config = SceneDetectionOptions::new()
        .with_threshold(5.0)
        .with_mode(SceneDetectionMode::Full)
        .with_max_duration(std::time::Duration::from_secs(2))
        .with_max_scene_changes(3);

    assert!((config.threshold - 5.0).abs() < f64::EPSILON);
    assert_eq!(config.mode, SceneDetectionMode::Full);
    assert_eq!(config.max_duration, Some(std::time::Duration::from_secs(2)));
    assert_eq!(config.max_scene_changes, Some(3));
}

#[test]
fn scene_change_debug_and_clone() {
    use unbundle::SceneChange;

    let sc = SceneChange {
        timestamp: std::time::Duration::from_millis(1500),
        frame_number: 45,
        score: 85.3,
    };

    let cloned = sc.clone();
    assert_eq!(cloned.frame_number, 45);
    assert!((cloned.score - 85.3).abs() < f64::EPSILON);

    let debug = format!("{:?}", sc);
    assert!(
        debug.contains("45"),
        "Debug should show frame number: {debug}"
    );
}