use std::collections::HashMap;
use super::*;
fn ladder() -> HlsLadder {
HlsLadder::new("input.mp4", "out")
.rendition(1280, 720, "2800k")
.rendition(640, 360, "800k")
}
#[test]
fn split_desc_single_rendition() {
let renditions = vec![Rendition::new(640, 360, "800k")];
assert_eq!(
build_split_desc(&renditions),
"[0:v]split=1[s0];[s0]scale=640:360,setsar=1,format=yuv420p[v0]"
);
}
#[test]
fn split_desc_three_renditions() {
let renditions = vec![
Rendition::new(1920, 1080, "5000k"),
Rendition::new(1280, 720, "2800k"),
Rendition::new(640, 360, "800k"),
];
assert_eq!(
build_split_desc(&renditions),
"[0:v]split=3[s0][s1][s2];\
[s0]scale=1920:1080,setsar=1,format=yuv420p[v0];\
[s1]scale=1280:720,setsar=1,format=yuv420p[v1];\
[s2]scale=640:360,setsar=1,format=yuv420p[v2]"
);
}
#[test]
fn split_desc_every_branch_has_yuv420p() {
let renditions = vec![
Rendition::new(1280, 720, "2800k"),
Rendition::new(640, 360, "800k"),
];
let desc = build_split_desc(&renditions);
assert_eq!(desc.matches("format=yuv420p").count(), 2);
}
#[test]
fn split_desc_qsv_pixel_format_is_nv12() {
let renditions = vec![Rendition::new(640, 360, "800k")];
assert_eq!(
build_split_desc_with_format(&renditions, "nv12"),
"[0:v]split=1[s0];[s0]scale=640:360,setsar=1,format=nv12[v0]"
);
}
#[test]
fn gop_exact_integer_rates() {
assert_eq!(compute_gop_frames(30, 1, 6_000_000), 180);
assert_eq!(compute_gop_frames(25, 1, 6_000_000), 150);
}
#[test]
fn gop_ntsc_rational_not_broken_by_round() {
assert_eq!(compute_gop_frames(30000, 1001, 6_000_000), 180);
assert_eq!(compute_gop_frames(24000, 1001, 1_000_000), 24);
}
#[test]
fn gop_uses_ceil_not_round() {
assert_eq!(compute_gop_frames(7, 3, 1_000_000), 3);
}
#[test]
fn gop_degenerate_inputs_clamp_to_one() {
assert_eq!(compute_gop_frames(0, 1, 6_000_000), 1);
assert_eq!(compute_gop_frames(30, 0, 6_000_000), 1);
assert_eq!(compute_gop_frames(30, 1, 0), 1);
}
#[test]
fn parse_bitrate_suffixes() {
assert_eq!(parse_bitrate_bps("800000").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("800k").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("800K").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("5M").unwrap(), 5_000_000);
assert_eq!(parse_bitrate_bps("1.5M").unwrap(), 1_500_000);
}
#[test]
fn parse_bitrate_rejects_garbage() {
assert!(parse_bitrate_bps("").is_err());
assert!(parse_bitrate_bps("abc").is_err());
assert!(parse_bitrate_bps("0").is_err());
assert!(parse_bitrate_bps("-5M").is_err());
}
#[test]
fn bandwidth_folds_audio_and_overhead() {
assert_eq!(compute_bandwidth(5_000_000, Some(128_000)), 5_640_800);
assert_eq!(compute_bandwidth(5_000_000, None), 5_500_000);
}
#[test]
fn master_variants_carry_resolution_and_relative_uri() {
let variants = ladder().build_master_variants(true).unwrap();
assert_eq!(variants.len(), 2);
let v720 = &variants[0];
assert_eq!((v720.width, v720.height), (1280, 720));
assert_eq!(v720.uri, "720p/index.m3u8");
assert_eq!(v720.bandwidth, 3_220_800);
assert!(v720.codecs.is_none());
}
#[test]
fn master_variants_omit_audio_when_absent() {
let variants = ladder().build_master_variants(false).unwrap();
assert_eq!(variants[1].bandwidth, 880_000);
}
#[test]
fn master_variants_custom_name_becomes_uri() {
let variants = HlsLadder::new("input.mp4", "out")
.rendition_named(Rendition::new(640, 360, "800k").with_name("low"))
.build_master_variants(false)
.unwrap();
assert_eq!(variants[0].uri, "low/index.m3u8");
}
#[test]
fn validate_accepts_default_ladder() {
assert!(ladder().validate().is_ok());
}
#[test]
fn validate_rejects_empty_ladder() {
let l = HlsLadder::new("input.mp4", "out");
assert!(matches!(l.validate(), Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn validate_rejects_odd_dimensions() {
let l = HlsLadder::new("input.mp4", "out").rendition(641, 360, "800k");
assert!(l.validate().is_err());
}
#[test]
fn validate_rejects_non_multiple_segment_gop() {
let l = ladder().segment_duration(6.0).gop_seconds(4.0);
assert!(l.validate().is_err());
assert!(ladder()
.segment_duration(6.0)
.gop_seconds(2.0)
.validate()
.is_ok());
}
#[test]
fn validate_rejects_bad_names() {
for bad in ["../escape", "a/b", "..", "/abs"] {
let l = HlsLadder::new("input.mp4", "out")
.rendition_named(Rendition::new(640, 360, "800k").with_name(bad));
assert!(l.validate().is_err(), "name '{bad}' should be rejected");
}
}
#[test]
fn validate_rejects_duplicate_names() {
let l = HlsLadder::new("input.mp4", "out")
.rendition(640, 360, "800k")
.rendition(480, 360, "600k");
assert!(l.validate().is_err());
}
#[test]
fn validate_rejects_nonpositive_fps_override() {
assert!(ladder().fps(0, 1).validate().is_err());
assert!(ladder().fps(30, 0).validate().is_err());
assert!(ladder().fps(30, 1).validate().is_ok());
}
#[test]
fn resolve_fps_uses_override() {
assert_eq!(
ladder().fps(30000, 1001).resolve_fps().unwrap(),
(30000, 1001)
);
}
#[test]
fn compute_bandwidth_saturates_on_overflow() {
assert_eq!(compute_bandwidth(u64::MAX, Some(128_000)), u64::MAX);
}
#[test]
fn path_segment_rejects_traversal_and_control_chars() {
assert!(validate_path_segment("../../etc", "x").is_err());
assert!(validate_path_segment("a\nBANDWIDTH=9", "x").is_err());
assert!(validate_path_segment("a/b", "x").is_err());
assert!(validate_path_segment("#low", "x").is_err()); assert!(validate_path_segment("master.m3u8", "x").is_ok());
assert!(validate_path_segment("720p", "x").is_ok());
}
#[test]
fn validate_rejects_codecs_injection_and_master_collision() {
assert!(ladder().codecs("avc1.640028\"\n#EXT").validate().is_err());
assert!(ladder().master("720p").validate().is_err());
assert!(ladder().validate().is_ok());
}
fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn segment_type_defaults_to_mpegts() {
assert_eq!(HlsSegmentType::default(), HlsSegmentType::MpegTs);
assert_eq!(ladder().segment_type, HlsSegmentType::MpegTs);
}
#[test]
fn mpegts_rendition_output_is_frozen() {
let l = ladder();
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(output.url(), Some("out/720p/index.m3u8"));
assert_eq!(output.format.as_deref(), Some("hls"));
assert_eq!(output.video_codec.as_deref(), Some("libx264"));
assert_eq!(output.audio_codec.as_deref(), Some("aac"));
assert_eq!(output.pix_fmt.as_deref(), Some("yuv420p"));
let maps: Vec<(&str, bool)> = output
.stream_map_specs
.iter()
.map(|spec| (spec.linklabel.as_str(), spec.copy))
.collect();
assert_eq!(maps, vec![("[v0]", false), ("0:a:0?", false)]);
assert_eq!(
output.format_opts,
Some(opts(&[
("hls_time", "6"),
("hls_playlist_type", "vod"),
("hls_segment_filename", "out/720p/seg_%05d.ts"),
]))
);
assert_eq!(
output.video_codec_opts,
Some(opts(&[
("b", "2800k"),
("g", "180"),
("keyint_min", "180"),
("sc_threshold", "0"),
("maxrate", "2800k"),
("bufsize", "5600000"),
("x264-params", "scenecut=0:open-gop=0"),
]))
);
assert_eq!(output.audio_codec_opts, Some(opts(&[("b", "128k")])));
assert_eq!(
output.forced_kf_spec,
crate::core::context::output::ForcedKeyframeSpec::None
);
assert_eq!(output.video_codec_tag, None);
}
#[test]
fn fmp4_rendition_output_wires_fmp4_options() {
let l = ladder().segment_type(HlsSegmentType::Fmp4);
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(
output.format_opts,
Some(opts(&[
("hls_time", "6"),
("hls_playlist_type", "vod"),
("hls_segment_filename", "out/720p/seg_%05d.m4s"),
("hls_segment_type", "fmp4"),
("hls_fmp4_init_filename", "init.mp4"),
]))
);
assert_eq!(output.video_codec.as_deref(), Some("libx264"));
assert!(output
.video_codec_opts
.as_ref()
.is_some_and(|codec_opts| codec_opts["g"] == "180"));
}
#[test]
fn video_codec_tag_wires_through_rendition_output() {
let l = ladder().video_codec_tag("hvc1");
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(output.video_codec_tag.as_deref(), Some("hvc1"));
}
#[test]
fn master_version_follows_segment_type() {
assert_eq!(HlsSegmentType::MpegTs.master_playlist_version(), 3);
assert_eq!(HlsSegmentType::Fmp4.master_playlist_version(), 7);
}
#[test]
fn mpegts_master_text_is_frozen() {
let l = ladder();
let variants = l.build_master_variants(true).unwrap();
let text = generate_master_playlist(&variants, l.segment_type.master_playlist_version());
assert_eq!(
text,
"#EXTM3U\n\
#EXT-X-VERSION:3\n\
#EXT-X-STREAM-INF:BANDWIDTH=1020800,RESOLUTION=640x360\n\
360p/index.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=3220800,RESOLUTION=1280x720\n\
720p/index.m3u8\n"
);
}
#[test]
fn fmp4_master_text_declares_version_7() {
let l = ladder().segment_type(HlsSegmentType::Fmp4);
let variants = l.build_master_variants(true).unwrap();
let text = generate_master_playlist(&variants, l.segment_type.master_playlist_version());
assert!(text.starts_with("#EXTM3U\n#EXT-X-VERSION:7\n"));
}
#[cfg(windows)]
#[test]
fn path_to_utf8_normalizes_separators_for_ffmpeg() {
let p = std::path::Path::new(r"out\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), "out/360p/index.m3u8");
let p = std::path::Path::new(r"C:\hls\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), "C:/hls/360p/index.m3u8");
let p = std::path::Path::new(r"\\server\share\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), "//server/share/360p/index.m3u8");
let p = std::path::Path::new(r"\\?\C:\hls\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), r"\\?\C:\hls\360p\index.m3u8");
}
#[test]
fn video_codec_literal_auto_is_not_auto_mode() {
let l = ladder().video_codec("auto");
assert!(matches!(
l.selection,
HlsVideoCodecSelection::Explicit(ref name) if name == "auto"
));
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(output.video_codec.as_deref(), Some("auto"));
assert_eq!(
output.forced_kf_spec,
crate::core::context::output::ForcedKeyframeSpec::None
);
}
#[test]
fn video_codec_auto_is_opt_in_selection() {
assert!(matches!(
ladder().video_codec_auto().selection,
HlsVideoCodecSelection::Auto
));
}
#[test]
fn fallback_qsv_rendition_uses_nv12_periodic_force_and_no_x264_opts() {
let l = ladder();
let encoder = encoder::ResolvedHlsEncoder::fallback(encoder::FallbackKind::Qsv);
let output = l
.rendition_output_with(0, &l.renditions[0], "180", "6", &encoder)
.unwrap();
assert_eq!(output.video_codec.as_deref(), Some("h264_qsv"));
assert_eq!(output.pix_fmt.as_deref(), Some("nv12"));
assert!(matches!(
output.forced_kf_spec,
crate::core::context::output::ForcedKeyframeSpec::Periodic { interval_us }
if interval_us == 6_000_000
));
let opts = output.video_codec_opts.as_ref().unwrap();
assert!(!opts.contains_key("keyint_min"));
assert!(!opts.contains_key("sc_threshold"));
assert!(!opts.contains_key("x264-params"));
assert_eq!(opts.get("bf").map(String::as_str), Some("0"));
assert_eq!(opts.get("g").map(String::as_str), Some("180"));
}
#[test]
fn explicit_qsv_rendition_matches_admitted_prescription() {
let l = ladder().video_codec("h264_qsv");
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(output.video_codec.as_deref(), Some("h264_qsv"));
assert_eq!(output.pix_fmt.as_deref(), Some("nv12"));
assert!(matches!(
output.forced_kf_spec,
crate::core::context::output::ForcedKeyframeSpec::Periodic { interval_us }
if interval_us == 6_000_000
));
let opts = output.video_codec_opts.as_ref().unwrap();
assert!(!opts.contains_key("keyint_min"));
assert_eq!(opts.get("bf").map(String::as_str), Some("0"));
}
#[test]
fn explicit_openh264_rendition_matches_admitted_prescription() {
let l = ladder().video_codec("libopenh264");
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(output.video_codec.as_deref(), Some("libopenh264"));
assert_eq!(output.pix_fmt.as_deref(), Some("yuv420p"));
assert!(matches!(
output.forced_kf_spec,
crate::core::context::output::ForcedKeyframeSpec::Periodic { interval_us }
if interval_us == 6_000_000
));
let opts = output.video_codec_opts.as_ref().unwrap();
assert_eq!(opts.get("bf").map(String::as_str), Some("0"));
assert!(!opts.contains_key("bufsize"));
assert!(!opts.contains_key("keyint_min"));
assert!(!opts.contains_key("sc_threshold"));
assert!(!opts.contains_key("x264-params"));
}
#[test]
fn fallback_videotoolbox_omits_bufsize() {
let l = ladder();
let encoder = encoder::ResolvedHlsEncoder::fallback(encoder::FallbackKind::VideoToolbox);
let output = l
.rendition_output_with(0, &l.renditions[0], "180", "6", &encoder)
.unwrap();
assert_eq!(output.pix_fmt.as_deref(), Some("yuv420p"));
let opts = output.video_codec_opts.as_ref().unwrap();
assert!(!opts.contains_key("bufsize"));
assert_eq!(opts.get("flags").map(String::as_str), Some("+cgop"));
assert_eq!(opts.get("bf").map(String::as_str), Some("0"));
}
#[test]
fn master_write_failure_states_transcode_succeeded() {
let dir = std::env::temp_dir().join(format!(
"ez-ffmpeg-hls-master-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let err = MasterWritePlan {
out_dir: dir.clone(),
master_name: "custom.m3u8".into(),
version: 3,
variants: None,
renditions: vec![Rendition::new(640, 360, "800k")],
master_codecs: None,
}
.write()
.unwrap_err();
let _ = std::fs::remove_dir_all(&dir);
assert!(
matches!(err, Error::HlsMasterWrite(_)),
"master-write failure must be typed, got {err}"
);
let text = err.to_string();
assert!(
text.contains("transcode succeeded") && text.contains("custom.m3u8"),
"{text}"
);
}