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, 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#[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 },
63 Probe {
65 input: String,
67 },
68}
69
70pub 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
102fn 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
127fn 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 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; 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 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 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
271fn 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 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
296fn 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
307fn 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}