1mod 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#[derive(Debug, Parser)]
24#[command(name = "sheathe", version, about, long_about = None)]
25struct Cli {
26 #[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 {
39 #[arg(required = true, num_args = 1..)]
41 inputs: Vec<String>,
42 #[arg(short, long, default_value = "out")]
44 out: String,
45 #[arg(long, default_value_t = 6.0)]
47 segment_duration: f64,
48 #[arg(long)]
50 dash: bool,
51 #[arg(long)]
53 hls: bool,
54 #[arg(long, value_name = "KID:KEY")]
57 enc_key: Option<String>,
58 #[arg(long, default_value = "cenc")]
61 enc_scheme: String,
62 #[arg(long, default_value = "key.bin")]
64 enc_key_uri: String,
65 },
66 Probe {
68 input: String,
70 },
71}
72
73pub 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
105fn 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
118struct EncryptionOpts<'a> {
123 key: Option<&'a str>,
125 scheme: &'a str,
127 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 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 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; 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 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 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
279fn 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 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
299fn 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
310fn 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}