use crate::{
ChromaSubsampling, FilterOptions, FormatDescriptor, FormatFamily, Rational, SampleType,
};
use semisafe::slice::get as semisafe_get;
use semisafe::slice::get_mut as semisafe_get_mut;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NodeId(usize);
impl NodeId {
#[must_use]
pub const fn new(index: usize) -> Self {
Self(index)
}
#[must_use]
pub const fn index(self) -> usize {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct Clip {
node_id: NodeId,
}
impl Clip {
#[must_use]
pub const fn new(node_id: NodeId) -> Self {
Self { node_id }
}
#[must_use]
pub const fn node_id(self) -> NodeId {
self.node_id
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FilterChangeSet {
pub format: bool,
pub resolution: bool,
pub frame_count: bool,
pub frame_rate: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FilterCompatibility {
Preserve,
AllowChanges(FilterChangeSet),
Custom,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ClipFormat {
Fixed(FormatDescriptor),
Variable,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ClipResolution {
Fixed {
width: usize,
height: usize,
},
Variable,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FrameCount {
Finite(usize),
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FrameRate {
Cfr(Rational),
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClipMedia {
format: ClipFormat,
resolution: ClipResolution,
frame_count: FrameCount,
frame_rate: FrameRate,
}
impl ClipMedia {
#[must_use]
pub const fn fixed(
format: FormatDescriptor,
width: usize,
height: usize,
frame_count: usize,
frame_rate: Rational,
) -> Self {
Self {
format: ClipFormat::Fixed(format),
resolution: ClipResolution::Fixed { width, height },
frame_count: FrameCount::Finite(frame_count),
frame_rate: FrameRate::Cfr(frame_rate),
}
}
#[must_use]
pub const fn new(
format: ClipFormat,
resolution: ClipResolution,
frame_count: FrameCount,
frame_rate: FrameRate,
) -> Self {
Self {
format,
resolution,
frame_count,
frame_rate,
}
}
#[must_use]
pub const fn format(&self) -> &ClipFormat {
&self.format
}
#[must_use]
pub const fn resolution(&self) -> &ClipResolution {
&self.resolution
}
#[must_use]
pub const fn frame_count(&self) -> FrameCount {
self.frame_count
}
#[must_use]
pub const fn frame_rate(&self) -> FrameRate {
self.frame_rate
}
}
#[must_use]
pub const fn is_y4m_compatible_format(format: &FormatDescriptor) -> bool {
let integer_sample = matches!(format.sample_type(), SampleType::U8 | SampleType::U16);
let supported_family = match format.family() {
FormatFamily::Gray => true,
FormatFamily::Yuv => matches!(
format.subsampling(),
Some(ChromaSubsampling::Cs420 | ChromaSubsampling::Cs422 | ChromaSubsampling::Cs444)
),
FormatFamily::PlanarRgb => false,
};
integer_sample && supported_family
}
#[derive(Clone, Debug, PartialEq)]
pub enum NodeKind {
Source {
name: String,
request: crate::SourceRequest,
capabilities: crate::SourceCapabilities,
},
Filter {
name: String,
inputs: Vec<Clip>,
options: FilterOptions,
compatibility: FilterCompatibility,
dependencies: crate::DependencyPattern,
concurrency: crate::ConcurrencyClass,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct GraphNode {
id: NodeId,
kind: NodeKind,
media: ClipMedia,
}
impl GraphNode {
#[must_use]
pub const fn id(&self) -> NodeId {
self.id
}
#[must_use]
pub const fn kind(&self) -> &NodeKind {
&self.kind
}
#[must_use]
pub const fn media(&self) -> &ClipMedia {
&self.media
}
#[must_use]
pub const fn filter_options(&self) -> Option<&FilterOptions> {
match &self.kind {
NodeKind::Filter { options, .. } => Some(options),
NodeKind::Source { .. } => None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graph {
nodes: Vec<GraphNode>,
outputs: Vec<Clip>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum VisitState {
Visiting,
Done,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ValidatedGraph {
plan: ValidationPlan,
}
impl ValidatedGraph {
#[must_use]
pub const fn plan(&self) -> &ValidationPlan {
&self.plan
}
}
impl Graph {
pub fn with_source_media(mut self, node_id: NodeId, media: ClipMedia) -> crate::Result<Self> {
let node = self.nodes.get_mut(node_id.index()).ok_or_else(|| {
crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!(
"source media update references missing node {}",
node_id.index()
),
)
})?;
if !matches!(node.kind, NodeKind::Source { .. }) {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!("node {} is not a source", node_id.index()),
));
}
node.media = media;
self.propagate_filter_media()?;
Ok(self)
}
fn propagate_filter_media(&mut self) -> crate::Result<()> {
for index in 0..self.nodes.len() {
let node = semisafe_get(&self.nodes, index);
let (inputs, compatibility, existing_media) = match &node.kind {
NodeKind::Filter {
inputs,
compatibility,
..
} => (inputs.clone(), *compatibility, node.media.clone()),
NodeKind::Source { .. } => continue,
};
let Some(first_input) = inputs.first().copied() else {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.missing_filter_input"),
format!("filter node {index} has no inputs"),
));
};
let input_media = self.input_media(first_input)?.clone();
let node = semisafe_get_mut(&mut self.nodes, index);
node.media = match compatibility {
FilterCompatibility::Preserve => input_media,
FilterCompatibility::AllowChanges(changes) => ClipMedia::new(
if changes.format {
existing_media.format().clone()
} else {
input_media.format().clone()
},
if changes.resolution {
existing_media.resolution().clone()
} else {
input_media.resolution().clone()
},
if changes.frame_count {
existing_media.frame_count()
} else {
input_media.frame_count()
},
if changes.frame_rate {
existing_media.frame_rate()
} else {
input_media.frame_rate()
},
),
FilterCompatibility::Custom => existing_media,
};
}
Ok(())
}
#[must_use]
pub fn node(&self, id: NodeId) -> Option<&GraphNode> {
self.nodes.get(id.index()).filter(|node| node.id == id)
}
#[must_use]
pub fn nodes(&self) -> &[GraphNode] {
&self.nodes
}
#[must_use]
pub fn outputs(&self) -> &[Clip] {
&self.outputs
}
pub fn validation_plan(&self) -> crate::Result<ValidationPlan> {
match self.outputs.as_slice() {
[] => Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.missing_output"),
"graph has no final output",
)),
[output] => {
let mut reachable = Vec::new();
self.collect_reachable(output.node_id(), &mut reachable)?;
let reachable_sources = reachable
.iter()
.copied()
.filter(|id| {
self.node(*id)
.is_some_and(|node| matches!(node.kind(), NodeKind::Source { .. }))
})
.collect();
Ok(ValidationPlan {
reachable_nodes: reachable,
reachable_sources,
})
}
_ => Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.multiple_outputs"),
"graph has multiple final outputs",
)),
}
}
pub fn validate(&self) -> crate::Result<ValidatedGraph> {
let plan = self.validation_plan()?;
self.validate_acyclic(&plan)?;
self.validate_media(&plan)?;
Ok(ValidatedGraph { plan })
}
fn collect_reachable(&self, id: NodeId, reachable: &mut Vec<NodeId>) -> crate::Result<()> {
let mut states = vec![None; self.nodes.len()];
self.collect_reachable_checked(id, reachable, &mut states)
}
fn collect_reachable_checked(
&self,
id: NodeId,
reachable: &mut Vec<NodeId>,
states: &mut [Option<VisitState>],
) -> crate::Result<()> {
match states.get(id.index()).copied().flatten() {
Some(VisitState::Visiting) => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.cycle"),
format!("graph contains a cycle involving node {}", id.index()),
));
}
Some(VisitState::Done) => return Ok(()),
None => {}
}
let node = self.node(id).ok_or_else(|| {
crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!("clip references missing node {}", id.index()),
)
})?;
*semisafe_get_mut(states, id.index()) = Some(VisitState::Visiting);
if let NodeKind::Filter { inputs, .. } = node.kind() {
for input in inputs {
self.collect_reachable_checked(input.node_id(), reachable, states)?;
}
}
*semisafe_get_mut(states, id.index()) = Some(VisitState::Done);
reachable.push(id);
Ok(())
}
fn validate_acyclic(&self, plan: &ValidationPlan) -> crate::Result<()> {
let mut states = vec![None; self.nodes.len()];
for id in plan.reachable_nodes() {
self.visit_for_cycle(*id, &mut states)?;
}
Ok(())
}
fn visit_for_cycle(&self, id: NodeId, states: &mut [Option<VisitState>]) -> crate::Result<()> {
match states.get(id.index()).copied().flatten() {
Some(VisitState::Visiting) => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.cycle"),
format!("graph contains a cycle involving node {}", id.index()),
));
}
Some(VisitState::Done) => return Ok(()),
None => {}
}
let state = states.get_mut(id.index()).ok_or_else(|| {
crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!("clip references missing node {}", id.index()),
)
})?;
*state = Some(VisitState::Visiting);
let node = self.node(id).ok_or_else(|| {
crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!("clip references missing node {}", id.index()),
)
})?;
if let NodeKind::Filter { inputs, .. } = node.kind() {
for input in inputs {
self.visit_for_cycle(input.node_id(), states)?;
}
}
*semisafe_get_mut(states, id.index()) = Some(VisitState::Done);
Ok(())
}
fn validate_media(&self, plan: &ValidationPlan) -> crate::Result<()> {
for id in plan.reachable_nodes() {
let node = self.node(*id).ok_or_else(|| {
crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!("clip references missing node {}", id.index()),
)
})?;
validate_phase1_media(node.media(), *id)?;
if let NodeKind::Filter {
inputs,
compatibility,
..
} = node.kind()
{
self.validate_filter_compatibility(node, inputs, *compatibility)?;
}
}
let output = semisafe_get(&self.outputs, 0).node_id();
let output_node = self.node(output).ok_or_else(|| {
crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!("output references missing node {}", output.index()),
)
})?;
let ClipFormat::Fixed(format) = output_node.media().format() else {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.variable_format"),
format!("node {} has variable format", output.index()),
));
};
if !is_y4m_compatible_format(format) {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.unsupported_output_format"),
format!(
"final output format '{}' is not Y4M-compatible",
format.name()
),
));
}
Ok(())
}
fn validate_filter_compatibility(
&self,
node: &GraphNode,
inputs: &[Clip],
compatibility: FilterCompatibility,
) -> crate::Result<()> {
let Some(first_input) = inputs.first() else {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.missing_filter_input"),
format!("filter node {} has no inputs", node.id().index()),
));
};
let first_media = self.input_media(*first_input)?;
if compatibility == FilterCompatibility::Custom {
return Ok(());
}
for input in inputs.iter().skip(1) {
let input_media = self.input_media(*input)?;
require_equal_format(first_media, input_media, input.node_id())?;
require_equal_resolution(first_media, input_media, input.node_id())?;
require_equal_frame_count(first_media, input_media, input.node_id())?;
require_equal_frame_rate(first_media, input_media, input.node_id())?;
}
match compatibility {
FilterCompatibility::Preserve => {
require_equal_format(first_media, node.media(), node.id())?;
require_equal_resolution(first_media, node.media(), node.id())?;
require_equal_frame_count(first_media, node.media(), node.id())?;
require_equal_frame_rate(first_media, node.media(), node.id())?;
}
FilterCompatibility::AllowChanges(changes) => {
if !changes.format {
require_equal_format(first_media, node.media(), node.id())?;
}
if !changes.resolution {
require_equal_resolution(first_media, node.media(), node.id())?;
}
if !changes.frame_count {
require_equal_frame_count(first_media, node.media(), node.id())?;
}
if !changes.frame_rate {
require_equal_frame_rate(first_media, node.media(), node.id())?;
}
}
FilterCompatibility::Custom => unreachable!("custom compatibility returned early"),
}
Ok(())
}
fn input_media(&self, clip: Clip) -> crate::Result<&ClipMedia> {
self.node(clip.node_id())
.map(GraphNode::media)
.ok_or_else(|| {
crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!("clip references missing node {}", clip.node_id().index()),
)
})
}
}
fn validate_phase1_media(media: &ClipMedia, id: NodeId) -> crate::Result<()> {
match media.format() {
ClipFormat::Fixed(_) => {}
ClipFormat::Variable => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.variable_format"),
format!("node {} has variable format", id.index()),
));
}
}
match media.resolution() {
ClipResolution::Fixed { width, height } if *width > 0 && *height > 0 => {}
ClipResolution::Fixed { .. } => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_resolution"),
format!("node {} has zero resolution dimension", id.index()),
));
}
ClipResolution::Variable => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.variable_resolution"),
format!("node {} has variable resolution", id.index()),
));
}
}
match media.frame_count() {
FrameCount::Finite(_) => {}
FrameCount::Unknown => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.unknown_frame_count"),
format!("node {} has unknown frame count", id.index()),
));
}
}
match media.frame_rate() {
FrameRate::Cfr(rate) if rate.denominator > 0 && rate.numerator > 0 => {}
FrameRate::Cfr(_) => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_frame_rate"),
format!("node {} has invalid frame rate", id.index()),
));
}
FrameRate::Unknown => {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.unknown_frame_rate"),
format!("node {} has unknown frame rate", id.index()),
));
}
}
Ok(())
}
fn require_equal_format(expected: &ClipMedia, actual: &ClipMedia, id: NodeId) -> crate::Result<()> {
if expected.format() == actual.format() {
return Ok(());
}
Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.incompatible_format"),
format!(
"node {} changes format without explicit conversion policy",
id.index()
),
))
}
fn require_equal_resolution(
expected: &ClipMedia,
actual: &ClipMedia,
id: NodeId,
) -> crate::Result<()> {
if expected.resolution() == actual.resolution() {
return Ok(());
}
Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.incompatible_resolution"),
format!(
"node {} changes resolution without explicit resize policy",
id.index()
),
))
}
fn require_equal_frame_count(
expected: &ClipMedia,
actual: &ClipMedia,
id: NodeId,
) -> crate::Result<()> {
if expected.frame_count() == actual.frame_count() {
return Ok(());
}
Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.incompatible_frame_count"),
format!(
"node {} changes frame count without explicit policy",
id.index()
),
))
}
fn require_equal_frame_rate(
expected: &ClipMedia,
actual: &ClipMedia,
id: NodeId,
) -> crate::Result<()> {
if expected.frame_rate() == actual.frame_rate() {
return Ok(());
}
Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.incompatible_frame_rate"),
format!(
"node {} changes frame rate without explicit policy",
id.index()
),
))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ValidationPlan {
reachable_nodes: Vec<NodeId>,
reachable_sources: Vec<NodeId>,
}
impl ValidationPlan {
#[must_use]
pub fn reachable_nodes(&self) -> &[NodeId] {
&self.reachable_nodes
}
#[must_use]
pub fn reachable_sources(&self) -> &[NodeId] {
&self.reachable_sources
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GraphBuilder {
nodes: Vec<GraphNode>,
outputs: Vec<Clip>,
}
impl GraphBuilder {
#[must_use]
pub const fn new() -> Self {
Self {
nodes: Vec::new(),
outputs: Vec::new(),
}
}
pub fn source(&mut self, name: impl Into<String>, media: ClipMedia) -> Clip {
let name = name.into();
self.source_with_request(crate::SourceRequest::new(name), media)
}
pub fn source_with_request(&mut self, request: crate::SourceRequest, media: ClipMedia) -> Clip {
self.source_with_request_and_capabilities(
request,
media,
crate::SourceCapabilities::random_access(),
)
}
pub fn source_with_capabilities(
&mut self,
name: impl Into<String>,
media: ClipMedia,
capabilities: crate::SourceCapabilities,
) -> Clip {
self.source_with_request_and_capabilities(
crate::SourceRequest::new(name.into()),
media,
capabilities,
)
}
pub fn source_with_request_and_capabilities(
&mut self,
request: crate::SourceRequest,
media: ClipMedia,
capabilities: crate::SourceCapabilities,
) -> Clip {
let id = NodeId::new(self.nodes.len());
let clip = Clip::new(id);
self.nodes.push(GraphNode {
id,
kind: NodeKind::Source {
name: request.path().to_owned(),
request,
capabilities,
},
media,
});
clip
}
pub fn filter(
&mut self,
name: impl Into<String>,
inputs: &[Clip],
media: ClipMedia,
compatibility: FilterCompatibility,
) -> crate::Result<Clip> {
self.filter_with_schedule(
name,
inputs,
media,
compatibility,
crate::DependencyPattern::same_frame(),
crate::ConcurrencyClass::Stateless,
)
}
pub fn filter_with_schedule(
&mut self,
name: impl Into<String>,
inputs: &[Clip],
media: ClipMedia,
compatibility: FilterCompatibility,
dependencies: crate::DependencyPattern,
concurrency: crate::ConcurrencyClass,
) -> crate::Result<Clip> {
self.filter_with_schedule_and_options(
name,
inputs,
media,
compatibility,
dependencies,
concurrency,
FilterOptions::new(),
)
}
pub fn filter_with_schedule_and_options(
&mut self,
name: impl Into<String>,
inputs: &[Clip],
media: ClipMedia,
compatibility: FilterCompatibility,
dependencies: crate::DependencyPattern,
concurrency: crate::ConcurrencyClass,
options: FilterOptions,
) -> crate::Result<Clip> {
for input in inputs {
if self.nodes.get(input.node_id().index()).is_none() {
return Err(crate::PixelFlowError::new(
crate::ErrorCategory::Graph,
crate::ErrorCode::new("graph.invalid_clip"),
format!(
"filter input references missing node {}",
input.node_id().index()
),
));
}
}
let id = NodeId::new(self.nodes.len());
let clip = Clip::new(id);
self.nodes.push(GraphNode {
id,
kind: NodeKind::Filter {
name: name.into(),
inputs: inputs.to_vec(),
options,
compatibility,
dependencies,
concurrency,
},
media,
});
Ok(clip)
}
pub fn set_output(&mut self, output: Clip) {
self.outputs.clear();
self.outputs.push(output);
}
pub fn add_output(&mut self, output: Clip) {
self.outputs.push(output);
}
#[must_use]
pub fn build(self) -> Graph {
Graph {
nodes: self.nodes,
outputs: self.outputs,
}
}
}
#[cfg(test)]
impl Graph {
fn from_parts_for_tests(nodes: Vec<GraphNode>, outputs: Vec<Clip>) -> Self {
Self { nodes, outputs }
}
}
#[cfg(test)]
impl GraphNode {
fn filter_for_tests(
id: NodeId,
name: impl Into<String>,
inputs: &[Clip],
media: ClipMedia,
compatibility: FilterCompatibility,
) -> Self {
Self {
id,
kind: NodeKind::Filter {
name: name.into(),
inputs: inputs.to_vec(),
options: crate::FilterOptions::new(),
compatibility,
dependencies: crate::DependencyPattern::same_frame(),
concurrency: crate::ConcurrencyClass::Stateless,
},
media,
}
}
}
#[cfg(test)]
mod tests {
#![expect(clippy::panic, reason = "allow in tests")]
#![expect(clippy::unwrap_used, reason = "allow in tests")]
use crate::{ChromaSubsampling, FormatFamily, Rational, SampleType, resolve_format_alias};
use super::{
ClipFormat, ClipMedia, ClipResolution, FilterChangeSet, FilterCompatibility, FrameCount,
FrameRate, GraphBuilder, NodeId, NodeKind, is_y4m_compatible_format,
};
fn fixed_media(alias: &str) -> ClipMedia {
ClipMedia::fixed(
resolve_format_alias(alias).expect("format alias should resolve"),
1920,
1080,
24,
Rational {
numerator: 24_000,
denominator: 1_001,
},
)
}
#[test]
fn graph_builder_source_uses_phase1_source_capability_defaults() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("yuv420p10"));
let graph = builder.build();
let node = graph.node(source.node_id()).expect("source exists");
let NodeKind::Source { capabilities, .. } = node.kind() else {
panic!("expected source node");
};
assert!(capabilities.supports_random_access());
assert!(capabilities.indexing_required());
assert!(capabilities.known_frame_count());
assert_eq!(capabilities.concurrency_limit(), Some(1));
}
#[test]
fn graph_builder_source_stores_request_path_and_options() {
let mut builder = GraphBuilder::new();
let request = crate::SourceRequest::new("relative/input.mkv")
.with_option(
"fps",
crate::SourceOptionValue::Rational(Rational {
numerator: 30_000,
denominator: 1_001,
}),
)
.with_option(
"vfr",
crate::SourceOptionValue::String("normalize".to_owned()),
);
let clip = builder.source_with_request(request.clone(), fixed_media("yuv420p8"));
let graph = builder.build();
let node = graph.node(clip.node_id()).expect("source exists");
let NodeKind::Source {
request: stored, ..
} = node.kind()
else {
panic!("expected source node");
};
assert_eq!(stored, &request);
}
#[test]
fn graph_with_source_media_replaces_only_source_media() {
let mut builder = GraphBuilder::new();
let source = builder.source(
"input.mkv",
ClipMedia::new(
ClipFormat::Fixed(resolve_format_alias("yuv420p8").unwrap()),
ClipResolution::Fixed {
width: 1,
height: 1,
},
FrameCount::Unknown,
FrameRate::Unknown,
),
);
let graph = builder.build();
let media = fixed_media("yuv420p10");
let graph = graph
.with_source_media(source.node_id(), media.clone())
.expect("source media updates");
assert_eq!(graph.node(source.node_id()).unwrap().media(), &media);
}
#[test]
fn graph_builder_filter_records_dependency_and_concurrency_defaults() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("yuv420p10"));
let filtered = builder
.filter(
"identity",
&[source],
fixed_media("yuv420p10"),
FilterCompatibility::Preserve,
)
.expect("filter should be added");
let graph = builder.build();
let node = graph.node(filtered.node_id()).expect("filter exists");
let NodeKind::Filter {
dependencies,
concurrency,
..
} = node.kind()
else {
panic!("expected filter node");
};
assert_eq!(dependencies, &crate::DependencyPattern::same_frame());
assert_eq!(*concurrency, crate::ConcurrencyClass::Stateless);
}
#[test]
fn graph_builder_filter_with_schedule_records_explicit_contracts() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("yuv420p10"));
let filtered = builder
.filter_with_schedule(
"temporal",
&[source],
fixed_media("yuv420p10"),
FilterCompatibility::Preserve,
crate::DependencyPattern::window(2, 0),
crate::ConcurrencyClass::OrderedStateful,
)
.expect("filter should be added");
let graph = builder.build();
let node = graph.node(filtered.node_id()).expect("filter exists");
let NodeKind::Filter {
dependencies,
concurrency,
..
} = node.kind()
else {
panic!("expected filter node");
};
assert_eq!(dependencies, &crate::DependencyPattern::window(2, 0));
assert_eq!(*concurrency, crate::ConcurrencyClass::OrderedStateful);
}
#[test]
fn graph_builder_filter_with_options_preserves_options() {
let media = fixed_media("yuv420p8");
let mut builder = GraphBuilder::new();
let source = builder.source("input", media.clone());
let mut options = crate::FilterOptions::new();
options.insert("width".to_owned(), crate::FilterOptionValue::Int(320));
options.insert("height".to_owned(), crate::FilterOptionValue::Int(180));
let filtered = builder
.filter_with_schedule_and_options(
"resize",
&[source],
media,
FilterCompatibility::Preserve,
crate::DependencyPattern::same_frame(),
crate::ConcurrencyClass::Stateless,
options.clone(),
)
.expect("filter should build");
builder.set_output(filtered);
let graph = builder.build();
let NodeKind::Filter {
options: stored, ..
} = graph
.node(filtered.node_id())
.expect("filter exists")
.kind()
else {
panic!("node should be filter");
};
assert_eq!(stored, &options);
}
#[test]
fn custom_filter_compatibility_allows_planner_validated_heterogeneous_inputs() {
let gray8 = resolve_format_alias("gray8").expect("format should resolve");
let yuv420 = resolve_format_alias("yuv420p8").expect("format should resolve");
let y_media = ClipMedia::fixed(
gray8.clone(),
4,
4,
1,
Rational {
numerator: 24,
denominator: 1,
},
);
let u_media = ClipMedia::fixed(
gray8.clone(),
2,
2,
1,
Rational {
numerator: 24,
denominator: 1,
},
);
let v_media = ClipMedia::fixed(
gray8,
2,
2,
1,
Rational {
numerator: 24,
denominator: 1,
},
);
let output_media = ClipMedia::fixed(
yuv420,
4,
4,
1,
Rational {
numerator: 24,
denominator: 1,
},
);
let mut builder = GraphBuilder::new();
let y = builder.source("y", y_media);
let u = builder.source("u", u_media);
let v = builder.source("v", v_media);
let merged = builder
.filter(
"merge_planes",
&[y, u, v],
output_media,
FilterCompatibility::Custom,
)
.expect("custom filter node should build");
builder.set_output(merged);
let graph = builder.build();
graph
.validate()
.expect("custom compatibility should skip generic input equality");
}
#[test]
fn custom_filter_compatibility_still_rejects_missing_inputs() {
let yuv420 = resolve_format_alias("yuv420p8").expect("format should resolve");
let output_media = ClipMedia::fixed(
yuv420,
4,
4,
1,
Rational {
numerator: 24,
denominator: 1,
},
);
let mut builder = GraphBuilder::new();
let merged = builder
.filter(
"merge_planes",
&[],
output_media,
FilterCompatibility::Custom,
)
.expect("graph builder records empty custom input list");
builder.set_output(merged);
let error = builder
.build()
.validate()
.expect_err("empty custom inputs should fail");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(
error.code(),
crate::ErrorCode::new("graph.missing_filter_input")
);
}
#[test]
fn y4m_compatibility_accepts_phase1_integer_yuv_and_gray() {
let yuv420 = resolve_format_alias("yuv420p10").expect("format should resolve");
let yuv422 = resolve_format_alias("yuv422p16").expect("format should resolve");
let yuv444 = resolve_format_alias("yuv444p8").expect("format should resolve");
let gray = resolve_format_alias("gray12").expect("format should resolve");
assert!(is_y4m_compatible_format(&yuv420));
assert!(is_y4m_compatible_format(&yuv422));
assert!(is_y4m_compatible_format(&yuv444));
assert!(is_y4m_compatible_format(&gray));
}
#[test]
fn y4m_compatibility_rejects_rgb_and_float_outputs() {
let rgb = resolve_format_alias("rgbp10").expect("format should resolve");
let gray_float = resolve_format_alias("grayf32").expect("format should resolve");
let yuv_float = resolve_format_alias("yuv420pf32").expect("format should resolve");
assert!(!is_y4m_compatible_format(&rgb));
assert!(!is_y4m_compatible_format(&gray_float));
assert!(!is_y4m_compatible_format(&yuv_float));
}
#[test]
fn fixed_media_records_phase1_constraints() {
let media = fixed_media("yuv420p10");
assert!(
matches!(media.format(), ClipFormat::Fixed(format) if format.family() == FormatFamily::Yuv)
);
assert!(
matches!(media.format(), ClipFormat::Fixed(format) if format.subsampling() == Some(ChromaSubsampling::Cs420))
);
assert!(
matches!(media.format(), ClipFormat::Fixed(format) if format.sample_type() == SampleType::U16)
);
assert_eq!(
media.resolution(),
&ClipResolution::Fixed {
width: 1920,
height: 1080,
}
);
assert_eq!(media.frame_count(), FrameCount::Finite(24));
assert_eq!(
media.frame_rate(),
FrameRate::Cfr(Rational {
numerator: 24_000,
denominator: 1_001,
})
);
}
#[test]
fn filter_call_creates_new_clip_without_mutating_input_clip() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("yuv420p10"));
let filtered = builder
.filter(
"identity",
&[source],
fixed_media("yuv420p10"),
FilterCompatibility::Preserve,
)
.expect("filter should be added");
assert_ne!(source.node_id(), filtered.node_id());
assert_eq!(source.node_id().index(), 0);
assert_eq!(filtered.node_id().index(), 1);
}
#[test]
fn missing_final_output_fails_with_structured_diagnostic() {
let mut builder = GraphBuilder::new();
builder.source("source", fixed_media("yuv420p10"));
let graph = builder.build();
let error = graph
.validation_plan()
.expect_err("missing output should fail");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(error.code(), crate::ErrorCode::new("graph.missing_output"));
}
#[test]
fn multiple_final_outputs_fail_phase1_validation() {
let mut builder = GraphBuilder::new();
let first = builder.source("first", fixed_media("yuv420p10"));
let second = builder.source("second", fixed_media("yuv420p10"));
builder.add_output(first);
builder.add_output(second);
let graph = builder.build();
let error = graph
.validation_plan()
.expect_err("multiple outputs should fail");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(
error.code(),
crate::ErrorCode::new("graph.multiple_outputs")
);
assert_eq!(error.message(), "graph has multiple final outputs");
}
#[test]
fn unreachable_source_and_filter_nodes_are_skipped_by_validation_plan() {
let mut builder = GraphBuilder::new();
let unused_source = builder.source("unused", fixed_media("yuv420p10"));
let used_source = builder.source("used", fixed_media("yuv420p10"));
let used_filter = builder
.filter(
"identity",
&[used_source],
fixed_media("yuv420p10"),
FilterCompatibility::Preserve,
)
.expect("reachable filter should be added");
let unused_filter = builder
.filter(
"identity",
&[unused_source],
fixed_media("yuv420p10"),
FilterCompatibility::Preserve,
)
.expect("unreachable filter should be added");
builder.set_output(used_filter);
let graph = builder.build();
let plan = graph
.validation_plan()
.expect("graph should have validation plan");
assert_eq!(
plan.reachable_nodes(),
&[used_source.node_id(), used_filter.node_id()]
);
assert_eq!(plan.reachable_sources(), &[used_source.node_id()]);
assert!(!plan.reachable_nodes().contains(&unused_source.node_id()));
assert!(!plan.reachable_nodes().contains(&unused_filter.node_id()));
assert!(matches!(
graph.node(used_source.node_id()).unwrap().kind(),
NodeKind::Source { .. }
));
}
#[test]
fn cyclic_graph_fails_before_render_validation() {
let media = fixed_media("yuv420p10");
let graph = super::Graph::from_parts_for_tests(
vec![
super::GraphNode::filter_for_tests(
NodeId::new(0),
"a",
&[super::Clip::new(NodeId::new(1))],
media.clone(),
FilterCompatibility::Preserve,
),
super::GraphNode::filter_for_tests(
NodeId::new(1),
"b",
&[super::Clip::new(NodeId::new(0))],
media,
FilterCompatibility::Preserve,
),
],
vec![super::Clip::new(NodeId::new(0))],
);
let error = graph.validate().expect_err("cycle should fail validation");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(error.code(), crate::ErrorCode::new("graph.cycle"));
}
fn invalid_media(
format: ClipFormat,
resolution: ClipResolution,
frame_count: FrameCount,
frame_rate: FrameRate,
) -> ClipMedia {
ClipMedia::new(format, resolution, frame_count, frame_rate)
}
#[test]
fn variable_format_resolution_unknown_count_and_unknown_rate_fail_before_render() {
let cases = [
(
invalid_media(
ClipFormat::Variable,
ClipResolution::Fixed {
width: 1920,
height: 1080,
},
FrameCount::Finite(24),
FrameRate::Cfr(Rational {
numerator: 24,
denominator: 1,
}),
),
crate::ErrorCode::new("graph.variable_format"),
),
(
invalid_media(
ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
ClipResolution::Variable,
FrameCount::Finite(24),
FrameRate::Cfr(Rational {
numerator: 24,
denominator: 1,
}),
),
crate::ErrorCode::new("graph.variable_resolution"),
),
(
invalid_media(
ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
ClipResolution::Fixed {
width: 1920,
height: 1080,
},
FrameCount::Unknown,
FrameRate::Cfr(Rational {
numerator: 24,
denominator: 1,
}),
),
crate::ErrorCode::new("graph.unknown_frame_count"),
),
(
invalid_media(
ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
ClipResolution::Fixed {
width: 1920,
height: 1080,
},
FrameCount::Finite(24),
FrameRate::Unknown,
),
crate::ErrorCode::new("graph.unknown_frame_rate"),
),
];
for (media, expected_code) in cases {
let mut builder = GraphBuilder::new();
let source = builder.source("source", media);
builder.set_output(source);
let graph = builder.build();
let error = graph.validate().expect_err("invalid media should fail");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(error.code(), expected_code);
}
}
#[test]
fn unsupported_final_y4m_format_fails_before_render() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("rgbp10"));
builder.set_output(source);
let graph = builder.build();
let error = graph
.validate()
.expect_err("rgb output should fail y4m validation");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(
error.code(),
crate::ErrorCode::new("graph.unsupported_output_format")
);
}
#[test]
fn preserve_policy_rejects_implicit_format_conversion() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("rgbp10"));
let converted = builder
.filter(
"identity",
&[source],
fixed_media("yuv420p10"),
FilterCompatibility::Preserve,
)
.expect("filter should be added");
builder.set_output(converted);
let graph = builder.build();
let error = graph
.validate()
.expect_err("implicit conversion should fail");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(
error.code(),
crate::ErrorCode::new("graph.incompatible_format")
);
}
#[test]
fn explicit_conversion_filter_can_change_format_without_implicit_insertion() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("rgbp10"));
let converted = builder
.filter(
"convert_format",
&[source],
fixed_media("yuv420p10"),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: true,
resolution: false,
frame_count: false,
frame_rate: false,
}),
)
.expect("filter should be added");
builder.set_output(converted);
let graph = builder.build();
let validated = graph.validate().expect("explicit conversion should pass");
assert_eq!(
validated.plan().reachable_nodes(),
&[source.node_id(), converted.node_id()]
);
}
#[test]
fn allow_changes_policy_accepts_format_and_resolution_change() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("yuv420p10"));
let changed_media = ClipMedia::fixed(
resolve_format_alias("yuv444p8").unwrap(),
1280,
720,
24,
Rational {
numerator: 24_000,
denominator: 1_001,
},
);
let filtered = builder
.filter(
"scale_convert",
&[source],
changed_media,
FilterCompatibility::AllowChanges(FilterChangeSet {
format: true,
resolution: true,
frame_count: false,
frame_rate: false,
}),
)
.expect("filter should be added");
builder.set_output(filtered);
builder
.build()
.validate()
.expect("declared format and resolution change should pass");
}
#[test]
fn allow_changes_policy_rejects_undeclared_frame_count_change() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("yuv420p10"));
let changed_media = ClipMedia::fixed(
resolve_format_alias("yuv444p8").unwrap(),
1280,
720,
12,
Rational {
numerator: 24_000,
denominator: 1_001,
},
);
let filtered = builder
.filter(
"scale_convert",
&[source],
changed_media,
FilterCompatibility::AllowChanges(FilterChangeSet {
format: true,
resolution: true,
frame_count: false,
frame_rate: false,
}),
)
.expect("filter should be added");
builder.set_output(filtered);
let error = builder
.build()
.validate()
.expect_err("undeclared frame-count change should fail");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(
error.code(),
crate::ErrorCode::new("graph.incompatible_frame_count")
);
}
#[test]
fn allow_changes_policy_accepts_frame_rate_change_when_declared() {
let mut builder = GraphBuilder::new();
let source = builder.source("source", fixed_media("yuv420p10"));
let changed_media = ClipMedia::fixed(
resolve_format_alias("yuv420p10").unwrap(),
1920,
1080,
24,
Rational {
numerator: 30,
denominator: 1,
},
);
let filtered = builder
.filter(
"retime",
&[source],
changed_media,
FilterCompatibility::AllowChanges(FilterChangeSet {
format: false,
resolution: false,
frame_count: false,
frame_rate: true,
}),
)
.expect("filter should be added");
builder.set_output(filtered);
builder
.build()
.validate()
.expect("declared frame-rate change should pass");
}
#[test]
fn preserve_policy_rejects_frame_count_and_rate_mismatch() {
let source_media = fixed_media("yuv420p10");
let count_changed = ClipMedia::fixed(
resolve_format_alias("yuv420p10").unwrap(),
1920,
1080,
12,
Rational {
numerator: 24_000,
denominator: 1_001,
},
);
let rate_changed = ClipMedia::fixed(
resolve_format_alias("yuv420p10").unwrap(),
1920,
1080,
24,
Rational {
numerator: 30,
denominator: 1,
},
);
for (media, code) in [
(
count_changed,
crate::ErrorCode::new("graph.incompatible_frame_count"),
),
(
rate_changed,
crate::ErrorCode::new("graph.incompatible_frame_rate"),
),
] {
let mut builder = GraphBuilder::new();
let source = builder.source("source", source_media.clone());
let filtered = builder
.filter("identity", &[source], media, FilterCompatibility::Preserve)
.expect("filter should be added");
builder.set_output(filtered);
let graph = builder.build();
let error = graph.validate().expect_err("preserve mismatch should fail");
assert_eq!(error.category(), crate::ErrorCategory::Graph);
assert_eq!(error.code(), code);
}
}
}