mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Lands the code-audit branches for aacs, css, keys, labels, udf, ifo and dvd, bdnav/mpls/clpi, disc/bluray, extract, io, scsi, the mkv, mp4, network and mpg muxers, the audio and video codecs, and CI. Fixes include MKB and FMTS validation, UDF bounds, IFO fallback to the BUP, DTS Express framing, MPEG-2 field pairing and mpg re-basing; the rest pins existing behaviour with tests.
904 lines
29 KiB
Rust
904 lines
29 KiB
Rust
//! Integration tests for the PES stream pipeline.
|
|
|
|
use libfreemkv::mux::meta::M2tsMeta;
|
|
use libfreemkv::pes::Stream as PesStream;
|
|
use libfreemkv::*;
|
|
use std::io::{Cursor, Write};
|
|
|
|
fn sample_disc_title() -> DiscTitle {
|
|
DiscTitle {
|
|
playlist: "Test Movie".into(),
|
|
playlist_id: 0,
|
|
duration_secs: 7200.0,
|
|
size_bytes: 0,
|
|
clips: Vec::new(),
|
|
streams: vec![
|
|
Stream::Video(VideoStream {
|
|
pid: 0x1011,
|
|
codec: Codec::Hevc,
|
|
resolution: Resolution::R2160p,
|
|
frame_rate: FrameRate::F23_976,
|
|
hdr: HdrFormat::Hdr10,
|
|
color_space: ColorSpace::Bt709,
|
|
display_aspect: None,
|
|
secondary: false,
|
|
label: "Main".into(),
|
|
measured_cicp: None,
|
|
}),
|
|
Stream::Audio(AudioStream {
|
|
pid: 0x1100,
|
|
codec: Codec::TrueHd,
|
|
channels: AudioChannels::Surround71,
|
|
language: "eng".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: false,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: "English Atmos".into(),
|
|
}),
|
|
Stream::Audio(AudioStream {
|
|
pid: 0x1101,
|
|
codec: Codec::Ac3,
|
|
channels: AudioChannels::Surround51,
|
|
language: "fra".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: false,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: "French".into(),
|
|
}),
|
|
Stream::Subtitle(SubtitleStream {
|
|
pid: 0x1200,
|
|
codec: Codec::Pgs,
|
|
language: "eng".into(),
|
|
forced: false,
|
|
qualifier: libfreemkv::LabelQualifier::None,
|
|
codec_data: None,
|
|
}),
|
|
],
|
|
chapters: Vec::new(),
|
|
extents: Vec::new(),
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
}
|
|
}
|
|
|
|
// ── URL parsing ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn parse_url_disc() {
|
|
let u = parse_url("disc://");
|
|
assert_eq!(u.scheme(), "disc");
|
|
assert_eq!(u.path_str(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_disc_device() {
|
|
let u = parse_url("disc:///dev/sg4");
|
|
assert_eq!(u.scheme(), "disc");
|
|
assert_eq!(u.path_str(), "/dev/sg4");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_mkv() {
|
|
let u = parse_url("mkv://Movie.mkv");
|
|
assert_eq!(u.scheme(), "mkv");
|
|
assert_eq!(u.path_str(), "Movie.mkv");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_network() {
|
|
let u = parse_url("network://192.0.2.10:9000");
|
|
assert_eq!(u.scheme(), "network");
|
|
assert_eq!(u.path_str(), "192.0.2.10:9000");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_bare_path_rejected() {
|
|
let u = parse_url("Movie.mkv");
|
|
assert_eq!(u.scheme(), "unknown");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_null() {
|
|
let u = parse_url("null://");
|
|
assert_eq!(u.scheme(), "null");
|
|
assert_eq!(u.path_str(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_m2ts_with_path() {
|
|
let u = parse_url("m2ts:///tmp/Movie.m2ts");
|
|
assert_eq!(u.scheme(), "m2ts");
|
|
assert_eq!(u.path_str(), "/tmp/Movie.m2ts");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_m2ts_relative() {
|
|
let u = parse_url("m2ts://Movie.m2ts");
|
|
assert_eq!(u.scheme(), "m2ts");
|
|
assert_eq!(u.path_str(), "Movie.m2ts");
|
|
}
|
|
|
|
#[test]
|
|
fn open_input_bare_path_errors() {
|
|
let result = libfreemkv::input("Movie.mkv", &libfreemkv::InputOptions::default());
|
|
assert!(result.is_err());
|
|
let msg = match result {
|
|
Err(e) => e.to_string(),
|
|
Ok(_) => panic!("expected error"),
|
|
};
|
|
assert!(msg.contains("E9002"), "got: {}", msg);
|
|
}
|
|
|
|
#[test]
|
|
fn open_output_bare_path_errors() {
|
|
let dt = sample_disc_title();
|
|
let result = libfreemkv::output("Movie.mkv", &dt, None);
|
|
assert!(result.is_err());
|
|
let msg = match result {
|
|
Err(e) => e.to_string(),
|
|
Ok(_) => panic!("expected error"),
|
|
};
|
|
assert!(msg.contains("E9002"), "got: {}", msg);
|
|
}
|
|
|
|
#[test]
|
|
fn open_input_m2ts_empty_path_errors() {
|
|
let result = libfreemkv::input("m2ts://", &libfreemkv::InputOptions::default());
|
|
assert!(result.is_err());
|
|
let msg = match result {
|
|
Err(e) => e.to_string(),
|
|
Ok(_) => panic!("expected error"),
|
|
};
|
|
assert!(msg.contains("E9003"), "got: {}", msg);
|
|
}
|
|
|
|
#[test]
|
|
fn open_output_null_input_errors() {
|
|
let result = libfreemkv::input("null://", &libfreemkv::InputOptions::default());
|
|
assert!(result.is_err());
|
|
let msg = match result {
|
|
Err(e) => e.to_string(),
|
|
Ok(_) => panic!("expected error"),
|
|
};
|
|
assert!(msg.contains("E9001"), "got: {}", msg);
|
|
}
|
|
|
|
#[test]
|
|
fn open_output_disc_errors() {
|
|
let dt = sample_disc_title();
|
|
let result = libfreemkv::output("disc://", &dt, None);
|
|
assert!(result.is_err());
|
|
let msg = match result {
|
|
Err(e) => e.to_string(),
|
|
Ok(_) => panic!("expected error"),
|
|
};
|
|
assert!(msg.contains("E9000"), "got: {}", msg);
|
|
}
|
|
|
|
#[test]
|
|
fn open_input_network_no_port_errors() {
|
|
let result = libfreemkv::input("network://192.0.2.1", &libfreemkv::InputOptions::default());
|
|
assert!(result.is_err());
|
|
let msg = match result {
|
|
Err(e) => e.to_string(),
|
|
Ok(_) => panic!("expected error"),
|
|
};
|
|
assert!(msg.contains("E9004"), "got: {}", msg);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_url_stdio() {
|
|
let u = parse_url("stdio://");
|
|
assert_eq!(u.scheme(), "stdio");
|
|
assert_eq!(u.path_str(), "");
|
|
}
|
|
|
|
// ── M2TS metadata roundtrip ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn m2ts_meta_roundtrip() {
|
|
let dt = sample_disc_title();
|
|
let meta = M2tsMeta::from_title(&dt);
|
|
let restored = meta.to_title();
|
|
|
|
assert_eq!(restored.playlist, dt.playlist);
|
|
assert_eq!(restored.duration_secs, dt.duration_secs);
|
|
assert_eq!(restored.streams.len(), dt.streams.len());
|
|
|
|
// Check video
|
|
if let Stream::Video(v) = &restored.streams[0] {
|
|
assert_eq!(v.codec, Codec::Hevc);
|
|
assert_eq!(v.resolution, Resolution::R2160p);
|
|
assert_eq!(v.label, "Main");
|
|
} else {
|
|
panic!("expected video");
|
|
}
|
|
|
|
// Check audio
|
|
if let Stream::Audio(a) = &restored.streams[1] {
|
|
assert_eq!(a.codec, Codec::TrueHd);
|
|
assert_eq!(a.language, "eng");
|
|
assert_eq!(a.label, "English Atmos");
|
|
} else {
|
|
panic!("expected audio");
|
|
}
|
|
|
|
// Check subtitle
|
|
if let Stream::Subtitle(s) = &restored.streams[3] {
|
|
assert_eq!(s.language, "eng");
|
|
assert!(!s.forced);
|
|
} else {
|
|
panic!("expected subtitle");
|
|
}
|
|
}
|
|
|
|
// ── M2TS header write + read ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn m2ts_header_write_read() {
|
|
let dt = sample_disc_title();
|
|
let meta = M2tsMeta::from_title(&dt);
|
|
|
|
// Write header to buffer
|
|
let mut buf = Vec::new();
|
|
libfreemkv::mux::meta::write_header(&mut buf, &meta).unwrap();
|
|
|
|
// Verify magic
|
|
assert_eq!(&buf[..4], b"FMKV");
|
|
|
|
// Verify 192-byte alignment
|
|
assert_eq!(buf.len() % 192, 0);
|
|
|
|
// Read it back
|
|
let mut cursor = Cursor::new(&buf);
|
|
let read_back = libfreemkv::mux::meta::read_header(&mut cursor)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(read_back.title, "Test Movie");
|
|
assert_eq!(read_back.duration, 7200.0);
|
|
assert_eq!(read_back.streams.len(), 4);
|
|
|
|
// Cursor should be at end of header (192-aligned)
|
|
assert_eq!(cursor.position() as usize, buf.len());
|
|
}
|
|
|
|
// ── M2tsStream write + read ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn m2ts_stream_write_read() {
|
|
let dt = sample_disc_title();
|
|
|
|
// Write PES frames through M2tsStream to a Cursor
|
|
let output = Cursor::new(Vec::new());
|
|
let mut stream = M2tsStream::create(output, &dt).unwrap();
|
|
|
|
// Write some PES frames
|
|
for i in 0..5u8 {
|
|
let frame = libfreemkv::pes::PesFrame {
|
|
discard_padding_ns: 0,
|
|
coding: None,
|
|
source: None,
|
|
track: 0,
|
|
pts: i as i64 * 1_000_000,
|
|
keyframe: i == 0,
|
|
data: vec![i; 100],
|
|
duration_ns: None,
|
|
};
|
|
PesStream::write(&mut stream, &frame).unwrap();
|
|
}
|
|
PesStream::finish(&mut stream).unwrap();
|
|
|
|
// Verify the info is correct
|
|
let info = PesStream::info(&stream);
|
|
assert_eq!(info.streams.len(), 4);
|
|
assert_eq!(info.duration_secs, 7200.0);
|
|
}
|
|
|
|
// ── M2tsStream PES frame roundtrip ───────────────────────────
|
|
|
|
#[test]
|
|
fn m2ts_pes_frame_roundtrip() {
|
|
// PesFrame serialize/deserialize roundtrip
|
|
let frame = libfreemkv::pes::PesFrame {
|
|
discard_padding_ns: 0,
|
|
coding: None,
|
|
source: None,
|
|
track: 2,
|
|
pts: 1_234_567_890,
|
|
keyframe: true,
|
|
data: vec![0xDE; 200],
|
|
duration_ns: None,
|
|
};
|
|
|
|
let mut buf = Vec::new();
|
|
frame.serialize(&mut buf).unwrap();
|
|
|
|
let mut cursor = Cursor::new(&buf);
|
|
let restored = libfreemkv::pes::PesFrame::deserialize(&mut cursor)
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(restored.track, frame.track);
|
|
assert_eq!(restored.pts, frame.pts);
|
|
assert_eq!(restored.keyframe, frame.keyframe);
|
|
assert_eq!(restored.data, frame.data);
|
|
}
|
|
|
|
// ── PES Stream trait ─────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn m2ts_implements_pes_stream() {
|
|
let dt = sample_disc_title();
|
|
let output = Cursor::new(Vec::new());
|
|
let stream = M2tsStream::create(output, &dt).unwrap();
|
|
|
|
let boxed: Box<dyn PesStream> = Box::new(stream);
|
|
let meta = boxed.info();
|
|
assert_eq!(meta.streams.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn m2ts_read_returns_error_on_write_stream() {
|
|
let dt = sample_disc_title();
|
|
let output = Cursor::new(Vec::new());
|
|
let mut stream = M2tsStream::create(output, &dt).unwrap();
|
|
assert!(PesStream::read(&mut stream).is_err());
|
|
}
|
|
|
|
// ── DiscTitle::empty ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn disc_title_empty() {
|
|
let dt = DiscTitle::empty();
|
|
assert_eq!(dt.streams.len(), 0);
|
|
assert_eq!(dt.duration_secs, 0.0);
|
|
assert!(dt.playlist.is_empty());
|
|
}
|
|
|
|
// Every named codec must round-trip through its serialized id (FMKV meta headers).
|
|
#[test]
|
|
fn codec_id_roundtrips_for_every_named_codec() {
|
|
use Codec::*;
|
|
let all = [
|
|
Hevc, H264, Vc1, Mpeg2, Mpeg1, Av1, TrueHd, DtsHdMa, DtsHdHr, Dts, Ac3, Ac3Plus, Lpcm, Aac,
|
|
Mp2, Mp3, Flac, Opus, Pgs, DvdSub, Srt, Ssa,
|
|
];
|
|
for c in all {
|
|
assert_ne!(c.id(), "unknown", "{c:?} missing from the codec table");
|
|
assert_eq!(c.id().parse::<Codec>(), Ok(c), "{c:?} id must parse back");
|
|
}
|
|
}
|
|
|
|
// ── Meta codec roundtrip ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn meta_codec_roundtrip() {
|
|
// Test that all codec types survive from_title -> to_title
|
|
let codecs_video = &[Codec::Hevc, Codec::H264, Codec::Vc1, Codec::Mpeg2];
|
|
let codecs_audio = &[
|
|
Codec::Ac3,
|
|
Codec::Ac3Plus,
|
|
Codec::TrueHd,
|
|
Codec::DtsHdMa,
|
|
Codec::DtsHdHr,
|
|
Codec::Dts,
|
|
Codec::Lpcm,
|
|
];
|
|
let codecs_sub = &[Codec::Pgs];
|
|
|
|
let mut streams = Vec::new();
|
|
for (i, &codec) in codecs_video.iter().enumerate() {
|
|
streams.push(Stream::Video(VideoStream {
|
|
pid: (0x1011 + i) as u16,
|
|
codec,
|
|
resolution: Resolution::R1080p,
|
|
frame_rate: FrameRate::F23_976,
|
|
hdr: HdrFormat::Sdr,
|
|
color_space: ColorSpace::Bt709,
|
|
display_aspect: None,
|
|
secondary: false,
|
|
label: String::new(),
|
|
measured_cicp: None,
|
|
}));
|
|
}
|
|
for (i, &codec) in codecs_audio.iter().enumerate() {
|
|
streams.push(Stream::Audio(AudioStream {
|
|
pid: (0x1100 + i) as u16,
|
|
codec,
|
|
channels: AudioChannels::Surround51,
|
|
language: "eng".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: false,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: String::new(),
|
|
}));
|
|
}
|
|
for (i, &codec) in codecs_sub.iter().enumerate() {
|
|
streams.push(Stream::Subtitle(SubtitleStream {
|
|
pid: (0x1200 + i) as u16,
|
|
codec,
|
|
language: "eng".into(),
|
|
forced: false,
|
|
qualifier: libfreemkv::LabelQualifier::None,
|
|
codec_data: None,
|
|
}));
|
|
}
|
|
|
|
let dt = DiscTitle {
|
|
playlist: "Codec Test".into(),
|
|
playlist_id: 0,
|
|
duration_secs: 100.0,
|
|
size_bytes: 0,
|
|
clips: Vec::new(),
|
|
streams,
|
|
chapters: Vec::new(),
|
|
extents: Vec::new(),
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
};
|
|
|
|
let meta = M2tsMeta::from_title(&dt);
|
|
let restored = meta.to_title();
|
|
|
|
assert_eq!(restored.streams.len(), dt.streams.len());
|
|
for (orig, rest) in dt.streams.iter().zip(restored.streams.iter()) {
|
|
match (orig, rest) {
|
|
(Stream::Video(o), Stream::Video(r)) => {
|
|
assert_eq!(o.codec, r.codec, "video codec mismatch")
|
|
}
|
|
(Stream::Audio(o), Stream::Audio(r)) => {
|
|
assert_eq!(o.codec, r.codec, "audio codec mismatch")
|
|
}
|
|
(Stream::Subtitle(o), Stream::Subtitle(r)) => {
|
|
assert_eq!(o.codec, r.codec, "subtitle codec mismatch")
|
|
}
|
|
_ => panic!("stream type mismatch"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn meta_empty_streams() {
|
|
let dt = DiscTitle {
|
|
playlist: "Empty".into(),
|
|
playlist_id: 0,
|
|
duration_secs: 0.0,
|
|
size_bytes: 0,
|
|
clips: Vec::new(),
|
|
streams: Vec::new(),
|
|
chapters: Vec::new(),
|
|
extents: Vec::new(),
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
};
|
|
|
|
let meta = M2tsMeta::from_title(&dt);
|
|
assert_eq!(meta.streams.len(), 0);
|
|
let restored = meta.to_title();
|
|
assert_eq!(restored.streams.len(), 0);
|
|
assert_eq!(restored.playlist, "Empty");
|
|
}
|
|
|
|
#[test]
|
|
fn meta_all_stream_types() {
|
|
let dt = DiscTitle {
|
|
playlist: "Full".into(),
|
|
playlist_id: 0,
|
|
duration_secs: 3600.0,
|
|
size_bytes: 0,
|
|
clips: Vec::new(),
|
|
chapters: Vec::new(),
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
streams: vec![
|
|
Stream::Video(VideoStream {
|
|
pid: 0x1011,
|
|
codec: Codec::Hevc,
|
|
resolution: Resolution::R2160p,
|
|
frame_rate: FrameRate::F23_976,
|
|
hdr: HdrFormat::Hdr10,
|
|
color_space: ColorSpace::Bt709,
|
|
display_aspect: None,
|
|
secondary: false,
|
|
label: "Primary".into(),
|
|
measured_cicp: None,
|
|
}),
|
|
Stream::Audio(AudioStream {
|
|
pid: 0x1100,
|
|
codec: Codec::TrueHd,
|
|
channels: AudioChannels::Surround71,
|
|
language: "eng".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: false,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: "Primary Audio".into(),
|
|
}),
|
|
Stream::Subtitle(SubtitleStream {
|
|
pid: 0x1200,
|
|
codec: Codec::Pgs,
|
|
language: "fra".into(),
|
|
forced: true,
|
|
qualifier: libfreemkv::LabelQualifier::None,
|
|
codec_data: None,
|
|
}),
|
|
Stream::Audio(AudioStream {
|
|
pid: 0x1110,
|
|
codec: Codec::Ac3,
|
|
channels: AudioChannels::Stereo,
|
|
language: "eng".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: true,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: "Commentary".into(),
|
|
}),
|
|
],
|
|
extents: Vec::new(),
|
|
};
|
|
|
|
let meta = M2tsMeta::from_title(&dt);
|
|
let restored = meta.to_title();
|
|
|
|
assert_eq!(restored.streams.len(), 4);
|
|
|
|
// Video preserved
|
|
if let Stream::Video(v) = &restored.streams[0] {
|
|
assert_eq!(v.codec, Codec::Hevc);
|
|
assert_eq!(v.resolution, Resolution::R2160p);
|
|
assert_eq!(v.label, "Primary");
|
|
assert!(!v.secondary);
|
|
} else {
|
|
panic!("expected video");
|
|
}
|
|
|
|
// Primary audio preserved
|
|
if let Stream::Audio(a) = &restored.streams[1] {
|
|
assert_eq!(a.codec, Codec::TrueHd);
|
|
assert_eq!(a.channels, AudioChannels::Surround71);
|
|
assert!(!a.secondary);
|
|
} else {
|
|
panic!("expected audio");
|
|
}
|
|
|
|
// Subtitle preserved (forced flag)
|
|
if let Stream::Subtitle(s) = &restored.streams[2] {
|
|
assert_eq!(s.language, "fra");
|
|
assert!(s.forced);
|
|
} else {
|
|
panic!("expected subtitle");
|
|
}
|
|
|
|
// Secondary audio preserved
|
|
if let Stream::Audio(a) = &restored.streams[3] {
|
|
assert_eq!(a.codec, Codec::Ac3);
|
|
assert!(a.secondary);
|
|
assert_eq!(a.label, "Commentary");
|
|
} else {
|
|
panic!("expected secondary audio");
|
|
}
|
|
}
|
|
|
|
// ── MkvStream tests ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn mkvstream_write_finish() {
|
|
let dt = sample_disc_title();
|
|
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
|
let mut stream = MkvStream::create(writer, &dt, None).unwrap();
|
|
|
|
// Write some fake PES frames (they won't produce valid MKV content
|
|
// since there is no real codec data, but it should not panic)
|
|
for i in 0..20u8 {
|
|
let frame = libfreemkv::pes::PesFrame {
|
|
discard_padding_ns: 0,
|
|
coding: None,
|
|
source: None,
|
|
track: 0,
|
|
pts: i as i64 * 1_000_000,
|
|
keyframe: i == 0,
|
|
data: vec![i; 100],
|
|
duration_ns: None,
|
|
};
|
|
PesStream::write(&mut stream, &frame).unwrap();
|
|
}
|
|
|
|
// finish should not panic even without valid codec data
|
|
PesStream::finish(&mut stream).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn mkvstream_meta_sets_title() {
|
|
let dt = sample_disc_title();
|
|
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
|
let stream = MkvStream::create(writer, &dt, None).unwrap();
|
|
|
|
let info = PesStream::info(&stream);
|
|
assert_eq!(info.playlist, "Test Movie");
|
|
assert_eq!(info.duration_secs, 7200.0);
|
|
assert_eq!(info.streams.len(), 4);
|
|
}
|
|
|
|
// ── MkvStream additional tests ──────────────────────────────
|
|
|
|
#[test]
|
|
fn mkvstream_roundtrip_bdts() {
|
|
// Write BD-TS packets through MkvStream and verify the pipeline works.
|
|
// Without real codec headers (SPS/PPS) the muxer would stay in scanning
|
|
// phase, but an audio-only title skips codec scanning and streams EBML output immediately.
|
|
|
|
let dt = DiscTitle {
|
|
playlist: "Audio Only".into(),
|
|
playlist_id: 0,
|
|
duration_secs: 60.0,
|
|
size_bytes: 0,
|
|
clips: Vec::new(),
|
|
streams: vec![Stream::Audio(AudioStream {
|
|
pid: 0x1100,
|
|
codec: Codec::Ac3,
|
|
channels: AudioChannels::Surround51,
|
|
language: "eng".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: false,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: "English".into(),
|
|
})],
|
|
chapters: Vec::new(),
|
|
extents: Vec::new(),
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
};
|
|
|
|
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
|
let mut stream = MkvStream::create(writer, &dt, None).unwrap();
|
|
|
|
// Write PES frames targeting the audio track
|
|
for i in 0..10u8 {
|
|
let frame = libfreemkv::pes::PesFrame {
|
|
discard_padding_ns: 0,
|
|
coding: None,
|
|
source: None,
|
|
track: 0,
|
|
pts: i as i64 * 1_000_000,
|
|
keyframe: true,
|
|
data: vec![i; 100],
|
|
duration_ns: None,
|
|
};
|
|
PesStream::write(&mut stream, &frame).unwrap();
|
|
}
|
|
|
|
PesStream::finish(&mut stream).unwrap();
|
|
|
|
// Verify the info is correct
|
|
let info = PesStream::info(&stream);
|
|
assert_eq!(info.streams.len(), 1);
|
|
assert_eq!(info.playlist, "Audio Only");
|
|
}
|
|
|
|
#[test]
|
|
fn mkvstream_meta_preserves_all_streams() {
|
|
let dt = DiscTitle {
|
|
playlist: "Stream Test".into(),
|
|
playlist_id: 0,
|
|
duration_secs: 3600.0,
|
|
size_bytes: 0,
|
|
clips: Vec::new(),
|
|
streams: vec![
|
|
Stream::Video(VideoStream {
|
|
pid: 0x1011,
|
|
codec: Codec::H264,
|
|
resolution: Resolution::R1080p,
|
|
frame_rate: FrameRate::F23_976,
|
|
hdr: HdrFormat::Sdr,
|
|
color_space: ColorSpace::Bt709,
|
|
display_aspect: None,
|
|
secondary: false,
|
|
label: "Main Video".into(),
|
|
measured_cicp: None,
|
|
}),
|
|
Stream::Audio(AudioStream {
|
|
pid: 0x1100,
|
|
codec: Codec::Ac3,
|
|
channels: AudioChannels::Surround51,
|
|
language: "eng".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: false,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: "English".into(),
|
|
}),
|
|
Stream::Audio(AudioStream {
|
|
pid: 0x1101,
|
|
codec: Codec::DtsHdMa,
|
|
channels: AudioChannels::Surround71,
|
|
language: "fra".into(),
|
|
sample_rate: SampleRate::S48,
|
|
secondary: false,
|
|
purpose: libfreemkv::LabelPurpose::Normal,
|
|
label: "French".into(),
|
|
}),
|
|
Stream::Subtitle(SubtitleStream {
|
|
pid: 0x1200,
|
|
codec: Codec::Pgs,
|
|
language: "eng".into(),
|
|
forced: false,
|
|
qualifier: libfreemkv::LabelQualifier::None,
|
|
codec_data: None,
|
|
}),
|
|
Stream::Subtitle(SubtitleStream {
|
|
pid: 0x1201,
|
|
codec: Codec::Pgs,
|
|
language: "fra".into(),
|
|
forced: true,
|
|
qualifier: libfreemkv::LabelQualifier::None,
|
|
codec_data: None,
|
|
}),
|
|
],
|
|
chapters: Vec::new(),
|
|
extents: Vec::new(),
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
};
|
|
|
|
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
|
let stream = MkvStream::create(writer, &dt, None).unwrap();
|
|
|
|
let info = PesStream::info(&stream);
|
|
assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved");
|
|
assert_eq!(info.playlist, "Stream Test");
|
|
assert_eq!(info.duration_secs, 3600.0);
|
|
|
|
// Verify stream types preserved in order
|
|
assert!(matches!(&info.streams[0], Stream::Video(_)));
|
|
assert!(matches!(&info.streams[1], Stream::Audio(_)));
|
|
assert!(matches!(&info.streams[2], Stream::Audio(_)));
|
|
assert!(matches!(&info.streams[3], Stream::Subtitle(_)));
|
|
assert!(matches!(&info.streams[4], Stream::Subtitle(_)));
|
|
|
|
// Check specific attributes
|
|
if let Stream::Video(v) = &info.streams[0] {
|
|
assert_eq!(v.codec, Codec::H264);
|
|
}
|
|
if let Stream::Audio(a) = &info.streams[1] {
|
|
assert_eq!(a.language, "eng");
|
|
}
|
|
if let Stream::Audio(a) = &info.streams[2] {
|
|
assert_eq!(a.codec, Codec::DtsHdMa);
|
|
assert_eq!(a.language, "fra");
|
|
}
|
|
if let Stream::Subtitle(s) = &info.streams[3] {
|
|
assert!(!s.forced);
|
|
}
|
|
if let Stream::Subtitle(s) = &info.streams[4] {
|
|
assert!(s.forced);
|
|
}
|
|
}
|
|
|
|
// ── H2: End-to-end MKV mux test ─────────────────────────────
|
|
|
|
#[test]
|
|
fn mkvstream_e2e_h264_produces_valid_mkv() {
|
|
// Construct a DiscTitle with one H.264 video stream
|
|
let dt = DiscTitle {
|
|
playlist: "H264 Test".into(),
|
|
playlist_id: 0,
|
|
duration_secs: 10.0,
|
|
size_bytes: 0,
|
|
clips: Vec::new(),
|
|
streams: vec![Stream::Video(VideoStream {
|
|
pid: 0x1011,
|
|
codec: Codec::H264,
|
|
resolution: Resolution::R1080p,
|
|
frame_rate: FrameRate::F23_976,
|
|
hdr: HdrFormat::Sdr,
|
|
color_space: ColorSpace::Bt709,
|
|
display_aspect: None,
|
|
secondary: false,
|
|
label: "Main".into(),
|
|
measured_cicp: None,
|
|
})],
|
|
chapters: Vec::new(),
|
|
extents: Vec::new(),
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
};
|
|
|
|
// Build elementary stream data: start codes + NALs
|
|
let mut es_data = Vec::new();
|
|
|
|
// SPS NAL (type 7): minimal valid SPS
|
|
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
|
|
es_data.push(0x67); // NAL type 7 (SPS), nal_ref_idc=3
|
|
es_data.extend_from_slice(&[
|
|
0x42, 0xC0, 0x1E, // profile=66, constraint_set0=1, level=30
|
|
0xD9, 0x00, 0xA0, 0x47, 0xFE, 0x88, // minimal SPS rbsp
|
|
]);
|
|
|
|
// PPS NAL (type 8): minimal valid PPS
|
|
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
|
|
es_data.push(0x68); // NAL type 8 (PPS), nal_ref_idc=3
|
|
es_data.extend_from_slice(&[0xCE, 0x38, 0x80]); // minimal PPS rbsp
|
|
|
|
// IDR NAL (type 5): keyframe
|
|
es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code
|
|
es_data.push(0x65); // NAL type 5 (IDR), nal_ref_idc=3
|
|
es_data.extend_from_slice(&[0x88, 0x84, 0x00, 0x21, 0xFF, 0xFE, 0xF6, 0xE2]);
|
|
es_data.extend_from_slice(&[0x00; 64]);
|
|
|
|
// Non-IDR slice data (NAL type 1)
|
|
let mut es_data2 = Vec::new();
|
|
es_data2.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
|
es_data2.push(0x41); // NAL type 1 (non-IDR)
|
|
es_data2.extend_from_slice(&[0x9A, 0x00, 0x10, 0x20]);
|
|
es_data2.extend_from_slice(&[0x00; 32]);
|
|
|
|
// Feed through MkvStream using a shared writer to inspect the output bytes.
|
|
let output2 = std::sync::Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
|
|
|
|
struct SharedWriter(std::sync::Arc<std::sync::Mutex<Cursor<Vec<u8>>>>);
|
|
impl Write for SharedWriter {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
self.0.lock().unwrap().write(buf)
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
self.0.lock().unwrap().flush()
|
|
}
|
|
}
|
|
impl std::io::Seek for SharedWriter {
|
|
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
|
self.0.lock().unwrap().seek(pos)
|
|
}
|
|
}
|
|
|
|
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> =
|
|
Box::new(SharedWriter(output2.clone()));
|
|
let mut stream2 = MkvStream::create(writer, &dt, None).unwrap();
|
|
|
|
// Write the ES data (SPS+PPS+IDR) as a keyframe PES frame.
|
|
let frame1 = libfreemkv::pes::PesFrame {
|
|
discard_padding_ns: 0,
|
|
coding: None,
|
|
source: None,
|
|
track: 0,
|
|
pts: 1_000_000_000, // 1 second in ns
|
|
keyframe: true,
|
|
data: es_data,
|
|
duration_ns: None,
|
|
};
|
|
PesStream::write(&mut stream2, &frame1).unwrap();
|
|
|
|
// Write a second non-IDR frame
|
|
let frame2 = libfreemkv::pes::PesFrame {
|
|
discard_padding_ns: 0,
|
|
coding: None,
|
|
source: None,
|
|
track: 0,
|
|
pts: 1_041_700_000, // ~1 frame later in ns
|
|
keyframe: false,
|
|
data: es_data2,
|
|
duration_ns: None,
|
|
};
|
|
PesStream::write(&mut stream2, &frame2).unwrap();
|
|
PesStream::finish(&mut stream2).unwrap();
|
|
|
|
let data = output2.lock().unwrap().clone().into_inner();
|
|
|
|
// Verify output starts with EBML magic (0x1A45DFA3)
|
|
assert!(
|
|
data.len() >= 4,
|
|
"MKV output too small: {} bytes",
|
|
data.len()
|
|
);
|
|
assert_eq!(
|
|
&data[0..4],
|
|
&[0x1A, 0x45, 0xDF, 0xA3],
|
|
"output should start with EBML magic"
|
|
);
|
|
|
|
// Verify output contains a Tracks element (0x1654AE6B)
|
|
let tracks_needle = [0x16, 0x54, 0xAE, 0x6B];
|
|
let has_tracks = data.windows(4).any(|w| w == tracks_needle);
|
|
assert!(has_tracks, "output should contain Tracks element");
|
|
|
|
// Note: With the PES stream API, codec_privates are populated from the
|
|
// DiscTitle at creation time, not extracted from ES data during write.
|
|
// This test verifies the muxer produces valid EBML structure.
|
|
}
|