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