1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//! `transmux` CLI front-end gate (issue #482).
//!
//! Exercises the CLI core ([`transmux::cli`]) that wires the existing demux
//! spokes through the hub IR into the existing mux spokes. Every test **bites**:
//!
//! 1. **TS → CMAF, validator-gated**: a real `.ts` fixture through `run_bytes`
//! to CMAF, then split into init + media and run the crate's own
//! `validate_init_segment` / `validate_media_segment`; assert **zero**
//! `Severity::Error`. A broken pipeline fails validation.
//! 2. **MP4 → HLS**: a fragmented mp4 through `run_bytes` to HLS; assert a valid
//! `#EXTM3U` playlist (with `#EXT-X-TARGETDURATION`) and that every referenced
//! `.m4s` segment validates as a CMAF init+media with no errors.
//! 3. **Autodetect bites**: the detector returns the correct container for TS,
//! MP4, WebM, and PS fixtures, and `Err` on garbage. A detector that always
//! picks one fails.
//! 4. **Format selection bites**: the same TS input with `-f ts` vs `-f cmaf`
//! produces structurally different leading bytes (TS `0x47` vs an ISO-BMFF
//! box), so a mux that ignored the format flag fails.
//! 5. **clap parsing**: `Args::try_parse_from` accepts representative argv and
//! the command builds (`--help`/`--version` wire up) without panic.
#![cfg(feature = "cli")]
use std::path::PathBuf;
use transmux::cli::{Args, Container, Opts, Output, OutputFormat, detect_container, run_bytes};
use transmux::{Severity, validate_init_segment, validate_media_segment};
fn fixtures() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures")
}
fn read(rel: &str) -> Vec<u8> {
let p = fixtures().join(rel);
std::fs::read(&p).unwrap_or_else(|e| panic!("read fixture {}: {e}", p.display()))
}
/// Split a self-contained CMAF artifact (`ftyp` + `moov` + `styp` + `moof` +
/// `mdat`) at the first `styp` box into `(init, media)`.
fn split_cmaf(bytes: &[u8]) -> (&[u8], &[u8]) {
let mut off = 0usize;
while off + 8 <= bytes.len() {
let size = u32::from_be_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
as usize;
let ty = &bytes[off + 4..off + 8];
if ty == b"styp" {
return (&bytes[..off], &bytes[off..]);
}
if size < 8 || off + size > bytes.len() {
break;
}
off += size;
}
panic!("no styp box found — CMAF media segment missing");
}
fn errors(issues: &[transmux::ConformanceIssue]) -> Vec<String> {
issues
.iter()
.filter(|i| i.severity == Severity::Error)
.map(|i| format!("{}: {}", i.code, i.message))
.collect()
}
// --------------------------------------------------------------------------
// 1. TS → CMAF, validator-gated
// --------------------------------------------------------------------------
#[test]
fn ts_to_cmaf_validates_clean() {
let ts = read("ts/h264_aac.ts");
let opts = Opts {
format: OutputFormat::Cmaf,
..Opts::default()
};
let out = run_bytes(&ts, &opts).expect("TS → CMAF must succeed");
let cmaf = match out {
Output::Bytes(b) => b,
_ => panic!("CMAF output must be Bytes"),
};
// A real fMP4: leading box is `ftyp`.
assert_eq!(&cmaf[4..8], b"ftyp", "CMAF must open with an ftyp box");
let (init, media) = split_cmaf(&cmaf);
let init_errs = errors(&validate_init_segment(init));
let media_errs = errors(&validate_media_segment(media));
assert!(
init_errs.is_empty(),
"init segment has validator errors: {init_errs:?}"
);
assert!(
media_errs.is_empty(),
"media segment has validator errors: {media_errs:?}"
);
}
// --------------------------------------------------------------------------
// 2. MP4 → HLS
// --------------------------------------------------------------------------
#[test]
fn mp4_to_hls_playlist_and_segments() {
let mp4 = read("mp4/frag/h264_high.frag.mp4");
let opts = Opts {
format: OutputFormat::Hls,
segment_duration: 4,
..Opts::default()
};
let out = run_bytes(&mp4, &opts).expect("MP4 → HLS must succeed");
let (text, segments) = match out {
Output::Manifest { text, segments } => (text, segments),
_ => panic!("HLS output must be a Manifest"),
};
assert!(
text.starts_with("#EXTM3U"),
"playlist must start with #EXTM3U"
);
assert!(
text.contains("#EXT-X-TARGETDURATION"),
"playlist must carry #EXT-X-TARGETDURATION"
);
assert!(
!segments.is_empty(),
"playlist must reference at least one segment"
);
// Every referenced .m4s segment must be named in the playlist and validate.
for (name, bytes) in &segments {
assert!(
text.contains(name.as_str()),
"playlist must reference {name}"
);
let (init, media) = split_cmaf(bytes);
assert!(
errors(&validate_init_segment(init)).is_empty(),
"segment {name} init has errors"
);
assert!(
errors(&validate_media_segment(media)).is_empty(),
"segment {name} media has errors"
);
}
}
// --------------------------------------------------------------------------
// 3. Autodetect bites
// --------------------------------------------------------------------------
#[test]
fn autodetect_recognises_each_container() {
assert_eq!(
detect_container(&read("ts/h264_aac.ts")).unwrap(),
Container::MpegTs
);
assert_eq!(
detect_container(&read("mp4/frag/h264_high.frag.mp4")).unwrap(),
Container::Mp4
);
assert_eq!(
detect_container(&read("webm/vp9_opus.webm")).unwrap(),
Container::WebM
);
assert_eq!(
detect_container(&read("ps/h264_ac3.ps")).unwrap(),
Container::MpegPs
);
// Garbage must not be mistaken for any container.
assert!(detect_container(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33, 0x44]).is_err());
assert!(detect_container(&[]).is_err());
}
// --------------------------------------------------------------------------
// 4. Format selection bites
// --------------------------------------------------------------------------
#[test]
fn format_selection_changes_output_shape() {
let ts = read("ts/h264_aac.ts");
let cmaf = match run_bytes(
&ts,
&Opts {
format: OutputFormat::Cmaf,
..Opts::default()
},
)
.expect("TS → CMAF")
{
Output::Bytes(b) => b,
_ => panic!("CMAF must be Bytes"),
};
let ts_out = match run_bytes(
&ts,
&Opts {
format: OutputFormat::Ts,
..Opts::default()
},
)
.expect("TS → TS")
{
Output::Bytes(b) => b,
_ => panic!("TS must be Bytes"),
};
// CMAF: first bytes are an ISO-BMFF box (size then `ftyp`), byte 0 is 0x00.
assert_eq!(&cmaf[4..8], b"ftyp", "CMAF must lead with ftyp box");
assert_ne!(cmaf[0], 0x47, "CMAF must not start with the TS sync byte");
// TS: first byte is the 0x47 sync byte, whole 188-byte packets.
assert_eq!(ts_out[0], 0x47, "TS output must start with the sync byte");
assert_eq!(
ts_out.len() % 188,
0,
"TS output must be whole 188-byte packets"
);
// The two outputs are genuinely different container shapes.
assert_ne!(
cmaf[0], ts_out[0],
"the two formats must differ at the leading byte"
);
}
// --------------------------------------------------------------------------
// 5. clap parsing
// --------------------------------------------------------------------------
#[test]
fn args_parse_representative_argv() {
use clap::{CommandFactory, Parser};
// Positional input + named output + format + segment-duration.
let a = Args::try_parse_from([
"transmux",
"in.ts",
"-o",
"out.m3u8",
"-f",
"hls",
"--segment-duration",
"4",
])
.expect("representative argv must parse");
assert_eq!(a.output, PathBuf::from("out.m3u8"));
assert_eq!(a.segment_duration, 4);
// `-i/--input` form + track selection.
let b = Args::try_parse_from([
"transmux", "-i", "in.mp4", "-o", "out.cmaf", "--tracks", "1,2",
])
.expect("-i form must parse");
assert_eq!(b.tracks, vec![1, 2]);
// Missing both input and output must be an error, not a panic.
assert!(Args::try_parse_from(["transmux"]).is_err());
// The command builds (so --help/--version are wired) without panic.
Args::command().debug_assert();
}