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