1mod 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#[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 #[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 {
44 #[arg(required = true, num_args = 1..)]
46 inputs: Vec<String>,
47 #[arg(short, long, default_value = "out")]
49 out: String,
50 #[arg(long, default_value_t = 6.0)]
52 segment_duration: f64,
53 #[arg(long)]
55 dash: bool,
56 #[arg(long)]
58 hls: bool,
59 #[arg(long, value_name = "KID:KEY")]
62 enc_key: Option<String>,
63 #[arg(long, value_name = "PATH")]
67 enc_key_file: Option<String>,
68 #[arg(long, default_value = "cenc")]
71 enc_scheme: String,
72 #[arg(long, default_value = "key.bin")]
74 enc_key_uri: String,
75 #[arg(long, default_value = "common")]
78 protection_systems: String,
79 #[arg(long, value_name = "SECONDS")]
83 crypto_period_duration: Option<f64>,
84 },
85 Probe {
87 input: String,
89 },
90}
91
92pub 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
134fn 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
147struct EncryptionOpts<'a> {
152 key: Option<&'a str>,
154 key_file: Option<&'a str>,
156 scheme: &'a str,
158 key_uri: &'a str,
160 systems: &'a str,
162 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 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 let hls_key = encryption.as_ref().map(|_| KeyInfo {
187 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 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; 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 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 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
331fn 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 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
371fn 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
383fn 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
396fn 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
407fn 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}