1use crate::{
4 ChromaSubsampling, FilterOptions, FormatDescriptor, FormatFamily, Rational, SampleType,
5};
6
7use semisafe::slice::get as semisafe_get;
8use semisafe::slice::get_mut as semisafe_get_mut;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct NodeId(usize);
13
14impl NodeId {
15 #[must_use]
17 pub const fn new(index: usize) -> Self {
18 Self(index)
19 }
20
21 #[must_use]
23 pub const fn index(self) -> usize {
24 self.0
25 }
26}
27
28#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
30pub struct Clip {
31 node_id: NodeId,
32}
33
34impl Clip {
35 #[must_use]
37 pub const fn new(node_id: NodeId) -> Self {
38 Self { node_id }
39 }
40
41 #[must_use]
43 pub const fn node_id(self) -> NodeId {
44 self.node_id
45 }
46}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub struct FilterChangeSet {
51 pub format: bool,
53 pub resolution: bool,
55 pub frame_count: bool,
57 pub frame_rate: bool,
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum FilterCompatibility {
64 Preserve,
66 AllowChanges(FilterChangeSet),
68 Custom,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
74pub enum ClipFormat {
75 Fixed(FormatDescriptor),
77 Variable,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
83pub enum ClipResolution {
84 Fixed {
86 width: usize,
88 height: usize,
90 },
91 Variable,
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum FrameCount {
98 Finite(usize),
100 Unknown,
102}
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub enum FrameRate {
107 Cfr(Rational),
109 Unknown,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct ClipMedia {
116 format: ClipFormat,
117 resolution: ClipResolution,
118 frame_count: FrameCount,
119 frame_rate: FrameRate,
120}
121
122impl ClipMedia {
123 #[must_use]
125 pub const fn fixed(
126 format: FormatDescriptor,
127 width: usize,
128 height: usize,
129 frame_count: usize,
130 frame_rate: Rational,
131 ) -> Self {
132 Self {
133 format: ClipFormat::Fixed(format),
134 resolution: ClipResolution::Fixed { width, height },
135 frame_count: FrameCount::Finite(frame_count),
136 frame_rate: FrameRate::Cfr(frame_rate),
137 }
138 }
139
140 #[must_use]
142 pub const fn new(
143 format: ClipFormat,
144 resolution: ClipResolution,
145 frame_count: FrameCount,
146 frame_rate: FrameRate,
147 ) -> Self {
148 Self {
149 format,
150 resolution,
151 frame_count,
152 frame_rate,
153 }
154 }
155
156 #[must_use]
158 pub const fn format(&self) -> &ClipFormat {
159 &self.format
160 }
161
162 #[must_use]
164 pub const fn resolution(&self) -> &ClipResolution {
165 &self.resolution
166 }
167
168 #[must_use]
170 pub const fn frame_count(&self) -> FrameCount {
171 self.frame_count
172 }
173
174 #[must_use]
176 pub const fn frame_rate(&self) -> FrameRate {
177 self.frame_rate
178 }
179}
180
181#[must_use]
183pub const fn is_y4m_compatible_format(format: &FormatDescriptor) -> bool {
184 let integer_sample = matches!(format.sample_type(), SampleType::U8 | SampleType::U16);
185 let supported_family = match format.family() {
186 FormatFamily::Gray => true,
187 FormatFamily::Yuv => matches!(
188 format.subsampling(),
189 Some(ChromaSubsampling::Cs420 | ChromaSubsampling::Cs422 | ChromaSubsampling::Cs444)
190 ),
191 FormatFamily::PlanarRgb => false,
192 };
193
194 integer_sample && supported_family
195}
196
197#[derive(Clone, Debug, PartialEq)]
199pub enum NodeKind {
200 Source {
202 name: String,
204 request: crate::SourceRequest,
206 capabilities: crate::SourceCapabilities,
208 },
209 Filter {
211 name: String,
213 inputs: Vec<Clip>,
215 options: FilterOptions,
217 compatibility: FilterCompatibility,
219 dependencies: crate::DependencyPattern,
221 concurrency: crate::ConcurrencyClass,
223 },
224}
225
226#[derive(Clone, Debug, PartialEq)]
228pub struct GraphNode {
229 id: NodeId,
230 kind: NodeKind,
231 media: ClipMedia,
232}
233
234impl GraphNode {
235 #[must_use]
237 pub const fn id(&self) -> NodeId {
238 self.id
239 }
240
241 #[must_use]
243 pub const fn kind(&self) -> &NodeKind {
244 &self.kind
245 }
246
247 #[must_use]
249 pub const fn media(&self) -> &ClipMedia {
250 &self.media
251 }
252
253 #[must_use]
255 pub const fn filter_options(&self) -> Option<&FilterOptions> {
256 match &self.kind {
257 NodeKind::Filter { options, .. } => Some(options),
258 NodeKind::Source { .. } => None,
259 }
260 }
261}
262
263#[derive(Clone, Debug, PartialEq)]
265pub struct Graph {
266 nodes: Vec<GraphNode>,
267 outputs: Vec<Clip>,
268}
269
270#[derive(Clone, Copy, Debug, Eq, PartialEq)]
271enum VisitState {
272 Visiting,
273 Done,
274}
275
276#[derive(Clone, Debug, Eq, PartialEq)]
278pub struct ValidatedGraph {
279 plan: ValidationPlan,
280}
281
282impl ValidatedGraph {
283 #[must_use]
285 pub const fn plan(&self) -> &ValidationPlan {
286 &self.plan
287 }
288}
289
290impl Graph {
291 pub fn with_source_media(mut self, node_id: NodeId, media: ClipMedia) -> crate::Result<Self> {
293 let node = self.nodes.get_mut(node_id.index()).ok_or_else(|| {
294 crate::PixelFlowError::new(
295 crate::ErrorCategory::Graph,
296 crate::ErrorCode::new("graph.invalid_clip"),
297 format!(
298 "source media update references missing node {}",
299 node_id.index()
300 ),
301 )
302 })?;
303
304 if !matches!(node.kind, NodeKind::Source { .. }) {
305 return Err(crate::PixelFlowError::new(
306 crate::ErrorCategory::Graph,
307 crate::ErrorCode::new("graph.invalid_clip"),
308 format!("node {} is not a source", node_id.index()),
309 ));
310 }
311
312 node.media = media;
313 self.propagate_filter_media()?;
314 Ok(self)
315 }
316
317 fn propagate_filter_media(&mut self) -> crate::Result<()> {
318 for index in 0..self.nodes.len() {
319 let node = semisafe_get(&self.nodes, index);
320 let (inputs, compatibility, existing_media) = match &node.kind {
321 NodeKind::Filter {
322 inputs,
323 compatibility,
324 ..
325 } => (inputs.clone(), *compatibility, node.media.clone()),
326 NodeKind::Source { .. } => continue,
327 };
328
329 let Some(first_input) = inputs.first().copied() else {
330 return Err(crate::PixelFlowError::new(
331 crate::ErrorCategory::Graph,
332 crate::ErrorCode::new("graph.missing_filter_input"),
333 format!("filter node {index} has no inputs"),
334 ));
335 };
336 let input_media = self.input_media(first_input)?.clone();
337
338 let node = semisafe_get_mut(&mut self.nodes, index);
339 node.media = match compatibility {
340 FilterCompatibility::Preserve => input_media,
341 FilterCompatibility::AllowChanges(changes) => ClipMedia::new(
342 if changes.format {
343 existing_media.format().clone()
344 } else {
345 input_media.format().clone()
346 },
347 if changes.resolution {
348 existing_media.resolution().clone()
349 } else {
350 input_media.resolution().clone()
351 },
352 if changes.frame_count {
353 existing_media.frame_count()
354 } else {
355 input_media.frame_count()
356 },
357 if changes.frame_rate {
358 existing_media.frame_rate()
359 } else {
360 input_media.frame_rate()
361 },
362 ),
363 FilterCompatibility::Custom => existing_media,
364 };
365 }
366
367 Ok(())
368 }
369
370 #[must_use]
372 pub fn node(&self, id: NodeId) -> Option<&GraphNode> {
373 self.nodes.get(id.index()).filter(|node| node.id == id)
374 }
375
376 #[must_use]
378 pub fn nodes(&self) -> &[GraphNode] {
379 &self.nodes
380 }
381
382 #[must_use]
384 pub fn outputs(&self) -> &[Clip] {
385 &self.outputs
386 }
387
388 pub fn validation_plan(&self) -> crate::Result<ValidationPlan> {
390 match self.outputs.as_slice() {
391 [] => Err(crate::PixelFlowError::new(
392 crate::ErrorCategory::Graph,
393 crate::ErrorCode::new("graph.missing_output"),
394 "graph has no final output",
395 )),
396 [output] => {
397 let mut reachable = Vec::new();
398 self.collect_reachable(output.node_id(), &mut reachable)?;
399 let reachable_sources = reachable
400 .iter()
401 .copied()
402 .filter(|id| {
403 self.node(*id)
404 .is_some_and(|node| matches!(node.kind(), NodeKind::Source { .. }))
405 })
406 .collect();
407
408 Ok(ValidationPlan {
409 reachable_nodes: reachable,
410 reachable_sources,
411 })
412 }
413 _ => Err(crate::PixelFlowError::new(
414 crate::ErrorCategory::Graph,
415 crate::ErrorCode::new("graph.multiple_outputs"),
416 "graph has multiple final outputs",
417 )),
418 }
419 }
420
421 pub fn validate(&self) -> crate::Result<ValidatedGraph> {
423 let plan = self.validation_plan()?;
424 self.validate_acyclic(&plan)?;
425 self.validate_media(&plan)?;
426 Ok(ValidatedGraph { plan })
427 }
428
429 fn collect_reachable(&self, id: NodeId, reachable: &mut Vec<NodeId>) -> crate::Result<()> {
430 let mut states = vec![None; self.nodes.len()];
431 self.collect_reachable_checked(id, reachable, &mut states)
432 }
433
434 fn collect_reachable_checked(
435 &self,
436 id: NodeId,
437 reachable: &mut Vec<NodeId>,
438 states: &mut [Option<VisitState>],
439 ) -> crate::Result<()> {
440 match states.get(id.index()).copied().flatten() {
441 Some(VisitState::Visiting) => {
442 return Err(crate::PixelFlowError::new(
443 crate::ErrorCategory::Graph,
444 crate::ErrorCode::new("graph.cycle"),
445 format!("graph contains a cycle involving node {}", id.index()),
446 ));
447 }
448 Some(VisitState::Done) => return Ok(()),
449 None => {}
450 }
451
452 let node = self.node(id).ok_or_else(|| {
453 crate::PixelFlowError::new(
454 crate::ErrorCategory::Graph,
455 crate::ErrorCode::new("graph.invalid_clip"),
456 format!("clip references missing node {}", id.index()),
457 )
458 })?;
459
460 *semisafe_get_mut(states, id.index()) = Some(VisitState::Visiting);
461
462 if let NodeKind::Filter { inputs, .. } = node.kind() {
463 for input in inputs {
464 self.collect_reachable_checked(input.node_id(), reachable, states)?;
465 }
466 }
467
468 *semisafe_get_mut(states, id.index()) = Some(VisitState::Done);
469 reachable.push(id);
470 Ok(())
471 }
472
473 fn validate_acyclic(&self, plan: &ValidationPlan) -> crate::Result<()> {
474 let mut states = vec![None; self.nodes.len()];
475 for id in plan.reachable_nodes() {
476 self.visit_for_cycle(*id, &mut states)?;
477 }
478 Ok(())
479 }
480
481 fn visit_for_cycle(&self, id: NodeId, states: &mut [Option<VisitState>]) -> crate::Result<()> {
482 match states.get(id.index()).copied().flatten() {
483 Some(VisitState::Visiting) => {
484 return Err(crate::PixelFlowError::new(
485 crate::ErrorCategory::Graph,
486 crate::ErrorCode::new("graph.cycle"),
487 format!("graph contains a cycle involving node {}", id.index()),
488 ));
489 }
490 Some(VisitState::Done) => return Ok(()),
491 None => {}
492 }
493
494 let state = states.get_mut(id.index()).ok_or_else(|| {
495 crate::PixelFlowError::new(
496 crate::ErrorCategory::Graph,
497 crate::ErrorCode::new("graph.invalid_clip"),
498 format!("clip references missing node {}", id.index()),
499 )
500 })?;
501 *state = Some(VisitState::Visiting);
502
503 let node = self.node(id).ok_or_else(|| {
504 crate::PixelFlowError::new(
505 crate::ErrorCategory::Graph,
506 crate::ErrorCode::new("graph.invalid_clip"),
507 format!("clip references missing node {}", id.index()),
508 )
509 })?;
510
511 if let NodeKind::Filter { inputs, .. } = node.kind() {
512 for input in inputs {
513 self.visit_for_cycle(input.node_id(), states)?;
514 }
515 }
516
517 *semisafe_get_mut(states, id.index()) = Some(VisitState::Done);
518 Ok(())
519 }
520
521 fn validate_media(&self, plan: &ValidationPlan) -> crate::Result<()> {
522 for id in plan.reachable_nodes() {
523 let node = self.node(*id).ok_or_else(|| {
524 crate::PixelFlowError::new(
525 crate::ErrorCategory::Graph,
526 crate::ErrorCode::new("graph.invalid_clip"),
527 format!("clip references missing node {}", id.index()),
528 )
529 })?;
530 validate_phase1_media(node.media(), *id)?;
531 if let NodeKind::Filter {
532 inputs,
533 compatibility,
534 ..
535 } = node.kind()
536 {
537 self.validate_filter_compatibility(node, inputs, *compatibility)?;
538 }
539 }
540
541 let output = semisafe_get(&self.outputs, 0).node_id();
542 let output_node = self.node(output).ok_or_else(|| {
543 crate::PixelFlowError::new(
544 crate::ErrorCategory::Graph,
545 crate::ErrorCode::new("graph.invalid_clip"),
546 format!("output references missing node {}", output.index()),
547 )
548 })?;
549 let ClipFormat::Fixed(format) = output_node.media().format() else {
550 return Err(crate::PixelFlowError::new(
551 crate::ErrorCategory::Graph,
552 crate::ErrorCode::new("graph.variable_format"),
553 format!("node {} has variable format", output.index()),
554 ));
555 };
556 if !is_y4m_compatible_format(format) {
557 return Err(crate::PixelFlowError::new(
558 crate::ErrorCategory::Graph,
559 crate::ErrorCode::new("graph.unsupported_output_format"),
560 format!(
561 "final output format '{}' is not Y4M-compatible",
562 format.name()
563 ),
564 ));
565 }
566
567 Ok(())
568 }
569
570 fn validate_filter_compatibility(
571 &self,
572 node: &GraphNode,
573 inputs: &[Clip],
574 compatibility: FilterCompatibility,
575 ) -> crate::Result<()> {
576 let Some(first_input) = inputs.first() else {
577 return Err(crate::PixelFlowError::new(
578 crate::ErrorCategory::Graph,
579 crate::ErrorCode::new("graph.missing_filter_input"),
580 format!("filter node {} has no inputs", node.id().index()),
581 ));
582 };
583 let first_media = self.input_media(*first_input)?;
584
585 if compatibility == FilterCompatibility::Custom {
586 return Ok(());
587 }
588
589 for input in inputs.iter().skip(1) {
590 let input_media = self.input_media(*input)?;
591 require_equal_format(first_media, input_media, input.node_id())?;
592 require_equal_resolution(first_media, input_media, input.node_id())?;
593 require_equal_frame_count(first_media, input_media, input.node_id())?;
594 require_equal_frame_rate(first_media, input_media, input.node_id())?;
595 }
596
597 match compatibility {
598 FilterCompatibility::Preserve => {
599 require_equal_format(first_media, node.media(), node.id())?;
600 require_equal_resolution(first_media, node.media(), node.id())?;
601 require_equal_frame_count(first_media, node.media(), node.id())?;
602 require_equal_frame_rate(first_media, node.media(), node.id())?;
603 }
604 FilterCompatibility::AllowChanges(changes) => {
605 if !changes.format {
606 require_equal_format(first_media, node.media(), node.id())?;
607 }
608 if !changes.resolution {
609 require_equal_resolution(first_media, node.media(), node.id())?;
610 }
611 if !changes.frame_count {
612 require_equal_frame_count(first_media, node.media(), node.id())?;
613 }
614 if !changes.frame_rate {
615 require_equal_frame_rate(first_media, node.media(), node.id())?;
616 }
617 }
618 FilterCompatibility::Custom => unreachable!("custom compatibility returned early"),
619 }
620
621 Ok(())
622 }
623
624 fn input_media(&self, clip: Clip) -> crate::Result<&ClipMedia> {
625 self.node(clip.node_id())
626 .map(GraphNode::media)
627 .ok_or_else(|| {
628 crate::PixelFlowError::new(
629 crate::ErrorCategory::Graph,
630 crate::ErrorCode::new("graph.invalid_clip"),
631 format!("clip references missing node {}", clip.node_id().index()),
632 )
633 })
634 }
635}
636
637fn validate_phase1_media(media: &ClipMedia, id: NodeId) -> crate::Result<()> {
638 match media.format() {
639 ClipFormat::Fixed(_) => {}
640 ClipFormat::Variable => {
641 return Err(crate::PixelFlowError::new(
642 crate::ErrorCategory::Graph,
643 crate::ErrorCode::new("graph.variable_format"),
644 format!("node {} has variable format", id.index()),
645 ));
646 }
647 }
648
649 match media.resolution() {
650 ClipResolution::Fixed { width, height } if *width > 0 && *height > 0 => {}
651 ClipResolution::Fixed { .. } => {
652 return Err(crate::PixelFlowError::new(
653 crate::ErrorCategory::Graph,
654 crate::ErrorCode::new("graph.invalid_resolution"),
655 format!("node {} has zero resolution dimension", id.index()),
656 ));
657 }
658 ClipResolution::Variable => {
659 return Err(crate::PixelFlowError::new(
660 crate::ErrorCategory::Graph,
661 crate::ErrorCode::new("graph.variable_resolution"),
662 format!("node {} has variable resolution", id.index()),
663 ));
664 }
665 }
666
667 match media.frame_count() {
668 FrameCount::Finite(_) => {}
669 FrameCount::Unknown => {
670 return Err(crate::PixelFlowError::new(
671 crate::ErrorCategory::Graph,
672 crate::ErrorCode::new("graph.unknown_frame_count"),
673 format!("node {} has unknown frame count", id.index()),
674 ));
675 }
676 }
677
678 match media.frame_rate() {
679 FrameRate::Cfr(rate) if rate.denominator > 0 && rate.numerator > 0 => {}
680 FrameRate::Cfr(_) => {
681 return Err(crate::PixelFlowError::new(
682 crate::ErrorCategory::Graph,
683 crate::ErrorCode::new("graph.invalid_frame_rate"),
684 format!("node {} has invalid frame rate", id.index()),
685 ));
686 }
687 FrameRate::Unknown => {
688 return Err(crate::PixelFlowError::new(
689 crate::ErrorCategory::Graph,
690 crate::ErrorCode::new("graph.unknown_frame_rate"),
691 format!("node {} has unknown frame rate", id.index()),
692 ));
693 }
694 }
695
696 Ok(())
697}
698
699fn require_equal_format(expected: &ClipMedia, actual: &ClipMedia, id: NodeId) -> crate::Result<()> {
700 if expected.format() == actual.format() {
701 return Ok(());
702 }
703 Err(crate::PixelFlowError::new(
704 crate::ErrorCategory::Graph,
705 crate::ErrorCode::new("graph.incompatible_format"),
706 format!(
707 "node {} changes format without explicit conversion policy",
708 id.index()
709 ),
710 ))
711}
712
713fn require_equal_resolution(
714 expected: &ClipMedia,
715 actual: &ClipMedia,
716 id: NodeId,
717) -> crate::Result<()> {
718 if expected.resolution() == actual.resolution() {
719 return Ok(());
720 }
721 Err(crate::PixelFlowError::new(
722 crate::ErrorCategory::Graph,
723 crate::ErrorCode::new("graph.incompatible_resolution"),
724 format!(
725 "node {} changes resolution without explicit resize policy",
726 id.index()
727 ),
728 ))
729}
730
731fn require_equal_frame_count(
732 expected: &ClipMedia,
733 actual: &ClipMedia,
734 id: NodeId,
735) -> crate::Result<()> {
736 if expected.frame_count() == actual.frame_count() {
737 return Ok(());
738 }
739 Err(crate::PixelFlowError::new(
740 crate::ErrorCategory::Graph,
741 crate::ErrorCode::new("graph.incompatible_frame_count"),
742 format!(
743 "node {} changes frame count without explicit policy",
744 id.index()
745 ),
746 ))
747}
748
749fn require_equal_frame_rate(
750 expected: &ClipMedia,
751 actual: &ClipMedia,
752 id: NodeId,
753) -> crate::Result<()> {
754 if expected.frame_rate() == actual.frame_rate() {
755 return Ok(());
756 }
757 Err(crate::PixelFlowError::new(
758 crate::ErrorCategory::Graph,
759 crate::ErrorCode::new("graph.incompatible_frame_rate"),
760 format!(
761 "node {} changes frame rate without explicit policy",
762 id.index()
763 ),
764 ))
765}
766
767#[derive(Clone, Debug, Eq, PartialEq)]
769pub struct ValidationPlan {
770 reachable_nodes: Vec<NodeId>,
771 reachable_sources: Vec<NodeId>,
772}
773
774impl ValidationPlan {
775 #[must_use]
777 pub fn reachable_nodes(&self) -> &[NodeId] {
778 &self.reachable_nodes
779 }
780
781 #[must_use]
783 pub fn reachable_sources(&self) -> &[NodeId] {
784 &self.reachable_sources
785 }
786}
787
788#[derive(Clone, Debug, Default, PartialEq)]
790pub struct GraphBuilder {
791 nodes: Vec<GraphNode>,
792 outputs: Vec<Clip>,
793}
794
795impl GraphBuilder {
796 #[must_use]
798 pub const fn new() -> Self {
799 Self {
800 nodes: Vec::new(),
801 outputs: Vec::new(),
802 }
803 }
804
805 pub fn source(&mut self, name: impl Into<String>, media: ClipMedia) -> Clip {
807 let name = name.into();
808 self.source_with_request(crate::SourceRequest::new(name), media)
809 }
810
811 pub fn source_with_request(&mut self, request: crate::SourceRequest, media: ClipMedia) -> Clip {
813 self.source_with_request_and_capabilities(
814 request,
815 media,
816 crate::SourceCapabilities::random_access(),
817 )
818 }
819
820 pub fn source_with_capabilities(
822 &mut self,
823 name: impl Into<String>,
824 media: ClipMedia,
825 capabilities: crate::SourceCapabilities,
826 ) -> Clip {
827 self.source_with_request_and_capabilities(
828 crate::SourceRequest::new(name.into()),
829 media,
830 capabilities,
831 )
832 }
833
834 pub fn source_with_request_and_capabilities(
836 &mut self,
837 request: crate::SourceRequest,
838 media: ClipMedia,
839 capabilities: crate::SourceCapabilities,
840 ) -> Clip {
841 let id = NodeId::new(self.nodes.len());
842 let clip = Clip::new(id);
843 self.nodes.push(GraphNode {
844 id,
845 kind: NodeKind::Source {
846 name: request.path().to_owned(),
847 request,
848 capabilities,
849 },
850 media,
851 });
852 clip
853 }
854
855 pub fn filter(
857 &mut self,
858 name: impl Into<String>,
859 inputs: &[Clip],
860 media: ClipMedia,
861 compatibility: FilterCompatibility,
862 ) -> crate::Result<Clip> {
863 self.filter_with_schedule(
864 name,
865 inputs,
866 media,
867 compatibility,
868 crate::DependencyPattern::same_frame(),
869 crate::ConcurrencyClass::Stateless,
870 )
871 }
872
873 pub fn filter_with_schedule(
875 &mut self,
876 name: impl Into<String>,
877 inputs: &[Clip],
878 media: ClipMedia,
879 compatibility: FilterCompatibility,
880 dependencies: crate::DependencyPattern,
881 concurrency: crate::ConcurrencyClass,
882 ) -> crate::Result<Clip> {
883 self.filter_with_schedule_and_options(
884 name,
885 inputs,
886 media,
887 compatibility,
888 dependencies,
889 concurrency,
890 FilterOptions::new(),
891 )
892 }
893
894 pub fn filter_with_schedule_and_options(
896 &mut self,
897 name: impl Into<String>,
898 inputs: &[Clip],
899 media: ClipMedia,
900 compatibility: FilterCompatibility,
901 dependencies: crate::DependencyPattern,
902 concurrency: crate::ConcurrencyClass,
903 options: FilterOptions,
904 ) -> crate::Result<Clip> {
905 for input in inputs {
906 if self.nodes.get(input.node_id().index()).is_none() {
907 return Err(crate::PixelFlowError::new(
908 crate::ErrorCategory::Graph,
909 crate::ErrorCode::new("graph.invalid_clip"),
910 format!(
911 "filter input references missing node {}",
912 input.node_id().index()
913 ),
914 ));
915 }
916 }
917
918 let id = NodeId::new(self.nodes.len());
919 let clip = Clip::new(id);
920 self.nodes.push(GraphNode {
921 id,
922 kind: NodeKind::Filter {
923 name: name.into(),
924 inputs: inputs.to_vec(),
925 options,
926 compatibility,
927 dependencies,
928 concurrency,
929 },
930 media,
931 });
932 Ok(clip)
933 }
934
935 pub fn set_output(&mut self, output: Clip) {
937 self.outputs.clear();
938 self.outputs.push(output);
939 }
940
941 pub fn add_output(&mut self, output: Clip) {
943 self.outputs.push(output);
944 }
945
946 #[must_use]
948 pub fn build(self) -> Graph {
949 Graph {
950 nodes: self.nodes,
951 outputs: self.outputs,
952 }
953 }
954}
955
956#[cfg(test)]
957impl Graph {
958 fn from_parts_for_tests(nodes: Vec<GraphNode>, outputs: Vec<Clip>) -> Self {
959 Self { nodes, outputs }
960 }
961}
962
963#[cfg(test)]
964impl GraphNode {
965 fn filter_for_tests(
966 id: NodeId,
967 name: impl Into<String>,
968 inputs: &[Clip],
969 media: ClipMedia,
970 compatibility: FilterCompatibility,
971 ) -> Self {
972 Self {
973 id,
974 kind: NodeKind::Filter {
975 name: name.into(),
976 inputs: inputs.to_vec(),
977 options: crate::FilterOptions::new(),
978 compatibility,
979 dependencies: crate::DependencyPattern::same_frame(),
980 concurrency: crate::ConcurrencyClass::Stateless,
981 },
982 media,
983 }
984 }
985}
986
987#[cfg(test)]
988mod tests {
989 #![expect(clippy::panic, reason = "allow in tests")]
990 #![expect(clippy::unwrap_used, reason = "allow in tests")]
991
992 use crate::{ChromaSubsampling, FormatFamily, Rational, SampleType, resolve_format_alias};
993
994 use super::{
995 ClipFormat, ClipMedia, ClipResolution, FilterChangeSet, FilterCompatibility, FrameCount,
996 FrameRate, GraphBuilder, NodeId, NodeKind, is_y4m_compatible_format,
997 };
998
999 fn fixed_media(alias: &str) -> ClipMedia {
1000 ClipMedia::fixed(
1001 resolve_format_alias(alias).expect("format alias should resolve"),
1002 1920,
1003 1080,
1004 24,
1005 Rational {
1006 numerator: 24_000,
1007 denominator: 1_001,
1008 },
1009 )
1010 }
1011
1012 #[test]
1013 fn graph_builder_source_uses_phase1_source_capability_defaults() {
1014 let mut builder = GraphBuilder::new();
1015 let source = builder.source("source", fixed_media("yuv420p10"));
1016 let graph = builder.build();
1017
1018 let node = graph.node(source.node_id()).expect("source exists");
1019 let NodeKind::Source { capabilities, .. } = node.kind() else {
1020 panic!("expected source node");
1021 };
1022
1023 assert!(capabilities.supports_random_access());
1024 assert!(capabilities.indexing_required());
1025 assert!(capabilities.known_frame_count());
1026 assert_eq!(capabilities.concurrency_limit(), Some(1));
1027 }
1028
1029 #[test]
1030 fn graph_builder_source_stores_request_path_and_options() {
1031 let mut builder = GraphBuilder::new();
1032 let request = crate::SourceRequest::new("relative/input.mkv")
1033 .with_option(
1034 "fps",
1035 crate::SourceOptionValue::Rational(Rational {
1036 numerator: 30_000,
1037 denominator: 1_001,
1038 }),
1039 )
1040 .with_option(
1041 "vfr",
1042 crate::SourceOptionValue::String("normalize".to_owned()),
1043 );
1044 let clip = builder.source_with_request(request.clone(), fixed_media("yuv420p8"));
1045 let graph = builder.build();
1046
1047 let node = graph.node(clip.node_id()).expect("source exists");
1048 let NodeKind::Source {
1049 request: stored, ..
1050 } = node.kind()
1051 else {
1052 panic!("expected source node");
1053 };
1054
1055 assert_eq!(stored, &request);
1056 }
1057
1058 #[test]
1059 fn graph_with_source_media_replaces_only_source_media() {
1060 let mut builder = GraphBuilder::new();
1061 let source = builder.source(
1062 "input.mkv",
1063 ClipMedia::new(
1064 ClipFormat::Fixed(resolve_format_alias("yuv420p8").unwrap()),
1065 ClipResolution::Fixed {
1066 width: 1,
1067 height: 1,
1068 },
1069 FrameCount::Unknown,
1070 FrameRate::Unknown,
1071 ),
1072 );
1073 let graph = builder.build();
1074 let media = fixed_media("yuv420p10");
1075
1076 let graph = graph
1077 .with_source_media(source.node_id(), media.clone())
1078 .expect("source media updates");
1079
1080 assert_eq!(graph.node(source.node_id()).unwrap().media(), &media);
1081 }
1082
1083 #[test]
1084 fn graph_builder_filter_records_dependency_and_concurrency_defaults() {
1085 let mut builder = GraphBuilder::new();
1086 let source = builder.source("source", fixed_media("yuv420p10"));
1087 let filtered = builder
1088 .filter(
1089 "identity",
1090 &[source],
1091 fixed_media("yuv420p10"),
1092 FilterCompatibility::Preserve,
1093 )
1094 .expect("filter should be added");
1095 let graph = builder.build();
1096
1097 let node = graph.node(filtered.node_id()).expect("filter exists");
1098 let NodeKind::Filter {
1099 dependencies,
1100 concurrency,
1101 ..
1102 } = node.kind()
1103 else {
1104 panic!("expected filter node");
1105 };
1106
1107 assert_eq!(dependencies, &crate::DependencyPattern::same_frame());
1108 assert_eq!(*concurrency, crate::ConcurrencyClass::Stateless);
1109 }
1110
1111 #[test]
1112 fn graph_builder_filter_with_schedule_records_explicit_contracts() {
1113 let mut builder = GraphBuilder::new();
1114 let source = builder.source("source", fixed_media("yuv420p10"));
1115 let filtered = builder
1116 .filter_with_schedule(
1117 "temporal",
1118 &[source],
1119 fixed_media("yuv420p10"),
1120 FilterCompatibility::Preserve,
1121 crate::DependencyPattern::window(2, 0),
1122 crate::ConcurrencyClass::OrderedStateful,
1123 )
1124 .expect("filter should be added");
1125 let graph = builder.build();
1126
1127 let node = graph.node(filtered.node_id()).expect("filter exists");
1128 let NodeKind::Filter {
1129 dependencies,
1130 concurrency,
1131 ..
1132 } = node.kind()
1133 else {
1134 panic!("expected filter node");
1135 };
1136
1137 assert_eq!(dependencies, &crate::DependencyPattern::window(2, 0));
1138 assert_eq!(*concurrency, crate::ConcurrencyClass::OrderedStateful);
1139 }
1140
1141 #[test]
1142 fn graph_builder_filter_with_options_preserves_options() {
1143 let media = fixed_media("yuv420p8");
1144 let mut builder = GraphBuilder::new();
1145 let source = builder.source("input", media.clone());
1146 let mut options = crate::FilterOptions::new();
1147 options.insert("width".to_owned(), crate::FilterOptionValue::Int(320));
1148 options.insert("height".to_owned(), crate::FilterOptionValue::Int(180));
1149
1150 let filtered = builder
1151 .filter_with_schedule_and_options(
1152 "resize",
1153 &[source],
1154 media,
1155 FilterCompatibility::Preserve,
1156 crate::DependencyPattern::same_frame(),
1157 crate::ConcurrencyClass::Stateless,
1158 options.clone(),
1159 )
1160 .expect("filter should build");
1161 builder.set_output(filtered);
1162 let graph = builder.build();
1163
1164 let NodeKind::Filter {
1165 options: stored, ..
1166 } = graph
1167 .node(filtered.node_id())
1168 .expect("filter exists")
1169 .kind()
1170 else {
1171 panic!("node should be filter");
1172 };
1173 assert_eq!(stored, &options);
1174 }
1175
1176 #[test]
1177 fn custom_filter_compatibility_allows_planner_validated_heterogeneous_inputs() {
1178 let gray8 = resolve_format_alias("gray8").expect("format should resolve");
1179 let yuv420 = resolve_format_alias("yuv420p8").expect("format should resolve");
1180 let y_media = ClipMedia::fixed(
1181 gray8.clone(),
1182 4,
1183 4,
1184 1,
1185 Rational {
1186 numerator: 24,
1187 denominator: 1,
1188 },
1189 );
1190 let u_media = ClipMedia::fixed(
1191 gray8.clone(),
1192 2,
1193 2,
1194 1,
1195 Rational {
1196 numerator: 24,
1197 denominator: 1,
1198 },
1199 );
1200 let v_media = ClipMedia::fixed(
1201 gray8,
1202 2,
1203 2,
1204 1,
1205 Rational {
1206 numerator: 24,
1207 denominator: 1,
1208 },
1209 );
1210 let output_media = ClipMedia::fixed(
1211 yuv420,
1212 4,
1213 4,
1214 1,
1215 Rational {
1216 numerator: 24,
1217 denominator: 1,
1218 },
1219 );
1220
1221 let mut builder = GraphBuilder::new();
1222 let y = builder.source("y", y_media);
1223 let u = builder.source("u", u_media);
1224 let v = builder.source("v", v_media);
1225 let merged = builder
1226 .filter(
1227 "merge_planes",
1228 &[y, u, v],
1229 output_media,
1230 FilterCompatibility::Custom,
1231 )
1232 .expect("custom filter node should build");
1233 builder.set_output(merged);
1234 let graph = builder.build();
1235
1236 graph
1237 .validate()
1238 .expect("custom compatibility should skip generic input equality");
1239 }
1240
1241 #[test]
1242 fn custom_filter_compatibility_still_rejects_missing_inputs() {
1243 let yuv420 = resolve_format_alias("yuv420p8").expect("format should resolve");
1244 let output_media = ClipMedia::fixed(
1245 yuv420,
1246 4,
1247 4,
1248 1,
1249 Rational {
1250 numerator: 24,
1251 denominator: 1,
1252 },
1253 );
1254 let mut builder = GraphBuilder::new();
1255 let merged = builder
1256 .filter(
1257 "merge_planes",
1258 &[],
1259 output_media,
1260 FilterCompatibility::Custom,
1261 )
1262 .expect("graph builder records empty custom input list");
1263 builder.set_output(merged);
1264
1265 let error = builder
1266 .build()
1267 .validate()
1268 .expect_err("empty custom inputs should fail");
1269 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1270 assert_eq!(
1271 error.code(),
1272 crate::ErrorCode::new("graph.missing_filter_input")
1273 );
1274 }
1275
1276 #[test]
1277 fn y4m_compatibility_accepts_phase1_integer_yuv_and_gray() {
1278 let yuv420 = resolve_format_alias("yuv420p10").expect("format should resolve");
1279 let yuv422 = resolve_format_alias("yuv422p16").expect("format should resolve");
1280 let yuv444 = resolve_format_alias("yuv444p8").expect("format should resolve");
1281 let gray = resolve_format_alias("gray12").expect("format should resolve");
1282
1283 assert!(is_y4m_compatible_format(&yuv420));
1284 assert!(is_y4m_compatible_format(&yuv422));
1285 assert!(is_y4m_compatible_format(&yuv444));
1286 assert!(is_y4m_compatible_format(&gray));
1287 }
1288
1289 #[test]
1290 fn y4m_compatibility_rejects_rgb_and_float_outputs() {
1291 let rgb = resolve_format_alias("rgbp10").expect("format should resolve");
1292 let gray_float = resolve_format_alias("grayf32").expect("format should resolve");
1293 let yuv_float = resolve_format_alias("yuv420pf32").expect("format should resolve");
1294
1295 assert!(!is_y4m_compatible_format(&rgb));
1296 assert!(!is_y4m_compatible_format(&gray_float));
1297 assert!(!is_y4m_compatible_format(&yuv_float));
1298 }
1299
1300 #[test]
1301 fn fixed_media_records_phase1_constraints() {
1302 let media = fixed_media("yuv420p10");
1303
1304 assert!(
1305 matches!(media.format(), ClipFormat::Fixed(format) if format.family() == FormatFamily::Yuv)
1306 );
1307 assert!(
1308 matches!(media.format(), ClipFormat::Fixed(format) if format.subsampling() == Some(ChromaSubsampling::Cs420))
1309 );
1310 assert!(
1311 matches!(media.format(), ClipFormat::Fixed(format) if format.sample_type() == SampleType::U16)
1312 );
1313 assert_eq!(
1314 media.resolution(),
1315 &ClipResolution::Fixed {
1316 width: 1920,
1317 height: 1080,
1318 }
1319 );
1320 assert_eq!(media.frame_count(), FrameCount::Finite(24));
1321 assert_eq!(
1322 media.frame_rate(),
1323 FrameRate::Cfr(Rational {
1324 numerator: 24_000,
1325 denominator: 1_001,
1326 })
1327 );
1328 }
1329
1330 #[test]
1331 fn filter_call_creates_new_clip_without_mutating_input_clip() {
1332 let mut builder = GraphBuilder::new();
1333 let source = builder.source("source", fixed_media("yuv420p10"));
1334 let filtered = builder
1335 .filter(
1336 "identity",
1337 &[source],
1338 fixed_media("yuv420p10"),
1339 FilterCompatibility::Preserve,
1340 )
1341 .expect("filter should be added");
1342
1343 assert_ne!(source.node_id(), filtered.node_id());
1344 assert_eq!(source.node_id().index(), 0);
1345 assert_eq!(filtered.node_id().index(), 1);
1346 }
1347
1348 #[test]
1349 fn missing_final_output_fails_with_structured_diagnostic() {
1350 let mut builder = GraphBuilder::new();
1351 builder.source("source", fixed_media("yuv420p10"));
1352 let graph = builder.build();
1353
1354 let error = graph
1355 .validation_plan()
1356 .expect_err("missing output should fail");
1357
1358 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1359 assert_eq!(error.code(), crate::ErrorCode::new("graph.missing_output"));
1360 }
1361
1362 #[test]
1363 fn multiple_final_outputs_fail_phase1_validation() {
1364 let mut builder = GraphBuilder::new();
1365 let first = builder.source("first", fixed_media("yuv420p10"));
1366 let second = builder.source("second", fixed_media("yuv420p10"));
1367 builder.add_output(first);
1368 builder.add_output(second);
1369 let graph = builder.build();
1370
1371 let error = graph
1372 .validation_plan()
1373 .expect_err("multiple outputs should fail");
1374
1375 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1376 assert_eq!(
1377 error.code(),
1378 crate::ErrorCode::new("graph.multiple_outputs")
1379 );
1380 assert_eq!(error.message(), "graph has multiple final outputs");
1381 }
1382
1383 #[test]
1384 fn unreachable_source_and_filter_nodes_are_skipped_by_validation_plan() {
1385 let mut builder = GraphBuilder::new();
1386 let unused_source = builder.source("unused", fixed_media("yuv420p10"));
1387 let used_source = builder.source("used", fixed_media("yuv420p10"));
1388 let used_filter = builder
1389 .filter(
1390 "identity",
1391 &[used_source],
1392 fixed_media("yuv420p10"),
1393 FilterCompatibility::Preserve,
1394 )
1395 .expect("reachable filter should be added");
1396 let unused_filter = builder
1397 .filter(
1398 "identity",
1399 &[unused_source],
1400 fixed_media("yuv420p10"),
1401 FilterCompatibility::Preserve,
1402 )
1403 .expect("unreachable filter should be added");
1404 builder.set_output(used_filter);
1405 let graph = builder.build();
1406
1407 let plan = graph
1408 .validation_plan()
1409 .expect("graph should have validation plan");
1410
1411 assert_eq!(
1412 plan.reachable_nodes(),
1413 &[used_source.node_id(), used_filter.node_id()]
1414 );
1415 assert_eq!(plan.reachable_sources(), &[used_source.node_id()]);
1416 assert!(!plan.reachable_nodes().contains(&unused_source.node_id()));
1417 assert!(!plan.reachable_nodes().contains(&unused_filter.node_id()));
1418 assert!(matches!(
1419 graph.node(used_source.node_id()).unwrap().kind(),
1420 NodeKind::Source { .. }
1421 ));
1422 }
1423
1424 #[test]
1425 fn cyclic_graph_fails_before_render_validation() {
1426 let media = fixed_media("yuv420p10");
1427 let graph = super::Graph::from_parts_for_tests(
1428 vec![
1429 super::GraphNode::filter_for_tests(
1430 NodeId::new(0),
1431 "a",
1432 &[super::Clip::new(NodeId::new(1))],
1433 media.clone(),
1434 FilterCompatibility::Preserve,
1435 ),
1436 super::GraphNode::filter_for_tests(
1437 NodeId::new(1),
1438 "b",
1439 &[super::Clip::new(NodeId::new(0))],
1440 media,
1441 FilterCompatibility::Preserve,
1442 ),
1443 ],
1444 vec![super::Clip::new(NodeId::new(0))],
1445 );
1446
1447 let error = graph.validate().expect_err("cycle should fail validation");
1448
1449 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1450 assert_eq!(error.code(), crate::ErrorCode::new("graph.cycle"));
1451 }
1452
1453 fn invalid_media(
1454 format: ClipFormat,
1455 resolution: ClipResolution,
1456 frame_count: FrameCount,
1457 frame_rate: FrameRate,
1458 ) -> ClipMedia {
1459 ClipMedia::new(format, resolution, frame_count, frame_rate)
1460 }
1461
1462 #[test]
1463 fn variable_format_resolution_unknown_count_and_unknown_rate_fail_before_render() {
1464 let cases = [
1465 (
1466 invalid_media(
1467 ClipFormat::Variable,
1468 ClipResolution::Fixed {
1469 width: 1920,
1470 height: 1080,
1471 },
1472 FrameCount::Finite(24),
1473 FrameRate::Cfr(Rational {
1474 numerator: 24,
1475 denominator: 1,
1476 }),
1477 ),
1478 crate::ErrorCode::new("graph.variable_format"),
1479 ),
1480 (
1481 invalid_media(
1482 ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
1483 ClipResolution::Variable,
1484 FrameCount::Finite(24),
1485 FrameRate::Cfr(Rational {
1486 numerator: 24,
1487 denominator: 1,
1488 }),
1489 ),
1490 crate::ErrorCode::new("graph.variable_resolution"),
1491 ),
1492 (
1493 invalid_media(
1494 ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
1495 ClipResolution::Fixed {
1496 width: 1920,
1497 height: 1080,
1498 },
1499 FrameCount::Unknown,
1500 FrameRate::Cfr(Rational {
1501 numerator: 24,
1502 denominator: 1,
1503 }),
1504 ),
1505 crate::ErrorCode::new("graph.unknown_frame_count"),
1506 ),
1507 (
1508 invalid_media(
1509 ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
1510 ClipResolution::Fixed {
1511 width: 1920,
1512 height: 1080,
1513 },
1514 FrameCount::Finite(24),
1515 FrameRate::Unknown,
1516 ),
1517 crate::ErrorCode::new("graph.unknown_frame_rate"),
1518 ),
1519 ];
1520
1521 for (media, expected_code) in cases {
1522 let mut builder = GraphBuilder::new();
1523 let source = builder.source("source", media);
1524 builder.set_output(source);
1525 let graph = builder.build();
1526
1527 let error = graph.validate().expect_err("invalid media should fail");
1528
1529 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1530 assert_eq!(error.code(), expected_code);
1531 }
1532 }
1533
1534 #[test]
1535 fn unsupported_final_y4m_format_fails_before_render() {
1536 let mut builder = GraphBuilder::new();
1537 let source = builder.source("source", fixed_media("rgbp10"));
1538 builder.set_output(source);
1539 let graph = builder.build();
1540
1541 let error = graph
1542 .validate()
1543 .expect_err("rgb output should fail y4m validation");
1544
1545 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1546 assert_eq!(
1547 error.code(),
1548 crate::ErrorCode::new("graph.unsupported_output_format")
1549 );
1550 }
1551
1552 #[test]
1553 fn preserve_policy_rejects_implicit_format_conversion() {
1554 let mut builder = GraphBuilder::new();
1555 let source = builder.source("source", fixed_media("rgbp10"));
1556 let converted = builder
1557 .filter(
1558 "identity",
1559 &[source],
1560 fixed_media("yuv420p10"),
1561 FilterCompatibility::Preserve,
1562 )
1563 .expect("filter should be added");
1564 builder.set_output(converted);
1565 let graph = builder.build();
1566
1567 let error = graph
1568 .validate()
1569 .expect_err("implicit conversion should fail");
1570
1571 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1572 assert_eq!(
1573 error.code(),
1574 crate::ErrorCode::new("graph.incompatible_format")
1575 );
1576 }
1577
1578 #[test]
1579 fn explicit_conversion_filter_can_change_format_without_implicit_insertion() {
1580 let mut builder = GraphBuilder::new();
1581 let source = builder.source("source", fixed_media("rgbp10"));
1582 let converted = builder
1583 .filter(
1584 "convert_format",
1585 &[source],
1586 fixed_media("yuv420p10"),
1587 FilterCompatibility::AllowChanges(FilterChangeSet {
1588 format: true,
1589 resolution: false,
1590 frame_count: false,
1591 frame_rate: false,
1592 }),
1593 )
1594 .expect("filter should be added");
1595 builder.set_output(converted);
1596 let graph = builder.build();
1597
1598 let validated = graph.validate().expect("explicit conversion should pass");
1599
1600 assert_eq!(
1601 validated.plan().reachable_nodes(),
1602 &[source.node_id(), converted.node_id()]
1603 );
1604 }
1605
1606 #[test]
1607 fn allow_changes_policy_accepts_format_and_resolution_change() {
1608 let mut builder = GraphBuilder::new();
1609 let source = builder.source("source", fixed_media("yuv420p10"));
1610 let changed_media = ClipMedia::fixed(
1611 resolve_format_alias("yuv444p8").unwrap(),
1612 1280,
1613 720,
1614 24,
1615 Rational {
1616 numerator: 24_000,
1617 denominator: 1_001,
1618 },
1619 );
1620 let filtered = builder
1621 .filter(
1622 "scale_convert",
1623 &[source],
1624 changed_media,
1625 FilterCompatibility::AllowChanges(FilterChangeSet {
1626 format: true,
1627 resolution: true,
1628 frame_count: false,
1629 frame_rate: false,
1630 }),
1631 )
1632 .expect("filter should be added");
1633 builder.set_output(filtered);
1634
1635 builder
1636 .build()
1637 .validate()
1638 .expect("declared format and resolution change should pass");
1639 }
1640
1641 #[test]
1642 fn allow_changes_policy_rejects_undeclared_frame_count_change() {
1643 let mut builder = GraphBuilder::new();
1644 let source = builder.source("source", fixed_media("yuv420p10"));
1645 let changed_media = ClipMedia::fixed(
1646 resolve_format_alias("yuv444p8").unwrap(),
1647 1280,
1648 720,
1649 12,
1650 Rational {
1651 numerator: 24_000,
1652 denominator: 1_001,
1653 },
1654 );
1655 let filtered = builder
1656 .filter(
1657 "scale_convert",
1658 &[source],
1659 changed_media,
1660 FilterCompatibility::AllowChanges(FilterChangeSet {
1661 format: true,
1662 resolution: true,
1663 frame_count: false,
1664 frame_rate: false,
1665 }),
1666 )
1667 .expect("filter should be added");
1668 builder.set_output(filtered);
1669
1670 let error = builder
1671 .build()
1672 .validate()
1673 .expect_err("undeclared frame-count change should fail");
1674
1675 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1676 assert_eq!(
1677 error.code(),
1678 crate::ErrorCode::new("graph.incompatible_frame_count")
1679 );
1680 }
1681
1682 #[test]
1683 fn allow_changes_policy_accepts_frame_rate_change_when_declared() {
1684 let mut builder = GraphBuilder::new();
1685 let source = builder.source("source", fixed_media("yuv420p10"));
1686 let changed_media = ClipMedia::fixed(
1687 resolve_format_alias("yuv420p10").unwrap(),
1688 1920,
1689 1080,
1690 24,
1691 Rational {
1692 numerator: 30,
1693 denominator: 1,
1694 },
1695 );
1696 let filtered = builder
1697 .filter(
1698 "retime",
1699 &[source],
1700 changed_media,
1701 FilterCompatibility::AllowChanges(FilterChangeSet {
1702 format: false,
1703 resolution: false,
1704 frame_count: false,
1705 frame_rate: true,
1706 }),
1707 )
1708 .expect("filter should be added");
1709 builder.set_output(filtered);
1710
1711 builder
1712 .build()
1713 .validate()
1714 .expect("declared frame-rate change should pass");
1715 }
1716
1717 #[test]
1718 fn preserve_policy_rejects_frame_count_and_rate_mismatch() {
1719 let source_media = fixed_media("yuv420p10");
1720 let count_changed = ClipMedia::fixed(
1721 resolve_format_alias("yuv420p10").unwrap(),
1722 1920,
1723 1080,
1724 12,
1725 Rational {
1726 numerator: 24_000,
1727 denominator: 1_001,
1728 },
1729 );
1730 let rate_changed = ClipMedia::fixed(
1731 resolve_format_alias("yuv420p10").unwrap(),
1732 1920,
1733 1080,
1734 24,
1735 Rational {
1736 numerator: 30,
1737 denominator: 1,
1738 },
1739 );
1740
1741 for (media, code) in [
1742 (
1743 count_changed,
1744 crate::ErrorCode::new("graph.incompatible_frame_count"),
1745 ),
1746 (
1747 rate_changed,
1748 crate::ErrorCode::new("graph.incompatible_frame_rate"),
1749 ),
1750 ] {
1751 let mut builder = GraphBuilder::new();
1752 let source = builder.source("source", source_media.clone());
1753 let filtered = builder
1754 .filter("identity", &[source], media, FilterCompatibility::Preserve)
1755 .expect("filter should be added");
1756 builder.set_output(filtered);
1757 let graph = builder.build();
1758
1759 let error = graph.validate().expect_err("preserve mismatch should fail");
1760
1761 assert_eq!(error.category(), crate::ErrorCategory::Graph);
1762 assert_eq!(error.code(), code);
1763 }
1764 }
1765}