use pixelflow_core::{
Clip, ClipMedia, ConcurrencyClass, DependencyPattern, DynamicDependencyBounds, ErrorCategory,
ErrorCode, FilterChangeSet, FilterCompatibility, FilterPlan, FilterPlanRequest, Frame,
FrameCount, FrameExecutor, FrameRequest, GraphBuilder, PixelFlowError, Result,
};
use crate::{
OfficialFilterContract, SupportedFormats,
contracts::{ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES},
options::{optional_i64, single_input_media, single_input_slice},
};
pub const FILTER_TRIM: &str = "trim";
pub const TRIM_CONTRACT: OfficialFilterContract = OfficialFilterContract::new(
FILTER_TRIM,
SupportedFormats::new(ALL_PLANAR_FAMILIES, ALL_SAMPLE_TYPES),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: false,
resolution: false,
frame_count: true,
frame_rate: false,
}),
);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TrimOptions {
start: Option<i64>,
end: Option<i64>,
}
impl TrimOptions {
#[must_use]
pub const fn new(start: Option<i64>, end: Option<i64>) -> Self {
Self { start, end }
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ValidatedTrim {
start: usize,
end: usize,
}
impl ValidatedTrim {
#[must_use]
pub const fn start(self) -> usize {
self.start
}
#[must_use]
pub const fn end(self) -> usize {
self.end
}
#[must_use]
pub const fn len(self) -> usize {
self.end - self.start
}
#[must_use]
pub const fn is_empty(self) -> bool {
self.start == self.end
}
fn source_frame(self, output_frame: usize) -> Result<usize> {
if output_frame >= self.len() {
return Err(trim_error(
"filter.trim_frame_out_of_range",
format!("filter '{FILTER_TRIM}' output frame {output_frame} is out of range"),
));
}
self.start.checked_add(output_frame).ok_or_else(|| {
trim_error(
"filter.invalid_trim",
format!("filter '{FILTER_TRIM}' source frame calculation overflowed"),
)
})
}
fn output_media(self, input: &ClipMedia) -> ClipMedia {
ClipMedia::new(
input.format().clone(),
input.resolution().clone(),
FrameCount::Finite(self.len()),
input.frame_rate(),
)
}
}
pub fn trim_output_media(
input: &ClipMedia,
options: TrimOptions,
) -> Result<(ValidatedTrim, ClipMedia)> {
TRIM_CONTRACT.validate_input_media(input)?;
let input_frames = match input.frame_count() {
FrameCount::Finite(frames) => frames,
FrameCount::Unknown => {
return Err(PixelFlowError::new(
ErrorCategory::Format,
ErrorCode::new("filter.variable_frame_count"),
format!("filter '{FILTER_TRIM}' requires finite input frame count"),
));
}
};
let start = match options.start {
Some(start) => non_negative_frame("start", start)?,
None => 0,
};
let end = match options.end {
Some(end) => non_negative_frame("end", end)?,
None => input_frames,
};
if start > input_frames {
return Err(trim_error(
"filter.trim_out_of_range",
format!("filter '{FILTER_TRIM}' start must not exceed input frame count"),
));
}
if end > input_frames {
return Err(trim_error(
"filter.trim_out_of_range",
format!("filter '{FILTER_TRIM}' end must not exceed input frame count"),
));
}
if start > end {
return Err(trim_error(
"filter.invalid_trim",
format!("filter '{FILTER_TRIM}' start must not exceed end"),
));
}
let trim = ValidatedTrim { start, end };
Ok((trim, trim.output_media(input)))
}
pub fn add_trim_filter(
builder: &mut GraphBuilder,
input: Clip,
input_media: &ClipMedia,
options: TrimOptions,
) -> Result<(Clip, TrimExecutor)> {
let (trim, output_media) = trim_output_media(input_media, options)?;
let output = builder.filter_with_schedule(
FILTER_TRIM,
&[input],
output_media,
TRIM_CONTRACT.compatibility(),
DependencyPattern::frame_map(DynamicDependencyBounds::any()),
ConcurrencyClass::Stateless,
)?;
Ok((output, TrimExecutor::new(trim)))
}
fn parse_trim_options(options: &pixelflow_core::FilterOptions) -> Result<TrimOptions> {
let start = optional_i64(options, FILTER_TRIM, "start", "filter.invalid_trim")?;
let end = optional_i64(options, FILTER_TRIM, "end", "filter.invalid_trim")?;
Ok(TrimOptions::new(start, end))
}
pub(crate) fn plan_trim(request: FilterPlanRequest<'_>) -> Result<FilterPlan> {
let input = single_input_media(request, FILTER_TRIM)?;
let output_media = trim_output_media(input, parse_trim_options(request.options())?)?.1;
Ok(
FilterPlan::new(output_media, TRIM_CONTRACT.compatibility()).with_schedule(
DependencyPattern::frame_map(DynamicDependencyBounds::any()),
ConcurrencyClass::Stateless,
),
)
}
pub(crate) fn trim_executor_from_options(
input_media: &[ClipMedia],
options: &pixelflow_core::FilterOptions,
) -> Result<TrimExecutor> {
let input = single_input_slice(input_media, FILTER_TRIM)?;
let (trim, _media) = trim_output_media(input, parse_trim_options(options)?)?;
Ok(TrimExecutor::new(trim))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrimExecutor {
trim: ValidatedTrim,
}
impl TrimExecutor {
#[must_use]
pub const fn new(trim: ValidatedTrim) -> Self {
Self { trim }
}
#[must_use]
pub const fn trim(&self) -> ValidatedTrim {
self.trim
}
}
impl FrameExecutor for TrimExecutor {
fn prepare(&self, request: FrameRequest<'_>) -> Result<Frame> {
let source_frame = self.trim.source_frame(request.frame_number())?;
request.input_frame(0, source_frame)
}
}
fn non_negative_frame(name: &str, value: i64) -> Result<usize> {
usize::try_from(value).map_err(|_| {
trim_error(
"filter.invalid_trim",
format!("filter '{FILTER_TRIM}' option '{name}' must be non-negative"),
)
})
}
fn trim_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, DynamicDependencyBounds, ErrorCategory,
ErrorCode, 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::{TrimOptions, trim_output_media};
#[test]
fn trim_options_validate_ranges_and_preserve_media_timing() {
let input = fixed_media("yuv420p10", 1920, 1080);
let cases = [
(TrimOptions::new(None, None), 0, 24, 24),
(TrimOptions::new(None, Some(5)), 0, 5, 5),
(TrimOptions::new(Some(23), None), 23, 24, 1),
(TrimOptions::new(Some(4), Some(4)), 4, 4, 0),
];
for (options, expected_start, expected_end, expected_len) in cases {
let (trim, output) = trim_output_media(&input, options)
.expect("valid trim range should derive output media");
assert_eq!(trim.start(), expected_start);
assert_eq!(trim.end(), expected_end);
assert_eq!(trim.len(), expected_len);
assert_eq!(output.format(), input.format());
assert_eq!(
output.resolution(),
&ClipResolution::Fixed {
width: 1920,
height: 1080,
}
);
assert_eq!(output.frame_count(), FrameCount::Finite(expected_len));
assert_eq!(
output.frame_rate(),
FrameRate::Cfr(Rational {
numerator: 24_000,
denominator: 1_001,
})
);
}
}
#[test]
fn trim_options_reject_invalid_empty_reversed_and_out_of_range_cases() {
let input = fixed_media("gray8", 8, 6);
for options in [
TrimOptions::new(Some(-1), None),
TrimOptions::new(None, Some(-1)),
TrimOptions::new(Some(8), Some(4)),
] {
let error = trim_output_media(&input, options).expect_err("invalid trim should fail");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.invalid_trim"));
}
for options in [
TrimOptions::new(Some(25), None),
TrimOptions::new(None, Some(25)),
] {
let error =
trim_output_media(&input, options).expect_err("out-of-range trim should fail");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.trim_out_of_range"));
}
let (empty, output) = trim_output_media(&input, TrimOptions::new(Some(4), Some(4)))
.expect("empty trim is valid and produces zero output frames");
assert_eq!(empty.len(), 0);
assert_eq!(output.frame_count(), FrameCount::Finite(0));
}
#[test]
fn trim_rejects_unknown_input_frame_count_during_planning_with_format_diagnostic() {
let input = fixed_media("gray8", 8, 6);
let unknown_count = ClipMedia::new(
input.format().clone(),
input.resolution().clone(),
FrameCount::Unknown,
input.frame_rate(),
);
let error = trim_output_media(&unknown_count, TrimOptions::new(None, None))
.expect_err("unknown frame count should fail before trim planning");
assert_eq!(error.category(), ErrorCategory::Format);
assert_eq!(error.code(), ErrorCode::new("filter.variable_frame_count"));
}
#[test]
fn add_trim_filter_creates_filter_node_with_frame_map_schedule() {
let input_media = fixed_media("gray8", 8, 6);
let mut builder = GraphBuilder::new();
let source = builder.source("source", input_media.clone());
let (trimmed, executor) = super::add_trim_filter(
&mut builder,
source,
&input_media,
TrimOptions::new(Some(2), Some(6)),
)
.expect("trim node should build");
let graph = builder.build();
let node = graph
.node(trimmed.node_id())
.expect("trim node should exist");
assert_eq!(executor.trim().start(), 2);
assert_eq!(executor.trim().end(), 6);
assert_eq!(node.media().frame_count(), FrameCount::Finite(4));
assert_eq!(node.media().frame_rate(), input_media.frame_rate());
assert!(matches!(
node.kind(),
NodeKind::Filter { dependencies, .. }
if dependencies == &DependencyPattern::frame_map(DynamicDependencyBounds::any())
));
}
#[test]
fn trim_executor_maps_output_frames_to_contiguous_source_frames() {
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 (trimmed, executor) = super::add_trim_filter(
&mut builder,
source,
&input_media,
TrimOptions::new(Some(5), Some(8)),
)
.expect("trim node should build");
builder.set_output(trimmed);
let graph = builder.build();
let mut executors = RenderExecutorMap::new();
executors.insert(source.node_id(), Arc::new(NumberedSource));
executors.insert(trimmed.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("trimmed frame should render"))
.collect();
let selected_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!(selected_numbers, [5, 6, 7]);
}
}