use crate::TrackId;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use broadcast_common::timescale::cmp_ticks;
use broadcast_common::{Package, Unpackage};
use crate::dash::DashPackager;
use crate::flv::FlvDemux;
use crate::media::{CmafMux, Fmp4Demux, Media};
use crate::progressive::ProgressiveMux;
use crate::ps_demux::PsDemux;
use crate::segmenter::{MediaClock, Segmenter};
use crate::ts_demux::TsDemux;
use crate::ts_hls::TsHlsPackager;
use crate::ts_mux::TsMux;
use crate::webm_demux::WebmDemux;
const LL_DASH_LATENCY_TARGET_MS: u32 = 3000;
const HLS_CMAF_VERSION: u8 = 7;
const HLS_INIT_FILE: &str = "init.mp4";
fn availability_start_time_now() -> String {
availability_start_time(jiff::Timestamp::now().as_second())
}
fn availability_start_time(unix_secs: i64) -> String {
let ts = jiff::Timestamp::from_second(unix_secs).unwrap_or(jiff::Timestamp::UNIX_EPOCH);
format!("{ts}")
}
#[cfg(test)]
mod availability_start_time_tests {
use super::availability_start_time;
#[test]
fn formats_whole_seconds_utc_with_a_z_suffix() {
assert_eq!(availability_start_time(0), "1970-01-01T00:00:00Z");
assert_eq!(availability_start_time(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(
availability_start_time(1_700_000_000),
"2023-11-14T22:13:20Z"
);
assert_eq!(
availability_start_time(4_107_542_400),
"2100-03-01T00:00:00Z"
);
assert_eq!(availability_start_time(-1), "1969-12-31T23:59:59Z");
}
#[test]
fn out_of_range_seconds_clamp_to_the_epoch_not_a_panic() {
assert_eq!(availability_start_time(i64::MAX), "1970-01-01T00:00:00Z");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Container {
MpegTs,
Mp4,
MpegPs,
WebM,
Flv,
}
impl Container {
pub fn name(&self) -> &'static str {
match self {
Container::MpegTs => "mpeg-ts",
Container::Mp4 => "mp4",
Container::MpegPs => "mpeg-ps",
Container::WebM => "webm",
Container::Flv => "flv",
}
}
}
broadcast_common::impl_spec_display!(Container);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OutputFormat {
Cmaf,
Hls,
TsHls,
Dash,
Ts,
Progressive,
}
impl OutputFormat {
pub fn name(&self) -> &'static str {
match self {
OutputFormat::Cmaf => "cmaf",
OutputFormat::Hls => "hls",
OutputFormat::TsHls => "ts-hls",
OutputFormat::Dash => "dash",
OutputFormat::Ts => "ts",
OutputFormat::Progressive => "progressive",
}
}
fn from_extension(ext: &str) -> Option<Self> {
match ext.to_ascii_lowercase().as_str() {
"m3u8" => Some(OutputFormat::Hls),
"mpd" => Some(OutputFormat::Dash),
"ts" => Some(OutputFormat::Ts),
"cmaf" | "m4s" => Some(OutputFormat::Cmaf),
"mp4" | "m4v" => Some(OutputFormat::Progressive),
_ => None,
}
}
}
broadcast_common::impl_spec_display!(OutputFormat);
#[derive(clap::Parser)]
#[command(name = "transmux", version, about, long_about = None)]
pub struct Args {
#[arg(value_name = "IN", required_unless_present = "input")]
pub in_positional: Option<PathBuf>,
#[arg(
short = 'i',
long = "input",
value_name = "PATH",
conflicts_with = "in_positional"
)]
pub input: Option<PathBuf>,
#[arg(short = 'o', long = "output", value_name = "PATH")]
pub output: PathBuf,
#[arg(short = 'f', long = "format", value_enum)]
pub format: Option<FormatArg>,
#[arg(long = "segment-duration", value_name = "SECS", default_value_t = 6)]
pub segment_duration: u32,
#[arg(long = "ll")]
pub ll: bool,
#[arg(long = "utc-timing-url", value_name = "URL")]
pub utc_timing_url: Option<String>,
#[arg(long = "tracks", value_name = "IDS", value_delimiter = ',')]
pub tracks: Vec<u32>,
#[cfg(feature = "cenc")]
#[arg(long = "decrypt")]
pub decrypt: bool,
#[cfg(feature = "cenc")]
#[arg(long = "key", value_name = "KID:KEY")]
pub keys: Vec<String>,
}
impl fmt::Debug for Args {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut d = f.debug_struct("Args");
d.field("in_positional", &self.in_positional)
.field("input", &self.input)
.field("output", &self.output)
.field("format", &self.format)
.field("segment_duration", &self.segment_duration)
.field("ll", &self.ll)
.field("tracks", &self.tracks);
#[cfg(feature = "cenc")]
{
d.field("decrypt", &self.decrypt);
let redacted_keys: Vec<String> = self.keys.iter().map(|s| redact_key_spec(s)).collect();
d.field("keys", &redacted_keys);
}
d.finish()
}
}
#[cfg_attr(not(feature = "cenc"), allow(dead_code))]
fn redact_key_spec(spec: &str) -> String {
let kid_part = spec.split_once(':').map(|(k, _)| k).unwrap_or(spec);
let is_kid_hex = kid_part.len() == 32 && kid_part.bytes().all(|b| b.is_ascii_hexdigit());
if is_kid_hex {
format!("{kid_part}:<redacted>")
} else {
format!("<{}-byte argument>", spec.len())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[non_exhaustive]
pub enum FormatArg {
Cmaf,
Hls,
#[value(name = "ts-hls")]
TsHls,
Dash,
Ts,
Progressive,
}
impl From<FormatArg> for OutputFormat {
fn from(a: FormatArg) -> Self {
match a {
FormatArg::Cmaf => OutputFormat::Cmaf,
FormatArg::Hls => OutputFormat::Hls,
FormatArg::TsHls => OutputFormat::TsHls,
FormatArg::Dash => OutputFormat::Dash,
FormatArg::Ts => OutputFormat::Ts,
FormatArg::Progressive => OutputFormat::Progressive,
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum CliError {
Io(std::io::Error),
Transmux(crate::Error),
UnknownContainer,
UnsupportedContainer(&'static str),
UtcTimingNeedsLl,
UndeterminedFormat,
NoTracksSelected,
BadKey(String),
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliError::Io(e) => write!(f, "i/o error: {e}"),
CliError::Transmux(e) => write!(f, "transmux error: {e}"),
CliError::UnknownContainer => write!(
f,
"unknown input container: leading bytes match no supported format \
(MPEG-TS, MP4/CMAF, MPEG-PS, WebM, FLV)"
),
CliError::UnsupportedContainer(name) => write!(
f,
"unsupported input container {name}: this CLI demuxes MPEG-TS, \
MP4/CMAF, MPEG-PS, WebM/Matroska and FLV"
),
CliError::UtcTimingNeedsLl => write!(
f,
"--utc-timing-url needs --ll (MPD/UTCTiming is emitted only for the low-latency DASH profile)"
),
CliError::UndeterminedFormat => write!(
f,
"output format not given and not inferable from the output extension; \
pass -f/--format"
),
CliError::NoTracksSelected => {
write!(f, "the --tracks selection matched no tracks in the input")
}
CliError::BadKey(s) => {
write!(f, "invalid --key ({s}): expected <32-hex-KID>:<32-hex-key>")
}
}
}
}
impl std::error::Error for CliError {}
impl From<std::io::Error> for CliError {
fn from(e: std::io::Error) -> Self {
CliError::Io(e)
}
}
impl From<crate::Error> for CliError {
fn from(e: crate::Error) -> Self {
CliError::Transmux(e)
}
}
pub type CliResult<T> = Result<T, CliError>;
pub fn detect_container(data: &[u8]) -> CliResult<Container> {
use container_probe::Format;
let format = match container_probe::probe(data) {
container_probe::Probe::Identified { format, .. } => format,
_ => return Err(CliError::UnknownContainer),
};
match format {
Format::MpegTs => Ok(Container::MpegTs),
Format::Isobmff => Ok(Container::Mp4),
Format::MpegPs => Ok(Container::MpegPs),
Format::WebM => Ok(Container::WebM),
Format::Flv => Ok(Container::Flv),
Format::Matroska => Ok(Container::WebM),
other => Err(CliError::UnsupportedContainer(other.name())),
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum Output {
Bytes(Vec<u8>),
Manifest {
text: String,
segments: Vec<(String, Vec<u8>)>,
},
}
#[derive(Debug, Clone)]
pub struct Opts {
pub format: OutputFormat,
pub segment_duration: u32,
pub low_latency: bool,
pub utc_timing_url: Option<String>,
pub tracks: Vec<TrackId>,
}
impl Default for Opts {
fn default() -> Self {
Self {
format: OutputFormat::Cmaf,
segment_duration: 6,
low_latency: false,
utc_timing_url: None,
tracks: Vec::new(),
}
}
}
pub fn run_bytes(input: &[u8], opts: &Opts) -> CliResult<Output> {
let container = detect_container(input)?;
let mut media = demux(container, input)?;
if !opts.tracks.is_empty() {
media
.tracks
.retain(|t| opts.tracks.contains(&t.spec.track_id));
if media.tracks.is_empty() {
return Err(CliError::NoTracksSelected);
}
}
package(&media, opts)
}
fn demux(container: Container, input: &[u8]) -> CliResult<Media> {
let media = match container {
Container::MpegTs => TsDemux::new().unpackage(input)?,
Container::Mp4 => Fmp4Demux::new().unpackage(input)?,
Container::MpegPs => PsDemux::new().unpackage(input)?,
Container::WebM => WebmDemux::new().unpackage(input)?,
Container::Flv => FlvDemux::new()
.unpackage(input)
.map_err(|e| crate::Error::InvalidInput(flv_reason(e)))?,
};
Ok(media)
}
fn flv_reason(_e: crate::flv::FlvError) -> &'static str {
"FLV demux failed"
}
fn filter_for_bmff_mux(media: &Media) -> CliResult<Media> {
let dropped: Vec<TrackId> = media
.tracks
.iter()
.filter(|t| !t.spec.config.is_muxable_in_bmff())
.map(|t| t.spec.track_id)
.collect();
if dropped.is_empty() {
return Ok(media.clone());
}
eprintln!(
"warning: dropping track(s) {dropped:?} from CMAF/fMP4 output \
(no ISOBMFF carriage in this crate for their codec)"
);
Ok(media.select_tracks_by(|t| t.spec.config.is_muxable_in_bmff())?)
}
fn segment_media(media: &Media, target_secs: u32) -> CliResult<(Vec<u8>, Vec<Vec<u8>>)> {
let target = f64::from(target_secs.max(1));
let specs: Vec<crate::pipeline::TrackSpec> =
media.tracks.iter().map(|t| t.spec.clone()).collect();
let mut seg = Segmenter::new(specs, media.movie_timescale, target)?;
let init = seg.init_segment()?;
let origin = crate::media::TimelineOrigin::of(media.tracks.iter());
let mut merged: Vec<(OrderedKey, usize, usize)> = Vec::new();
for (ti, t) in media.tracks.iter().enumerate() {
let scale = t.spec.timescale.max(1);
for si in 0..t.samples.len() {
let sample = &t.samples[si];
let key = match sample.dts {
Some(dts) => OrderedKey::Timed {
ticks: dts.saturating_sub(origin.in_timescale(scale)),
timescale: scale,
},
None => OrderedKey::Untimed,
};
merged.push((key, ti, si));
}
}
merged.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
for (_, ti, si) in merged {
let track_id = media.tracks[ti].spec.track_id;
seg.push(track_id, media.tracks[ti].samples[si].clone())?;
}
seg.flush()?;
Ok((init, seg.take_ready()))
}
type MeasuredSegment = (Vec<u8>, f64);
type TrackSegments = Vec<(core::ops::Range<usize>, u64)>;
fn segment_media_measured(
media: &Media,
target_secs: u32,
) -> CliResult<(Vec<u8>, Vec<MeasuredSegment>)> {
let (init, segments) = segment_media(media, target_secs)?;
let mut out = Vec::with_capacity(segments.len());
for seg in segments {
let secs = segment_secs(&seg, media)?;
out.push((seg, secs));
}
Ok((init, out))
}
fn segment_track_ranges(media: &Media, target_secs: u32) -> CliResult<Vec<TrackSegments>> {
let (_init, segments) = segment_media(media, target_secs)?;
let configs: Vec<&crate::pipeline::CodecConfig> =
media.tracks.iter().map(|t| &t.spec.config).collect();
let anchor_idx = crate::segmenter::choose_anchor(configs.into_iter())?;
let anchor_id = media.tracks[anchor_idx].spec.track_id;
let mut boundaries_anchor: Vec<u64> = Vec::with_capacity(segments.len());
let mut acc = 0u64;
let mut clock = MediaClock::new();
let mut cursor = 0usize;
for seg in &segments {
let present = segment_track_fragments(seg)?;
let count = present
.iter()
.find(|(id, _, _)| *id == anchor_id)
.map(|(_, c, _)| *c as usize)
.unwrap_or(0);
for s in media.tracks[anchor_idx]
.samples
.iter()
.skip(cursor)
.take(count)
{
acc += clock.tick(s);
}
cursor += count;
boundaries_anchor.push(acc);
}
let anchor_scale = u64::from(media.tracks[anchor_idx].spec.timescale.max(1));
let mut per_track: Vec<Vec<(core::ops::Range<usize>, u64)>> =
Vec::with_capacity(media.tracks.len());
for t in media.tracks.iter() {
let scale = u64::from(t.spec.timescale.max(1));
let mut ranges = Vec::with_capacity(boundaries_anchor.len());
let mut start_idx = 0usize;
let mut clock = MediaClock::new();
let mut elapsed = 0u64;
let mut prev_end_anchor = 0u64;
for &end_anchor in &boundaries_anchor {
let start_ticks = prev_end_anchor.saturating_mul(scale) / anchor_scale;
let end_ticks = end_anchor.saturating_mul(scale) / anchor_scale;
let mut i = start_idx;
while i < t.samples.len() && elapsed < end_ticks {
elapsed += clock.tick(&t.samples[i]);
i += 1;
}
if i == start_idx && i < t.samples.len() {
elapsed += clock.tick(&t.samples[i]);
i += 1;
}
ranges.push((start_idx..i, start_ticks));
start_idx = i;
prev_end_anchor = end_anchor;
}
if start_idx < t.samples.len() {
if let Some(last) = ranges.last_mut() {
last.0.end = t.samples.len();
}
}
per_track.push(ranges);
}
Ok(per_track)
}
fn segment_track_fragments(segment: &[u8]) -> CliResult<Vec<(TrackId, u32, u64)>> {
use crate::box_types::parse_box;
use crate::movie_fragment::MovieFragmentBox;
let mut remaining = segment;
while !remaining.is_empty() {
let (bx, consumed) = parse_box(remaining)
.map_err(|_| crate::Error::InvalidInput("media segment box could not be parsed"))?;
if bx.header.box_type.is(b"moof") {
let moof = MovieFragmentBox::parse_body(bx.body)
.map_err(|_| crate::Error::InvalidInput("moof box could not be parsed"))?;
let mut out = Vec::with_capacity(moof.traf.len());
for traf in &moof.traf {
let count: u32 = traf
.trun
.iter()
.map(|r| u32::try_from(r.samples.len()).unwrap_or(u32::MAX))
.fold(0u32, u32::saturating_add);
let tfdt = traf
.tfdt
.as_ref()
.map(|t| t.base_media_decode_time())
.unwrap_or(0);
out.push((traf.tfhd.track_id, count, tfdt));
}
return Ok(out);
}
if consumed == 0 || consumed > remaining.len() {
break;
}
remaining = &remaining[consumed..];
}
Err(
crate::Error::InvalidInput("media segment carries no moof: cannot derive its track layout")
.into(),
)
}
fn segment_secs(segment: &[u8], media: &Media) -> CliResult<f64> {
use crate::box_types::parse_box;
use crate::movie_fragment::MovieFragmentBox;
let mut remaining = segment;
while !remaining.is_empty() {
let (bx, consumed) = parse_box(remaining)
.map_err(|_| crate::Error::InvalidInput("media segment box could not be parsed"))?;
if bx.header.box_type.is(b"moof") {
let moof = MovieFragmentBox::parse_body(bx.body)
.map_err(|_| crate::Error::InvalidInput("moof box could not be parsed"))?;
let mut longest = 0.0f64;
for traf in &moof.traf {
let scale = media
.tracks
.iter()
.find(|t| t.spec.track_id == traf.tfhd.track_id)
.map(|t| f64::from(t.spec.timescale.max(1)))
.ok_or(crate::Error::InvalidInput(
"media segment names a track not present in the input",
))?;
let mut ticks = 0u64;
for run in &traf.trun {
for s in &run.samples {
ticks += u64::from(s.sample_duration.unwrap_or(0));
}
}
let secs = ticks as f64 / scale;
if secs > longest {
longest = secs;
}
}
return Ok(longest);
}
if consumed == 0 || consumed > remaining.len() {
break;
}
remaining = &remaining[consumed..];
}
Err(
crate::Error::InvalidInput("media segment carries no moof: cannot derive its duration")
.into(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrderedKey {
Timed {
ticks: i64,
timescale: u32,
},
Untimed,
}
impl Ord for OrderedKey {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
use core::cmp::Ordering;
match (self, other) {
(
OrderedKey::Timed {
ticks: a,
timescale: at,
},
OrderedKey::Timed {
ticks: b,
timescale: bt,
},
) => cmp_ticks(*a, *at, *b, *bt),
(OrderedKey::Untimed, OrderedKey::Untimed) => Ordering::Equal,
(OrderedKey::Untimed, OrderedKey::Timed { .. }) => Ordering::Greater,
(OrderedKey::Timed { .. }, OrderedKey::Untimed) => Ordering::Less,
}
}
}
impl PartialOrd for OrderedKey {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
fn segment_ticks(segment: &[u8]) -> CliResult<u64> {
use crate::box_types::parse_box;
use crate::movie_fragment::MovieFragmentBox;
let mut remaining = segment;
while !remaining.is_empty() {
let (bx, consumed) = parse_box(remaining)
.map_err(|_| crate::Error::InvalidInput("media segment box could not be parsed"))?;
if bx.header.box_type.is(b"moof") {
let moof = MovieFragmentBox::parse_body(bx.body)
.map_err(|_| crate::Error::InvalidInput("moof box could not be parsed"))?;
let mut longest = 0u64;
for traf in &moof.traf {
let mut ticks = 0u64;
for run in &traf.trun {
for s in &run.samples {
ticks += u64::from(s.sample_duration.unwrap_or(0));
}
}
if ticks > longest {
longest = ticks;
}
}
return Ok(longest);
}
if consumed == 0 || consumed > remaining.len() {
break;
}
remaining = &remaining[consumed..];
}
Err(
crate::Error::InvalidInput("media segment carries no moof: cannot derive its duration")
.into(),
)
}
fn render_cmaf_hls_playlist(segments: &[(Vec<u8>, f64)]) -> CliResult<String> {
use broadcast_hls::{DecimalSeconds, MediaPlaylist, MediaSegment};
if segments.is_empty() {
return Err(
crate::Error::InvalidInput("cannot render an HLS playlist with no segments").into(),
);
}
let segs = segments
.iter()
.enumerate()
.map(|(i, (_, secs))| {
Ok(MediaSegment {
uri: format!("seg{i}.m4s"),
duration: DecimalSeconds::new(*secs).map_err(|_| {
crate::Error::InvalidInput("segment duration must be finite and non-negative")
})?,
discontinuous: false,
map: Some(broadcast_hls::MapTag {
uri: String::from(HLS_INIT_FILE),
byte_range: None,
extra_attrs: Vec::new(),
}),
..Default::default()
})
})
.collect::<CliResult<Vec<_>>>()?;
let max_secs = segments
.iter()
.map(|(_, s)| *s)
.fold(0.0f64, f64::max)
.max(1.0);
let target_duration = u32::try_from(max_secs.ceil() as u64).unwrap_or(u32::MAX);
let playlist = MediaPlaylist {
version: HLS_CMAF_VERSION,
target_duration,
media_sequence: 0,
discontinuity_sequence: 0,
segments: segs,
endlist: true,
..Default::default()
};
playlist
.to_m3u8()
.map_err(|e| crate::Error::HlsAttrValue(e).into())
}
fn package(media: &Media, opts: &Opts) -> CliResult<Output> {
match opts.format {
OutputFormat::Cmaf => {
let media = filter_for_bmff_mux(media)?;
Ok(Output::Bytes(CmafMux::new(1).package(&media)?))
}
OutputFormat::Progressive => {
let media = filter_for_bmff_mux(media)?;
Ok(Output::Bytes(ProgressiveMux::new(true).package(&media)?))
}
OutputFormat::Ts => Ok(Output::Bytes(TsMux::new().package(media)?)),
OutputFormat::Hls => {
let media = filter_for_bmff_mux(media)?;
let (init, measured) = segment_media_measured(&media, opts.segment_duration)?;
let text = render_cmaf_hls_playlist(&measured)?;
let mut files: Vec<(String, Vec<u8>)> =
alloc::vec![(String::from(HLS_INIT_FILE), init)];
files.extend(
measured
.iter()
.enumerate()
.map(|(i, (seg, _))| (format!("seg{i}.m4s"), seg.clone())),
);
Ok(Output::Manifest {
text,
segments: files,
})
}
OutputFormat::TsHls => {
let out = TsHlsPackager::new(opts.segment_duration).package(media)?;
let segments = out
.segments
.into_iter()
.enumerate()
.map(|(i, bytes)| (format!("seg{i}.ts"), bytes))
.collect();
Ok(Output::Manifest {
text: out.playlist,
segments,
})
}
OutputFormat::Dash => {
let media = filter_for_bmff_mux(media)?;
let mut segments: Vec<(String, Vec<u8>)> = Vec::new();
let mut track_segments: Vec<crate::dash::TrackSegments> = Vec::new();
let layout = segment_track_ranges(&media, opts.segment_duration)?;
for (ti, t) in media.tracks.iter().enumerate() {
let id = t.spec.track_id;
segments.push((
format!("init-stream{id}.m4s"),
crate::pipeline::build_init_segment(
core::slice::from_ref(&t.spec),
media.movie_timescale,
)?,
));
let mut durations = Vec::with_capacity(layout[ti].len());
for (seg_no, (range, tfdt)) in layout[ti].iter().enumerate() {
let samples = &t.samples[range.clone()];
let bytes = crate::build_media_segment(
u32::try_from(seg_no + 1).unwrap_or(u32::MAX),
&[crate::FragmentTrackData::new(id, *tfdt, samples)],
)?;
durations.push(segment_ticks(&bytes)?);
segments.push((format!("chunk-stream{id}-{}.m4s", seg_no + 1), bytes));
}
track_segments.push(crate::dash::TrackSegments {
track_id: id,
durations,
});
}
let text = if opts.low_latency {
let seg = opts.segment_duration.max(1) as f64;
let mut pkg = crate::ll_dash::LlDashPackager::new(
seg,
seg / 2.0,
LL_DASH_LATENCY_TARGET_MS,
availability_start_time_now(),
)?;
pkg.base.dynamic = false;
pkg.base.availability_start_time = None;
pkg.base.segments = track_segments;
if let Some(url) = &opts.utc_timing_url {
pkg =
pkg.with_utc_timing(crate::ll_dash::UTCTIMING_HTTP_HEAD_2014, url.clone());
}
pkg.package(&media)?
} else {
if opts.utc_timing_url.is_some() {
return Err(CliError::UtcTimingNeedsLl);
}
DashPackager {
segments: track_segments,
..DashPackager::default()
}
.package(&media)?
};
Ok(Output::Manifest { text, segments })
}
}
}
fn input_path(args: &Args) -> &Path {
args.in_positional
.as_deref()
.or(args.input.as_deref())
.expect("clap requires one of <IN> or --input")
}
fn resolve_format(args: &Args) -> CliResult<OutputFormat> {
if let Some(f) = args.format {
return Ok(f.into());
}
args.output
.extension()
.and_then(|e| e.to_str())
.and_then(OutputFormat::from_extension)
.ok_or(CliError::UndeterminedFormat)
}
pub fn run(args: Args) -> CliResult<(Container, OutputFormat)> {
let in_path = input_path(&args).to_path_buf();
let format = resolve_format(&args)?;
let input = fs::read(&in_path)?;
let container = detect_container(&input)?;
let opts = Opts {
format,
segment_duration: args.segment_duration,
low_latency: args.ll,
utc_timing_url: args.utc_timing_url.clone(),
tracks: args.tracks.iter().copied().map(TrackId::new).collect(),
};
#[cfg(feature = "cenc")]
let media_bytes;
#[cfg(feature = "cenc")]
let input_ref: &[u8] = if args.decrypt {
media_bytes = decrypt_input(&input, container, &args.keys)?;
&media_bytes
} else {
&input
};
#[cfg(not(feature = "cenc"))]
let input_ref: &[u8] = &input;
let out = run_bytes(input_ref, &opts)?;
write_output(&args.output, out)?;
Ok((container, format))
}
fn write_output(out_path: &Path, out: Output) -> CliResult<()> {
match out {
Output::Bytes(b) => {
fs::write(out_path, b)?;
}
Output::Manifest { text, segments } => {
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
fs::write(out_path, text)?;
let dir = out_path.parent().unwrap_or_else(|| Path::new("."));
for (name, bytes) in segments {
fs::write(dir.join(name), bytes)?;
}
}
}
Ok(())
}
#[cfg(feature = "cenc")]
fn decrypt_input(input: &[u8], container: Container, keys: &[String]) -> CliResult<Vec<u8>> {
use broadcast_common::Decrypt;
if container != Container::Mp4 {
return Err(CliError::Transmux(crate::Error::InvalidInput(
"--decrypt only applies to CENC-protected MP4/CMAF input",
)));
}
let mut key_map = crate::cenc_decrypt::KeyMap::new();
for spec in keys {
let (kid, key) = parse_key(spec)?;
key_map.insert(kid, key);
}
let decryptor = crate::cenc_decrypt::CencDecryptor::from_fmp4(input)?;
let mut media = decryptor.demux()?;
decryptor.decrypt(&mut media, &key_map)?;
let media = filter_for_bmff_mux(&media)?;
Ok(CmafMux::new(1).package(&media)?)
}
#[cfg(feature = "cenc")]
fn parse_key(spec: &str) -> CliResult<([u8; 16], [u8; 16])> {
let (kid_hex, key_hex) = spec
.split_once(':')
.ok_or_else(|| CliError::BadKey(redact_key_spec(spec)))?;
let kid = parse_hex16(kid_hex).ok_or_else(|| CliError::BadKey(redact_key_spec(spec)))?;
let key = parse_hex16(key_hex).ok_or_else(|| CliError::BadKey(redact_key_spec(spec)))?;
Ok((kid, key))
}
#[cfg(feature = "cenc")]
fn parse_hex16(s: &str) -> Option<[u8; 16]> {
let mut out = [0u8; 16];
::hex::decode_to_slice(s.trim(), &mut out).ok()?;
Some(out)
}
#[cfg(all(test, feature = "cenc"))]
mod parse_hex16_tests {
use super::parse_hex16;
#[test]
fn non_ascii_input_of_the_right_byte_length_is_none_not_a_panic() {
let s = format!("a{}b", "é".repeat(15));
assert_eq!(s.len(), 32);
assert_eq!(parse_hex16(&s), None);
}
#[test]
fn a_sign_is_not_a_hex_digit() {
assert_eq!(parse_hex16(&"+1".repeat(16)), None);
assert_eq!(parse_hex16(&"-1".repeat(16)), None);
}
#[test]
fn valid_key_still_parses_and_whitespace_is_trimmed() {
let hex = "0102030405060708090a0b0c0d0e0f10";
assert_eq!(
parse_hex16(&format!(" {hex}\n")),
Some([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])
);
assert_eq!(parse_hex16("0102"), None);
}
}
#[cfg(all(test, feature = "cenc"))]
mod key_redaction_tests {
use super::*;
const KID_HEX: &str = "0102030405060708090a0b0c0d0e0f10";
const KEY_HEX: &str = "aabbccddeeff00112233445566778899";
#[test]
fn missing_separator_reports_length_only() {
let spec = format!("{KID_HEX}{KEY_HEX}");
let err = parse_key(&spec).unwrap_err();
let display = format!("{err}");
let debug = format!("{err:?}");
assert!(
!display.contains(KEY_HEX),
"Display leaked key hex: {display}"
);
assert!(!debug.contains(KEY_HEX), "Debug leaked key hex: {debug}");
assert!(
!display.contains(KID_HEX),
"no separator means no verified KID half either"
);
}
#[test]
fn malformed_kid_half_reports_length_only() {
let spec = format!("not-32-hex-chars:{KEY_HEX}");
let err = parse_key(&spec).unwrap_err();
let display = format!("{err}");
let debug = format!("{err:?}");
assert!(
!display.contains(KEY_HEX),
"Display leaked key hex: {display}"
);
assert!(!debug.contains(KEY_HEX), "Debug leaked key hex: {debug}");
}
#[test]
fn malformed_key_half_still_redacts_and_keeps_the_kid() {
let bad_key_half = "not-32-hex-chars-either";
let spec = format!("{KID_HEX}:{bad_key_half}");
let err = parse_key(&spec).unwrap_err();
let display = format!("{err}");
let debug = format!("{err:?}");
assert!(
!display.contains(bad_key_half),
"Display leaked the key half: {display}"
);
assert!(
!debug.contains(bad_key_half),
"Debug leaked the key half: {debug}"
);
assert!(
display.contains(KID_HEX),
"the KID half is not secret: {display}"
);
}
#[test]
fn well_formed_key_parses_without_going_through_bad_key() {
let spec = format!("{KID_HEX}:{KEY_HEX}");
assert!(parse_key(&spec).is_ok());
}
#[test]
fn unsupported_container_message_has_no_whitespace_run() {
let msg = format!("{}", CliError::UnsupportedContainer("ogg"));
assert_eq!(
msg,
"unsupported input container ogg: this CLI demuxes MPEG-TS, \
MP4/CMAF, MPEG-PS, WebM/Matroska and FLV"
);
}
}