#![cfg(feature = "ffmpeg-cli-tests")]
use assert_cmd::prelude::*;
use predicates::str;
use std::env;
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\nhello bitmap\n\n2\n00:00:01,000 --> 00:00:01,600\nsecond line\n"
)
.unwrap();
}
fn mk_subs_file_with_text(path: &Path, first: &str, second: &str) {
let mut f = File::create(path).expect("create srt");
writeln!(
f,
"1\n00:00:00,000 --> 00:00:00,800\n{}\n\n2\n00:00:01,000 --> 00:00:01,600\n{}\n",
first, second
)
.unwrap();
}
fn gen_problem_input_with_bitmap_subs(tmp: &TempDir) -> (PathBuf, u64, bool) {
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_bitmap.mkv");
let mut used_text_subs = false;
mk_subs_file(&subs);
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");
let candidates = ["hdmv_pgs_subtitle", "dvdsub", "dvb_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 bitmap subs");
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 text subs");
assert!(
status_text.success(),
"ffmpeg mux with bitmap and text subtitle encoders failed"
);
used_text_subs = true;
}
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, used_text_subs)
}
fn gen_multi_stream_bitmap_input(tmp: &TempDir) -> Option<PathBuf> {
let dir = tmp.path();
let video = dir.join("v_multi.mkv");
let audio = dir.join("a_multi.mp2");
let subs_eng = dir.join("subs_eng.srt");
let subs_spa = dir.join("subs_spa.srt");
let input = dir.join("input_multi_bitmap.mkv");
mk_subs_file_with_text(&subs_eng, "hello world", "second english line");
mk_subs_file_with_text(&subs_spa, "hola mundo", "segunda linea");
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");
let candidates = ["hdmv_pgs_subtitle", "dvdsub", "dvb_subtitle"];
for codec in candidates {
let status_mux = Command::new("ffmpeg")
.args([
"-y",
"-i",
&video.to_string_lossy(),
"-i",
&audio.to_string_lossy(),
"-i",
&subs_eng.to_string_lossy(),
"-i",
&subs_spa.to_string_lossy(),
"-c:v",
"copy",
"-c:a",
"copy",
"-c:s",
codec,
"-metadata:s:s:0",
"language=eng",
"-metadata:s:s:1",
"language=spa",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-map",
"2:0",
"-map",
"3:0",
&input.to_string_lossy(),
])
.status()
.expect("run ffmpeg mux bitmap subs");
if status_mux.success() {
return Some(input);
}
}
None
}
fn is_bitmap_subtitle_codec(codec_id: ffi::AVCodecID) -> bool {
matches!(
codec_id,
ffi::AV_CODEC_ID_HDMV_PGS_SUBTITLE
| ffi::AV_CODEC_ID_DVD_SUBTITLE
| ffi::AV_CODEC_ID_DVB_SUBTITLE
| ffi::AV_CODEC_ID_XSUB
)
}
fn count_bitmap_subtitle_streams(path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
let path_cstr = CString::new(path.to_string_lossy().to_string())?;
let ictx = AVFormatContextInput::open(path_cstr.as_c_str())?;
Ok(ictx
.streams()
.iter()
.filter(|st| {
let cp = st.codecpar();
cp.codec_type == ffi::AVMEDIA_TYPE_SUBTITLE && is_bitmap_subtitle_codec(cp.codec_id)
})
.count())
}
fn count_mov_text_streams(path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
let path_cstr = CString::new(path.to_string_lossy().to_string())?;
let ictx = AVFormatContextInput::open(path_cstr.as_c_str())?;
Ok(ictx
.streams()
.iter()
.filter(|st| {
let cp = st.codecpar();
cp.codec_type == ffi::AVMEDIA_TYPE_SUBTITLE && cp.codec_id == ffi::AV_CODEC_ID_MOV_TEXT
})
.count())
}
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_bitmap_subs_to_mov_text_and_direct_play() -> Result<(), Box<dyn std::error::Error>>
{
ensure_ffmpeg_present();
let tmp = TempDir::new()?;
let (input, in_dur_ms, used_text_subs) = gen_problem_input_with_bitmap_subs(&tmp);
let output = tmp.path().join("out_bitmap.mp4");
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");
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);
if !used_text_subs {
assert!(
diff <= 200,
"duration drift too large: in={}ms out={}ms",
in_dur_ms,
out_dur_ms
);
} else {
assert!(
out_dur_ms <= in_dur_ms.saturating_add(120_000),
"duration drift too large (text fallback): in={}ms out={}ms",
in_dur_ms,
out_dur_ms
);
}
Ok(())
}
#[test]
fn cli_ai_ocr_processes_all_bitmap_subtitle_streams() -> Result<(), Box<dyn std::error::Error>> {
ensure_ffmpeg_present();
if env::var("DPN_OCR_AI_E2E").ok().as_deref() != Some("1") {
eprintln!("Skipping AI all-stream OCR test (set DPN_OCR_AI_E2E=1 to enable).");
return Ok(());
}
let tmp = TempDir::new()?;
let Some(input) = gen_multi_stream_bitmap_input(&tmp) else {
eprintln!("No bitmap subtitle encoder available; skipping AI all-stream OCR test.");
return Ok(());
};
let output = tmp.path().join("out_ai_all_streams.mp4");
let input_bitmap_count = count_bitmap_subtitle_streams(&input)?;
assert!(
input_bitmap_count >= 2,
"expected at least two bitmap subtitle streams, got {}",
input_bitmap_count
);
let run = Command::new(assert_cmd::cargo::cargo_bin!("direct_play_nice"))
.env("DPN_OCR_FORCE_CPU", "1")
.env("DPN_OCR_SKIP_CLS", "1")
.arg("--sub-mode")
.arg("force")
.arg("--ocr-engine")
.arg("pp-ocr-v3")
.arg("--skip-codec-check")
.arg(&input)
.arg(&output)
.output()?;
assert!(
run.status.success(),
"AI OCR run failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
let output_sub_count = count_mov_text_streams(&output)?;
assert_eq!(
output_sub_count, input_bitmap_count,
"expected all bitmap subtitle streams to be OCR-converted ({}), got {}",
input_bitmap_count, output_sub_count
);
Ok(())
}
#[test]
fn cli_ai_ocr_gpu_processes_all_bitmap_subtitle_streams() -> Result<(), Box<dyn std::error::Error>>
{
ensure_ffmpeg_present();
if env::var("DPN_OCR_GPU_E2E").ok().as_deref() != Some("1") {
eprintln!("Skipping GPU OCR test (set DPN_OCR_GPU_E2E=1 to enable).");
return Ok(());
}
let tmp = TempDir::new()?;
let Some(input) = gen_multi_stream_bitmap_input(&tmp) else {
eprintln!("No bitmap subtitle encoder available; skipping GPU OCR test.");
return Ok(());
};
let output = tmp.path().join("out_ai_gpu_all_streams.mp4");
let input_bitmap_count = count_bitmap_subtitle_streams(&input)?;
assert!(
input_bitmap_count >= 2,
"expected at least two bitmap subtitle streams, got {}",
input_bitmap_count
);
let run = Command::new(assert_cmd::cargo::cargo_bin!("direct_play_nice"))
.env("DPN_OCR_REQUIRE_GPU", "1")
.env("DPN_OCR_SKIP_CLS", "1")
.arg("--sub-mode")
.arg("force")
.arg("--ocr-engine")
.arg("auto")
.arg("--skip-codec-check")
.arg(&input)
.arg(&output)
.output()?;
assert!(
run.status.success(),
"GPU OCR run failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
let output_sub_count = count_mov_text_streams(&output)?;
assert_eq!(
output_sub_count, input_bitmap_count,
"expected all bitmap subtitle streams to be OCR-converted ({}), got {}",
input_bitmap_count, output_sub_count
);
Ok(())
}