Skip to main content

sheathe_package/
lib.rs

1//! End-to-end VOD 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. Multiple inputs form an ABR ladder (each input's
7//! track(s) become separate renditions sharing one manifest).
8//!
9//! The `sheathe-cli` `package` command is a thin wrapper over [`package`].
10
11use anyhow::{Context, Result};
12use sheathe_core::Sample;
13use sheathe_core::{MediaKind, Scaled, StreamInfo};
14use sheathe_crypto::{ContentKey, Scheme};
15use sheathe_dash::{Manifest, Protection, Representation};
16use sheathe_es::{EsDemuxer, is_mp4};
17use sheathe_hls::{KeyInfo, SegmentRef, Variant, master_playlist, media_playlist};
18use sheathe_mp4::{
19    Encryption, Fragmenter, Mp4Demuxer, SegmentPolicy, Track, write_init_segment,
20    write_media_segment,
21};
22use sheathe_ts::{TsDemuxer, packet::PACKET_SIZE};
23use std::fs;
24use std::path::{Path, PathBuf};
25
26pub use sheathe_crypto::{ProtectionSystem as DrmSystem, Scheme as EncScheme};
27
28/// Options controlling a [`package`] run.
29#[derive(Debug, Clone)]
30pub struct PackageOptions {
31    /// Directory to write segments and manifests into (created if missing).
32    pub out_dir: PathBuf,
33    /// Target segment duration in seconds (segments cut on keyframes).
34    pub segment_duration: f64,
35    /// Emit a DASH manifest (`manifest.mpd`).
36    pub dash: bool,
37    /// Emit HLS playlists (`master.m3u8` + per-track media playlists).
38    pub hls: bool,
39    /// Content encryption; `None` produces clear output.
40    pub encryption: Option<EncryptionSpec>,
41}
42
43impl Default for PackageOptions {
44    fn default() -> Self {
45        Self {
46            out_dir: PathBuf::from("out"),
47            segment_duration: 6.0,
48            dash: true,
49            hls: true,
50            encryption: None,
51        }
52    }
53}
54
55/// Raw-key content encryption for a [`package`] run.
56#[derive(Debug, Clone)]
57pub struct EncryptionSpec {
58    /// 16-byte key id.
59    pub kid: [u8; 16],
60    /// 16-byte content key.
61    pub key: [u8; 16],
62    /// Common Encryption scheme (`cenc`, `cens`, `cbc1`, `cbcs`).
63    pub scheme: EncScheme,
64    /// Key-delivery URI written into the HLS `#EXT-X-KEY` tag.
65    pub key_uri: String,
66    /// DRM systems to emit `pssh` boxes for.
67    pub systems: Vec<DrmSystem>,
68    /// Key-rotation crypto-period duration in seconds, or `None` for a single key.
69    pub crypto_period_seconds: Option<f64>,
70}
71
72/// What a [`package`] run produced.
73#[derive(Debug, Clone)]
74pub struct PackageOutput {
75    /// The output directory (echoes [`PackageOptions::out_dir`]).
76    pub out_dir: PathBuf,
77    /// Path to `manifest.mpd` when `dash` was requested.
78    pub dash_manifest: Option<PathBuf>,
79    /// Path to `master.m3u8` when `hls` was requested.
80    pub hls_master: Option<PathBuf>,
81    /// Every CMAF init segment written (one per rendition).
82    pub init_segments: Vec<PathBuf>,
83    /// Every CMAF media segment written.
84    pub media_segments: Vec<PathBuf>,
85    /// Longest rendition duration in seconds.
86    pub duration_seconds: f64,
87    /// Number of renditions produced (across all inputs/tracks).
88    pub renditions: usize,
89}
90
91/// Package one or more inputs into CMAF segments + DASH and/or HLS manifests under
92/// `opts.out_dir`. Each input's track(s) become separate renditions sharing one
93/// manifest — several video inputs form an ABR ladder.
94pub fn package(inputs: &[PathBuf], opts: &PackageOptions) -> Result<PackageOutput> {
95    anyhow::ensure!(!inputs.is_empty(), "package: at least one input is required");
96
97    let out_dir = &opts.out_dir;
98    fs::create_dir_all(out_dir).with_context(|| format!("creating {}/", out_dir.display()))?;
99
100    let encryption: Option<Encryption> = opts.encryption.as_ref().map(build_encryption);
101
102    // HLS `#EXT-X-KEY` signalling for encrypted output.
103    let hls_key = opts.encryption.as_ref().map(|spec| KeyInfo {
104        // HLS fMP4 maps the CBC schemes to SAMPLE-AES and the CTR schemes to
105        // SAMPLE-AES-CTR.
106        method: match spec.scheme {
107            Scheme::Cbcs | Scheme::Cbc1 => "SAMPLE-AES",
108            _ => "SAMPLE-AES-CTR",
109        }
110        .to_string(),
111        key_format: "urn:mpeg:dash:mp4protection:2011".to_string(),
112        uri: spec.key_uri.clone(),
113    });
114
115    let datas: Vec<Vec<u8>> = inputs
116        .iter()
117        .map(|p| fs::read(p).with_context(|| format!("reading {}", p.display())))
118        .collect::<Result<_>>()?;
119    let loaded: Vec<LoadedInput> = datas
120        .iter()
121        .zip(inputs)
122        .map(|(d, p)| load_input(&p.to_string_lossy(), d))
123        .collect::<Result<_>>()?;
124
125    let policy = SegmentPolicy { target_seconds: opts.segment_duration, keyframes_only: true };
126    let mut dash_reps = Vec::new();
127    let mut hls_variants = Vec::new();
128    let mut init_segments = Vec::new();
129    let mut media_segments = Vec::new();
130    let mut total_seconds = 0.0_f64;
131    let mut rep = 0usize; // global rendition index across all inputs/tracks
132
133    for input in &loaded {
134        for lt in &input.tracks {
135            let track = &lt.track;
136            let samples = &lt.samples;
137            let mut frag = Fragmenter::new(track.info.clone(), policy);
138            for s in samples.iter().cloned() {
139                frag.push(s)?;
140            }
141            let segments = frag.finish();
142            let ts = track.info.timescale;
143
144            // Init segment.
145            let init_name = format!("init_{rep}.mp4");
146            fs::write(out_dir.join(&init_name), write_init_segment(track, encryption.as_ref()))
147                .with_context(|| format!("writing {init_name}"))?;
148            init_segments.push(out_dir.join(&init_name));
149
150            // Media segments.
151            let mut durations = Vec::with_capacity(segments.len());
152            let mut hls_segs = Vec::with_capacity(segments.len());
153            let mut sample_index = 0u64;
154            for (n, seg) in segments.iter().enumerate() {
155                let seg_name = format!("seg_{rep}_{}.m4s", n + 1);
156                let data = write_media_segment(
157                    track,
158                    (n + 1) as u32,
159                    seg,
160                    sample_index,
161                    encryption.as_ref(),
162                );
163                fs::write(out_dir.join(&seg_name), data)
164                    .with_context(|| format!("writing {seg_name}"))?;
165                media_segments.push(out_dir.join(&seg_name));
166                sample_index += seg.samples.len() as u64;
167                durations.push(seg.duration_ticks);
168                hls_segs.push(SegmentRef {
169                    duration: Scaled::new(seg.duration_ticks, ts).seconds(),
170                    uri: seg_name,
171                });
172            }
173
174            let track_total: u64 = segments.iter().map(|s| s.duration_ticks).sum();
175            let track_seconds = Scaled::new(track_total, ts).seconds();
176            total_seconds = total_seconds.max(track_seconds);
177
178            dash_reps.push(Representation {
179                id: rep.to_string(),
180                stream: track.info.clone(),
181                init: init_name.clone(),
182                media: format!("seg_{rep}_$Number$.m4s"),
183                timescale: ts.0,
184                segment_durations: durations,
185            });
186
187            if opts.hls {
188                let media_name = format!("media_{rep}.m3u8");
189                fs::write(
190                    out_dir.join(&media_name),
191                    media_playlist(&init_name, &hls_segs, hls_key.as_ref()),
192                )
193                .with_context(|| format!("writing {media_name}"))?;
194                hls_variants.push(Variant { stream: track.info.clone(), playlist_uri: media_name });
195            }
196
197            rep += 1;
198        }
199    }
200
201    let mut dash_manifest = None;
202    if opts.dash {
203        let protection = opts.encryption.as_ref().map(|spec| Protection {
204            scheme: scheme_str(spec.scheme).to_string(),
205            default_kid: spec.kid,
206        });
207        let mpd =
208            Manifest { duration_seconds: total_seconds, representations: dash_reps, protection }
209                .to_xml();
210        let path = out_dir.join("manifest.mpd");
211        fs::write(&path, mpd).context("writing manifest.mpd")?;
212        dash_manifest = Some(path);
213    }
214
215    let mut hls_master = None;
216    if opts.hls {
217        let path = out_dir.join("master.m3u8");
218        fs::write(&path, master_playlist(&hls_variants)).context("writing master.m3u8")?;
219        hls_master = Some(path);
220    }
221
222    Ok(PackageOutput {
223        out_dir: out_dir.clone(),
224        dash_manifest,
225        hls_master,
226        init_segments,
227        media_segments,
228        duration_seconds: total_seconds,
229        renditions: rep,
230    })
231}
232
233/// Human-readable name for a [`Scheme`] (matches the CLI/DASH spelling).
234pub fn scheme_str(scheme: Scheme) -> &'static str {
235    match scheme {
236        Scheme::Cenc => "cenc",
237        Scheme::Cens => "cens",
238        Scheme::Cbc1 => "cbc1",
239        Scheme::Cbcs => "cbcs",
240    }
241}
242
243fn build_encryption(spec: &EncryptionSpec) -> Encryption {
244    // A fixed, asset-wide constant IV for cbcs (cenc derives per-sample IVs and
245    // ignores this).
246    let constant_iv = [
247        0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
248        0xff,
249    ];
250    Encryption {
251        scheme: spec.scheme,
252        key: ContentKey { kid: spec.kid, key: spec.key },
253        constant_iv,
254        systems: spec.systems.clone(),
255        crypto_period_seconds: spec.crypto_period_seconds,
256    }
257}
258
259/// One stream discovered by [`probe`].
260#[derive(Debug, Clone)]
261pub struct ProbeStream {
262    /// In-container track id.
263    pub track_id: u32,
264    /// Stream metadata (codec, resolution, timescale, …).
265    pub info: StreamInfo,
266    /// Number of coded samples in the track.
267    pub sample_count: usize,
268}
269
270/// Result of [`probe`]: the detected container and its streams.
271#[derive(Debug, Clone)]
272pub struct ProbeReport {
273    /// Detected container format (e.g. `"MP4"`, `"MPEG-TS"`).
274    pub format: &'static str,
275    /// Input size in bytes.
276    pub size_bytes: usize,
277    /// The streams the demuxer found.
278    pub streams: Vec<ProbeStream>,
279}
280
281/// Inspect an input and report the container + streams sheathe detects, without
282/// writing anything. The library form of the `sheathe probe` CLI command.
283pub fn probe(input: &Path) -> Result<ProbeReport> {
284    let bytes = fs::read(input).with_context(|| format!("reading {}", input.display()))?;
285    let loaded = load_input(&input.to_string_lossy(), &bytes)?;
286    let streams = loaded
287        .tracks
288        .iter()
289        .map(|lt| ProbeStream {
290            track_id: lt.track.track_id,
291            info: lt.track.info.clone(),
292            sample_count: lt.samples.len(),
293        })
294        .collect();
295    Ok(ProbeReport { format: loaded.format, size_bytes: bytes.len(), streams })
296}
297
298/// A loaded input with pre-extracted tracks and samples.
299struct LoadedInput {
300    format: &'static str,
301    tracks: Vec<LoadedTrack>,
302}
303
304struct LoadedTrack {
305    track: Track,
306    samples: Vec<Sample>,
307}
308
309/// Detect MPEG-TS by 0x47 sync bytes at 188-byte intervals.
310fn is_transport_stream(data: &[u8]) -> bool {
311    if data.len() < PACKET_SIZE * 3 {
312        return false;
313    }
314    (0..3).all(|i| data[i * PACKET_SIZE] == 0x47)
315}
316
317/// Extract CEA-608 captions from an Annex B H.264/H.265 video track and append
318/// them as a WebVTT text track. A no-op when no captions are present.
319fn append_captions(tracks: &mut Vec<LoadedTrack>) {
320    let Some(vid) = tracks.iter().find(|t| {
321        t.track.info.kind == MediaKind::Video
322            && matches!(t.track.info.codec, sheathe_core::Codec::H264 | sheathe_core::Codec::H265)
323    }) else {
324        return;
325    };
326    let hevc = vid.track.info.codec == sheathe_core::Codec::H265;
327    let samples: Vec<(u64, &[u8])> =
328        vid.samples.iter().map(|s| (s.pts, s.data.as_slice())).collect();
329    for text in sheathe_text::extract_captions(&samples, hevc) {
330        let id = tracks.len() as u32 + 1;
331        tracks.push(LoadedTrack {
332            track: Track::from_sample_entry(
333                text.info.clone(),
334                id,
335                text.sample_entry.clone(),
336                &text.samples,
337            ),
338            samples: text.samples.clone(),
339        });
340    }
341}
342
343fn load_input(path: &str, data: &[u8]) -> Result<LoadedInput> {
344    if is_transport_stream(data) {
345        let demux = TsDemuxer::parse(data).with_context(|| format!("parsing MPEG-TS {path}"))?;
346        let mut tracks: Vec<LoadedTrack> = demux
347            .tracks()
348            .iter()
349            .enumerate()
350            .map(|(i, t)| LoadedTrack {
351                track: Track::from_sample_entry(
352                    t.info.clone(),
353                    (i + 1) as u32,
354                    t.sample_entry.clone(),
355                    &t.samples,
356                ),
357                samples: t.samples.clone(),
358            })
359            .collect();
360        append_captions(&mut tracks);
361        return Ok(LoadedInput { format: "MPEG-TS", tracks });
362    }
363
364    if is_mp4(data) {
365        return load_mp4(path, data);
366    }
367
368    if sheathe_mkv::is_webm(data) {
369        let demux =
370            sheathe_mkv::MkvDemuxer::parse(data).with_context(|| format!("parsing WebM {path}"))?;
371        let tracks = demux
372            .tracks()
373            .iter()
374            .enumerate()
375            .map(|(i, t)| LoadedTrack {
376                track: Track::from_sample_entry(
377                    t.info.clone(),
378                    (i + 1) as u32,
379                    t.sample_entry.clone(),
380                    &t.samples,
381                ),
382                samples: t.samples.clone(),
383            })
384            .collect();
385        return Ok(LoadedInput { format: "WebM", tracks });
386    }
387
388    if sheathe_text::is_webvtt(path, data) {
389        let text = std::str::from_utf8(data)
390            .with_context(|| format!("WebVTT {path} is not valid UTF-8"))?;
391        let t = sheathe_text::webvtt(text).with_context(|| format!("parsing WebVTT {path}"))?;
392        let tracks = vec![LoadedTrack {
393            track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
394            samples: t.samples.clone(),
395        }];
396        return Ok(LoadedInput { format: "WebVTT", tracks });
397    }
398
399    if sheathe_text::is_ttml(data) {
400        let text =
401            std::str::from_utf8(data).with_context(|| format!("TTML {path} is not valid UTF-8"))?;
402        let t = sheathe_text::ttml(text).with_context(|| format!("parsing TTML {path}"))?;
403        let tracks = vec![LoadedTrack {
404            track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
405            samples: t.samples.clone(),
406        }];
407        return Ok(LoadedInput { format: "TTML", tracks });
408    }
409
410    if sheathe_es::detect(path, data).is_some() {
411        let demux = EsDemuxer::parse_auto(path, data)
412            .with_context(|| format!("parsing elementary stream {path}"))?;
413        let t = demux.track();
414        let mut tracks = vec![LoadedTrack {
415            track: Track::from_sample_entry(t.info.clone(), 1, t.sample_entry.clone(), &t.samples),
416            samples: t.samples.clone(),
417        }];
418        append_captions(&mut tracks);
419        return Ok(LoadedInput { format: "elementary", tracks });
420    }
421
422    load_mp4(path, data)
423}
424
425fn load_mp4(path: &str, data: &[u8]) -> Result<LoadedInput> {
426    let demux = Mp4Demuxer::parse(data).with_context(|| format!("parsing MP4 {path}"))?;
427    let mut tracks = Vec::new();
428    for (i, t) in demux.tracks().iter().enumerate() {
429        tracks.push(LoadedTrack {
430            track: t.clone(),
431            samples: demux.samples(i).with_context(|| format!("reading samples for track {i}"))?,
432        });
433    }
434    Ok(LoadedInput { format: "MP4", tracks })
435}
436
437/// One-line human description of a stream (re-exported for CLI/probe use).
438pub fn describe(info: &StreamInfo) -> String {
439    let kind = match info.kind {
440        MediaKind::Video => "video",
441        MediaKind::Audio => "audio",
442        MediaKind::Text => "text",
443    };
444    let mut s = format!("{kind} {}", info.rfc6381());
445    if let Some((w, h)) = info.resolution {
446        s.push_str(&format!(" {w}x{h}"));
447    }
448    if let Some(rate) = info.sample_rate {
449        s.push_str(&format!(" {rate}Hz"));
450    }
451    if let Some(br) = info.bitrate {
452        s.push_str(&format!(" ~{}kbps", br / 1000));
453    }
454    s
455}