pub mod common;
use std::fs;
use std::net::SocketAddr;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use tempfile::Builder;
use axum::{routing::get, Router};
use axum::http::header;
use axum_server::{Handle, bind};
use ffprobe::ffprobe;
use dash_mpd::{MPD, Period, AdaptationSet, Representation, Initialization, SegmentList, SegmentURL};
use dash_mpd::fetch::DashDownloader;
use anyhow::Result;
use common::setup_logging;
fn check_frame_color(video: &Path, timestamp: &str, expected_rgb: &[u8; 3]) {
use image::GenericImageView;
let out = Builder::new().suffix(".png").tempfile().unwrap();
let ffmpeg = Command::new("ffmpeg")
.env("LANG", "C")
.args(["-y",
"-nostdin",
"-ss", timestamp,
"-i", &video.to_string_lossy(),
"-frames:v", "1",
"-update", "1",
out.path().to_str().unwrap()])
.output()
.expect("spawning ffmpeg");
if !ffmpeg.status.success() {
let stderr = String::from_utf8_lossy(&ffmpeg.stderr);
eprintln!("ffmpeg stderr: {stderr}");
}
assert!(ffmpeg.status.success());
let img = image::ImageReader::open(out.path())
.unwrap().decode().unwrap();
for (_x, _y, rgba) in img.pixels() {
let dr: i32 = rgba[0] as i32 - expected_rgb[0] as i32;
let dg: i32 = rgba[1] as i32 - expected_rgb[1] as i32;
let db: i32 = rgba[2] as i32 - expected_rgb[2] as i32;
assert!(dr*dr + dg*dg + db*db < 20);
}
}
fn as_data_url(video: &Path) -> String {
use base64::prelude::{Engine as _, BASE64_STANDARD};
let bytes = fs::read(video).unwrap();
"data:video/x-matroska;base64,".to_owned() + &BASE64_STANDARD.encode(bytes)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_data_url() -> Result<()> {
setup_logging();
let tmpd = Builder::new().prefix("dash-mpd-ffmpeg").tempdir().unwrap();
let tmpdp = tmpd.path();
let ffmpeg = Command::new("ffmpeg")
.env("LANG", "C")
.current_dir(tmpdp)
.args(["-y",
"-nostdin",
"-f", "lavfi", "-i", "color=c=0xff0000:size=100x100:r=10:duration=5",
"-f", "lavfi", "-i", "color=c=0x00ff00:size=100x100:r=10:duration=5",
"-f", "lavfi", "-i", "color=c=0x0000ff:size=100x100:r=10:duration=5",
"-c:v", "libx264",
"-filter_complex", "[0:v:0][1:v:0][2:v:0]concat=n=3:v=1:a=0[outv]",
"-map", "[outv]", "concat.mp4"])
.output()
.expect("spawning ffmpeg");
if !ffmpeg.status.success() {
let stderr = String::from_utf8_lossy(&ffmpeg.stderr);
eprintln!("ffmpeg stderr: {stderr}");
}
assert!(ffmpeg.status.success());
let ffmpeg = Command::new("ffmpeg")
.env("LANG", "C")
.current_dir(tmpdp)
.args(["-y",
"-nostdin",
"-i", "concat.mp4",
"-single_file", "0",
"-init_seg_name", "init.mp4",
"-media_seg_name", "fragment-$Number$.mp4",
"-seg_duration", "5", "-frag_duration", "5",
"-f", "dash", "manifest.mpd"])
.output()
.expect("spawning ffmpeg");
if !ffmpeg.status.success() {
let stderr = String::from_utf8_lossy(&ffmpeg.stderr);
eprintln!("ffmpeg stderr: {stderr}");
}
assert!(ffmpeg.status.success());
let init_bytes = tmpdp.join("init.mp4");
let frag1_bytes = tmpdp.join("fragment-1.mp4");
let frag2_bytes = tmpdp.join("fragment-2.mp4");
let initialization = Initialization {
sourceURL: Some(as_data_url(&init_bytes)),
..Default::default()
};
let seg1 = SegmentURL {
media: Some(as_data_url(&frag1_bytes)),
..Default::default()
};
let seg2 = SegmentURL {
media: Some(as_data_url(&frag2_bytes)),
..Default::default()
};
let segment_list = SegmentList {
Initialization: Some(initialization),
segment_urls: vec!(seg1, seg2),
..Default::default()
};
let rep1 = Representation {
id: Some("1".to_string()),
mimeType: Some("video/mp4".to_string()),
width: Some(100),
height: Some(100),
SegmentList: Some(segment_list),
..Default::default()
};
let adap = AdaptationSet {
id: Some("1".to_string()),
contentType: Some("video".to_string()),
representations: vec!(rep1),
..Default::default()
};
let period = Period {
id: Some("p1".to_string()),
duration: Some(Duration::new(15, 0)),
adaptations: vec!(adap),
..Default::default()
};
let mpd = MPD {
xmlns: Some("urn:mpeg:dash:schema:mpd:2011".to_string()),
mpdtype: Some("static".to_string()),
periods: vec!(period),
..Default::default()
};
let xml = mpd.to_string();
let app = Router::new()
.route("/mpd", get(|| async { ([(header::CONTENT_TYPE, "application/dash+xml")], xml) }));
let server_handle: Handle<SocketAddr> = Handle::new();
let backend_handle = server_handle.clone();
let backend = async move {
bind("127.0.0.1:6666".parse().unwrap())
.handle(backend_handle)
.serve(app.into_make_service()).await
.unwrap()
};
tokio::spawn(backend);
tokio::time::sleep(Duration::from_millis(500)).await;
let out = tmpdp.join("data-url.mp4");
DashDownloader::new("http://localhost:6666/mpd")
.intermediate_quality()
.verbosity(2)
.download_to(&out).await
.unwrap();
let meta = ffprobe(&out).unwrap();
assert_eq!(meta.streams.len(), 1);
let stream = &meta.streams[0];
assert_eq!(stream.codec_type, Some(String::from("video")));
assert_eq!(stream.codec_name, Some(String::from("h264")));
assert_eq!(stream.width, Some(100));
check_frame_color(&out.clone(), "00:00:03", &[255, 0, 0]);
check_frame_color(&out.clone(), "00:00:08", &[0, 255, 0]);
check_frame_color(&out.clone(), "00:00:13", &[0, 0, 255]);
server_handle.shutdown();
Ok(())
}