1mod cpix;
20pub mod descriptor;
21pub mod io;
22pub mod knobs;
23pub mod origin;
24mod scte35;
25mod tls;
26mod widevine;
27
28use anyhow::{Context, Result};
29use sheathe_core::Sample;
30use sheathe_core::{MediaKind, Scaled, StreamInfo};
31use sheathe_crypto::{ContentKey, Scheme};
32use sheathe_dash::{
33 DashEvent, DashProfile, EventStream, Manifest, MpdType, Period, Protection, Representation,
34 SegmentBaseInfo, UtcTiming,
35};
36use sheathe_es::{EsDemuxer, is_mp4};
37use sheathe_hls::{
38 DateRange, KeyInfo, MasterOptions, MediaPlaylist, PartialSegment, SegmentRef, Variant,
39 iframe_playlist, master_playlist_with, packed_audio_playlist, ts_media_playlist,
40};
41use sheathe_mp4::{
42 Encryption, Fragmenter, Mp4Demuxer, MuxOptions, Segment, SegmentPolicy, Track, decrypt_samples,
43 write_init_segment, write_init_segment_opts, write_media_segment,
44};
45use sheathe_ts::{MuxTrack, PID_AUDIO, PID_VIDEO, TsDemuxer, mux_segment, packet::PACKET_SIZE};
46use std::fs;
47use std::path::{Path, PathBuf};
48use std::time::{SystemTime, UNIX_EPOCH};
49
50pub use descriptor::{StreamDescriptor, StreamSelector, parse_descriptor, parse_input_arg};
51pub use io::{FileSink, HttpPushSink, ObjectSink, read_input};
52pub use knobs::{ClosedCaption, CpixConfig, LabeledKey, PackageKnobs, WidevineConfig};
53pub use origin::{OriginConfig, serve as serve_origin};
54pub use scte35::{Scte35Marker, build_splice_insert, to_base64, to_hex_0x};
55pub use sheathe_crypto::{ProtectionSystem as DrmSystem, Scheme as EncScheme};
56pub use tls::HttpOptions;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum SegmentFormat {
61 #[default]
63 Cmaf,
64 MpegTs,
66 PackedAudio,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum PresentationMode {
73 #[default]
75 Vod,
76 Event,
78 Live,
80}
81
82#[derive(Debug, Clone)]
84pub struct PackageOptions {
85 pub out_dir: PathBuf,
87 pub segment_duration: f64,
89 pub dash: bool,
91 pub hls: bool,
93 pub encryption: Option<EncryptionSpec>,
95 pub presentation: PresentationMode,
97 pub live_window_segments: Option<usize>,
100 pub multi_period: bool,
103 pub trick_play: bool,
105 pub low_latency: bool,
107 pub part_duration: Option<f64>,
109 pub availability_start_time: Option<String>,
111 pub scte35_markers: Vec<Scte35Marker>,
113 pub segment_format: SegmentFormat,
115 pub on_demand: bool,
117 pub parallel: bool,
119 pub http_push_url: Option<String>,
121 pub knobs: PackageKnobs,
123}
124
125impl Default for PackageOptions {
126 fn default() -> Self {
127 Self {
128 out_dir: PathBuf::from("out"),
129 segment_duration: 6.0,
130 dash: true,
131 hls: true,
132 encryption: None,
133 presentation: PresentationMode::Vod,
134 live_window_segments: None,
135 multi_period: false,
136 trick_play: false,
137 low_latency: false,
138 part_duration: None,
139 availability_start_time: None,
140 scte35_markers: Vec::new(),
141 segment_format: SegmentFormat::Cmaf,
142 on_demand: false,
143 parallel: false,
144 http_push_url: None,
145 knobs: PackageKnobs::default(),
146 }
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct EncryptionSpec {
153 pub kid: [u8; 16],
155 pub key: [u8; 16],
157 pub scheme: EncScheme,
159 pub key_uri: String,
161 pub systems: Vec<DrmSystem>,
163 pub crypto_period_seconds: Option<f64>,
165 pub hls_aes128: bool,
167 pub crypt_byte_block: u8,
169 pub skip_byte_block: u8,
170 pub playready_extra: Option<String>,
172}
173
174#[derive(Debug, Clone)]
176pub struct PackageOutput {
177 pub out_dir: PathBuf,
179 pub dash_manifest: Option<PathBuf>,
181 pub hls_master: Option<PathBuf>,
183 pub init_segments: Vec<PathBuf>,
185 pub media_segments: Vec<PathBuf>,
187 pub duration_seconds: f64,
189 pub renditions: usize,
191}
192
193struct PackagedRendition {
195 stream: StreamInfo,
196 init_name: String,
197 media_template: String,
198 timescale: u32,
199 all_durations: Vec<u64>,
201 all_hls_segs: Vec<SegmentRef>,
203 period_index: usize,
205 trick: Option<TrickRendition>,
207 on_demand_file: Option<OnDemandFile>,
209 presentation: StreamDescriptor,
211}
212
213struct OnDemandFile {
214 file_name: String,
215 init_range: (u64, u64),
216 media_ranges: Vec<(u64, u64)>,
217}
218
219struct TrickRendition {
220 init_name: String,
221 media_template: String,
222 all_durations: Vec<u64>,
223 all_hls_segs: Vec<SegmentRef>,
224}
225
226pub fn package(inputs: &[PathBuf], opts: &PackageOptions) -> Result<PackageOutput> {
229 anyhow::ensure!(!inputs.is_empty(), "package: at least one input is required");
230 if opts.low_latency {
231 anyhow::ensure!(
232 opts.part_duration.unwrap_or(1.0) > 0.0,
233 "package: part_duration must be positive when low_latency is set"
234 );
235 }
236
237 let out_dir = &opts.out_dir;
238 fs::create_dir_all(out_dir).with_context(|| format!("creating {}/", out_dir.display()))?;
239
240 let http_opts = crate::tls::HttpOptions {
241 user_agent: opts.knobs.user_agent.clone(),
242 ca_file: opts.knobs.ca_file.clone(),
243 client_cert_file: opts.knobs.client_cert_file.clone(),
244 client_cert_key_file: opts.knobs.client_cert_key_file.clone(),
245 client_cert_key_password: opts.knobs.client_cert_key_password.clone(),
246 disable_peer_verification: opts.knobs.disable_peer_verification,
247 ignore_failures: opts.knobs.ignore_http_output_failures,
248 };
249 let mut fetched_keys = opts.knobs.labeled_keys.clone();
250 if let Some(wv) = &opts.knobs.widevine {
251 let scheme = opts.encryption.as_ref().map(|e| scheme_str(e.scheme)).unwrap_or("cenc");
252 fetched_keys.extend(widevine::fetch_keys(wv, &http_opts, scheme)?);
253 }
254 if let Some(c) = &opts.knobs.cpix {
255 for k in cpix::load_cpix(c, &http_opts)? {
256 fetched_keys.push(k.as_labeled());
257 }
258 }
259 let encryption: Option<Encryption> = opts
260 .encryption
261 .as_ref()
262 .and_then(|spec| if spec.hls_aes128 { None } else { Some(build_encryption(spec)) })
263 .or_else(|| {
264 fetched_keys.first().map(|k| {
265 build_encryption(&EncryptionSpec {
266 kid: k.kid,
267 key: k.key,
268 scheme: EncScheme::Cenc,
269 key_uri: "key.bin".into(),
270 systems: vec![DrmSystem::Widevine],
271 crypto_period_seconds: None,
272 hls_aes128: false,
273 crypt_byte_block: opts.knobs.crypt_byte_block,
274 skip_byte_block: opts.knobs.skip_byte_block,
275 playready_extra: opts.knobs.playready_extra_header_data.clone(),
276 })
277 })
278 });
279
280 let hls_key = opts.encryption.as_ref().map(|spec| {
281 if spec.hls_aes128 {
282 KeyInfo {
283 method: "AES-128".into(),
284 key_format: "identity".into(),
285 uri: spec.key_uri.clone(),
286 iv: None,
287 }
288 } else {
289 KeyInfo {
290 method: match spec.scheme {
291 Scheme::Cbcs | Scheme::Cbc1 => "SAMPLE-AES",
292 _ => "SAMPLE-AES-CTR",
293 }
294 .to_string(),
295 key_format: "urn:mpeg:dash:mp4protection:2011".to_string(),
296 uri: spec.key_uri.clone(),
297 iv: None,
298 }
299 }
300 });
301
302 let descriptors: Vec<StreamDescriptor> = if opts.knobs.descriptors.is_empty() {
303 inputs.iter().map(StreamDescriptor::from_path).collect()
304 } else {
305 opts.knobs.descriptors.clone()
306 };
307 let load_paths: Vec<PathBuf> = {
308 let mut v = Vec::new();
309 for d in &descriptors {
310 if !v.iter().any(|p: &PathBuf| p == &d.input) {
311 v.push(d.input.clone());
312 }
313 }
314 v
315 };
316 let datas: Vec<Vec<u8>> = load_paths
317 .iter()
318 .map(|p| fs::read(p).with_context(|| format!("reading {}", p.display())))
319 .collect::<Result<_>>()?;
320 let mut loaded: Vec<LoadedInput> = datas
321 .iter()
322 .zip(&load_paths)
323 .map(|(d, p)| load_input(&p.to_string_lossy(), d))
324 .collect::<Result<_>>()?;
325
326 if opts.knobs.decrypt_input {
327 if let Some(spec) = &opts.encryption {
328 let key = sheathe_crypto::ContentKey { kid: spec.kid, key: spec.key };
329 for (loaded_input, path) in loaded.iter_mut().zip(&load_paths) {
330 let bytes = fs::read(path).ok();
331 if let Some(bytes) = bytes {
332 for lt in &mut loaded_input.tracks {
333 let _ = decrypt_samples(&bytes, <.track, &mut lt.samples, &key);
334 }
335 }
336 }
337 }
338 }
339
340 let policy = SegmentPolicy { target_seconds: opts.segment_duration, keyframes_only: true };
341 let part_dur = opts.part_duration.unwrap_or(1.0);
342
343 let mut renditions = Vec::new();
344 let mut init_segments = Vec::new();
345 let mut media_segments = Vec::new();
346 let mut total_seconds = 0.0_f64;
347 let mut rep = 0usize;
348 let mut period_durations: Vec<f64> = Vec::new();
350
351 let mut work: Vec<(usize, usize, StreamDescriptor)> = Vec::new();
353 for desc in &descriptors {
354 let Some(ii) = load_paths.iter().position(|p| p == &desc.input) else { continue };
355 let input = &loaded[ii];
356 let mut matched = false;
357 for ti in 0..input.tracks.len() {
358 let kind = input.tracks[ti].track.info.kind;
359 if desc.matches(ti, kind) {
360 work.push((ii, ti, desc.clone()));
361 matched = true;
362 if !matches!(desc.selector, StreamSelector::All) {
363 break;
364 }
365 }
366 }
367 anyhow::ensure!(matched, "no track matched descriptor for {}", desc.input.display());
368 }
369
370 let package_one = |input_idx: usize,
371 track_idx: usize,
372 desc: StreamDescriptor,
373 rep: usize|
374 -> Result<PackagedRendition> {
375 let input = &loaded[input_idx];
376 let lt = &input.tracks[track_idx];
377 let mut track = lt.track.clone();
378 if let Some(lang) = &desc.language {
379 track.info.language = Some(lang.clone());
380 }
381 if let Some(bw) = desc.bandwidth {
382 track.info.bitrate = Some(bw);
383 }
384 if opts.knobs.use_legacy_vp9_codec_string
385 && matches!(track.info.codec, sheathe_core::Codec::Vp9)
386 {
387 track.info.codec_string = Some("vp9".into());
388 }
389 let samples = <.samples;
390 let track = &track;
391 let period_index = if opts.multi_period { input_idx } else { 0 };
392 let mut frag = Fragmenter::new(track.info.clone(), policy);
393 for s in samples.iter().cloned() {
394 frag.push(s)?;
395 }
396 let segments = frag.finish();
397 let ts = track.info.timescale;
398
399 let enc = if desc.skip_encryption { None } else { encryption.as_ref() };
400 match opts.segment_format {
401 SegmentFormat::Cmaf => package_cmaf_rendition(
402 out_dir,
403 track,
404 samples,
405 &segments,
406 rep,
407 period_index,
408 enc,
409 opts,
410 part_dur,
411 desc.trick_play_factor.unwrap_or(1),
412 ),
413 SegmentFormat::MpegTs => {
414 package_ts_rendition(out_dir, track, samples, &segments, rep, period_index)
415 }
416 SegmentFormat::PackedAudio => {
417 package_packed_audio(out_dir, track, samples, &segments, rep, period_index)
418 }
419 }
420 .map(|mut r| {
421 let track_total: u64 = r.all_durations.iter().sum();
422 let _ = Scaled::new(track_total, ts).seconds(); r.period_index = period_index;
424 r.presentation = desc;
425 r
426 })
427 };
428
429 if opts.parallel && work.len() > 1 {
430 use std::sync::Mutex;
431 let results = Mutex::new(Vec::new());
432 let errors = Mutex::new(Vec::new());
433 std::thread::scope(|scope| {
434 for (i, (ii, ti, desc)) in work.iter().enumerate() {
435 let results = &results;
436 let errors = &errors;
437 let desc = desc.clone();
438 scope.spawn(move || match package_one(*ii, *ti, desc, i) {
439 Ok(r) => results.lock().unwrap().push((i, r)),
440 Err(e) => errors.lock().unwrap().push(format!("{e:#}")),
441 });
442 }
443 });
444 let errs = errors.into_inner().unwrap();
445 if let Some(e) = errs.first() {
446 anyhow::bail!("parallel package failed: {e}");
447 }
448 let mut got = results.into_inner().unwrap();
449 got.sort_by_key(|(i, _)| *i);
450 for (_, r) in got {
451 let track_total: u64 = r.all_durations.iter().sum();
452 let track_seconds =
453 Scaled::new(track_total, sheathe_core::Timescale(r.timescale)).seconds();
454 total_seconds = total_seconds.max(track_seconds);
455 if opts.multi_period {
456 while period_durations.len() <= r.period_index {
457 period_durations.push(0.0);
458 }
459 period_durations[r.period_index] =
460 period_durations[r.period_index].max(track_seconds);
461 }
462 if r.init_name.ends_with(".mp4") || r.init_name.ends_with(".ts") {
463 init_segments.push(out_dir.join(&r.init_name));
464 }
465 for s in &r.all_hls_segs {
466 media_segments.push(out_dir.join(&s.uri));
467 }
468 renditions.push(r);
469 rep += 1;
470 }
471 } else {
472 for (i, (ii, ti, desc)) in work.iter().enumerate() {
473 let r = package_one(*ii, *ti, desc.clone(), i)?;
474 let track_total: u64 = r.all_durations.iter().sum();
475 let track_seconds =
476 Scaled::new(track_total, sheathe_core::Timescale(r.timescale)).seconds();
477 total_seconds = total_seconds.max(track_seconds);
478 if opts.multi_period {
479 while period_durations.len() <= r.period_index {
480 period_durations.push(0.0);
481 }
482 period_durations[r.period_index] =
483 period_durations[r.period_index].max(track_seconds);
484 }
485 if !r.init_name.is_empty() {
486 init_segments.push(out_dir.join(&r.init_name));
487 }
488 for s in &r.all_hls_segs {
489 media_segments.push(out_dir.join(&s.uri));
490 }
491 if let Some(od) = &r.on_demand_file {
492 media_segments.push(out_dir.join(&od.file_name));
493 }
494 renditions.push(r);
495 rep += 1;
496 }
497 }
498
499 let window = resolve_window(opts, &renditions);
500 let avail_start = opts.availability_start_time.clone().unwrap_or_else(iso8601_now);
501 let publish_time = iso8601_now();
502
503 let mut dash_manifest = None;
504 if opts.dash {
505 let mpd = build_dash_manifest(
506 opts,
507 &renditions,
508 window,
509 total_seconds,
510 &period_durations,
511 &avail_start,
512 &publish_time,
513 );
514 let path = out_dir.join("manifest.mpd");
515 fs::write(&path, mpd.to_xml()).context("writing manifest.mpd")?;
516 dash_manifest = Some(path);
517 }
518
519 let mut hls_master = None;
520 if opts.hls {
521 let variants = write_hls_playlists(
522 out_dir,
523 opts,
524 &renditions,
525 window,
526 hls_key.as_ref(),
527 &avail_start,
528 )?;
529 let path = out_dir.join("master.m3u8");
530 let master_opts = MasterOptions {
531 session_keys: if opts.knobs.create_session_keys {
532 hls_key.iter().cloned().collect()
533 } else {
534 Vec::new()
535 },
536 closed_captions: opts
537 .knobs
538 .closed_captions
539 .iter()
540 .map(|c| sheathe_hls::ClosedCaption {
541 instream_id: c.instream_id.clone(),
542 name: c.name.clone(),
543 language: c.language.clone(),
544 default: c.default,
545 autoselect: c.autoselect,
546 })
547 .collect(),
548 start_time_offset: opts.knobs.hls_start_time_offset,
549 base_url: opts.knobs.hls_base_url.clone().unwrap_or_default(),
550 };
551 fs::write(&path, master_playlist_with(&variants, &master_opts))
552 .context("writing master.m3u8")?;
553 hls_master = Some(path);
554 }
555
556 if opts.encryption.as_ref().is_some_and(|e| e.hls_aes128) {
557 let spec = opts.encryption.as_ref().unwrap();
558 for (i, p) in media_segments.iter().enumerate() {
559 if let Ok(data) = fs::read(p) {
560 let mut iv = [0u8; 16];
561 iv[12..16].copy_from_slice(&((i as u32) + 1).to_be_bytes());
562 if let Ok(enc) = sheathe_crypto::aes_cbc_pkcs7_encrypt(&spec.key, &iv, &data) {
563 let _ = fs::write(p, enc);
564 }
565 }
566 }
567 }
568
569 if let Some(url) = &opts.http_push_url {
571 let http_opts = crate::tls::HttpOptions {
572 user_agent: opts.knobs.user_agent.clone(),
573 ca_file: opts.knobs.ca_file.clone(),
574 client_cert_file: opts.knobs.client_cert_file.clone(),
575 client_cert_key_file: opts.knobs.client_cert_key_file.clone(),
576 client_cert_key_password: opts.knobs.client_cert_key_password.clone(),
577 disable_peer_verification: opts.knobs.disable_peer_verification,
578 ignore_failures: opts.knobs.ignore_http_output_failures,
579 };
580 let mut paths: Vec<PathBuf> =
581 init_segments.iter().chain(media_segments.iter()).cloned().collect();
582 if let Some(p) = &dash_manifest {
583 paths.push(p.clone());
584 }
585 if let Some(p) = &hls_master {
586 paths.push(p.clone());
587 for r in &renditions {
589 let id = r
590 .init_name
591 .trim_start_matches("init_")
592 .trim_end_matches(".mp4")
593 .trim_end_matches(".ts");
594 let media_name = if r.init_name.is_empty() {
595 continue;
597 } else {
598 format!("media_{id}.m3u8")
599 };
600 let mp = out_dir.join(&media_name);
601 if mp.exists() {
602 paths.push(mp);
603 }
604 }
605 }
606 for p in paths {
607 if let Ok(data) = fs::read(&p) {
608 let rel =
609 p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
610 let dest = format!("{}/{}", url.trim_end_matches('/'), rel);
611 crate::tls::http_put(&dest, &data, &http_opts)?;
612 }
613 }
614 }
615
616 Ok(PackageOutput {
617 out_dir: out_dir.clone(),
618 dash_manifest,
619 hls_master,
620 init_segments,
621 media_segments,
622 duration_seconds: total_seconds,
623 renditions: rep,
624 })
625}
626
627fn resolve_window(opts: &PackageOptions, renditions: &[PackagedRendition]) -> Option<usize> {
628 match opts.presentation {
629 PresentationMode::Vod => None,
630 PresentationMode::Event => opts.live_window_segments,
631 PresentationMode::Live => {
632 Some(opts.live_window_segments.unwrap_or_else(|| {
633 let max_len = renditions.iter().map(|r| r.all_durations.len()).max().unwrap_or(0);
635 max_len.clamp(1, 3)
636 }))
637 }
638 }
639}
640
641fn window_slice<T: Clone>(items: &[T], window: Option<usize>) -> (usize, Vec<T>) {
643 match window {
644 Some(w) if w < items.len() => {
645 let start = items.len() - w;
646 (start, items[start..].to_vec())
647 }
648 _ => (0, items.to_vec()),
649 }
650}
651
652fn build_dash_manifest(
653 opts: &PackageOptions,
654 renditions: &[PackagedRendition],
655 window: Option<usize>,
656 total_seconds: f64,
657 period_durations: &[f64],
658 avail_start: &str,
659 publish_time: &str,
660) -> Manifest {
661 let protection = opts.encryption.as_ref().map(|spec| Protection {
662 scheme: scheme_str(spec.scheme).to_string(),
663 default_kid: spec.kid,
664 });
665
666 let event_streams = scte35_event_streams(&opts.scte35_markers, 90_000);
667
668 let num_periods = if opts.multi_period { period_durations.len().max(1) } else { 1 };
669
670 let mut periods = Vec::with_capacity(num_periods);
671 let mut period_start = 0.0_f64;
672 for p in 0..num_periods {
673 let reps_in_period: Vec<&PackagedRendition> =
674 renditions.iter().filter(|r| r.period_index == p).collect();
675
676 let mut representations = Vec::new();
677 for r in &reps_in_period {
678 let (start_idx, durs) = window_slice(&r.all_durations, window);
679 let start_number = (start_idx as u32) + 1;
680 let pto: u64 = r.all_durations[..start_idx].iter().sum();
681 let ato =
682 if opts.low_latency { Some(opts.part_duration.unwrap_or(1.0) * 3.0) } else { None };
683 let rep_id = rep_id_from_init(&r.init_name);
684
685 if let Some(od) = &r.on_demand_file {
686 let rep = Representation::on_demand(
687 rep_id.clone(),
688 r.stream.clone(),
689 r.timescale,
690 durs,
691 SegmentBaseInfo {
692 base_url: od.file_name.clone(),
693 init_range: od.init_range,
694 index_range: None,
695 media_ranges: od.media_ranges.clone(),
696 },
697 );
698 representations.push(rep);
699 } else {
700 let mut rep = Representation::new(
701 rep_id.clone(),
702 r.stream.clone(),
703 r.init_name.clone(),
704 r.media_template.clone(),
705 r.timescale,
706 durs,
707 );
708 apply_dash_presentation(&mut rep, r);
709 rep.set_start_number(start_number);
710 rep.set_presentation_time_offset(pto);
711 rep.set_availability_time_offset(ato);
712 representations.push(rep);
713 }
714
715 if let Some(trick) = &r.trick {
716 let (t_start, t_durs) = window_slice(&trick.all_durations, window);
717 let mut trep = Representation::new(
718 format!("{rep_id}_trick"),
719 r.stream.clone(),
720 trick.init_name.clone(),
721 trick.media_template.clone(),
722 r.timescale,
723 t_durs,
724 );
725 trep.set_start_number(t_start as u32 + 1);
726 trep.set_presentation_time_offset(trick.all_durations[..t_start].iter().sum());
727 trep.max_playout_rate = Some(8.0);
728 representations.push(trep);
729 }
730 }
731
732 let period_dur = if opts.multi_period {
733 period_durations.get(p).copied()
734 } else if opts.presentation == PresentationMode::Vod {
735 Some(total_seconds)
736 } else {
737 None
738 };
739
740 periods.push(Period {
741 id: format!("p{p}"),
742 start_seconds: Some(period_start),
743 duration_seconds: period_dur,
744 representations,
745 event_streams: if p == 0 { event_streams.clone() } else { Vec::new() },
746 });
747 if let Some(d) = period_dur {
748 period_start += d;
749 }
750 }
751
752 let profile = if opts.on_demand { DashProfile::OnDemand } else { DashProfile::Live };
753 match opts.presentation {
754 PresentationMode::Vod => Manifest {
755 mpd_type: MpdType::Static,
756 profile,
757 duration_seconds: Some(if opts.multi_period {
758 period_durations.iter().sum()
759 } else {
760 total_seconds
761 }),
762 periods,
763 protection,
764 add_last_segment_number: opts.knobs.dash_add_last_segment_number,
765 segment_template_constant_duration: opts.knobs.segment_template_constant_duration,
766 ..Manifest::default()
767 },
768 PresentationMode::Event | PresentationMode::Live => {
769 let window_secs =
770 window.map(|w| w as f64 * opts.segment_duration).unwrap_or(total_seconds);
771 Manifest {
772 mpd_type: MpdType::Dynamic,
773 profile: DashProfile::Live,
774 duration_seconds: None,
775 availability_start_time: Some(avail_start.to_string()),
776 publish_time: Some(publish_time.to_string()),
777 minimum_update_period: Some(if opts.low_latency {
778 opts.part_duration.unwrap_or(1.0)
779 } else {
780 opts.segment_duration.min(2.0)
781 }),
782 time_shift_buffer_depth: Some(window_secs.max(opts.segment_duration)),
783 suggested_presentation_delay: Some((window_secs / 2.0).max(opts.segment_duration)),
784 utc_timing: Some(UtcTiming::http_iso("https://time.akamai.com/?iso")),
785 periods,
786 protection,
787 add_last_segment_number: opts.knobs.dash_add_last_segment_number,
788 segment_template_constant_duration: opts.knobs.segment_template_constant_duration,
789 }
790 }
791 }
792}
793
794fn apply_dash_presentation(rep: &mut Representation, r: &PackagedRendition) {
795 rep.language = r.presentation.language.clone();
796 rep.roles = r.presentation.dash_roles.clone();
797 if r.presentation.forced_subtitle && !rep.roles.iter().any(|x| x == "forced-subtitle") {
798 rep.roles.push("forced-subtitle".into());
799 }
800 rep.accessibilities = r.presentation.dash_accessibilities.clone();
801 if r.stream.codec_string.as_deref().is_some_and(|c| c.contains("dvh1") || c.contains("dvhe")) {
802 rep.supplemental_codecs = r.stream.codec_string.clone();
804 }
805}
806
807fn rep_id_from_init(init_name: &str) -> String {
808 init_name
809 .trim_start_matches("init_")
810 .trim_end_matches(".mp4")
811 .trim_end_matches(".ts")
812 .to_string()
813}
814
815fn scte35_event_streams(markers: &[Scte35Marker], timescale: u32) -> Vec<EventStream> {
816 if markers.is_empty() {
817 return Vec::new();
818 }
819 let events: Vec<DashEvent> = markers
820 .iter()
821 .enumerate()
822 .map(|(i, m)| {
823 let bytes = build_splice_insert(m);
824 DashEvent {
825 id: Some(format!("{}", m.event_id.unwrap_or(i as u32 + 1))),
826 presentation_time: (m.time_seconds * f64::from(timescale)).round() as u64,
827 duration: m
828 .break_duration_seconds
829 .map(|d| (d * f64::from(timescale)).round() as u64),
830 message_data: Some(to_base64(&bytes)),
831 }
832 })
833 .collect();
834 vec![EventStream::scte35_bin(timescale, events)]
835}
836
837fn write_hls_playlists(
838 out_dir: &Path,
839 opts: &PackageOptions,
840 renditions: &[PackagedRendition],
841 window: Option<usize>,
842 hls_key: Option<&KeyInfo>,
843 avail_start: &str,
844) -> Result<Vec<Variant>> {
845 let mut variants = Vec::new();
846 let dateranges = scte35_dateranges(&opts.scte35_markers, avail_start);
847
848 for r in renditions {
849 let (start_idx, segs) = window_slice(&r.all_hls_segs, window);
850 let media_sequence = start_idx as u64;
851 let id = if r.init_name.is_empty() {
852 r.all_hls_segs.first().and_then(|s| s.uri.split('_').nth(1)).unwrap_or("0").to_string()
854 } else {
855 rep_id_from_init(&r.init_name)
856 };
857 let media_name =
858 r.presentation.playlist_name.clone().unwrap_or_else(|| format!("media_{id}.m3u8"));
859
860 let use_map = matches!(opts.segment_format, SegmentFormat::Cmaf) && !r.init_name.is_empty();
861 let mut pl =
862 if matches!(opts.segment_format, SegmentFormat::MpegTs | SegmentFormat::PackedAudio) {
863 let text = match opts.segment_format {
865 SegmentFormat::MpegTs => ts_media_playlist(&segs, hls_key),
866 SegmentFormat::PackedAudio => packed_audio_playlist(&segs, hls_key),
867 SegmentFormat::Cmaf => unreachable!(),
868 };
869 fs::write(out_dir.join(&media_name), text)
870 .with_context(|| format!("writing {media_name}"))?;
871 variants.push(variant_from_rendition(r, media_name, None));
872 continue;
873 } else {
874 match opts.presentation {
875 PresentationMode::Vod => {
876 MediaPlaylist::vod(&r.init_name, segs, hls_key.cloned())
877 }
878 PresentationMode::Event => {
879 MediaPlaylist::event(&r.init_name, segs, hls_key.cloned(), false)
880 }
881 PresentationMode::Live => MediaPlaylist::live(
882 &r.init_name,
883 media_sequence + opts.knobs.hls_media_sequence_number,
884 segs,
885 hls_key.cloned(),
886 ),
887 }
888 };
889 if !use_map {
890 pl.init_uri = None;
891 }
892 let has_parts = pl.segments.iter().any(|s| !s.parts.is_empty());
895 if opts.low_latency && has_parts {
896 pl.part_target = Some(opts.part_duration.unwrap_or(1.0));
897 pl.part_hold_back = Some(opts.part_duration.unwrap_or(1.0) * 3.0);
898 pl.can_block_reload = true;
899 if let Some(last_part) = pl.segments.iter().rev().find_map(|s| s.parts.last()) {
900 let hint = last_part.uri.replace(".m4s", "") + ".next.m4s";
902 pl.preload_hint = Some(("PART".into(), hint));
903 }
904 }
905 if r.period_index == 0 {
906 pl.dateranges = dateranges.clone();
907 }
908 pl.start_time_offset = opts.knobs.hls_start_time_offset;
909 pl.base_url = opts.knobs.hls_base_url.clone().unwrap_or_default();
910 let tag_pdt = opts.knobs.add_program_date_time
912 || matches!(opts.presentation, PresentationMode::Live | PresentationMode::Event);
913 if tag_pdt {
914 for (i, seg) in pl.segments.iter_mut().enumerate() {
915 if (i == 0 || opts.knobs.add_program_date_time) && seg.program_date_time.is_none() {
916 seg.program_date_time = Some(avail_start.to_string());
917 }
918 }
919 }
920
921 fs::write(out_dir.join(&media_name), pl.to_m3u8())
922 .with_context(|| format!("writing {media_name}"))?;
923
924 let iframe_uri = if let Some(trick) = &r.trick {
925 let (_t0, tsegs) = window_slice(&trick.all_hls_segs, window);
926 let iframe_name = r
927 .presentation
928 .iframe_playlist_name
929 .clone()
930 .unwrap_or_else(|| format!("iframe_{id}.m3u8"));
931 fs::write(out_dir.join(&iframe_name), iframe_playlist(&trick.init_name, &tsegs))
932 .with_context(|| format!("writing {iframe_name}"))?;
933 Some(iframe_name)
934 } else {
935 None
936 };
937
938 variants.push(variant_from_rendition(r, media_name, iframe_uri));
939 }
940 Ok(variants)
941}
942
943fn variant_from_rendition(
944 r: &PackagedRendition,
945 playlist_uri: String,
946 iframe: Option<String>,
947) -> Variant {
948 let mut v = Variant::new(r.stream.clone(), playlist_uri);
949 v.iframe_playlist_uri = iframe;
950 v.name = r.presentation.hls_name.clone();
951 v.group_id = r.presentation.hls_group_id.clone();
952 v.language = r.presentation.language.clone();
953 v.characteristics = r.presentation.hls_characteristics.clone();
954 v.forced = r.presentation.forced_subtitle;
955 v.bandwidth = r.presentation.bandwidth;
956 v
957}
958
959fn scte35_dateranges(markers: &[Scte35Marker], avail_start: &str) -> Vec<DateRange> {
960 markers
961 .iter()
962 .enumerate()
963 .map(|(i, m)| {
964 let bytes = build_splice_insert(m);
965 let hex = to_hex_0x(&bytes);
966 let start = offset_iso8601(avail_start, m.time_seconds);
967 let id = format!("scte35-{}", m.event_id.unwrap_or(i as u32 + 1));
968 if m.out_of_network {
969 let mut dr = DateRange::scte35_out(id, start, hex);
970 dr.planned_duration = m.break_duration_seconds;
971 dr
972 } else {
973 DateRange::scte35_in(id, start, hex)
974 }
975 })
976 .collect()
977}
978
979#[allow(clippy::too_many_arguments)]
980fn package_cmaf_rendition(
981 out_dir: &Path,
982 track: &Track,
983 samples: &[Sample],
984 segments: &[Segment],
985 rep: usize,
986 period_index: usize,
987 encryption: Option<&Encryption>,
988 opts: &PackageOptions,
989 part_dur: f64,
990 trick_play_factor: u32,
991) -> Result<PackagedRendition> {
992 let ts = track.info.timescale;
993 let mux = MuxOptions { mvex_before_trak: opts.knobs.mvex_before_trak };
994 let init_bytes = write_init_segment_opts(track, encryption, &mux);
995 let init_name = format!("init_{rep}.mp4");
996 fs::write(out_dir.join(&init_name), &init_bytes)
997 .with_context(|| format!("writing {init_name}"))?;
998
999 let mut durations = Vec::with_capacity(segments.len());
1000 let mut hls_segs = Vec::with_capacity(segments.len());
1001 let mut sample_index = 0u64;
1002 let mut media_blobs: Vec<Vec<u8>> = Vec::new();
1003 let mut media_segments_paths = Vec::new();
1004
1005 for (n, seg) in segments.iter().enumerate() {
1006 let seg_name = format!("seg_{rep}_{}.m4s", n + 1);
1007 let data = write_media_segment(track, (n + 1) as u32, seg, sample_index, encryption);
1008 if opts.on_demand {
1009 media_blobs.push(data);
1010 } else {
1011 fs::write(out_dir.join(&seg_name), &data)
1012 .with_context(|| format!("writing {seg_name}"))?;
1013 media_segments_paths.push(seg_name.clone());
1014 }
1015 sample_index += seg.samples.len() as u64;
1016 durations.push(seg.duration_ticks);
1017
1018 let mut href =
1019 SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name.clone());
1020 if opts.low_latency && track.info.kind == MediaKind::Video && !opts.on_demand {
1021 let mut paths = Vec::new();
1022 href.parts = write_ll_parts(
1023 out_dir,
1024 track,
1025 rep,
1026 n + 1,
1027 seg,
1028 sample_index - seg.samples.len() as u64,
1029 encryption,
1030 part_dur,
1031 &mut paths,
1032 )?;
1033 }
1034 hls_segs.push(href);
1035 let _ = media_segments_paths;
1036 }
1037
1038 let on_demand_file = if opts.on_demand {
1039 let mut bytes = init_bytes;
1040 let init_end = (bytes.len() as u64).saturating_sub(1);
1041 let mut media_ranges = Vec::new();
1042 for blob in media_blobs {
1043 let start = bytes.len() as u64;
1044 bytes.extend_from_slice(&blob);
1045 let end = (bytes.len() as u64).saturating_sub(1);
1046 media_ranges.push((start, end));
1047 }
1048 let file_name = format!("rep_{rep}.mp4");
1049 fs::write(out_dir.join(&file_name), &bytes)
1050 .with_context(|| format!("writing {file_name}"))?;
1051 Some(OnDemandFile { file_name, init_range: (0, init_end), media_ranges })
1054 } else {
1055 None
1056 };
1057
1058 let trick = if (opts.trick_play || trick_play_factor > 1)
1059 && track.info.kind == MediaKind::Video
1060 && !opts.on_demand
1061 {
1062 let mut init_segs = Vec::new();
1063 let mut media_segs = Vec::new();
1064 Some(write_trick_play(
1065 out_dir,
1066 track,
1067 samples,
1068 rep,
1069 encryption,
1070 trick_play_factor.max(1),
1071 &mut init_segs,
1072 &mut media_segs,
1073 )?)
1074 } else {
1075 None
1076 };
1077
1078 Ok(PackagedRendition {
1079 stream: track.info.clone(),
1080 init_name,
1081 media_template: format!("seg_{rep}_$Number$.m4s"),
1082 timescale: ts.0,
1083 all_durations: durations,
1084 all_hls_segs: hls_segs,
1085 period_index,
1086 trick,
1087 on_demand_file,
1088 presentation: StreamDescriptor::default(),
1089 })
1090}
1091
1092fn package_ts_rendition(
1093 out_dir: &Path,
1094 track: &Track,
1095 _samples: &[Sample],
1096 segments: &[Segment],
1097 rep: usize,
1098 period_index: usize,
1099) -> Result<PackagedRendition> {
1100 let ts = track.info.timescale;
1101 let pid = if track.info.kind == MediaKind::Video { PID_VIDEO } else { PID_AUDIO };
1102 let mut durations = Vec::new();
1103 let mut hls_segs = Vec::new();
1104 for (n, seg) in segments.iter().enumerate() {
1105 let mux_track = MuxTrack { info: track.info.clone(), pid, samples: seg.samples.clone() };
1106 let data = mux_segment(&mux_track);
1107 let seg_name = format!("seg_{rep}_{}.ts", n + 1);
1108 fs::write(out_dir.join(&seg_name), data).with_context(|| format!("writing {seg_name}"))?;
1109 durations.push(seg.duration_ticks);
1110 hls_segs.push(SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name));
1111 }
1112 Ok(PackagedRendition {
1113 stream: track.info.clone(),
1114 init_name: String::new(),
1115 media_template: format!("seg_{rep}_$Number$.ts"),
1116 timescale: ts.0,
1117 all_durations: durations,
1118 all_hls_segs: hls_segs,
1119 period_index,
1120 trick: None,
1121 on_demand_file: None,
1122 presentation: StreamDescriptor::default(),
1123 })
1124}
1125
1126fn package_packed_audio(
1127 out_dir: &Path,
1128 track: &Track,
1129 _samples: &[Sample],
1130 segments: &[Segment],
1131 rep: usize,
1132 period_index: usize,
1133) -> Result<PackagedRendition> {
1134 anyhow::ensure!(
1135 track.info.kind == MediaKind::Audio,
1136 "packed-audio format requires an audio track"
1137 );
1138 let ts = track.info.timescale;
1139 let mut durations = Vec::new();
1140 let mut hls_segs = Vec::new();
1141 for (n, seg) in segments.iter().enumerate() {
1142 let mut data = Vec::new();
1144 for s in &seg.samples {
1145 data.extend_from_slice(&s.data);
1146 }
1147 let ext = match track.info.codec {
1148 sheathe_core::Codec::Ac3 | sheathe_core::Codec::Eac3 => "ac3",
1149 _ => "aac",
1150 };
1151 let seg_name = format!("seg_{rep}_{n}.{ext}");
1152 fs::write(out_dir.join(&seg_name), data).with_context(|| format!("writing {seg_name}"))?;
1153 durations.push(seg.duration_ticks);
1154 hls_segs.push(SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name));
1155 }
1156 Ok(PackagedRendition {
1157 stream: track.info.clone(),
1158 init_name: String::new(),
1159 media_template: format!("seg_{rep}_$Number$"),
1160 timescale: ts.0,
1161 all_durations: durations,
1162 all_hls_segs: hls_segs,
1163 period_index,
1164 trick: None,
1165 on_demand_file: None,
1166 presentation: StreamDescriptor::default(),
1167 })
1168}
1169
1170#[allow(clippy::too_many_arguments)]
1172fn write_trick_play(
1173 out_dir: &Path,
1174 track: &Track,
1175 samples: &[Sample],
1176 rep: usize,
1177 encryption: Option<&Encryption>,
1178 factor: u32,
1179 init_segments: &mut Vec<PathBuf>,
1180 media_segments: &mut Vec<PathBuf>,
1181) -> Result<TrickRendition> {
1182 let keyframes: Vec<Sample> = samples
1183 .iter()
1184 .filter(|s| s.is_segment_boundary())
1185 .step_by(factor.max(1) as usize)
1186 .cloned()
1187 .collect();
1188 anyhow::ensure!(!keyframes.is_empty(), "trick-play: video track has no keyframes");
1189
1190 let policy = SegmentPolicy { target_seconds: 0.001, keyframes_only: true };
1192 let mut frag = Fragmenter::new(track.info.clone(), policy);
1193 for s in keyframes {
1194 frag.push(s)?;
1195 }
1196 let segments = frag.finish();
1197 let ts = track.info.timescale;
1198
1199 let init_name = format!("init_{rep}_trick.mp4");
1200 fs::write(out_dir.join(&init_name), write_init_segment(track, encryption))
1201 .with_context(|| format!("writing {init_name}"))?;
1202 init_segments.push(out_dir.join(&init_name));
1203
1204 let mut durations = Vec::new();
1205 let mut hls_segs = Vec::new();
1206 let mut sample_index = 0u64;
1207 for (n, seg) in segments.iter().enumerate() {
1208 let seg_name = format!("seg_{rep}_trick_{}.m4s", n + 1);
1209 let data = write_media_segment(track, (n + 1) as u32, seg, sample_index, encryption);
1210 fs::write(out_dir.join(&seg_name), data).with_context(|| format!("writing {seg_name}"))?;
1211 media_segments.push(out_dir.join(&seg_name));
1212 sample_index += seg.samples.len() as u64;
1213 durations.push(seg.duration_ticks);
1214 hls_segs.push(SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name));
1215 }
1216
1217 Ok(TrickRendition {
1218 init_name,
1219 media_template: format!("seg_{rep}_trick_$Number$.m4s"),
1220 all_durations: durations,
1221 all_hls_segs: hls_segs,
1222 })
1223}
1224
1225#[allow(clippy::too_many_arguments)]
1227fn write_ll_parts(
1228 out_dir: &Path,
1229 track: &Track,
1230 rep: usize,
1231 seg_number: usize,
1232 seg: &Segment,
1233 base_sample_index: u64,
1234 encryption: Option<&Encryption>,
1235 part_duration_secs: f64,
1236 media_segments: &mut Vec<PathBuf>,
1237) -> Result<Vec<PartialSegment>> {
1238 let ts = track.info.timescale.0.max(1) as f64;
1239 let part_ticks = (part_duration_secs * ts).round().max(1.0) as u64;
1240 let mut parts = Vec::new();
1241 let mut acc: Vec<Sample> = Vec::new();
1242 let mut acc_ticks = 0u64;
1243 let mut part_idx = 1usize;
1244 let mut sample_index = base_sample_index;
1245 let mut part_start_dts = seg.start_ticks;
1246
1247 let flush = |acc: &mut Vec<Sample>,
1248 acc_ticks: &mut u64,
1249 part_idx: &mut usize,
1250 sample_index: &mut u64,
1251 part_start_dts: &mut u64,
1252 parts: &mut Vec<PartialSegment>,
1253 media_segments: &mut Vec<PathBuf>|
1254 -> Result<()> {
1255 if acc.is_empty() {
1256 return Ok(());
1257 }
1258 let independent = acc.first().is_some_and(|s| s.is_segment_boundary());
1259 let part_seg = Segment {
1260 start_ticks: *part_start_dts,
1261 duration_ticks: *acc_ticks,
1262 samples: std::mem::take(acc),
1263 };
1264 let part_name = format!("seg_{rep}_{seg_number}.{part_idx}.m4s");
1265 let data =
1266 write_media_segment(track, (*part_idx) as u32, &part_seg, *sample_index, encryption);
1267 fs::write(out_dir.join(&part_name), data)
1268 .with_context(|| format!("writing {part_name}"))?;
1269 media_segments.push(out_dir.join(&part_name));
1270 *sample_index += part_seg.samples.len() as u64;
1271 parts.push(PartialSegment {
1272 uri: part_name,
1273 duration: *acc_ticks as f64 / ts,
1274 independent,
1275 });
1276 *part_start_dts += *acc_ticks;
1277 *acc_ticks = 0;
1278 *part_idx += 1;
1279 Ok(())
1280 };
1281
1282 for sample in &seg.samples {
1283 if !acc.is_empty() && acc_ticks >= part_ticks {
1284 flush(
1285 &mut acc,
1286 &mut acc_ticks,
1287 &mut part_idx,
1288 &mut sample_index,
1289 &mut part_start_dts,
1290 &mut parts,
1291 media_segments,
1292 )?;
1293 }
1294 acc_ticks += u64::from(sample.duration);
1295 acc.push(sample.clone());
1296 }
1297 flush(
1298 &mut acc,
1299 &mut acc_ticks,
1300 &mut part_idx,
1301 &mut sample_index,
1302 &mut part_start_dts,
1303 &mut parts,
1304 media_segments,
1305 )?;
1306 Ok(parts)
1307}
1308
1309fn iso8601_now() -> String {
1310 let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1311 const SECS_PER_DAY: u64 = 86_400;
1313 let days = secs / SECS_PER_DAY;
1314 let day_secs = secs % SECS_PER_DAY;
1315 let h = day_secs / 3600;
1316 let m = (day_secs % 3600) / 60;
1317 let s = day_secs % 60;
1318 let (y, mo, d) = civil_from_days(days as i64);
1319 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
1320}
1321
1322fn civil_from_days(z: i64) -> (i32, u32, u32) {
1324 let z = z + 719_468;
1325 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1326 let doe = (z - era * 146_097) as u64;
1327 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1328 let y = yoe as i64 + era * 400;
1329 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1330 let mp = (5 * doy + 2) / 153;
1331 let d = doy - (153 * mp + 2) / 5 + 1;
1332 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1333 let y = if m <= 2 { y + 1 } else { y };
1334 (y as i32, m as u32, d as u32)
1335}
1336
1337fn offset_iso8601(base: &str, offset_secs: f64) -> String {
1339 let b = base.trim().trim_end_matches('Z');
1341 let Some((date, time)) = b.split_once('T') else {
1342 return base.to_string();
1343 };
1344 let parts: Vec<_> = date.split('-').collect();
1345 let tparts: Vec<_> = time.split(':').collect();
1346 if parts.len() != 3 || tparts.len() < 3 {
1347 return base.to_string();
1348 }
1349 let y: i32 = parts[0].parse().unwrap_or(1970);
1350 let mo: u32 = parts[1].parse().unwrap_or(1);
1351 let d: u32 = parts[2].parse().unwrap_or(1);
1352 let h: u64 = tparts[0].parse().unwrap_or(0);
1353 let mi: u64 = tparts[1].parse().unwrap_or(0);
1354 let s: f64 = tparts[2].parse().unwrap_or(0.0);
1355 let total = days_from_civil(y, mo, d) * 86_400
1356 + h as i64 * 3600
1357 + mi as i64 * 60
1358 + s as i64
1359 + offset_secs.round() as i64;
1360 let secs = total.max(0) as u64;
1361 let days = secs / 86_400;
1362 let day_secs = secs % 86_400;
1363 let (y, mo, d) = civil_from_days(days as i64);
1364 let h = day_secs / 3600;
1365 let m = (day_secs % 3600) / 60;
1366 let s = day_secs % 60;
1367 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
1368}
1369
1370fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
1371 let y = if m <= 2 { y - 1 } else { y } as i64;
1372 let era = if y >= 0 { y } else { y - 399 } / 400;
1373 let yoe = (y - era * 400) as u64;
1374 let mp = if m > 2 { m as u64 - 3 } else { m as u64 + 9 };
1375 let doy = (153 * mp + 2) / 5 + d as u64 - 1;
1376 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1377 era * 146_097 + doe as i64 - 719_468
1378}
1379
1380pub fn scheme_str(scheme: Scheme) -> &'static str {
1382 match scheme {
1383 Scheme::Cenc => "cenc",
1384 Scheme::Cens => "cens",
1385 Scheme::Cbc1 => "cbc1",
1386 Scheme::Cbcs => "cbcs",
1387 }
1388}
1389
1390fn build_encryption(spec: &EncryptionSpec) -> Encryption {
1391 let constant_iv = [
1392 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
1393 0xff,
1394 ];
1395 Encryption {
1396 scheme: spec.scheme,
1397 key: ContentKey { kid: spec.kid, key: spec.key },
1398 constant_iv,
1399 systems: spec.systems.clone(),
1400 crypto_period_seconds: spec.crypto_period_seconds,
1401 crypt_byte_block: spec.crypt_byte_block,
1402 skip_byte_block: spec.skip_byte_block,
1403 playready_extra: spec.playready_extra.clone(),
1404 }
1405}
1406
1407#[derive(Debug, Clone)]
1409pub struct ProbeStream {
1410 pub track_id: u32,
1412 pub info: StreamInfo,
1414 pub sample_count: usize,
1416 pub duration_ticks: u64,
1418 pub keyframe_count: usize,
1420}
1421
1422#[derive(Debug, Clone)]
1424pub struct ProbeReport {
1425 pub format: &'static str,
1427 pub size_bytes: usize,
1429 pub streams: Vec<ProbeStream>,
1431}
1432
1433pub fn probe(input: &Path) -> Result<ProbeReport> {
1436 let bytes = fs::read(input).with_context(|| format!("reading {}", input.display()))?;
1437 let loaded = load_input(&input.to_string_lossy(), &bytes)?;
1438 let streams = loaded
1439 .tracks
1440 .iter()
1441 .map(|lt| ProbeStream {
1442 track_id: lt.track.track_id,
1443 info: lt.track.info.clone(),
1444 sample_count: lt.samples.len(),
1445 duration_ticks: lt.samples.iter().map(|s| u64::from(s.duration)).sum(),
1446 keyframe_count: lt.samples.iter().filter(|s| s.is_segment_boundary()).count(),
1447 })
1448 .collect();
1449 Ok(ProbeReport { format: loaded.format, size_bytes: bytes.len(), streams })
1450}
1451
1452struct LoadedInput {
1453 format: &'static str,
1454 tracks: Vec<LoadedTrack>,
1455}
1456
1457struct LoadedTrack {
1458 track: Track,
1459 samples: Vec<Sample>,
1460}
1461
1462fn is_transport_stream(data: &[u8]) -> bool {
1463 if data.len() < PACKET_SIZE * 3 {
1464 return false;
1465 }
1466 (0..3).all(|i| data[i * PACKET_SIZE] == 0x47)
1467}
1468
1469fn append_captions(tracks: &mut Vec<LoadedTrack>) {
1470 let Some(vid) = tracks.iter().find(|t| {
1471 t.track.info.kind == MediaKind::Video
1472 && matches!(t.track.info.codec, sheathe_core::Codec::H264 | sheathe_core::Codec::H265)
1473 }) else {
1474 return;
1475 };
1476 let hevc = vid.track.info.codec == sheathe_core::Codec::H265;
1477 let samples: Vec<(u64, &[u8])> =
1478 vid.samples.iter().map(|s| (s.pts, s.data.as_slice())).collect();
1479 for text in sheathe_text::extract_captions(&samples, hevc) {
1480 let id = tracks.len() as u32 + 1;
1481 tracks.push(LoadedTrack {
1482 track: Track::from_sample_entry(
1483 text.info.clone(),
1484 id,
1485 text.sample_entry.clone(),
1486 &text.samples,
1487 ),
1488 samples: text.samples.clone(),
1489 });
1490 }
1491}
1492
1493fn load_input(path: &str, data: &[u8]) -> Result<LoadedInput> {
1494 if is_transport_stream(data) {
1495 let demux = TsDemuxer::parse(data).with_context(|| format!("parsing MPEG-TS {path}"))?;
1496 let mut tracks: Vec<LoadedTrack> = demux
1497 .tracks()
1498 .iter()
1499 .enumerate()
1500 .map(|(i, t)| LoadedTrack {
1501 track: Track::from_sample_entry(
1502 t.info.clone(),
1503 (i + 1) as u32,
1504 t.sample_entry.clone(),
1505 &t.samples,
1506 ),
1507 samples: t.samples.clone(),
1508 })
1509 .collect();
1510 append_captions(&mut tracks);
1511 return Ok(LoadedInput { format: "MPEG-TS", tracks });
1512 }
1513
1514 if is_mp4(data) {
1515 return load_mp4(path, data);
1516 }
1517
1518 if sheathe_mkv::is_webm(data) {
1519 let demux =
1520 sheathe_mkv::MkvDemuxer::parse(data).with_context(|| format!("parsing WebM {path}"))?;
1521 let tracks = demux
1522 .tracks()
1523 .iter()
1524 .enumerate()
1525 .map(|(i, t)| LoadedTrack {
1526 track: Track::from_sample_entry(
1527 t.info.clone(),
1528 (i + 1) as u32,
1529 t.sample_entry.clone(),
1530 &t.samples,
1531 ),
1532 samples: t.samples.clone(),
1533 })
1534 .collect();
1535 return Ok(LoadedInput { format: "WebM", tracks });
1536 }
1537
1538 if sheathe_text::is_webvtt(path, data) {
1539 let text = std::str::from_utf8(data)
1540 .with_context(|| format!("WebVTT {path} is not valid UTF-8"))?;
1541 let t = sheathe_text::webvtt(text).with_context(|| format!("parsing WebVTT {path}"))?;
1542 let tracks = vec![LoadedTrack {
1543 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
1544 samples: t.samples.clone(),
1545 }];
1546 return Ok(LoadedInput { format: "WebVTT", tracks });
1547 }
1548
1549 if sheathe_text::is_ttml(data) {
1550 let text =
1551 std::str::from_utf8(data).with_context(|| format!("TTML {path} is not valid UTF-8"))?;
1552 let t = sheathe_text::ttml(text).with_context(|| format!("parsing TTML {path}"))?;
1553 let tracks = vec![LoadedTrack {
1554 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
1555 samples: t.samples.clone(),
1556 }];
1557 return Ok(LoadedInput { format: "TTML", tracks });
1558 }
1559
1560 if sheathe_es::detect(path, data).is_some() {
1561 let demux = EsDemuxer::parse_auto(path, data)
1562 .with_context(|| format!("parsing elementary stream {path}"))?;
1563 let t = demux.track();
1564 let mut tracks = vec![LoadedTrack {
1565 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
1566 samples: t.samples.clone(),
1567 }];
1568 append_captions(&mut tracks);
1569 return Ok(LoadedInput { format: "elementary", tracks });
1570 }
1571
1572 load_mp4(path, data)
1573}
1574
1575fn load_mp4(path: &str, data: &[u8]) -> Result<LoadedInput> {
1576 let demux = Mp4Demuxer::parse(data).with_context(|| format!("parsing MP4 {path}"))?;
1577 let mut tracks = Vec::new();
1578 for (i, t) in demux.tracks().iter().enumerate() {
1579 tracks.push(LoadedTrack {
1580 track: t.clone(),
1581 samples: demux.samples(i).with_context(|| format!("reading samples for track {i}"))?,
1582 });
1583 }
1584 Ok(LoadedInput { format: "MP4", tracks })
1585}
1586
1587pub fn describe(info: &StreamInfo) -> String {
1591 let kind = match info.kind {
1592 MediaKind::Video => "video",
1593 MediaKind::Audio => "audio",
1594 MediaKind::Text => "text",
1595 };
1596 let mut s = format!("{kind} {}", info.rfc6381());
1597 if let Some((w, h)) = info.resolution {
1598 s.push_str(&format!(" {w}x{h}"));
1599 }
1600 if let Some(rate) = info.sample_rate {
1601 s.push_str(&format!(" {rate}Hz"));
1602 }
1603 if let Some(br) = info.bitrate {
1604 s.push_str(&format!(" ~{}kbps", br / 1000));
1605 }
1606 s
1607}
1608
1609pub fn dump_stream(index: usize, stream: &ProbeStream) -> String {
1612 use sheathe_core::Codec;
1613
1614 let type_name = match stream.info.kind {
1615 MediaKind::Video => "Video",
1616 MediaKind::Audio => "Audio",
1617 MediaKind::Text => "Text",
1618 };
1619 let codec = match &stream.info.codec {
1620 Codec::H264 => "H264",
1621 Codec::H265 => "H265",
1622 Codec::Av1 => "AV1",
1623 Codec::Vp8 => "VP8",
1624 Codec::Vp9 => "VP9",
1625 Codec::Aac => "AAC",
1626 Codec::Ac3 => "AC3",
1627 Codec::Eac3 => "EAC3",
1628 Codec::Mp3 => "MP3",
1629 Codec::Flac => "FLAC",
1630 Codec::Opus => "Opus",
1631 Codec::WebVtt => "WebVTT",
1632 Codec::Stpp => "TTML",
1633 Codec::Other(s) => s.as_str(),
1634 };
1635 let scale = stream.info.timescale.0.max(1);
1636 let seconds = stream.duration_ticks as f64 / f64::from(scale);
1637
1638 let mut lines = vec![
1639 format!("Stream [{index}] type: {type_name}"),
1640 format!(" track_id: {}", stream.track_id),
1641 format!(" codec: {codec}"),
1642 format!(" codec_string: {}", stream.info.rfc6381()),
1643 format!(" time_scale: {}", stream.info.timescale.0),
1644 format!(" duration: {} ({seconds:.3} seconds)", stream.duration_ticks),
1645 format!(" samples: {}", stream.sample_count),
1646 ];
1647 if stream.info.kind == MediaKind::Video {
1648 lines.push(format!(" keyframes: {}", stream.keyframe_count));
1649 }
1650 if let Some((w, h)) = stream.info.resolution {
1651 lines.push(format!(" width: {w}"));
1652 lines.push(format!(" height: {h}"));
1653 }
1654 if let Some(rate) = stream.info.sample_rate {
1655 lines.push(format!(" sampling_frequency: {rate}"));
1656 }
1657 if let Some(br) = stream.info.bitrate {
1658 lines.push(format!(" avg_bitrate: {br}"));
1659 }
1660 if let Some(lang) = &stream.info.language {
1661 lines.push(format!(" language: {lang}"));
1662 }
1663 lines.join("\n")
1664}
1665
1666#[cfg(test)]
1667mod dump_tests {
1668 use super::*;
1669 use sheathe_core::{Codec, MediaKind, StreamInfo, Timescale};
1670
1671 fn video_stream() -> ProbeStream {
1672 ProbeStream {
1673 track_id: 1,
1674 info: StreamInfo {
1675 kind: MediaKind::Video,
1676 codec: Codec::H264,
1677 timescale: Timescale(12_800),
1678 resolution: Some((640, 360)),
1679 sample_rate: None,
1680 bitrate: Some(500_000),
1681 codec_string: Some("avc1.64001e".into()),
1682 language: None,
1683 },
1684 sample_count: 4,
1685 duration_ticks: 2_048,
1686 keyframe_count: 1,
1687 }
1688 }
1689
1690 #[test]
1691 fn dump_stream_matches_shaka_field_names() {
1692 let dump = dump_stream(0, &video_stream());
1693 assert!(dump.starts_with("Stream [0] type: Video"));
1694 for needle in [
1695 "track_id: 1",
1696 "codec: H264",
1697 "codec_string: avc1.64001e",
1698 "time_scale: 12800",
1699 "duration: 2048 (0.160 seconds)",
1700 "samples: 4",
1701 "keyframes: 1",
1702 "width: 640",
1703 "height: 360",
1704 "avg_bitrate: 500000",
1705 ] {
1706 assert!(dump.contains(needle), "missing {needle:?} in:\n{dump}");
1707 }
1708 }
1709}