use pixelflow_core::{
Clip, ClipFormat, ClipMedia, ErrorCategory, ErrorCode, FilterChangeSet, FilterCompatibility,
FilterPlan, FilterPlanRequest, FormatDescriptor, Frame, FrameBuilder, FrameExecutor,
FrameRequest, GraphBuilder, PixelFlowError, Plane, PlaneMut, Result, Sample, SampleType,
};
use safefma::Fma;
use semisafe::slice::get as semisafe_get;
use crate::{
DitherMode, OfficialFilterContract, SupportedFormats,
contracts::{ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES},
dither::fruit_offset,
options::{optional_dither, required_i64, single_input_media, single_input_slice},
};
pub const FILTER_CONVERT_BIT_DEPTH: &str = "convert_bit_depth";
pub const CONVERT_BIT_DEPTH_CONTRACT: OfficialFilterContract = OfficialFilterContract::new(
FILTER_CONVERT_BIT_DEPTH,
SupportedFormats::new(ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: true,
resolution: false,
frame_count: false,
frame_rate: false,
}),
);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConvertBitDepthOptions {
bits: u8,
dither: DitherMode,
}
impl ConvertBitDepthOptions {
#[must_use]
pub const fn new(bits: u8) -> Self {
Self {
bits,
dither: DitherMode::Fruit,
}
}
#[must_use]
pub const fn bits(self) -> u8 {
self.bits
}
#[must_use]
pub const fn with_dither(mut self, dither: DitherMode) -> Self {
self.dither = dither;
self
}
#[must_use]
pub const fn dither(self) -> DitherMode {
self.dither
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ValidatedConvertBitDepth {
output_format: FormatDescriptor,
target_bits: u8,
dither: DitherMode,
}
impl ValidatedConvertBitDepth {
#[must_use]
pub const fn output_format(&self) -> &FormatDescriptor {
&self.output_format
}
#[must_use]
pub const fn target_bits(&self) -> u8 {
self.target_bits
}
#[must_use]
pub const fn dither(&self) -> DitherMode {
self.dither
}
}
pub fn convert_bit_depth_output_media(
input: &ClipMedia,
options: ConvertBitDepthOptions,
) -> Result<(ValidatedConvertBitDepth, ClipMedia)> {
let input_format = CONVERT_BIT_DEPTH_CONTRACT.validate_input_media(input)?;
let output_format = pixelflow_core::format_with_bit_depth(input_format, options.bits())?;
let validated = ValidatedConvertBitDepth {
output_format: output_format.clone(),
target_bits: options.bits(),
dither: options.dither(),
};
let output = ClipMedia::new(
ClipFormat::Fixed(output_format),
input.resolution().clone(),
input.frame_count(),
input.frame_rate(),
);
Ok((validated, output))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConvertBitDepthExecutor {
conversion: ValidatedConvertBitDepth,
}
impl ConvertBitDepthExecutor {
#[must_use]
pub const fn new(conversion: ValidatedConvertBitDepth) -> Self {
Self { conversion }
}
#[must_use]
pub const fn conversion(&self) -> &ValidatedConvertBitDepth {
&self.conversion
}
fn convert_frame(&self, input: &Frame) -> Result<Frame> {
if input.format() == self.conversion.output_format() {
return Ok(input.clone());
}
let input_bits = input.format().bits_per_sample();
let output_format = self.conversion.output_format().clone();
let output_bits = output_format.bits_per_sample();
let input_sample = input.format().sample_type();
let output_sample = output_format.sample_type();
let apply_dither = should_dither_bit_depth(
self.conversion.dither(),
input_bits,
output_bits,
input_sample,
output_sample,
);
let mut builder = FrameBuilder::new(
output_format,
input.width(),
input.height(),
&pixelflow_core::MetadataSchema::core(),
pixelflow_core::AllocatorConfig::default(),
)?;
for plane_index in 0..input.format().planes().len() {
match input_sample {
SampleType::U8 => {
let input_plane = input.plane::<u8>(plane_index)?;
write_converted_plane::<u8>(
&input_plane,
&mut builder,
plane_index,
input_bits,
output_bits,
output_sample,
apply_dither,
)?;
}
SampleType::U16 => {
let input_plane = input.plane::<u16>(plane_index)?;
write_converted_plane::<u16>(
&input_plane,
&mut builder,
plane_index,
input_bits,
output_bits,
output_sample,
apply_dither,
)?;
}
SampleType::F32 => {
let input_plane = input.plane::<f32>(plane_index)?;
write_converted_plane::<f32>(
&input_plane,
&mut builder,
plane_index,
input_bits,
output_bits,
output_sample,
apply_dither,
)?;
}
}
}
Ok(builder.finish().with_metadata(input.metadata().clone()))
}
}
#[cfg(test)]
impl ConvertBitDepthExecutor {
fn convert_frame_for_tests(&self, input: &Frame) -> Result<Frame> {
self.convert_frame(input)
}
}
impl FrameExecutor for ConvertBitDepthExecutor {
fn prepare(&self, request: FrameRequest<'_>) -> Result<Frame> {
let input = request.input_frame(0, request.frame_number())?;
self.convert_frame(&input)
}
}
pub fn add_convert_bit_depth_filter(
builder: &mut GraphBuilder,
input: Clip,
input_media: &ClipMedia,
options: ConvertBitDepthOptions,
) -> Result<(Clip, ConvertBitDepthExecutor)> {
let (conversion, output_media) = convert_bit_depth_output_media(input_media, options)?;
let output = builder.filter(
FILTER_CONVERT_BIT_DEPTH,
&[input],
output_media,
CONVERT_BIT_DEPTH_CONTRACT.compatibility(),
)?;
Ok((output, ConvertBitDepthExecutor::new(conversion)))
}
fn parse_convert_bit_depth_options(
options: &pixelflow_core::FilterOptions,
) -> Result<ConvertBitDepthOptions> {
let bits = required_i64(
options,
FILTER_CONVERT_BIT_DEPTH,
"bits",
"filter.invalid_bit_depth",
)?;
let bits = u8::try_from(bits).map_err(|_| {
bit_depth_error(
"filter.invalid_bit_depth",
format!("filter '{FILTER_CONVERT_BIT_DEPTH}' option 'bits' must fit u8"),
)
})?;
let dither = optional_dither(
options,
FILTER_CONVERT_BIT_DEPTH,
"filter.invalid_bit_depth",
)?
.unwrap_or_default();
Ok(ConvertBitDepthOptions::new(bits).with_dither(dither))
}
pub(crate) fn plan_convert_bit_depth(request: FilterPlanRequest<'_>) -> Result<FilterPlan> {
let input = single_input_media(request, FILTER_CONVERT_BIT_DEPTH)?;
let output_media =
convert_bit_depth_output_media(input, parse_convert_bit_depth_options(request.options())?)?
.1;
Ok(FilterPlan::new(
output_media,
CONVERT_BIT_DEPTH_CONTRACT.compatibility(),
))
}
pub(crate) fn convert_bit_depth_executor_from_options(
input_media: &[ClipMedia],
options: &pixelflow_core::FilterOptions,
) -> Result<ConvertBitDepthExecutor> {
let input = single_input_slice(input_media, FILTER_CONVERT_BIT_DEPTH)?;
let (conversion, _media) =
convert_bit_depth_output_media(input, parse_convert_bit_depth_options(options)?)?;
Ok(ConvertBitDepthExecutor::new(conversion))
}
trait BitDepthSample: Sample {
fn to_unit(self, bits: u8) -> f32;
}
fn bit_depth_error(code: &'static str, message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(ErrorCategory::Format, ErrorCode::new(code), message)
}
impl BitDepthSample for u8 {
fn to_unit(self, _bits: u8) -> f32 {
self as f32 / u8::MAX as f32
}
}
impl BitDepthSample for u16 {
fn to_unit(self, bits: u8) -> f32 {
self as f32 / integer_max(bits)
}
}
impl BitDepthSample for f32 {
fn to_unit(self, _bits: u8) -> f32 {
self
}
}
fn write_converted_plane<T: BitDepthSample>(
input_plane: &Plane<T>,
builder: &mut FrameBuilder,
plane_index: usize,
input_bits: u8,
output_bits: u8,
output_sample: SampleType,
apply_dither: bool,
) -> Result<()> {
match output_sample {
SampleType::U8 => {
let mut output_plane = builder.plane_mut::<u8>(plane_index)?;
write_plane_to_u8(
input_plane,
&mut output_plane,
plane_index,
input_bits,
apply_dither,
);
}
SampleType::U16 => {
let mut output_plane = builder.plane_mut::<u16>(plane_index)?;
write_plane_to_u16(
input_plane,
&mut output_plane,
plane_index,
input_bits,
output_bits,
apply_dither,
);
}
SampleType::F32 => {
let mut output_plane = builder.plane_mut::<f32>(plane_index)?;
write_plane_to_f32(input_plane, &mut output_plane, input_bits);
}
}
Ok(())
}
fn write_plane_to_u8<T: BitDepthSample>(
input_plane: &Plane<T>,
output_plane: &mut PlaneMut<'_, u8>,
plane_index: usize,
input_bits: u8,
apply_dither: bool,
) {
for y in 0..output_plane.height() {
let input_row = input_plane.row(y).expect("validated source row exists");
let output_row = output_plane
.row_mut(y)
.expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
let unit = semisafe_get(input_row, x)
.to_unit(input_bits)
.clamp(0.0, 1.0);
let dither = if apply_dither {
fruit_offset(plane_index, x, y)
} else {
0.0
};
let code = unit.fma(u8::MAX as f32, dither);
*output_sample = code.round().clamp(0.0, u8::MAX as f32) as u8;
}
}
}
fn write_plane_to_u16<T: BitDepthSample>(
input_plane: &Plane<T>,
output_plane: &mut PlaneMut<'_, u16>,
plane_index: usize,
input_bits: u8,
output_bits: u8,
apply_dither: bool,
) {
let max = integer_max(output_bits);
for y in 0..output_plane.height() {
let input_row = input_plane.row(y).expect("validated source row exists");
let output_row = output_plane
.row_mut(y)
.expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
let unit = semisafe_get(input_row, x)
.to_unit(input_bits)
.clamp(0.0, 1.0);
let dither = if apply_dither {
fruit_offset(plane_index, x, y)
} else {
0.0
};
let code = unit.fma(max, dither);
*output_sample = code.round().clamp(0.0, max) as u16;
}
}
}
fn write_plane_to_f32<T: BitDepthSample>(
input_plane: &Plane<T>,
output_plane: &mut PlaneMut<'_, f32>,
input_bits: u8,
) {
for y in 0..output_plane.height() {
let input_row = input_plane.row(y).expect("validated source row exists");
let output_row = output_plane
.row_mut(y)
.expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
*output_sample = semisafe_get(input_row, x)
.to_unit(input_bits)
.clamp(0.0, 1.0);
}
}
}
fn integer_max(bits: u8) -> f32 {
1_u32
.checked_shl(u32::from(bits))
.map_or(u32::MAX as f32, |value| (value - 1) as f32)
}
fn should_dither_bit_depth(
mode: DitherMode,
input_bits: u8,
output_bits: u8,
input_sample: SampleType,
output_sample: SampleType,
) -> bool {
if mode == DitherMode::None {
return false;
}
let integer_down_depth = matches!(
(input_sample, output_sample),
(
SampleType::U8 | SampleType::U16,
SampleType::U8 | SampleType::U16
)
) && output_bits < input_bits;
let float_to_integer = input_sample == SampleType::F32 && output_sample != SampleType::F32;
integer_down_depth || float_to_integer
}
#[cfg(test)]
mod tests {
#![expect(clippy::indexing_slicing, reason = "allow in tests")]
use pixelflow_core::{
ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterChangeSet, FilterCompatibility,
FrameCount, FrameRate, GraphBuilder, Rational,
};
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,
};
use crate::{
CONVERT_BIT_DEPTH_CONTRACT, DitherMode, EXACT_GOLDEN_TOLERANCE, FILTER_CONVERT_BIT_DEPTH,
RESIZE_GOLDEN_TOLERANCE,
};
use super::{
ConvertBitDepthExecutor, ConvertBitDepthOptions, add_convert_bit_depth_filter,
convert_bit_depth_output_media,
};
fn executor_for(
alias: &str,
width: usize,
height: usize,
options: ConvertBitDepthOptions,
) -> ConvertBitDepthExecutor {
let media = fixed_media(alias, width, height);
let (conversion, _) = convert_bit_depth_output_media(&media, options)
.expect("bit-depth conversion should validate");
ConvertBitDepthExecutor::new(conversion)
}
#[test]
fn convert_bit_depth_contract_accepts_phase1_planar_formats() {
let media = fixed_media("grayf32", 1920, 1080);
let format = CONVERT_BIT_DEPTH_CONTRACT
.validate_input_media(&media)
.expect("convert_bit_depth should accept f32 planar input");
assert_eq!(format.name(), "grayf32");
assert_eq!(CONVERT_BIT_DEPTH_CONTRACT.name(), FILTER_CONVERT_BIT_DEPTH);
assert_eq!(
CONVERT_BIT_DEPTH_CONTRACT.compatibility(),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: true,
resolution: false,
frame_count: false,
frame_rate: false,
})
);
}
#[test]
fn convert_bit_depth_options_validates_output_media_and_preserves_timing() {
let input = fixed_media("yuv420p8", 1920, 1080);
let (conversion, output) =
convert_bit_depth_output_media(&input, ConvertBitDepthOptions::new(10))
.expect("conversion should validate");
assert_eq!(conversion.output_format().name(), "yuv420p10");
assert_eq!(conversion.target_bits(), 10);
assert_eq!(conversion.dither(), DitherMode::Fruit);
assert_eq!(output.resolution(), input.resolution());
assert_eq!(output.frame_count(), input.frame_count());
assert_eq!(output.frame_rate(), input.frame_rate());
}
#[test]
fn convert_bit_depth_options_can_disable_dither() {
let input = fixed_media("gray10", 4, 1);
let (conversion, _) = convert_bit_depth_output_media(
&input,
ConvertBitDepthOptions::new(8).with_dither(DitherMode::None),
)
.expect("conversion should validate");
assert_eq!(conversion.dither(), DitherMode::None);
}
#[test]
fn convert_bit_depth_options_rejects_invalid_depth_and_variable_media() {
let input = fixed_media("gray8", 4, 1);
let error = convert_bit_depth_output_media(&input, ConvertBitDepthOptions::new(14))
.expect_err("14-bit output should fail");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("format.unsupported_bit_depth"));
let variable = ClipMedia::new(
pixelflow_core::ClipFormat::Variable,
ClipResolution::Fixed {
width: 4,
height: 1,
},
FrameCount::Finite(1),
FrameRate::Cfr(Rational {
numerator: 24,
denominator: 1,
}),
);
let error = convert_bit_depth_output_media(&variable, ConvertBitDepthOptions::new(8))
.expect_err("variable format should fail upfront");
assert_eq!(error.code(), ErrorCode::new("filter.variable_format"));
}
#[test]
fn integer_max_handles_large_bit_counts_without_overflow_panics() {
assert_eq!(super::integer_max(16), 65_535.0);
assert_eq!(super::integer_max(32), u32::MAX as f32);
assert_eq!(super::integer_max(64), u32::MAX as f32);
}
#[test]
fn convert_bit_depth_executor_downconverts_u10_to_u8_without_dither() {
let input = synthetic_u16_frame("gray10", 4, 1, |_plane, x, _y| [0_u16, 64, 512, 1023][x])
.expect("input frame should build");
let executor = executor_for(
"gray10",
4,
1,
ConvertBitDepthOptions::new(8).with_dither(DitherMode::None),
);
let output = executor
.convert_frame_for_tests(&input)
.expect("conversion should render");
assert_eq!(output.format().name(), "gray8");
assert_plane_u8_near(&output, 0, &[&[0, 16, 128, 255]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_bit_depth_executor_upconverts_u8_to_u10_without_dither() {
let input = synthetic_u8_frame("gray8", 3, 1, |_plane, x, _y| [0_u8, 128, 255][x])
.expect("input frame should build");
let executor = executor_for("gray8", 3, 1, ConvertBitDepthOptions::new(10));
let output = executor
.convert_frame_for_tests(&input)
.expect("conversion should render");
assert_eq!(output.format().name(), "gray10");
assert_plane_u16_near(&output, 0, &[&[0, 514, 1023]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_bit_depth_executor_converts_float_to_u8_and_clamps() {
let input = synthetic_f32_frame("grayf32", 5, 1, |_plane, x, _y| {
[-0.2, 0.0, 0.5, 1.0, 1.5][x]
})
.expect("input frame should build");
let executor = executor_for(
"grayf32",
5,
1,
ConvertBitDepthOptions::new(8).with_dither(DitherMode::None),
);
let output = executor
.convert_frame_for_tests(&input)
.expect("conversion should render");
assert_plane_u8_near(
&output,
0,
&[&[0, 0, 128, 255, 255]],
EXACT_GOLDEN_TOLERANCE,
);
}
#[test]
fn convert_bit_depth_executor_converts_u8_to_float() {
let input = synthetic_u8_frame("gray8", 3, 1, |_plane, x, _y| [0_u8, 128, 255][x])
.expect("input frame should build");
let executor = executor_for("gray8", 3, 1, ConvertBitDepthOptions::new(32));
let output = executor
.convert_frame_for_tests(&input)
.expect("conversion should render");
assert_eq!(output.format().name(), "grayf32");
assert_plane_f32_near(
&output,
0,
&[&[0.0, 128.0 / 255.0, 1.0]],
RESIZE_GOLDEN_TOLERANCE,
);
}
#[test]
fn convert_bit_depth_executor_applies_fruit_dither_by_default_for_down_depth() {
let input = synthetic_u16_frame("gray10", 8, 1, |_plane, _x, _y| 2)
.expect("input frame should build");
let executor = executor_for("gray10", 8, 1, ConvertBitDepthOptions::new(8));
let output = executor
.convert_frame_for_tests(&input)
.expect("conversion should render");
assert_plane_u8_near(
&output,
0,
&[&[0, 1, 0, 1, 0, 1, 1, 0]],
EXACT_GOLDEN_TOLERANCE,
);
}
#[test]
fn convert_bit_depth_executor_dither_can_be_disabled_for_down_depth() {
let input = synthetic_u16_frame("gray10", 8, 1, |_plane, _x, _y| 2)
.expect("input frame should build");
let executor = executor_for(
"gray10",
8,
1,
ConvertBitDepthOptions::new(8).with_dither(DitherMode::None),
);
let output = executor
.convert_frame_for_tests(&input)
.expect("conversion should render");
assert_plane_u8_near(
&output,
0,
&[&[0, 0, 0, 0, 0, 0, 0, 0]],
EXACT_GOLDEN_TOLERANCE,
);
}
#[test]
fn add_convert_bit_depth_filter_creates_filter_node_with_validated_output_media() {
let input_media = fixed_media("gray10", 4, 1);
let mut builder = GraphBuilder::new();
let source = builder.source("source", input_media.clone());
let (converted, executor) = add_convert_bit_depth_filter(
&mut builder,
source,
&input_media,
ConvertBitDepthOptions::new(8).with_dither(DitherMode::None),
)
.expect("convert node should build");
let graph = builder.build();
let node = graph
.node(converted.node_id())
.expect("convert node exists");
assert_eq!(executor.conversion().output_format().name(), "gray8");
assert!(matches!(
node.media().format(),
pixelflow_core::ClipFormat::Fixed(format) if format.name() == "gray8"
));
assert_eq!(node.media().resolution(), input_media.resolution());
}
}