1use std::path::Path;
4
5pub(super) use super::FilterGraph;
6pub(super) use super::filter_step::FilterStep;
7pub(super) use super::types::{
8 DrawTextOptions, EqBand, HwAccel, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
9};
10pub(super) use crate::blend::BlendMode;
11pub(super) use crate::error::FilterError;
12use crate::filter_inner::FilterGraphInner;
13
14mod audio;
15mod video;
16
17#[derive(Debug, Default, Clone)]
35pub struct FilterGraphBuilder {
36 pub(super) steps: Vec<FilterStep>,
37 pub(super) hw: Option<HwAccel>,
38}
39
40impl FilterGraphBuilder {
41 #[must_use]
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub(crate) fn steps(&self) -> &[FilterStep] {
52 &self.steps
53 }
54
55 #[must_use]
60 pub fn hardware(mut self, hw: HwAccel) -> Self {
61 self.hw = Some(hw);
62 self
63 }
64
65 pub fn build(self) -> Result<FilterGraph, FilterError> {
76 if self.steps.is_empty() {
77 return Err(FilterError::BuildFailed);
78 }
79
80 for step in &self.steps {
85 if let FilterStep::ParametricEq { bands } = step
86 && bands.is_empty()
87 {
88 return Err(FilterError::InvalidConfig {
89 reason: "equalizer bands must not be empty".to_string(),
90 });
91 }
92 if let FilterStep::Speed { factor } = step
93 && !(0.1..=100.0).contains(factor)
94 {
95 return Err(FilterError::InvalidConfig {
96 reason: format!("speed factor {factor} out of range [0.1, 100.0]"),
97 });
98 }
99 if let FilterStep::LoudnessNormalize {
100 target_lufs,
101 true_peak_db,
102 lra,
103 } = step
104 {
105 if *target_lufs >= 0.0 {
106 return Err(FilterError::InvalidConfig {
107 reason: format!(
108 "loudness_normalize target_lufs {target_lufs} must be < 0.0"
109 ),
110 });
111 }
112 if *true_peak_db > 0.0 {
113 return Err(FilterError::InvalidConfig {
114 reason: format!(
115 "loudness_normalize true_peak_db {true_peak_db} must be <= 0.0"
116 ),
117 });
118 }
119 if *lra <= 0.0 {
120 return Err(FilterError::InvalidConfig {
121 reason: format!("loudness_normalize lra {lra} must be > 0.0"),
122 });
123 }
124 }
125 if let FilterStep::NormalizePeak { target_db } = step
126 && *target_db > 0.0
127 {
128 return Err(FilterError::InvalidConfig {
129 reason: format!("normalize_peak target_db {target_db} must be <= 0.0"),
130 });
131 }
132 if let FilterStep::FreezeFrame { pts, duration } = step {
133 if *pts < 0.0 {
134 return Err(FilterError::InvalidConfig {
135 reason: format!("freeze_frame pts {pts} must be >= 0.0"),
136 });
137 }
138 if *duration <= 0.0 {
139 return Err(FilterError::InvalidConfig {
140 reason: format!("freeze_frame duration {duration} must be > 0.0"),
141 });
142 }
143 }
144 if let FilterStep::Crop { width, height, .. } = step
145 && (*width == 0 || *height == 0)
146 {
147 return Err(FilterError::InvalidConfig {
148 reason: "crop width and height must be > 0".to_string(),
149 });
150 }
151 if let FilterStep::FadeIn { duration, .. }
152 | FilterStep::FadeOut { duration, .. }
153 | FilterStep::FadeInWhite { duration, .. }
154 | FilterStep::FadeOutWhite { duration, .. } = step
155 && *duration <= 0.0
156 {
157 return Err(FilterError::InvalidConfig {
158 reason: format!("fade duration {duration} must be > 0.0"),
159 });
160 }
161 if let FilterStep::AFadeIn { duration, .. } | FilterStep::AFadeOut { duration, .. } =
162 step
163 && *duration <= 0.0
164 {
165 return Err(FilterError::InvalidConfig {
166 reason: format!("afade duration {duration} must be > 0.0"),
167 });
168 }
169 if let FilterStep::XFade { duration, .. } = step
170 && *duration <= 0.0
171 {
172 return Err(FilterError::InvalidConfig {
173 reason: format!("xfade duration {duration} must be > 0.0"),
174 });
175 }
176 if let FilterStep::JoinWithDissolve {
177 dissolve_dur,
178 clip_a_end,
179 ..
180 } = step
181 {
182 if *dissolve_dur <= 0.0 {
183 return Err(FilterError::InvalidConfig {
184 reason: format!(
185 "join_with_dissolve dissolve_dur={dissolve_dur} must be > 0.0"
186 ),
187 });
188 }
189 if *clip_a_end <= 0.0 {
190 return Err(FilterError::InvalidConfig {
191 reason: format!("join_with_dissolve clip_a_end={clip_a_end} must be > 0.0"),
192 });
193 }
194 }
195 if let FilterStep::ANoiseGate {
196 attack_ms,
197 release_ms,
198 ..
199 } = step
200 {
201 if *attack_ms <= 0.0 {
202 return Err(FilterError::InvalidConfig {
203 reason: format!("agate attack_ms {attack_ms} must be > 0.0"),
204 });
205 }
206 if *release_ms <= 0.0 {
207 return Err(FilterError::InvalidConfig {
208 reason: format!("agate release_ms {release_ms} must be > 0.0"),
209 });
210 }
211 }
212 if let FilterStep::ACompressor {
213 ratio,
214 attack_ms,
215 release_ms,
216 ..
217 } = step
218 {
219 if *ratio < 1.0 {
220 return Err(FilterError::InvalidConfig {
221 reason: format!("compressor ratio {ratio} must be >= 1.0"),
222 });
223 }
224 if *attack_ms <= 0.0 {
225 return Err(FilterError::InvalidConfig {
226 reason: format!("compressor attack_ms {attack_ms} must be > 0.0"),
227 });
228 }
229 if *release_ms <= 0.0 {
230 return Err(FilterError::InvalidConfig {
231 reason: format!("compressor release_ms {release_ms} must be > 0.0"),
232 });
233 }
234 }
235 if let FilterStep::ChannelMap { mapping } = step
236 && mapping.is_empty()
237 {
238 return Err(FilterError::InvalidConfig {
239 reason: "channel_map mapping must not be empty".to_string(),
240 });
241 }
242 if let FilterStep::ConcatVideo { n } = step
243 && *n < 2
244 {
245 return Err(FilterError::InvalidConfig {
246 reason: format!("concat_video n={n} must be >= 2"),
247 });
248 }
249 if let FilterStep::ConcatAudio { n } = step
250 && *n < 2
251 {
252 return Err(FilterError::InvalidConfig {
253 reason: format!("concat_audio n={n} must be >= 2"),
254 });
255 }
256 if let FilterStep::DrawText { opts } = step {
257 if opts.text.is_empty() {
258 return Err(FilterError::InvalidConfig {
259 reason: "drawtext text must not be empty".to_string(),
260 });
261 }
262 if !(0.0..=1.0).contains(&opts.opacity) {
263 return Err(FilterError::InvalidConfig {
264 reason: format!(
265 "drawtext opacity {} out of range [0.0, 1.0]",
266 opts.opacity
267 ),
268 });
269 }
270 }
271 if let FilterStep::Ticker {
272 text,
273 speed_px_per_sec,
274 ..
275 } = step
276 {
277 if text.is_empty() {
278 return Err(FilterError::InvalidConfig {
279 reason: "ticker text must not be empty".to_string(),
280 });
281 }
282 if *speed_px_per_sec <= 0.0 {
283 return Err(FilterError::InvalidConfig {
284 reason: format!("ticker speed_px_per_sec {speed_px_per_sec} must be > 0.0"),
285 });
286 }
287 }
288 if let FilterStep::Overlay { x, y } = step
289 && (*x < 0 || *y < 0)
290 {
291 return Err(FilterError::InvalidConfig {
292 reason: format!(
293 "overlay position ({x}, {y}) is off-screen; \
294 ensure the watermark fits within the video dimensions"
295 ),
296 });
297 }
298 if let FilterStep::Lut3d { path } = step {
299 let ext = Path::new(path)
300 .extension()
301 .and_then(|e| e.to_str())
302 .unwrap_or("");
303 if !matches!(ext, "cube" | "3dl") {
304 return Err(FilterError::InvalidConfig {
305 reason: format!("unsupported LUT format: .{ext}; expected .cube or .3dl"),
306 });
307 }
308 if !Path::new(path).exists() {
309 return Err(FilterError::InvalidConfig {
310 reason: format!("LUT file not found: {path}"),
311 });
312 }
313 }
314 if let FilterStep::SubtitlesSrt { path } = step {
315 let ext = Path::new(path)
316 .extension()
317 .and_then(|e| e.to_str())
318 .unwrap_or("");
319 if ext != "srt" {
320 return Err(FilterError::InvalidConfig {
321 reason: format!("unsupported subtitle format: .{ext}; expected .srt"),
322 });
323 }
324 if !Path::new(path).exists() {
325 return Err(FilterError::InvalidConfig {
326 reason: format!("subtitle file not found: {path}"),
327 });
328 }
329 }
330 if let FilterStep::SubtitlesAss { path } = step {
331 let ext = Path::new(path)
332 .extension()
333 .and_then(|e| e.to_str())
334 .unwrap_or("");
335 if !matches!(ext, "ass" | "ssa") {
336 return Err(FilterError::InvalidConfig {
337 reason: format!(
338 "unsupported subtitle format: .{ext}; expected .ass or .ssa"
339 ),
340 });
341 }
342 if !Path::new(path).exists() {
343 return Err(FilterError::InvalidConfig {
344 reason: format!("subtitle file not found: {path}"),
345 });
346 }
347 }
348 if let FilterStep::ChromaKey {
349 similarity, blend, ..
350 } = step
351 {
352 if !(0.0..=1.0).contains(similarity) {
353 return Err(FilterError::InvalidConfig {
354 reason: format!(
355 "chromakey similarity {similarity} out of range [0.0, 1.0]"
356 ),
357 });
358 }
359 if !(0.0..=1.0).contains(blend) {
360 return Err(FilterError::InvalidConfig {
361 reason: format!("chromakey blend {blend} out of range [0.0, 1.0]"),
362 });
363 }
364 }
365 if let FilterStep::ColorKey {
366 similarity, blend, ..
367 } = step
368 {
369 if !(0.0..=1.0).contains(similarity) {
370 return Err(FilterError::InvalidConfig {
371 reason: format!("colorkey similarity {similarity} out of range [0.0, 1.0]"),
372 });
373 }
374 if !(0.0..=1.0).contains(blend) {
375 return Err(FilterError::InvalidConfig {
376 reason: format!("colorkey blend {blend} out of range [0.0, 1.0]"),
377 });
378 }
379 }
380 if let FilterStep::SpillSuppress { strength, .. } = step
381 && !(0.0..=1.0).contains(strength)
382 {
383 return Err(FilterError::InvalidConfig {
384 reason: format!("spill_suppress strength {strength} out of range [0.0, 1.0]"),
385 });
386 }
387 if let FilterStep::LumaKey {
388 threshold,
389 tolerance,
390 softness,
391 ..
392 } = step
393 {
394 if !(0.0..=1.0).contains(threshold) {
395 return Err(FilterError::InvalidConfig {
396 reason: format!("lumakey threshold {threshold} out of range [0.0, 1.0]"),
397 });
398 }
399 if !(0.0..=1.0).contains(tolerance) {
400 return Err(FilterError::InvalidConfig {
401 reason: format!("lumakey tolerance {tolerance} out of range [0.0, 1.0]"),
402 });
403 }
404 if !(0.0..=1.0).contains(softness) {
405 return Err(FilterError::InvalidConfig {
406 reason: format!("lumakey softness {softness} out of range [0.0, 1.0]"),
407 });
408 }
409 }
410 if let FilterStep::FeatherMask { radius } = step
411 && *radius == 0
412 {
413 return Err(FilterError::InvalidConfig {
414 reason: "feather_mask radius must be > 0".to_string(),
415 });
416 }
417 if let FilterStep::RectMask { width, height, .. } = step
418 && (*width == 0 || *height == 0)
419 {
420 return Err(FilterError::InvalidConfig {
421 reason: "rect_mask width and height must be > 0".to_string(),
422 });
423 }
424 if let FilterStep::PolygonMatte { vertices, .. } = step {
425 if vertices.len() < 3 {
426 return Err(FilterError::InvalidConfig {
427 reason: format!(
428 "polygon_matte requires at least 3 vertices, got {}",
429 vertices.len()
430 ),
431 });
432 }
433 if vertices.len() > 16 {
434 return Err(FilterError::InvalidConfig {
435 reason: format!(
436 "polygon_matte supports up to 16 vertices, got {}",
437 vertices.len()
438 ),
439 });
440 }
441 for &(x, y) in vertices {
442 if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
443 return Err(FilterError::InvalidConfig {
444 reason: format!(
445 "polygon_matte vertex ({x}, {y}) out of range [0.0, 1.0]"
446 ),
447 });
448 }
449 }
450 }
451 if let FilterStep::OverlayImage { path, opacity, .. } = step {
452 let ext = Path::new(path)
453 .extension()
454 .and_then(|e| e.to_str())
455 .unwrap_or("");
456 if ext != "png" {
457 return Err(FilterError::InvalidConfig {
458 reason: format!("unsupported image format: .{ext}; expected .png"),
459 });
460 }
461 if !(0.0..=1.0).contains(opacity) {
462 return Err(FilterError::InvalidConfig {
463 reason: format!("overlay_image opacity {opacity} out of range [0.0, 1.0]"),
464 });
465 }
466 if !Path::new(path).exists() {
467 return Err(FilterError::InvalidConfig {
468 reason: format!("overlay image not found: {path}"),
469 });
470 }
471 }
472 if let FilterStep::Eq {
473 brightness,
474 contrast,
475 saturation,
476 } = step
477 {
478 if !(-1.0..=1.0).contains(brightness) {
479 return Err(FilterError::InvalidConfig {
480 reason: format!("eq brightness {brightness} out of range [-1.0, 1.0]"),
481 });
482 }
483 if !(0.0..=3.0).contains(contrast) {
484 return Err(FilterError::InvalidConfig {
485 reason: format!("eq contrast {contrast} out of range [0.0, 3.0]"),
486 });
487 }
488 if !(0.0..=3.0).contains(saturation) {
489 return Err(FilterError::InvalidConfig {
490 reason: format!("eq saturation {saturation} out of range [0.0, 3.0]"),
491 });
492 }
493 }
494 if let FilterStep::Curves { master, r, g, b } = step {
495 for (channel, pts) in [
496 ("master", master.as_slice()),
497 ("r", r.as_slice()),
498 ("g", g.as_slice()),
499 ("b", b.as_slice()),
500 ] {
501 for &(x, y) in pts {
502 if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
503 return Err(FilterError::InvalidConfig {
504 reason: format!(
505 "curves {channel} control point ({x}, {y}) out of range [0.0, 1.0]"
506 ),
507 });
508 }
509 }
510 }
511 }
512 if let FilterStep::WhiteBalance {
513 temperature_k,
514 tint,
515 } = step
516 {
517 if !(1000..=40000).contains(temperature_k) {
518 return Err(FilterError::InvalidConfig {
519 reason: format!(
520 "white_balance temperature_k {temperature_k} out of range [1000, 40000]"
521 ),
522 });
523 }
524 if !(-1.0..=1.0).contains(tint) {
525 return Err(FilterError::InvalidConfig {
526 reason: format!("white_balance tint {tint} out of range [-1.0, 1.0]"),
527 });
528 }
529 }
530 if let FilterStep::Hue { degrees } = step
531 && !(-360.0..=360.0).contains(degrees)
532 {
533 return Err(FilterError::InvalidConfig {
534 reason: format!("hue degrees {degrees} out of range [-360.0, 360.0]"),
535 });
536 }
537 if let FilterStep::Gamma { r, g, b } = step {
538 for (channel, val) in [("r", r), ("g", g), ("b", b)] {
539 if !(0.1..=10.0).contains(val) {
540 return Err(FilterError::InvalidConfig {
541 reason: format!("gamma {channel} {val} out of range [0.1, 10.0]"),
542 });
543 }
544 }
545 }
546 if let FilterStep::ThreeWayCC { gamma, .. } = step {
547 for (channel, val) in [("r", gamma.r), ("g", gamma.g), ("b", gamma.b)] {
548 if val <= 0.0 {
549 return Err(FilterError::InvalidConfig {
550 reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
551 });
552 }
553 }
554 }
555 if let FilterStep::Vignette { angle, .. } = step
556 && !((0.0)..=std::f32::consts::FRAC_PI_2).contains(angle)
557 {
558 return Err(FilterError::InvalidConfig {
559 reason: format!("vignette angle {angle} out of range [0.0, π/2]"),
560 });
561 }
562 if let FilterStep::Pad { width, height, .. } = step
563 && (*width == 0 || *height == 0)
564 {
565 return Err(FilterError::InvalidConfig {
566 reason: "pad width and height must be > 0".to_string(),
567 });
568 }
569 if let FilterStep::FitToAspect { width, height, .. } = step
570 && (*width == 0 || *height == 0)
571 {
572 return Err(FilterError::InvalidConfig {
573 reason: "fit_to_aspect width and height must be > 0".to_string(),
574 });
575 }
576 if let FilterStep::GBlur { sigma } = step
577 && *sigma < 0.0
578 {
579 return Err(FilterError::InvalidConfig {
580 reason: format!("gblur sigma {sigma} must be >= 0.0"),
581 });
582 }
583 if let FilterStep::Unsharp {
584 luma_strength,
585 chroma_strength,
586 } = step
587 {
588 if !(-1.5..=1.5).contains(luma_strength) {
589 return Err(FilterError::InvalidConfig {
590 reason: format!(
591 "unsharp luma_strength {luma_strength} out of range [-1.5, 1.5]"
592 ),
593 });
594 }
595 if !(-1.5..=1.5).contains(chroma_strength) {
596 return Err(FilterError::InvalidConfig {
597 reason: format!(
598 "unsharp chroma_strength {chroma_strength} out of range [-1.5, 1.5]"
599 ),
600 });
601 }
602 }
603 if let FilterStep::Hqdn3d {
604 luma_spatial,
605 chroma_spatial,
606 luma_tmp,
607 chroma_tmp,
608 } = step
609 {
610 for (name, val) in [
611 ("luma_spatial", luma_spatial),
612 ("chroma_spatial", chroma_spatial),
613 ("luma_tmp", luma_tmp),
614 ("chroma_tmp", chroma_tmp),
615 ] {
616 if *val < 0.0 {
617 return Err(FilterError::InvalidConfig {
618 reason: format!("hqdn3d {name} {val} must be >= 0.0"),
619 });
620 }
621 }
622 }
623 if let FilterStep::Nlmeans { strength } = step
624 && (*strength < 1.0 || *strength > 30.0)
625 {
626 return Err(FilterError::InvalidConfig {
627 reason: format!("nlmeans strength {strength} out of range [1.0, 30.0]"),
628 });
629 }
630 }
631
632 crate::filter_inner::validate_filter_steps(&self.steps)?;
633 let output_resolution = self.steps.iter().rev().find_map(|s| {
634 if let FilterStep::Scale { width, height, .. } = s {
635 Some((*width, *height))
636 } else {
637 None
638 }
639 });
640 Ok(FilterGraph {
641 inner: FilterGraphInner::new(self.steps, self.hw),
642 output_resolution,
643 })
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn builder_empty_steps_should_return_error() {
653 let result = FilterGraph::builder().build();
654 assert!(
655 matches!(result, Err(FilterError::BuildFailed)),
656 "expected BuildFailed, got {result:?}"
657 );
658 }
659
660 #[test]
661 fn builder_steps_should_accumulate_in_order() {
662 let result = FilterGraph::builder()
663 .trim(0.0, 5.0)
664 .scale(1280, 720, ScaleAlgorithm::Fast)
665 .volume(-3.0)
666 .build();
667 assert!(
668 result.is_ok(),
669 "builder with multiple valid steps must succeed, got {result:?}"
670 );
671 }
672
673 #[test]
674 fn builder_with_valid_steps_should_succeed() {
675 let result = FilterGraph::builder()
676 .scale(1280, 720, ScaleAlgorithm::Fast)
677 .build();
678 assert!(
679 result.is_ok(),
680 "builder with a known filter step must succeed, got {result:?}"
681 );
682 }
683
684 #[test]
685 fn output_resolution_should_be_none_when_no_scale() {
686 let fg = FilterGraph::builder().trim(0.0, 5.0).build().unwrap();
687 assert_eq!(fg.output_resolution(), None);
688 }
689
690 #[test]
691 fn output_resolution_should_be_last_scale_dimensions() {
692 let fg = FilterGraph::builder()
693 .scale(1280, 720, ScaleAlgorithm::Fast)
694 .build()
695 .unwrap();
696 assert_eq!(fg.output_resolution(), Some((1280, 720)));
697 }
698
699 #[test]
700 fn output_resolution_should_use_last_scale_when_multiple_present() {
701 let fg = FilterGraph::builder()
702 .scale(1920, 1080, ScaleAlgorithm::Fast)
703 .scale(1280, 720, ScaleAlgorithm::Bicubic)
704 .build()
705 .unwrap();
706 assert_eq!(fg.output_resolution(), Some((1280, 720)));
707 }
708
709 #[test]
710 fn rgb_neutral_constant_should_have_all_channels_one() {
711 assert_eq!(Rgb::NEUTRAL.r, 1.0);
712 assert_eq!(Rgb::NEUTRAL.g, 1.0);
713 assert_eq!(Rgb::NEUTRAL.b, 1.0);
714 }
715
716 #[test]
719 fn blend_normal_full_opacity_should_use_overlay_filter() {
720 let top = FilterGraphBuilder::new().trim(0.0, 5.0);
723 let result = FilterGraph::builder()
724 .trim(0.0, 5.0)
725 .blend(top, BlendMode::Normal, 1.0)
726 .build();
727 assert!(
728 result.is_ok(),
729 "blend(Normal, opacity=1.0) must build successfully, got {result:?}"
730 );
731 }
732
733 #[test]
734 fn blend_normal_half_opacity_should_apply_colorchannelmixer() {
735 let top = FilterGraphBuilder::new().trim(0.0, 5.0);
738 let result = FilterGraph::builder()
739 .trim(0.0, 5.0)
740 .blend(top, BlendMode::Normal, 0.5)
741 .build();
742 assert!(
743 result.is_ok(),
744 "blend(Normal, opacity=0.5) must build successfully, got {result:?}"
745 );
746 }
747
748 #[test]
749 fn blend_opacity_above_one_should_be_clamped_to_one() {
750 let top = FilterGraphBuilder::new().trim(0.0, 5.0);
752 let result = FilterGraph::builder()
753 .trim(0.0, 5.0)
754 .blend(top, BlendMode::Normal, 2.5)
755 .build();
756 assert!(
757 result.is_ok(),
758 "blend with opacity=2.5 must clamp to 1.0 and build successfully, got {result:?}"
759 );
760 }
761
762 #[test]
763 fn colorkey_out_of_range_similarity_should_return_invalid_config() {
764 let result = FilterGraph::builder()
765 .trim(0.0, 5.0)
766 .colorkey("green", 1.5, 0.0)
767 .build();
768 assert!(
769 matches!(result, Err(FilterError::InvalidConfig { .. })),
770 "colorkey similarity > 1.0 must return InvalidConfig, got {result:?}"
771 );
772 }
773
774 #[test]
775 fn colorkey_out_of_range_blend_should_return_invalid_config() {
776 let result = FilterGraph::builder()
777 .trim(0.0, 5.0)
778 .colorkey("green", 0.3, -0.1)
779 .build();
780 assert!(
781 matches!(result, Err(FilterError::InvalidConfig { .. })),
782 "colorkey blend < 0.0 must return InvalidConfig, got {result:?}"
783 );
784 }
785
786 #[test]
787 fn lumakey_out_of_range_threshold_should_return_invalid_config() {
788 let result = FilterGraph::builder()
789 .trim(0.0, 5.0)
790 .lumakey(1.5, 0.1, 0.0, false)
791 .build();
792 assert!(
793 matches!(result, Err(FilterError::InvalidConfig { .. })),
794 "lumakey threshold > 1.0 must return InvalidConfig, got {result:?}"
795 );
796 }
797
798 #[test]
799 fn lumakey_out_of_range_tolerance_should_return_invalid_config() {
800 let result = FilterGraph::builder()
801 .trim(0.0, 5.0)
802 .lumakey(0.9, -0.1, 0.0, false)
803 .build();
804 assert!(
805 matches!(result, Err(FilterError::InvalidConfig { .. })),
806 "lumakey tolerance < 0.0 must return InvalidConfig, got {result:?}"
807 );
808 }
809
810 #[test]
811 fn lumakey_out_of_range_softness_should_return_invalid_config() {
812 let result = FilterGraph::builder()
813 .trim(0.0, 5.0)
814 .lumakey(0.9, 0.1, 1.5, false)
815 .build();
816 assert!(
817 matches!(result, Err(FilterError::InvalidConfig { .. })),
818 "lumakey softness > 1.0 must return InvalidConfig, got {result:?}"
819 );
820 }
821
822 #[test]
823 fn spill_suppress_out_of_range_strength_should_return_invalid_config() {
824 let result = FilterGraph::builder()
825 .trim(0.0, 5.0)
826 .spill_suppress("green", 1.5)
827 .build();
828 assert!(
829 matches!(result, Err(FilterError::InvalidConfig { .. })),
830 "spill_suppress strength > 1.0 must return InvalidConfig, got {result:?}"
831 );
832 }
833
834 #[test]
835 fn spill_suppress_negative_strength_should_return_invalid_config() {
836 let result = FilterGraph::builder()
837 .trim(0.0, 5.0)
838 .spill_suppress("green", -0.1)
839 .build();
840 assert!(
841 matches!(result, Err(FilterError::InvalidConfig { .. })),
842 "spill_suppress strength < 0.0 must return InvalidConfig, got {result:?}"
843 );
844 }
845
846 #[test]
847 fn feather_mask_zero_radius_should_return_invalid_config() {
848 let result = FilterGraph::builder()
849 .trim(0.0, 5.0)
850 .feather_mask(0)
851 .build();
852 assert!(
853 matches!(result, Err(FilterError::InvalidConfig { .. })),
854 "feather_mask radius=0 must return InvalidConfig, got {result:?}"
855 );
856 }
857
858 #[test]
859 fn rect_mask_zero_width_should_return_invalid_config() {
860 let result = FilterGraph::builder()
861 .trim(0.0, 5.0)
862 .rect_mask(0, 0, 0, 32, false)
863 .build();
864 assert!(
865 matches!(result, Err(FilterError::InvalidConfig { .. })),
866 "rect_mask width=0 must return InvalidConfig, got {result:?}"
867 );
868 }
869
870 #[test]
871 fn rect_mask_zero_height_should_return_invalid_config() {
872 let result = FilterGraph::builder()
873 .trim(0.0, 5.0)
874 .rect_mask(0, 0, 32, 0, false)
875 .build();
876 assert!(
877 matches!(result, Err(FilterError::InvalidConfig { .. })),
878 "rect_mask height=0 must return InvalidConfig, got {result:?}"
879 );
880 }
881
882 #[test]
883 fn polygon_matte_fewer_than_3_vertices_should_return_invalid_config() {
884 let result = FilterGraph::builder()
885 .trim(0.0, 5.0)
886 .polygon_matte(vec![(0.0, 0.0), (1.0, 0.0)], false)
887 .build();
888 assert!(
889 matches!(result, Err(FilterError::InvalidConfig { .. })),
890 "polygon_matte with < 3 vertices must return InvalidConfig, got {result:?}"
891 );
892 }
893
894 #[test]
895 fn polygon_matte_more_than_16_vertices_should_return_invalid_config() {
896 let verts = (0..17)
897 .map(|i| {
898 let angle = i as f32 * 2.0 * std::f32::consts::PI / 17.0;
899 (0.5 + 0.4 * angle.cos(), 0.5 + 0.4 * angle.sin())
900 })
901 .collect();
902 let result = FilterGraph::builder()
903 .trim(0.0, 5.0)
904 .polygon_matte(verts, false)
905 .build();
906 assert!(
907 matches!(result, Err(FilterError::InvalidConfig { .. })),
908 "polygon_matte with > 16 vertices must return InvalidConfig, got {result:?}"
909 );
910 }
911
912 #[test]
913 fn polygon_matte_out_of_range_vertex_should_return_invalid_config() {
914 let result = FilterGraph::builder()
915 .trim(0.0, 5.0)
916 .polygon_matte(vec![(0.0, 0.0), (1.5, 0.0), (0.0, 1.0)], false)
917 .build();
918 assert!(
919 matches!(result, Err(FilterError::InvalidConfig { .. })),
920 "polygon_matte with vertex x > 1.0 must return InvalidConfig, got {result:?}"
921 );
922 }
923
924 #[test]
925 fn chromakey_out_of_range_similarity_should_return_invalid_config() {
926 let result = FilterGraph::builder()
927 .trim(0.0, 5.0)
928 .chromakey("green", 1.5, 0.0)
929 .build();
930 assert!(
931 matches!(result, Err(FilterError::InvalidConfig { .. })),
932 "chromakey similarity > 1.0 must return InvalidConfig, got {result:?}"
933 );
934 }
935
936 #[test]
937 fn chromakey_out_of_range_blend_should_return_invalid_config() {
938 let result = FilterGraph::builder()
939 .trim(0.0, 5.0)
940 .chromakey("green", 0.3, -0.1)
941 .build();
942 assert!(
943 matches!(result, Err(FilterError::InvalidConfig { .. })),
944 "chromakey blend < 0.0 must return InvalidConfig, got {result:?}"
945 );
946 }
947}