use crate::op::{ConvertPlan, Dither, Op};
use indexmap::IndexMap;
use oxideav_core::Error;
use oxideav_pipeline::{FilterNode, Job, OutputSpec, SourceRef, TrackInput, TrackSpec};
use serde_json::{json, Value};
pub fn plan_to_job(plan: &ConvertPlan) -> Result<Job, Error> {
let mut codec_params = json!({});
let mut strip_metadata = false;
let mut format_override: Option<String> = None;
let mut chain: TrackInput = TrackInput::Source(SourceRef {
from: plan.input.clone(),
});
let wrap = |prev: TrackInput, filter: &str, params: Value| {
TrackInput::Filter(FilterNode {
filter: filter.to_string(),
params,
input: Box::new(prev),
})
};
for op in &plan.ops {
match op {
Op::Resize {
width,
height,
bang,
} => {
let _ = bang;
chain = wrap(
chain,
"video.resize",
json!({
"width": width,
"height": height,
"interpolation": "bilinear"
}),
);
}
Op::Blur { radius, sigma } => {
chain = wrap(
chain,
"video.blur",
json!({
"radius": radius,
"sigma": sigma,
"planes": "all"
}),
);
}
Op::Edge { radius } => {
chain = wrap(chain, "video.edge", json!({ "radius": radius }));
}
Op::Colors { count, dither } => {
let dither_str = match dither {
Dither::None => "none",
Dither::Bayer => "bayer",
Dither::FloydSteinberg => "floyd_steinberg",
};
chain = wrap(
chain,
"video.pixfmt",
json!({
"format": "pal8",
"dither": dither_str,
"colors": count,
}),
);
}
Op::Format(fmt) => format_override = Some(fmt.clone()),
Op::Quality(q) => {
codec_params["quality"] = json!(q);
}
Op::Strip => strip_metadata = true,
}
}
if strip_metadata {
codec_params["strip_metadata"] = json!(true);
}
if let Some(ref f) = format_override {
codec_params["format"] = json!(f);
}
let codec = codec_for_output(format_override.as_deref(), &plan.output);
let track = TrackSpec {
input: chain,
codec,
params: codec_params,
stream_selector: None,
};
let mut outputs = IndexMap::new();
outputs.insert(
plan.output.clone(),
OutputSpec {
audio: vec![],
video: vec![],
subtitle: vec![],
all: vec![track],
},
);
Ok(Job {
outputs,
aliases: IndexMap::new(),
threads: None,
})
}
fn codec_for_output(format_override: Option<&str>, output: &str) -> Option<String> {
let ext = format_override
.map(|s| s.to_ascii_lowercase())
.or_else(|| ext_of(output).map(|s| s.to_ascii_lowercase()))?;
let codec = match ext.as_str() {
"png" | "apng" => "png",
"jpg" | "jpeg" => "mjpeg",
"gif" => "gif",
"webp" => "webp",
"bmp" => "bmp",
"tiff" | "tif" => "tiff",
"ico" | "cur" => "png", "jxl" => "jxl",
"jp2" | "j2k" => "jpeg2000",
"avif" => "av1",
"wav" | "wave" => "pcm_s16le",
"flac" => "flac",
"mp3" => "mp3",
"ogg" | "oga" => "vorbis",
"opus" => "opus",
_ => return None,
};
Some(codec.to_string())
}
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::*;
fn plan_with(ops: Vec<Op>) -> ConvertPlan {
ConvertPlan {
input: "in.png".into(),
ops,
output: "out.jpg".into(),
}
}
#[test]
fn empty_plan_is_passthrough() {
let job = plan_to_job(&plan_with(vec![])).unwrap();
assert_eq!(job.outputs.len(), 1);
let (key, out) = job.outputs.iter().next().unwrap();
assert_eq!(key, "out.jpg");
assert_eq!(out.all.len(), 1);
match &out.all[0].input {
TrackInput::Source(s) => assert_eq!(s.from, "in.png"),
other => panic!("expected direct source, got {other:?}"),
}
}
#[test]
fn resize_produces_filter_wrapper() {
let job = plan_to_job(&plan_with(vec![Op::Resize {
width: 64,
height: 32,
bang: false,
}]))
.unwrap();
let out = job.outputs.values().next().unwrap();
match &out.all[0].input {
TrackInput::Filter(f) => {
assert_eq!(f.filter, "video.resize");
assert_eq!(f.params["width"], 64);
assert_eq!(f.params["height"], 32);
}
other => panic!("expected filter, got {other:?}"),
}
}
#[test]
fn chain_order_preserved() {
let job = plan_to_job(&plan_with(vec![
Op::Resize {
width: 64,
height: 64,
bang: false,
},
Op::Blur {
radius: 2,
sigma: 1.0,
},
]))
.unwrap();
let out = job.outputs.values().next().unwrap();
let outer = match &out.all[0].input {
TrackInput::Filter(f) => f,
other => panic!("expected outer filter, got {other:?}"),
};
assert_eq!(outer.filter, "video.blur");
let inner = match outer.input.as_ref() {
TrackInput::Filter(f) => f,
other => panic!("expected inner filter, got {other:?}"),
};
assert_eq!(inner.filter, "video.resize");
}
#[test]
fn quality_and_strip_land_in_codec_params() {
let job = plan_to_job(&plan_with(vec![Op::Quality(85), Op::Strip])).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["quality"], 85);
assert_eq!(track.params["strip_metadata"], true);
}
}