use crate::frame_tap::{TapSpec, TAP_SINK};
use crate::image_sink::{self, LayoutHint};
use crate::op::{ConvertPlan, Dither, Mesh3DOptions, Mesh3DRenderMode, Op, ProjectionMode};
use indexmap::IndexMap;
use oxideav_core::{Error, MediaType, PixelFormat, RuntimeContext, StreamInfo};
use oxideav_pipeline::schema::{ConvertNode, StreamSelector};
use oxideav_pipeline::{select_streams, OutputKind};
use oxideav_pipeline::{
FilterNode, Job, OutputSpec, Render3DNode, SourceRef, TrackInput, TrackSpec,
};
use serde_json::{json, Value};
pub const RENDER3D_DEFAULT_BACKEND: &str = "scanline";
const DEFAULT_RENDER3D_WIDTH: u32 = 1024;
const DEFAULT_RENDER3D_HEIGHT: u32 = 1024;
const DEFAULT_RENDER3D_BG: [u8; 4] = [0, 0, 0, 0];
const DEFAULT_RENDER3D_FOV_DEG: f32 = 60.0;
pub fn plan_to_job(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<Job, Error> {
plan_pipeline(plan, ctx).map(|p| p.job)
}
pub(crate) fn plan_pipeline(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<Planned, Error> {
let mut sink = SinkSide::default();
let mut chain: TrackInput = TrackInput::Source(SourceRef {
from: plan.input.clone(),
});
for op in &plan.ops {
chain = lower_op(chain, op, &mut sink, false);
}
finish_job(plan, ctx, chain, sink, true)
}
pub fn plan_to_render3d_job(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<Job, Error> {
let (width, height) = pick_render3d_dims(&plan.ops);
let bg = pick_render3d_bg(&plan.ops, &plan.mesh3d_options);
let opts = build_render3d_opts(&plan.mesh3d_options, width, height, bg);
let mut chain = TrackInput::Render3D(Render3DNode {
source: plan.input.clone(),
backend: RENDER3D_DEFAULT_BACKEND.to_string(),
opts,
});
let mut sink = SinkSide::default();
for op in &plan.ops {
chain = lower_op(chain, op, &mut sink, true);
}
finish_job(plan, ctx, chain, sink, false).map(|p| p.job)
}
fn pick_render3d_dims(ops: &[Op]) -> (u32, u32) {
for op in ops.iter().rev() {
if let Op::Resize { width, height, .. } | Op::Thumbnail { width, height, .. } = op {
return (*width, *height);
}
}
(DEFAULT_RENDER3D_WIDTH, DEFAULT_RENDER3D_HEIGHT)
}
fn pick_render3d_bg(ops: &[Op], options: &Mesh3DOptions) -> [u8; 4] {
if let Some(bg) = options.bg {
return bg;
}
for op in ops.iter().rev() {
if let Op::Background(c) = op {
return *c;
}
}
DEFAULT_RENDER3D_BG
}
fn build_render3d_opts(options: &Mesh3DOptions, width: u32, height: u32, bg: [u8; 4]) -> Value {
let shading = options
.render_mode
.map(shading_tag_from_mode)
.unwrap_or("flat");
let projection = options
.projection
.map(projection_tag_from_mode)
.unwrap_or("perspective");
let fov_deg = f32j(options.fov_deg.unwrap_or(DEFAULT_RENDER3D_FOV_DEG));
let light = options
.light
.map(|l| {
json!({
"azimuth_deg": f32j(l.azimuth_deg),
"elevation_deg": f32j(l.elevation_deg),
"intensity": f32j(l.intensity),
})
})
.unwrap_or_else(|| {
json!({
"azimuth_deg": 45.0,
"elevation_deg": 45.0,
"intensity": 1.0,
})
});
let camera = options.camera.map(|c| {
json!({
"elevation_deg": f32j(c.elevation_deg),
"azimuth_deg": f32j(c.azimuth_deg),
"distance": f32j(c.distance),
})
});
let aa = options.aa.unwrap_or(1).clamp(1, 8);
json!({
"width": width,
"height": height,
"background": [bg[0], bg[1], bg[2], bg[3]],
"shading": shading,
"projection": projection,
"fov_deg": fov_deg,
"light": light,
"camera": camera,
"aa": aa,
})
}
fn shading_tag_from_mode(mode: Mesh3DRenderMode) -> &'static str {
match mode {
Mesh3DRenderMode::Flat => "flat",
Mesh3DRenderMode::Wireframe => "wireframe",
Mesh3DRenderMode::Gouraud => "gouraud",
Mesh3DRenderMode::Phong => "phong",
Mesh3DRenderMode::NormalDebug => "normal-debug",
Mesh3DRenderMode::DepthDebug => "depth-debug",
}
}
fn projection_tag_from_mode(mode: ProjectionMode) -> &'static str {
match mode {
ProjectionMode::Perspective => "perspective",
ProjectionMode::Orthographic => "orthographic",
}
}
struct SinkSide {
codec_params: Value,
strip_metadata: bool,
format_override: Option<String>,
depth: Option<u8>,
encoder_opts: Vec<(String, String)>,
threads: Option<usize>,
}
impl Default for SinkSide {
fn default() -> Self {
SinkSide {
codec_params: json!({}),
strip_metadata: false,
format_override: None,
depth: None,
encoder_opts: Vec::new(),
threads: None,
}
}
}
fn f32j(v: f32) -> Value {
json!(v.to_string().parse::<f64>().unwrap_or(f64::from(v)))
}
fn lower_op(chain: TrackInput, op: &Op, sink: &mut SinkSide, absorb_canvas: bool) -> TrackInput {
let wrap = |prev: TrackInput, filter: &str, params: Value| {
TrackInput::Filter(FilterNode {
filter: filter.to_string(),
params,
input: Box::new(prev),
})
};
match op {
Op::Resize {
width,
height,
mode,
} => {
if absorb_canvas {
return chain;
}
wrap(
chain,
"video.resize",
json!({
"width": width,
"height": height,
"interpolation": "bilinear",
"mode": mode.as_tag(),
}),
)
}
Op::Thumbnail {
width,
height,
mode,
} => {
sink.strip_metadata = true;
if absorb_canvas {
return chain;
}
wrap(
chain,
"video.resize",
json!({
"width": width,
"height": height,
"interpolation": "bilinear",
"mode": mode.as_tag(),
}),
)
}
Op::Define { key, value } => {
match value {
Some(v) => sink.codec_params[key.clone()] = json!(v),
None => sink.codec_params[key.clone()] = json!(true),
}
chain
}
Op::Blur { radius, sigma } => wrap(
chain,
"video.blur",
json!({
"radius": radius,
"sigma": f32j(*sigma),
"planes": "all"
}),
),
Op::Edge { radius } => 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",
};
wrap(
chain,
"video.pixfmt",
json!({
"format": "pal8",
"dither": dither_str,
"colors": count,
}),
)
}
Op::Format(fmt) => {
sink.format_override = Some(fmt.clone());
chain
}
Op::Quality(q) => {
sink.codec_params["quality"] = json!(q);
chain
}
Op::Strip => {
sink.strip_metadata = true;
chain
}
Op::Depth(d) => {
sink.depth = Some(*d);
chain
}
Op::EncoderOpt { key, value } => {
sink.encoder_opts.push((key.clone(), value.clone()));
chain
}
Op::Threads(n) => {
sink.threads = (*n >= 1).then_some(*n);
chain
}
Op::Density(_) | Op::Background(_) | Op::Alpha(_) => chain,
Op::Rotate { degrees } => wrap(chain, "video.rotate", json!({ "degrees": degrees })),
Op::Flip => wrap(chain, "video.flip", json!({})),
Op::Flop => wrap(chain, "video.flop", json!({})),
Op::Crop { x, y, w, h } => wrap(
chain,
"video.crop",
json!({ "x": x, "y": y, "width": w, "height": h }),
),
Op::Extent {
width,
height,
x,
y,
bg,
} => {
wrap(
chain,
"video.extent",
json!({
"width": width,
"height": height,
"offset_x": x,
"offset_y": y,
"background": [bg[0], bg[1], bg[2], bg[3]],
}),
)
}
Op::Negate => wrap(chain, "video.negate", json!({})),
Op::Sharpen { radius, sigma } => wrap(
chain,
"video.sharpen",
json!({ "radius": radius, "sigma": f32j(*sigma) }),
),
Op::Unsharp {
radius,
sigma,
amount,
threshold,
} => wrap(
chain,
"video.unsharp",
json!({
"radius": radius,
"sigma": f32j(*sigma),
"amount": f32j(*amount),
"threshold": threshold,
}),
),
Op::Gamma { value } => wrap(chain, "video.gamma", json!({ "value": f32j(*value) })),
Op::BrightnessContrast {
brightness,
contrast,
} => wrap(
chain,
"video.brightness-contrast",
json!({ "brightness": f32j(*brightness), "contrast": f32j(*contrast) }),
),
Op::Contrast { delta } => {
let pct = f64::from(*delta) * 5.0;
wrap(chain, "video.contrast", json!({ "value": pct }))
}
Op::Sepia { threshold } => wrap(
chain,
"video.sepia",
json!({ "threshold": f32j(*threshold) }),
),
Op::Modulate {
brightness,
saturation,
hue,
} => {
let hue_clean = f32j(*hue).as_f64().unwrap_or(f64::from(*hue));
let hue_degrees = (hue_clean - 100.0) * 1.8;
wrap(
chain,
"video.modulate",
json!({
"brightness": f32j(*brightness),
"saturation": f32j(*saturation),
"hue_degrees": hue_degrees,
}),
)
}
Op::Level {
black,
gamma,
white,
} => wrap(
chain,
"video.level",
json!({ "black": black, "gamma": f32j(*gamma), "white": white }),
),
Op::Normalize => wrap(chain, "video.normalize", json!({})),
Op::Threshold { value } => wrap(chain, "video.threshold", json!({ "value": value })),
Op::Posterize { levels } => wrap(chain, "video.posterize", json!({ "levels": levels })),
Op::Solarize { value } => wrap(chain, "video.solarize", json!({ "value": value })),
Op::Colorspace(cs) => {
let lower = cs.to_ascii_lowercase();
if lower == "gray" || lower == "grey" {
return wrap(chain, "video.grayscale", json!({ "preserve_alpha": true }));
}
chain
}
Op::Vignette {
radius,
sigma,
x,
y,
} => wrap(
chain,
"video.vignette",
json!({
"x": f32j(*x),
"y": f32j(*y),
"radius": f32j(*radius),
"sigma": f32j(*sigma),
}),
),
Op::Colorize { color, amount } => wrap(
chain,
"video.colorize",
json!({
"color": [color[0], color[1], color[2], color[3]],
"amount": f32j(*amount),
}),
),
Op::Equalize => wrap(chain, "video.equalize", json!({})),
Op::AutoGamma => wrap(chain, "video.auto-gamma", json!({})),
Op::Trim { fuzz } => {
wrap(chain, "video.trim", json!({ "fuzz": fuzz }))
}
Op::Roll { dx, dy } => {
wrap(chain, "video.roll", json!({ "dx": dx, "dy": dy }))
}
}
}
pub(crate) struct Planned {
pub job: Job,
pub notes: Vec<String>,
pub tap: Option<TapSpec>,
}
struct StillInput {
ordinal: usize,
streams: Vec<StreamInfo>,
accepted: Vec<PixelFormat>,
}
fn finish_job(
plan: &ConvertPlan,
ctx: &RuntimeContext,
chain: TrackInput,
mut sink: SinkSide,
probe_input: bool,
) -> Result<Planned, Error> {
if sink.strip_metadata {
sink.codec_params["strip_metadata"] = json!(true);
}
if let Some(ref f) = sink.format_override {
sink.codec_params["format"] = json!(f);
}
let codec = codec_for_output(sink.format_override.as_deref(), &plan.output, ctx);
let out_ext = sink
.format_override
.clone()
.or_else(|| ext_of(&plan.output).map(str::to_string));
let options = image_sink::encoder_options(
ctx,
codec.as_deref(),
out_ext.as_deref(),
&sink.encoder_opts,
)?;
let fanout = plan.output_template.is_some();
let wants_tap = fanout || !options.is_empty();
let mut notes = Vec::new();
let still = if probe_input {
still_input(plan, ctx, codec.as_deref(), fanout, &mut notes)
} else {
None
};
let (job, tap) = match (still, wants_tap) {
(Some(si), false) => {
let job = still_job(plan, chain, codec, sink, &si, &mut notes)?;
(job, None)
}
(Some(si), true) => {
let (job, tap) = tap_job(plan, ctx, chain, codec, sink, si, options)?;
(job, Some(tap))
}
(None, true) => {
let what = if fanout {
format!(
"a `%d` output template ('{}') fans out one file per frame",
plan.output
)
} else {
"encoder options (--opt, or ones the output extension implies)".to_string()
};
return Err(Error::unsupported(format!(
"convert: {what} — that needs a still-image output format (png, ppm, heic, avif, …) and an input file whose streams can be read"
)));
}
(None, false) => {
if sink.depth.is_some() {
notes.push(
"convert: note: -depth applies to still-image outputs only; ignored"
.to_string(),
);
}
if probe_input {
if let Some(out) =
container_default_output(plan, ctx, &chain, &sink, codec.as_deref(), &mut notes)
{
let job = single_output_job(&plan.output, out, sink.threads)?;
return Ok(Planned {
job,
notes,
tap: None,
});
}
}
let track = TrackSpec {
input: chain,
codec,
params: sink.codec_params,
stream_selector: None,
};
let out = OutputSpec {
audio: vec![],
video: vec![],
subtitle: vec![],
all: vec![track],
};
(single_output_job(&plan.output, out, sink.threads)?, None)
}
};
Ok(Planned { job, notes, tap })
}
fn container_default_output(
plan: &ConvertPlan,
ctx: &RuntimeContext,
chain: &TrackInput,
sink: &SinkSide,
codec: Option<&str>,
notes: &mut Vec<String>,
) -> Option<OutputSpec> {
let ext = sink
.format_override
.clone()
.or_else(|| ext_of(&plan.output).map(str::to_string))?
.to_ascii_lowercase();
let container = ctx.containers.container_for_extension(&ext)?.to_string();
let streams = image_sink::probe_streams(&plan.input, ctx)?;
let output = match codec {
Some(c) => OutputKind::SingleCodec(c),
None => OutputKind::Container(&container),
};
let selection = select_streams(&ctx.containers, &ctx.codecs, output, &streams);
if selection.keep.is_empty() {
return None;
}
let timed_video_kept = streams.iter().any(|s| {
s.params.media_type == MediaType::Video
&& selection.keeps(s.index)
&& !image_sink::is_still(s)
});
let skipped_stills: Vec<u32> = if timed_video_kept {
streams
.iter()
.filter(|s| {
s.params.media_type == MediaType::Video
&& selection.keeps(s.index)
&& image_sink::is_still(s)
})
.map(|s| s.index)
.collect()
} else {
Vec::new()
};
let kept = |idx: u32| selection.keeps(idx) && !skipped_stills.contains(&idx);
let tracks: Vec<(MediaType, u32, Option<String>)> = match codec {
Some(c) => kind_ordinals(&streams)
.into_iter()
.map(|(idx, kind, ordinal)| (idx, kind, ordinal, Some(c.to_string())))
.filter(|(idx, ..)| kept(*idx))
.map(|(_, kind, ordinal, codec)| (kind, ordinal, codec))
.collect(),
None => {
let planned = crate::container_defaults::plan_tracks(ctx, &container, &streams);
if planned.is_none() && selection.dropped.is_empty() && skipped_stills.is_empty() {
return None;
}
let ordinals = kind_ordinals(&streams);
ordinals
.into_iter()
.filter(|(idx, ..)| kept(*idx))
.map(|(_, kind, ordinal)| {
let codec = planned.as_ref().and_then(|p| {
p.iter()
.find(|(k, o, _)| *k == kind && *o == ordinal)
.and_then(|(_, _, c)| c.clone())
});
(kind, ordinal, codec)
})
.collect()
}
};
if selection.dropped.is_empty() && skipped_stills.is_empty() && codec.is_some() {
return None;
}
for idx in &skipped_stills {
notes.push(format!(
"convert: skipping still-image stream #{idx} of '{}' (the cover image); a timed output takes the image-sequence track",
plan.input
));
}
notes.extend(
selection
.notes
.iter()
.map(|n| format!("convert: {n} ('{}')", plan.input)),
);
let mut out = OutputSpec {
audio: vec![],
video: vec![],
subtitle: vec![],
all: vec![],
};
for (kind, ordinal, codec) in tracks {
let params = if codec.is_some() {
sink.codec_params.clone()
} else {
json!({})
};
let track = TrackSpec {
input: chain.clone(),
codec,
params,
stream_selector: Some(StreamSelector {
kind: Some(kind),
index: Some(ordinal),
}),
};
match kind {
MediaType::Video => out.video.push(track),
_ => out.audio.push(track),
}
}
Some(out)
}
fn kind_ordinals(streams: &[StreamInfo]) -> Vec<(u32, MediaType, u32)> {
let (mut video, mut audio) = (0u32, 0u32);
streams
.iter()
.filter_map(|s| {
let counter = match s.params.media_type {
MediaType::Video => &mut video,
MediaType::Audio => &mut audio,
_ => return None,
};
let ordinal = *counter;
*counter += 1;
Some((s.index, s.params.media_type, ordinal))
})
.collect()
}
fn single_output_job(key: &str, out: OutputSpec, threads: Option<usize>) -> Result<Job, Error> {
let mut outputs = IndexMap::new();
outputs.insert(key.to_string(), out);
let job = Job {
outputs,
aliases: IndexMap::new(),
threads,
};
job.validate()?;
Ok(job)
}
fn still_input(
plan: &ConvertPlan,
ctx: &RuntimeContext,
codec: Option<&str>,
fanout: bool,
notes: &mut Vec<String>,
) -> Option<StillInput> {
let accepted = image_sink::encoder_accepted_formats(ctx, codec?)?;
let streams = image_sink::probe_video_streams(&plan.input, ctx)?;
if streams.is_empty() {
return None;
}
let ordinal = if fanout {
image_sink::fanout_stream(&streams)
} else {
image_sink::still_stream(&streams)
};
if streams.len() > 1 {
notes.push(if fanout {
format!(
"convert: note: '{}' has {} video streams; writing the frames of video stream #{ordinal}",
plan.input,
streams.len()
)
} else {
format!(
"convert: note: '{}' has {} video streams; writing video stream #{ordinal} (the primary still image); use a `%d` output template for the image-sequence frames",
plan.input,
streams.len()
)
});
}
Some(StillInput {
ordinal,
streams,
accepted,
})
}
fn video_selector(ordinal: usize) -> StreamSelector {
StreamSelector {
kind: Some(MediaType::Video),
index: Some(ordinal as u32),
}
}
fn wrap_convert(chain: TrackInput, target: PixelFormat) -> TrackInput {
match pixfmt_name(target) {
Some(name) => TrackInput::Convert(ConvertNode {
convert: name,
input: Box::new(chain),
}),
None => chain,
}
}
fn still_job(
plan: &ConvertPlan,
mut chain: TrackInput,
codec: Option<String>,
sink: SinkSide,
si: &StillInput,
notes: &mut Vec<String>,
) -> Result<Job, Error> {
if matches!(chain, TrackInput::Source(_)) {
let hint = LayoutHint::for_output(&plan.output);
if let Some(target) = si.streams[si.ordinal]
.params
.pixel_format
.and_then(|src| image_sink::pick_sink_format(src, &si.accepted, hint, sink.depth))
{
chain = wrap_convert(chain, target);
}
} else if sink.depth.is_some() {
notes.push(
"convert: note: -depth applies to plain conversions (no filter ops) unless encoder options are given; ignored"
.to_string(),
);
}
let track = TrackSpec {
input: chain,
codec,
params: sink.codec_params,
stream_selector: Some(video_selector(si.ordinal)),
};
single_output_job(
&plan.output,
OutputSpec {
audio: vec![],
video: vec![track],
subtitle: vec![],
all: vec![],
},
sink.threads,
)
}
fn tap_job(
plan: &ConvertPlan,
ctx: &RuntimeContext,
chain: TrackInput,
codec: Option<String>,
sink: SinkSide,
si: StillInput,
options: Vec<(String, String)>,
) -> Result<(Job, TapSpec), Error> {
let codec = codec.ok_or_else(|| Error::unsupported("convert: no output codec"))?;
let stream = si.streams[si.ordinal].clone();
let src = stream.params.pixel_format.ok_or_else(|| {
Error::unsupported(format!(
"convert: '{}' does not declare the pixel layout of video stream #{}",
plan.input, si.ordinal
))
})?;
let hint = LayoutHint::for_output(&plan.output);
let target =
image_sink::pick_sink_format(src, &si.accepted, hint, sink.depth).unwrap_or_else(|| {
if si.accepted.is_empty() || si.accepted.contains(&src) {
src
} else {
si.accepted[0]
}
});
let bare = matches!(chain, TrackInput::Source(_));
let chain = if target != src {
let wrapped = wrap_convert(chain, target);
if !wrapped.is_convert() {
return Err(Error::unsupported(format!(
"convert: no job-schema name for the {target:?} layout"
)));
}
wrapped
} else {
chain
};
let dims = if bare {
stream.params.width.zip(stream.params.height)
} else {
None
};
let container_key = sink
.format_override
.clone()
.or_else(|| ext_of(&plan.output).map(str::to_string))
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
let container = ctx
.containers
.container_for_extension(&container_key)
.ok_or_else(|| {
Error::format_not_found(format!(
"convert: no container registered for '.{container_key}'"
))
})?
.to_string();
let track = TrackSpec {
input: chain,
codec: None,
params: json!({}),
stream_selector: Some(video_selector(si.ordinal)),
};
let job = single_output_job(
TAP_SINK,
OutputSpec {
audio: vec![],
video: vec![track],
subtitle: vec![],
all: vec![],
},
sink.threads,
)?;
let threads = sink
.threads
.unwrap_or_else(|| oxideav_core::ExecutionContext::auto().threads);
let tap = TapSpec {
codec,
container,
options,
frame_format: target,
dims,
stream,
output: plan.output.clone(),
template: plan.output_template.clone(),
threads,
};
Ok((job, tap))
}
fn pixfmt_name(fmt: PixelFormat) -> Option<String> {
let name = format!("{fmt:?}").to_ascii_lowercase();
(oxideav_pipeline::schema::parse_pixel_format(&name).ok() == Some(fmt)).then_some(name)
}
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..])
}
fn codec_for_output(
format_override: Option<&str>,
output: &str,
ctx: &RuntimeContext,
) -> Option<String> {
image_sink::resolve_output_codec(format_override, output, ctx)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_ordinals_count_per_media_type_in_input_order() {
use oxideav_core::{CodecId, CodecParameters, TimeBase};
let mk = |index: u32, params: CodecParameters| StreamInfo {
index,
time_base: TimeBase::new(1, 25),
duration: None,
start_time: Some(0),
params,
};
let streams = [
mk(0, CodecParameters::video(CodecId::new("h264"))),
mk(1, CodecParameters::audio(CodecId::new("aac"))),
mk(2, CodecParameters::subtitle(CodecId::new("srt"))),
mk(3, CodecParameters::audio(CodecId::new("flac"))),
];
assert_eq!(
kind_ordinals(&streams),
vec![
(0, MediaType::Video, 0),
(1, MediaType::Audio, 0),
(3, MediaType::Audio, 1),
]
);
}
use crate::op::ResizeMode;
fn plan_with(ops: Vec<Op>) -> ConvertPlan {
ConvertPlan {
input: "in.png".into(),
input_pages: None,
ops,
output: "out.jpg".into(),
output_template: None,
help: false,
ping: false,
probe: false,
probe_json: false,
probe_watch: false,
mesh3d_options: Default::default(),
}
}
fn empty_ctx() -> RuntimeContext {
RuntimeContext::new()
}
#[test]
fn empty_plan_is_passthrough() {
let job = plan_to_job(&plan_with(vec![]), &empty_ctx()).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,
mode: ResizeMode::Default,
}]),
&empty_ctx(),
)
.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,
mode: ResizeMode::Default,
},
Op::Blur {
radius: 2,
sigma: 1.0,
},
]),
&empty_ctx(),
)
.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]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["quality"], 85);
assert_eq!(track.params["strip_metadata"], true);
}
#[test]
fn resize_mode_tag_emitted_in_filter_params() {
for (mode, tag) in [
(ResizeMode::Default, "default"),
(ResizeMode::Force, "force"),
(ResizeMode::Fill, "fill"),
(ResizeMode::Shrink, "shrink"),
(ResizeMode::Grow, "grow"),
(ResizeMode::Percent, "percent"),
(ResizeMode::Area, "area"),
] {
let job = plan_to_job(
&plan_with(vec![Op::Resize {
width: 100,
height: 100,
mode,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = match &track.input {
TrackInput::Filter(f) => f,
other => panic!("expected resize filter, got {other:?}"),
};
assert_eq!(f.params["mode"], tag, "mode={mode:?}");
}
}
#[test]
fn thumbnail_unrolls_into_resize_plus_strip() {
let job = plan_to_job(
&plan_with(vec![Op::Thumbnail {
width: 200,
height: 200,
mode: ResizeMode::Fill,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = match &track.input {
TrackInput::Filter(f) => f,
other => panic!("expected outer resize filter, got {other:?}"),
};
assert_eq!(f.filter, "video.resize");
assert_eq!(f.params["mode"], "fill");
assert_eq!(track.params["strip_metadata"], true);
}
#[test]
fn define_with_value_lands_in_codec_params() {
let job = plan_to_job(
&plan_with(vec![Op::Define {
key: "jpeg:dct-method".into(),
value: Some("float".into()),
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["jpeg:dct-method"], "float");
}
#[test]
fn define_bare_key_becomes_json_true() {
let job = plan_to_job(
&plan_with(vec![Op::Define {
key: "png:strip-comments".into(),
value: None,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["png:strip-comments"], true);
}
#[test]
fn multiple_defines_all_preserved() {
let job = plan_to_job(
&plan_with(vec![
Op::Define {
key: "jpeg:dct-method".into(),
value: Some("float".into()),
},
Op::Define {
key: "jpeg:optimize-coding".into(),
value: Some("true".into()),
},
Op::Define {
key: "webp:lossless".into(),
value: None,
},
]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["jpeg:dct-method"], "float");
assert_eq!(track.params["jpeg:optimize-coding"], "true");
assert_eq!(track.params["webp:lossless"], true);
}
#[test]
fn unknown_extension_returns_none() {
let ctx = empty_ctx();
assert!(codec_for_output(None, "out.jpg", &ctx).is_none());
assert!(codec_for_output(None, "out.mkv", &ctx).is_none());
assert!(codec_for_output(None, "out.unknown_ext_xyz", &ctx).is_none());
}
#[test]
fn registry_hit_returns_container_name() {
let mut ctx = RuntimeContext::new();
ctx.containers.register_extension("foo", "foo_codec");
assert_eq!(
codec_for_output(None, "out.foo", &ctx).as_deref(),
Some("foo_codec")
);
assert_eq!(
codec_for_output(None, "OUT.FOO", &ctx).as_deref(),
Some("foo_codec")
);
}
fn collect_filter_names(input: &TrackInput) -> Vec<String> {
let mut names = Vec::new();
let mut cur = input;
while let TrackInput::Filter(f) = cur {
names.push(f.filter.clone());
cur = f.input.as_ref();
}
names.reverse();
names
}
fn find_filter<'a>(input: &'a TrackInput, name: &str) -> Option<&'a FilterNode> {
let mut cur = input;
while let TrackInput::Filter(f) = cur {
if f.filter == name {
return Some(f);
}
cur = f.input.as_ref();
}
None
}
#[test]
fn sharpen_wires_to_video_sharpen_factory() {
let job = plan_to_job(
&plan_with(vec![Op::Sharpen {
radius: 2,
sigma: 1.0,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.sharpen").expect("video.sharpen node");
assert_eq!(f.params["radius"], 2);
assert!((f.params["sigma"].as_f64().unwrap() - 1.0).abs() < 1e-6);
}
#[test]
fn unsharp_wires_full_params() {
let job = plan_to_job(
&plan_with(vec![Op::Unsharp {
radius: 3,
sigma: 1.5,
amount: 0.8,
threshold: 7,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.unsharp").expect("video.unsharp node");
assert_eq!(f.params["radius"], 3);
assert!((f.params["sigma"].as_f64().unwrap() - 1.5).abs() < 1e-6);
assert!((f.params["amount"].as_f64().unwrap() - 0.8).abs() < 1e-6);
assert_eq!(f.params["threshold"], 7);
}
#[test]
fn gamma_wires_value_key() {
let job = plan_to_job(&plan_with(vec![Op::Gamma { value: 2.2 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.gamma").expect("video.gamma node");
assert!((f.params["value"].as_f64().unwrap() - 2.2).abs() < 1e-6);
}
#[test]
fn brightness_contrast_wires_both_keys() {
let job = plan_to_job(
&plan_with(vec![Op::BrightnessContrast {
brightness: 10.0,
contrast: -5.0,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.brightness-contrast")
.expect("video.brightness-contrast node");
assert!((f.params["brightness"].as_f64().unwrap() - 10.0).abs() < 1e-6);
assert!((f.params["contrast"].as_f64().unwrap() - (-5.0)).abs() < 1e-6);
}
#[test]
fn contrast_step_translates_to_factor() {
let job = plan_to_job(&plan_with(vec![Op::Contrast { delta: 1 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.contrast").expect("video.contrast node");
assert!((f.params["value"].as_f64().unwrap() - 5.0).abs() < 1e-6);
}
#[test]
fn modulate_translates_hue_to_degrees() {
let job = plan_to_job(
&plan_with(vec![Op::Modulate {
brightness: 100.0,
saturation: 100.0,
hue: 200.0,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.modulate").expect("video.modulate node");
assert!((f.params["brightness"].as_f64().unwrap() - 100.0).abs() < 1e-6);
assert!((f.params["saturation"].as_f64().unwrap() - 100.0).abs() < 1e-6);
assert!((f.params["hue_degrees"].as_f64().unwrap() - 180.0).abs() < 1e-6);
}
#[test]
fn level_wires_all_three_endpoints() {
let job = plan_to_job(
&plan_with(vec![Op::Level {
black: 16,
gamma: 1.2,
white: 235,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.level").expect("video.level node");
assert_eq!(f.params["black"], 16);
assert!((f.params["gamma"].as_f64().unwrap() - 1.2).abs() < 1e-6);
assert_eq!(f.params["white"], 235);
}
#[test]
fn normalize_wires_empty_params() {
let job = plan_to_job(&plan_with(vec![Op::Normalize]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.normalize").expect("video.normalize node");
assert!(f.params.as_object().unwrap().is_empty());
}
#[test]
fn threshold_wires_value() {
let job =
plan_to_job(&plan_with(vec![Op::Threshold { value: 200 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.threshold").expect("video.threshold node");
assert_eq!(f.params["value"], 200);
}
#[test]
fn posterize_wires_levels() {
let job = plan_to_job(&plan_with(vec![Op::Posterize { levels: 6 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.posterize").expect("video.posterize node");
assert_eq!(f.params["levels"], 6);
}
#[test]
fn solarize_wires_value() {
let job = plan_to_job(&plan_with(vec![Op::Solarize { value: 100 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.solarize").expect("video.solarize node");
assert_eq!(f.params["value"], 100);
}
#[test]
fn sepia_wires_threshold() {
let job =
plan_to_job(&plan_with(vec![Op::Sepia { threshold: 0.8 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.sepia").expect("video.sepia node");
assert!((f.params["threshold"].as_f64().unwrap() - 0.8).abs() < 1e-6);
}
#[test]
fn colorspace_gray_wires_grayscale_factory() {
let job = plan_to_job(
&plan_with(vec![Op::Colorspace("Gray".into())]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.grayscale").expect("video.grayscale node");
assert_eq!(f.params["preserve_alpha"], true);
}
#[test]
fn colorspace_rgb_is_pure_passthrough() {
let job =
plan_to_job(&plan_with(vec![Op::Colorspace("RGB".into())]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
match &track.input {
TrackInput::Source(_) => {}
other => panic!("expected raw source, got {other:?}"),
}
}
#[test]
fn rotate_wires_video_rotate_factory() {
let job = plan_to_job(&plan_with(vec![Op::Rotate { degrees: 90 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.rotate").expect("video.rotate node");
assert_eq!(f.params["degrees"], 90);
}
#[test]
fn flip_flop_wire_independent_factories() {
let job = plan_to_job(&plan_with(vec![Op::Flip, Op::Flop]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let names = collect_filter_names(&track.input);
assert_eq!(
names,
vec!["video.flip".to_string(), "video.flop".to_string()]
);
}
#[test]
fn crop_wires_xywh() {
let job = plan_to_job(
&plan_with(vec![Op::Crop {
x: 1,
y: 2,
w: 10,
h: 20,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.crop").expect("video.crop node");
assert_eq!(f.params["x"], 1);
assert_eq!(f.params["y"], 2);
assert_eq!(f.params["width"], 10);
assert_eq!(f.params["height"], 20);
}
#[test]
fn negate_wires_video_negate_factory() {
let job = plan_to_job(&plan_with(vec![Op::Negate]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
find_filter(&track.input, "video.negate").expect("video.negate node");
}
#[test]
fn extent_wires_width_height_offsets_background() {
let job = plan_to_job(
&plan_with(vec![Op::Extent {
width: 200,
height: 150,
x: -10,
y: 20,
bg: [12, 34, 56, 200],
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.extent").expect("video.extent node");
assert_eq!(f.params["width"], 200);
assert_eq!(f.params["height"], 150);
assert_eq!(f.params["offset_x"], -10);
assert_eq!(f.params["offset_y"], 20);
let bg = f.params["background"].as_array().expect("background array");
assert_eq!(bg.len(), 4);
assert_eq!(bg[0], 12);
assert_eq!(bg[1], 34);
assert_eq!(bg[2], 56);
assert_eq!(bg[3], 200);
}
#[test]
fn sharpen_emitted_params_are_accepted_by_image_filter_registry() {
use oxideav_core::{PixelFormat, PortSpec, TimeBase};
let job = plan_to_job(
&plan_with(vec![Op::Sharpen {
radius: 1,
sigma: 0.5,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.sharpen").expect("video.sharpen node");
let mut filter_ctx = RuntimeContext::new();
oxideav_image_filter::register(&mut filter_ctx);
if !filter_ctx.filters.contains("sharpen") {
return;
}
let inputs = [PortSpec::video(
"in",
4,
4,
PixelFormat::Rgba,
TimeBase::new(1, 30),
)];
filter_ctx
.filters
.make("video.sharpen", &f.params, &inputs)
.expect("registry must accept the JSON params we emit");
}
#[test]
fn every_round_next_op_round_trips_through_image_filter_registry() {
use oxideav_core::{PixelFormat, PortSpec, TimeBase};
let make_inputs = |w: u32, h: u32, fmt: PixelFormat| {
[PortSpec::video("in", w, h, fmt, TimeBase::new(1, 30))]
};
let mut filter_ctx = RuntimeContext::new();
oxideav_image_filter::register(&mut filter_ctx);
if !filter_ctx.filters.contains("sharpen") {
return;
}
let cases: Vec<(Op, &str, [PortSpec; 1])> = vec![
(
Op::Sharpen {
radius: 1,
sigma: 0.5,
},
"video.sharpen",
make_inputs(4, 4, PixelFormat::Rgba),
),
(
Op::Unsharp {
radius: 1,
sigma: 0.5,
amount: 1.0,
threshold: 0,
},
"video.unsharp",
make_inputs(4, 4, PixelFormat::Rgba),
),
(
Op::Gamma { value: 1.8 },
"video.gamma",
make_inputs(4, 4, PixelFormat::Rgba),
),
(
Op::BrightnessContrast {
brightness: 10.0,
contrast: -5.0,
},
"video.brightness-contrast",
make_inputs(4, 4, PixelFormat::Rgba),
),
(
Op::Contrast { delta: 1 },
"video.contrast",
make_inputs(4, 4, PixelFormat::Rgba),
),
(
Op::Sepia { threshold: 0.8 },
"video.sepia",
make_inputs(4, 4, PixelFormat::Rgb24),
),
(
Op::Modulate {
brightness: 100.0,
saturation: 100.0,
hue: 100.0,
},
"video.modulate",
make_inputs(4, 4, PixelFormat::Rgba),
),
(
Op::Level {
black: 16,
gamma: 1.2,
white: 235,
},
"video.level",
make_inputs(4, 4, PixelFormat::Gray8),
),
(
Op::Normalize,
"video.normalize",
make_inputs(4, 4, PixelFormat::Gray8),
),
(
Op::Threshold { value: 128 },
"video.threshold",
make_inputs(4, 4, PixelFormat::Gray8),
),
(
Op::Posterize { levels: 4 },
"video.posterize",
make_inputs(4, 4, PixelFormat::Gray8),
),
(
Op::Solarize { value: 100 },
"video.solarize",
make_inputs(4, 4, PixelFormat::Gray8),
),
(
Op::Colorspace("Gray".into()),
"video.grayscale",
make_inputs(4, 4, PixelFormat::Rgba),
),
(
Op::Rotate { degrees: 90 },
"video.rotate",
make_inputs(4, 4, PixelFormat::Rgba),
),
(Op::Flip, "video.flip", make_inputs(4, 4, PixelFormat::Rgba)),
(Op::Flop, "video.flop", make_inputs(4, 4, PixelFormat::Rgba)),
(
Op::Crop {
x: 1,
y: 1,
w: 2,
h: 2,
},
"video.crop",
make_inputs(16, 16, PixelFormat::Rgba),
),
(
Op::Negate,
"video.negate",
make_inputs(4, 4, PixelFormat::Rgba),
),
];
let mut later_cases: Vec<(Op, &str, [PortSpec; 1])> = Vec::new();
if filter_ctx.filters.contains("vignette") {
later_cases.push((
Op::Vignette {
radius: 50.0,
sigma: 25.0,
x: 0.5,
y: 0.5,
},
"video.vignette",
make_inputs(4, 4, PixelFormat::Rgba),
));
later_cases.push((
Op::Colorize {
color: [200, 100, 50, 255],
amount: 0.4,
},
"video.colorize",
make_inputs(4, 4, PixelFormat::Rgba),
));
later_cases.push((
Op::Equalize,
"video.equalize",
make_inputs(4, 4, PixelFormat::Gray8),
));
later_cases.push((
Op::AutoGamma,
"video.auto-gamma",
make_inputs(4, 4, PixelFormat::Gray8),
));
}
let cases: Vec<(Op, &str, [PortSpec; 1])> = cases.into_iter().chain(later_cases).collect();
for (op, expected_name, inputs) in cases {
let job = plan_to_job(&plan_with(vec![op.clone()]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, expected_name)
.unwrap_or_else(|| panic!("expected node {expected_name} for {op:?}"));
filter_ctx
.filters
.make(expected_name, &f.params, &inputs)
.unwrap_or_else(|e| {
panic!(
"registry rejected emitted params for {op:?}: {e:?} \
(json: {})",
f.params
)
});
}
}
#[test]
fn sharpen_4x4_pixel_match_through_registry() {
use oxideav_core::{
filter::FilterContext, Frame, PixelFormat, PortSpec, Result as CoreResult, TimeBase,
VideoFrame, VideoPlane,
};
use oxideav_image_filter::{ImageFilter, Sharpen, VideoStreamParams};
let make_frame = || {
let mut data = Vec::with_capacity(4 * 4 * 4);
for _y in 0..4 {
for x in 0..4 {
let v = if x < 2 { 60 } else { 200 };
data.extend_from_slice(&[v, v, v, 255]);
}
}
VideoFrame {
pts: None,
planes: vec![VideoPlane { stride: 16, data }],
}
};
let reference = Sharpen::new(1, 0.5)
.apply(
&make_frame(),
VideoStreamParams {
format: PixelFormat::Rgba,
width: 4,
height: 4,
},
)
.expect("library Sharpen apply");
let plan = ConvertPlan {
input: "in.png".into(),
input_pages: None,
ops: vec![Op::Sharpen {
radius: 1,
sigma: 0.5,
}],
output: "out.png".into(),
output_template: None,
help: false,
ping: false,
probe: false,
probe_json: false,
probe_watch: false,
mesh3d_options: Default::default(),
};
let job = plan_to_job(&plan, &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.sharpen").expect("video.sharpen node");
let mut filter_ctx = RuntimeContext::new();
oxideav_image_filter::register(&mut filter_ctx);
if !filter_ctx.filters.contains("sharpen") {
return;
}
let inputs = [PortSpec::video(
"in",
4,
4,
PixelFormat::Rgba,
TimeBase::new(1, 30),
)];
let mut built = filter_ctx
.filters
.make("video.sharpen", &f.params, &inputs)
.expect("factory build");
struct Collect {
out: Vec<Frame>,
}
impl FilterContext for Collect {
fn emit(&mut self, _port: usize, frame: Frame) -> CoreResult<()> {
self.out.push(frame);
Ok(())
}
}
let mut col = Collect { out: Vec::new() };
built
.push(&mut col, 0, &Frame::Video(make_frame()))
.expect("filter push");
let registry_out = match col.out.into_iter().next().unwrap() {
Frame::Video(v) => v,
other => panic!("expected video frame, got {other:?}"),
};
assert_eq!(
registry_out.planes[0].data, reference.planes[0].data,
"registry-built sharpen must match library Sharpen byte-for-byte"
);
}
#[test]
fn vignette_wires_centre_radius_sigma() {
let job = plan_to_job(
&plan_with(vec![Op::Vignette {
radius: 50.0,
sigma: 25.0,
x: 0.5,
y: 0.5,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.vignette").expect("video.vignette node");
assert!((f.params["x"].as_f64().unwrap() - 0.5).abs() < 1e-6);
assert!((f.params["y"].as_f64().unwrap() - 0.5).abs() < 1e-6);
assert!((f.params["radius"].as_f64().unwrap() - 50.0).abs() < 1e-6);
assert!((f.params["sigma"].as_f64().unwrap() - 25.0).abs() < 1e-6);
}
#[test]
fn colorize_wires_color_array_and_amount() {
let job = plan_to_job(
&plan_with(vec![Op::Colorize {
color: [200, 100, 50, 255],
amount: 0.4,
}]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.colorize").expect("video.colorize node");
let arr = f.params["color"].as_array().expect("color is array");
assert_eq!(arr.len(), 4);
assert_eq!(arr[0], 200);
assert_eq!(arr[1], 100);
assert_eq!(arr[2], 50);
assert_eq!(arr[3], 255);
assert!((f.params["amount"].as_f64().unwrap() - 0.4).abs() < 1e-6);
}
#[test]
fn equalize_wires_empty_params() {
let job = plan_to_job(&plan_with(vec![Op::Equalize]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.equalize").expect("video.equalize node");
assert!(f.params.as_object().unwrap().is_empty());
}
#[test]
fn auto_gamma_wires_empty_params() {
let job = plan_to_job(&plan_with(vec![Op::AutoGamma]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.auto-gamma").expect("video.auto-gamma node");
assert!(f.params.as_object().unwrap().is_empty());
}
#[test]
fn long_chain_preserves_source_order() {
let job = plan_to_job(
&plan_with(vec![
Op::Resize {
width: 64,
height: 64,
mode: ResizeMode::Default,
},
Op::Sharpen {
radius: 1,
sigma: 0.5,
},
Op::Gamma { value: 1.8 },
Op::Negate,
]),
&empty_ctx(),
)
.unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let names = collect_filter_names(&track.input);
assert_eq!(
names,
vec![
"video.resize".to_string(),
"video.sharpen".to_string(),
"video.gamma".to_string(),
"video.negate".to_string(),
]
);
}
#[test]
fn trim_wires_fuzz_value() {
let job = plan_to_job(&plan_with(vec![Op::Trim { fuzz: 17 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.trim").expect("video.trim node");
assert_eq!(f.params["fuzz"], 17);
assert!(f.params.get("background").is_none());
}
#[test]
fn trim_default_fuzz_lowers_to_zero() {
let job = plan_to_job(&plan_with(vec![Op::Trim { fuzz: 0 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.trim").expect("video.trim node");
assert_eq!(f.params["fuzz"], 0);
}
#[test]
fn roll_lowers_to_video_roll() {
let job = plan_to_job(&plan_with(vec![Op::Roll { dx: 5, dy: -10 }]), &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let f = find_filter(&track.input, "video.roll").expect("video.roll node");
assert_eq!(f.params["dx"], 5);
assert_eq!(f.params["dy"], -10);
}
#[test]
fn format_override_uses_registry() {
let mut ctx = RuntimeContext::new();
ctx.containers.register_extension("bar", "bar_codec");
assert_eq!(
codec_for_output(Some("BAR"), "out.unrelated", &ctx).as_deref(),
Some("bar_codec")
);
assert!(codec_for_output(Some("nothing"), "out.unrelated", &ctx).is_none());
}
use crate::op::{CameraSpec as OpCameraSpec, LightSpec as OpLightSpec};
fn render3d_plan(input: &str, output: &str, ops: Vec<Op>, opts: Mesh3DOptions) -> ConvertPlan {
ConvertPlan {
input: input.into(),
input_pages: None,
ops,
output: output.into(),
output_template: None,
help: false,
ping: false,
probe: false,
probe_json: false,
probe_watch: false,
mesh3d_options: opts,
}
}
#[test]
fn render3d_defaults_emit_scanline_source_at_1024x1024() {
let plan = render3d_plan("cube.stl", "out.png", vec![], Mesh3DOptions::default());
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
assert_eq!(job.outputs.len(), 1);
let out = job.outputs.values().next().unwrap();
assert_eq!(out.all.len(), 1);
let track = &out.all[0];
let node = match &track.input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
assert_eq!(node.source, "cube.stl");
assert_eq!(node.backend, RENDER3D_DEFAULT_BACKEND);
assert_eq!(node.backend, "scanline");
assert_eq!(node.opts["width"], 1024);
assert_eq!(node.opts["height"], 1024);
let bg = node.opts["background"]
.as_array()
.expect("background array");
assert_eq!(bg.len(), 4);
for c in bg {
assert_eq!(c.as_u64(), Some(0));
}
assert_eq!(node.opts["shading"], "flat");
assert_eq!(node.opts["projection"], "perspective");
assert!((node.opts["fov_deg"].as_f64().unwrap() - 60.0).abs() < 1e-6);
assert_eq!(node.opts["aa"], 1);
let l = &node.opts["light"];
assert!((l["azimuth_deg"].as_f64().unwrap() - 45.0).abs() < 1e-6);
assert!((l["elevation_deg"].as_f64().unwrap() - 45.0).abs() < 1e-6);
assert!((l["intensity"].as_f64().unwrap() - 1.0).abs() < 1e-6);
assert!(node.opts["camera"].is_null());
}
#[test]
fn resize_is_absorbed_into_render_dims_not_emitted_as_filter() {
let plan = render3d_plan(
"scene.gltf",
"out.png",
vec![Op::Resize {
width: 800,
height: 600,
mode: ResizeMode::Default,
}],
Mesh3DOptions::default(),
);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let node = match &track.input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D directly (resize absorbed), got {other:?}"),
};
assert_eq!(node.opts["width"], 800);
assert_eq!(node.opts["height"], 600);
}
#[test]
fn background_op_is_absorbed_into_render_bg() {
let plan = render3d_plan(
"cube.stl",
"out.png",
vec![Op::Background([12, 34, 56, 200])],
Mesh3DOptions::default(),
);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
let node = match &track.input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D directly, got {other:?}"),
};
let bg = node.opts["background"]
.as_array()
.expect("background array");
assert_eq!(bg[0], 12);
assert_eq!(bg[1], 34);
assert_eq!(bg[2], 56);
assert_eq!(bg[3], 200);
}
#[test]
fn bg_option_overrides_background_op() {
let opts = Mesh3DOptions {
bg: Some([10, 20, 30, 255]),
..Mesh3DOptions::default()
};
let plan = render3d_plan(
"cube.stl",
"out.png",
vec![Op::Background([99, 99, 99, 255])],
opts,
);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
let bg = node.opts["background"].as_array().unwrap();
assert_eq!(bg[0], 10);
assert_eq!(bg[1], 20);
assert_eq!(bg[2], 30);
assert_eq!(bg[3], 255);
}
#[test]
fn render_mode_round_trips_through_opts() {
let cases = [
(Mesh3DRenderMode::Flat, "flat"),
(Mesh3DRenderMode::Wireframe, "wireframe"),
(Mesh3DRenderMode::Gouraud, "gouraud"),
(Mesh3DRenderMode::Phong, "phong"),
(Mesh3DRenderMode::NormalDebug, "normal-debug"),
(Mesh3DRenderMode::DepthDebug, "depth-debug"),
];
for (mode, tag) in cases {
let opts = Mesh3DOptions {
render_mode: Some(mode),
..Mesh3DOptions::default()
};
let plan = render3d_plan("cube.stl", "out.png", vec![], opts);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
assert_eq!(node.opts["shading"], tag, "mode={mode:?}");
}
}
#[test]
fn projection_mode_round_trips_through_opts() {
for (mode, tag) in [
(ProjectionMode::Perspective, "perspective"),
(ProjectionMode::Orthographic, "orthographic"),
] {
let opts = Mesh3DOptions {
projection: Some(mode),
..Mesh3DOptions::default()
};
let plan = render3d_plan("cube.stl", "out.png", vec![], opts);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
assert_eq!(node.opts["projection"], tag, "mode={mode:?}");
}
}
#[test]
fn light_override_round_trips_through_opts() {
let opts = Mesh3DOptions {
light: Some(OpLightSpec {
azimuth_deg: 12.0,
elevation_deg: 34.0,
intensity: 0.75,
}),
..Mesh3DOptions::default()
};
let plan = render3d_plan("cube.stl", "out.png", vec![], opts);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
let l = &node.opts["light"];
assert!((l["azimuth_deg"].as_f64().unwrap() - 12.0).abs() < 1e-6);
assert!((l["elevation_deg"].as_f64().unwrap() - 34.0).abs() < 1e-6);
assert!((l["intensity"].as_f64().unwrap() - 0.75).abs() < 1e-6);
}
#[test]
fn camera_override_round_trips_through_opts() {
let opts = Mesh3DOptions {
camera: Some(OpCameraSpec {
elevation_deg: 30.0,
azimuth_deg: 45.0,
distance: 1.5,
}),
..Mesh3DOptions::default()
};
let plan = render3d_plan("cube.stl", "out.png", vec![], opts);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
let c = &node.opts["camera"];
assert!((c["elevation_deg"].as_f64().unwrap() - 30.0).abs() < 1e-6);
assert!((c["azimuth_deg"].as_f64().unwrap() - 45.0).abs() < 1e-6);
assert!((c["distance"].as_f64().unwrap() - 1.5).abs() < 1e-6);
}
#[test]
fn fov_override_round_trips_through_opts() {
let opts = Mesh3DOptions {
fov_deg: Some(35.0),
..Mesh3DOptions::default()
};
let plan = render3d_plan("cube.stl", "out.png", vec![], opts);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
assert!((node.opts["fov_deg"].as_f64().unwrap() - 35.0).abs() < 1e-6);
}
#[test]
fn aa_round_trips_through_opts_and_clamps_to_range() {
for in_aa in [1_u32, 2, 4, 8] {
let opts = Mesh3DOptions {
aa: Some(in_aa),
..Mesh3DOptions::default()
};
let plan = render3d_plan("cube.stl", "out.png", vec![], opts);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
assert_eq!(node.opts["aa"], in_aa, "in_aa={in_aa}");
}
for (in_aa, expected) in [(0_u32, 1_u32), (9, 8), (100, 8)] {
let opts = Mesh3DOptions {
aa: Some(in_aa),
..Mesh3DOptions::default()
};
let plan = render3d_plan("cube.stl", "out.png", vec![], opts);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
assert_eq!(
node.opts["aa"], expected,
"in_aa={in_aa} expected clamp→{expected}"
);
}
}
#[test]
fn post_raster_ops_wrap_the_render3d_source_in_source_order() {
let plan = render3d_plan(
"scene.gltf",
"out.png",
vec![
Op::Rotate { degrees: 90 },
Op::Negate,
Op::Sharpen {
radius: 1,
sigma: 0.5,
},
Op::Strip,
Op::Quality(85),
],
Mesh3DOptions::default(),
);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["quality"], 85);
assert_eq!(track.params["strip_metadata"], true);
let outer = match &track.input {
TrackInput::Filter(f) => f,
other => panic!("expected outer filter, got {other:?}"),
};
assert_eq!(outer.filter, "video.sharpen");
let mid = match outer.input.as_ref() {
TrackInput::Filter(f) => f,
other => panic!("expected mid filter, got {other:?}"),
};
assert_eq!(mid.filter, "video.negate");
let inner = match mid.input.as_ref() {
TrackInput::Filter(f) => f,
other => panic!("expected inner filter, got {other:?}"),
};
assert_eq!(inner.filter, "video.rotate");
assert_eq!(inner.params["degrees"], 90);
match inner.input.as_ref() {
TrackInput::Render3D(n) => {
assert_eq!(n.source, "scene.gltf");
assert_eq!(n.backend, "scanline");
}
other => panic!("expected Render3D leaf, got {other:?}"),
}
}
#[test]
fn thumbnail_absorbs_resize_and_sets_strip_metadata() {
let plan = render3d_plan(
"cube.stl",
"out.png",
vec![Op::Thumbnail {
width: 256,
height: 256,
mode: ResizeMode::Fill,
}],
Mesh3DOptions::default(),
);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["strip_metadata"], true);
match &track.input {
TrackInput::Render3D(node) => {
assert_eq!(node.opts["width"], 256);
assert_eq!(node.opts["height"], 256);
}
other => panic!("expected Render3D (no surviving filters), got {other:?}"),
}
}
#[test]
fn last_geometry_op_wins_render_canvas() {
let plan = render3d_plan(
"cube.stl",
"out.png",
vec![
Op::Resize {
width: 512,
height: 512,
mode: ResizeMode::Default,
},
Op::Thumbnail {
width: 256,
height: 128,
mode: ResizeMode::Fill,
},
],
Mesh3DOptions::default(),
);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
assert_eq!(node.opts["width"], 256);
assert_eq!(node.opts["height"], 128);
}
#[test]
fn render3d_output_codec_resolves_via_registry() {
let mut ctx = RuntimeContext::new();
ctx.containers.register_extension("png", "png_codec");
let plan = render3d_plan("scene.gltf", "out.png", vec![], Mesh3DOptions::default());
let job = plan_to_render3d_job(&plan, &ctx).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.codec.as_deref(), Some("png_codec"));
}
#[test]
fn render3d_format_override_threads_through_to_codec_params() {
let mut ctx = RuntimeContext::new();
ctx.containers.register_extension("jpg", "jpeg_codec");
let plan = render3d_plan(
"scene.gltf",
"out.unrelated",
vec![Op::Format("jpg".into())],
Mesh3DOptions::default(),
);
let job = plan_to_render3d_job(&plan, &ctx).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.codec.as_deref(), Some("jpeg_codec"));
assert_eq!(track.params["format"], "jpg");
}
#[test]
fn render3d_define_lands_on_codec_params() {
let plan = render3d_plan(
"cube.stl",
"out.png",
vec![
Op::Define {
key: "png:strip-comments".into(),
value: None,
},
Op::Define {
key: "jpeg:dct-method".into(),
value: Some("float".into()),
},
],
Mesh3DOptions::default(),
);
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let track = &job.outputs.values().next().unwrap().all[0];
assert_eq!(track.params["png:strip-comments"], true);
assert_eq!(track.params["jpeg:dct-method"], "float");
}
#[test]
fn render3d_opts_carry_every_documented_field() {
let plan = render3d_plan("cube.stl", "out.png", vec![], Mesh3DOptions::default());
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
let node = match &job.outputs.values().next().unwrap().all[0].input {
TrackInput::Render3D(n) => n,
other => panic!("expected Render3D, got {other:?}"),
};
for key in [
"width",
"height",
"background",
"shading",
"projection",
"fov_deg",
"light",
"camera",
"aa",
] {
assert!(
node.opts.get(key).is_some(),
"opts is missing documented field {key:?} — full opts: {}",
node.opts
);
}
}
fn representative_op_corpus() -> Vec<Vec<Op>> {
vec![
vec![],
vec![Op::Resize {
width: 64,
height: 32,
mode: ResizeMode::Fill,
}],
vec![Op::Thumbnail {
width: 128,
height: 128,
mode: ResizeMode::Default,
}],
vec![Op::Define {
key: "jpeg:dct-method".into(),
value: Some("float".into()),
}],
vec![Op::Blur {
radius: 2,
sigma: 1.0,
}],
vec![Op::Edge { radius: 1 }],
vec![Op::Colors {
count: 16,
dither: Dither::FloydSteinberg,
}],
vec![Op::Format("png".into()), Op::Quality(85), Op::Strip],
vec![Op::Density(300), Op::Background([1, 2, 3, 4])],
vec![Op::Alpha(crate::op::AlphaOp::Remove)],
vec![Op::Rotate { degrees: -270 }, Op::Flip, Op::Flop],
vec![Op::Crop {
x: 1,
y: 2,
w: 3,
h: 4,
}],
vec![Op::Extent {
width: 10,
height: 20,
x: -1,
y: 1,
bg: [0, 0, 0, 255],
}],
vec![Op::Negate],
vec![Op::Sharpen {
radius: 1,
sigma: 0.5,
}],
vec![Op::Unsharp {
radius: 2,
sigma: 1.0,
amount: 0.8,
threshold: 3,
}],
vec![Op::Gamma { value: 2.2 }],
vec![Op::BrightnessContrast {
brightness: 10.0,
contrast: -5.0,
}],
vec![Op::Contrast { delta: 2 }],
vec![Op::Sepia { threshold: 0.8 }],
vec![Op::Modulate {
brightness: 110.0,
saturation: 90.0,
hue: 150.0,
}],
vec![Op::Level {
black: 16,
gamma: 1.1,
white: 235,
}],
vec![Op::Normalize, Op::Equalize, Op::AutoGamma],
vec![Op::Threshold { value: 128 }],
vec![Op::Posterize { levels: 4 }],
vec![Op::Solarize { value: 200 }],
vec![Op::Colorspace("gray".into())],
vec![Op::Colorspace("srgb".into())],
vec![Op::Vignette {
radius: 50.0,
sigma: 25.0,
x: 0.5,
y: 0.5,
}],
vec![Op::Colorize {
color: [200, 100, 50, 255],
amount: 0.4,
}],
vec![Op::Trim { fuzz: 12 }],
vec![Op::Roll { dx: -3, dy: 7 }],
vec![
Op::Density(150),
Op::Resize {
width: 800,
height: 600,
mode: ResizeMode::Shrink,
},
Op::Background([255, 255, 255, 255]),
Op::Alpha(crate::op::AlphaOp::Off),
Op::Sharpen {
radius: 1,
sigma: 0.5,
},
Op::Colorspace("gray".into()),
Op::Colors {
count: 2,
dither: Dither::FloydSteinberg,
},
Op::Quality(92),
Op::Strip,
Op::Define {
key: "png:compression-level".into(),
value: Some("9".into()),
},
],
]
}
#[test]
fn every_emitted_job_passes_pipeline_validation() {
for (i, ops) in representative_op_corpus().into_iter().enumerate() {
let job = plan_to_job(&plan_with(ops.clone()), &empty_ctx())
.unwrap_or_else(|e| panic!("corpus[{i}] {ops:?} failed to plan: {e}"));
job.validate()
.unwrap_or_else(|e| panic!("corpus[{i}] {ops:?} emitted invalid job: {e}"));
}
}
#[test]
fn every_emitted_job_round_trips_through_job_json() {
let mut ctx = RuntimeContext::new();
ctx.containers.register_extension("jpg", "mjpeg");
for (i, ops) in representative_op_corpus().into_iter().enumerate() {
let job = plan_to_job(&plan_with(ops.clone()), &ctx).unwrap();
let first = job.to_json_pretty();
let reparsed = Job::from_json(&first).unwrap_or_else(|e| {
panic!("corpus[{i}] {ops:?} JSON did not re-parse: {e}\n{first}")
});
reparsed
.validate()
.unwrap_or_else(|e| panic!("corpus[{i}] re-parsed job invalid: {e}"));
let second = reparsed.to_json_pretty();
assert_eq!(
first, second,
"corpus[{i}] {ops:?}: JSON round-trip not stable"
);
}
}
#[test]
fn every_emitted_render3d_job_validates_and_round_trips() {
let option_corpus = vec![
Mesh3DOptions::default(),
Mesh3DOptions {
render_mode: Some(Mesh3DRenderMode::Phong),
projection: Some(ProjectionMode::Orthographic),
fov_deg: Some(35.0),
light: Some(crate::op::LightSpec {
azimuth_deg: 12.0,
elevation_deg: 34.0,
intensity: 0.75,
}),
camera: Some(crate::op::CameraSpec {
elevation_deg: 30.0,
azimuth_deg: 45.0,
distance: 1.5,
}),
bg: Some([10, 20, 30, 255]),
aa: Some(4),
..Mesh3DOptions::default()
},
];
let op_corpus = vec![
vec![],
vec![
Op::Resize {
width: 800,
height: 600,
mode: ResizeMode::Default,
},
Op::Background([255, 255, 255, 255]),
Op::Rotate { degrees: 90 },
Op::Negate,
Op::Quality(85),
Op::Strip,
],
];
for opts in &option_corpus {
for ops in &op_corpus {
let plan = render3d_plan("scene.gltf", "out.png", ops.clone(), opts.clone());
let job = plan_to_render3d_job(&plan, &empty_ctx()).unwrap();
job.validate()
.unwrap_or_else(|e| panic!("render3d job invalid: {e} (ops={ops:?})"));
let first = job.to_json_pretty();
let reparsed = Job::from_json(&first)
.unwrap_or_else(|e| panic!("render3d JSON did not re-parse: {e}\n{first}"));
let second = reparsed.to_json_pretty();
assert_eq!(first, second, "render3d JSON round-trip not stable");
let orig = match &job.outputs.values().next().unwrap().all[0].input.leaf() {
TrackInput::Render3D(n) => (*n).clone(),
other => panic!("expected Render3D leaf, got {other:?}"),
};
let back = match &reparsed.outputs.values().next().unwrap().all[0]
.input
.leaf()
{
TrackInput::Render3D(n) => (*n).clone(),
other => panic!("expected Render3D leaf after re-parse, got {other:?}"),
};
assert_eq!(orig, back, "Render3D node changed across the round-trip");
}
}
}
#[test]
fn empty_input_path_is_rejected_at_plan_time() {
let mut plan = plan_with(vec![]);
plan.input = String::new();
let err = plan_to_job(&plan, &empty_ctx()).expect_err("empty source must not plan");
let msg = format!("{err}");
assert!(msg.contains("empty `from`"), "got: {msg}");
}
}