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