1use anyhow::{Context, Result};
12use sheathe_core::Sample;
13use sheathe_core::{MediaKind, Scaled, StreamInfo};
14use sheathe_crypto::{ContentKey, Scheme};
15use sheathe_dash::{Manifest, Protection, Representation};
16use sheathe_es::{EsDemuxer, is_mp4};
17use sheathe_hls::{KeyInfo, SegmentRef, Variant, master_playlist, media_playlist};
18use sheathe_mp4::{
19 Encryption, Fragmenter, Mp4Demuxer, SegmentPolicy, Track, write_init_segment,
20 write_media_segment,
21};
22use sheathe_ts::{TsDemuxer, packet::PACKET_SIZE};
23use std::fs;
24use std::path::{Path, PathBuf};
25
26pub use sheathe_crypto::{ProtectionSystem as DrmSystem, Scheme as EncScheme};
27
28#[derive(Debug, Clone)]
30pub struct PackageOptions {
31 pub out_dir: PathBuf,
33 pub segment_duration: f64,
35 pub dash: bool,
37 pub hls: bool,
39 pub encryption: Option<EncryptionSpec>,
41}
42
43impl Default for PackageOptions {
44 fn default() -> Self {
45 Self {
46 out_dir: PathBuf::from("out"),
47 segment_duration: 6.0,
48 dash: true,
49 hls: true,
50 encryption: None,
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
57pub struct EncryptionSpec {
58 pub kid: [u8; 16],
60 pub key: [u8; 16],
62 pub scheme: EncScheme,
64 pub key_uri: String,
66 pub systems: Vec<DrmSystem>,
68 pub crypto_period_seconds: Option<f64>,
70}
71
72#[derive(Debug, Clone)]
74pub struct PackageOutput {
75 pub out_dir: PathBuf,
77 pub dash_manifest: Option<PathBuf>,
79 pub hls_master: Option<PathBuf>,
81 pub init_segments: Vec<PathBuf>,
83 pub media_segments: Vec<PathBuf>,
85 pub duration_seconds: f64,
87 pub renditions: usize,
89}
90
91pub fn package(inputs: &[PathBuf], opts: &PackageOptions) -> Result<PackageOutput> {
95 anyhow::ensure!(!inputs.is_empty(), "package: at least one input is required");
96
97 let out_dir = &opts.out_dir;
98 fs::create_dir_all(out_dir).with_context(|| format!("creating {}/", out_dir.display()))?;
99
100 let encryption: Option<Encryption> = opts.encryption.as_ref().map(build_encryption);
101
102 let hls_key = opts.encryption.as_ref().map(|spec| KeyInfo {
104 method: match spec.scheme {
107 Scheme::Cbcs | Scheme::Cbc1 => "SAMPLE-AES",
108 _ => "SAMPLE-AES-CTR",
109 }
110 .to_string(),
111 key_format: "urn:mpeg:dash:mp4protection:2011".to_string(),
112 uri: spec.key_uri.clone(),
113 });
114
115 let datas: Vec<Vec<u8>> = inputs
116 .iter()
117 .map(|p| fs::read(p).with_context(|| format!("reading {}", p.display())))
118 .collect::<Result<_>>()?;
119 let loaded: Vec<LoadedInput> = datas
120 .iter()
121 .zip(inputs)
122 .map(|(d, p)| load_input(&p.to_string_lossy(), d))
123 .collect::<Result<_>>()?;
124
125 let policy = SegmentPolicy { target_seconds: opts.segment_duration, keyframes_only: true };
126 let mut dash_reps = Vec::new();
127 let mut hls_variants = Vec::new();
128 let mut init_segments = Vec::new();
129 let mut media_segments = Vec::new();
130 let mut total_seconds = 0.0_f64;
131 let mut rep = 0usize; for input in &loaded {
134 for lt in &input.tracks {
135 let track = <.track;
136 let samples = <.samples;
137 let mut frag = Fragmenter::new(track.info.clone(), policy);
138 for s in samples.iter().cloned() {
139 frag.push(s)?;
140 }
141 let segments = frag.finish();
142 let ts = track.info.timescale;
143
144 let init_name = format!("init_{rep}.mp4");
146 fs::write(out_dir.join(&init_name), write_init_segment(track, encryption.as_ref()))
147 .with_context(|| format!("writing {init_name}"))?;
148 init_segments.push(out_dir.join(&init_name));
149
150 let mut durations = Vec::with_capacity(segments.len());
152 let mut hls_segs = Vec::with_capacity(segments.len());
153 let mut sample_index = 0u64;
154 for (n, seg) in segments.iter().enumerate() {
155 let seg_name = format!("seg_{rep}_{}.m4s", n + 1);
156 let data = write_media_segment(
157 track,
158 (n + 1) as u32,
159 seg,
160 sample_index,
161 encryption.as_ref(),
162 );
163 fs::write(out_dir.join(&seg_name), data)
164 .with_context(|| format!("writing {seg_name}"))?;
165 media_segments.push(out_dir.join(&seg_name));
166 sample_index += seg.samples.len() as u64;
167 durations.push(seg.duration_ticks);
168 hls_segs.push(SegmentRef {
169 duration: Scaled::new(seg.duration_ticks, ts).seconds(),
170 uri: seg_name,
171 });
172 }
173
174 let track_total: u64 = segments.iter().map(|s| s.duration_ticks).sum();
175 let track_seconds = Scaled::new(track_total, ts).seconds();
176 total_seconds = total_seconds.max(track_seconds);
177
178 dash_reps.push(Representation {
179 id: rep.to_string(),
180 stream: track.info.clone(),
181 init: init_name.clone(),
182 media: format!("seg_{rep}_$Number$.m4s"),
183 timescale: ts.0,
184 segment_durations: durations,
185 });
186
187 if opts.hls {
188 let media_name = format!("media_{rep}.m3u8");
189 fs::write(
190 out_dir.join(&media_name),
191 media_playlist(&init_name, &hls_segs, hls_key.as_ref()),
192 )
193 .with_context(|| format!("writing {media_name}"))?;
194 hls_variants.push(Variant { stream: track.info.clone(), playlist_uri: media_name });
195 }
196
197 rep += 1;
198 }
199 }
200
201 let mut dash_manifest = None;
202 if opts.dash {
203 let protection = opts.encryption.as_ref().map(|spec| Protection {
204 scheme: scheme_str(spec.scheme).to_string(),
205 default_kid: spec.kid,
206 });
207 let mpd =
208 Manifest { duration_seconds: total_seconds, representations: dash_reps, protection }
209 .to_xml();
210 let path = out_dir.join("manifest.mpd");
211 fs::write(&path, mpd).context("writing manifest.mpd")?;
212 dash_manifest = Some(path);
213 }
214
215 let mut hls_master = None;
216 if opts.hls {
217 let path = out_dir.join("master.m3u8");
218 fs::write(&path, master_playlist(&hls_variants)).context("writing master.m3u8")?;
219 hls_master = Some(path);
220 }
221
222 Ok(PackageOutput {
223 out_dir: out_dir.clone(),
224 dash_manifest,
225 hls_master,
226 init_segments,
227 media_segments,
228 duration_seconds: total_seconds,
229 renditions: rep,
230 })
231}
232
233pub fn scheme_str(scheme: Scheme) -> &'static str {
235 match scheme {
236 Scheme::Cenc => "cenc",
237 Scheme::Cens => "cens",
238 Scheme::Cbc1 => "cbc1",
239 Scheme::Cbcs => "cbcs",
240 }
241}
242
243fn build_encryption(spec: &EncryptionSpec) -> Encryption {
244 let constant_iv = [
247 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
248 0xff,
249 ];
250 Encryption {
251 scheme: spec.scheme,
252 key: ContentKey { kid: spec.kid, key: spec.key },
253 constant_iv,
254 systems: spec.systems.clone(),
255 crypto_period_seconds: spec.crypto_period_seconds,
256 }
257}
258
259#[derive(Debug, Clone)]
261pub struct ProbeStream {
262 pub track_id: u32,
264 pub info: StreamInfo,
266 pub sample_count: usize,
268}
269
270#[derive(Debug, Clone)]
272pub struct ProbeReport {
273 pub format: &'static str,
275 pub size_bytes: usize,
277 pub streams: Vec<ProbeStream>,
279}
280
281pub fn probe(input: &Path) -> Result<ProbeReport> {
284 let bytes = fs::read(input).with_context(|| format!("reading {}", input.display()))?;
285 let loaded = load_input(&input.to_string_lossy(), &bytes)?;
286 let streams = loaded
287 .tracks
288 .iter()
289 .map(|lt| ProbeStream {
290 track_id: lt.track.track_id,
291 info: lt.track.info.clone(),
292 sample_count: lt.samples.len(),
293 })
294 .collect();
295 Ok(ProbeReport { format: loaded.format, size_bytes: bytes.len(), streams })
296}
297
298struct LoadedInput {
300 format: &'static str,
301 tracks: Vec<LoadedTrack>,
302}
303
304struct LoadedTrack {
305 track: Track,
306 samples: Vec<Sample>,
307}
308
309fn is_transport_stream(data: &[u8]) -> bool {
311 if data.len() < PACKET_SIZE * 3 {
312 return false;
313 }
314 (0..3).all(|i| data[i * PACKET_SIZE] == 0x47)
315}
316
317fn append_captions(tracks: &mut Vec<LoadedTrack>) {
320 let Some(vid) = tracks.iter().find(|t| {
321 t.track.info.kind == MediaKind::Video
322 && matches!(t.track.info.codec, sheathe_core::Codec::H264 | sheathe_core::Codec::H265)
323 }) else {
324 return;
325 };
326 let hevc = vid.track.info.codec == sheathe_core::Codec::H265;
327 let samples: Vec<(u64, &[u8])> =
328 vid.samples.iter().map(|s| (s.pts, s.data.as_slice())).collect();
329 for text in sheathe_text::extract_captions(&samples, hevc) {
330 let id = tracks.len() as u32 + 1;
331 tracks.push(LoadedTrack {
332 track: Track::from_sample_entry(
333 text.info.clone(),
334 id,
335 text.sample_entry.clone(),
336 &text.samples,
337 ),
338 samples: text.samples.clone(),
339 });
340 }
341}
342
343fn load_input(path: &str, data: &[u8]) -> Result<LoadedInput> {
344 if is_transport_stream(data) {
345 let demux = TsDemuxer::parse(data).with_context(|| format!("parsing MPEG-TS {path}"))?;
346 let mut tracks: Vec<LoadedTrack> = demux
347 .tracks()
348 .iter()
349 .enumerate()
350 .map(|(i, t)| LoadedTrack {
351 track: Track::from_sample_entry(
352 t.info.clone(),
353 (i + 1) as u32,
354 t.sample_entry.clone(),
355 &t.samples,
356 ),
357 samples: t.samples.clone(),
358 })
359 .collect();
360 append_captions(&mut tracks);
361 return Ok(LoadedInput { format: "MPEG-TS", tracks });
362 }
363
364 if is_mp4(data) {
365 return load_mp4(path, data);
366 }
367
368 if sheathe_mkv::is_webm(data) {
369 let demux =
370 sheathe_mkv::MkvDemuxer::parse(data).with_context(|| format!("parsing WebM {path}"))?;
371 let tracks = demux
372 .tracks()
373 .iter()
374 .enumerate()
375 .map(|(i, t)| LoadedTrack {
376 track: Track::from_sample_entry(
377 t.info.clone(),
378 (i + 1) as u32,
379 t.sample_entry.clone(),
380 &t.samples,
381 ),
382 samples: t.samples.clone(),
383 })
384 .collect();
385 return Ok(LoadedInput { format: "WebM", tracks });
386 }
387
388 if sheathe_text::is_webvtt(path, data) {
389 let text = std::str::from_utf8(data)
390 .with_context(|| format!("WebVTT {path} is not valid UTF-8"))?;
391 let t = sheathe_text::webvtt(text).with_context(|| format!("parsing WebVTT {path}"))?;
392 let tracks = vec![LoadedTrack {
393 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
394 samples: t.samples.clone(),
395 }];
396 return Ok(LoadedInput { format: "WebVTT", tracks });
397 }
398
399 if sheathe_text::is_ttml(data) {
400 let text =
401 std::str::from_utf8(data).with_context(|| format!("TTML {path} is not valid UTF-8"))?;
402 let t = sheathe_text::ttml(text).with_context(|| format!("parsing TTML {path}"))?;
403 let tracks = vec![LoadedTrack {
404 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
405 samples: t.samples.clone(),
406 }];
407 return Ok(LoadedInput { format: "TTML", tracks });
408 }
409
410 if sheathe_es::detect(path, data).is_some() {
411 let demux = EsDemuxer::parse_auto(path, data)
412 .with_context(|| format!("parsing elementary stream {path}"))?;
413 let t = demux.track();
414 let mut tracks = vec![LoadedTrack {
415 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
416 samples: t.samples.clone(),
417 }];
418 append_captions(&mut tracks);
419 return Ok(LoadedInput { format: "elementary", tracks });
420 }
421
422 load_mp4(path, data)
423}
424
425fn load_mp4(path: &str, data: &[u8]) -> Result<LoadedInput> {
426 let demux = Mp4Demuxer::parse(data).with_context(|| format!("parsing MP4 {path}"))?;
427 let mut tracks = Vec::new();
428 for (i, t) in demux.tracks().iter().enumerate() {
429 tracks.push(LoadedTrack {
430 track: t.clone(),
431 samples: demux.samples(i).with_context(|| format!("reading samples for track {i}"))?,
432 });
433 }
434 Ok(LoadedInput { format: "MP4", tracks })
435}
436
437pub fn describe(info: &StreamInfo) -> String {
439 let kind = match info.kind {
440 MediaKind::Video => "video",
441 MediaKind::Audio => "audio",
442 MediaKind::Text => "text",
443 };
444 let mut s = format!("{kind} {}", info.rfc6381());
445 if let Some((w, h)) = info.resolution {
446 s.push_str(&format!(" {w}x{h}"));
447 }
448 if let Some(rate) = info.sample_rate {
449 s.push_str(&format!(" {rate}Hz"));
450 }
451 if let Some(br) = info.bitrate {
452 s.push_str(&format!(" ~{}kbps", br / 1000));
453 }
454 s
455}