use pixelflow_core::{
ChromaSiting, Clip, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterChangeSet,
FilterCompatibility, FilterPlan, FilterPlanRequest, FormatFamily, Frame, FrameBuilder,
FrameExecutor, FrameRequest, GraphBuilder, MetadataSchema, PixelFlowError, Plane,
PlaneDescriptor, Result, Sample, SampleType,
};
use safefma::Fma;
use semisafe::slice::get as semisafe_get;
#[cfg(test)]
use crate::GoldenTolerance;
use crate::{
OfficialFilterContract, SupportedFormats,
contracts::{ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES},
options::{
optional_f64, optional_i64, optional_string, required_i64, single_input_media,
single_input_slice,
},
};
pub const FILTER_RESIZE: &str = "resize";
pub const RESIZE_CONTRACT: OfficialFilterContract = OfficialFilterContract::new(
FILTER_RESIZE,
SupportedFormats::new(ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: false,
resolution: true,
frame_count: false,
frame_rate: false,
}),
);
#[cfg(test)]
pub const RESIZE_GOLDEN_TOLERANCE: GoldenTolerance = GoldenTolerance {
u8_abs: 1,
u16_abs: 4,
f32_abs: 0.00001,
};
pub const BICUBIC_DEFAULT_B: f32 = 1.0 / 3.0;
pub const BICUBIC_DEFAULT_C: f32 = 1.0 / 3.0;
pub const LANCZOS_DEFAULT_TAPS: usize = 3;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum ResizeKernel {
Nearest,
#[default]
Bilinear,
Bicubic {
b: f32,
c: f32,
},
Lanczos {
taps: usize,
},
Spline16,
Spline36,
}
impl ResizeKernel {
pub fn named(name: &str) -> Result<Self> {
match name.to_ascii_lowercase().as_str() {
"nearest" => Ok(Self::Nearest),
"bilinear" => Ok(Self::Bilinear),
"bicubic" => Self::bicubic(BICUBIC_DEFAULT_B, BICUBIC_DEFAULT_C),
"lanczos" => Self::lanczos(LANCZOS_DEFAULT_TAPS),
"spline16" => Ok(Self::Spline16),
"spline36" => Ok(Self::Spline36),
_ => Err(resize_error(
"filter.invalid_resize_kernel",
format!("filter '{FILTER_RESIZE}' does not support resize kernel '{name}'"),
)),
}
}
pub fn bicubic(b: f32, c: f32) -> Result<Self> {
if !b.is_finite()
|| !c.is_finite()
|| !(0.0..=1.0).contains(&b)
|| !(0.0..=1.0).contains(&c)
{
return Err(resize_error(
"filter.invalid_resize_parameter",
"filter 'resize' bicubic b and c must be finite values in 0.0..=1.0",
));
}
Ok(Self::Bicubic { b, c })
}
pub fn lanczos(taps: usize) -> Result<Self> {
if !(1..=8).contains(&taps) {
return Err(resize_error(
"filter.invalid_resize_parameter",
"filter 'resize' lanczos taps must be in 1..=8",
));
}
Ok(Self::Lanczos { taps })
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResizeOptions {
width: i64,
height: i64,
kernel: ResizeKernel,
}
impl ResizeOptions {
#[must_use]
pub const fn new(width: i64, height: i64) -> Self {
Self {
width,
height,
kernel: ResizeKernel::Bilinear,
}
}
#[must_use]
pub const fn with_kernel(mut self, kernel: ResizeKernel) -> Self {
self.kernel = kernel;
self
}
#[must_use]
pub const fn width(self) -> i64 {
self.width
}
#[must_use]
pub const fn height(self) -> i64 {
self.height
}
#[must_use]
pub const fn kernel(self) -> ResizeKernel {
self.kernel
}
}
#[cfg(test)]
impl ResizeOptions {
fn validate_for_tests(self, input: &ClipMedia) -> Result<ValidatedResize> {
resize_output_media(input, self).map(|(resize, _)| resize)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ValidatedResize {
width: usize,
height: usize,
kernel: ResizeKernel,
}
impl ValidatedResize {
#[must_use]
pub const fn width(self) -> usize {
self.width
}
#[must_use]
pub const fn height(self) -> usize {
self.height
}
#[must_use]
pub const fn kernel(self) -> ResizeKernel {
self.kernel
}
fn output_media(self, input: &ClipMedia) -> ClipMedia {
ClipMedia::new(
input.format().clone(),
ClipResolution::Fixed {
width: self.width,
height: self.height,
},
input.frame_count(),
input.frame_rate(),
)
}
}
pub fn resize_output_media(
input: &ClipMedia,
options: ResizeOptions,
) -> Result<(ValidatedResize, ClipMedia)> {
let _format = RESIZE_CONTRACT.validate_input_media(input)?;
let width = positive_resize_dimension("width", options.width)?;
let height = positive_resize_dimension("height", options.height)?;
let kernel = validated_kernel(options.kernel)?;
let resize = ValidatedResize {
width,
height,
kernel,
};
Ok((resize, resize.output_media(input)))
}
#[derive(Clone, Debug, PartialEq)]
pub struct ResizeExecutor {
resize: ValidatedResize,
}
impl ResizeExecutor {
#[must_use]
pub const fn new(resize: ValidatedResize) -> Self {
Self { resize }
}
#[must_use]
pub const fn resize(&self) -> ValidatedResize {
self.resize
}
fn resize_frame(&self, input: &Frame) -> Result<Frame> {
let siting = chroma_siting_from_metadata(input)?;
let mut builder = FrameBuilder::new(
input.format().clone(),
self.resize.width,
self.resize.height,
&MetadataSchema::core(),
pixelflow_core::AllocatorConfig::default(),
)?;
match input.format().sample_type() {
SampleType::U8 => resize_frame_typed::<u8>(input, &mut builder, self.resize, siting)?,
SampleType::U16 => {
resize_frame_typed::<u16>(input, &mut builder, self.resize, siting)?;
}
SampleType::F32 => {
resize_frame_typed::<f32>(input, &mut builder, self.resize, siting)?;
}
}
Ok(builder.finish().with_metadata(input.metadata().clone()))
}
}
#[cfg(test)]
impl ResizeExecutor {
fn resize_frame_for_tests(&self, input: &Frame) -> Result<Frame> {
self.resize_frame(input)
}
}
impl FrameExecutor for ResizeExecutor {
fn prepare(&self, request: FrameRequest<'_>) -> Result<Frame> {
let input = request.input_frame(0, request.frame_number())?;
self.resize_frame(&input)
}
}
pub fn add_resize_filter(
builder: &mut GraphBuilder,
input: Clip,
input_media: &ClipMedia,
options: ResizeOptions,
) -> Result<(Clip, ResizeExecutor)> {
let (resize, output_media) = resize_output_media(input_media, options)?;
let output = builder.filter(
FILTER_RESIZE,
&[input],
output_media,
RESIZE_CONTRACT.compatibility(),
)?;
Ok((output, ResizeExecutor::new(resize)))
}
fn parse_resize_options(options: &pixelflow_core::FilterOptions) -> Result<ResizeOptions> {
let width = required_i64(options, FILTER_RESIZE, "width", "filter.invalid_resize")?;
let height = required_i64(options, FILTER_RESIZE, "height", "filter.invalid_resize")?;
let kernel = planner_kernel(options)?;
Ok(ResizeOptions::new(width, height).with_kernel(kernel))
}
pub(crate) fn plan_resize(request: FilterPlanRequest<'_>) -> Result<FilterPlan> {
let input = single_input_media(request, FILTER_RESIZE)?;
let output_media = resize_output_media(input, parse_resize_options(request.options())?)?.1;
Ok(FilterPlan::new(
output_media,
RESIZE_CONTRACT.compatibility(),
))
}
pub(crate) fn resize_executor_from_options(
input_media: &[ClipMedia],
options: &pixelflow_core::FilterOptions,
) -> Result<ResizeExecutor> {
let input = single_input_slice(input_media, FILTER_RESIZE)?;
let (resize, _media) = resize_output_media(input, parse_resize_options(options)?)?;
Ok(ResizeExecutor::new(resize))
}
trait ResizeSample: Sample {
fn to_f32(self) -> f32;
fn from_f32(value: f32, bits_per_sample: u8) -> Self;
}
impl ResizeSample for u8 {
fn to_f32(self) -> f32 {
self as f32
}
fn from_f32(value: f32, _bits_per_sample: u8) -> Self {
value.round().clamp(0.0, u8::MAX as f32) as u8
}
}
impl ResizeSample for u16 {
fn to_f32(self) -> f32 {
self as f32
}
fn from_f32(value: f32, bits_per_sample: u8) -> Self {
let max = ((1_u32 << u32::from(bits_per_sample)) - 1) as f32;
value.round().clamp(0.0, max) as u16
}
}
impl ResizeSample for f32 {
fn to_f32(self) -> f32 {
self
}
fn from_f32(value: f32, _bits_per_sample: u8) -> Self {
value
}
}
fn resize_frame_typed<T: ResizeSample>(
input: &Frame,
builder: &mut FrameBuilder,
resize: ValidatedResize,
siting: ChromaSiting,
) -> Result<()> {
for (plane_index, descriptor) in input.format().planes().iter().enumerate() {
let input_plane = input.plane::<T>(plane_index)?;
let mut output_plane = builder.plane_mut::<T>(plane_index)?;
let (offset_x, offset_y) = plane_offsets(input.format().family(), descriptor, siting);
for y in 0..output_plane.height() {
let src_y = source_position(
y,
input.height(),
resize.height,
descriptor.height_divisor,
descriptor.height_divisor,
offset_y,
);
let row = output_plane
.row_mut(y)
.expect("validated output row exists");
for (x, output_sample) in row.iter_mut().enumerate() {
let src_x = source_position(
x,
input.width(),
resize.width,
descriptor.width_divisor,
descriptor.width_divisor,
offset_x,
);
let value = sample_plane(&input_plane, resize.kernel, src_x, src_y);
*output_sample = T::from_f32(value, input.format().bits_per_sample());
}
}
}
Ok(())
}
fn sample_plane<T: ResizeSample>(
plane: &Plane<T>,
kernel: ResizeKernel,
src_x: f32,
src_y: f32,
) -> f32 {
if kernel == ResizeKernel::Nearest {
let x = clamp_index(src_x.round() as isize, plane.width());
let y = clamp_index(src_y.round() as isize, plane.height());
return semisafe_get(plane.row(y).expect("clamped row exists"), x).to_f32();
}
let radius = kernel_radius(kernel);
let min_y = (src_y - radius + 1.0).floor() as isize;
let max_y = (src_y + radius).floor() as isize;
let min_x = (src_x - radius + 1.0).floor() as isize;
let max_x = (src_x + radius).floor() as isize;
let mut weighted_sum = 0.0_f32;
let mut weight_sum = 0.0_f32;
for sy in min_y..=max_y {
let wy = kernel_weight(kernel, src_y - sy as f32);
if wy == 0.0 {
continue;
}
let row = plane
.row(clamp_index(sy, plane.height()))
.expect("clamped row exists");
for sx in min_x..=max_x {
let wx = kernel_weight(kernel, src_x - sx as f32);
let weight = wx * wy;
if weight == 0.0 {
continue;
}
weighted_sum += semisafe_get(row, clamp_index(sx, plane.width())).to_f32() * weight;
weight_sum += weight;
}
}
if weight_sum == 0.0 {
0.0
} else {
weighted_sum / weight_sum
}
}
fn chroma_siting_from_metadata(frame: &Frame) -> Result<ChromaSiting> {
match frame.metadata().get("core:chroma_siting") {
Some(pixelflow_core::MetadataValue::String(value)) => {
ChromaSiting::parse(value).map_err(|_| {
resize_error(
"filter.invalid_chroma_siting",
format!("filter '{FILTER_RESIZE}' does not support chroma siting '{value}'"),
)
})
}
Some(pixelflow_core::MetadataValue::None) | None => Ok(ChromaSiting::Center),
Some(_) => Err(resize_error(
"filter.invalid_chroma_siting",
"filter 'resize' core:chroma_siting metadata must be a string or None",
)),
}
}
fn plane_offsets(
family: FormatFamily,
descriptor: &PlaneDescriptor,
siting: ChromaSiting,
) -> (f32, f32) {
let (offset_x, offset_y) = siting.offsets();
let plane_x = if family == FormatFamily::Yuv && descriptor.width_divisor > 1 {
offset_x
} else {
0.0
};
let plane_y = if family == FormatFamily::Yuv && descriptor.height_divisor > 1 {
offset_y
} else {
0.0
};
(plane_x, plane_y)
}
fn clamp_index(index: isize, len: usize) -> usize {
if index < 0 {
return 0;
}
let last = len.saturating_sub(1);
usize::try_from(index).map_or_else(|_| last, |value| value.min(last))
}
const fn kernel_radius(kernel: ResizeKernel) -> f32 {
match kernel {
ResizeKernel::Nearest => 0.0,
ResizeKernel::Bilinear => 1.0,
ResizeKernel::Bicubic { .. } => 2.0,
ResizeKernel::Lanczos { taps } => taps as f32,
ResizeKernel::Spline16 => 2.0,
ResizeKernel::Spline36 => 3.0,
}
}
fn kernel_weight(kernel: ResizeKernel, distance: f32) -> f32 {
let x = distance.abs();
match kernel {
ResizeKernel::Nearest => {
if x < 0.5 {
1.0
} else {
0.0
}
}
ResizeKernel::Bilinear => (1.0 - x).max(0.0),
ResizeKernel::Bicubic { b, c } => bicubic_weight(x, b, c),
ResizeKernel::Lanczos { taps } => lanczos_weight(x, taps),
ResizeKernel::Spline16 => spline16_weight(x),
ResizeKernel::Spline36 => spline36_weight(x),
}
}
fn bicubic_weight(x: f32, b: f32, c: f32) -> f32 {
if x < 1.0 {
let poly_a = 6.0f32.fma(-c, 9.0f32.fma(-b, 12.0));
let poly_b = 6.0f32.fma(c, 12.0f32.fma(b, -18.0));
let poly_d = 2.0f32.fma(-b, 6.0);
(poly_a.fma(x.powi(3), poly_b.fma(x.powi(2), poly_d))) / 6.0
} else if x < 2.0 {
let poly_a = 6.0f32.fma(-c, -b);
let poly_b = 6.0f32.fma(b, 30.0 * c);
let poly_c = (-12.0f32).fma(b, -(48.0 * c));
let poly_d = 8.0f32.fma(b, 24.0 * c);
(poly_a.fma(x.powi(3), poly_b.fma(x.powi(2), poly_c.fma(x, poly_d)))) / 6.0
} else {
0.0
}
}
fn lanczos_weight(x: f32, taps: usize) -> f32 {
let taps = taps as f32;
if x == 0.0 {
1.0
} else if x < taps {
sinc(x) * sinc(x / taps)
} else {
0.0
}
}
fn sinc(x: f32) -> f32 {
let pi_x = std::f32::consts::PI * x;
pi_x.sin() / pi_x
}
fn spline16_weight(x: f32) -> f32 {
if x < 1.0 {
((x - 9.0 / 5.0).fma(x, -1.0 / 5.0)).fma(x, 1.0)
} else if x < 2.0 {
(((-1.0 / 3.0).fma(x, 9.0 / 5.0)).fma(x, -46.0 / 15.0)).fma(x, 8.0 / 5.0)
} else {
0.0
}
}
fn spline36_weight(x: f32) -> f32 {
if x < 1.0 {
(((13.0f32 / 11.0).fma(x, -453.0 / 209.0)).fma(x, -3.0 / 209.0)).fma(x, 1.0)
} else if x < 2.0 {
(((-6.0f32 / 11.0).fma(x, 270.0 / 209.0)).fma(x, -156.0 / 209.0)).fma(x, -6.0 / 209.0)
} else if x < 3.0 {
(((1.0f32 / 11.0).fma(x, -45.0 / 209.0)).fma(x, 26.0 / 209.0)).fma(x, -1.0 / 209.0)
} else {
0.0
}
}
fn source_position(
dst_index: usize,
src_full_len: usize,
dst_full_len: usize,
src_divisor: usize,
dst_divisor: usize,
siting_offset: f32,
) -> f32 {
let dst_luma = (dst_index as f32).fma(dst_divisor as f32, siting_offset);
let src_luma = (dst_luma + 0.5).fma(src_full_len as f32 / dst_full_len as f32, -0.5);
(src_luma - siting_offset) / src_divisor as f32
}
fn positive_resize_dimension(name: &str, value: i64) -> Result<usize> {
let value = usize::try_from(value).map_err(|_| {
resize_error(
"filter.invalid_resize",
format!("filter '{FILTER_RESIZE}' output {name} must be positive"),
)
})?;
if value == 0 {
return Err(resize_error(
"filter.invalid_resize",
format!("filter '{FILTER_RESIZE}' output {name} must be positive"),
));
}
Ok(value)
}
fn planner_kernel(options: &pixelflow_core::FilterOptions) -> Result<ResizeKernel> {
let Some(name) = optional_string(
options,
FILTER_RESIZE,
"kernel",
"filter.invalid_resize_kernel",
)?
else {
reject_unexpected_kernel_options(options, &["b", "c", "taps"], "bilinear")?;
return Ok(ResizeKernel::Bilinear);
};
match ResizeKernel::named(name)? {
ResizeKernel::Bicubic { .. } => {
reject_unexpected_kernel_options(options, &["taps"], "bicubic")?;
let b = optional_f64(
options,
FILTER_RESIZE,
"b",
"filter.invalid_resize_parameter",
)?
.unwrap_or_else(|| f64::from(BICUBIC_DEFAULT_B));
let c = optional_f64(
options,
FILTER_RESIZE,
"c",
"filter.invalid_resize_parameter",
)?
.unwrap_or_else(|| f64::from(BICUBIC_DEFAULT_C));
ResizeKernel::bicubic(b as f32, c as f32)
}
ResizeKernel::Lanczos { .. } => {
reject_unexpected_kernel_options(options, &["b", "c"], "lanczos")?;
let taps = optional_i64(
options,
FILTER_RESIZE,
"taps",
"filter.invalid_resize_parameter",
)?
.map(|value| {
usize::try_from(value).map_err(|_| {
resize_error(
"filter.invalid_resize_parameter",
format!("filter '{FILTER_RESIZE}' option 'taps' must be non-negative"),
)
})
})
.transpose()?
.unwrap_or(LANCZOS_DEFAULT_TAPS);
ResizeKernel::lanczos(taps)
}
kernel => {
reject_unexpected_kernel_options(options, &["b", "c", "taps"], name)?;
Ok(kernel)
}
}
}
fn reject_unexpected_kernel_options(
options: &pixelflow_core::FilterOptions,
names: &[&str],
kernel: &str,
) -> Result<()> {
for name in names {
if options.contains_key(*name) {
return Err(resize_error(
"filter.invalid_resize_parameter",
format!(
"filter '{FILTER_RESIZE}' option '{name}' requires resize kernel '{kernel}'"
),
));
}
}
Ok(())
}
fn resize_error(code: &'static str, message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(ErrorCategory::Format, ErrorCode::new(code), message)
}
fn validated_kernel(kernel: ResizeKernel) -> Result<ResizeKernel> {
match kernel {
ResizeKernel::Nearest
| ResizeKernel::Bilinear
| ResizeKernel::Spline16
| ResizeKernel::Spline36 => Ok(kernel),
ResizeKernel::Bicubic { b, c } => ResizeKernel::bicubic(b, c),
ResizeKernel::Lanczos { taps } => ResizeKernel::lanczos(taps),
}
}
#[cfg(test)]
mod tests {
#![expect(clippy::indexing_slicing, reason = "allow in tests")]
use std::sync::Arc;
use pixelflow_core::{
ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterChangeSet,
FilterCompatibility, FilterOptionValue, FilterOptions, FilterPlanRequest, Frame,
FrameCount, FrameExecutor, FrameRate, FrameRequest, GraphBuilder, MetadataSchema,
MetadataValue, Rational, RenderEngine, RenderExecutorMap, RenderOptions, WorkerPoolConfig,
};
use crate::testkit::{
assert_plane_f32_near, assert_plane_u8_near, assert_plane_u16_near, fixed_media,
synthetic_f32_frame, synthetic_u8_frame, synthetic_u16_frame, with_frame_number_metadata,
with_string_metadata,
};
use crate::{
BICUBIC_DEFAULT_B, BICUBIC_DEFAULT_C, LANCZOS_DEFAULT_TAPS, RESIZE_CONTRACT,
RESIZE_GOLDEN_TOLERANCE,
};
use super::{ResizeKernel, ResizeOptions, resize_output_media};
fn with_chroma_siting(frame: &Frame, siting: &str) -> pixelflow_core::Result<Frame> {
with_string_metadata(frame, "core:chroma_siting", siting)
}
#[test]
fn resize_contract_accepts_fixed_planar_phase1_formats() {
let media = fixed_media("yuv420p10", 1920, 1080);
let format = RESIZE_CONTRACT
.validate_input_media(&media)
.expect("resize should accept fixed YUV input");
assert_eq!(format.name(), "yuv420p10");
assert_eq!(
RESIZE_CONTRACT.compatibility(),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: false,
resolution: true,
frame_count: false,
frame_rate: false,
})
);
}
#[test]
fn resize_options_validates_output_media_and_preserves_timing() {
let input = fixed_media("yuv420p10", 1920, 1080);
let (resize, output) = resize_output_media(&input, ResizeOptions::new(1280, 720))
.expect("resize should validate");
assert_eq!(resize.width(), 1280);
assert_eq!(resize.height(), 720);
assert_eq!(resize.kernel(), ResizeKernel::Bilinear);
assert_eq!(output.format(), input.format());
assert_eq!(
output.resolution(),
&ClipResolution::Fixed {
width: 1280,
height: 720,
}
);
assert_eq!(output.frame_count(), FrameCount::Finite(24));
assert_eq!(
output.frame_rate(),
FrameRate::Cfr(Rational {
numerator: 24_000,
denominator: 1_001,
})
);
}
#[test]
fn resize_plan_and_executor_parse_same_options() {
let input = fixed_media("gray8", 640, 480);
let schema = MetadataSchema::core();
let options = FilterOptions::from([
("width".to_owned(), FilterOptionValue::Int(320)),
("height".to_owned(), FilterOptionValue::Int(180)),
(
"kernel".to_owned(),
FilterOptionValue::String("lanczos".to_owned()),
),
("taps".to_owned(), FilterOptionValue::Int(4)),
]);
let request = FilterPlanRequest::new(std::slice::from_ref(&input), &options, &schema);
let plan = super::plan_resize(request).expect("planner parses options");
let executor = super::resize_executor_from_options(std::slice::from_ref(&input), &options)
.expect("executor parses same options");
assert_eq!(executor.resize().width(), 320);
assert_eq!(executor.resize().height(), 180);
assert_eq!(
executor.resize().kernel(),
ResizeKernel::Lanczos { taps: 4 }
);
assert_eq!(
plan.output_media().resolution(),
&ClipResolution::Fixed {
width: 320,
height: 180,
}
);
}
#[test]
fn resize_options_accepts_all_required_kernel_names() {
let cases = [
("nearest", ResizeKernel::Nearest),
("BILINEAR", ResizeKernel::Bilinear),
(
"bicubic",
ResizeKernel::Bicubic {
b: BICUBIC_DEFAULT_B,
c: BICUBIC_DEFAULT_C,
},
),
(
"lanczos",
ResizeKernel::Lanczos {
taps: LANCZOS_DEFAULT_TAPS,
},
),
("spline16", ResizeKernel::Spline16),
("spline36", ResizeKernel::Spline36),
];
for (name, expected) in cases {
let kernel = ResizeKernel::named(name).expect("kernel name should validate");
assert_eq!(kernel, expected);
}
}
#[test]
fn resize_options_rejects_invalid_dimensions_kernel_and_params() {
let input = fixed_media("gray8", 8, 6);
for options in [
ResizeOptions::new(0, 4),
ResizeOptions::new(4, 0),
ResizeOptions::new(-1, 4),
ResizeOptions::new(4, -1),
] {
let error = resize_output_media(&input, options).expect_err("invalid size should fail");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.invalid_resize"));
}
let bad_kernel =
ResizeKernel::named("catmull-rom").expect_err("unknown resize kernel should fail");
assert_eq!(bad_kernel.category(), ErrorCategory::Format);
assert_eq!(
bad_kernel.code(),
ErrorCode::new("filter.invalid_resize_kernel")
);
let bad_bicubic =
ResizeKernel::bicubic(-0.1, 0.5).expect_err("negative bicubic parameter should fail");
assert_eq!(bad_bicubic.category(), ErrorCategory::Format);
assert_eq!(
bad_bicubic.code(),
ErrorCode::new("filter.invalid_resize_parameter")
);
let bad_lanczos = ResizeKernel::lanczos(0).expect_err("zero taps should fail");
assert_eq!(bad_lanczos.category(), ErrorCategory::Format);
assert_eq!(
bad_lanczos.code(),
ErrorCode::new("filter.invalid_resize_parameter")
);
for options in [
ResizeOptions::new(4, 4).with_kernel(ResizeKernel::Bicubic { b: -1.0, c: 2.0 }),
ResizeOptions::new(4, 4).with_kernel(ResizeKernel::Lanczos { taps: 0 }),
] {
let error = resize_output_media(&input, options)
.expect_err("directly constructed invalid kernels should fail upfront");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(
error.code(),
ErrorCode::new("filter.invalid_resize_parameter")
);
}
}
#[test]
fn resize_options_rejects_unsupported_or_variable_media_before_render() {
let variable = ClipMedia::new(
ClipFormat::Variable,
ClipResolution::Fixed {
width: 8,
height: 6,
},
FrameCount::Finite(2),
FrameRate::Cfr(Rational {
numerator: 24,
denominator: 1,
}),
);
let error = resize_output_media(&variable, ResizeOptions::new(4, 4))
.expect_err("variable format should fail upfront");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.variable_format"));
}
#[test]
fn resize_kernel_radius_and_weight_contracts_are_stable() {
assert_eq!(super::kernel_radius(ResizeKernel::Nearest), 0.0);
assert_eq!(super::kernel_radius(ResizeKernel::Bilinear), 1.0);
assert_eq!(
super::kernel_radius(ResizeKernel::Bicubic {
b: BICUBIC_DEFAULT_B,
c: BICUBIC_DEFAULT_C,
}),
2.0
);
assert_eq!(super::kernel_radius(ResizeKernel::Lanczos { taps: 3 }), 3.0);
assert_eq!(super::kernel_radius(ResizeKernel::Spline16), 2.0);
assert_eq!(super::kernel_radius(ResizeKernel::Spline36), 3.0);
for kernel in [
ResizeKernel::Bilinear,
ResizeKernel::Lanczos { taps: 3 },
ResizeKernel::Spline16,
ResizeKernel::Spline36,
] {
assert!((super::kernel_weight(kernel, 0.0) - 1.0).abs() <= f32::EPSILON);
assert_eq!(
super::kernel_weight(kernel, super::kernel_radius(kernel)),
0.0
);
}
assert!(
(super::kernel_weight(
ResizeKernel::Bicubic {
b: BICUBIC_DEFAULT_B,
c: BICUBIC_DEFAULT_C,
},
0.0,
) - (8.0 / 9.0))
.abs()
<= f32::EPSILON
);
assert_eq!(
super::kernel_weight(
ResizeKernel::Bicubic {
b: BICUBIC_DEFAULT_B,
c: BICUBIC_DEFAULT_C,
},
2.0,
),
0.0,
);
}
#[test]
fn chroma_siting_offsets_and_source_positions_are_modeled() {
let left = super::ChromaSiting::parse("left").expect("left siting should parse");
let center = super::ChromaSiting::parse("center").expect("center siting should parse");
assert_eq!(left.offsets(), (0.0, 0.5));
assert_eq!(center.offsets(), (0.5, 0.5));
let left_x = super::source_position(1, 4, 6, 2, 2, left.offsets().0);
let center_x = super::source_position(1, 4, 6, 2, 2, center.offsets().0);
assert!((left_x - 0.5833334).abs() < 0.000001);
assert!((center_x - 0.5).abs() < 0.000001);
}
#[test]
fn resize_accepts_all_core_chroma_siting_values() {
for value in ["left", "center", "top_left", "top", "bottom_left", "bottom"] {
let siting =
pixelflow_core::ChromaSiting::parse(value).expect("core siting value should parse");
assert_eq!(siting.as_str(), value);
}
}
#[test]
fn resize_executor_nearest_upsamples_gray_u8_with_deterministic_golden() {
let input = synthetic_u8_frame("gray8", 2, 2, |_plane, x, y| {
u8::try_from(x + y * 10).expect("fixture sample fits u8")
})
.expect("input frame should build");
let executor = super::ResizeExecutor::new(
ResizeOptions::new(4, 4)
.with_kernel(ResizeKernel::Nearest)
.validate_for_tests(&fixed_media("gray8", 2, 2))
.expect("resize should validate"),
);
let output = executor
.resize_frame_for_tests(&input)
.expect("resize should render");
assert_plane_u8_near(
&output,
0,
&[
&[0, 0, 1, 1],
&[0, 0, 1, 1],
&[10, 10, 11, 11],
&[10, 10, 11, 11],
],
RESIZE_GOLDEN_TOLERANCE,
);
}
#[test]
fn resize_executor_bilinear_downsamples_gray_f32_with_deterministic_golden() {
let input = synthetic_f32_frame("grayf32", 4, 1, |_plane, x, _y| x as f32 * 10.0)
.expect("input frame should build");
let executor = super::ResizeExecutor::new(
ResizeOptions::new(2, 1)
.with_kernel(ResizeKernel::Bilinear)
.validate_for_tests(&fixed_media("grayf32", 4, 1))
.expect("resize should validate"),
);
let output = executor
.resize_frame_for_tests(&input)
.expect("resize should render");
assert_plane_f32_near(&output, 0, &[&[5.0, 25.0]], RESIZE_GOLDEN_TOLERANCE);
}
#[test]
fn resize_executor_bicubic_handles_u16_logical_range() {
let input = synthetic_u16_frame("gray10", 3, 1, |_plane, x, _y| [0_u16, 512, 1023][x])
.expect("input frame should build");
let executor = super::ResizeExecutor::new(
ResizeOptions::new(3, 1)
.with_kernel(
ResizeKernel::bicubic(BICUBIC_DEFAULT_B, BICUBIC_DEFAULT_C)
.expect("valid bicubic"),
)
.validate_for_tests(&fixed_media("gray10", 3, 1))
.expect("resize should validate"),
);
let output = executor
.resize_frame_for_tests(&input)
.expect("resize should render");
assert_plane_u16_near(&output, 0, &[&[28, 512, 995]], RESIZE_GOLDEN_TOLERANCE);
}
#[test]
fn resize_executor_resizes_all_yuv420_planes_and_preserves_metadata() {
let input = synthetic_u8_frame("yuv420p8", 4, 4, |plane, x, y| {
u8::try_from(plane * 40 + x + y * 10).expect("fixture sample fits u8")
})
.expect("input frame should build");
let input = with_chroma_siting(&input, "left").expect("metadata should set");
let executor = super::ResizeExecutor::new(
ResizeOptions::new(8, 8)
.with_kernel(ResizeKernel::Nearest)
.validate_for_tests(&fixed_media("yuv420p8", 4, 4))
.expect("resize should validate"),
);
let output = executor
.resize_frame_for_tests(&input)
.expect("resize should render");
assert_eq!(output.width(), 8);
assert_eq!(output.height(), 8);
assert_eq!(
output.metadata().get("core:chroma_siting"),
Some(&MetadataValue::String("left".to_owned())),
);
assert_plane_u8_near(
&output,
0,
&[
&[0, 0, 1, 1, 2, 2, 3, 3],
&[0, 0, 1, 1, 2, 2, 3, 3],
&[10, 10, 11, 11, 12, 12, 13, 13],
&[10, 10, 11, 11, 12, 12, 13, 13],
&[20, 20, 21, 21, 22, 22, 23, 23],
&[20, 20, 21, 21, 22, 22, 23, 23],
&[30, 30, 31, 31, 32, 32, 33, 33],
&[30, 30, 31, 31, 32, 32, 33, 33],
],
RESIZE_GOLDEN_TOLERANCE,
);
assert_plane_u8_near(
&output,
1,
&[
&[40, 40, 41, 41],
&[40, 40, 41, 41],
&[50, 50, 51, 51],
&[50, 50, 51, 51],
],
RESIZE_GOLDEN_TOLERANCE,
);
assert_plane_u8_near(
&output,
2,
&[
&[80, 80, 81, 81],
&[80, 80, 81, 81],
&[90, 90, 91, 91],
&[90, 90, 91, 91],
],
RESIZE_GOLDEN_TOLERANCE,
);
}
#[test]
fn resize_executor_chroma_siting_changes_subsampled_coordinate_mapping() {
let input = synthetic_f32_frame("yuv420pf32", 4, 4, |plane, x, _y| {
if plane == 0 { 0.0 } else { x as f32 * 100.0 }
})
.expect("input frame should build");
let left_input = with_chroma_siting(&input, "left").expect("left metadata should set");
let center_input =
with_chroma_siting(&input, "center").expect("center metadata should set");
let executor = super::ResizeExecutor::new(
ResizeOptions::new(6, 4)
.with_kernel(ResizeKernel::Bilinear)
.validate_for_tests(&fixed_media("yuv420pf32", 4, 4))
.expect("resize should validate"),
);
let left = executor
.resize_frame_for_tests(&left_input)
.expect("left resize should render");
let center = executor
.resize_frame_for_tests(¢er_input)
.expect("center resize should render");
assert_plane_f32_near(
&left,
1,
&[&[0.0, 58.33334, 100.0], &[0.0, 58.33334, 100.0]],
RESIZE_GOLDEN_TOLERANCE,
);
assert_plane_f32_near(
¢er,
1,
&[&[0.0, 50.0, 100.0], &[0.0, 50.0, 100.0]],
RESIZE_GOLDEN_TOLERANCE,
);
}
#[test]
fn resize_executor_rejects_unknown_chroma_siting_metadata() {
let input = synthetic_u8_frame("yuv420p8", 4, 4, |_plane, x, y| {
u8::try_from(x + y).expect("fixture sample fits u8")
})
.expect("input frame should build");
let input = with_chroma_siting(&input, "mystery").expect("metadata should set");
let executor = super::ResizeExecutor::new(
ResizeOptions::new(2, 2)
.validate_for_tests(&fixed_media("yuv420p8", 4, 4))
.expect("resize should validate"),
);
let Err(error) = executor.resize_frame_for_tests(&input) else {
panic!("unknown siting should fail");
};
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.invalid_chroma_siting"));
}
#[test]
fn resize_executor_resizes_planar_rgb_planes_without_chroma_offsets() {
let input = synthetic_u8_frame("rgbp8", 2, 1, |plane, x, _y| {
u8::try_from(plane * 20 + x * 10).expect("fixture sample fits u8")
})
.expect("input frame should build");
let executor = super::ResizeExecutor::new(
ResizeOptions::new(4, 1)
.with_kernel(ResizeKernel::Nearest)
.validate_for_tests(&fixed_media("rgbp8", 2, 1))
.expect("resize should validate"),
);
let output = executor
.resize_frame_for_tests(&input)
.expect("resize should render");
assert_plane_u8_near(&output, 0, &[&[0, 0, 10, 10]], RESIZE_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 1, &[&[20, 20, 30, 30]], RESIZE_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 2, &[&[40, 40, 50, 50]], RESIZE_GOLDEN_TOLERANCE);
}
#[test]
fn add_resize_filter_creates_filter_node_with_validated_output_media() {
let input_media = fixed_media("gray8", 8, 6);
let mut builder = GraphBuilder::new();
let source = builder.source("source", input_media.clone());
let (resized, executor) = super::add_resize_filter(
&mut builder,
source,
&input_media,
ResizeOptions::new(4, 3).with_kernel(ResizeKernel::Nearest),
)
.expect("resize node should build");
let graph = builder.build();
let node = graph
.node(resized.node_id())
.expect("resize node should exist");
assert_eq!(executor.resize().width(), 4);
assert_eq!(executor.resize().height(), 3);
assert_eq!(
node.media().resolution(),
&ClipResolution::Fixed {
width: 4,
height: 3,
},
);
assert_eq!(node.media().format(), input_media.format());
assert_eq!(node.media().frame_count(), input_media.frame_count());
assert_eq!(node.media().frame_rate(), input_media.frame_rate());
}
#[test]
fn resize_executor_renders_through_core_engine_and_preserves_frame_number_metadata() {
struct SyntheticSource;
impl FrameExecutor for SyntheticSource {
fn prepare(&self, request: FrameRequest<'_>) -> pixelflow_core::Result<Frame> {
let frame = synthetic_u8_frame("gray8", 2, 2, |_plane, x, y| {
u8::try_from(x + y * 10).expect("fixture sample fits u8")
})?;
with_frame_number_metadata(&frame, request.frame_number())
}
}
let input_media = fixed_media("gray8", 2, 2);
let mut builder = GraphBuilder::new();
let source = builder.source("source", input_media.clone());
let (resized, executor) = super::add_resize_filter(
&mut builder,
source,
&input_media,
ResizeOptions::new(4, 4).with_kernel(ResizeKernel::Nearest),
)
.expect("resize node should build");
builder.set_output(resized);
let graph = builder.build();
let mut executors = RenderExecutorMap::new();
executors.insert(source.node_id(), Arc::new(SyntheticSource));
executors.insert(resized.node_id(), Arc::new(executor));
let mut render = RenderEngine::new(WorkerPoolConfig::new(1))
.render_ordered(graph, executors, RenderOptions::new(2, Some(3)))
.expect("render should start");
let output = render
.next()
.expect("one frame should render")
.expect("resize should succeed");
assert_eq!(output.width(), 4);
assert_eq!(output.height(), 4);
assert_eq!(
output.metadata().get("core:frame_number"),
Some(&MetadataValue::Int(2)),
);
assert_plane_u8_near(
&output,
0,
&[
&[0, 0, 1, 1],
&[0, 0, 1, 1],
&[10, 10, 11, 11],
&[10, 10, 11, 11],
],
RESIZE_GOLDEN_TOLERANCE,
);
assert!(render.next().is_none());
}
#[test]
fn resize_executor_covers_all_required_kernels_with_deterministic_golden_frames() {
let input =
synthetic_f32_frame("grayf32", 4, 1, |_plane, x, _y| [0.0, 10.0, 20.0, 30.0][x])
.expect("input frame should build");
let media = fixed_media("grayf32", 4, 1);
let cases: &[(ResizeKernel, &[&[f32]])] = &[
(ResizeKernel::Nearest, &[&[10.0, 30.0]]),
(ResizeKernel::Bilinear, &[&[5.0, 25.0]]),
(
ResizeKernel::Bicubic {
b: BICUBIC_DEFAULT_B,
c: BICUBIC_DEFAULT_C,
},
&[&[4.652778, 25.347221]],
),
(ResizeKernel::Lanczos { taps: 3 }, &[&[4.130435, 25.869564]]),
(ResizeKernel::Spline16, &[&[4.250001, 25.75]]),
(ResizeKernel::Spline36, &[&[8.786667, 21.213333]]),
];
for (kernel, expected) in cases {
let executor = super::ResizeExecutor::new(
ResizeOptions::new(2, 1)
.with_kernel(*kernel)
.validate_for_tests(&media)
.expect("resize should validate"),
);
let output = executor
.resize_frame_for_tests(&input)
.expect("resize should render");
assert_plane_f32_near(&output, 0, expected, RESIZE_GOLDEN_TOLERANCE);
}
}
}