use pixelflow_core::{
Clip, ClipMedia, ErrorCategory, ErrorCode, FilterChangeSet, FilterCompatibility,
FilterOptionValue, FilterOptions, FilterPlan, FilterPlanRequest, Frame, FrameExecutor,
FrameRate, FrameRequest, GraphBuilder, PixelFlowError, Rational, Result,
};
use crate::{
OfficialFilterContract, SupportedFormats,
contracts::{ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES},
options::{single_input_media, single_input_slice},
};
pub const FILTER_ASSUME_FPS: &str = "assume_fps";
pub const ASSUME_FPS_CONTRACT: OfficialFilterContract = OfficialFilterContract::new(
FILTER_ASSUME_FPS,
SupportedFormats::new(ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: false,
resolution: false,
frame_count: false,
frame_rate: true,
}),
);
pub type AssumeFpsOptions = FilterOptions;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ValidatedAssumeFps {
fps: Rational,
}
impl ValidatedAssumeFps {
#[must_use]
pub const fn new(fps: Rational) -> Self {
Self { fps }
}
#[must_use]
pub const fn fps(self) -> Rational {
self.fps
}
}
pub fn assume_fps_output_media(
input: &ClipMedia,
options: &AssumeFpsOptions,
) -> Result<(ValidatedAssumeFps, ClipMedia)> {
ASSUME_FPS_CONTRACT.validate_input_media(input)?;
let assumption = ValidatedAssumeFps::new(required_fps(options)?);
let output_media = ClipMedia::new(
input.format().clone(),
input.resolution().clone(),
input.frame_count(),
FrameRate::Cfr(assumption.fps()),
);
Ok((assumption, output_media))
}
pub(crate) fn plan_assume_fps(request: FilterPlanRequest<'_>) -> Result<FilterPlan> {
let input = single_input_media(request, FILTER_ASSUME_FPS)?;
let output_media = assume_fps_output_media(input, request.options())?.1;
Ok(FilterPlan::new(
output_media,
ASSUME_FPS_CONTRACT.compatibility(),
))
}
pub(crate) fn assume_fps_executor_from_options(
input_media: &[ClipMedia],
options: &AssumeFpsOptions,
) -> Result<AssumeFpsExecutor> {
let input = single_input_slice(input_media, FILTER_ASSUME_FPS)?;
let (assumption, _media) = assume_fps_output_media(input, options)?;
Ok(AssumeFpsExecutor::new(assumption))
}
pub fn add_assume_fps_filter(
builder: &mut GraphBuilder,
input: Clip,
input_media: &ClipMedia,
options: &AssumeFpsOptions,
) -> Result<(Clip, AssumeFpsExecutor)> {
let (assumption, output_media) = assume_fps_output_media(input_media, options)?;
let output = builder.filter(
FILTER_ASSUME_FPS,
&[input],
output_media,
ASSUME_FPS_CONTRACT.compatibility(),
)?;
Ok((output, AssumeFpsExecutor::new(assumption)))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AssumeFpsExecutor {
assumption: ValidatedAssumeFps,
}
impl AssumeFpsExecutor {
#[must_use]
pub const fn new(assumption: ValidatedAssumeFps) -> Self {
Self { assumption }
}
#[must_use]
pub const fn fps(self) -> Rational {
self.assumption.fps()
}
}
impl FrameExecutor for AssumeFpsExecutor {
fn prepare(&self, request: FrameRequest<'_>) -> Result<Frame> {
request.input_frame(0, request.frame_number())
}
}
fn required_fps(options: &AssumeFpsOptions) -> Result<Rational> {
for key in options.keys() {
if key != "fps" {
return Err(assume_fps_error(
"filter.invalid_assume_fps",
format!("filter '{FILTER_ASSUME_FPS}' option '{key}' is not supported"),
));
}
}
let fps = match options.get("fps") {
None | Some(FilterOptionValue::None) => {
return Err(assume_fps_error(
"filter.invalid_assume_fps",
format!("filter '{FILTER_ASSUME_FPS}' requires option 'fps'"),
));
}
Some(FilterOptionValue::Rational(rate)) => *rate,
Some(FilterOptionValue::Int(value)) => Rational {
numerator: *value,
denominator: 1,
},
Some(FilterOptionValue::String(value)) => parse_fps_string(value)?,
Some(_) => {
return Err(assume_fps_error(
"filter.invalid_assume_fps",
format!(
"filter '{FILTER_ASSUME_FPS}' option 'fps' must be rational, integer, or numerator/denominator string"
),
));
}
};
positive_fps(fps)
}
fn parse_fps_string(value: &str) -> Result<Rational> {
let Some((numerator, denominator)) = value.split_once('/') else {
return Err(assume_fps_error(
"filter.invalid_assume_fps",
format!(
"filter '{FILTER_ASSUME_FPS}' option 'fps' must use numerator/denominator syntax"
),
));
};
let numerator = numerator.trim().parse::<i64>().map_err(|_| {
assume_fps_error(
"filter.invalid_assume_fps",
format!("filter '{FILTER_ASSUME_FPS}' option 'fps' has invalid numerator"),
)
})?;
let denominator = denominator.trim().parse::<i64>().map_err(|_| {
assume_fps_error(
"filter.invalid_assume_fps",
format!("filter '{FILTER_ASSUME_FPS}' option 'fps' has invalid denominator"),
)
})?;
Ok(Rational {
numerator,
denominator,
})
}
fn positive_fps(fps: Rational) -> Result<Rational> {
if fps.numerator <= 0 || fps.denominator <= 0 {
return Err(assume_fps_error(
"filter.invalid_assume_fps",
format!("filter '{FILTER_ASSUME_FPS}' option 'fps' must be positive"),
));
}
Ok(fps)
}
fn assume_fps_error(code: &'static str, message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(ErrorCategory::Format, ErrorCode::new(code), message)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use pixelflow_core::{
ClipMedia, ClipResolution, DependencyPattern, ErrorCategory, ErrorCode, FilterOptionValue,
Frame, FrameCount, FrameExecutor, FrameRate, FrameRequest, GraphBuilder, MetadataValue,
NodeKind, Rational, RenderEngine, RenderExecutorMap, RenderOptions, WorkerPoolConfig,
};
use crate::testkit::{fixed_media, synthetic_u8_frame, with_frame_number_metadata};
use super::{AssumeFpsOptions, assume_fps_output_media};
fn options(
entries: impl IntoIterator<Item = (&'static str, FilterOptionValue)>,
) -> AssumeFpsOptions {
entries
.into_iter()
.map(|(name, value)| (name.to_owned(), value))
.collect()
}
#[test]
fn assume_fps_validates_rate_and_preserves_media_except_frame_rate() {
let input = fixed_media("yuv420p10", 1920, 1080);
let (assumption, output) = assume_fps_output_media(
&input,
&options([(
"fps",
FilterOptionValue::Rational(Rational {
numerator: 30_000,
denominator: 1_001,
}),
)]),
)
.expect("valid fps should derive output media");
assert_eq!(
assumption.fps(),
Rational {
numerator: 30_000,
denominator: 1_001,
}
);
assert_eq!(output.format(), input.format());
assert_eq!(
output.resolution(),
&ClipResolution::Fixed {
width: 1920,
height: 1080,
}
);
assert_eq!(output.frame_count(), input.frame_count());
assert_eq!(
output.frame_rate(),
FrameRate::Cfr(Rational {
numerator: 30_000,
denominator: 1_001,
})
);
}
#[test]
fn assume_fps_accepts_unknown_input_timing_and_sets_output_rate() {
let known = fixed_media("gray8", 8, 6);
let input = ClipMedia::new(
known.format().clone(),
known.resolution().clone(),
FrameCount::Unknown,
FrameRate::Unknown,
);
let (_assumption, output) =
assume_fps_output_media(&input, &options([("fps", FilterOptionValue::Int(24))]))
.expect("assume_fps should not require known input frame count or rate");
assert_eq!(output.frame_count(), FrameCount::Unknown);
assert_eq!(
output.frame_rate(),
FrameRate::Cfr(Rational {
numerator: 24,
denominator: 1,
})
);
}
#[test]
fn assume_fps_accepts_rational_strings_and_integer_rates() {
let input = fixed_media("gray8", 8, 6);
let (from_string, string_output) = assume_fps_output_media(
&input,
&options([("fps", FilterOptionValue::String("60000/1001".to_owned()))]),
)
.expect("rational string fps should be accepted");
assert_eq!(
from_string.fps(),
Rational {
numerator: 60_000,
denominator: 1_001,
}
);
assert_eq!(string_output.frame_count(), input.frame_count());
let (from_int, int_output) =
assume_fps_output_media(&input, &options([("fps", FilterOptionValue::Int(25))]))
.expect("integer fps should be accepted as denominator 1");
assert_eq!(
from_int.fps(),
Rational {
numerator: 25,
denominator: 1,
}
);
assert_eq!(int_output.frame_count(), input.frame_count());
}
#[test]
fn assume_fps_rejects_missing_non_positive_invalid_and_unknown_options() {
let input = fixed_media("gray8", 8, 6);
for bad_options in [
options([]),
options([(
"fps",
FilterOptionValue::Rational(Rational {
numerator: 0,
denominator: 1,
}),
)]),
options([(
"fps",
FilterOptionValue::Rational(Rational {
numerator: 24,
denominator: 0,
}),
)]),
options([("fps", FilterOptionValue::String("24000".to_owned()))]),
options([("fps", FilterOptionValue::Float(23.976))]),
options([
("fps", FilterOptionValue::String("24/1".to_owned())),
("unexpected", FilterOptionValue::Int(1)),
]),
] {
let error = assume_fps_output_media(&input, &bad_options)
.expect_err("invalid assume_fps options should fail");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.invalid_assume_fps"));
}
}
#[test]
fn add_assume_fps_filter_creates_same_frame_filter_node() {
let input_media = fixed_media("gray8", 8, 6);
let mut builder = GraphBuilder::new();
let source = builder.source("source", input_media.clone());
let (output, executor) = super::add_assume_fps_filter(
&mut builder,
source,
&input_media,
&options([("fps", FilterOptionValue::Int(30))]),
)
.expect("assume_fps node should build");
let graph = builder.build();
let node = graph
.node(output.node_id())
.expect("assume_fps node should exist");
assert_eq!(
executor.fps(),
Rational {
numerator: 30,
denominator: 1,
}
);
assert_eq!(node.media().format(), input_media.format());
assert_eq!(node.media().resolution(), input_media.resolution());
assert_eq!(node.media().frame_count(), input_media.frame_count());
assert_eq!(
node.media().frame_rate(),
FrameRate::Cfr(Rational {
numerator: 30,
denominator: 1,
})
);
assert!(matches!(
node.kind(),
NodeKind::Filter {
name,
dependencies,
..
} if name == super::FILTER_ASSUME_FPS
&& dependencies == &DependencyPattern::same_frame()
));
}
#[test]
fn assume_fps_executor_forwards_same_source_frame_and_metadata() {
struct NumberedSource;
impl FrameExecutor for NumberedSource {
fn prepare(&self, request: FrameRequest<'_>) -> pixelflow_core::Result<Frame> {
let frame = synthetic_u8_frame("gray8", 2, 2, |_plane, x, y| {
u8::try_from(request.frame_number() + x + y).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 (output, executor) = super::add_assume_fps_filter(
&mut builder,
source,
&input_media,
&options([("fps", FilterOptionValue::Int(30))]),
)
.expect("assume_fps node should build");
builder.set_output(output);
let graph = builder.build();
let mut executors = RenderExecutorMap::new();
executors.insert(source.node_id(), Arc::new(NumberedSource));
executors.insert(output.node_id(), Arc::new(executor));
let frames: Vec<_> = RenderEngine::new(WorkerPoolConfig::new(1))
.render_ordered(graph, executors, RenderOptions::default())
.expect("render should start")
.map(|frame| frame.expect("assumed frame should render"))
.collect();
let numbers: Vec<_> = frames
.iter()
.map(|frame| match frame.metadata().get("core:frame_number") {
Some(MetadataValue::Int(value)) => *value,
other => panic!("expected frame_number metadata, got {other:?}"),
})
.collect();
assert_eq!(
numbers,
[
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23,
]
);
}
}