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