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::{MediaKind, Scaled, StreamInfo};
13use sheathe_crypto::{ContentKey, ProtectionSystem, Scheme};
14use sheathe_dash::{Manifest, Protection, Representation};
15use sheathe_hls::{KeyInfo, SegmentRef, Variant, master_playlist, media_playlist};
16use sheathe_mp4::{
17    Encryption, Fragmenter, Mp4Demuxer, SegmentPolicy, write_init_segment, write_media_segment,
18};
19use std::fs;
20use std::path::Path;
21
22/// Pure-Rust HLS/DASH/CMAF media packager.
23#[derive(Debug, Parser)]
24#[command(
25    name = "sheathe",
26    version,
27    about = "Pure-Rust HLS/DASH/CMAF media packager",
28    long_about = None
29)]
30struct Cli {
31    /// Suppress the startup banner.
32    #[arg(long, global = true)]
33    no_banner: bool,
34
35    #[command(subcommand)]
36    command: Command,
37}
38
39#[derive(Debug, Subcommand)]
40enum Command {
41    /// Package one or more inputs into CMAF segments + DASH and/or HLS
42    /// manifests. Multiple inputs form an ABR ladder (one rendition each).
43    Package {
44        /// Input media file(s). Each becomes its own rendition(s).
45        #[arg(required = true, num_args = 1..)]
46        inputs: Vec<String>,
47        /// Output directory.
48        #[arg(short, long, default_value = "out")]
49        out: String,
50        /// Target segment duration in seconds.
51        #[arg(long, default_value_t = 6.0)]
52        segment_duration: f64,
53        /// Emit a DASH manifest (`manifest.mpd`).
54        #[arg(long)]
55        dash: bool,
56        /// Emit HLS playlists (`master.m3u8`).
57        #[arg(long)]
58        hls: bool,
59        /// Encrypt using a raw key, as `<KID hex>:<KEY hex>` (both 16 bytes /
60        /// 32 hex chars).
61        #[arg(long, value_name = "KID:KEY")]
62        enc_key: Option<String>,
63        /// Read the raw key from a file (a `<KID hex>:<KEY hex>` line; `#`
64        /// comments and blank lines ignored). Takes precedence over `--enc-key`
65        /// and keeps the key out of the process arguments.
66        #[arg(long, value_name = "PATH")]
67        enc_key_file: Option<String>,
68        /// Encryption scheme when `--enc-key` is set: `cenc` (AES-CTR),
69        /// `cens` (AES-CTR pattern), `cbc1` (AES-CBC) or `cbcs` (AES-CBC pattern).
70        #[arg(long, default_value = "cenc")]
71        enc_scheme: String,
72        /// Key-delivery URI written into the HLS `#EXT-X-KEY` tag when encrypting.
73        #[arg(long, default_value = "key.bin")]
74        enc_key_uri: String,
75        /// DRM systems to emit `pssh` boxes for (comma-separated): any of
76        /// `common`, `widevine`, `playready`.
77        #[arg(long, default_value = "common")]
78        protection_systems: String,
79        /// Enable key rotation with this crypto-period duration in seconds. Each
80        /// period uses a key derived from the base key; signalled per segment via
81        /// `seig` sample groups and per-period `pssh`.
82        #[arg(long, value_name = "SECONDS")]
83        crypto_period_duration: Option<f64>,
84    },
85    /// Probe an input and print the streams sheathe detects.
86    Probe {
87        /// Input media file.
88        input: String,
89    },
90}
91
92/// Parse CLI args and run the requested command.
93pub fn run() -> Result<()> {
94    let cli = Cli::parse();
95
96    if !cli.no_banner {
97        banner::print();
98    }
99
100    match cli.command {
101        Command::Package {
102            inputs,
103            out,
104            segment_duration,
105            dash,
106            hls,
107            enc_key,
108            enc_key_file,
109            enc_scheme,
110            enc_key_uri,
111            protection_systems,
112            crypto_period_duration,
113        } => cmd_package(
114            &inputs,
115            &out,
116            segment_duration,
117            dash,
118            hls,
119            EncryptionOpts {
120                key: enc_key.as_deref(),
121                key_file: enc_key_file.as_deref(),
122                scheme: &enc_scheme,
123                key_uri: &enc_key_uri,
124                systems: &protection_systems,
125                crypto_period: crypto_period_duration,
126            },
127        )?,
128        Command::Probe { input } => cmd_probe(&input)?,
129    }
130
131    Ok(())
132}
133
134/// Read an MP4 and print the streams sheathe detects.
135fn cmd_probe(input: &str) -> Result<()> {
136    let bytes = fs::read(input).with_context(|| format!("reading {input}"))?;
137    let demux = Mp4Demuxer::parse(&bytes).with_context(|| format!("parsing {input}"))?;
138
139    println!("probe: {input}  ({} bytes, {} track(s))", bytes.len(), demux.tracks().len());
140    for (i, track) in demux.tracks().iter().enumerate() {
141        println!("  [{}] track #{}  {}", i, track.track_id, describe(&track.info));
142        println!("       samples={}  timescale={}", track.sample_count, track.info.timescale.0);
143    }
144    Ok(())
145}
146
147/// Demux, fragment, and write CMAF init + media segments plus DASH/HLS manifests
148/// for one or more inputs. Each input's track(s) become separate renditions
149/// sharing one manifest (an ABR ladder when several video inputs are given).
150/// Encryption-related CLI options, grouped so `cmd_package` stays tidy.
151struct EncryptionOpts<'a> {
152    /// `<KID hex>:<KEY hex>` raw key, or `None` for clear output.
153    key: Option<&'a str>,
154    /// Path to a file holding the raw key; takes precedence over `key`.
155    key_file: Option<&'a str>,
156    /// `cenc`, `cens`, `cbc1` or `cbcs`.
157    scheme: &'a str,
158    /// HLS `#EXT-X-KEY` delivery URI.
159    key_uri: &'a str,
160    /// Comma-separated DRM systems to emit `pssh` boxes for.
161    systems: &'a str,
162    /// Key-rotation crypto-period duration in seconds, or `None` for one key.
163    crypto_period: Option<f64>,
164}
165
166fn cmd_package(
167    inputs: &[String],
168    out: &str,
169    segment_duration: f64,
170    dash: bool,
171    hls: bool,
172    enc: EncryptionOpts<'_>,
173) -> Result<()> {
174    let out_dir = Path::new(out);
175    fs::create_dir_all(out_dir).with_context(|| format!("creating {out}/"))?;
176    // The key file (if given) wins over an inline --enc-key.
177    let key_spec = match enc.key_file {
178        Some(path) => Some(read_key_file(path)?),
179        None => enc.key.map(str::to_string),
180    };
181    let encryption = key_spec
182        .map(|k| parse_enc_key(&k, enc.scheme, enc.systems, enc.crypto_period))
183        .transpose()?;
184
185    // HLS `#EXT-X-KEY` signalling for encrypted output.
186    let hls_key = encryption.as_ref().map(|_| KeyInfo {
187        // HLS fMP4 maps the CBC schemes to SAMPLE-AES and the CTR schemes to
188        // SAMPLE-AES-CTR.
189        method: match enc.scheme {
190            "cbcs" | "cbc1" => "SAMPLE-AES",
191            _ => "SAMPLE-AES-CTR",
192        }
193        .to_string(),
194        key_format: "urn:mpeg:dash:mp4protection:2011".to_string(),
195        uri: enc.key_uri.to_string(),
196    });
197
198    // Read then parse all inputs (each demuxer borrows its byte buffer).
199    let datas: Vec<Vec<u8>> = inputs
200        .iter()
201        .map(|p| fs::read(p).with_context(|| format!("reading {p}")))
202        .collect::<Result<_>>()?;
203    let demuxers: Vec<Mp4Demuxer> = datas
204        .iter()
205        .zip(inputs)
206        .map(|(d, p)| Mp4Demuxer::parse(d).with_context(|| format!("parsing {p}")))
207        .collect::<Result<_>>()?;
208
209    println!("package: {} input(s) -> {out}/", inputs.len());
210    println!("  segment_duration = {segment_duration}s  (dash={dash}, hls={hls})");
211    if encryption.is_some() {
212        let alg = match enc.scheme {
213            "cens" => "cens (AES-128-CTR pattern)",
214            "cbc1" => "cbc1 (AES-128-CBC)",
215            "cbcs" => "cbcs (AES-128-CBC pattern)",
216            _ => "cenc (AES-128-CTR)",
217        };
218        println!("  encryption = {alg}");
219        println!("  protection_systems = {}", enc.systems);
220        if let Some(p) = enc.crypto_period {
221            println!("  key_rotation = every {p}s (crypto period)");
222        }
223    }
224
225    let policy = SegmentPolicy { target_seconds: segment_duration, keyframes_only: true };
226    let mut dash_reps = Vec::new();
227    let mut hls_variants = Vec::new();
228    let mut total_seconds = 0.0_f64;
229    let mut rep = 0usize; // global rendition index across all inputs/tracks
230
231    for demux in &demuxers {
232        for ti in 0..demux.tracks().len() {
233            let track = &demux.tracks()[ti];
234            let samples = demux.samples(ti)?;
235            let mut frag = Fragmenter::new(track.info.clone(), policy);
236            for s in samples {
237                frag.push(s)?;
238            }
239            let segments = frag.finish();
240            let ts = track.info.timescale;
241
242            // Init segment.
243            let init_name = format!("init_{rep}.mp4");
244            fs::write(out_dir.join(&init_name), write_init_segment(track, encryption.as_ref()))
245                .with_context(|| format!("writing {init_name}"))?;
246
247            // Media segments.
248            let mut durations = Vec::with_capacity(segments.len());
249            let mut hls_segs = Vec::with_capacity(segments.len());
250            let mut sample_index = 0u64;
251            for (n, seg) in segments.iter().enumerate() {
252                let seg_name = format!("seg_{rep}_{}.m4s", n + 1);
253                let data = write_media_segment(
254                    track,
255                    (n + 1) as u32,
256                    seg,
257                    sample_index,
258                    encryption.as_ref(),
259                );
260                fs::write(out_dir.join(&seg_name), data)
261                    .with_context(|| format!("writing {seg_name}"))?;
262                sample_index += seg.samples.len() as u64;
263                durations.push(seg.duration_ticks);
264                hls_segs.push(SegmentRef {
265                    duration: Scaled::new(seg.duration_ticks, ts).seconds(),
266                    uri: seg_name,
267                });
268            }
269
270            let track_total: u64 = segments.iter().map(|s| s.duration_ticks).sum();
271            let track_seconds = Scaled::new(track_total, ts).seconds();
272            total_seconds = total_seconds.max(track_seconds);
273            println!(
274                "  [{}] {}  ->  {} + {} segment(s), {:.2}s",
275                rep,
276                describe(&track.info),
277                init_name,
278                segments.len(),
279                track_seconds,
280            );
281
282            dash_reps.push(Representation {
283                id: rep.to_string(),
284                stream: track.info.clone(),
285                init: init_name.clone(),
286                media: format!("seg_{rep}_$Number$.m4s"),
287                timescale: ts.0,
288                segment_durations: durations,
289            });
290
291            if hls {
292                let media_name = format!("media_{rep}.m3u8");
293                fs::write(
294                    out_dir.join(&media_name),
295                    media_playlist(&init_name, &hls_segs, hls_key.as_ref()),
296                )
297                .with_context(|| format!("writing {media_name}"))?;
298                hls_variants.push(Variant { stream: track.info.clone(), playlist_uri: media_name });
299            }
300
301            rep += 1;
302        }
303    }
304
305    if dash {
306        let protection = encryption.as_ref().map(|e| Protection {
307            scheme: match e.scheme {
308                Scheme::Cenc => "cenc",
309                Scheme::Cens => "cens",
310                Scheme::Cbc1 => "cbc1",
311                Scheme::Cbcs => "cbcs",
312            }
313            .to_string(),
314            default_kid: e.key.kid,
315        });
316        let mpd =
317            Manifest { duration_seconds: total_seconds, representations: dash_reps, protection }
318                .to_xml();
319        fs::write(out_dir.join("manifest.mpd"), mpd).context("writing manifest.mpd")?;
320        println!("  wrote manifest.mpd");
321    }
322    if hls {
323        fs::write(out_dir.join("master.m3u8"), master_playlist(&hls_variants))
324            .context("writing master.m3u8")?;
325        println!("  wrote master.m3u8 (+ per-track media playlists)");
326    }
327
328    Ok(())
329}
330
331/// Parse a `<KID hex>:<KEY hex>` raw-key spec, scheme name, and DRM-system list
332/// into an [`Encryption`].
333fn parse_enc_key(
334    spec: &str,
335    scheme: &str,
336    systems: &str,
337    crypto_period: Option<f64>,
338) -> Result<Encryption> {
339    let (kid_hex, key_hex) =
340        spec.split_once(':').context("--enc-key must be <KID hex>:<KEY hex>")?;
341    let kid = parse_hex16(kid_hex).context("invalid KID")?;
342    let key = parse_hex16(key_hex).context("invalid KEY")?;
343    let scheme = match scheme {
344        "cenc" => Scheme::Cenc,
345        "cens" => Scheme::Cens,
346        "cbc1" => Scheme::Cbc1,
347        "cbcs" => Scheme::Cbcs,
348        other => {
349            anyhow::bail!("unknown --enc-scheme '{other}' (expected cenc, cens, cbc1 or cbcs)")
350        }
351    };
352    let systems = parse_protection_systems(systems)?;
353    // A fixed, asset-wide constant IV for cbcs (cenc derives per-sample IVs and
354    // ignores this).
355    let constant_iv = [
356        0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
357        0xff,
358    ];
359    if let Some(p) = crypto_period {
360        anyhow::ensure!(p > 0.0, "--crypto-period-duration must be positive");
361    }
362    Ok(Encryption {
363        scheme,
364        key: ContentKey { kid, key },
365        constant_iv,
366        systems,
367        crypto_period_seconds: crypto_period,
368    })
369}
370
371/// Read a raw key from a file: the first `<KID hex>:<KEY hex>` line, ignoring
372/// blank lines and `#` comments.
373fn read_key_file(path: &str) -> Result<String> {
374    let content = fs::read_to_string(path).with_context(|| format!("reading key file {path}"))?;
375    content
376        .lines()
377        .map(|line| line.split('#').next().unwrap_or("").trim())
378        .find(|line| line.contains(':'))
379        .map(str::to_string)
380        .with_context(|| format!("no <KID hex>:<KEY hex> entry in key file {path}"))
381}
382
383/// Parse a comma-separated DRM-system list (e.g. `common,widevine,playready`).
384fn parse_protection_systems(list: &str) -> Result<Vec<ProtectionSystem>> {
385    list.split(',')
386        .map(str::trim)
387        .filter(|s| !s.is_empty())
388        .map(|name| {
389            ProtectionSystem::parse(name).with_context(|| {
390                format!("unknown protection system '{name}' (expected common, widevine, playready)")
391            })
392        })
393        .collect()
394}
395
396/// Parse exactly 32 hex chars into a 16-byte array.
397fn parse_hex16(s: &str) -> Result<[u8; 16]> {
398    let s = s.trim();
399    anyhow::ensure!(s.len() == 32, "expected 32 hex chars, got {}", s.len());
400    let mut out = [0u8; 16];
401    for (i, b) in out.iter_mut().enumerate() {
402        *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).context("non-hex digit")?;
403    }
404    Ok(out)
405}
406
407/// One-line human description of a stream.
408fn describe(info: &StreamInfo) -> String {
409    let kind = match info.kind {
410        MediaKind::Video => "video",
411        MediaKind::Audio => "audio",
412        MediaKind::Text => "text",
413    };
414    let mut s = format!("{kind} {}", info.rfc6381());
415    if let Some((w, h)) = info.resolution {
416        s.push_str(&format!(" {w}x{h}"));
417    }
418    if let Some(rate) = info.sample_rate {
419        s.push_str(&format!(" {rate}Hz"));
420    }
421    if let Some(br) = info.bitrate {
422        s.push_str(&format!(" ~{}kbps", br / 1000));
423    }
424    s
425}