use std::time::Duration;
#[cfg(feature = "libvpx")]
use hisui::decoder_libvpx::LibvpxDecoder;
use hisui::{
decoder::{VideoDecoder, VideoDecoderOptions},
decoder_opus::OpusDecoder,
media::MediaStreamId,
metadata::SourceId,
processor::{MediaProcessor, MediaProcessorInput, MediaProcessorOutput},
reader_mp4::{Mp4AudioReader, Mp4VideoReader},
stats::{Mp4AudioReaderStats, Mp4VideoReaderStats},
types::{CodecName, EngineName},
video::VideoFrame,
};
use orfail::OrFail;
#[test]
#[cfg(feature = "libvpx")]
fn empty_source() -> noargs::Result<()> {
let out_file = tempfile::NamedTempFile::new().or_fail()?;
let hisui_bin = env!("CARGO_BIN_EXE_hisui");
let output = std::process::Command::new(hisui_bin)
.args([
"compose",
"--no-progress-bar",
"--output-file",
&out_file.path().display().to_string(),
"testdata/e2e/empty_source/",
])
.output()
.or_fail()?;
if !output.status.success() {
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
return Err("hisui command failed".into());
}
assert!(out_file.path().exists());
assert_eq!(
Mp4AudioReader::new(SourceId::new("dummy"), out_file.path(), audio_stats())
.or_fail()?
.count(),
0
);
assert_eq!(
Mp4VideoReader::new(SourceId::new("dummy"), out_file.path(), video_stats())
.or_fail()?
.count(),
0
);
Ok(())
}
fn test_simple_single_source_common(
test_data_dir: &str,
expected_codec: CodecName,
expected_engine: Option<EngineName>,
) -> noargs::Result<()> {
let out_file = tempfile::NamedTempFile::new().or_fail()?;
let stats_file = tempfile::NamedTempFile::new().or_fail()?;
let hisui_bin = env!("CARGO_BIN_EXE_hisui");
let output = std::process::Command::new(hisui_bin)
.args([
"compose",
"--no-progress-bar",
"--layout-file",
&format!("{test_data_dir}/layout.jsonc"),
"--output-file",
&out_file.path().display().to_string(),
"--stats-file",
&stats_file.path().display().to_string(),
test_data_dir,
])
.output()
.or_fail()?;
if !output.status.success() {
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
return Err("hisui command failed".into());
}
if let Some(expected_engine) = expected_engine {
check_engine_in_stats(&stats_file, expected_engine)?;
}
assert!(out_file.path().exists());
let mut audio_reader =
Mp4AudioReader::new(SourceId::new("dummy"), out_file.path(), audio_stats()).or_fail()?;
let mut video_reader =
Mp4VideoReader::new(SourceId::new("dummy"), out_file.path(), video_stats()).or_fail()?;
let audio_samples = audio_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let video_samples = video_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let audio_stats = audio_reader.stats();
assert_eq!(audio_stats.codec, Some(CodecName::Opus));
assert_eq!(audio_stats.total_sample_count.get(), 51);
assert_eq!(
audio_stats.total_track_duration.get(),
Duration::from_millis(1020)
);
let video_stats = video_reader.stats();
assert_eq!(video_stats.codec.get(), Some(expected_codec));
assert_eq!(
video_stats
.resolutions
.get()
.into_iter()
.map(|r| (r.width, r.height))
.collect::<Vec<_>>(),
[(320, 240)]
);
assert_eq!(video_stats.total_sample_count.get(), 25);
assert_eq!(
video_stats.total_track_duration.get(),
Duration::from_secs(1)
);
let mut decoder = OpusDecoder::new().or_fail()?;
for data in audio_samples {
let decoded = decoder.decode(&data).or_fail()?;
assert!(!decoded.data.iter().all(|v| *v == 0));
}
const DECODER_INPUT_STREAM_ID: MediaStreamId = MediaStreamId::new(0);
const DECODER_OUTPUT_STREAM_ID: MediaStreamId = MediaStreamId::new(1);
let check_decoded_frame = |decoded: &VideoFrame| -> orfail::Result<()> {
let (y_plane, u_plane, v_plane) = decoded.as_yuv_planes().or_fail()?;
y_plane
.iter()
.for_each(|x| assert!(matches!(x, 80..=83), "y={x}"));
u_plane
.iter()
.for_each(|x| assert!(matches!(*x, 90 | 91), "u={x}"));
v_plane
.iter()
.for_each(|x| assert!(matches!(x, 240 | 241), "v={x}"));
Ok(())
};
let mut decoder = VideoDecoder::new(
DECODER_INPUT_STREAM_ID,
DECODER_OUTPUT_STREAM_ID,
VideoDecoderOptions::default(),
);
for frame in video_samples {
decoder
.process_input(MediaProcessorInput::video_frame(
DECODER_INPUT_STREAM_ID,
frame,
))
.or_fail()?;
while let MediaProcessorOutput::Processed { sample, .. } =
decoder.process_output().or_fail()?
{
let decoded = sample.expect_video_frame().or_fail()?;
check_decoded_frame(&decoded).or_fail()?;
}
}
decoder
.process_input(MediaProcessorInput::eos(DECODER_INPUT_STREAM_ID))
.or_fail()?;
while let MediaProcessorOutput::Processed { sample, .. } = decoder.process_output().or_fail()? {
let decoded = sample.expect_video_frame().or_fail()?;
check_decoded_frame(&decoded).or_fail()?;
}
Ok(())
}
fn check_engine_in_stats(
stats_file: &tempfile::NamedTempFile,
expected_engine: EngineName,
) -> noargs::Result<()> {
let stats_json = std::fs::read_to_string(stats_file.path())
.map_err(|e| format!("Failed to read stats file: {e}"))?;
let stats = nojson::RawJson::parse(&stats_json)
.map_err(|e| format!("Failed to parse stats JSON: {e}"))?;
let processors = stats
.value()
.to_member("processors")?
.required()?
.to_array()?;
let mut found_decoder = false;
let mut found_encoder = false;
for processor in processors {
let processor_type = processor
.to_member("type")?
.required()?
.to_unquoted_string_str()?;
match processor_type.as_ref() {
"video_decoder" => {
if let Some(engine_value) = processor.to_member("engine")?.get() {
if let Ok(engine_str) = engine_value.to_unquoted_string_str() {
assert_eq!(
engine_str.as_ref(),
expected_engine.as_str(),
"video decoder engine mismatch"
);
found_decoder = true;
}
}
}
"video_encoder" => {
if let Some(engine_value) = processor.to_member("engine")?.get() {
let engine_str = engine_value
.to_unquoted_string_str()
.map_err(|e| format!("engine is not a string: {e}"))?;
assert_eq!(
engine_str.as_ref(),
expected_engine.as_str(),
"video encoder engine mismatch"
);
found_encoder = true;
}
}
_ => {}
}
}
assert!(found_decoder, "video decoder not found in stats");
assert!(found_encoder, "video encoder not found in stats");
Ok(())
}
#[test]
#[cfg(feature = "libvpx")]
fn simple_single_source_vp9() -> noargs::Result<()> {
test_simple_single_source_common(
"testdata/e2e/simple_single_source_vp9/",
CodecName::Vp9,
Some(EngineName::Libvpx),
)
}
#[test]
#[cfg(feature = "nvcodec")]
fn simple_single_source_vp9_nvcodec() -> noargs::Result<()> {
test_simple_single_source_common(
"testdata/e2e/simple_single_source_vp9_nvcodec/",
CodecName::H264,
Some(EngineName::Nvcodec),
)
}
#[test]
#[cfg(any(feature = "nvcodec", target_os = "macos"))]
fn simple_single_source_h265() -> noargs::Result<()> {
test_simple_single_source_common(
"testdata/e2e/simple_single_source_h265/",
CodecName::H265,
None,
)
}
#[test]
#[cfg(any(feature = "nvcodec", target_os = "macos"))]
fn simple_single_source_h264() -> noargs::Result<()> {
test_simple_single_source_common(
"testdata/e2e/simple_single_source_h264/",
CodecName::H264,
None,
)
}
#[test]
fn simple_single_source_av1() -> noargs::Result<()> {
test_simple_single_source_common(
"testdata/e2e/simple_single_source_av1/",
CodecName::Av1,
None,
)
}
#[test]
#[cfg(feature = "libvpx")]
fn odd_resolution_single_source() -> noargs::Result<()> {
let out_file = tempfile::NamedTempFile::new().or_fail()?;
let hisui_bin = env!("CARGO_BIN_EXE_hisui");
let output = std::process::Command::new(hisui_bin)
.args([
"compose",
"--no-progress-bar",
"--output-file",
&out_file.path().display().to_string(),
"testdata/e2e/odd_resolution_single_source/",
])
.output()
.or_fail()?;
if !output.status.success() {
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
return Err("hisui command failed".into());
}
assert!(out_file.path().exists());
let mut audio_reader =
Mp4AudioReader::new(SourceId::new("dummy"), out_file.path(), audio_stats()).or_fail()?;
let mut video_reader =
Mp4VideoReader::new(SourceId::new("dummy"), out_file.path(), video_stats()).or_fail()?;
let audio_samples = audio_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let video_samples = video_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let audio_stats = audio_reader.stats();
assert_eq!(audio_stats.codec, Some(CodecName::Opus));
assert_eq!(audio_stats.total_sample_count.get(), 51);
assert_eq!(
audio_stats.total_track_duration.get(),
Duration::from_millis(1020)
);
let video_stats = video_reader.stats();
assert_eq!(video_stats.codec.get(), Some(CodecName::Vp9));
assert_eq!(
video_stats
.resolutions
.get()
.into_iter()
.map(|r| (r.width, r.height))
.collect::<Vec<_>>(),
[(320, 240)]
);
assert_eq!(video_stats.total_sample_count.get(), 25);
assert_eq!(
video_stats.total_track_duration.get(),
Duration::from_secs(1)
);
let mut decoder = OpusDecoder::new().or_fail()?;
for data in audio_samples {
let decoded = decoder.decode(&data).or_fail()?;
assert!(!decoded.data.iter().all(|v| *v == 0));
}
let check_decoded_frames = |decoder: &mut LibvpxDecoder| -> orfail::Result<()> {
while let Some(decoded) = decoder.next_decoded_frame() {
let (y_plane, u_plane, v_plane) = decoded.as_yuv_planes().or_fail()?;
y_plane.iter().enumerate().for_each(|(i, &x)| {
let col = i % 320;
let row = i / 320;
if col >= 318 || row >= 238 {
assert!(matches!(x, 0..=3), "Expected black Y value, got y={x}",);
} else {
assert!(matches!(x, 79..=83), "Expected red Y value, got y={x}",);
}
});
u_plane.iter().enumerate().for_each(|(i, &x)| {
let col = (i % 160) * 2;
let row = (i / 160) * 2;
if col >= 318 || row >= 238 {
assert!(matches!(x, 122..=131), "Expected black U value, got u={x}");
} else {
assert!(matches!(x, 86..=95), "Expected red U value, got u={x}");
}
});
v_plane.iter().enumerate().for_each(|(i, &x)| {
let col = (i % 160) * 2;
let row = (i / 160) * 2;
if col >= 318 || row >= 238 {
assert!(matches!(x, 122..=131), "Expected black V value, got v={x}");
} else {
assert!(matches!(x, 235..=244), "Expected red V value, got v={x}");
}
});
}
Ok(())
};
let mut decoder = LibvpxDecoder::new_vp9().or_fail()?;
for frame in video_samples {
decoder.decode(&frame).or_fail()?;
check_decoded_frames(&mut decoder).or_fail()?;
}
decoder.finish().or_fail()?;
check_decoded_frames(&mut decoder).or_fail()?;
Ok(())
}
#[test]
#[cfg(feature = "libvpx")]
fn simple_multi_sources() -> noargs::Result<()> {
let out_file = tempfile::NamedTempFile::new().or_fail()?;
let hisui_bin = env!("CARGO_BIN_EXE_hisui");
let output = std::process::Command::new(hisui_bin)
.args([
"compose",
"--no-progress-bar",
"--output-file",
&out_file.path().display().to_string(),
"testdata/e2e/simple_multi_sources/",
])
.output()
.or_fail()?;
if !output.status.success() {
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
return Err("hisui command failed".into());
}
assert!(out_file.path().exists());
let mut audio_reader =
Mp4AudioReader::new(SourceId::new("dummy"), out_file.path(), audio_stats()).or_fail()?;
let mut video_reader =
Mp4VideoReader::new(SourceId::new("dummy"), out_file.path(), video_stats()).or_fail()?;
let _audio_samples = audio_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let _video_samples = video_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let audio_stats = audio_reader.stats();
assert_eq!(audio_stats.codec, Some(CodecName::Opus));
assert_eq!(audio_stats.total_sample_count.get(), 51);
assert_eq!(
audio_stats.total_track_duration.get(),
Duration::from_millis(1020)
);
let video_stats = video_reader.stats();
assert_eq!(video_stats.codec.get(), Some(CodecName::Vp9));
assert_eq!(
video_stats
.resolutions
.get()
.into_iter()
.map(|r| (r.width, r.height))
.collect::<Vec<_>>(),
[(320 * 3 + 4, 240 * 1)]
);
assert_eq!(video_stats.total_sample_count.get(), 25);
assert_eq!(
video_stats.total_track_duration.get(),
Duration::from_secs(1)
);
Ok(())
}
#[test]
#[cfg(feature = "libvpx")]
fn simple_split_archive() -> noargs::Result<()> {
let out_file = tempfile::NamedTempFile::new().or_fail()?;
let hisui_bin = env!("CARGO_BIN_EXE_hisui");
let output = std::process::Command::new(hisui_bin)
.args([
"compose",
"--no-progress-bar",
"--layout-file",
"testdata/e2e/simple_split_archive/layout.jsonc",
"--output-file",
&out_file.path().display().to_string(),
"testdata/e2e/simple_split_archive/",
])
.output()
.or_fail()?;
if !output.status.success() {
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
return Err("hisui command failed".into());
}
assert!(out_file.path().exists());
let mut audio_reader =
Mp4AudioReader::new(SourceId::new("dummy"), out_file.path(), audio_stats()).or_fail()?;
let mut video_reader =
Mp4VideoReader::new(SourceId::new("dummy"), out_file.path(), video_stats()).or_fail()?;
let audio_samples = audio_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let video_samples = video_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let audio_stats = audio_reader.stats();
assert_eq!(audio_stats.codec, Some(CodecName::Opus));
assert_eq!(audio_stats.total_sample_count.get(), 153); assert_eq!(
audio_stats.total_track_duration.get(),
Duration::from_millis(3060) );
let video_stats = video_reader.stats();
assert_eq!(video_stats.codec.get(), Some(CodecName::Vp9));
assert_eq!(
video_stats
.resolutions
.get()
.into_iter()
.map(|r| (r.width, r.height))
.collect::<Vec<_>>(),
[(16, 16)] );
assert_eq!(video_stats.total_sample_count.get(), 75); assert_eq!(
video_stats.total_track_duration.get(),
Duration::from_secs(3)
);
let mut decoder = OpusDecoder::new().or_fail()?;
for data in audio_samples {
let decoded = decoder.decode(&data).or_fail()?;
assert!(!decoded.data.iter().all(|v| *v == 0));
}
let check_decoded_frames =
|decoder: &mut LibvpxDecoder, frame_index: &mut usize| -> orfail::Result<()> {
while let Some(decoded) = decoder.next_decoded_frame() {
let (y_plane, _u_plane, v_plane) = decoded.as_yuv_planes().or_fail()?;
if *frame_index < 25 {
(y_plane.iter().zip(v_plane.iter())).for_each(|(&y, &v)| {
assert!(
matches!(y, 80..=82) && matches!(v, 240),
"Expected red Y / V value, got y={y} / v={v} at frame {}",
*frame_index
);
});
} else if *frame_index < 50 {
(y_plane.iter().zip(v_plane.iter())).for_each(|(&y, &v)| {
assert!(
matches!(y, 80..=82) && matches!(v, 81),
"Expected green Y / V value, got y={y} / v={v} at frame {}",
*frame_index
);
});
} else if *frame_index < 75 {
y_plane.iter().for_each(|&y| {
assert!(
matches!(y, 40..=42),
"Expected blue Y value, got y={y} at frame {}",
*frame_index
);
});
}
*frame_index += 1;
}
Ok(())
};
let mut decoder = LibvpxDecoder::new_vp9().or_fail()?;
let mut frame_index = 0;
for frame in video_samples {
decoder.decode(&frame).or_fail()?;
check_decoded_frames(&mut decoder, &mut frame_index).or_fail()?;
}
decoder.finish().or_fail()?;
check_decoded_frames(&mut decoder, &mut frame_index).or_fail()?;
assert_eq!(frame_index, 75);
Ok(())
}
#[test]
#[cfg(feature = "libvpx")]
fn multi_sources_single_column() -> noargs::Result<()> {
let out_file = tempfile::NamedTempFile::new().or_fail()?;
let hisui_bin = env!("CARGO_BIN_EXE_hisui");
let output = std::process::Command::new(hisui_bin)
.args([
"compose",
"--no-progress-bar",
"--layout-file",
"testdata/e2e/multi_sources_single_column/layout.json",
"--output-file",
&out_file.path().display().to_string(),
"testdata/e2e/multi_sources_single_column/",
])
.output()
.or_fail()?;
if !output.status.success() {
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
return Err("hisui command failed".into());
}
assert!(out_file.path().exists());
let mut audio_reader =
Mp4AudioReader::new(SourceId::new("dummy"), out_file.path(), audio_stats()).or_fail()?;
let mut video_reader =
Mp4VideoReader::new(SourceId::new("dummy"), out_file.path(), video_stats()).or_fail()?;
let audio_samples = audio_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let video_samples = video_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let audio_stats = audio_reader.stats();
assert_eq!(audio_stats.codec, Some(CodecName::Opus));
assert_eq!(audio_stats.total_sample_count.get(), 51);
assert_eq!(
audio_stats.total_track_duration.get(),
Duration::from_millis(1020)
);
let video_stats = video_reader.stats();
assert_eq!(video_stats.codec.get(), Some(CodecName::Vp9));
assert_eq!(
video_stats
.resolutions
.get()
.into_iter()
.map(|r| (r.width, r.height))
.collect::<Vec<_>>(),
[(16, 52)]
);
assert_eq!(video_stats.total_sample_count.get(), 25);
assert_eq!(
video_stats.total_track_duration.get(),
Duration::from_secs(1)
);
let mut decoder = OpusDecoder::new().or_fail()?;
for data in audio_samples {
let decoded = decoder.decode(&data).or_fail()?;
assert!(!decoded.data.iter().all(|v| *v == 0));
}
let check_decoded_frames = |decoder: &mut LibvpxDecoder| -> orfail::Result<()> {
while let Some(decoded) = decoder.next_decoded_frame() {
let (y_plane, _u_plane, _v_plane) = decoded.as_yuv_planes().or_fail()?;
let width = 16;
for (i, y) in y_plane.iter().copied().enumerate() {
if i / width < 16 {
assert!(matches!(y, 40..=43), "y={y}");
} else if i / width < 16 + 2 {
assert!(matches!(y, 0..=2), "y={y}");
} else if i / width < 16 + 2 + 16 {
assert!(matches!(y, 186 | 187 | 188 | 189), "y={y}");
} else if i / width < 16 + 2 + 16 + 2 {
assert!(matches!(y, 0..=2), "y={y}");
} else if i / width < 16 + 2 + 16 + 2 + 16 {
assert!(matches!(y, 80..=82), "y={y}");
} else {
unreachable!()
}
}
}
Ok(())
};
let mut decoder = LibvpxDecoder::new_vp9().or_fail()?;
for frame in video_samples {
decoder.decode(&frame).or_fail()?;
check_decoded_frames(&mut decoder).or_fail()?;
}
decoder.finish().or_fail()?;
check_decoded_frames(&mut decoder).or_fail()?;
Ok(())
}
#[test]
#[cfg(feature = "libvpx")]
fn two_regions() -> noargs::Result<()> {
let out_file = tempfile::NamedTempFile::new().or_fail()?;
let hisui_bin = env!("CARGO_BIN_EXE_hisui");
let output = std::process::Command::new(hisui_bin)
.args([
"compose",
"--no-progress-bar",
"--layout-file",
"testdata/e2e/two_regions/layout.json",
"--output-file",
&out_file.path().display().to_string(),
"testdata/e2e/two_regions/",
])
.output()
.or_fail()?;
if !output.status.success() {
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
return Err("hisui command failed".into());
}
assert!(out_file.path().exists());
let mut video_reader =
Mp4VideoReader::new(SourceId::new("dummy"), out_file.path(), video_stats()).or_fail()?;
assert_eq!(
Mp4AudioReader::new(SourceId::new("dummy"), out_file.path(), audio_stats())
.or_fail()?
.count(),
0
);
let video_samples = video_reader.by_ref().collect::<orfail::Result<Vec<_>>>()?;
let video_stats = video_reader.stats();
assert_eq!(video_stats.codec.get(), Some(CodecName::Vp9));
assert_eq!(
video_stats
.resolutions
.get()
.into_iter()
.map(|r| (r.width, r.height))
.collect::<Vec<_>>(),
[(16, 34)]
);
assert_eq!(video_stats.total_sample_count.get(), 25);
assert_eq!(
video_stats.total_track_duration.get(),
Duration::from_secs(1)
);
let check_decoded_frames = |decoder: &mut LibvpxDecoder| -> orfail::Result<()> {
while let Some(decoded) = decoder.next_decoded_frame() {
let (y_plane, _u_plane, _v_plane) = decoded.as_yuv_planes().or_fail()?;
let width = 16;
for (i, y) in y_plane.iter().copied().enumerate() {
if i / width < 8 {
assert!(matches!(y, 40..=44), "y={y}");
} else if i / width < 8 + 2 {
assert!(matches!(y, 0..=2), "y={y}");
} else if i / width < 8 + 2 + 16 {
assert!(matches!(y, 79..=83), "y={y}");
} else if i / width < 8 + 2 + 16 + 2 {
assert!(matches!(y, 0..=2), "y={y}");
} else if i / width < 8 + 2 + 16 + 2 + 6 {
assert!(matches!(y, 186..=188), "y={y}");
} else {
unreachable!()
}
}
}
Ok(())
};
let mut decoder = LibvpxDecoder::new_vp9().or_fail()?;
for frame in video_samples {
decoder.decode(&frame).or_fail()?;
check_decoded_frames(&mut decoder).or_fail()?;
}
decoder.finish().or_fail()?;
check_decoded_frames(&mut decoder).or_fail()?;
Ok(())
}
fn audio_stats() -> Mp4AudioReaderStats {
Mp4AudioReaderStats {
codec: Some(CodecName::Opus),
..Default::default()
}
}
fn video_stats() -> Mp4VideoReaderStats {
Mp4VideoReaderStats::default()
}