use anyhow::{Result, bail};
use codec::frame::{ColorMetadata, PixelFormat, TransferFn};
pub use codec::encode::tuning::{QualityTarget as PerceptualTarget, SpeedTier as Speed};
pub use codec::frame::VideoCodec;
mod policy;
mod rung;
#[cfg(test)]
mod tests;
pub use policy::*;
pub use rung::*;
#[derive(Debug, Clone)]
pub struct OutputSpec {
pub mode: OutputMode,
pub video_codec: VideoCodecPolicy,
pub audio: AudioCodecPolicy,
pub container: Container,
pub muxer: Muxer,
pub rungs: Vec<Rung>,
pub max_frame_rate: Option<f64>,
pub gpu_index: Option<u32>,
pub encode_policy: EncodePolicy,
pub decode_policy: DecodePolicy,
pub color: ColorPolicy,
pub bit_depth: BitDepth,
pub chunk_seam_mode: ChunkSeamMode,
pub filters: Vec<codec::filter::VideoFilter>,
pub trim_start: Option<f64>,
pub trim_end: Option<f64>,
}
impl Default for OutputSpec {
fn default() -> Self {
Self {
mode: OutputMode::SingleFile,
video_codec: VideoCodecPolicy::Av1,
audio: AudioCodecPolicy::Auto,
container: Container::Mp4,
muxer: Muxer::Mp4File,
rungs: Vec::new(),
max_frame_rate: None,
gpu_index: None,
encode_policy: EncodePolicy::default(),
decode_policy: DecodePolicy::Auto,
color: ColorPolicy::default(),
bit_depth: BitDepth::default(),
chunk_seam_mode: ChunkSeamMode::default(),
filters: Vec::new(),
trim_start: None,
trim_end: None,
}
}
}
impl OutputSpec {
pub fn single_file(rungs: Vec<Rung>) -> Self {
Self {
mode: OutputMode::SingleFile,
container: Container::Mp4,
muxer: Muxer::Mp4File,
rungs,
..Default::default()
}
}
pub fn hls(rungs: Vec<Rung>, segment_seconds: f32) -> Self {
Self {
mode: OutputMode::Hls { segment_seconds },
container: Container::Cmaf,
muxer: Muxer::CmafHls,
rungs,
..Default::default()
}
}
pub fn with_audio(mut self, audio: AudioCodecPolicy) -> Self {
self.audio = audio;
self
}
pub fn with_max_frame_rate(mut self, fps: f64) -> Self {
self.max_frame_rate = Some(fps);
self
}
pub fn with_gpu_index(mut self, idx: u32) -> Self {
self.gpu_index = Some(idx);
self.encode_policy = EncodePolicy::SingleGpu(Some(idx));
self
}
pub fn encode_policy(mut self, policy: EncodePolicy) -> Self {
self.encode_policy = policy;
if let EncodePolicy::SingleGpu(idx) = policy {
self.gpu_index = idx;
}
self
}
pub fn decode_policy(mut self, policy: DecodePolicy) -> Self {
self.decode_policy = policy;
self
}
pub fn with_color(mut self, color: ColorPolicy) -> Self {
self.color = color;
self
}
pub fn with_bit_depth(mut self, depth: BitDepth) -> Self {
self.bit_depth = depth;
self
}
pub fn web_sdr(self) -> Self {
self.with_color(ColorPolicy::TonemapToSdr)
.with_bit_depth(BitDepth::EightBit)
}
pub fn hdr10(self) -> Self {
self.with_color(ColorPolicy::Hdr10)
}
pub fn hlg(self) -> Self {
self.with_color(ColorPolicy::Hlg)
}
pub fn passthrough(self) -> Self {
self.with_color(ColorPolicy::Passthrough)
}
pub fn chunk_seam_mode(mut self, mode: ChunkSeamMode) -> Self {
self.chunk_seam_mode = mode;
self
}
pub fn with_filters(mut self, filters: Vec<codec::filter::VideoFilter>) -> Self {
self.filters = filters;
self
}
pub fn with_trim(mut self, start: Option<f64>, end: Option<f64>) -> Self {
self.trim_start = start;
self.trim_end = end;
self
}
pub fn with_video_codec(mut self, codec: VideoCodecPolicy) -> Self {
self.video_codec = codec;
self
}
pub fn tonemaps(&self) -> bool {
self.color.tonemaps()
}
pub fn resolve_output(
&self,
source_color: ColorMetadata,
source_pixel_format: PixelFormat,
) -> (ColorMetadata, PixelFormat) {
let source_is_hdr = matches!(
source_color.transfer,
TransferFn::St2084 | TransferFn::AribStdB67
);
let (color, mut pix) = match self.color {
ColorPolicy::TonemapToSdr => {
if source_is_hdr {
(ColorMetadata::default(), PixelFormat::Yuv420p)
} else {
(source_color, source_pixel_format)
}
}
ColorPolicy::Passthrough => (source_color, source_pixel_format),
ColorPolicy::Hdr10 => (hdr_metadata(TransferFn::St2084), PixelFormat::Yuv420p10le),
ColorPolicy::Hlg => (hdr_metadata(TransferFn::AribStdB67), PixelFormat::Yuv420p10le),
};
match self.bit_depth {
BitDepth::Auto => {}
BitDepth::EightBit => pix = PixelFormat::Yuv420p,
BitDepth::TenBit => pix = PixelFormat::Yuv420p10le,
}
(color, pix)
}
pub fn validate(&self) -> Result<()> {
if self.rungs.is_empty() {
bail!("OutputSpec has no rungs — at least one rendition is required");
}
for r in &self.rungs {
if r.width == 0 || r.height == 0 {
bail!("rung '{}' has a zero dimension ({}x{})", r.label, r.width, r.height);
}
if r.width % 2 != 0 || r.height % 2 != 0 {
bail!(
"rung '{}' has an odd dimension ({}x{}); 4:2:0 requires even dims",
r.label,
r.width,
r.height
);
}
}
match self.mode {
OutputMode::SingleFile => {
if self.muxer != Muxer::Mp4File || self.container != Container::Mp4 {
bail!("SingleFile mode requires Container::Mp4 + Muxer::Mp4File");
}
}
OutputMode::Hls { segment_seconds } => {
if self.muxer != Muxer::CmafHls || self.container != Container::Cmaf {
bail!("Hls mode requires Container::Cmaf + Muxer::CmafHls");
}
if !(segment_seconds > 0.0) {
bail!("Hls segment_seconds must be > 0 (got {segment_seconds})");
}
}
}
if self.color.is_hdr() && matches!(self.bit_depth, BitDepth::EightBit) {
bail!(
"color {:?} is HDR and requires 10-bit output, but bit_depth is forced to 8-bit",
self.color
);
}
let caps = codec::encode::build_output_caps();
let needs_10bit = self.color.is_hdr() || matches!(self.bit_depth, BitDepth::TenBit);
if needs_10bit && caps.max_bit_depth < 10 {
bail!(
"10-bit output requested (color={:?}, bit_depth={:?}) but this build has no \
10-bit AV1 encoder — build with `nvidia` (NVENC), `amd` (AMF), or `qsv` (oneVPL \
P010) for hardware 10-bit, or `ffmpeg` for software.",
self.color,
self.bit_depth
);
}
if self.color.is_hdr() && !caps.hdr {
bail!(
"HDR output ({:?}) requested but this build has no HDR-capable encoder — build \
with the `nvidia`, `amd`, `qsv`, or `ffmpeg` feature",
self.color
);
}
Ok(())
}
}
fn hdr_metadata(transfer: TransferFn) -> ColorMetadata {
ColorMetadata {
transfer,
matrix_coefficients: 9, colour_primaries: 9, full_range: false,
..ColorMetadata::default()
}
}