Skip to main content

sheathe_package/
lib.rs

1//! End-to-end packaging pipeline for the sheathe packager.
2//!
3//! [`package`] takes one or more input media files (MP4, MPEG-TS, WebM/Matroska,
4//! raw elementary streams, WebVTT/TTML) and writes CMAF init + media segments plus
5//! DASH and/or HLS manifests into an output directory — the library form of the
6//! `sheathe package` CLI command.
7//!
8//! Multiple inputs form an ABR ladder by default (each input's track(s) become
9//! separate renditions sharing one period). With [`PackageOptions::multi_period`]
10//! each input becomes a successive DASH Period instead.
11//!
12//! Phase 4 (live & advanced manifests) is controlled via
13//! [`PresentationMode`], live window size, trick-play, low-latency parts, and
14//! SCTE-35 markers.
15//!
16//! Phase 5 adds on-demand single-file DASH, packed-audio / MPEG-TS HLS, IO
17//! backends, JIT origin, and multi-track parallelism.
18
19pub 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/// Container / segment format for packaged media.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum SegmentFormat {
53    /// CMAF fMP4 init + `.m4s` media (default).
54    #[default]
55    Cmaf,
56    /// MPEG-TS segments (`.ts`) for HLS.
57    MpegTs,
58    /// Packed elementary audio segments (ADTS/AC-3) for audio-only HLS.
59    PackedAudio,
60}
61
62/// How the presentation should be signalled in DASH/HLS manifests.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum PresentationMode {
65    /// Finished VOD asset (`type=static`, `#EXT-X-PLAYLIST-TYPE:VOD` + ENDLIST).
66    #[default]
67    Vod,
68    /// Growing event (`type=dynamic` without a sliding window, EVENT playlist).
69    Event,
70    /// Live sliding window (`type=dynamic`, no ENDLIST, `#EXT-X-MEDIA-SEQUENCE`).
71    Live,
72}
73
74/// Options controlling a [`package`] run.
75#[derive(Debug, Clone)]
76pub struct PackageOptions {
77    /// Directory to write segments and manifests into (created if missing).
78    pub out_dir: PathBuf,
79    /// Target segment duration in seconds (segments cut on keyframes).
80    pub segment_duration: f64,
81    /// Emit a DASH manifest (`manifest.mpd`).
82    pub dash: bool,
83    /// Emit HLS playlists (`master.m3u8` + per-track media playlists).
84    pub hls: bool,
85    /// Content encryption; `None` produces clear output.
86    pub encryption: Option<EncryptionSpec>,
87    /// VOD / EVENT / Live presentation mode.
88    pub presentation: PresentationMode,
89    /// Live window size in segments. `None` keeps every segment (EVENT/VOD) or
90    /// defaults to 3 for Live.
91    pub live_window_segments: Option<usize>,
92    /// Treat each input as a successive DASH Period (and separate HLS master
93    /// variant group) instead of an ABR ladder in one period.
94    pub multi_period: bool,
95    /// Emit trick-play (I-frame) tracks for video renditions.
96    pub trick_play: bool,
97    /// Low-latency packaging: split media segments into parts.
98    pub low_latency: bool,
99    /// Part target duration in seconds when `low_latency` is set (default: 1s).
100    pub part_duration: Option<f64>,
101    /// Wall-clock availability start (ISO-8601). Defaults to "now" for live/event.
102    pub availability_start_time: Option<String>,
103    /// SCTE-35 ad markers to inject into manifests.
104    pub scte35_markers: Vec<Scte35Marker>,
105    /// Segment container format (CMAF / MPEG-TS / packed audio).
106    pub segment_format: SegmentFormat,
107    /// DASH on-demand single-file output (`SegmentList` byte ranges).
108    pub on_demand: bool,
109    /// Parallelise per-track packaging with a thread pool (std threads).
110    pub parallel: bool,
111    /// Optional HTTP base URL for push after local write (`http://host/path`).
112    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/// Raw-key content encryption for a [`package`] run.
140#[derive(Debug, Clone)]
141pub struct EncryptionSpec {
142    /// 16-byte key id.
143    pub kid: [u8; 16],
144    /// 16-byte content key.
145    pub key: [u8; 16],
146    /// Common Encryption scheme (`cenc`, `cens`, `cbc1`, `cbcs`).
147    pub scheme: EncScheme,
148    /// Key-delivery URI written into the HLS `#EXT-X-KEY` tag.
149    pub key_uri: String,
150    /// DRM systems to emit `pssh` boxes for.
151    pub systems: Vec<DrmSystem>,
152    /// Key-rotation crypto-period duration in seconds, or `None` for a single key.
153    pub crypto_period_seconds: Option<f64>,
154}
155
156/// What a [`package`] run produced.
157#[derive(Debug, Clone)]
158pub struct PackageOutput {
159    /// The output directory (echoes [`PackageOptions::out_dir`]).
160    pub out_dir: PathBuf,
161    /// Path to `manifest.mpd` when `dash` was requested.
162    pub dash_manifest: Option<PathBuf>,
163    /// Path to `master.m3u8` when `hls` was requested.
164    pub hls_master: Option<PathBuf>,
165    /// Every CMAF init segment written (one per rendition).
166    pub init_segments: Vec<PathBuf>,
167    /// Every CMAF media segment written.
168    pub media_segments: Vec<PathBuf>,
169    /// Longest rendition duration in seconds.
170    pub duration_seconds: f64,
171    /// Number of renditions produced (across all inputs/tracks).
172    pub renditions: usize,
173}
174
175/// Intermediate state for one packaged rendition (one track of one input).
176struct PackagedRendition {
177    stream: StreamInfo,
178    init_name: String,
179    media_template: String,
180    timescale: u32,
181    /// Full list of segment durations (timescale ticks), pre-window.
182    all_durations: Vec<u64>,
183    /// Full list of HLS segment refs, pre-window.
184    all_hls_segs: Vec<SegmentRef>,
185    /// Period index this rendition belongs to (0 for ABR ladder).
186    period_index: usize,
187    /// Trick-play companion (I-frame only), if any.
188    trick: Option<TrickRendition>,
189    /// On-demand single-file bytes + ranges (when `on_demand`).
190    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
206/// Package one or more inputs into CMAF segments + DASH and/or HLS manifests under
207/// `opts.out_dir`.
208pub 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    // Per-input duration for multi-period starts.
251    let mut period_durations: Vec<f64> = Vec::new();
252
253    // Build work items (input, track) so we can optionally parallelise.
254    let mut work: Vec<(usize, usize)> = Vec::new(); // (input_idx, track_idx)
255    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 = &lt.track;
266            let samples = &lt.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(); // keep timing consistent
297                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    // HTTP push of all written objects (after manifests exist).
407    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            // Also push per-track media playlists.
417            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                    // packed audio uses media_{rep}.m3u8 from segment names
425                    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                // Default: last 3 segments, or whatever exists.
462                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
469/// Slice the trailing `window` segments (or the full list when `window` is None).
470fn 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            // packed-audio / TS: derive from first segment name or rep index.
663            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                // Self-initialising segments — no MAP.
673                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        // Only signal LL-HLS tags when this rendition actually has parts
703        // (video tracks under --low-latency).
704        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                // Hint the next part URI pattern.
711                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        // Tag first segment with program-date-time for live/event.
719        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        // Point HLS at the single file via byte-range is not standard in our
838        // simple playlist — keep multi-seg names for HLS off when on_demand.
839        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        // Concatenate sample payloads — ADTS/AC-3 frames are already framed.
923        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
949/// Write keyframe-only trick-play segments for a video track.
950fn 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    // One segment per keyframe so the I-frame playlist can seek freely.
964    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/// Split a segment into low-latency parts by sample duration budget.
999#[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    // Format as UTC YYYY-MM-DDTHH:MM:SSZ without chrono.
1085    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
1095/// Howard Hinnant civil_from_days (UTC days since 1970-01-01 → Y-M-D).
1096fn 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
1110/// Best-effort: append `offset_secs` to an ISO-8601 `…Z` timestamp.
1111fn offset_iso8601(base: &str, offset_secs: f64) -> String {
1112    // Parse trailing `YYYY-MM-DDTHH:MM:SSZ` only; fall back to base on failure.
1113    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
1153/// Human-readable name for a [`Scheme`] (matches the CLI/DASH spelling).
1154pub 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/// One stream discovered by [`probe`].
1178#[derive(Debug, Clone)]
1179pub struct ProbeStream {
1180    /// In-container track id.
1181    pub track_id: u32,
1182    /// Stream metadata (codec, resolution, timescale, …).
1183    pub info: StreamInfo,
1184    /// Number of coded samples in the track.
1185    pub sample_count: usize,
1186}
1187
1188/// Result of [`probe`]: the detected container and its streams.
1189#[derive(Debug, Clone)]
1190pub struct ProbeReport {
1191    /// Detected container format (e.g. `"MP4"`, `"MPEG-TS"`).
1192    pub format: &'static str,
1193    /// Input size in bytes.
1194    pub size_bytes: usize,
1195    /// The streams the demuxer found.
1196    pub streams: Vec<ProbeStream>,
1197}
1198
1199/// Inspect an input and report the container + streams sheathe detects, without
1200/// writing anything. The library form of the `sheathe probe` CLI command.
1201pub 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
1351/// One-line human description of a stream (re-exported for CLI/probe use).
1352pub 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}