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