1use std::path::Path;
4use std::time::Duration;
5
6pub(super) use super::FilterGraph;
7pub(super) use super::filter_step::FilterStep;
8pub(super) use super::types::{
9 DrawTextOptions, EqBand, HwAccel, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
10};
11pub(super) use crate::animation::{AnimatedValue, AnimationEntry};
12pub(super) use crate::blend::BlendMode;
13pub(super) use crate::error::FilterError;
14use crate::filter_inner::{FilterGraphInner, MIN_INPUT_FRAME_RATE};
15
16mod audio;
17mod video;
18
19#[derive(Debug, Default, Clone)]
37pub struct FilterGraphBuilder {
38 pub(super) steps: Vec<FilterStep>,
39 pub(super) hw: Option<HwAccel>,
40 pub(super) animations: Vec<AnimationEntry>,
42 pub(super) input_frame_rate: Option<f64>,
45}
46
47impl FilterGraphBuilder {
48 #[must_use]
50 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub(crate) fn steps(&self) -> &[FilterStep] {
59 &self.steps
60 }
61
62 #[must_use]
69 pub fn add_step(mut self, step: FilterStep) -> Self {
70 self.steps.push(step);
71 self
72 }
73
74 #[must_use]
84 pub fn raw_filter(self, filter: impl Into<String>, args: impl Into<String>) -> Self {
85 self.add_step(FilterStep::Raw {
86 filter: filter.into(),
87 args: args.into(),
88 })
89 }
90
91 #[must_use]
147 pub fn parse_desc(self, desc: impl Into<String>) -> Self {
148 self.add_step(FilterStep::ParseDesc { desc: desc.into() })
149 }
150
151 #[must_use]
161 pub fn input_frame_rate(mut self, fps: f64) -> Self {
162 self.input_frame_rate = Some(fps);
163 self
164 }
165
166 #[must_use]
171 pub fn hardware(mut self, hw: HwAccel) -> Self {
172 self.hw = Some(hw);
173 self
174 }
175
176 pub fn build(self) -> Result<FilterGraph, FilterError> {
192 if self.steps.is_empty() {
193 return Err(FilterError::BuildFailed);
194 }
195
196 for step in &self.steps {
201 if let FilterStep::ParametricEq { bands } = step
202 && bands.is_empty()
203 {
204 return Err(FilterError::InvalidConfig {
205 reason: "equalizer bands must not be empty".to_string(),
206 });
207 }
208 if let FilterStep::Speed { factor } = step
209 && !(0.1..=100.0).contains(factor)
210 {
211 return Err(FilterError::InvalidConfig {
212 reason: format!("speed factor {factor} out of range [0.1, 100.0]"),
213 });
214 }
215 if let FilterStep::LoudnessNormalize {
216 target_lufs,
217 true_peak_db,
218 lra,
219 } = step
220 {
221 if *target_lufs >= 0.0 {
222 return Err(FilterError::InvalidConfig {
223 reason: format!(
224 "loudness_normalize target_lufs {target_lufs} must be < 0.0"
225 ),
226 });
227 }
228 if *true_peak_db > 0.0 {
229 return Err(FilterError::InvalidConfig {
230 reason: format!(
231 "loudness_normalize true_peak_db {true_peak_db} must be <= 0.0"
232 ),
233 });
234 }
235 if *lra <= 0.0 {
236 return Err(FilterError::InvalidConfig {
237 reason: format!("loudness_normalize lra {lra} must be > 0.0"),
238 });
239 }
240 }
241 if let FilterStep::NormalizePeak { target_db } = step
242 && *target_db > 0.0
243 {
244 return Err(FilterError::InvalidConfig {
245 reason: format!("normalize_peak target_db {target_db} must be <= 0.0"),
246 });
247 }
248 if let FilterStep::FreezeFrame { pts, duration } = step {
249 if *pts < 0.0 {
250 return Err(FilterError::InvalidConfig {
251 reason: format!("freeze_frame pts {pts} must be >= 0.0"),
252 });
253 }
254 if *duration <= 0.0 {
255 return Err(FilterError::InvalidConfig {
256 reason: format!("freeze_frame duration {duration} must be > 0.0"),
257 });
258 }
259 }
260 if let FilterStep::Crop { width, height, .. } = step
261 && (*width == 0 || *height == 0)
262 {
263 return Err(FilterError::InvalidConfig {
264 reason: "crop width and height must be > 0".to_string(),
265 });
266 }
267 if let FilterStep::CropAnimated { width, height, .. } = step {
268 let w0 = width.value_at(Duration::ZERO);
269 let h0 = height.value_at(Duration::ZERO);
270 if w0 <= 0.0 || h0 <= 0.0 {
271 return Err(FilterError::InvalidConfig {
272 reason: "crop width and height must be > 0".to_string(),
273 });
274 }
275 }
276 if let FilterStep::GBlurAnimated { sigma } = step {
277 let s0 = sigma.value_at(Duration::ZERO);
278 if s0 < 0.0 {
279 return Err(FilterError::InvalidConfig {
280 reason: format!("gblur sigma {s0} must be >= 0.0"),
281 });
282 }
283 }
284 if let FilterStep::UnsharpAnimated {
285 luma_strength,
286 chroma_strength,
287 } = step
288 {
289 let l0 = luma_strength.value_at(Duration::ZERO);
290 let c0 = chroma_strength.value_at(Duration::ZERO);
291 if !(-1.5..=1.5).contains(&l0) {
292 return Err(FilterError::InvalidConfig {
293 reason: format!("unsharp luma_strength {l0} out of range [-1.5, 1.5]"),
294 });
295 }
296 if !(-1.5..=1.5).contains(&c0) {
297 return Err(FilterError::InvalidConfig {
298 reason: format!("unsharp chroma_strength {c0} out of range [-1.5, 1.5]"),
299 });
300 }
301 }
302 if let FilterStep::EqAnimated {
303 brightness,
304 contrast,
305 saturation,
306 gamma,
307 temperature,
308 tint,
309 } = step
310 {
311 let b = brightness.value_at(Duration::ZERO);
312 if !(-1.0..=1.0).contains(&b) {
313 return Err(FilterError::InvalidConfig {
314 reason: format!("eq brightness {b} out of range [-1.0, 1.0]"),
315 });
316 }
317 let c = contrast.value_at(Duration::ZERO);
318 if !(0.0..=3.0).contains(&c) {
319 return Err(FilterError::InvalidConfig {
320 reason: format!("eq contrast {c} out of range [0.0, 3.0]"),
321 });
322 }
323 let s = saturation.value_at(Duration::ZERO);
324 if !(0.0..=3.0).contains(&s) {
325 return Err(FilterError::InvalidConfig {
326 reason: format!("eq saturation {s} out of range [0.0, 3.0]"),
327 });
328 }
329 let g = gamma.value_at(Duration::ZERO);
330 if !(0.1..=10.0).contains(&g) {
331 return Err(FilterError::InvalidConfig {
332 reason: format!("eq gamma {g} out of range [0.1, 10.0]"),
333 });
334 }
335 let temp = temperature.value_at(Duration::ZERO);
336 if !(-1.0..=1.0).contains(&temp) {
337 return Err(FilterError::InvalidConfig {
338 reason: format!("eq temperature {temp} out of range [-1.0, 1.0]"),
339 });
340 }
341 let ti = tint.value_at(Duration::ZERO);
342 if !(-1.0..=1.0).contains(&ti) {
343 return Err(FilterError::InvalidConfig {
344 reason: format!("eq tint {ti} out of range [-1.0, 1.0]"),
345 });
346 }
347 }
348 if let FilterStep::ColorBalanceAnimated { lift, gamma, gain } = step {
349 for (label, av) in [("lift", lift), ("gamma", gamma), ("gain", gain)] {
350 let (r, g, b) = av.value_at(Duration::ZERO);
351 for (channel, v) in [("r", r), ("g", g), ("b", b)] {
352 if !(-1.0..=1.0).contains(&v) {
353 return Err(FilterError::InvalidConfig {
354 reason: format!(
355 "color_correct {label}.{channel} {v} out of range [-1.0, 1.0]"
356 ),
357 });
358 }
359 }
360 }
361 }
362 if let FilterStep::FadeIn { duration, .. }
363 | FilterStep::FadeOut { duration, .. }
364 | FilterStep::FadeInWhite { duration, .. }
365 | FilterStep::FadeOutWhite { duration, .. } = step
366 && *duration <= 0.0
367 {
368 return Err(FilterError::InvalidConfig {
369 reason: format!("fade duration {duration} must be > 0.0"),
370 });
371 }
372 if let FilterStep::AFadeIn { duration, .. } | FilterStep::AFadeOut { duration, .. } =
373 step
374 && *duration <= 0.0
375 {
376 return Err(FilterError::InvalidConfig {
377 reason: format!("afade duration {duration} must be > 0.0"),
378 });
379 }
380 if let FilterStep::XFade { duration, .. } = step
381 && *duration <= 0.0
382 {
383 return Err(FilterError::InvalidConfig {
384 reason: format!("xfade duration {duration} must be > 0.0"),
385 });
386 }
387 if let FilterStep::ANoiseGate {
388 attack_ms,
389 release_ms,
390 ..
391 } = step
392 {
393 if *attack_ms <= 0.0 {
394 return Err(FilterError::InvalidConfig {
395 reason: format!("agate attack_ms {attack_ms} must be > 0.0"),
396 });
397 }
398 if *release_ms <= 0.0 {
399 return Err(FilterError::InvalidConfig {
400 reason: format!("agate release_ms {release_ms} must be > 0.0"),
401 });
402 }
403 }
404 if let FilterStep::ACompressor {
405 ratio,
406 attack_ms,
407 release_ms,
408 ..
409 } = step
410 {
411 if *ratio < 1.0 {
412 return Err(FilterError::InvalidConfig {
413 reason: format!("compressor ratio {ratio} must be >= 1.0"),
414 });
415 }
416 if *attack_ms <= 0.0 {
417 return Err(FilterError::InvalidConfig {
418 reason: format!("compressor attack_ms {attack_ms} must be > 0.0"),
419 });
420 }
421 if *release_ms <= 0.0 {
422 return Err(FilterError::InvalidConfig {
423 reason: format!("compressor release_ms {release_ms} must be > 0.0"),
424 });
425 }
426 }
427 if let FilterStep::ChannelMap { mapping } = step
428 && mapping.is_empty()
429 {
430 return Err(FilterError::InvalidConfig {
431 reason: "channel_map mapping must not be empty".to_string(),
432 });
433 }
434 if let FilterStep::ConcatVideo { n } = step
435 && *n < 2
436 {
437 return Err(FilterError::InvalidConfig {
438 reason: format!("concat_video n={n} must be >= 2"),
439 });
440 }
441 if let FilterStep::ConcatAudio { n } = step
442 && *n < 2
443 {
444 return Err(FilterError::InvalidConfig {
445 reason: format!("concat_audio n={n} must be >= 2"),
446 });
447 }
448 if let FilterStep::DrawText { opts } = step {
449 if opts.text.is_empty() {
450 return Err(FilterError::InvalidConfig {
451 reason: "drawtext text must not be empty".to_string(),
452 });
453 }
454 if !(0.0..=1.0).contains(&opts.opacity) {
455 return Err(FilterError::InvalidConfig {
456 reason: format!(
457 "drawtext opacity {} out of range [0.0, 1.0]",
458 opts.opacity
459 ),
460 });
461 }
462 }
463 if let FilterStep::Ticker {
464 text,
465 speed_px_per_sec,
466 ..
467 } = step
468 {
469 if text.is_empty() {
470 return Err(FilterError::InvalidConfig {
471 reason: "ticker text must not be empty".to_string(),
472 });
473 }
474 if *speed_px_per_sec <= 0.0 {
475 return Err(FilterError::InvalidConfig {
476 reason: format!("ticker speed_px_per_sec {speed_px_per_sec} must be > 0.0"),
477 });
478 }
479 }
480 if let FilterStep::Overlay { x, y } = step
481 && (*x < 0 || *y < 0)
482 {
483 return Err(FilterError::InvalidConfig {
484 reason: format!(
485 "overlay position ({x}, {y}) is off-screen; \
486 ensure the watermark fits within the video dimensions"
487 ),
488 });
489 }
490 if let FilterStep::Lut3d { path } = step {
491 let ext = Path::new(path)
492 .extension()
493 .and_then(|e| e.to_str())
494 .unwrap_or("");
495 if !matches!(ext, "cube" | "3dl") {
496 return Err(FilterError::InvalidConfig {
497 reason: format!("unsupported LUT format: .{ext}; expected .cube or .3dl"),
498 });
499 }
500 if !Path::new(path).exists() {
501 return Err(FilterError::InvalidConfig {
502 reason: format!("LUT file not found: {path}"),
503 });
504 }
505 }
506 if let FilterStep::SubtitlesSrt { path, .. } = step {
507 let ext = Path::new(path)
508 .extension()
509 .and_then(|e| e.to_str())
510 .unwrap_or("");
511 if ext != "srt" {
512 return Err(FilterError::InvalidConfig {
513 reason: format!("unsupported subtitle format: .{ext}; expected .srt"),
514 });
515 }
516 if !Path::new(path).exists() {
517 return Err(FilterError::InvalidConfig {
518 reason: format!("subtitle file not found: {path}"),
519 });
520 }
521 }
522 if let FilterStep::SubtitlesAss { path } = step {
523 let ext = Path::new(path)
524 .extension()
525 .and_then(|e| e.to_str())
526 .unwrap_or("");
527 if !matches!(ext, "ass" | "ssa") {
528 return Err(FilterError::InvalidConfig {
529 reason: format!(
530 "unsupported subtitle format: .{ext}; expected .ass or .ssa"
531 ),
532 });
533 }
534 if !Path::new(path).exists() {
535 return Err(FilterError::InvalidConfig {
536 reason: format!("subtitle file not found: {path}"),
537 });
538 }
539 }
540 if let FilterStep::ChromaKey {
541 similarity, blend, ..
542 } = step
543 {
544 if !(0.0..=1.0).contains(similarity) {
545 return Err(FilterError::InvalidConfig {
546 reason: format!(
547 "chromakey similarity {similarity} out of range [0.0, 1.0]"
548 ),
549 });
550 }
551 if !(0.0..=1.0).contains(blend) {
552 return Err(FilterError::InvalidConfig {
553 reason: format!("chromakey blend {blend} out of range [0.0, 1.0]"),
554 });
555 }
556 }
557 if let FilterStep::ColorKey {
558 similarity, blend, ..
559 } = step
560 {
561 if !(0.0..=1.0).contains(similarity) {
562 return Err(FilterError::InvalidConfig {
563 reason: format!("colorkey similarity {similarity} out of range [0.0, 1.0]"),
564 });
565 }
566 if !(0.0..=1.0).contains(blend) {
567 return Err(FilterError::InvalidConfig {
568 reason: format!("colorkey blend {blend} out of range [0.0, 1.0]"),
569 });
570 }
571 }
572 if let FilterStep::SpillSuppress { strength, .. } = step
573 && !(0.0..=1.0).contains(strength)
574 {
575 return Err(FilterError::InvalidConfig {
576 reason: format!("spill_suppress strength {strength} out of range [0.0, 1.0]"),
577 });
578 }
579 if let FilterStep::LumaKey {
580 threshold,
581 tolerance,
582 softness,
583 ..
584 } = step
585 {
586 if !(0.0..=1.0).contains(threshold) {
587 return Err(FilterError::InvalidConfig {
588 reason: format!("lumakey threshold {threshold} out of range [0.0, 1.0]"),
589 });
590 }
591 if !(0.0..=1.0).contains(tolerance) {
592 return Err(FilterError::InvalidConfig {
593 reason: format!("lumakey tolerance {tolerance} out of range [0.0, 1.0]"),
594 });
595 }
596 if !(0.0..=1.0).contains(softness) {
597 return Err(FilterError::InvalidConfig {
598 reason: format!("lumakey softness {softness} out of range [0.0, 1.0]"),
599 });
600 }
601 }
602 if let FilterStep::FeatherMask { radius } = step
603 && *radius == 0
604 {
605 return Err(FilterError::InvalidConfig {
606 reason: "feather_mask radius must be > 0".to_string(),
607 });
608 }
609 if let FilterStep::RectMask { width, height, .. } = step
610 && (*width == 0 || *height == 0)
611 {
612 return Err(FilterError::InvalidConfig {
613 reason: "rect_mask width and height must be > 0".to_string(),
614 });
615 }
616 if let FilterStep::PolygonMatte { vertices, .. } = step {
617 if vertices.len() < 3 {
618 return Err(FilterError::InvalidConfig {
619 reason: format!(
620 "polygon_matte requires at least 3 vertices, got {}",
621 vertices.len()
622 ),
623 });
624 }
625 if vertices.len() > 16 {
626 return Err(FilterError::InvalidConfig {
627 reason: format!(
628 "polygon_matte supports up to 16 vertices, got {}",
629 vertices.len()
630 ),
631 });
632 }
633 for &(x, y) in vertices {
634 if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
635 return Err(FilterError::InvalidConfig {
636 reason: format!(
637 "polygon_matte vertex ({x}, {y}) out of range [0.0, 1.0]"
638 ),
639 });
640 }
641 }
642 }
643 if let FilterStep::OverlayImage { path, opacity, .. } = step {
644 let ext = Path::new(path)
645 .extension()
646 .and_then(|e| e.to_str())
647 .unwrap_or("");
648 if ext != "png" {
649 return Err(FilterError::InvalidConfig {
650 reason: format!("unsupported image format: .{ext}; expected .png"),
651 });
652 }
653 if !(0.0..=1.0).contains(opacity) {
654 return Err(FilterError::InvalidConfig {
655 reason: format!("overlay_image opacity {opacity} out of range [0.0, 1.0]"),
656 });
657 }
658 if !Path::new(path).exists() {
659 return Err(FilterError::InvalidConfig {
660 reason: format!("overlay image not found: {path}"),
661 });
662 }
663 }
664 if let FilterStep::Eq {
665 brightness,
666 contrast,
667 saturation,
668 temperature,
669 tint,
670 } = step
671 {
672 if !(-1.0..=1.0).contains(brightness) {
673 return Err(FilterError::InvalidConfig {
674 reason: format!("eq brightness {brightness} out of range [-1.0, 1.0]"),
675 });
676 }
677 if !(0.0..=3.0).contains(contrast) {
678 return Err(FilterError::InvalidConfig {
679 reason: format!("eq contrast {contrast} out of range [0.0, 3.0]"),
680 });
681 }
682 if !(0.0..=3.0).contains(saturation) {
683 return Err(FilterError::InvalidConfig {
684 reason: format!("eq saturation {saturation} out of range [0.0, 3.0]"),
685 });
686 }
687 if !(-1.0..=1.0).contains(temperature) {
688 return Err(FilterError::InvalidConfig {
689 reason: format!("eq temperature {temperature} out of range [-1.0, 1.0]"),
690 });
691 }
692 if !(-1.0..=1.0).contains(tint) {
693 return Err(FilterError::InvalidConfig {
694 reason: format!("eq tint {tint} out of range [-1.0, 1.0]"),
695 });
696 }
697 }
698 if let FilterStep::Curves { master, r, g, b } = step {
699 for (channel, pts) in [
700 ("master", master.as_slice()),
701 ("r", r.as_slice()),
702 ("g", g.as_slice()),
703 ("b", b.as_slice()),
704 ] {
705 for &(x, y) in pts {
706 if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
707 return Err(FilterError::InvalidConfig {
708 reason: format!(
709 "curves {channel} control point ({x}, {y}) out of range [0.0, 1.0]"
710 ),
711 });
712 }
713 }
714 }
715 }
716 if let FilterStep::WhiteBalance {
717 temperature_k,
718 tint,
719 } = step
720 {
721 if !(1000..=40000).contains(temperature_k) {
722 return Err(FilterError::InvalidConfig {
723 reason: format!(
724 "white_balance temperature_k {temperature_k} out of range [1000, 40000]"
725 ),
726 });
727 }
728 if !(-1.0..=1.0).contains(tint) {
729 return Err(FilterError::InvalidConfig {
730 reason: format!("white_balance tint {tint} out of range [-1.0, 1.0]"),
731 });
732 }
733 }
734 if let FilterStep::Hue { degrees } = step
735 && !(-360.0..=360.0).contains(degrees)
736 {
737 return Err(FilterError::InvalidConfig {
738 reason: format!("hue degrees {degrees} out of range [-360.0, 360.0]"),
739 });
740 }
741 if let FilterStep::Gamma { r, g, b } = step {
742 for (channel, val) in [("r", r), ("g", g), ("b", b)] {
743 if !(0.1..=10.0).contains(val) {
744 return Err(FilterError::InvalidConfig {
745 reason: format!("gamma {channel} {val} out of range [0.1, 10.0]"),
746 });
747 }
748 }
749 }
750 if let FilterStep::ThreeWayCC { gamma, .. } = step {
751 for (channel, val) in [("r", gamma.r), ("g", gamma.g), ("b", gamma.b)] {
752 if val <= 0.0 {
753 return Err(FilterError::InvalidConfig {
754 reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
755 });
756 }
757 }
758 }
759 if let FilterStep::ThreeWayCCAnimated {
760 gamma: [gr, gg, gb],
761 ..
762 } = step
763 {
764 for (channel, av) in [("r", gr), ("g", gg), ("b", gb)] {
765 let val = av.value_at(Duration::ZERO);
766 if val <= 0.0 {
767 return Err(FilterError::InvalidConfig {
768 reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
769 });
770 }
771 }
772 }
773 if let FilterStep::Vignette { angle, .. } = step
774 && !((0.0)..=std::f32::consts::FRAC_PI_2).contains(angle)
775 {
776 return Err(FilterError::InvalidConfig {
777 reason: format!("vignette angle {angle} out of range [0.0, π/2]"),
778 });
779 }
780 if let FilterStep::VignetteAnimated { amount, .. } = step {
781 let a0 = amount.value_at(Duration::ZERO);
782 if !(0.0..=1.0).contains(&a0) {
783 return Err(FilterError::InvalidConfig {
784 reason: format!("vignette amount {a0} out of range [0.0, 1.0]"),
785 });
786 }
787 }
788 if let FilterStep::Pad { width, height, .. } = step
789 && (*width == 0 || *height == 0)
790 {
791 return Err(FilterError::InvalidConfig {
792 reason: "pad width and height must be > 0".to_string(),
793 });
794 }
795 if let FilterStep::FitToAspect { width, height, .. } = step
796 && (*width == 0 || *height == 0)
797 {
798 return Err(FilterError::InvalidConfig {
799 reason: "fit_to_aspect width and height must be > 0".to_string(),
800 });
801 }
802 if let FilterStep::FillToAspect { width, height } = step
803 && (*width == 0 || *height == 0)
804 {
805 return Err(FilterError::InvalidConfig {
806 reason: "fill_to_aspect width and height must be > 0".to_string(),
807 });
808 }
809 if let FilterStep::GBlur { sigma } = step
810 && *sigma < 0.0
811 {
812 return Err(FilterError::InvalidConfig {
813 reason: format!("gblur sigma {sigma} must be >= 0.0"),
814 });
815 }
816 if let FilterStep::Unsharp {
817 luma_strength,
818 chroma_strength,
819 } = step
820 {
821 if !(-1.5..=1.5).contains(luma_strength) {
822 return Err(FilterError::InvalidConfig {
823 reason: format!(
824 "unsharp luma_strength {luma_strength} out of range [-1.5, 1.5]"
825 ),
826 });
827 }
828 if !(-1.5..=1.5).contains(chroma_strength) {
829 return Err(FilterError::InvalidConfig {
830 reason: format!(
831 "unsharp chroma_strength {chroma_strength} out of range [-1.5, 1.5]"
832 ),
833 });
834 }
835 }
836 if let FilterStep::Hqdn3d {
837 luma_spatial,
838 chroma_spatial,
839 luma_tmp,
840 chroma_tmp,
841 } = step
842 {
843 for (name, val) in [
844 ("luma_spatial", luma_spatial),
845 ("chroma_spatial", chroma_spatial),
846 ("luma_tmp", luma_tmp),
847 ("chroma_tmp", chroma_tmp),
848 ] {
849 if *val < 0.0 {
850 return Err(FilterError::InvalidConfig {
851 reason: format!("hqdn3d {name} {val} must be >= 0.0"),
852 });
853 }
854 }
855 }
856 if let FilterStep::Nlmeans { strength } = step
857 && (*strength < 1.0 || *strength > 30.0)
858 {
859 return Err(FilterError::InvalidConfig {
860 reason: format!("nlmeans strength {strength} out of range [1.0, 30.0]"),
861 });
862 }
863 }
864
865 if let Some(fps) = self.input_frame_rate
874 && (!fps.is_finite() || fps < MIN_INPUT_FRAME_RATE)
875 {
876 return Err(FilterError::InvalidConfig {
877 reason: format!(
878 "input_frame_rate {fps} must be finite and >= {MIN_INPUT_FRAME_RATE} \
879 (a smaller rate rounds to the unusable 0/1)"
880 ),
881 });
882 }
883 if self.input_frame_rate.is_none()
887 && self
888 .steps
889 .iter()
890 .any(|s| matches!(s, FilterStep::XFade { .. }))
891 {
892 return Err(FilterError::InvalidConfig {
893 reason: "xfade requires a constant frame rate: call input_frame_rate() \
894 with the rate of the pushed frames"
895 .to_string(),
896 });
897 }
898
899 crate::filter_inner::validate_composite_ops(&self.steps)?;
902 crate::filter_inner::validate_filter_steps(&self.steps)?;
903 crate::filter_inner::validate_parse_descs(&self.steps)?;
904 let output_resolution = self.steps.iter().rev().find_map(|s| {
905 if let FilterStep::Scale { width, height, .. } = s {
906 Some((*width, *height))
907 } else {
908 None
909 }
910 });
911 Ok(FilterGraph {
912 inner: FilterGraphInner::new(self.steps, self.hw, self.input_frame_rate),
913 output_resolution,
914 pending_animations: self.animations,
915 })
916 }
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use ff_format::AlphaMode;
923
924 #[test]
925 fn builder_empty_steps_should_return_error() {
926 let result = FilterGraph::builder().build();
927 assert!(
928 matches!(result, Err(FilterError::BuildFailed)),
929 "expected BuildFailed, got {result:?}"
930 );
931 }
932
933 #[test]
934 fn builder_steps_should_accumulate_in_order() {
935 let result = FilterGraph::builder()
936 .trim(0.0, 5.0)
937 .scale(1280, 720, ScaleAlgorithm::Fast)
938 .volume(-3.0)
939 .build();
940 assert!(
941 result.is_ok(),
942 "builder with multiple valid steps must succeed, got {result:?}"
943 );
944 }
945
946 #[test]
947 fn builder_with_valid_steps_should_succeed() {
948 let result = FilterGraph::builder()
949 .scale(1280, 720, ScaleAlgorithm::Fast)
950 .build();
951 assert!(
952 result.is_ok(),
953 "builder with a known filter step must succeed, got {result:?}"
954 );
955 }
956
957 #[test]
958 fn output_resolution_should_be_none_when_no_scale() {
959 let fg = FilterGraph::builder().trim(0.0, 5.0).build().unwrap();
960 assert_eq!(fg.output_resolution(), None);
961 }
962
963 #[test]
964 fn output_resolution_should_be_last_scale_dimensions() {
965 let fg = FilterGraph::builder()
966 .scale(1280, 720, ScaleAlgorithm::Fast)
967 .build()
968 .unwrap();
969 assert_eq!(fg.output_resolution(), Some((1280, 720)));
970 }
971
972 #[test]
973 fn output_resolution_should_use_last_scale_when_multiple_present() {
974 let fg = FilterGraph::builder()
975 .scale(1920, 1080, ScaleAlgorithm::Fast)
976 .scale(1280, 720, ScaleAlgorithm::Bicubic)
977 .build()
978 .unwrap();
979 assert_eq!(fg.output_resolution(), Some((1280, 720)));
980 }
981
982 #[test]
983 fn rgb_neutral_constant_should_have_all_channels_one() {
984 assert_eq!(Rgb::NEUTRAL.r, 1.0);
985 assert_eq!(Rgb::NEUTRAL.g, 1.0);
986 assert_eq!(Rgb::NEUTRAL.b, 1.0);
987 }
988
989 #[test]
992 fn blend_normal_full_opacity_should_use_overlay_filter() {
993 let top = FilterGraphBuilder::new().trim(0.0, 5.0);
996 let result = FilterGraph::builder()
997 .trim(0.0, 5.0)
998 .blend(top, BlendMode::Normal, 1.0, AlphaMode::Straight)
999 .build();
1000 assert!(
1001 result.is_ok(),
1002 "blend(Normal, opacity=1.0) must build successfully, got {result:?}"
1003 );
1004 }
1005
1006 #[test]
1007 fn blend_normal_half_opacity_should_apply_colorchannelmixer() {
1008 let top = FilterGraphBuilder::new().trim(0.0, 5.0);
1011 let result = FilterGraph::builder()
1012 .trim(0.0, 5.0)
1013 .blend(top, BlendMode::Normal, 0.5, AlphaMode::Straight)
1014 .build();
1015 assert!(
1016 result.is_ok(),
1017 "blend(Normal, opacity=0.5) must build successfully, got {result:?}"
1018 );
1019 }
1020
1021 #[test]
1022 fn blend_opacity_above_one_should_be_clamped_to_one() {
1023 let top = FilterGraphBuilder::new().trim(0.0, 5.0);
1025 let result = FilterGraph::builder()
1026 .trim(0.0, 5.0)
1027 .blend(top, BlendMode::Normal, 2.5, AlphaMode::Straight)
1028 .build();
1029 assert!(
1030 result.is_ok(),
1031 "blend with opacity=2.5 must clamp to 1.0 and build successfully, got {result:?}"
1032 );
1033 }
1034
1035 #[test]
1036 fn colorkey_out_of_range_similarity_should_return_invalid_config() {
1037 let result = FilterGraph::builder()
1038 .trim(0.0, 5.0)
1039 .colorkey("green", 1.5, 0.0)
1040 .build();
1041 assert!(
1042 matches!(result, Err(FilterError::InvalidConfig { .. })),
1043 "colorkey similarity > 1.0 must return InvalidConfig, got {result:?}"
1044 );
1045 }
1046
1047 #[test]
1048 fn colorkey_out_of_range_blend_should_return_invalid_config() {
1049 let result = FilterGraph::builder()
1050 .trim(0.0, 5.0)
1051 .colorkey("green", 0.3, -0.1)
1052 .build();
1053 assert!(
1054 matches!(result, Err(FilterError::InvalidConfig { .. })),
1055 "colorkey blend < 0.0 must return InvalidConfig, got {result:?}"
1056 );
1057 }
1058
1059 #[test]
1060 fn lumakey_out_of_range_threshold_should_return_invalid_config() {
1061 let result = FilterGraph::builder()
1062 .trim(0.0, 5.0)
1063 .lumakey(1.5, 0.1, 0.0, false)
1064 .build();
1065 assert!(
1066 matches!(result, Err(FilterError::InvalidConfig { .. })),
1067 "lumakey threshold > 1.0 must return InvalidConfig, got {result:?}"
1068 );
1069 }
1070
1071 #[test]
1072 fn lumakey_out_of_range_tolerance_should_return_invalid_config() {
1073 let result = FilterGraph::builder()
1074 .trim(0.0, 5.0)
1075 .lumakey(0.9, -0.1, 0.0, false)
1076 .build();
1077 assert!(
1078 matches!(result, Err(FilterError::InvalidConfig { .. })),
1079 "lumakey tolerance < 0.0 must return InvalidConfig, got {result:?}"
1080 );
1081 }
1082
1083 #[test]
1084 fn lumakey_out_of_range_softness_should_return_invalid_config() {
1085 let result = FilterGraph::builder()
1086 .trim(0.0, 5.0)
1087 .lumakey(0.9, 0.1, 1.5, false)
1088 .build();
1089 assert!(
1090 matches!(result, Err(FilterError::InvalidConfig { .. })),
1091 "lumakey softness > 1.0 must return InvalidConfig, got {result:?}"
1092 );
1093 }
1094
1095 #[test]
1096 fn spill_suppress_out_of_range_strength_should_return_invalid_config() {
1097 let result = FilterGraph::builder()
1098 .trim(0.0, 5.0)
1099 .spill_suppress("green", 1.5)
1100 .build();
1101 assert!(
1102 matches!(result, Err(FilterError::InvalidConfig { .. })),
1103 "spill_suppress strength > 1.0 must return InvalidConfig, got {result:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn spill_suppress_negative_strength_should_return_invalid_config() {
1109 let result = FilterGraph::builder()
1110 .trim(0.0, 5.0)
1111 .spill_suppress("green", -0.1)
1112 .build();
1113 assert!(
1114 matches!(result, Err(FilterError::InvalidConfig { .. })),
1115 "spill_suppress strength < 0.0 must return InvalidConfig, got {result:?}"
1116 );
1117 }
1118
1119 #[test]
1120 fn feather_mask_zero_radius_should_return_invalid_config() {
1121 let result = FilterGraph::builder()
1122 .trim(0.0, 5.0)
1123 .feather_mask(0)
1124 .build();
1125 assert!(
1126 matches!(result, Err(FilterError::InvalidConfig { .. })),
1127 "feather_mask radius=0 must return InvalidConfig, got {result:?}"
1128 );
1129 }
1130
1131 #[test]
1132 fn rect_mask_zero_width_should_return_invalid_config() {
1133 let result = FilterGraph::builder()
1134 .trim(0.0, 5.0)
1135 .rect_mask(0, 0, 0, 32, false)
1136 .build();
1137 assert!(
1138 matches!(result, Err(FilterError::InvalidConfig { .. })),
1139 "rect_mask width=0 must return InvalidConfig, got {result:?}"
1140 );
1141 }
1142
1143 #[test]
1144 fn rect_mask_zero_height_should_return_invalid_config() {
1145 let result = FilterGraph::builder()
1146 .trim(0.0, 5.0)
1147 .rect_mask(0, 0, 32, 0, false)
1148 .build();
1149 assert!(
1150 matches!(result, Err(FilterError::InvalidConfig { .. })),
1151 "rect_mask height=0 must return InvalidConfig, got {result:?}"
1152 );
1153 }
1154
1155 #[test]
1156 fn polygon_matte_fewer_than_3_vertices_should_return_invalid_config() {
1157 let result = FilterGraph::builder()
1158 .trim(0.0, 5.0)
1159 .polygon_matte(vec![(0.0, 0.0), (1.0, 0.0)], false)
1160 .build();
1161 assert!(
1162 matches!(result, Err(FilterError::InvalidConfig { .. })),
1163 "polygon_matte with < 3 vertices must return InvalidConfig, got {result:?}"
1164 );
1165 }
1166
1167 #[test]
1168 fn polygon_matte_more_than_16_vertices_should_return_invalid_config() {
1169 let verts = (0..17)
1170 .map(|i| {
1171 let angle = i as f32 * 2.0 * std::f32::consts::PI / 17.0;
1172 (0.5 + 0.4 * angle.cos(), 0.5 + 0.4 * angle.sin())
1173 })
1174 .collect();
1175 let result = FilterGraph::builder()
1176 .trim(0.0, 5.0)
1177 .polygon_matte(verts, false)
1178 .build();
1179 assert!(
1180 matches!(result, Err(FilterError::InvalidConfig { .. })),
1181 "polygon_matte with > 16 vertices must return InvalidConfig, got {result:?}"
1182 );
1183 }
1184
1185 #[test]
1186 fn polygon_matte_out_of_range_vertex_should_return_invalid_config() {
1187 let result = FilterGraph::builder()
1188 .trim(0.0, 5.0)
1189 .polygon_matte(vec![(0.0, 0.0), (1.5, 0.0), (0.0, 1.0)], false)
1190 .build();
1191 assert!(
1192 matches!(result, Err(FilterError::InvalidConfig { .. })),
1193 "polygon_matte with vertex x > 1.0 must return InvalidConfig, got {result:?}"
1194 );
1195 }
1196
1197 #[test]
1198 fn polygon_matte_geq_uses_valid_constants_and_no_bare_star_minus() {
1199 let steps = FilterGraph::builder()
1205 .polygon_matte(vec![(0.2, 0.1), (0.9, 0.5), (0.3, 0.9)], false)
1206 .steps()
1207 .to_vec();
1208 let args = steps[0].args();
1209 assert!(
1210 !args.contains("iw") && !args.contains("ih"),
1211 "geq must use W/H, not iw/ih: {args}"
1212 );
1213 assert!(
1214 args.contains("*W") && args.contains("*H"),
1215 "geq should use W/H: {args}"
1216 );
1217 assert!(
1218 !args.contains("*-") && !args.contains("/-"),
1219 "geq expression must not contain a bare '*-'/'/-': {args}"
1220 );
1221 }
1222
1223 #[test]
1224 fn chromakey_out_of_range_similarity_should_return_invalid_config() {
1225 let result = FilterGraph::builder()
1226 .trim(0.0, 5.0)
1227 .chromakey("green", 1.5, 0.0)
1228 .build();
1229 assert!(
1230 matches!(result, Err(FilterError::InvalidConfig { .. })),
1231 "chromakey similarity > 1.0 must return InvalidConfig, got {result:?}"
1232 );
1233 }
1234
1235 #[test]
1236 fn chromakey_out_of_range_blend_should_return_invalid_config() {
1237 let result = FilterGraph::builder()
1238 .trim(0.0, 5.0)
1239 .chromakey("green", 0.3, -0.1)
1240 .build();
1241 assert!(
1242 matches!(result, Err(FilterError::InvalidConfig { .. })),
1243 "chromakey blend < 0.0 must return InvalidConfig, got {result:?}"
1244 );
1245 }
1246
1247 #[test]
1248 fn parse_desc_should_append_a_step_carrying_the_description_verbatim() {
1249 let desc = "split[a][b];[a]hue=s=0[c];[b][c]overlay";
1254 let builder = FilterGraph::builder()
1255 .scale(1280, 720, ScaleAlgorithm::Fast)
1256 .parse_desc(desc);
1257
1258 let steps = builder.steps();
1259 assert_eq!(steps.len(), 2, "parse_desc must append, not replace");
1260 assert!(
1261 matches!(&steps[0], FilterStep::Scale { .. }),
1262 "the typed step must keep its position, got {:?}",
1263 steps[0]
1264 );
1265 match &steps[1] {
1266 FilterStep::ParseDesc { desc: recorded } => assert_eq!(
1267 recorded, desc,
1268 "the description must be carried through unchanged"
1269 ),
1270 other => panic!("expected a ParseDesc step, got {other:?}"),
1271 }
1272 }
1273}