mpv-engine 0.2.1

Toolkit-agnostic libmpv embedding core: handle lifecycle, commands, typed events, and render seams.
Documentation
//! Exported-frame backend tests against real libmpv and a real (hidden)
//! WGL context with `WGL_NV_DX_interop2` — Windows + `export` feature
//! only. Like the headless suite, missing tooling degrades to a skip: no
//! ffmpeg, no libmpv, or no GPU access (no OpenGL ICD, no interop
//! support, no window station on a bare CI runner) all print and return
//! instead of failing.
#![cfg(all(feature = "export", target_os = "windows"))]

mod common;

use std::time::{Duration, Instant};

use common::engine_with_frame;

/// `DXGI_FORMAT_B8G8R8A8_UNORM` — the only format this backend exports.
const DXGI_FORMAT_B8G8R8A8_UNORM: u32 = 87;

#[test]
fn exported_frames_carry_correctly_oriented_bgra_pixels() {
    let Some((engine, frame, _rx)) = engine_with_frame() else {
        return;
    };
    assert_eq!((frame.width(), frame.height()), (64, 64));
    assert!(!frame.shared_handle().is_null());
    assert_eq!(
        frame.dxgi_format(),
        DXGI_FORMAT_B8G8R8A8_UNORM,
        "backend promises BGRA8"
    );

    let px = frame.copy_pixels();
    assert_eq!(px.len(), 64 * 64 * 4);
    let pixel = |x: usize, y: usize| {
        let i = (y * 64 + x) * 4;
        (px[i], px[i + 1], px[i + 2]) // BGRA layout → (B, G, R)
    };
    // Top area red, bottom black — flipped output or RGBA/BGRA confusion
    // both fail loudly here. Sampled well away from the halfway seam so
    // chroma subsampling can't blur the assertion.
    let (top_b, _top_g, top_r) = pixel(32, 8);
    assert!(
        top_r > 150 && top_b < 100,
        "top should be red in BGRA order, got B={top_b} R={top_r}"
    );
    let (bot_b, bot_g, bot_r) = pixel(32, 56);
    assert!(
        bot_b < 60 && bot_g < 60 && bot_r < 60,
        "bottom should be black, got ({bot_b},{bot_g},{bot_r})"
    );

    // `presented` consumes the frame and must not wedge anything.
    frame.presented();
    engine.detach_render();
}

#[test]
fn resize_rerenders_at_new_size_without_playback() {
    let Some((engine, first, rx)) = engine_with_frame() else {
        return;
    };
    drop(first);
    // The clip may already have ended (keep-open holds the last frame);
    // the resize must force a re-render on its own.
    engine.set_export_size(32, 32).expect("resize");
    let deadline = Instant::now() + Duration::from_secs(10);
    loop {
        assert!(Instant::now() < deadline, "no 32x32 frame within 10s");
        let _ = rx.recv_timeout(Duration::from_millis(200));
        match engine.acquire_frame().expect("acquire") {
            Some(frame) if (frame.width(), frame.height()) == (32, 32) => {
                assert_eq!(frame.copy_pixels().len(), 32 * 32 * 4);
                break;
            }
            _ => {}
        }
    }
    engine.detach_render();
}

#[test]
fn outstanding_frame_survives_detach() {
    let Some((engine, frame, _rx)) = engine_with_frame() else {
        return;
    };
    // Detach (joins the render thread, closes the interop device and
    // destroys the GL context) with the frame still in hand: the shared
    // texture must stay alive and readable — `copy_pixels` runs on the
    // D3D11 device the frame keeps its own reference on, with no GL and
    // no interop involved — and nothing may deadlock or crash, including
    // the frame's return to the torn-down pool afterwards and dropping
    // the engine last.
    engine.detach_render();
    let px = frame.copy_pixels();
    assert_eq!(px.len(), 64 * 64 * 4);
    drop(engine);
    drop(frame);
}