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, 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(name = "sheathe", version, about, long_about = None)]
25struct Cli {
26    /// Suppress the startup banner.
27    #[arg(long, global = true)]
28    no_banner: bool,
29
30    #[command(subcommand)]
31    command: Command,
32}
33
34#[derive(Debug, Subcommand)]
35enum Command {
36    /// Package one or more inputs into CMAF segments + DASH and/or HLS
37    /// manifests. Multiple inputs form an ABR ladder (one rendition each).
38    Package {
39        /// Input media file(s). Each becomes its own rendition(s).
40        #[arg(required = true, num_args = 1..)]
41        inputs: Vec<String>,
42        /// Output directory.
43        #[arg(short, long, default_value = "out")]
44        out: String,
45        /// Target segment duration in seconds.
46        #[arg(long, default_value_t = 6.0)]
47        segment_duration: f64,
48        /// Emit a DASH manifest (`manifest.mpd`).
49        #[arg(long)]
50        dash: bool,
51        /// Emit HLS playlists (`master.m3u8`).
52        #[arg(long)]
53        hls: bool,
54        /// Encrypt using a raw key, as `<KID hex>:<KEY hex>` (both 16 bytes /
55        /// 32 hex chars).
56        #[arg(long, value_name = "KID:KEY")]
57        enc_key: Option<String>,
58        /// Encryption scheme when `--enc-key` is set: `cenc` (AES-CTR) or
59        /// `cbcs` (AES-CBC pattern).
60        #[arg(long, default_value = "cenc")]
61        enc_scheme: String,
62        /// Key-delivery URI written into the HLS `#EXT-X-KEY` tag when encrypting.
63        #[arg(long, default_value = "key.bin")]
64        enc_key_uri: String,
65    },
66    /// Probe an input and print the streams sheathe detects.
67    Probe {
68        /// Input media file.
69        input: String,
70    },
71}
72
73/// Parse CLI args and run the requested command.
74pub fn run() -> Result<()> {
75    let cli = Cli::parse();
76
77    if !cli.no_banner {
78        banner::print();
79    }
80
81    match cli.command {
82        Command::Package {
83            inputs,
84            out,
85            segment_duration,
86            dash,
87            hls,
88            enc_key,
89            enc_scheme,
90            enc_key_uri,
91        } => cmd_package(
92            &inputs,
93            &out,
94            segment_duration,
95            dash,
96            hls,
97            EncryptionOpts { key: enc_key.as_deref(), scheme: &enc_scheme, key_uri: &enc_key_uri },
98        )?,
99        Command::Probe { input } => cmd_probe(&input)?,
100    }
101
102    Ok(())
103}
104
105/// Read an MP4 and print the streams sheathe detects.
106fn cmd_probe(input: &str) -> Result<()> {
107    let bytes = fs::read(input).with_context(|| format!("reading {input}"))?;
108    let demux = Mp4Demuxer::parse(&bytes).with_context(|| format!("parsing {input}"))?;
109
110    println!("probe: {input}  ({} bytes, {} track(s))", bytes.len(), demux.tracks().len());
111    for (i, track) in demux.tracks().iter().enumerate() {
112        println!("  [{}] track #{}  {}", i, track.track_id, describe(&track.info));
113        println!("       samples={}  timescale={}", track.sample_count, track.info.timescale.0);
114    }
115    Ok(())
116}
117
118/// Demux, fragment, and write CMAF init + media segments plus DASH/HLS manifests
119/// for one or more inputs. Each input's track(s) become separate renditions
120/// sharing one manifest (an ABR ladder when several video inputs are given).
121/// Encryption-related CLI options, grouped so `cmd_package` stays tidy.
122struct EncryptionOpts<'a> {
123    /// `<KID hex>:<KEY hex>` raw key, or `None` for clear output.
124    key: Option<&'a str>,
125    /// `cenc` or `cbcs`.
126    scheme: &'a str,
127    /// HLS `#EXT-X-KEY` delivery URI.
128    key_uri: &'a str,
129}
130
131fn cmd_package(
132    inputs: &[String],
133    out: &str,
134    segment_duration: f64,
135    dash: bool,
136    hls: bool,
137    enc: EncryptionOpts<'_>,
138) -> Result<()> {
139    let out_dir = Path::new(out);
140    fs::create_dir_all(out_dir).with_context(|| format!("creating {out}/"))?;
141    let encryption = enc.key.map(|k| parse_enc_key(k, enc.scheme)).transpose()?;
142
143    // HLS `#EXT-X-KEY` signalling for encrypted output.
144    let hls_key = encryption.as_ref().map(|_| KeyInfo {
145        method: match enc.scheme {
146            "cbcs" => "SAMPLE-AES",
147            _ => "SAMPLE-AES-CTR",
148        }
149        .to_string(),
150        key_format: "urn:mpeg:dash:mp4protection:2011".to_string(),
151        uri: enc.key_uri.to_string(),
152    });
153
154    // Read then parse all inputs (each demuxer borrows its byte buffer).
155    let datas: Vec<Vec<u8>> = inputs
156        .iter()
157        .map(|p| fs::read(p).with_context(|| format!("reading {p}")))
158        .collect::<Result<_>>()?;
159    let demuxers: Vec<Mp4Demuxer> = datas
160        .iter()
161        .zip(inputs)
162        .map(|(d, p)| Mp4Demuxer::parse(d).with_context(|| format!("parsing {p}")))
163        .collect::<Result<_>>()?;
164
165    println!("package: {} input(s) -> {out}/", inputs.len());
166    println!("  segment_duration = {segment_duration}s  (dash={dash}, hls={hls})");
167    if encryption.is_some() {
168        let alg = match enc.scheme {
169            "cbcs" => "cbcs (AES-128-CBC pattern)",
170            _ => "cenc (AES-128-CTR)",
171        };
172        println!("  encryption = {alg}");
173    }
174
175    let policy = SegmentPolicy { target_seconds: segment_duration, keyframes_only: true };
176    let mut dash_reps = Vec::new();
177    let mut hls_variants = Vec::new();
178    let mut total_seconds = 0.0_f64;
179    let mut rep = 0usize; // global rendition index across all inputs/tracks
180
181    for demux in &demuxers {
182        for ti in 0..demux.tracks().len() {
183            let track = &demux.tracks()[ti];
184            let samples = demux.samples(ti)?;
185            let mut frag = Fragmenter::new(track.info.clone(), policy);
186            for s in samples {
187                frag.push(s)?;
188            }
189            let segments = frag.finish();
190            let ts = track.info.timescale;
191
192            // Init segment.
193            let init_name = format!("init_{rep}.mp4");
194            fs::write(out_dir.join(&init_name), write_init_segment(track, encryption.as_ref()))
195                .with_context(|| format!("writing {init_name}"))?;
196
197            // Media segments.
198            let mut durations = Vec::with_capacity(segments.len());
199            let mut hls_segs = Vec::with_capacity(segments.len());
200            let mut sample_index = 0u64;
201            for (n, seg) in segments.iter().enumerate() {
202                let seg_name = format!("seg_{rep}_{}.m4s", n + 1);
203                let data = write_media_segment(
204                    track,
205                    (n + 1) as u32,
206                    seg,
207                    sample_index,
208                    encryption.as_ref(),
209                );
210                fs::write(out_dir.join(&seg_name), data)
211                    .with_context(|| format!("writing {seg_name}"))?;
212                sample_index += seg.samples.len() as u64;
213                durations.push(seg.duration_ticks);
214                hls_segs.push(SegmentRef {
215                    duration: Scaled::new(seg.duration_ticks, ts).seconds(),
216                    uri: seg_name,
217                });
218            }
219
220            let track_total: u64 = segments.iter().map(|s| s.duration_ticks).sum();
221            let track_seconds = Scaled::new(track_total, ts).seconds();
222            total_seconds = total_seconds.max(track_seconds);
223            println!(
224                "  [{}] {}  ->  {} + {} segment(s), {:.2}s",
225                rep,
226                describe(&track.info),
227                init_name,
228                segments.len(),
229                track_seconds,
230            );
231
232            dash_reps.push(Representation {
233                id: rep.to_string(),
234                stream: track.info.clone(),
235                init: init_name.clone(),
236                media: format!("seg_{rep}_$Number$.m4s"),
237                timescale: ts.0,
238                segment_durations: durations,
239            });
240
241            if hls {
242                let media_name = format!("media_{rep}.m3u8");
243                fs::write(
244                    out_dir.join(&media_name),
245                    media_playlist(&init_name, &hls_segs, hls_key.as_ref()),
246                )
247                .with_context(|| format!("writing {media_name}"))?;
248                hls_variants.push(Variant { stream: track.info.clone(), playlist_uri: media_name });
249            }
250
251            rep += 1;
252        }
253    }
254
255    if dash {
256        let protection = encryption.as_ref().map(|e| Protection {
257            scheme: match e.scheme {
258                Scheme::Cenc => "cenc",
259                Scheme::Cbcs => "cbcs",
260            }
261            .to_string(),
262            default_kid: e.key.kid,
263        });
264        let mpd =
265            Manifest { duration_seconds: total_seconds, representations: dash_reps, protection }
266                .to_xml();
267        fs::write(out_dir.join("manifest.mpd"), mpd).context("writing manifest.mpd")?;
268        println!("  wrote manifest.mpd");
269    }
270    if hls {
271        fs::write(out_dir.join("master.m3u8"), master_playlist(&hls_variants))
272            .context("writing master.m3u8")?;
273        println!("  wrote master.m3u8 (+ per-track media playlists)");
274    }
275
276    Ok(())
277}
278
279/// Parse a `<KID hex>:<KEY hex>` raw-key spec + scheme name into an [`Encryption`].
280fn parse_enc_key(spec: &str, scheme: &str) -> Result<Encryption> {
281    let (kid_hex, key_hex) =
282        spec.split_once(':').context("--enc-key must be <KID hex>:<KEY hex>")?;
283    let kid = parse_hex16(kid_hex).context("invalid KID")?;
284    let key = parse_hex16(key_hex).context("invalid KEY")?;
285    let scheme = match scheme {
286        "cenc" => Scheme::Cenc,
287        "cbcs" => Scheme::Cbcs,
288        other => anyhow::bail!("unknown --enc-scheme '{other}' (expected cenc or cbcs)"),
289    };
290    // A fixed, asset-wide constant IV for cbcs (cenc derives per-sample IVs and
291    // ignores this).
292    let constant_iv = [
293        0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
294        0xff,
295    ];
296    Ok(Encryption { scheme, key: ContentKey { kid, key }, constant_iv })
297}
298
299/// Parse exactly 32 hex chars into a 16-byte array.
300fn parse_hex16(s: &str) -> Result<[u8; 16]> {
301    let s = s.trim();
302    anyhow::ensure!(s.len() == 32, "expected 32 hex chars, got {}", s.len());
303    let mut out = [0u8; 16];
304    for (i, b) in out.iter_mut().enumerate() {
305        *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).context("non-hex digit")?;
306    }
307    Ok(out)
308}
309
310/// One-line human description of a stream.
311fn describe(info: &StreamInfo) -> String {
312    let kind = match info.kind {
313        MediaKind::Video => "video",
314        MediaKind::Audio => "audio",
315        MediaKind::Text => "text",
316    };
317    let mut s = format!("{kind} {}", info.rfc6381());
318    if let Some((w, h)) = info.resolution {
319        s.push_str(&format!(" {w}x{h}"));
320    }
321    if let Some(rate) = info.sample_rate {
322        s.push_str(&format!(" {rate}Hz"));
323    }
324    if let Some(br) = info.bitrate {
325        s.push_str(&format!(" ~{}kbps", br / 1000));
326    }
327    s
328}