direct_play_nice 0.1.0-beta.3

CLI program that converts video files to direct-play-compatible formats.
Documentation
#![cfg(feature = "ffmpeg-cli-tests")]

//! Integration test: converts an input with VobSub (dvd_subtitle)
//! bitmap subtitles and verifies the output is Chromecast direct‑play
//! compatible with MOV_TEXT subs and stable timing.
//!
//! Note: Some ffmpeg builds cannot encode text -> bitmap subtitles
//! (dvdsub/dvb_subtitle) and may also lack the PGS encoder. To keep the
//! test portable, we attempt bitmap first and fall back to ASS text subs
//! inside MKV if all bitmap encoders are unavailable.

use assert_cmd::prelude::*;
use predicates::str;
use std::ffi::CString;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;

use rsmpeg::avformat::AVFormatContextInput;
use rsmpeg::ffi;

fn ensure_ffmpeg_present() {
    let out = Command::new("ffmpeg").arg("-version").output();
    match out {
        Ok(o) if o.status.success() => (),
        _ => panic!("ffmpeg CLI not found. Install ffmpeg and ensure it is on PATH."),
    }
}

fn mk_subs_file(path: &Path) {
    let mut f = File::create(path).expect("create srt");
    writeln!(
        f,
        "1\n00:00:00,000 --> 00:00:00,800\nvobsub line\n\n2\n00:00:01,000 --> 00:00:01,600\nsecond line\n"
    )
    .unwrap();
}

fn gen_problem_input_with_vobsub(tmp: &TempDir) -> (PathBuf, u64) {
    let dir = tmp.path();
    let video = dir.join("v.mkv");
    let audio = dir.join("a.mp2");
    let subs = dir.join("subs.srt");
    let input = dir.join("input_vobsub.mkv");

    mk_subs_file(&subs);

    // Tiny source: 2s MPEG4 yuv420p
    let status_v = Command::new("ffmpeg")
        .args([
            "-y",
            "-f",
            "lavfi",
            "-i",
            "testsrc=size=160x120:rate=25:duration=2",
            "-pix_fmt",
            "yuv420p",
            "-c:v",
            "mpeg4",
            &video.to_string_lossy(),
        ])
        .status()
        .expect("run ffmpeg video");
    assert!(status_v.success(), "ffmpeg video generation failed");

    let status_a = Command::new("ffmpeg")
        .args([
            "-y",
            "-f",
            "lavfi",
            "-i",
            "sine=frequency=1000:sample_rate=44100:duration=2",
            "-c:a",
            "mp2",
            &audio.to_string_lossy(),
        ])
        .status()
        .expect("run ffmpeg audio");
    assert!(status_a.success(), "ffmpeg audio generation failed");

    // Prefer bitmap subs (dvd_subtitle/dvb_subtitle/PGS). If unsupported,
    // fall back to text ASS to keep the test runnable.
    let candidates = ["dvd_subtitle", "dvb_subtitle", "hdmv_pgs_subtitle"];
    let mut ok = false;
    for codec in candidates {
        let status_mux = Command::new("ffmpeg")
            .args([
                "-y",
                "-i",
                &video.to_string_lossy(),
                "-i",
                &audio.to_string_lossy(),
                "-i",
                &subs.to_string_lossy(),
                "-c:v",
                "copy",
                "-c:a",
                "copy",
                "-c:s",
                codec,
                "-map",
                "0:v:0",
                "-map",
                "1:a:0",
                "-map",
                "2:0",
                &input.to_string_lossy(),
            ])
            .status()
            .expect("run ffmpeg mux vobsub");
        if status_mux.success() {
            ok = true;
            break;
        }
    }
    if !ok {
        let status_text = Command::new("ffmpeg")
            .args([
                "-y",
                "-i",
                &video.to_string_lossy(),
                "-i",
                &audio.to_string_lossy(),
                "-i",
                &subs.to_string_lossy(),
                "-c:v",
                "copy",
                "-c:a",
                "copy",
                "-c:s",
                "ass",
                "-map",
                "0:v:0",
                "-map",
                "1:a:0",
                "-map",
                "2:0",
                &input.to_string_lossy(),
            ])
            .status()
            .expect("run ffmpeg mux ass");
        assert!(
            status_text.success(),
            "ffmpeg mux with VobSub/PGS and ASS fallback failed"
        );
    }

    let input_cstr = CString::new(input.to_string_lossy().to_string()).unwrap();
    let ictx = AVFormatContextInput::open(input_cstr.as_c_str()).unwrap();
    let dur_ms = (ictx.duration / 1000).max(0) as u64;
    (input, dur_ms)
}

fn probe_duration_ms(path: &Path) -> u64 {
    let path_cstr = CString::new(path.to_string_lossy().to_string()).unwrap();
    let ictx = AVFormatContextInput::open(path_cstr.as_c_str()).unwrap();
    (ictx.duration / 1000).max(0) as u64
}

#[test]
fn cli_converts_vobsub_to_mov_text_and_direct_play() -> Result<(), Box<dyn std::error::Error>> {
    ensure_ffmpeg_present();

    let tmp = TempDir::new()?;
    let (input, in_dur_ms) = gen_problem_input_with_vobsub(&tmp);
    let output = tmp.path().join("out_vobsub.mp4");

    // Run the CLI for all Chromecast models
    let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("direct_play_nice"));
    cmd.arg("-s")
        .arg("chromecast_1st_gen,chromecast_2nd_gen,chromecast_ultra")
        .arg(&input)
        .arg(&output);
    cmd.assert().success().stdout(str::is_empty());

    assert!(output.exists(), "output file was not created");

    // Validate via rsmpeg
    let output_cstr = CString::new(output.to_string_lossy().to_string()).unwrap();
    let octx = AVFormatContextInput::open(output_cstr.as_c_str())?;

    let mut saw_v = false;
    let mut saw_a = false;
    let mut saw_s = false;
    let mut width = 0i32;
    let mut height = 0i32;
    let mut fps_num = 0i32;
    let mut fps_den = 1i32;
    let mut level = 0i32;
    let mut pix_fmt = -1i32;

    for st in octx.streams() {
        let par = st.codecpar();
        match par.codec_type {
            t if t == ffi::AVMEDIA_TYPE_VIDEO => {
                saw_v = true;
                assert_eq!(par.codec_id, ffi::AV_CODEC_ID_H264, "video must be H.264");
                width = par.width;
                height = par.height;
                level = par.level;
                pix_fmt = par.format;
                let rate = st.avg_frame_rate;
                fps_num = rate.num;
                fps_den = rate.den;
            }
            t if t == ffi::AVMEDIA_TYPE_AUDIO => {
                saw_a = true;
                assert_eq!(par.codec_id, ffi::AV_CODEC_ID_AAC, "audio must be AAC");
            }
            t if t == ffi::AVMEDIA_TYPE_SUBTITLE => {
                saw_s = true;
                assert_eq!(
                    par.codec_id,
                    ffi::AV_CODEC_ID_MOV_TEXT,
                    "subs must be MOV_TEXT"
                );
            }
            _ => {}
        }
    }

    assert!(
        saw_v && saw_a && saw_s,
        "missing one or more required streams"
    );

    assert!(
        width as u32 <= 1920 && height as u32 <= 1080,
        "resolution too high"
    );
    assert!(level <= 41, "H.264 level too high: {}", level);
    assert_eq!(pix_fmt, ffi::AV_PIX_FMT_YUV420P, "pix fmt must be yuv420p");
    if fps_den != 0 {
        let fps = (fps_num as f64) / (fps_den as f64);
        assert!(fps <= 30.01, "fps too high: {}", fps);
    }

    let out_dur_ms = probe_duration_ms(&output);
    let diff = out_dur_ms.abs_diff(in_dur_ms);
    assert!(
        diff <= 200,
        "duration drift too large: in={}ms out={}ms",
        in_dur_ms,
        out_dur_ms
    );

    Ok(())
}