use oxideav_core::{
CodecId, Error, MediaType, OptionField, OptionKind, PixelFormat, ReadSeek, RuntimeContext,
SourceOutput, StreamInfo, TimeBase,
};
use oxideav_pixfmt::FormatInfo;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayoutHint {
Any,
Mono,
Gray,
Color,
}
impl LayoutHint {
pub fn for_output(output: &str) -> LayoutHint {
match ext_of(output).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("pbm") => LayoutHint::Mono,
Some("pgm") => LayoutHint::Gray,
Some("ppm") => LayoutHint::Color,
_ => LayoutHint::Any,
}
}
fn admits(self, s: &Shape) -> bool {
match self {
LayoutHint::Any => true,
LayoutHint::Mono => s.mono,
LayoutHint::Gray => s.gray && !s.mono && !s.alpha,
LayoutHint::Color => !s.gray && !s.palette && !s.alpha,
}
}
}
#[derive(Clone, Copy, Debug)]
struct Shape {
gray: bool,
mono: bool,
palette: bool,
alpha: bool,
depth: u8,
bits_per_pixel: u32,
}
fn shape(f: PixelFormat) -> Shape {
let info = FormatInfo::of(f);
let mono = matches!(f, PixelFormat::MonoBlack | PixelFormat::MonoWhite);
let gray = mono
|| matches!(
f,
PixelFormat::Gray8
| PixelFormat::Gray10Le
| PixelFormat::Gray12Le
| PixelFormat::Gray16Le
| PixelFormat::GrayF32Le
| PixelFormat::Ya8
| PixelFormat::Ya16Le
);
Shape {
gray,
mono,
palette: info.is_palette,
alpha: info.has_alpha,
depth: if mono { 1 } else { info.bit_depth },
bits_per_pixel: f.bits_per_pixel_approx(),
}
}
fn loss(src: &Shape, dst: &Shape, want_depth: u8) -> u32 {
let mut l = 0;
if dst.mono && !src.mono {
l += 4000;
} else if dst.palette && !src.palette {
l += 2000;
}
if dst.gray && !src.gray {
l += 1000;
}
if src.alpha && !dst.alpha {
l += 100;
}
if dst.depth < want_depth {
l += 10 * u32::from(want_depth - dst.depth);
}
l
}
pub fn pick_sink_format(
src: PixelFormat,
accepted: &[PixelFormat],
hint: LayoutHint,
depth: Option<u8>,
) -> Option<PixelFormat> {
if accepted.is_empty() {
return None;
}
let s = shape(src);
let want_depth = depth.unwrap_or(s.depth);
let reachable: Vec<(usize, PixelFormat, Shape)> = accepted
.iter()
.enumerate()
.filter(|(_, &f)| oxideav_pixfmt::supports(src, f))
.map(|(i, &f)| (i, f, shape(f)))
.collect();
let mut cands: Vec<_> = reachable
.iter()
.filter(|(_, _, sh)| hint.admits(sh))
.cloned()
.collect();
if cands.is_empty() {
cands = reachable.clone();
}
if let Some(d) = depth {
let capped: Vec<_> = cands
.iter()
.filter(|(_, _, sh)| sh.depth <= d)
.cloned()
.collect();
if !capped.is_empty() {
cands = capped;
}
}
let rank = |sh: &Shape| (loss(&s, sh, want_depth), sh.bits_per_pixel);
let &(_, best, best_shape) = cands.iter().min_by_key(|(i, _, sh)| (rank(sh), *i))?;
let explicit = hint != LayoutHint::Any || depth.is_some();
if explicit {
return (best != src).then_some(best);
}
if accepted.contains(&src) {
return None;
}
let default = accepted[0];
if !oxideav_pixfmt::supports(src, default) || rank(&best_shape) < rank(&shape(default)) {
Some(best)
} else {
None
}
}
pub fn resolve_output_codec(
format_override: Option<&str>,
output: &str,
ctx: &RuntimeContext,
) -> Option<String> {
let ext = format_override
.map(|s| s.to_ascii_lowercase())
.or_else(|| ext_of(output).map(|s| s.to_ascii_lowercase()))?;
let container = ctx.containers.container_for_extension(&ext)?.to_string();
let has_enc = |id: &str| ctx.codecs.has_encoder(&CodecId::new(id));
if has_enc(&container) {
return Some(container);
}
if has_enc(&ext) {
return Some(ext);
}
if let Some(&(_, codec)) = CONTAINER_CODEC_ALIASES
.iter()
.find(|(c, _)| *c == container)
{
if has_enc(codec) {
return Some(codec.to_string());
}
}
Some(container)
}
const CONTAINER_CODEC_ALIASES: &[(&str, &str)] = &[("jpeg", "mjpeg")];
pub fn encoder_accepted_formats(ctx: &RuntimeContext, codec: &str) -> Option<Vec<PixelFormat>> {
ctx.codecs
.implementations(&CodecId::new(codec))
.iter()
.enumerate()
.filter(|(_, i)| i.make_encoder.is_some() && i.caps.media_type == MediaType::Video)
.min_by_key(|(order, i)| (i.caps.priority, *order))
.map(|(_, i)| i.caps.accepted_pixel_formats.clone())
}
pub fn encoder_options(
ctx: &RuntimeContext,
codec: Option<&str>,
ext: Option<&str>,
user: &[(String, String)],
) -> oxideav_core::Result<Vec<(String, String)>> {
let schema = codec.and_then(|c| ctx.codecs.encoder_options_schema(&CodecId::new(c)));
let mut out: Vec<(String, String)> = Vec::new();
for (k, v) in user {
let Some(codec) = codec else {
return Err(Error::invalid(format!(
"convert: --opt {k}={v}: the output has no encoder to hand options to"
)));
};
let fields = schema.unwrap_or(&[]);
let field = fields.iter().find(|f| f.name == k).ok_or_else(|| {
let known: Vec<&str> = fields.iter().map(|f| f.name).collect();
let known = if known.is_empty() {
"it declares no options".to_string()
} else {
format!("known: {}", known.join(", "))
};
Error::invalid(format!(
"convert: --opt {k}={v}: encoder '{codec}' has no option '{k}' ({known})"
))
})?;
check_option_value(field, v).map_err(|why| {
Error::invalid(format!("convert: --opt {k}={v}: {why} ({})", field.help))
})?;
out.retain(|(ek, _)| ek != k);
out.push((k.clone(), v.clone()));
}
let avif =
ext.is_some_and(|e| e.eq_ignore_ascii_case("avif") || e.eq_ignore_ascii_case("avifs"));
if avif && !out.iter().any(|(k, _)| k == "codec") {
let offers_av1 = schema
.and_then(|fields| fields.iter().find(|f| f.name == "codec"))
.is_some_and(|f| matches!(f.kind, OptionKind::Enum(vals) if vals.contains(&"av1")));
if offers_av1 {
out.push(("codec".to_string(), "av1".to_string()));
}
}
Ok(out)
}
fn check_option_value(field: &OptionField, raw: &str) -> Result<(), String> {
let ok = match field.kind {
OptionKind::Bool => matches!(
raw,
"true" | "1" | "yes" | "on" | "false" | "0" | "no" | "off"
),
OptionKind::U32 => raw.parse::<u32>().is_ok(),
OptionKind::I32 => raw.parse::<i32>().is_ok(),
OptionKind::F32 => raw.parse::<f32>().is_ok_and(f32::is_finite),
OptionKind::String => true,
OptionKind::Enum(vals) => vals.contains(&raw),
};
if ok {
return Ok(());
}
Err(match field.kind {
OptionKind::Bool => "expected a boolean (true/false/1/0/yes/no/on/off)".to_string(),
OptionKind::U32 => "expected a non-negative integer".to_string(),
OptionKind::I32 => "expected an integer".to_string(),
OptionKind::F32 => "expected a finite number".to_string(),
OptionKind::Enum(vals) => format!("expected one of {}", vals.join(" | ")),
OptionKind::String => unreachable!("strings always pass"),
})
}
pub fn fanout_stream(streams: &[StreamInfo]) -> usize {
let is_still = |s: &StreamInfo| s.time_base == TimeBase::new(1, 1) && s.duration == Some(1);
if streams.len() < 2 {
return 0;
}
streams.iter().position(|s| !is_still(s)).unwrap_or(0)
}
pub fn probe_video_streams(input: &str, ctx: &RuntimeContext) -> Option<Vec<StreamInfo>> {
let raw = match ctx.sources.open(input).ok()? {
SourceOutput::Bytes(b) => b,
_ => return None,
};
let mut handle: Box<dyn ReadSeek> = Box::new(raw);
let ext = ext_of(input).map(|e| e.to_ascii_lowercase());
let format = ctx
.containers
.probe_input(&mut *handle, ext.as_deref())
.ok()?;
let demuxer = ctx
.containers
.open_demuxer(&format, handle, &ctx.codecs)
.ok()?;
Some(
demuxer
.streams()
.iter()
.filter(|s| s.params.media_type == MediaType::Video)
.cloned()
.collect(),
)
}
fn ext_of(path: &str) -> Option<&str> {
let last = path.rsplit('/').next().unwrap_or(path);
let last = last.split('?').next().unwrap_or(last);
let dot = last.rfind('.')?;
Some(&last[dot + 1..])
}
#[cfg(test)]
mod tests {
use super::*;
use PixelFormat::*;
const PNG: &[PixelFormat] = &[Rgba, Rgb24, Gray8, Pal8, Rgb48Le, Rgba64Le];
const PNM: &[PixelFormat] = &[
MonoBlack, Gray8, Gray16Le, Rgb24, Rgb48Le, Rgba, Rgba64Le, Ya8,
];
fn pick(src: PixelFormat, acc: &[PixelFormat]) -> Option<PixelFormat> {
pick_sink_format(src, acc, LayoutHint::Any, None)
}
#[test]
fn accepted_source_is_left_alone() {
for f in [Rgb24, Rgba, Gray8, Rgb48Le] {
assert_eq!(pick(f, PNG), None, "{f:?}");
}
assert_eq!(pick(Yuv420P, &[]), None, "empty list accepts anything");
}
#[test]
fn yuv_to_png_drops_the_synthetic_alpha() {
assert_eq!(pick(Yuv420P, PNG), Some(Rgb24));
assert_eq!(pick(YuvJ420P, PNG), Some(Rgb24));
assert_eq!(pick(Yuv444P, PNG), Some(Rgb24));
}
#[test]
fn deep_yuv_to_png_keeps_its_bits() {
assert_eq!(pick(Yuv420P10Le, PNG), Some(Rgb48Le));
}
#[test]
fn yuv_with_alpha_keeps_alpha() {
assert_eq!(pick(Yuva420P, PNG), None);
}
#[test]
fn yuv_to_netpbm_is_not_thresholded_to_one_bit() {
assert_eq!(pick(Yuv420P, PNM), Some(Rgb24));
assert_eq!(pick(Yuv420P10Le, PNM), Some(Rgb48Le));
}
#[test]
fn netpbm_extensions_pick_their_family() {
let h = |o| LayoutHint::for_output(o);
assert_eq!(
pick_sink_format(Yuv420P, PNM, h("x.pbm"), None),
Some(MonoBlack)
);
assert_eq!(
pick_sink_format(Yuv420P, PNM, h("x.pgm"), None),
Some(Gray8)
);
assert_eq!(
pick_sink_format(Yuv420P, PNM, h("x.ppm"), None),
Some(Rgb24)
);
assert_eq!(pick_sink_format(Rgb24, PNM, h("x.PGM"), None), Some(Gray8));
assert_eq!(pick_sink_format(Rgb24, PNM, h("x.ppm"), None), None);
assert_eq!(pick_sink_format(Rgba, PNM, h("x.ppm"), None), Some(Rgb24));
assert_eq!(h("x.pam"), LayoutHint::Any);
}
#[test]
fn depth_request_caps_and_targets() {
let d = |src, n| pick_sink_format(src, PNG, LayoutHint::Any, Some(n));
assert_eq!(d(Yuv420P10Le, 8), Some(Rgb24));
assert_eq!(d(Rgb48Le, 8), Some(Rgb24));
assert_eq!(d(Yuv420P, 16), Some(Rgb48Le));
assert_eq!(d(Rgb24, 8), None);
}
#[test]
fn unreachable_candidates_are_skipped() {
assert_eq!(pick(Yuv420P, &[Pal8]), None);
}
#[test]
fn output_hint_reads_the_last_extension() {
assert_eq!(LayoutHint::for_output("dir.ppm/out.png"), LayoutHint::Any);
assert_eq!(LayoutHint::for_output("a/b.pgm"), LayoutHint::Gray);
assert_eq!(LayoutHint::for_output("noext"), LayoutHint::Any);
}
}