use anyhow::{Result, bail};
use codec::encode::tuning::{QualityTarget, SpeedTier};
use codec::encode::{AUTO_FROM_TARGET, EncoderConfig};
use codec::frame::{ColorMetadata, PixelFormat, TransferFn};
pub use codec::encode::tuning::{QualityTarget as PerceptualTarget, SpeedTier as Speed};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VideoCodec {
#[default]
Av1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AudioPolicy {
#[default]
Auto,
ForceOpus,
Drop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Container {
#[default]
Mp4,
Cmaf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Muxer {
#[default]
Mp4File,
CmafHls,
}
#[derive(Debug, Clone, PartialEq)]
pub enum OutputMode {
SingleFile,
Hls { segment_seconds: f32 },
}
impl Default for OutputMode {
fn default() -> Self {
OutputMode::SingleFile
}
}
#[derive(Debug, Clone)]
pub struct Quality {
pub crf: Option<u8>,
pub speed_preset: Option<u8>,
pub target: QualityTarget,
pub tier: SpeedTier,
pub keyframe_interval: Option<u32>,
}
impl Default for Quality {
fn default() -> Self {
Self {
crf: None,
speed_preset: None,
target: QualityTarget::Standard,
tier: SpeedTier::Standard,
keyframe_interval: None,
}
}
}
impl Quality {
pub fn crf(crf: u8) -> Self {
Self {
crf: Some(crf),
..Default::default()
}
}
pub fn target(target: QualityTarget) -> Self {
Self {
target,
..Default::default()
}
}
pub(crate) fn apply(&self, cfg: &mut EncoderConfig, frame_rate: f64) {
cfg.target = self.target;
cfg.tier = self.tier;
cfg.quality = self.crf.unwrap_or(AUTO_FROM_TARGET);
cfg.speed_preset = self.speed_preset.unwrap_or(AUTO_FROM_TARGET);
cfg.keyframe_interval = self
.keyframe_interval
.unwrap_or_else(|| (frame_rate * 2.0).round().max(1.0) as u32);
}
}
#[derive(Debug, Clone)]
pub struct Rung {
pub width: u32,
pub height: u32,
pub label: String,
pub quality: Quality,
}
impl Rung {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
label: format!("{}p", width.min(height)),
quality: Quality::default(),
}
}
pub fn with_quality(mut self, quality: Quality) -> Self {
self.quality = quality;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn short_side(&self) -> u32 {
self.width.min(self.height)
}
}
#[derive(Debug, Clone)]
pub struct OutputSpec {
pub mode: OutputMode,
pub video_codec: VideoCodec,
pub audio: AudioPolicy,
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_gpu: Option<u32>,
pub color: ColorPolicy,
pub bit_depth: BitDepth,
pub chunk_seam_mode: ChunkSeamMode,
pub filters: Vec<codec::filter::VideoFilter>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EncodePolicy {
#[default]
AllGpus,
SingleGpu(Option<u32>),
Family(GpuFamily),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GpuFamily {
Nvidia,
Amd,
Intel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChunkSeamMode {
#[default]
Parallel,
ParallelConstQp,
Serial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorPolicy {
#[default]
TonemapToSdr,
Passthrough,
Hdr10,
Hlg,
}
impl ColorPolicy {
pub fn tonemaps(self) -> bool {
matches!(self, ColorPolicy::TonemapToSdr)
}
pub fn is_hdr(self) -> bool {
matches!(self, ColorPolicy::Hdr10 | ColorPolicy::Hlg)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BitDepth {
#[default]
Auto,
EightBit,
TenBit,
}
impl Default for OutputSpec {
fn default() -> Self {
Self {
mode: OutputMode::SingleFile,
video_codec: VideoCodec::Av1,
audio: AudioPolicy::Auto,
container: Container::Mp4,
muxer: Muxer::Mp4File,
rungs: Vec::new(),
max_frame_rate: None,
gpu_index: None,
encode_policy: EncodePolicy::default(),
decode_gpu: None,
color: ColorPolicy::default(),
bit_depth: BitDepth::default(),
chunk_seam_mode: ChunkSeamMode::default(),
filters: Vec::new(),
}
}
}
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: AudioPolicy) -> 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_gpu(mut self, idx: Option<u32>) -> Self {
self.decode_gpu = idx;
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 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()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_file_sets_coherent_fields() {
let s = OutputSpec::single_file(vec![Rung::new(1280, 720)]);
assert_eq!(s.mode, OutputMode::SingleFile);
assert_eq!(s.container, Container::Mp4);
assert_eq!(s.muxer, Muxer::Mp4File);
assert!(s.validate().is_ok());
}
#[test]
fn encode_policy_defaults_to_all_gpus() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
assert_eq!(s.gpu_index, None);
}
#[test]
fn chunk_seam_mode_defaults_parallel_and_builder_sets_it() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
assert_eq!(s.chunk_seam_mode, ChunkSeamMode::Parallel);
let s = s.chunk_seam_mode(ChunkSeamMode::Serial);
assert_eq!(s.chunk_seam_mode, ChunkSeamMode::Serial);
let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
.chunk_seam_mode(ChunkSeamMode::ParallelConstQp);
assert_eq!(s.chunk_seam_mode, ChunkSeamMode::ParallelConstQp);
assert!(s.validate().is_ok());
}
#[test]
fn encode_policy_single_gpu_syncs_gpu_index() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
.encode_policy(EncodePolicy::SingleGpu(Some(2)));
assert_eq!(s.encode_policy, EncodePolicy::SingleGpu(Some(2)));
assert_eq!(s.gpu_index, Some(2));
}
#[test]
fn with_gpu_index_implies_single_gpu_policy() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_gpu_index(1);
assert_eq!(s.encode_policy, EncodePolicy::SingleGpu(Some(1)));
assert_eq!(s.gpu_index, Some(1));
}
#[test]
fn encode_policy_family_does_not_pin_gpu_index() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
.encode_policy(EncodePolicy::Family(GpuFamily::Nvidia));
assert_eq!(s.encode_policy, EncodePolicy::Family(GpuFamily::Nvidia));
assert_eq!(s.gpu_index, None);
}
#[test]
fn decode_gpu_defaults_to_none_and_is_settable() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
assert_eq!(s.decode_gpu, None);
let s = s.decode_gpu(Some(0));
assert_eq!(s.decode_gpu, Some(0));
assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
}
#[test]
fn encode_policy_all_gpus_leaves_gpu_index_untouched() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
.with_gpu_index(3)
.encode_policy(EncodePolicy::AllGpus);
assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
assert_eq!(s.gpu_index, Some(3));
}
#[test]
fn hls_sets_coherent_fields() {
let s = OutputSpec::hls(vec![Rung::new(1920, 1080), Rung::new(640, 360)], 4.0);
assert!(matches!(s.mode, OutputMode::Hls { .. }));
assert_eq!(s.container, Container::Cmaf);
assert_eq!(s.muxer, Muxer::CmafHls);
assert!(s.validate().is_ok());
}
#[test]
fn validate_rejects_empty_rungs() {
assert!(OutputSpec::single_file(vec![]).validate().is_err());
}
#[test]
fn validate_rejects_odd_dimensions() {
assert!(OutputSpec::single_file(vec![Rung::new(1281, 720)]).validate().is_err());
}
#[test]
fn validate_rejects_incoherent_mode_muxer() {
let mut s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
s.muxer = Muxer::CmafHls; assert!(s.validate().is_err());
}
#[test]
fn rung_label_uses_short_side() {
assert_eq!(Rung::new(1920, 1080).label, "1080p");
assert_eq!(Rung::new(1080, 1920).label, "1080p");
assert_eq!(Rung::new(640, 360).short_side(), 360);
}
#[test]
fn color_and_pixel_format_default_to_sdr_8bit() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
assert_eq!(s.color, ColorPolicy::TonemapToSdr);
assert_eq!(s.bit_depth, BitDepth::Auto);
assert!(s.tonemaps());
assert!(s.validate().is_ok());
}
#[test]
fn resolve_output_default_folds_hdr_source_to_sdr_8bit() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
let hdr_src = hdr_metadata(TransferFn::St2084);
let (color, pix) = s.resolve_output(hdr_src, PixelFormat::Yuv420p10le);
assert_eq!(color.transfer, TransferFn::Bt709);
assert_eq!(pix, PixelFormat::Yuv420p);
}
#[test]
fn resolve_output_passthrough_keeps_source() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_color(ColorPolicy::Passthrough);
assert!(!s.tonemaps());
let src = hdr_metadata(TransferFn::St2084);
let (color, pix) = s.resolve_output(src, PixelFormat::Yuv420p10le);
assert_eq!(color.transfer, TransferFn::St2084);
assert_eq!(pix, PixelFormat::Yuv420p10le);
}
#[test]
fn validate_rejects_hdr_without_10bit_or_ffmpeg() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_color(ColorPolicy::Hdr10);
let caps = codec::encode::build_output_caps();
if caps.max_bit_depth < 10 {
assert!(s.validate().is_err(), "HDR must be rejected on an 8-bit-only build");
} else {
assert!(s.validate().is_ok());
}
}
#[test]
fn validate_rejects_hdr_forced_8bit() {
let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
.with_color(ColorPolicy::Hdr10)
.with_bit_depth(BitDepth::EightBit);
assert!(s.validate().is_err());
}
#[test]
fn quality_crf_applies_to_encoder_config() {
let q = Quality::crf(28);
let mut cfg = EncoderConfig::default();
q.apply(&mut cfg, 30.0);
assert_eq!(cfg.quality, 28);
assert_eq!(cfg.keyframe_interval, 60); }
}