Skip to main content

sheathe_cli/
lib.rs

1//! `sheathe` command-line media packager (library entry point).
2//!
3//! A pure-Rust alternative to Shaka Packager's `packager` binary. [`run`] parses
4//! args and dispatches: `probe` lists an MP4's streams; `package` demuxes,
5//! fragments, and writes CMAF init + media segments plus DASH and HLS manifests.
6//! Both the `sheathe-cli` and `sheathe` binaries are thin wrappers over [`run`].
7
8mod 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/// Pure-Rust HLS/DASH/CMAF media packager.
27#[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    /// Suppress the startup banner.
36    #[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 one or more inputs into CMAF segments + DASH and/or HLS
46    /// manifests. Multiple inputs form an ABR ladder (one rendition each).
47    Package {
48        /// Input media file(s). Each becomes its own rendition(s).
49        #[arg(required = true, num_args = 1..)]
50        inputs: Vec<String>,
51        /// Output directory.
52        #[arg(short, long, default_value = "out")]
53        out: String,
54        /// Target segment duration in seconds.
55        #[arg(long, default_value_t = 6.0)]
56        segment_duration: f64,
57        /// Emit a DASH manifest (`manifest.mpd`).
58        #[arg(long)]
59        dash: bool,
60        /// Emit HLS playlists (`master.m3u8`).
61        #[arg(long)]
62        hls: bool,
63        /// Encrypt using a raw key, as `<KID hex>:<KEY hex>` (both 16 bytes /
64        /// 32 hex chars).
65        #[arg(long, value_name = "KID:KEY")]
66        enc_key: Option<String>,
67        /// Read the raw key from a file (a `<KID hex>:<KEY hex>` line; `#`
68        /// comments and blank lines ignored). Takes precedence over `--enc-key`
69        /// and keeps the key out of the process arguments.
70        #[arg(long, value_name = "PATH")]
71        enc_key_file: Option<String>,
72        /// Encryption scheme when `--enc-key` is set: `cenc` (AES-CTR),
73        /// `cens` (AES-CTR pattern), `cbc1` (AES-CBC) or `cbcs` (AES-CBC pattern).
74        #[arg(long, default_value = "cenc")]
75        enc_scheme: String,
76        /// Key-delivery URI written into the HLS `#EXT-X-KEY` tag when encrypting.
77        #[arg(long, default_value = "key.bin")]
78        enc_key_uri: String,
79        /// DRM systems to emit `pssh` boxes for (comma-separated): any of
80        /// `common`, `widevine`, `playready`.
81        #[arg(long, default_value = "common")]
82        protection_systems: String,
83        /// Enable key rotation with this crypto-period duration in seconds. Each
84        /// period uses a key derived from the base key; signalled per segment via
85        /// `seig` sample groups and per-period `pssh`.
86        #[arg(long, value_name = "SECONDS")]
87        crypto_period_duration: Option<f64>,
88    },
89    /// Probe an input and print the streams sheathe detects.
90    Probe {
91        /// Input media file.
92        input: String,
93    },
94}
95
96/// Parse CLI args and run the requested command.
97pub fn run() -> Result<()> {
98    // Clap handles `--help` / `--version` inside `parse()` and exits before
99    // returning, so the banner must be printed first.
100    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
140/// Read an input file and print the streams sheathe detects.
141fn 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
158/// A loaded input with pre-extracted tracks and samples.
159struct LoadedInput {
160    format: &'static str,
161    tracks: Vec<LoadedTrack>,
162}
163
164struct LoadedTrack {
165    track: Track,
166    samples: Vec<Sample>,
167}
168
169/// Detect MPEG-TS by 0x47 sync bytes at 188-byte intervals.
170fn 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
177/// Extract CEA-608 captions from an Annex B H.264/H.265 video track and append
178/// them as a WebVTT text track. A no-op when no captions are present.
179fn 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
297/// Demux, fragment, and write CMAF init + media segments plus DASH/HLS manifests
298/// for one or more inputs. Each input's track(s) become separate renditions
299/// sharing one manifest (an ABR ladder when several video inputs are given).
300/// Encryption-related CLI options, grouped so `cmd_package` stays tidy.
301struct EncryptionOpts<'a> {
302    /// `<KID hex>:<KEY hex>` raw key, or `None` for clear output.
303    key: Option<&'a str>,
304    /// Path to a file holding the raw key; takes precedence over `key`.
305    key_file: Option<&'a str>,
306    /// `cenc`, `cens`, `cbc1` or `cbcs`.
307    scheme: &'a str,
308    /// HLS `#EXT-X-KEY` delivery URI.
309    key_uri: &'a str,
310    /// Comma-separated DRM systems to emit `pssh` boxes for.
311    systems: &'a str,
312    /// Key-rotation crypto-period duration in seconds, or `None` for one key.
313    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    // The key file (if given) wins over an inline --enc-key.
327    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    // HLS `#EXT-X-KEY` signalling for encrypted output.
336    let hls_key = encryption.as_ref().map(|_| KeyInfo {
337        // HLS fMP4 maps the CBC schemes to SAMPLE-AES and the CTR schemes to
338        // SAMPLE-AES-CTR.
339        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; // global rendition index across all inputs/tracks
376
377    for input in &loaded {
378        for lt in &input.tracks {
379            let track = &lt.track;
380            let samples = &lt.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            // Init segment.
389            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            // Media segments.
394            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
477/// Parse a `<KID hex>:<KEY hex>` raw-key spec, scheme name, and DRM-system list
478/// into an [`Encryption`].
479fn 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    // A fixed, asset-wide constant IV for cbcs (cenc derives per-sample IVs and
500    // ignores this).
501    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
517/// Read a raw key from a file: the first `<KID hex>:<KEY hex>` line, ignoring
518/// blank lines and `#` comments.
519fn 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
529/// Parse a comma-separated DRM-system list (e.g. `common,widevine,playready`).
530fn 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
542/// Parse exactly 32 hex chars into a 16-byte array.
543fn 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
553/// One-line human description of a stream.
554fn 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}