1mod banner;
9
10use anyhow::{Context, Result};
11use clap::{Parser, Subcommand};
12use sheathe_core::Sample;
13use sheathe_core::{MediaKind, Scaled, StreamInfo};
14use sheathe_crypto::{ContentKey, ProtectionSystem, Scheme};
15use sheathe_dash::{Manifest, Protection, Representation};
16use sheathe_es::{EsDemuxer, is_mp4};
17use sheathe_hls::{KeyInfo, SegmentRef, Variant, master_playlist, media_playlist};
18use sheathe_mp4::{
19 Encryption, Fragmenter, Mp4Demuxer, SegmentPolicy, Track, write_init_segment,
20 write_media_segment,
21};
22use sheathe_ts::{TsDemuxer, packet::PACKET_SIZE};
23use std::fs;
24use std::path::Path;
25
26#[derive(Debug, Parser)]
28#[command(
29 name = "sheathe",
30 version,
31 about = "Pure-Rust HLS/DASH/CMAF media packager",
32 long_about = None
33)]
34struct Cli {
35 #[arg(long, global = true)]
37 no_banner: bool,
38
39 #[command(subcommand)]
40 command: Command,
41}
42
43#[derive(Debug, Subcommand)]
44enum Command {
45 Package {
48 #[arg(required = true, num_args = 1..)]
50 inputs: Vec<String>,
51 #[arg(short, long, default_value = "out")]
53 out: String,
54 #[arg(long, default_value_t = 6.0)]
56 segment_duration: f64,
57 #[arg(long)]
59 dash: bool,
60 #[arg(long)]
62 hls: bool,
63 #[arg(long, value_name = "KID:KEY")]
66 enc_key: Option<String>,
67 #[arg(long, value_name = "PATH")]
71 enc_key_file: Option<String>,
72 #[arg(long, default_value = "cenc")]
75 enc_scheme: String,
76 #[arg(long, default_value = "key.bin")]
78 enc_key_uri: String,
79 #[arg(long, default_value = "common")]
82 protection_systems: String,
83 #[arg(long, value_name = "SECONDS")]
87 crypto_period_duration: Option<f64>,
88 },
89 Probe {
91 input: String,
93 },
94}
95
96pub fn run() -> Result<()> {
98 if !std::env::args().any(|a| a == "--no-banner") {
101 banner::print();
102 }
103
104 let cli = Cli::parse();
105
106 match cli.command {
107 Command::Package {
108 inputs,
109 out,
110 segment_duration,
111 dash,
112 hls,
113 enc_key,
114 enc_key_file,
115 enc_scheme,
116 enc_key_uri,
117 protection_systems,
118 crypto_period_duration,
119 } => cmd_package(
120 &inputs,
121 &out,
122 segment_duration,
123 dash,
124 hls,
125 EncryptionOpts {
126 key: enc_key.as_deref(),
127 key_file: enc_key_file.as_deref(),
128 scheme: &enc_scheme,
129 key_uri: &enc_key_uri,
130 systems: &protection_systems,
131 crypto_period: crypto_period_duration,
132 },
133 )?,
134 Command::Probe { input } => cmd_probe(&input)?,
135 }
136
137 Ok(())
138}
139
140fn cmd_probe(input: &str) -> Result<()> {
142 let bytes = fs::read(input).with_context(|| format!("reading {input}"))?;
143 let loaded = load_input(input, &bytes)?;
144
145 println!(
146 "probe: {input} ({} bytes, {} track(s), {})",
147 bytes.len(),
148 loaded.tracks.len(),
149 loaded.format
150 );
151 for (i, t) in loaded.tracks.iter().enumerate() {
152 println!(" [{}] track #{} {}", i, t.track.track_id, describe(&t.track.info));
153 println!(" samples={} timescale={}", t.samples.len(), t.track.info.timescale.0);
154 }
155 Ok(())
156}
157
158struct LoadedInput {
160 format: &'static str,
161 tracks: Vec<LoadedTrack>,
162}
163
164struct LoadedTrack {
165 track: Track,
166 samples: Vec<Sample>,
167}
168
169fn is_transport_stream(data: &[u8]) -> bool {
171 if data.len() < PACKET_SIZE * 3 {
172 return false;
173 }
174 (0..3).all(|i| data[i * PACKET_SIZE] == 0x47)
175}
176
177fn append_captions(tracks: &mut Vec<LoadedTrack>) {
180 let Some(vid) = tracks.iter().find(|t| {
181 t.track.info.kind == sheathe_core::MediaKind::Video
182 && matches!(t.track.info.codec, sheathe_core::Codec::H264 | sheathe_core::Codec::H265)
183 }) else {
184 return;
185 };
186 let hevc = vid.track.info.codec == sheathe_core::Codec::H265;
187 let samples: Vec<(u64, &[u8])> =
188 vid.samples.iter().map(|s| (s.pts, s.data.as_slice())).collect();
189 for text in sheathe_text::extract_captions(&samples, hevc) {
190 let id = tracks.len() as u32 + 1;
191 tracks.push(LoadedTrack {
192 track: Track::from_sample_entry(
193 text.info.clone(),
194 id,
195 text.sample_entry.clone(),
196 &text.samples,
197 ),
198 samples: text.samples.clone(),
199 });
200 }
201}
202
203fn load_input(path: &str, data: &[u8]) -> Result<LoadedInput> {
204 if is_transport_stream(data) {
205 let demux = TsDemuxer::parse(data).with_context(|| format!("parsing MPEG-TS {path}"))?;
206 let mut tracks: Vec<LoadedTrack> = demux
207 .tracks()
208 .iter()
209 .enumerate()
210 .map(|(i, t)| LoadedTrack {
211 track: Track::from_sample_entry(
212 t.info.clone(),
213 (i + 1) as u32,
214 t.sample_entry.clone(),
215 &t.samples,
216 ),
217 samples: t.samples.clone(),
218 })
219 .collect();
220 append_captions(&mut tracks);
221 return Ok(LoadedInput { format: "MPEG-TS", tracks });
222 }
223
224 if is_mp4(data) {
225 return load_mp4(path, data);
226 }
227
228 if sheathe_mkv::is_webm(data) {
229 let demux =
230 sheathe_mkv::MkvDemuxer::parse(data).with_context(|| format!("parsing WebM {path}"))?;
231 let tracks = demux
232 .tracks()
233 .iter()
234 .enumerate()
235 .map(|(i, t)| LoadedTrack {
236 track: Track::from_sample_entry(
237 t.info.clone(),
238 (i + 1) as u32,
239 t.sample_entry.clone(),
240 &t.samples,
241 ),
242 samples: t.samples.clone(),
243 })
244 .collect();
245 return Ok(LoadedInput { format: "WebM", tracks });
246 }
247
248 if sheathe_text::is_webvtt(path, data) {
249 let text = std::str::from_utf8(data)
250 .with_context(|| format!("WebVTT {path} is not valid UTF-8"))?;
251 let t = sheathe_text::webvtt(text).with_context(|| format!("parsing WebVTT {path}"))?;
252 let tracks = vec![LoadedTrack {
253 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
254 samples: t.samples.clone(),
255 }];
256 return Ok(LoadedInput { format: "WebVTT", tracks });
257 }
258
259 if sheathe_text::is_ttml(data) {
260 let text =
261 std::str::from_utf8(data).with_context(|| format!("TTML {path} is not valid UTF-8"))?;
262 let t = sheathe_text::ttml(text).with_context(|| format!("parsing TTML {path}"))?;
263 let tracks = vec![LoadedTrack {
264 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
265 samples: t.samples.clone(),
266 }];
267 return Ok(LoadedInput { format: "TTML", tracks });
268 }
269
270 if sheathe_es::detect(path, data).is_some() {
271 let demux = EsDemuxer::parse_auto(path, data)
272 .with_context(|| format!("parsing elementary stream {path}"))?;
273 let t = demux.track();
274 let mut tracks = vec![LoadedTrack {
275 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
276 samples: t.samples.clone(),
277 }];
278 append_captions(&mut tracks);
279 return Ok(LoadedInput { format: "elementary", tracks });
280 }
281
282 load_mp4(path, data)
283}
284
285fn load_mp4(path: &str, data: &[u8]) -> Result<LoadedInput> {
286 let demux = Mp4Demuxer::parse(data).with_context(|| format!("parsing MP4 {path}"))?;
287 let mut tracks = Vec::new();
288 for (i, t) in demux.tracks().iter().enumerate() {
289 tracks.push(LoadedTrack {
290 track: t.clone(),
291 samples: demux.samples(i).with_context(|| format!("reading samples for track {i}"))?,
292 });
293 }
294 Ok(LoadedInput { format: "MP4", tracks })
295}
296
297struct EncryptionOpts<'a> {
302 key: Option<&'a str>,
304 key_file: Option<&'a str>,
306 scheme: &'a str,
308 key_uri: &'a str,
310 systems: &'a str,
312 crypto_period: Option<f64>,
314}
315
316fn cmd_package(
317 inputs: &[String],
318 out: &str,
319 segment_duration: f64,
320 dash: bool,
321 hls: bool,
322 enc: EncryptionOpts<'_>,
323) -> Result<()> {
324 let out_dir = Path::new(out);
325 fs::create_dir_all(out_dir).with_context(|| format!("creating {out}/"))?;
326 let key_spec = match enc.key_file {
328 Some(path) => Some(read_key_file(path)?),
329 None => enc.key.map(str::to_string),
330 };
331 let encryption = key_spec
332 .map(|k| parse_enc_key(&k, enc.scheme, enc.systems, enc.crypto_period))
333 .transpose()?;
334
335 let hls_key = encryption.as_ref().map(|_| KeyInfo {
337 method: match enc.scheme {
340 "cbcs" | "cbc1" => "SAMPLE-AES",
341 _ => "SAMPLE-AES-CTR",
342 }
343 .to_string(),
344 key_format: "urn:mpeg:dash:mp4protection:2011".to_string(),
345 uri: enc.key_uri.to_string(),
346 });
347
348 let datas: Vec<Vec<u8>> = inputs
349 .iter()
350 .map(|p| fs::read(p).with_context(|| format!("reading {p}")))
351 .collect::<Result<_>>()?;
352 let loaded: Vec<LoadedInput> =
353 datas.iter().zip(inputs).map(|(d, p)| load_input(p, d)).collect::<Result<_>>()?;
354
355 println!("package: {} input(s) -> {out}/", inputs.len());
356 println!(" segment_duration = {segment_duration}s (dash={dash}, hls={hls})");
357 if encryption.is_some() {
358 let alg = match enc.scheme {
359 "cens" => "cens (AES-128-CTR pattern)",
360 "cbc1" => "cbc1 (AES-128-CBC)",
361 "cbcs" => "cbcs (AES-128-CBC pattern)",
362 _ => "cenc (AES-128-CTR)",
363 };
364 println!(" encryption = {alg}");
365 println!(" protection_systems = {}", enc.systems);
366 if let Some(p) = enc.crypto_period {
367 println!(" key_rotation = every {p}s (crypto period)");
368 }
369 }
370
371 let policy = SegmentPolicy { target_seconds: segment_duration, keyframes_only: true };
372 let mut dash_reps = Vec::new();
373 let mut hls_variants = Vec::new();
374 let mut total_seconds = 0.0_f64;
375 let mut rep = 0usize; for input in &loaded {
378 for lt in &input.tracks {
379 let track = <.track;
380 let samples = <.samples;
381 let mut frag = Fragmenter::new(track.info.clone(), policy);
382 for s in samples.iter().cloned() {
383 frag.push(s)?;
384 }
385 let segments = frag.finish();
386 let ts = track.info.timescale;
387
388 let init_name = format!("init_{rep}.mp4");
390 fs::write(out_dir.join(&init_name), write_init_segment(track, encryption.as_ref()))
391 .with_context(|| format!("writing {init_name}"))?;
392
393 let mut durations = Vec::with_capacity(segments.len());
395 let mut hls_segs = Vec::with_capacity(segments.len());
396 let mut sample_index = 0u64;
397 for (n, seg) in segments.iter().enumerate() {
398 let seg_name = format!("seg_{rep}_{}.m4s", n + 1);
399 let data = write_media_segment(
400 track,
401 (n + 1) as u32,
402 seg,
403 sample_index,
404 encryption.as_ref(),
405 );
406 fs::write(out_dir.join(&seg_name), data)
407 .with_context(|| format!("writing {seg_name}"))?;
408 sample_index += seg.samples.len() as u64;
409 durations.push(seg.duration_ticks);
410 hls_segs.push(SegmentRef {
411 duration: Scaled::new(seg.duration_ticks, ts).seconds(),
412 uri: seg_name,
413 });
414 }
415
416 let track_total: u64 = segments.iter().map(|s| s.duration_ticks).sum();
417 let track_seconds = Scaled::new(track_total, ts).seconds();
418 total_seconds = total_seconds.max(track_seconds);
419 println!(
420 " [{}] {} -> {} + {} segment(s), {:.2}s",
421 rep,
422 describe(&track.info),
423 init_name,
424 segments.len(),
425 track_seconds,
426 );
427
428 dash_reps.push(Representation {
429 id: rep.to_string(),
430 stream: track.info.clone(),
431 init: init_name.clone(),
432 media: format!("seg_{rep}_$Number$.m4s"),
433 timescale: ts.0,
434 segment_durations: durations,
435 });
436
437 if hls {
438 let media_name = format!("media_{rep}.m3u8");
439 fs::write(
440 out_dir.join(&media_name),
441 media_playlist(&init_name, &hls_segs, hls_key.as_ref()),
442 )
443 .with_context(|| format!("writing {media_name}"))?;
444 hls_variants.push(Variant { stream: track.info.clone(), playlist_uri: media_name });
445 }
446
447 rep += 1;
448 }
449 }
450
451 if dash {
452 let protection = encryption.as_ref().map(|e| Protection {
453 scheme: match e.scheme {
454 Scheme::Cenc => "cenc",
455 Scheme::Cens => "cens",
456 Scheme::Cbc1 => "cbc1",
457 Scheme::Cbcs => "cbcs",
458 }
459 .to_string(),
460 default_kid: e.key.kid,
461 });
462 let mpd =
463 Manifest { duration_seconds: total_seconds, representations: dash_reps, protection }
464 .to_xml();
465 fs::write(out_dir.join("manifest.mpd"), mpd).context("writing manifest.mpd")?;
466 println!(" wrote manifest.mpd");
467 }
468 if hls {
469 fs::write(out_dir.join("master.m3u8"), master_playlist(&hls_variants))
470 .context("writing master.m3u8")?;
471 println!(" wrote master.m3u8 (+ per-track media playlists)");
472 }
473
474 Ok(())
475}
476
477fn parse_enc_key(
480 spec: &str,
481 scheme: &str,
482 systems: &str,
483 crypto_period: Option<f64>,
484) -> Result<Encryption> {
485 let (kid_hex, key_hex) =
486 spec.split_once(':').context("--enc-key must be <KID hex>:<KEY hex>")?;
487 let kid = parse_hex16(kid_hex).context("invalid KID")?;
488 let key = parse_hex16(key_hex).context("invalid KEY")?;
489 let scheme = match scheme {
490 "cenc" => Scheme::Cenc,
491 "cens" => Scheme::Cens,
492 "cbc1" => Scheme::Cbc1,
493 "cbcs" => Scheme::Cbcs,
494 other => {
495 anyhow::bail!("unknown --enc-scheme '{other}' (expected cenc, cens, cbc1 or cbcs)")
496 }
497 };
498 let systems = parse_protection_systems(systems)?;
499 let constant_iv = [
502 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
503 0xff,
504 ];
505 if let Some(p) = crypto_period {
506 anyhow::ensure!(p > 0.0, "--crypto-period-duration must be positive");
507 }
508 Ok(Encryption {
509 scheme,
510 key: ContentKey { kid, key },
511 constant_iv,
512 systems,
513 crypto_period_seconds: crypto_period,
514 })
515}
516
517fn read_key_file(path: &str) -> Result<String> {
520 let content = fs::read_to_string(path).with_context(|| format!("reading key file {path}"))?;
521 content
522 .lines()
523 .map(|line| line.split('#').next().unwrap_or("").trim())
524 .find(|line| line.contains(':'))
525 .map(str::to_string)
526 .with_context(|| format!("no <KID hex>:<KEY hex> entry in key file {path}"))
527}
528
529fn parse_protection_systems(list: &str) -> Result<Vec<ProtectionSystem>> {
531 list.split(',')
532 .map(str::trim)
533 .filter(|s| !s.is_empty())
534 .map(|name| {
535 ProtectionSystem::parse(name).with_context(|| {
536 format!("unknown protection system '{name}' (expected common, widevine, playready)")
537 })
538 })
539 .collect()
540}
541
542fn parse_hex16(s: &str) -> Result<[u8; 16]> {
544 let s = s.trim();
545 anyhow::ensure!(s.len() == 32, "expected 32 hex chars, got {}", s.len());
546 let mut out = [0u8; 16];
547 for (i, b) in out.iter_mut().enumerate() {
548 *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).context("non-hex digit")?;
549 }
550 Ok(out)
551}
552
553fn describe(info: &StreamInfo) -> String {
555 let kind = match info.kind {
556 MediaKind::Video => "video",
557 MediaKind::Audio => "audio",
558 MediaKind::Text => "text",
559 };
560 let mut s = format!("{kind} {}", info.rfc6381());
561 if let Some((w, h)) = info.resolution {
562 s.push_str(&format!(" {w}x{h}"));
563 }
564 if let Some(rate) = info.sample_rate {
565 s.push_str(&format!(" {rate}Hz"));
566 }
567 if let Some(br) = info.bitrate {
568 s.push_str(&format!(" ~{}kbps", br / 1000));
569 }
570 s
571}