use pixelflow_core::{
ChromaSiting, Clip, ClipFormat, ClipMedia, ColorMatrix, ColorPrimaries, ColorRange,
ColorTransfer, ErrorCategory, ErrorCode, FilterChangeSet, FilterCompatibility, FilterPlan,
FilterPlanRequest, FormatDescriptor, FormatFamily, Frame, FrameBuilder, FrameExecutor,
FrameRequest, GraphBuilder, Logger, Metadata, MetadataSchema, MetadataValue, PixelFlowError,
PlaneRole, Result, Sample, SampleType,
};
use semisafe::slice::get as semisafe_get;
use crate::{
DitherMode, OfficialFilterContract, SupportedFormats,
colorspace::{
ColorMetadataOverrides, RangeSample, ResolvedColorMetadata, Rgb,
ValidatedConvertColorspace, encode_output_sample, normalize_sample, plane_code_range,
plane_index_for_role, plane_luma_coordinate, resolve_source_metadata_with_overrides,
rgb_to_components, sample_bilinear, sample_source_yuv_rgb, transform_rgb_between_metadata,
},
contracts::{ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES},
options::{optional_string, required_string, single_input_media, single_input_slice},
};
pub const FILTER_CONVERT_FORMAT: &str = "convert_format";
pub const CONVERT_FORMAT_CONTRACT: OfficialFilterContract = OfficialFilterContract::new(
FILTER_CONVERT_FORMAT,
SupportedFormats::new(ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: true,
resolution: false,
frame_count: false,
frame_rate: false,
}),
);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConvertFormatOptions {
format: String,
range: Option<ColorRange>,
matrix: Option<String>,
transfer: Option<String>,
primaries: Option<String>,
chroma_siting: Option<String>,
source_range: Option<ColorRange>,
source_matrix: Option<String>,
source_transfer: Option<String>,
source_primaries: Option<String>,
source_chroma_siting: Option<String>,
}
impl ConvertFormatOptions {
#[must_use]
pub fn new(format: impl Into<String>) -> Self {
Self {
format: format.into(),
range: None,
matrix: None,
transfer: None,
primaries: None,
chroma_siting: None,
source_range: None,
source_matrix: None,
source_transfer: None,
source_primaries: None,
source_chroma_siting: None,
}
}
#[must_use]
pub fn format(&self) -> &str {
&self.format
}
#[must_use]
pub const fn with_range(mut self, range: ColorRange) -> Self {
self.range = Some(range);
self
}
#[must_use]
pub fn with_matrix(mut self, matrix: impl Into<String>) -> Self {
self.matrix = Some(matrix.into());
self
}
#[must_use]
pub fn with_transfer(mut self, transfer: impl Into<String>) -> Self {
self.transfer = Some(transfer.into());
self
}
#[must_use]
pub fn with_primaries(mut self, primaries: impl Into<String>) -> Self {
self.primaries = Some(primaries.into());
self
}
#[must_use]
pub fn with_chroma_siting(mut self, chroma_siting: impl Into<String>) -> Self {
self.chroma_siting = Some(chroma_siting.into());
self
}
#[must_use]
pub const fn with_source_range(mut self, range: ColorRange) -> Self {
self.source_range = Some(range);
self
}
#[must_use]
pub fn with_source_matrix(mut self, matrix: impl Into<String>) -> Self {
self.source_matrix = Some(matrix.into());
self
}
#[must_use]
pub fn with_source_transfer(mut self, transfer: impl Into<String>) -> Self {
self.source_transfer = Some(transfer.into());
self
}
#[must_use]
pub fn with_source_primaries(mut self, primaries: impl Into<String>) -> Self {
self.source_primaries = Some(primaries.into());
self
}
#[must_use]
pub fn with_source_chroma_siting(mut self, chroma_siting: impl Into<String>) -> Self {
self.source_chroma_siting = Some(chroma_siting.into());
self
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ParsedColorimetry {
range: Option<ColorRange>,
matrix: Option<ColorMatrix>,
transfer: Option<ColorTransfer>,
primaries: Option<ColorPrimaries>,
chroma_siting: Option<ChromaSiting>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ValidatedCrossFamilyColorimetry {
source_overrides: ColorMetadataOverrides,
target: ValidatedConvertColorspace,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FormatConversionKind {
SameDescriptor,
PlanarRgbCopy,
YuvToYuv,
GrayToGray,
GrayToYuv,
YuvToGray,
GrayToPlanarRgb,
PlanarRgbToGray,
YuvToPlanarRgb,
PlanarRgbToYuv,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ValidatedConvertFormat {
input_format: FormatDescriptor,
output_format: FormatDescriptor,
kind: FormatConversionKind,
cross_family: Option<ValidatedCrossFamilyColorimetry>,
}
impl ValidatedConvertFormat {
#[must_use]
pub const fn input_format(&self) -> &FormatDescriptor {
&self.input_format
}
#[must_use]
pub const fn output_format(&self) -> &FormatDescriptor {
&self.output_format
}
}
pub fn convert_format_output_media(
input: &ClipMedia,
options: ConvertFormatOptions,
) -> Result<(ValidatedConvertFormat, ClipMedia)> {
let input_format = CONVERT_FORMAT_CONTRACT.validate_input_media(input)?;
let ConvertFormatOptions {
format,
range,
matrix,
transfer,
primaries,
chroma_siting,
source_range,
source_matrix,
source_transfer,
source_primaries,
source_chroma_siting,
} = options;
let output_format = pixelflow_core::resolve_format_alias(&format)?;
let target = ParsedColorimetry {
range,
matrix: matrix.as_deref().map(ColorMatrix::parse).transpose()?,
transfer: transfer.as_deref().map(ColorTransfer::parse).transpose()?,
primaries: primaries
.as_deref()
.map(ColorPrimaries::parse)
.transpose()?,
chroma_siting: chroma_siting
.as_deref()
.map(ChromaSiting::parse)
.transpose()?,
};
let source = ParsedColorimetry {
range: source_range,
matrix: source_matrix
.as_deref()
.map(ColorMatrix::parse)
.transpose()?,
transfer: source_transfer
.as_deref()
.map(ColorTransfer::parse)
.transpose()?,
primaries: source_primaries
.as_deref()
.map(ColorPrimaries::parse)
.transpose()?,
chroma_siting: source_chroma_siting
.as_deref()
.map(ChromaSiting::parse)
.transpose()?,
};
let (kind, cross_family) = classify_conversion(input_format, &output_format, source, target)?;
Ok((
ValidatedConvertFormat {
input_format: input_format.clone(),
output_format: output_format.clone(),
kind,
cross_family,
},
ClipMedia::new(
ClipFormat::Fixed(output_format),
input.resolution().clone(),
input.frame_count(),
input.frame_rate(),
),
))
}
fn classify_conversion(
input: &FormatDescriptor,
output: &FormatDescriptor,
source: ParsedColorimetry,
target: ParsedColorimetry,
) -> Result<(
FormatConversionKind,
Option<ValidatedCrossFamilyColorimetry>,
)> {
if input == output {
return Ok((FormatConversionKind::SameDescriptor, None));
}
if input.bits_per_sample() != output.bits_per_sample()
|| input.sample_type() != output.sample_type()
{
return Err(format_conversion_error(format!(
"filter '{FILTER_CONVERT_FORMAT}' cannot change bit depth from '{}' to '{}'; use convert_bit_depth first",
input.name(),
output.name()
)));
}
match (input.family(), output.family()) {
(FormatFamily::PlanarRgb, FormatFamily::PlanarRgb) => {
Ok((FormatConversionKind::PlanarRgbCopy, None))
}
(FormatFamily::Yuv, FormatFamily::Yuv) => Ok((FormatConversionKind::YuvToYuv, None)),
(FormatFamily::Gray, FormatFamily::Gray) => Ok((FormatConversionKind::GrayToGray, None)),
(FormatFamily::Gray, FormatFamily::Yuv) => Ok((FormatConversionKind::GrayToYuv, None)),
(FormatFamily::Yuv, FormatFamily::Gray) => Ok((FormatConversionKind::YuvToGray, None)),
(FormatFamily::Gray, FormatFamily::PlanarRgb) => Ok((
FormatConversionKind::GrayToPlanarRgb,
Some(validate_cross_family_colorimetry(
input, output, source, target,
)?),
)),
(FormatFamily::PlanarRgb, FormatFamily::Gray) => Ok((
FormatConversionKind::PlanarRgbToGray,
Some(validate_cross_family_colorimetry(
input, output, source, target,
)?),
)),
(FormatFamily::Yuv, FormatFamily::PlanarRgb) => Ok((
FormatConversionKind::YuvToPlanarRgb,
Some(validate_cross_family_colorimetry(
input, output, source, target,
)?),
)),
(FormatFamily::PlanarRgb, FormatFamily::Yuv) => Ok((
FormatConversionKind::PlanarRgbToYuv,
Some(validate_cross_family_colorimetry(
input, output, source, target,
)?),
)),
}
}
fn validate_cross_family_colorimetry(
input: &FormatDescriptor,
output: &FormatDescriptor,
source: ParsedColorimetry,
target: ParsedColorimetry,
) -> Result<ValidatedCrossFamilyColorimetry> {
validate_source_override_axes(input, source)?;
validate_target_axes(output, target)?;
if input.family() == FormatFamily::PlanarRgb && output.family() != FormatFamily::PlanarRgb {
require_matrix(
target.matrix,
input.name(),
output.name(),
"target 'matrix'",
)?;
}
let target_matrix = match output.family() {
FormatFamily::PlanarRgb => Some(ColorMatrix::Identity),
FormatFamily::Gray | FormatFamily::Yuv => target.matrix,
};
Ok(ValidatedCrossFamilyColorimetry {
source_overrides: ColorMetadataOverrides {
range: source.range,
matrix: source.matrix,
transfer: source.transfer,
primaries: source.primaries,
chroma_siting: source.chroma_siting,
},
target: ValidatedConvertColorspace::from_parts(
target.range,
target_matrix,
target.transfer,
target.primaries,
target.chroma_siting,
DitherMode::Fruit,
),
})
}
fn validate_source_override_axes(
input: &FormatDescriptor,
source: ParsedColorimetry,
) -> Result<()> {
match input.family() {
FormatFamily::Gray => {
reject_if_some(source.matrix.is_some(), "source_matrix", input.name())?;
reject_if_some(source.primaries.is_some(), "source_primaries", input.name())?;
reject_if_some(
source.chroma_siting.is_some(),
"source_chroma_siting",
input.name(),
)?;
}
FormatFamily::PlanarRgb => {
reject_if_some(source.matrix.is_some(), "source_matrix", input.name())?;
reject_if_some(
source.chroma_siting.is_some(),
"source_chroma_siting",
input.name(),
)?;
}
FormatFamily::Yuv => {}
}
Ok(())
}
fn validate_target_axes(output: &FormatDescriptor, target: ParsedColorimetry) -> Result<()> {
match output.family() {
FormatFamily::PlanarRgb => {
reject_if_some(target.matrix.is_some(), "matrix", output.name())?;
reject_if_some(
target.chroma_siting.is_some(),
"chroma_siting",
output.name(),
)?;
}
FormatFamily::Gray => {
reject_if_some(
target.chroma_siting.is_some(),
"chroma_siting",
output.name(),
)?;
}
FormatFamily::Yuv => {}
}
Ok(())
}
fn reject_if_some(value: bool, option: &str, format_name: &str) -> Result<()> {
if value {
return Err(colorimetry_error(
"filter.invalid_colorimetry",
format!(
"filter '{FILTER_CONVERT_FORMAT}' option '{option}' is not applicable when converting to or from '{format_name}'"
),
));
}
Ok(())
}
fn require_matrix(
matrix: Option<ColorMatrix>,
input_name: &str,
output_name: &str,
name: &str,
) -> Result<()> {
if matrix.is_none() {
return Err(colorimetry_error(
"filter.missing_colorimetry",
format!(
"filter '{FILTER_CONVERT_FORMAT}' requires {name} when converting '{}' to '{}'",
input_name, output_name
),
));
}
Ok(())
}
fn colorimetry_error(code: &'static str, message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(ErrorCategory::Format, ErrorCode::new(code), message)
}
fn format_conversion_error(message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(
ErrorCategory::Format,
ErrorCode::new("filter.unsupported_format_conversion"),
message,
)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConvertFormatExecutor {
conversion: ValidatedConvertFormat,
}
impl ConvertFormatExecutor {
#[must_use]
pub const fn new(conversion: ValidatedConvertFormat) -> Self {
Self { conversion }
}
#[must_use]
pub const fn conversion(&self) -> &ValidatedConvertFormat {
&self.conversion
}
fn convert_frame(&self, input: &Frame) -> Result<Frame> {
validate_runtime_input(input, &self.conversion)?;
if input.format() == self.conversion.output_format() {
return Ok(input.clone());
}
let mut builder = FrameBuilder::new(
self.conversion.output_format().clone(),
input.width(),
input.height(),
&MetadataSchema::core(),
pixelflow_core::AllocatorConfig::default(),
)?;
let output_bits = self.conversion.output_format().bits_per_sample();
let target_metadata = match input.format().sample_type() {
SampleType::U8 => {
convert_frame_typed::<u8>(input, &mut builder, &self.conversion, output_bits)?
}
SampleType::U16 => {
convert_frame_typed::<u16>(input, &mut builder, &self.conversion, output_bits)?
}
SampleType::F32 => {
convert_frame_typed::<f32>(input, &mut builder, &self.conversion, output_bits)?
}
};
Ok(builder.finish().with_metadata(format_metadata(
input.metadata(),
&self.conversion,
target_metadata,
)?))
}
}
fn validate_runtime_input(input: &Frame, conversion: &ValidatedConvertFormat) -> Result<()> {
if input.format() != conversion.input_format() {
return Err(PixelFlowError::new(
ErrorCategory::Format,
ErrorCode::new("filter.runtime_format_mismatch"),
format!(
"filter '{FILTER_CONVERT_FORMAT}' expected runtime input format '{}' but received '{}'",
conversion.input_format().name(),
input.format().name()
),
));
}
Ok(())
}
#[cfg(test)]
impl ConvertFormatExecutor {
fn convert_frame_for_tests(&self, input: &Frame) -> Result<Frame> {
self.convert_frame(input)
}
}
impl FrameExecutor for ConvertFormatExecutor {
fn prepare(&self, request: FrameRequest<'_>) -> Result<Frame> {
let input = request.input_frame(0, request.frame_number())?;
self.convert_frame(&input)
}
}
trait FormatSample: Sample + RangeSample {
fn neutral_chroma(bits: u8) -> Self;
}
impl FormatSample for u8 {
fn neutral_chroma(_bits: u8) -> Self {
128
}
}
impl FormatSample for u16 {
fn neutral_chroma(bits: u8) -> Self {
let max = 1_u32
.checked_shl(u32::from(bits))
.map_or(u32::MAX, |value| value.saturating_sub(1));
u16::try_from(max.div_ceil(2)).unwrap_or(u16::MAX)
}
}
impl FormatSample for f32 {
fn neutral_chroma(_bits: u8) -> Self {
0.5
}
}
fn convert_frame_typed<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
conversion: &ValidatedConvertFormat,
output_bits: u8,
) -> Result<Option<ResolvedColorMetadata>> {
match conversion.kind {
FormatConversionKind::SameDescriptor
| FormatConversionKind::PlanarRgbCopy
| FormatConversionKind::GrayToGray => {
copy_all_planes::<T>(input, builder)?;
Ok(None)
}
FormatConversionKind::YuvToYuv => {
copy_luma_and_regrid_chroma::<T>(input, builder)?;
Ok(None)
}
FormatConversionKind::GrayToYuv => {
gray_to_yuv::<T>(input, builder, output_bits)?;
Ok(None)
}
FormatConversionKind::YuvToGray => {
copy_plane::<T>(input, builder, 0, 0)?;
Ok(None)
}
FormatConversionKind::GrayToPlanarRgb => {
let resolved = cross_family_metadata(input, conversion)?;
gray_to_rgb::<T>(input, builder, resolved.source, resolved.target)?;
Ok(Some(resolved.target))
}
FormatConversionKind::PlanarRgbToGray => {
let resolved = cross_family_metadata(input, conversion)?;
rgb_to_gray::<T>(input, builder, resolved.source, resolved.target)?;
Ok(Some(resolved.target))
}
FormatConversionKind::YuvToPlanarRgb => {
let resolved = cross_family_metadata(input, conversion)?;
yuv_to_rgb::<T>(input, builder, resolved.source, resolved.target)?;
Ok(Some(resolved.target))
}
FormatConversionKind::PlanarRgbToYuv => {
let resolved = cross_family_metadata(input, conversion)?;
rgb_to_yuv::<T>(
input,
builder,
conversion.output_format(),
resolved.source,
resolved.target,
)?;
Ok(Some(resolved.target))
}
}
}
fn copy_all_planes<T: FormatSample>(input: &Frame, builder: &mut FrameBuilder) -> Result<()> {
for plane_index in 0..input.format().planes().len() {
copy_plane::<T>(input, builder, plane_index, plane_index)?;
}
Ok(())
}
fn copy_luma_and_regrid_chroma<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
) -> Result<()> {
copy_plane::<T>(input, builder, 0, 0)?;
regrid_plane::<T>(input, builder, 1, 1)?;
regrid_plane::<T>(input, builder, 2, 2)
}
fn gray_to_yuv<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
output_bits: u8,
) -> Result<()> {
copy_plane::<T>(input, builder, 0, 0)?;
fill_plane::<T>(builder, 1, T::neutral_chroma(output_bits))?;
fill_plane::<T>(builder, 2, T::neutral_chroma(output_bits))
}
#[derive(Clone, Copy, Debug)]
struct ResolvedCrossFamilyMetadata {
source: ResolvedColorMetadata,
target: ResolvedColorMetadata,
}
fn cross_family_metadata(
input: &Frame,
conversion: &ValidatedConvertFormat,
) -> Result<ResolvedCrossFamilyMetadata> {
let Some(cross_family) = conversion.cross_family else {
unreachable!("cross-family conversion kind should carry colorimetry");
};
let logger = Logger::default();
let source = resolve_source_metadata_with_overrides(
input,
cross_family.target,
cross_family.source_overrides,
&logger,
)?;
let target = source.with_targets(conversion.output_format(), cross_family.target);
Ok(ResolvedCrossFamilyMetadata { source, target })
}
fn gray_to_rgb<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
source: ResolvedColorMetadata,
target: ResolvedColorMetadata,
) -> Result<()> {
let input_plane = input.plane::<T>(0)?;
let bits = input.format().bits_per_sample();
let source_range = plane_code_range(
PlaneRole::Gray,
bits,
source.range,
T::SAMPLE_TYPE == SampleType::F32,
);
let mut converted = Vec::with_capacity(input.width() * input.height());
for y in 0..input.height() {
let row = input_plane.row(y).expect("validated source row exists");
for x in 0..row.len() {
let value = normalize_sample(semisafe_get(row, x).to_value(), source_range);
converted.push(transform_rgb_between_metadata(
Rgb {
r: value,
g: value,
b: value,
},
source,
target,
));
}
}
write_rgb_planes::<T>(
builder,
input.width(),
bits,
source.range,
target,
&converted,
)
}
fn rgb_to_gray<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
source: ResolvedColorMetadata,
target: ResolvedColorMetadata,
) -> Result<()> {
let input_r = input.plane::<T>(0)?;
let input_g = input.plane::<T>(1)?;
let input_b = input.plane::<T>(2)?;
let bits = input.format().bits_per_sample();
let mut output = builder.plane_mut::<T>(0)?;
for y in 0..output.height() {
let output_row = output.row_mut(y).expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
let rgb = sample_source_rgb::<T>(
&input_r, &input_g, &input_b, source, bits, x as f32, y as f32,
);
let rgb = transform_rgb_between_metadata(rgb, source, target);
let (gray, _, _) = rgb_to_components(
rgb,
target.matrix.expect("gray target matrix resolved"),
target.primaries,
target.transfer,
)?;
*output_sample = encode_output_sample::<T>(
gray,
PlaneRole::Gray,
target.range,
bits,
source.range,
DitherMode::Fruit,
0,
x,
y,
);
}
}
Ok(())
}
fn yuv_to_rgb<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
source: ResolvedColorMetadata,
target: ResolvedColorMetadata,
) -> Result<()> {
let bits = input.format().bits_per_sample();
let y_index = plane_index_for_role(input.format(), PlaneRole::Y).expect("Y plane exists");
let u_index = plane_index_for_role(input.format(), PlaneRole::U).expect("U plane exists");
let v_index = plane_index_for_role(input.format(), PlaneRole::V).expect("V plane exists");
let y_desc = semisafe_get(input.format().planes(), y_index);
let u_desc = semisafe_get(input.format().planes(), u_index);
let v_desc = semisafe_get(input.format().planes(), v_index);
let y_plane = input.plane::<T>(y_index)?;
let u_plane = input.plane::<T>(u_index)?;
let v_plane = input.plane::<T>(v_index)?;
let mut converted = Vec::with_capacity(input.width() * input.height());
for y in 0..input.height() {
for x in 0..input.width() {
let rgb = sample_source_yuv_rgb::<T>(
&y_plane, &u_plane, &v_plane, y_desc, u_desc, v_desc, source, bits, x as f32,
y as f32,
)?;
converted.push(transform_rgb_between_metadata(rgb, source, target));
}
}
write_rgb_planes::<T>(
builder,
input.width(),
bits,
source.range,
target,
&converted,
)
}
fn rgb_to_yuv<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
output_format: &FormatDescriptor,
source: ResolvedColorMetadata,
target: ResolvedColorMetadata,
) -> Result<()> {
let input_r = input.plane::<T>(0)?;
let input_g = input.plane::<T>(1)?;
let input_b = input.plane::<T>(2)?;
let bits = input.format().bits_per_sample();
let y_index = plane_index_for_role(output_format, PlaneRole::Y).expect("Y plane exists");
let u_index = plane_index_for_role(output_format, PlaneRole::U).expect("U plane exists");
let v_index = plane_index_for_role(output_format, PlaneRole::V).expect("V plane exists");
let u_desc = semisafe_get(output_format.planes(), u_index);
let v_desc = semisafe_get(output_format.planes(), v_index);
{
let mut output_y = builder.plane_mut::<T>(y_index)?;
for y in 0..output_y.height() {
let output_row = output_y.row_mut(y).expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
let rgb = sample_source_rgb::<T>(
&input_r, &input_g, &input_b, source, bits, x as f32, y as f32,
);
let rgb = transform_rgb_between_metadata(rgb, source, target);
let (y_component, _, _) = rgb_to_components(
rgb,
target.matrix.expect("YUV target matrix resolved"),
target.primaries,
target.transfer,
)?;
*output_sample = encode_output_sample::<T>(
y_component,
PlaneRole::Y,
target.range,
bits,
source.range,
DitherMode::Fruit,
y_index,
x,
y,
);
}
}
}
{
let mut output_u = builder.plane_mut::<T>(u_index)?;
let (siting_x, siting_y) = target.chroma_siting.offsets();
for y in 0..output_u.height() {
let luma_y = plane_luma_coordinate(y, u_desc.height_divisor, siting_y);
let output_row = output_u.row_mut(y).expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
let luma_x = plane_luma_coordinate(x, u_desc.width_divisor, siting_x);
let rgb = sample_source_rgb::<T>(
&input_r, &input_g, &input_b, source, bits, luma_x, luma_y,
);
let rgb = transform_rgb_between_metadata(rgb, source, target);
let (_, u_component, _) = rgb_to_components(
rgb,
target.matrix.expect("YUV target matrix resolved"),
target.primaries,
target.transfer,
)?;
*output_sample = encode_output_sample::<T>(
u_component + 0.5,
PlaneRole::U,
target.range,
bits,
source.range,
DitherMode::Fruit,
u_index,
x,
y,
);
}
}
}
{
let mut output_v = builder.plane_mut::<T>(v_index)?;
let (siting_x, siting_y) = target.chroma_siting.offsets();
for y in 0..output_v.height() {
let luma_y = plane_luma_coordinate(y, v_desc.height_divisor, siting_y);
let output_row = output_v.row_mut(y).expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
let luma_x = plane_luma_coordinate(x, v_desc.width_divisor, siting_x);
let rgb = sample_source_rgb::<T>(
&input_r, &input_g, &input_b, source, bits, luma_x, luma_y,
);
let rgb = transform_rgb_between_metadata(rgb, source, target);
let (_, _, v_component) = rgb_to_components(
rgb,
target.matrix.expect("YUV target matrix resolved"),
target.primaries,
target.transfer,
)?;
*output_sample = encode_output_sample::<T>(
v_component + 0.5,
PlaneRole::V,
target.range,
bits,
source.range,
DitherMode::Fruit,
v_index,
x,
y,
);
}
}
}
Ok(())
}
fn sample_source_rgb<T: FormatSample>(
input_r: &pixelflow_core::Plane<T>,
input_g: &pixelflow_core::Plane<T>,
input_b: &pixelflow_core::Plane<T>,
source: ResolvedColorMetadata,
bits: u8,
luma_x: f32,
luma_y: f32,
) -> Rgb {
let is_float = T::SAMPLE_TYPE == SampleType::F32;
let range_r = plane_code_range(PlaneRole::R, bits, source.range, is_float);
let range_g = plane_code_range(PlaneRole::G, bits, source.range, is_float);
let range_b = plane_code_range(PlaneRole::B, bits, source.range, is_float);
Rgb {
r: normalize_sample(sample_bilinear(input_r, luma_x, luma_y), range_r),
g: normalize_sample(sample_bilinear(input_g, luma_x, luma_y), range_g),
b: normalize_sample(sample_bilinear(input_b, luma_x, luma_y), range_b),
}
}
fn write_rgb_planes<T: FormatSample>(
builder: &mut FrameBuilder,
width: usize,
bits: u8,
source_range: ColorRange,
target: ResolvedColorMetadata,
converted: &[Rgb],
) -> Result<()> {
write_rgb_plane::<T>(
builder,
0,
width,
bits,
source_range,
target,
converted,
|rgb| rgb.r,
)?;
write_rgb_plane::<T>(
builder,
1,
width,
bits,
source_range,
target,
converted,
|rgb| rgb.g,
)?;
write_rgb_plane::<T>(
builder,
2,
width,
bits,
source_range,
target,
converted,
|rgb| rgb.b,
)
}
fn write_rgb_plane<T: FormatSample>(
builder: &mut FrameBuilder,
plane_index: usize,
width: usize,
bits: u8,
source_range: ColorRange,
target: ResolvedColorMetadata,
converted: &[Rgb],
sample: impl Fn(Rgb) -> f32,
) -> Result<()> {
let role = match plane_index {
0 => PlaneRole::R,
1 => PlaneRole::G,
2 => PlaneRole::B,
_ => unreachable!("RGB output only has three planes"),
};
let mut output_plane = builder.plane_mut::<T>(plane_index)?;
for y in 0..output_plane.height() {
let output_row = output_plane
.row_mut(y)
.expect("validated output row exists");
for (x, output_sample) in output_row.iter_mut().enumerate() {
let rgb = *semisafe_get(converted, y * width + x);
*output_sample = encode_output_sample::<T>(
sample(rgb),
role,
target.range,
bits,
source_range,
DitherMode::Fruit,
plane_index,
x,
y,
);
}
}
Ok(())
}
fn copy_plane<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
input_plane_index: usize,
output_plane_index: usize,
) -> Result<()> {
let input_plane = input.plane::<T>(input_plane_index)?;
let mut output_plane = builder.plane_mut::<T>(output_plane_index)?;
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);
}
}
Ok(())
}
fn regrid_plane<T: FormatSample>(
input: &Frame,
builder: &mut FrameBuilder,
input_plane_index: usize,
output_plane_index: usize,
) -> Result<()> {
let input_plane = input.plane::<T>(input_plane_index)?;
let mut output_plane = builder.plane_mut::<T>(output_plane_index)?;
for y in 0..output_plane.height() {
let source_y = nearest_center_index(y, output_plane.height(), input_plane.height());
let input_row = input_plane
.row(source_y)
.expect("validated source row exists");
let output_row = output_plane
.row_mut(y)
.expect("validated output row exists");
let output_width = output_row.len();
for (x, output_sample) in output_row.iter_mut().enumerate() {
let source_x = nearest_center_index(x, output_width, input_row.len());
*output_sample = *semisafe_get(input_row, source_x);
}
}
Ok(())
}
fn fill_plane<T: FormatSample>(
builder: &mut FrameBuilder,
plane_index: usize,
value: T,
) -> Result<()> {
let mut plane = builder.plane_mut::<T>(plane_index)?;
for y in 0..plane.height() {
let row = plane.row_mut(y).expect("validated output row exists");
row.fill(value);
}
Ok(())
}
fn nearest_center_index(index: usize, output_len: usize, input_len: usize) -> usize {
let doubled = index
.checked_mul(2)
.and_then(|value| value.checked_add(1))
.expect("plane dimensions are validated small enough for index math");
let numerator = doubled
.checked_mul(input_len)
.expect("plane dimensions are validated small enough for index math");
let denominator = output_len
.checked_mul(2)
.expect("plane dimensions are validated small enough for index math");
(numerator / denominator).min(input_len.saturating_sub(1))
}
fn format_metadata(
input: &Metadata,
conversion: &ValidatedConvertFormat,
target: Option<ResolvedColorMetadata>,
) -> Result<Metadata> {
let schema = MetadataSchema::core();
let mut metadata = input.clone();
let output_format = conversion.output_format();
if let Some(target) = target {
metadata.set(
&schema,
"core:range",
MetadataValue::String(target.range.as_str().to_owned()),
)?;
if let Some(matrix) = target.matrix {
metadata.set(
&schema,
"core:matrix",
MetadataValue::String(matrix.as_str().to_owned()),
)?;
} else {
metadata.clear(&schema, "core:matrix")?;
}
if let Some(transfer) = target.transfer {
metadata.set(
&schema,
"core:transfer",
MetadataValue::String(transfer.as_str().to_owned()),
)?;
} else {
metadata.clear(&schema, "core:transfer")?;
}
if let Some(primaries) = target.primaries {
metadata.set(
&schema,
"core:primaries",
MetadataValue::String(primaries.as_str().to_owned()),
)?;
} else {
metadata.clear(&schema, "core:primaries")?;
}
if output_format.family() == FormatFamily::Yuv {
metadata.set(
&schema,
"core:chroma_siting",
MetadataValue::String(target.chroma_siting.as_str().to_owned()),
)?;
} else {
metadata.clear(&schema, "core:chroma_siting")?;
}
return Ok(metadata);
}
if output_format.family() != FormatFamily::Yuv {
metadata.clear(&schema, "core:chroma_siting")?;
}
if output_format.family() == FormatFamily::Gray {
metadata.clear(&schema, "core:matrix")?;
}
Ok(metadata)
}
pub fn add_convert_format_filter(
builder: &mut GraphBuilder,
input: Clip,
input_media: &ClipMedia,
options: ConvertFormatOptions,
) -> Result<(Clip, ConvertFormatExecutor)> {
let (conversion, output_media) = convert_format_output_media(input_media, options)?;
let output = builder.filter(
FILTER_CONVERT_FORMAT,
&[input],
output_media,
CONVERT_FORMAT_CONTRACT.compatibility(),
)?;
Ok((output, ConvertFormatExecutor::new(conversion)))
}
fn parse_convert_format_options(
options: &pixelflow_core::FilterOptions,
) -> Result<ConvertFormatOptions> {
let format_name = required_string(
options,
FILTER_CONVERT_FORMAT,
"format",
"filter.invalid_format",
)?;
let mut parsed = ConvertFormatOptions::new(format_name);
if let Some(range) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"range",
"filter.invalid_format",
)? {
parsed = parsed.with_range(ColorRange::parse(range)?);
}
if let Some(matrix) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"matrix",
"filter.invalid_format",
)? {
parsed = parsed.with_matrix(matrix);
}
if let Some(transfer) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"transfer",
"filter.invalid_format",
)? {
parsed = parsed.with_transfer(transfer);
}
if let Some(primaries) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"primaries",
"filter.invalid_format",
)? {
parsed = parsed.with_primaries(primaries);
}
if let Some(chroma_siting) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"chroma_siting",
"filter.invalid_format",
)? {
parsed = parsed.with_chroma_siting(chroma_siting);
}
if let Some(range) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"source_range",
"filter.invalid_format",
)? {
parsed = parsed.with_source_range(ColorRange::parse(range)?);
}
if let Some(matrix) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"source_matrix",
"filter.invalid_format",
)? {
parsed = parsed.with_source_matrix(matrix);
}
if let Some(transfer) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"source_transfer",
"filter.invalid_format",
)? {
parsed = parsed.with_source_transfer(transfer);
}
if let Some(primaries) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"source_primaries",
"filter.invalid_format",
)? {
parsed = parsed.with_source_primaries(primaries);
}
if let Some(chroma_siting) = optional_string(
options,
FILTER_CONVERT_FORMAT,
"source_chroma_siting",
"filter.invalid_format",
)? {
parsed = parsed.with_source_chroma_siting(chroma_siting);
}
Ok(parsed)
}
pub(crate) fn plan_convert_format(request: FilterPlanRequest<'_>) -> Result<FilterPlan> {
let input = single_input_media(request, FILTER_CONVERT_FORMAT)?;
let output_media =
convert_format_output_media(input, parse_convert_format_options(request.options())?)?.1;
Ok(FilterPlan::new(
output_media,
CONVERT_FORMAT_CONTRACT.compatibility(),
))
}
pub(crate) fn convert_format_executor_from_options(
input_media: &[ClipMedia],
options: &pixelflow_core::FilterOptions,
) -> Result<ConvertFormatExecutor> {
let input = single_input_slice(input_media, FILTER_CONVERT_FORMAT)?;
let (conversion, _media) =
convert_format_output_media(input, parse_convert_format_options(options)?)?;
Ok(ConvertFormatExecutor::new(conversion))
}
#[cfg(test)]
mod tests {
#![expect(clippy::indexing_slicing, reason = "allow in tests")]
use pixelflow_core::{
ClipMedia, ClipResolution, ColorRange, 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, with_string_metadata,
};
use crate::{CONVERT_FORMAT_CONTRACT, EXACT_GOLDEN_TOLERANCE, FILTER_CONVERT_FORMAT};
use super::{
ConvertFormatExecutor, ConvertFormatOptions, add_convert_format_filter,
convert_format_output_media,
};
fn executor_for(
input_alias: &str,
width: usize,
height: usize,
output_alias: &str,
) -> ConvertFormatExecutor {
let media = fixed_media(input_alias, width, height);
let (conversion, _) =
convert_format_output_media(&media, ConvertFormatOptions::new(output_alias))
.expect("format conversion should validate");
ConvertFormatExecutor::new(conversion)
}
fn executor_for_options(
input_alias: &str,
width: usize,
height: usize,
options: ConvertFormatOptions,
) -> ConvertFormatExecutor {
let media = fixed_media(input_alias, width, height);
let (conversion, _) = convert_format_output_media(&media, options)
.expect("format conversion should validate");
ConvertFormatExecutor::new(conversion)
}
#[test]
fn convert_format_contract_accepts_phase1_planar_formats() {
let media = fixed_media("rgbpf32", 1920, 1080);
let format = CONVERT_FORMAT_CONTRACT
.validate_input_media(&media)
.expect("convert_format should accept f32 planar RGB input");
assert_eq!(format.name(), "rgbpf32");
assert_eq!(CONVERT_FORMAT_CONTRACT.name(), FILTER_CONVERT_FORMAT);
assert_eq!(
CONVERT_FORMAT_CONTRACT.compatibility(),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: true,
resolution: false,
frame_count: false,
frame_rate: false,
})
);
}
#[test]
fn convert_format_options_resolve_alias_and_preserve_timing() {
let input = fixed_media("yuv420p10", 1920, 1080);
let (conversion, output) =
convert_format_output_media(&input, ConvertFormatOptions::new("yuv444p10"))
.expect("same-depth YUV conversion should validate");
assert_eq!(conversion.output_format().name(), "yuv444p10");
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_format_rejects_bit_depth_change_before_render() {
let input = fixed_media("yuv420p8", 1920, 1080);
let error = convert_format_output_media(&input, ConvertFormatOptions::new("yuv420p10"))
.expect_err("bit-depth conversion belongs to convert_bit_depth");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(
error.code(),
ErrorCode::new("filter.unsupported_format_conversion")
);
assert!(error.message().contains("convert_bit_depth"));
}
#[test]
fn convert_format_rejects_rgb_yuv_without_target_matrix_before_render() {
let input = fixed_media("rgbp10", 1920, 1080);
let error = convert_format_output_media(&input, ConvertFormatOptions::new("yuv444p10"))
.expect_err("RGB/YUV conversion should require explicit target matrix");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.missing_colorimetry"));
assert!(error.message().contains("matrix"));
}
#[test]
fn convert_format_rejects_rgb_gray_without_target_matrix_before_render() {
let input = fixed_media("rgbp8", 1920, 1080);
let error = convert_format_output_media(&input, ConvertFormatOptions::new("gray8"))
.expect_err("RGB/Gray conversion should require explicit target matrix");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.missing_colorimetry"));
assert!(error.message().contains("matrix"));
}
#[test]
fn convert_format_rejects_variable_format_before_render() {
let input = ClipMedia::new(
pixelflow_core::ClipFormat::Variable,
ClipResolution::Fixed {
width: 16,
height: 16,
},
FrameCount::Finite(1),
FrameRate::Cfr(Rational {
numerator: 24,
denominator: 1,
}),
);
let error = convert_format_output_media(&input, ConvertFormatOptions::new("gray8"))
.expect_err("variable input format should fail upfront");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.variable_format"));
}
#[test]
fn convert_format_executor_returns_clone_for_same_descriptor() {
let input = synthetic_u8_frame("gray8", 3, 1, |_plane, x, _y| [7_u8, 8, 9][x])
.expect("input frame should build");
let executor = executor_for("gray8", 3, 1, "gray8");
let output = executor
.convert_frame_for_tests(&input)
.expect("same-format conversion should render");
assert_eq!(output.format().name(), "gray8");
assert!(output.shares_plane_storage(&input, 0));
}
#[test]
fn convert_format_executor_converts_gray_to_yuv_with_neutral_chroma() {
let input = synthetic_u8_frame("gray8", 4, 2, |_plane, x, y| {
u8::try_from(x + y * 10).expect("fixture sample fits u8")
})
.expect("input frame should build");
let executor = executor_for("gray8", 4, 2, "yuv420p8");
let output = executor
.convert_frame_for_tests(&input)
.expect("gray to yuv should render");
assert_eq!(output.format().name(), "yuv420p8");
assert_plane_u8_near(
&output,
0,
&[&[0, 1, 2, 3], &[10, 11, 12, 13]],
EXACT_GOLDEN_TOLERANCE,
);
assert_plane_u8_near(&output, 1, &[&[128, 128]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 2, &[&[128, 128]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_format_executor_converts_yuv_to_gray_by_copying_luma() {
let input = synthetic_u16_frame("yuv420p10", 4, 2, |plane, x, y| match plane {
0 => u16::try_from(x + y * 10).expect("fixture sample fits u16"),
1 => 400,
2 => 600,
_ => unreachable!("format has three planes"),
})
.expect("input frame should build");
let executor = executor_for("yuv420p10", 4, 2, "gray10");
let output = executor
.convert_frame_for_tests(&input)
.expect("yuv to gray should render");
assert_eq!(output.format().name(), "gray10");
assert_plane_u16_near(
&output,
0,
&[&[0, 1, 2, 3], &[10, 11, 12, 13]],
EXACT_GOLDEN_TOLERANCE,
);
}
#[test]
fn convert_format_executor_regrids_yuv_chroma_deterministically() {
let input = synthetic_u8_frame("yuv420p8", 4, 2, |plane, x, _y| match plane {
0 => u8::try_from(x).expect("fixture sample fits u8"),
1 => [20_u8, 40][x],
2 => [60_u8, 80][x],
_ => unreachable!("format has three planes"),
})
.expect("input frame should build");
let executor = executor_for("yuv420p8", 4, 2, "yuv444p8");
let output = executor
.convert_frame_for_tests(&input)
.expect("yuv subsampling conversion should render");
assert_eq!(output.format().name(), "yuv444p8");
assert_plane_u8_near(
&output,
0,
&[&[0, 1, 2, 3], &[0, 1, 2, 3]],
EXACT_GOLDEN_TOLERANCE,
);
assert_plane_u8_near(
&output,
1,
&[&[20, 20, 40, 40], &[20, 20, 40, 40]],
EXACT_GOLDEN_TOLERANCE,
);
assert_plane_u8_near(
&output,
2,
&[&[60, 60, 80, 80], &[60, 60, 80, 80]],
EXACT_GOLDEN_TOLERANCE,
);
}
#[test]
fn convert_format_executor_downsamples_yuv_chroma_deterministically() {
let input = synthetic_u8_frame("yuv444p8", 4, 2, |plane, x, y| match plane {
0 => u8::try_from(x + y * 10).expect("fixture sample fits u8"),
1 => [[10_u8, 20, 30, 40], [50, 60, 70, 80]][y][x],
2 => [[90_u8, 100, 110, 120], [130, 140, 150, 160]][y][x],
_ => unreachable!("format has three planes"),
})
.expect("input frame should build");
let executor = executor_for("yuv444p8", 4, 2, "yuv420p8");
let output = executor
.convert_frame_for_tests(&input)
.expect("yuv subsampling downconversion should render");
assert_eq!(output.format().name(), "yuv420p8");
assert_plane_u8_near(
&output,
0,
&[&[0, 1, 2, 3], &[10, 11, 12, 13]],
EXACT_GOLDEN_TOLERANCE,
);
assert_plane_u8_near(&output, 1, &[&[60, 80]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 2, &[&[140, 160]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_format_executor_uses_float_neutral_chroma() {
let input = synthetic_f32_frame("grayf32", 2, 1, |_plane, x, _y| [0.25_f32, 0.75][x])
.expect("input frame should build");
let executor = executor_for("grayf32", 2, 1, "yuv444pf32");
let output = executor
.convert_frame_for_tests(&input)
.expect("float gray to yuv should render");
assert_plane_f32_near(&output, 0, &[&[0.25, 0.75]], EXACT_GOLDEN_TOLERANCE);
assert_plane_f32_near(&output, 1, &[&[0.5, 0.5]], EXACT_GOLDEN_TOLERANCE);
assert_plane_f32_near(&output, 2, &[&[0.5, 0.5]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_format_executor_converts_yuv_to_rgb_with_explicit_source_colorimetry() {
let input = synthetic_u8_frame("yuv444p8", 3, 1, |plane, x, _y| match plane {
0 => [16_u8, 126, 235][x],
1 | 2 => 128,
_ => unreachable!("fixture has three planes"),
})
.expect("input frame should build");
let executor = executor_for_options(
"yuv444p8",
3,
1,
ConvertFormatOptions::new("rgbp8")
.with_source_range(ColorRange::Limited)
.with_source_matrix("bt709")
.with_range(ColorRange::Full),
);
let output = executor
.convert_frame_for_tests(&input)
.expect("YUV to RGB should render with explicit source colorimetry");
assert_eq!(output.format().name(), "rgbp8");
assert_plane_u8_near(&output, 0, &[&[0, 128, 255]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 1, &[&[0, 128, 255]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 2, &[&[0, 128, 255]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_format_executor_converts_rgb_to_yuv_with_explicit_target_colorimetry() {
let input = synthetic_u8_frame("rgbp8", 3, 1, |_plane, x, _y| [0_u8, 128, 255][x])
.expect("input frame should build");
let executor = executor_for_options(
"rgbp8",
3,
1,
ConvertFormatOptions::new("yuv444p8")
.with_matrix("bt709")
.with_range(ColorRange::Limited),
);
let output = executor
.convert_frame_for_tests(&input)
.expect("RGB to YUV should render with explicit target colorimetry");
assert_eq!(output.format().name(), "yuv444p8");
assert_plane_u8_near(&output, 0, &[&[16, 126, 235]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 1, &[&[128, 128, 128]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 2, &[&[128, 128, 128]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_format_executor_converts_gray_to_rgb_with_explicit_range() {
let input = synthetic_u8_frame("gray8", 3, 1, |_plane, x, _y| [16_u8, 126, 235][x])
.expect("input frame should build");
let executor = executor_for_options(
"gray8",
3,
1,
ConvertFormatOptions::new("rgbp8")
.with_source_range(ColorRange::Limited)
.with_range(ColorRange::Full),
);
let output = executor
.convert_frame_for_tests(&input)
.expect("Gray to RGB should render with explicit range");
assert_eq!(output.format().name(), "rgbp8");
assert_plane_u8_near(&output, 0, &[&[0, 128, 255]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 1, &[&[0, 128, 255]], EXACT_GOLDEN_TOLERANCE);
assert_plane_u8_near(&output, 2, &[&[0, 128, 255]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_format_executor_converts_rgb_to_gray_with_explicit_target_matrix() {
let input = synthetic_u8_frame("rgbp8", 3, 1, |_plane, x, _y| [0_u8, 128, 255][x])
.expect("input frame should build");
let executor = executor_for_options(
"rgbp8",
3,
1,
ConvertFormatOptions::new("gray8")
.with_matrix("bt709")
.with_range(ColorRange::Limited),
);
let output = executor
.convert_frame_for_tests(&input)
.expect("RGB to Gray should render with explicit target matrix");
assert_eq!(output.format().name(), "gray8");
assert_plane_u8_near(&output, 0, &[&[16, 126, 235]], EXACT_GOLDEN_TOLERANCE);
}
#[test]
fn convert_format_executor_clears_non_applicable_metadata_for_gray_output() {
let input = synthetic_u8_frame("yuv420p8", 2, 2, |_plane, _x, _y| 16)
.expect("input frame should build");
let input = with_string_metadata(&input, "core:matrix", "bt709")
.and_then(|frame| with_string_metadata(&frame, "core:chroma_siting", "left"))
.expect("metadata should set");
let executor = executor_for("yuv420p8", 2, 2, "gray8");
let output = executor
.convert_frame_for_tests(&input)
.expect("yuv to gray should render");
assert_eq!(
output.metadata().get("core:matrix"),
Some(&pixelflow_core::MetadataValue::None)
);
assert_eq!(
output.metadata().get("core:chroma_siting"),
Some(&pixelflow_core::MetadataValue::None)
);
}
#[test]
fn convert_format_executor_rejects_runtime_input_format_mismatch() {
let input = synthetic_u8_frame("rgbp8", 2, 1, |_plane, _x, _y| 16)
.expect("input frame should build");
let executor = executor_for("gray8", 2, 1, "yuv420p8");
let Err(error) = executor.convert_frame_for_tests(&input) else {
panic!("runtime input format mismatch should fail fast");
};
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(
error.code(),
ErrorCode::new("filter.runtime_format_mismatch")
);
assert!(error.message().contains("gray8"));
assert!(error.message().contains("rgbp8"));
}
#[test]
fn add_convert_format_filter_creates_filter_node_with_validated_output_media() {
let input_media = fixed_media("gray8", 4, 2);
let mut builder = GraphBuilder::new();
let source = builder.source("source", input_media.clone());
let (converted, executor) = add_convert_format_filter(
&mut builder,
source,
&input_media,
ConvertFormatOptions::new("yuv420p8"),
)
.expect("convert_format 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(), "yuv420p8");
assert!(matches!(
node.media().format(),
pixelflow_core::ClipFormat::Fixed(format) if format.name() == "yuv420p8"
));
assert_eq!(node.media().resolution(), input_media.resolution());
assert_eq!(node.media().frame_count(), input_media.frame_count());
assert_eq!(node.media().frame_rate(), input_media.frame_rate());
}
}