1pub mod io;
20pub mod origin;
21mod scte35;
22
23use anyhow::{Context, Result};
24use sheathe_core::Sample;
25use sheathe_core::{MediaKind, Scaled, StreamInfo};
26use sheathe_crypto::{ContentKey, Scheme};
27use sheathe_dash::{
28 DashEvent, DashProfile, EventStream, Manifest, MpdType, Period, Protection, Representation,
29 SegmentBaseInfo, UtcTiming,
30};
31use sheathe_es::{EsDemuxer, is_mp4};
32use sheathe_hls::{
33 DateRange, KeyInfo, MediaPlaylist, PartialSegment, SegmentRef, Variant, iframe_playlist,
34 master_playlist, packed_audio_playlist, ts_media_playlist,
35};
36use sheathe_mp4::{
37 Encryption, Fragmenter, Mp4Demuxer, Segment, SegmentPolicy, Track, write_init_segment,
38 write_media_segment,
39};
40use sheathe_ts::{MuxTrack, PID_AUDIO, PID_VIDEO, TsDemuxer, mux_segment, packet::PACKET_SIZE};
41use std::fs;
42use std::path::{Path, PathBuf};
43use std::time::{SystemTime, UNIX_EPOCH};
44
45pub use io::{FileSink, HttpPushSink, ObjectSink, read_input};
46pub use origin::{OriginConfig, serve as serve_origin};
47pub use scte35::{Scte35Marker, build_splice_insert, to_base64, to_hex_0x};
48pub use sheathe_crypto::{ProtectionSystem as DrmSystem, Scheme as EncScheme};
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum SegmentFormat {
53 #[default]
55 Cmaf,
56 MpegTs,
58 PackedAudio,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum PresentationMode {
65 #[default]
67 Vod,
68 Event,
70 Live,
72}
73
74#[derive(Debug, Clone)]
76pub struct PackageOptions {
77 pub out_dir: PathBuf,
79 pub segment_duration: f64,
81 pub dash: bool,
83 pub hls: bool,
85 pub encryption: Option<EncryptionSpec>,
87 pub presentation: PresentationMode,
89 pub live_window_segments: Option<usize>,
92 pub multi_period: bool,
95 pub trick_play: bool,
97 pub low_latency: bool,
99 pub part_duration: Option<f64>,
101 pub availability_start_time: Option<String>,
103 pub scte35_markers: Vec<Scte35Marker>,
105 pub segment_format: SegmentFormat,
107 pub on_demand: bool,
109 pub parallel: bool,
111 pub http_push_url: Option<String>,
113}
114
115impl Default for PackageOptions {
116 fn default() -> Self {
117 Self {
118 out_dir: PathBuf::from("out"),
119 segment_duration: 6.0,
120 dash: true,
121 hls: true,
122 encryption: None,
123 presentation: PresentationMode::Vod,
124 live_window_segments: None,
125 multi_period: false,
126 trick_play: false,
127 low_latency: false,
128 part_duration: None,
129 availability_start_time: None,
130 scte35_markers: Vec::new(),
131 segment_format: SegmentFormat::Cmaf,
132 on_demand: false,
133 parallel: false,
134 http_push_url: None,
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
141pub struct EncryptionSpec {
142 pub kid: [u8; 16],
144 pub key: [u8; 16],
146 pub scheme: EncScheme,
148 pub key_uri: String,
150 pub systems: Vec<DrmSystem>,
152 pub crypto_period_seconds: Option<f64>,
154}
155
156#[derive(Debug, Clone)]
158pub struct PackageOutput {
159 pub out_dir: PathBuf,
161 pub dash_manifest: Option<PathBuf>,
163 pub hls_master: Option<PathBuf>,
165 pub init_segments: Vec<PathBuf>,
167 pub media_segments: Vec<PathBuf>,
169 pub duration_seconds: f64,
171 pub renditions: usize,
173}
174
175struct PackagedRendition {
177 stream: StreamInfo,
178 init_name: String,
179 media_template: String,
180 timescale: u32,
181 all_durations: Vec<u64>,
183 all_hls_segs: Vec<SegmentRef>,
185 period_index: usize,
187 trick: Option<TrickRendition>,
189 on_demand_file: Option<OnDemandFile>,
191}
192
193struct OnDemandFile {
194 file_name: String,
195 init_range: (u64, u64),
196 media_ranges: Vec<(u64, u64)>,
197}
198
199struct TrickRendition {
200 init_name: String,
201 media_template: String,
202 all_durations: Vec<u64>,
203 all_hls_segs: Vec<SegmentRef>,
204}
205
206pub fn package(inputs: &[PathBuf], opts: &PackageOptions) -> Result<PackageOutput> {
209 anyhow::ensure!(!inputs.is_empty(), "package: at least one input is required");
210 if opts.low_latency {
211 anyhow::ensure!(
212 opts.part_duration.unwrap_or(1.0) > 0.0,
213 "package: part_duration must be positive when low_latency is set"
214 );
215 }
216
217 let out_dir = &opts.out_dir;
218 fs::create_dir_all(out_dir).with_context(|| format!("creating {}/", out_dir.display()))?;
219
220 let encryption: Option<Encryption> = opts.encryption.as_ref().map(build_encryption);
221
222 let hls_key = opts.encryption.as_ref().map(|spec| KeyInfo {
223 method: match spec.scheme {
224 Scheme::Cbcs | Scheme::Cbc1 => "SAMPLE-AES",
225 _ => "SAMPLE-AES-CTR",
226 }
227 .to_string(),
228 key_format: "urn:mpeg:dash:mp4protection:2011".to_string(),
229 uri: spec.key_uri.clone(),
230 });
231
232 let datas: Vec<Vec<u8>> = inputs
233 .iter()
234 .map(|p| fs::read(p).with_context(|| format!("reading {}", p.display())))
235 .collect::<Result<_>>()?;
236 let loaded: Vec<LoadedInput> = datas
237 .iter()
238 .zip(inputs)
239 .map(|(d, p)| load_input(&p.to_string_lossy(), d))
240 .collect::<Result<_>>()?;
241
242 let policy = SegmentPolicy { target_seconds: opts.segment_duration, keyframes_only: true };
243 let part_dur = opts.part_duration.unwrap_or(1.0);
244
245 let mut renditions = Vec::new();
246 let mut init_segments = Vec::new();
247 let mut media_segments = Vec::new();
248 let mut total_seconds = 0.0_f64;
249 let mut rep = 0usize;
250 let mut period_durations: Vec<f64> = Vec::new();
252
253 let mut work: Vec<(usize, usize)> = Vec::new(); for (ii, input) in loaded.iter().enumerate() {
256 for ti in 0..input.tracks.len() {
257 work.push((ii, ti));
258 }
259 }
260
261 let package_one =
262 |input_idx: usize, track_idx: usize, rep: usize| -> Result<PackagedRendition> {
263 let input = &loaded[input_idx];
264 let lt = &input.tracks[track_idx];
265 let track = <.track;
266 let samples = <.samples;
267 let period_index = if opts.multi_period { input_idx } else { 0 };
268 let mut frag = Fragmenter::new(track.info.clone(), policy);
269 for s in samples.iter().cloned() {
270 frag.push(s)?;
271 }
272 let segments = frag.finish();
273 let ts = track.info.timescale;
274
275 match opts.segment_format {
276 SegmentFormat::Cmaf => package_cmaf_rendition(
277 out_dir,
278 track,
279 samples,
280 &segments,
281 rep,
282 period_index,
283 encryption.as_ref(),
284 opts,
285 part_dur,
286 ),
287 SegmentFormat::MpegTs => {
288 package_ts_rendition(out_dir, track, samples, &segments, rep, period_index)
289 }
290 SegmentFormat::PackedAudio => {
291 package_packed_audio(out_dir, track, samples, &segments, rep, period_index)
292 }
293 }
294 .map(|mut r| {
295 let track_total: u64 = r.all_durations.iter().sum();
296 let _ = Scaled::new(track_total, ts).seconds(); r.period_index = period_index;
298 r
299 })
300 };
301
302 if opts.parallel && work.len() > 1 {
303 use std::sync::Mutex;
304 let results = Mutex::new(Vec::new());
305 let errors = Mutex::new(Vec::new());
306 std::thread::scope(|scope| {
307 for (i, (ii, ti)) in work.iter().enumerate() {
308 let results = &results;
309 let errors = &errors;
310 scope.spawn(move || match package_one(*ii, *ti, i) {
311 Ok(r) => results.lock().unwrap().push((i, r)),
312 Err(e) => errors.lock().unwrap().push(format!("{e:#}")),
313 });
314 }
315 });
316 let errs = errors.into_inner().unwrap();
317 if let Some(e) = errs.first() {
318 anyhow::bail!("parallel package failed: {e}");
319 }
320 let mut got = results.into_inner().unwrap();
321 got.sort_by_key(|(i, _)| *i);
322 for (_, r) in got {
323 let track_total: u64 = r.all_durations.iter().sum();
324 let track_seconds =
325 Scaled::new(track_total, sheathe_core::Timescale(r.timescale)).seconds();
326 total_seconds = total_seconds.max(track_seconds);
327 if opts.multi_period {
328 while period_durations.len() <= r.period_index {
329 period_durations.push(0.0);
330 }
331 period_durations[r.period_index] =
332 period_durations[r.period_index].max(track_seconds);
333 }
334 if r.init_name.ends_with(".mp4") || r.init_name.ends_with(".ts") {
335 init_segments.push(out_dir.join(&r.init_name));
336 }
337 for s in &r.all_hls_segs {
338 media_segments.push(out_dir.join(&s.uri));
339 }
340 renditions.push(r);
341 rep += 1;
342 }
343 } else {
344 for (i, (ii, ti)) in work.iter().enumerate() {
345 let r = package_one(*ii, *ti, i)?;
346 let track_total: u64 = r.all_durations.iter().sum();
347 let track_seconds =
348 Scaled::new(track_total, sheathe_core::Timescale(r.timescale)).seconds();
349 total_seconds = total_seconds.max(track_seconds);
350 if opts.multi_period {
351 while period_durations.len() <= r.period_index {
352 period_durations.push(0.0);
353 }
354 period_durations[r.period_index] =
355 period_durations[r.period_index].max(track_seconds);
356 }
357 if !r.init_name.is_empty() {
358 init_segments.push(out_dir.join(&r.init_name));
359 }
360 for s in &r.all_hls_segs {
361 media_segments.push(out_dir.join(&s.uri));
362 }
363 if let Some(od) = &r.on_demand_file {
364 media_segments.push(out_dir.join(&od.file_name));
365 }
366 renditions.push(r);
367 rep += 1;
368 }
369 }
370
371 let window = resolve_window(opts, &renditions);
372 let avail_start = opts.availability_start_time.clone().unwrap_or_else(iso8601_now);
373 let publish_time = iso8601_now();
374
375 let mut dash_manifest = None;
376 if opts.dash {
377 let mpd = build_dash_manifest(
378 opts,
379 &renditions,
380 window,
381 total_seconds,
382 &period_durations,
383 &avail_start,
384 &publish_time,
385 );
386 let path = out_dir.join("manifest.mpd");
387 fs::write(&path, mpd.to_xml()).context("writing manifest.mpd")?;
388 dash_manifest = Some(path);
389 }
390
391 let mut hls_master = None;
392 if opts.hls {
393 let variants = write_hls_playlists(
394 out_dir,
395 opts,
396 &renditions,
397 window,
398 hls_key.as_ref(),
399 &avail_start,
400 )?;
401 let path = out_dir.join("master.m3u8");
402 fs::write(&path, master_playlist(&variants)).context("writing master.m3u8")?;
403 hls_master = Some(path);
404 }
405
406 if let Some(url) = &opts.http_push_url {
408 let mut sink = HttpPushSink::new(url);
409 let mut paths: Vec<PathBuf> =
410 init_segments.iter().chain(media_segments.iter()).cloned().collect();
411 if let Some(p) = &dash_manifest {
412 paths.push(p.clone());
413 }
414 if let Some(p) = &hls_master {
415 paths.push(p.clone());
416 for r in &renditions {
418 let id = r
419 .init_name
420 .trim_start_matches("init_")
421 .trim_end_matches(".mp4")
422 .trim_end_matches(".ts");
423 let media_name = if r.init_name.is_empty() {
424 continue;
426 } else {
427 format!("media_{id}.m3u8")
428 };
429 let mp = out_dir.join(&media_name);
430 if mp.exists() {
431 paths.push(mp);
432 }
433 }
434 }
435 for p in paths {
436 if let Ok(data) = fs::read(&p) {
437 let rel =
438 p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
439 sink.put(&rel, &data)?;
440 }
441 }
442 }
443
444 Ok(PackageOutput {
445 out_dir: out_dir.clone(),
446 dash_manifest,
447 hls_master,
448 init_segments,
449 media_segments,
450 duration_seconds: total_seconds,
451 renditions: rep,
452 })
453}
454
455fn resolve_window(opts: &PackageOptions, renditions: &[PackagedRendition]) -> Option<usize> {
456 match opts.presentation {
457 PresentationMode::Vod => None,
458 PresentationMode::Event => opts.live_window_segments,
459 PresentationMode::Live => {
460 Some(opts.live_window_segments.unwrap_or_else(|| {
461 let max_len = renditions.iter().map(|r| r.all_durations.len()).max().unwrap_or(0);
463 max_len.clamp(1, 3)
464 }))
465 }
466 }
467}
468
469fn window_slice<T: Clone>(items: &[T], window: Option<usize>) -> (usize, Vec<T>) {
471 match window {
472 Some(w) if w < items.len() => {
473 let start = items.len() - w;
474 (start, items[start..].to_vec())
475 }
476 _ => (0, items.to_vec()),
477 }
478}
479
480fn build_dash_manifest(
481 opts: &PackageOptions,
482 renditions: &[PackagedRendition],
483 window: Option<usize>,
484 total_seconds: f64,
485 period_durations: &[f64],
486 avail_start: &str,
487 publish_time: &str,
488) -> Manifest {
489 let protection = opts.encryption.as_ref().map(|spec| Protection {
490 scheme: scheme_str(spec.scheme).to_string(),
491 default_kid: spec.kid,
492 });
493
494 let event_streams = scte35_event_streams(&opts.scte35_markers, 90_000);
495
496 let num_periods = if opts.multi_period { period_durations.len().max(1) } else { 1 };
497
498 let mut periods = Vec::with_capacity(num_periods);
499 let mut period_start = 0.0_f64;
500 for p in 0..num_periods {
501 let reps_in_period: Vec<&PackagedRendition> =
502 renditions.iter().filter(|r| r.period_index == p).collect();
503
504 let mut representations = Vec::new();
505 for r in &reps_in_period {
506 let (start_idx, durs) = window_slice(&r.all_durations, window);
507 let start_number = (start_idx as u32) + 1;
508 let pto: u64 = r.all_durations[..start_idx].iter().sum();
509 let ato =
510 if opts.low_latency { Some(opts.part_duration.unwrap_or(1.0) * 3.0) } else { None };
511 let rep_id = rep_id_from_init(&r.init_name);
512
513 if let Some(od) = &r.on_demand_file {
514 let rep = Representation::on_demand(
515 rep_id.clone(),
516 r.stream.clone(),
517 r.timescale,
518 durs,
519 SegmentBaseInfo {
520 base_url: od.file_name.clone(),
521 init_range: od.init_range,
522 index_range: None,
523 media_ranges: od.media_ranges.clone(),
524 },
525 );
526 representations.push(rep);
527 } else {
528 let mut rep = Representation::new(
529 rep_id.clone(),
530 r.stream.clone(),
531 r.init_name.clone(),
532 r.media_template.clone(),
533 r.timescale,
534 durs,
535 );
536 rep.set_start_number(start_number);
537 rep.set_presentation_time_offset(pto);
538 rep.set_availability_time_offset(ato);
539 representations.push(rep);
540 }
541
542 if let Some(trick) = &r.trick {
543 let (t_start, t_durs) = window_slice(&trick.all_durations, window);
544 let mut trep = Representation::new(
545 format!("{rep_id}_trick"),
546 r.stream.clone(),
547 trick.init_name.clone(),
548 trick.media_template.clone(),
549 r.timescale,
550 t_durs,
551 );
552 trep.set_start_number(t_start as u32 + 1);
553 trep.set_presentation_time_offset(trick.all_durations[..t_start].iter().sum());
554 trep.max_playout_rate = Some(8.0);
555 representations.push(trep);
556 }
557 }
558
559 let period_dur = if opts.multi_period {
560 period_durations.get(p).copied()
561 } else if opts.presentation == PresentationMode::Vod {
562 Some(total_seconds)
563 } else {
564 None
565 };
566
567 periods.push(Period {
568 id: format!("p{p}"),
569 start_seconds: Some(period_start),
570 duration_seconds: period_dur,
571 representations,
572 event_streams: if p == 0 { event_streams.clone() } else { Vec::new() },
573 });
574 if let Some(d) = period_dur {
575 period_start += d;
576 }
577 }
578
579 let profile = if opts.on_demand { DashProfile::OnDemand } else { DashProfile::Live };
580 match opts.presentation {
581 PresentationMode::Vod => Manifest {
582 mpd_type: MpdType::Static,
583 profile,
584 duration_seconds: Some(if opts.multi_period {
585 period_durations.iter().sum()
586 } else {
587 total_seconds
588 }),
589 periods,
590 protection,
591 ..Manifest::default()
592 },
593 PresentationMode::Event | PresentationMode::Live => {
594 let window_secs =
595 window.map(|w| w as f64 * opts.segment_duration).unwrap_or(total_seconds);
596 Manifest {
597 mpd_type: MpdType::Dynamic,
598 profile: DashProfile::Live,
599 duration_seconds: None,
600 availability_start_time: Some(avail_start.to_string()),
601 publish_time: Some(publish_time.to_string()),
602 minimum_update_period: Some(if opts.low_latency {
603 opts.part_duration.unwrap_or(1.0)
604 } else {
605 opts.segment_duration.min(2.0)
606 }),
607 time_shift_buffer_depth: Some(window_secs.max(opts.segment_duration)),
608 suggested_presentation_delay: Some((window_secs / 2.0).max(opts.segment_duration)),
609 utc_timing: Some(UtcTiming::http_iso("https://time.akamai.com/?iso")),
610 periods,
611 protection,
612 }
613 }
614 }
615}
616
617fn rep_id_from_init(init_name: &str) -> String {
618 init_name
619 .trim_start_matches("init_")
620 .trim_end_matches(".mp4")
621 .trim_end_matches(".ts")
622 .to_string()
623}
624
625fn scte35_event_streams(markers: &[Scte35Marker], timescale: u32) -> Vec<EventStream> {
626 if markers.is_empty() {
627 return Vec::new();
628 }
629 let events: Vec<DashEvent> = markers
630 .iter()
631 .enumerate()
632 .map(|(i, m)| {
633 let bytes = build_splice_insert(m);
634 DashEvent {
635 id: Some(format!("{}", m.event_id.unwrap_or(i as u32 + 1))),
636 presentation_time: (m.time_seconds * f64::from(timescale)).round() as u64,
637 duration: m
638 .break_duration_seconds
639 .map(|d| (d * f64::from(timescale)).round() as u64),
640 message_data: Some(to_base64(&bytes)),
641 }
642 })
643 .collect();
644 vec![EventStream::scte35_bin(timescale, events)]
645}
646
647fn write_hls_playlists(
648 out_dir: &Path,
649 opts: &PackageOptions,
650 renditions: &[PackagedRendition],
651 window: Option<usize>,
652 hls_key: Option<&KeyInfo>,
653 avail_start: &str,
654) -> Result<Vec<Variant>> {
655 let mut variants = Vec::new();
656 let dateranges = scte35_dateranges(&opts.scte35_markers, avail_start);
657
658 for r in renditions {
659 let (start_idx, segs) = window_slice(&r.all_hls_segs, window);
660 let media_sequence = start_idx as u64;
661 let id = if r.init_name.is_empty() {
662 r.all_hls_segs.first().and_then(|s| s.uri.split('_').nth(1)).unwrap_or("0").to_string()
664 } else {
665 rep_id_from_init(&r.init_name)
666 };
667 let media_name = format!("media_{id}.m3u8");
668
669 let use_map = matches!(opts.segment_format, SegmentFormat::Cmaf) && !r.init_name.is_empty();
670 let mut pl =
671 if matches!(opts.segment_format, SegmentFormat::MpegTs | SegmentFormat::PackedAudio) {
672 let text = match opts.segment_format {
674 SegmentFormat::MpegTs => ts_media_playlist(&segs, hls_key),
675 SegmentFormat::PackedAudio => packed_audio_playlist(&segs, hls_key),
676 SegmentFormat::Cmaf => unreachable!(),
677 };
678 fs::write(out_dir.join(&media_name), text)
679 .with_context(|| format!("writing {media_name}"))?;
680 variants.push(Variant {
681 stream: r.stream.clone(),
682 playlist_uri: media_name,
683 iframe_playlist_uri: None,
684 });
685 continue;
686 } else {
687 match opts.presentation {
688 PresentationMode::Vod => {
689 MediaPlaylist::vod(&r.init_name, segs, hls_key.cloned())
690 }
691 PresentationMode::Event => {
692 MediaPlaylist::event(&r.init_name, segs, hls_key.cloned(), false)
693 }
694 PresentationMode::Live => {
695 MediaPlaylist::live(&r.init_name, media_sequence, segs, hls_key.cloned())
696 }
697 }
698 };
699 if !use_map {
700 pl.init_uri = None;
701 }
702 let has_parts = pl.segments.iter().any(|s| !s.parts.is_empty());
705 if opts.low_latency && has_parts {
706 pl.part_target = Some(opts.part_duration.unwrap_or(1.0));
707 pl.part_hold_back = Some(opts.part_duration.unwrap_or(1.0) * 3.0);
708 pl.can_block_reload = true;
709 if let Some(last_part) = pl.segments.iter().rev().find_map(|s| s.parts.last()) {
710 let hint = last_part.uri.replace(".m4s", "") + ".next.m4s";
712 pl.preload_hint = Some(("PART".into(), hint));
713 }
714 }
715 if r.period_index == 0 {
716 pl.dateranges = dateranges.clone();
717 }
718 if matches!(opts.presentation, PresentationMode::Live | PresentationMode::Event)
720 && let Some(first) = pl.segments.first_mut()
721 {
722 first.program_date_time = Some(avail_start.to_string());
723 }
724
725 fs::write(out_dir.join(&media_name), pl.to_m3u8())
726 .with_context(|| format!("writing {media_name}"))?;
727
728 let iframe_uri = if let Some(trick) = &r.trick {
729 let (_t0, tsegs) = window_slice(&trick.all_hls_segs, window);
730 let iframe_name = format!("iframe_{id}.m3u8");
731 fs::write(out_dir.join(&iframe_name), iframe_playlist(&trick.init_name, &tsegs))
732 .with_context(|| format!("writing {iframe_name}"))?;
733 Some(iframe_name)
734 } else {
735 None
736 };
737
738 variants.push(Variant {
739 stream: r.stream.clone(),
740 playlist_uri: media_name,
741 iframe_playlist_uri: iframe_uri,
742 });
743 }
744 Ok(variants)
745}
746
747fn scte35_dateranges(markers: &[Scte35Marker], avail_start: &str) -> Vec<DateRange> {
748 markers
749 .iter()
750 .enumerate()
751 .map(|(i, m)| {
752 let bytes = build_splice_insert(m);
753 let hex = to_hex_0x(&bytes);
754 let start = offset_iso8601(avail_start, m.time_seconds);
755 let id = format!("scte35-{}", m.event_id.unwrap_or(i as u32 + 1));
756 if m.out_of_network {
757 let mut dr = DateRange::scte35_out(id, start, hex);
758 dr.planned_duration = m.break_duration_seconds;
759 dr
760 } else {
761 DateRange::scte35_in(id, start, hex)
762 }
763 })
764 .collect()
765}
766
767#[allow(clippy::too_many_arguments)]
768fn package_cmaf_rendition(
769 out_dir: &Path,
770 track: &Track,
771 samples: &[Sample],
772 segments: &[Segment],
773 rep: usize,
774 period_index: usize,
775 encryption: Option<&Encryption>,
776 opts: &PackageOptions,
777 part_dur: f64,
778) -> Result<PackagedRendition> {
779 let ts = track.info.timescale;
780 let init_bytes = write_init_segment(track, encryption);
781 let init_name = format!("init_{rep}.mp4");
782 fs::write(out_dir.join(&init_name), &init_bytes)
783 .with_context(|| format!("writing {init_name}"))?;
784
785 let mut durations = Vec::with_capacity(segments.len());
786 let mut hls_segs = Vec::with_capacity(segments.len());
787 let mut sample_index = 0u64;
788 let mut media_blobs: Vec<Vec<u8>> = Vec::new();
789 let mut media_segments_paths = Vec::new();
790
791 for (n, seg) in segments.iter().enumerate() {
792 let seg_name = format!("seg_{rep}_{}.m4s", n + 1);
793 let data = write_media_segment(track, (n + 1) as u32, seg, sample_index, encryption);
794 if opts.on_demand {
795 media_blobs.push(data);
796 } else {
797 fs::write(out_dir.join(&seg_name), &data)
798 .with_context(|| format!("writing {seg_name}"))?;
799 media_segments_paths.push(seg_name.clone());
800 }
801 sample_index += seg.samples.len() as u64;
802 durations.push(seg.duration_ticks);
803
804 let mut href =
805 SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name.clone());
806 if opts.low_latency && track.info.kind == MediaKind::Video && !opts.on_demand {
807 let mut paths = Vec::new();
808 href.parts = write_ll_parts(
809 out_dir,
810 track,
811 rep,
812 n + 1,
813 seg,
814 sample_index - seg.samples.len() as u64,
815 encryption,
816 part_dur,
817 &mut paths,
818 )?;
819 }
820 hls_segs.push(href);
821 let _ = media_segments_paths;
822 }
823
824 let on_demand_file = if opts.on_demand {
825 let mut bytes = init_bytes;
826 let init_end = (bytes.len() as u64).saturating_sub(1);
827 let mut media_ranges = Vec::new();
828 for blob in media_blobs {
829 let start = bytes.len() as u64;
830 bytes.extend_from_slice(&blob);
831 let end = (bytes.len() as u64).saturating_sub(1);
832 media_ranges.push((start, end));
833 }
834 let file_name = format!("rep_{rep}.mp4");
835 fs::write(out_dir.join(&file_name), &bytes)
836 .with_context(|| format!("writing {file_name}"))?;
837 Some(OnDemandFile { file_name, init_range: (0, init_end), media_ranges })
840 } else {
841 None
842 };
843
844 let trick = if opts.trick_play && track.info.kind == MediaKind::Video && !opts.on_demand {
845 let mut init_segs = Vec::new();
846 let mut media_segs = Vec::new();
847 Some(write_trick_play(
848 out_dir,
849 track,
850 samples,
851 rep,
852 encryption,
853 &mut init_segs,
854 &mut media_segs,
855 )?)
856 } else {
857 None
858 };
859
860 Ok(PackagedRendition {
861 stream: track.info.clone(),
862 init_name,
863 media_template: format!("seg_{rep}_$Number$.m4s"),
864 timescale: ts.0,
865 all_durations: durations,
866 all_hls_segs: hls_segs,
867 period_index,
868 trick,
869 on_demand_file,
870 })
871}
872
873fn package_ts_rendition(
874 out_dir: &Path,
875 track: &Track,
876 _samples: &[Sample],
877 segments: &[Segment],
878 rep: usize,
879 period_index: usize,
880) -> Result<PackagedRendition> {
881 let ts = track.info.timescale;
882 let pid = if track.info.kind == MediaKind::Video { PID_VIDEO } else { PID_AUDIO };
883 let mut durations = Vec::new();
884 let mut hls_segs = Vec::new();
885 for (n, seg) in segments.iter().enumerate() {
886 let mux_track = MuxTrack { info: track.info.clone(), pid, samples: seg.samples.clone() };
887 let data = mux_segment(&mux_track);
888 let seg_name = format!("seg_{rep}_{}.ts", n + 1);
889 fs::write(out_dir.join(&seg_name), data).with_context(|| format!("writing {seg_name}"))?;
890 durations.push(seg.duration_ticks);
891 hls_segs.push(SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name));
892 }
893 Ok(PackagedRendition {
894 stream: track.info.clone(),
895 init_name: String::new(),
896 media_template: format!("seg_{rep}_$Number$.ts"),
897 timescale: ts.0,
898 all_durations: durations,
899 all_hls_segs: hls_segs,
900 period_index,
901 trick: None,
902 on_demand_file: None,
903 })
904}
905
906fn package_packed_audio(
907 out_dir: &Path,
908 track: &Track,
909 _samples: &[Sample],
910 segments: &[Segment],
911 rep: usize,
912 period_index: usize,
913) -> Result<PackagedRendition> {
914 anyhow::ensure!(
915 track.info.kind == MediaKind::Audio,
916 "packed-audio format requires an audio track"
917 );
918 let ts = track.info.timescale;
919 let mut durations = Vec::new();
920 let mut hls_segs = Vec::new();
921 for (n, seg) in segments.iter().enumerate() {
922 let mut data = Vec::new();
924 for s in &seg.samples {
925 data.extend_from_slice(&s.data);
926 }
927 let ext = match track.info.codec {
928 sheathe_core::Codec::Ac3 | sheathe_core::Codec::Eac3 => "ac3",
929 _ => "aac",
930 };
931 let seg_name = format!("seg_{rep}_{n}.{ext}");
932 fs::write(out_dir.join(&seg_name), data).with_context(|| format!("writing {seg_name}"))?;
933 durations.push(seg.duration_ticks);
934 hls_segs.push(SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name));
935 }
936 Ok(PackagedRendition {
937 stream: track.info.clone(),
938 init_name: String::new(),
939 media_template: format!("seg_{rep}_$Number$"),
940 timescale: ts.0,
941 all_durations: durations,
942 all_hls_segs: hls_segs,
943 period_index,
944 trick: None,
945 on_demand_file: None,
946 })
947}
948
949fn write_trick_play(
951 out_dir: &Path,
952 track: &Track,
953 samples: &[Sample],
954 rep: usize,
955 encryption: Option<&Encryption>,
956 init_segments: &mut Vec<PathBuf>,
957 media_segments: &mut Vec<PathBuf>,
958) -> Result<TrickRendition> {
959 let keyframes: Vec<Sample> =
960 samples.iter().filter(|s| s.is_segment_boundary()).cloned().collect();
961 anyhow::ensure!(!keyframes.is_empty(), "trick-play: video track has no keyframes");
962
963 let policy = SegmentPolicy { target_seconds: 0.001, keyframes_only: true };
965 let mut frag = Fragmenter::new(track.info.clone(), policy);
966 for s in keyframes {
967 frag.push(s)?;
968 }
969 let segments = frag.finish();
970 let ts = track.info.timescale;
971
972 let init_name = format!("init_{rep}_trick.mp4");
973 fs::write(out_dir.join(&init_name), write_init_segment(track, encryption))
974 .with_context(|| format!("writing {init_name}"))?;
975 init_segments.push(out_dir.join(&init_name));
976
977 let mut durations = Vec::new();
978 let mut hls_segs = Vec::new();
979 let mut sample_index = 0u64;
980 for (n, seg) in segments.iter().enumerate() {
981 let seg_name = format!("seg_{rep}_trick_{}.m4s", n + 1);
982 let data = write_media_segment(track, (n + 1) as u32, seg, sample_index, encryption);
983 fs::write(out_dir.join(&seg_name), data).with_context(|| format!("writing {seg_name}"))?;
984 media_segments.push(out_dir.join(&seg_name));
985 sample_index += seg.samples.len() as u64;
986 durations.push(seg.duration_ticks);
987 hls_segs.push(SegmentRef::new(Scaled::new(seg.duration_ticks, ts).seconds(), seg_name));
988 }
989
990 Ok(TrickRendition {
991 init_name,
992 media_template: format!("seg_{rep}_trick_$Number$.m4s"),
993 all_durations: durations,
994 all_hls_segs: hls_segs,
995 })
996}
997
998#[allow(clippy::too_many_arguments)]
1000fn write_ll_parts(
1001 out_dir: &Path,
1002 track: &Track,
1003 rep: usize,
1004 seg_number: usize,
1005 seg: &Segment,
1006 base_sample_index: u64,
1007 encryption: Option<&Encryption>,
1008 part_duration_secs: f64,
1009 media_segments: &mut Vec<PathBuf>,
1010) -> Result<Vec<PartialSegment>> {
1011 let ts = track.info.timescale.0.max(1) as f64;
1012 let part_ticks = (part_duration_secs * ts).round().max(1.0) as u64;
1013 let mut parts = Vec::new();
1014 let mut acc: Vec<Sample> = Vec::new();
1015 let mut acc_ticks = 0u64;
1016 let mut part_idx = 1usize;
1017 let mut sample_index = base_sample_index;
1018 let mut part_start_dts = seg.start_ticks;
1019
1020 let flush = |acc: &mut Vec<Sample>,
1021 acc_ticks: &mut u64,
1022 part_idx: &mut usize,
1023 sample_index: &mut u64,
1024 part_start_dts: &mut u64,
1025 parts: &mut Vec<PartialSegment>,
1026 media_segments: &mut Vec<PathBuf>|
1027 -> Result<()> {
1028 if acc.is_empty() {
1029 return Ok(());
1030 }
1031 let independent = acc.first().is_some_and(|s| s.is_segment_boundary());
1032 let part_seg = Segment {
1033 start_ticks: *part_start_dts,
1034 duration_ticks: *acc_ticks,
1035 samples: std::mem::take(acc),
1036 };
1037 let part_name = format!("seg_{rep}_{seg_number}.{part_idx}.m4s");
1038 let data =
1039 write_media_segment(track, (*part_idx) as u32, &part_seg, *sample_index, encryption);
1040 fs::write(out_dir.join(&part_name), data)
1041 .with_context(|| format!("writing {part_name}"))?;
1042 media_segments.push(out_dir.join(&part_name));
1043 *sample_index += part_seg.samples.len() as u64;
1044 parts.push(PartialSegment {
1045 uri: part_name,
1046 duration: *acc_ticks as f64 / ts,
1047 independent,
1048 });
1049 *part_start_dts += *acc_ticks;
1050 *acc_ticks = 0;
1051 *part_idx += 1;
1052 Ok(())
1053 };
1054
1055 for sample in &seg.samples {
1056 if !acc.is_empty() && acc_ticks >= part_ticks {
1057 flush(
1058 &mut acc,
1059 &mut acc_ticks,
1060 &mut part_idx,
1061 &mut sample_index,
1062 &mut part_start_dts,
1063 &mut parts,
1064 media_segments,
1065 )?;
1066 }
1067 acc_ticks += u64::from(sample.duration);
1068 acc.push(sample.clone());
1069 }
1070 flush(
1071 &mut acc,
1072 &mut acc_ticks,
1073 &mut part_idx,
1074 &mut sample_index,
1075 &mut part_start_dts,
1076 &mut parts,
1077 media_segments,
1078 )?;
1079 Ok(parts)
1080}
1081
1082fn iso8601_now() -> String {
1083 let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1084 const SECS_PER_DAY: u64 = 86_400;
1086 let days = secs / SECS_PER_DAY;
1087 let day_secs = secs % SECS_PER_DAY;
1088 let h = day_secs / 3600;
1089 let m = (day_secs % 3600) / 60;
1090 let s = day_secs % 60;
1091 let (y, mo, d) = civil_from_days(days as i64);
1092 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
1093}
1094
1095fn civil_from_days(z: i64) -> (i32, u32, u32) {
1097 let z = z + 719_468;
1098 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1099 let doe = (z - era * 146_097) as u64;
1100 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1101 let y = yoe as i64 + era * 400;
1102 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1103 let mp = (5 * doy + 2) / 153;
1104 let d = doy - (153 * mp + 2) / 5 + 1;
1105 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1106 let y = if m <= 2 { y + 1 } else { y };
1107 (y as i32, m as u32, d as u32)
1108}
1109
1110fn offset_iso8601(base: &str, offset_secs: f64) -> String {
1112 let b = base.trim().trim_end_matches('Z');
1114 let Some((date, time)) = b.split_once('T') else {
1115 return base.to_string();
1116 };
1117 let parts: Vec<_> = date.split('-').collect();
1118 let tparts: Vec<_> = time.split(':').collect();
1119 if parts.len() != 3 || tparts.len() < 3 {
1120 return base.to_string();
1121 }
1122 let y: i32 = parts[0].parse().unwrap_or(1970);
1123 let mo: u32 = parts[1].parse().unwrap_or(1);
1124 let d: u32 = parts[2].parse().unwrap_or(1);
1125 let h: u64 = tparts[0].parse().unwrap_or(0);
1126 let mi: u64 = tparts[1].parse().unwrap_or(0);
1127 let s: f64 = tparts[2].parse().unwrap_or(0.0);
1128 let total = days_from_civil(y, mo, d) * 86_400
1129 + h as i64 * 3600
1130 + mi as i64 * 60
1131 + s as i64
1132 + offset_secs.round() as i64;
1133 let secs = total.max(0) as u64;
1134 let days = secs / 86_400;
1135 let day_secs = secs % 86_400;
1136 let (y, mo, d) = civil_from_days(days as i64);
1137 let h = day_secs / 3600;
1138 let m = (day_secs % 3600) / 60;
1139 let s = day_secs % 60;
1140 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
1141}
1142
1143fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
1144 let y = if m <= 2 { y - 1 } else { y } as i64;
1145 let era = if y >= 0 { y } else { y - 399 } / 400;
1146 let yoe = (y - era * 400) as u64;
1147 let mp = if m > 2 { m as u64 - 3 } else { m as u64 + 9 };
1148 let doy = (153 * mp + 2) / 5 + d as u64 - 1;
1149 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1150 era * 146_097 + doe as i64 - 719_468
1151}
1152
1153pub fn scheme_str(scheme: Scheme) -> &'static str {
1155 match scheme {
1156 Scheme::Cenc => "cenc",
1157 Scheme::Cens => "cens",
1158 Scheme::Cbc1 => "cbc1",
1159 Scheme::Cbcs => "cbcs",
1160 }
1161}
1162
1163fn build_encryption(spec: &EncryptionSpec) -> Encryption {
1164 let constant_iv = [
1165 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
1166 0xff,
1167 ];
1168 Encryption {
1169 scheme: spec.scheme,
1170 key: ContentKey { kid: spec.kid, key: spec.key },
1171 constant_iv,
1172 systems: spec.systems.clone(),
1173 crypto_period_seconds: spec.crypto_period_seconds,
1174 }
1175}
1176
1177#[derive(Debug, Clone)]
1179pub struct ProbeStream {
1180 pub track_id: u32,
1182 pub info: StreamInfo,
1184 pub sample_count: usize,
1186}
1187
1188#[derive(Debug, Clone)]
1190pub struct ProbeReport {
1191 pub format: &'static str,
1193 pub size_bytes: usize,
1195 pub streams: Vec<ProbeStream>,
1197}
1198
1199pub fn probe(input: &Path) -> Result<ProbeReport> {
1202 let bytes = fs::read(input).with_context(|| format!("reading {}", input.display()))?;
1203 let loaded = load_input(&input.to_string_lossy(), &bytes)?;
1204 let streams = loaded
1205 .tracks
1206 .iter()
1207 .map(|lt| ProbeStream {
1208 track_id: lt.track.track_id,
1209 info: lt.track.info.clone(),
1210 sample_count: lt.samples.len(),
1211 })
1212 .collect();
1213 Ok(ProbeReport { format: loaded.format, size_bytes: bytes.len(), streams })
1214}
1215
1216struct LoadedInput {
1217 format: &'static str,
1218 tracks: Vec<LoadedTrack>,
1219}
1220
1221struct LoadedTrack {
1222 track: Track,
1223 samples: Vec<Sample>,
1224}
1225
1226fn is_transport_stream(data: &[u8]) -> bool {
1227 if data.len() < PACKET_SIZE * 3 {
1228 return false;
1229 }
1230 (0..3).all(|i| data[i * PACKET_SIZE] == 0x47)
1231}
1232
1233fn append_captions(tracks: &mut Vec<LoadedTrack>) {
1234 let Some(vid) = tracks.iter().find(|t| {
1235 t.track.info.kind == MediaKind::Video
1236 && matches!(t.track.info.codec, sheathe_core::Codec::H264 | sheathe_core::Codec::H265)
1237 }) else {
1238 return;
1239 };
1240 let hevc = vid.track.info.codec == sheathe_core::Codec::H265;
1241 let samples: Vec<(u64, &[u8])> =
1242 vid.samples.iter().map(|s| (s.pts, s.data.as_slice())).collect();
1243 for text in sheathe_text::extract_captions(&samples, hevc) {
1244 let id = tracks.len() as u32 + 1;
1245 tracks.push(LoadedTrack {
1246 track: Track::from_sample_entry(
1247 text.info.clone(),
1248 id,
1249 text.sample_entry.clone(),
1250 &text.samples,
1251 ),
1252 samples: text.samples.clone(),
1253 });
1254 }
1255}
1256
1257fn load_input(path: &str, data: &[u8]) -> Result<LoadedInput> {
1258 if is_transport_stream(data) {
1259 let demux = TsDemuxer::parse(data).with_context(|| format!("parsing MPEG-TS {path}"))?;
1260 let mut tracks: Vec<LoadedTrack> = demux
1261 .tracks()
1262 .iter()
1263 .enumerate()
1264 .map(|(i, t)| LoadedTrack {
1265 track: Track::from_sample_entry(
1266 t.info.clone(),
1267 (i + 1) as u32,
1268 t.sample_entry.clone(),
1269 &t.samples,
1270 ),
1271 samples: t.samples.clone(),
1272 })
1273 .collect();
1274 append_captions(&mut tracks);
1275 return Ok(LoadedInput { format: "MPEG-TS", tracks });
1276 }
1277
1278 if is_mp4(data) {
1279 return load_mp4(path, data);
1280 }
1281
1282 if sheathe_mkv::is_webm(data) {
1283 let demux =
1284 sheathe_mkv::MkvDemuxer::parse(data).with_context(|| format!("parsing WebM {path}"))?;
1285 let tracks = demux
1286 .tracks()
1287 .iter()
1288 .enumerate()
1289 .map(|(i, t)| LoadedTrack {
1290 track: Track::from_sample_entry(
1291 t.info.clone(),
1292 (i + 1) as u32,
1293 t.sample_entry.clone(),
1294 &t.samples,
1295 ),
1296 samples: t.samples.clone(),
1297 })
1298 .collect();
1299 return Ok(LoadedInput { format: "WebM", tracks });
1300 }
1301
1302 if sheathe_text::is_webvtt(path, data) {
1303 let text = std::str::from_utf8(data)
1304 .with_context(|| format!("WebVTT {path} is not valid UTF-8"))?;
1305 let t = sheathe_text::webvtt(text).with_context(|| format!("parsing WebVTT {path}"))?;
1306 let tracks = vec![LoadedTrack {
1307 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
1308 samples: t.samples.clone(),
1309 }];
1310 return Ok(LoadedInput { format: "WebVTT", tracks });
1311 }
1312
1313 if sheathe_text::is_ttml(data) {
1314 let text =
1315 std::str::from_utf8(data).with_context(|| format!("TTML {path} is not valid UTF-8"))?;
1316 let t = sheathe_text::ttml(text).with_context(|| format!("parsing TTML {path}"))?;
1317 let tracks = vec![LoadedTrack {
1318 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
1319 samples: t.samples.clone(),
1320 }];
1321 return Ok(LoadedInput { format: "TTML", tracks });
1322 }
1323
1324 if sheathe_es::detect(path, data).is_some() {
1325 let demux = EsDemuxer::parse_auto(path, data)
1326 .with_context(|| format!("parsing elementary stream {path}"))?;
1327 let t = demux.track();
1328 let mut tracks = vec![LoadedTrack {
1329 track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
1330 samples: t.samples.clone(),
1331 }];
1332 append_captions(&mut tracks);
1333 return Ok(LoadedInput { format: "elementary", tracks });
1334 }
1335
1336 load_mp4(path, data)
1337}
1338
1339fn load_mp4(path: &str, data: &[u8]) -> Result<LoadedInput> {
1340 let demux = Mp4Demuxer::parse(data).with_context(|| format!("parsing MP4 {path}"))?;
1341 let mut tracks = Vec::new();
1342 for (i, t) in demux.tracks().iter().enumerate() {
1343 tracks.push(LoadedTrack {
1344 track: t.clone(),
1345 samples: demux.samples(i).with_context(|| format!("reading samples for track {i}"))?,
1346 });
1347 }
1348 Ok(LoadedInput { format: "MP4", tracks })
1349}
1350
1351pub fn describe(info: &StreamInfo) -> String {
1353 let kind = match info.kind {
1354 MediaKind::Video => "video",
1355 MediaKind::Audio => "audio",
1356 MediaKind::Text => "text",
1357 };
1358 let mut s = format!("{kind} {}", info.rfc6381());
1359 if let Some((w, h)) = info.resolution {
1360 s.push_str(&format!(" {w}x{h}"));
1361 }
1362 if let Some(rate) = info.sample_rate {
1363 s.push_str(&format!(" {rate}Hz"));
1364 }
1365 if let Some(br) = info.bitrate {
1366 s.push_str(&format!(" ~{}kbps", br / 1000));
1367 }
1368 s
1369}