Skip to main content

ff_filter/graph/builder/
video.rs

1//! Video filter methods for [`FilterGraphBuilder`].
2
3#[allow(clippy::wildcard_imports)]
4use super::*;
5
6impl FilterGraphBuilder {
7    // ── Video filters ─────────────────────────────────────────────────────────
8
9    /// Trim the stream to the half-open interval `[start, end)` in seconds.
10    #[must_use]
11    pub fn trim(mut self, start: f64, end: f64) -> Self {
12        self.steps.push(FilterStep::Trim { start, end });
13        self
14    }
15
16    /// Scale the video to `width × height` pixels using the given resampling
17    /// `algorithm`.
18    ///
19    /// Use [`ScaleAlgorithm::Fast`] for the best speed/quality trade-off.
20    /// For highest quality use [`ScaleAlgorithm::Lanczos`] at the cost of
21    /// additional CPU time.
22    #[must_use]
23    pub fn scale(mut self, width: u32, height: u32, algorithm: ScaleAlgorithm) -> Self {
24        self.steps.push(FilterStep::Scale {
25            width,
26            height,
27            algorithm,
28        });
29        self
30    }
31
32    /// Crop a rectangle starting at `(x, y)` with the given dimensions.
33    #[must_use]
34    pub fn crop(mut self, x: u32, y: u32, width: u32, height: u32) -> Self {
35        self.steps.push(FilterStep::Crop {
36            x,
37            y,
38            width,
39            height,
40        });
41        self
42    }
43
44    /// Overlay a second input stream at position `(x, y)`.
45    #[must_use]
46    pub fn overlay(mut self, x: i32, y: i32) -> Self {
47        self.steps.push(FilterStep::Overlay { x, y });
48        self
49    }
50
51    /// Fade in from black, starting at `start_sec` seconds and reaching full
52    /// brightness after `duration_sec` seconds.
53    #[must_use]
54    pub fn fade_in(mut self, start_sec: f64, duration_sec: f64) -> Self {
55        self.steps.push(FilterStep::FadeIn {
56            start: start_sec,
57            duration: duration_sec,
58        });
59        self
60    }
61
62    /// Fade out to black, starting at `start_sec` seconds and reaching full
63    /// black after `duration_sec` seconds.
64    #[must_use]
65    pub fn fade_out(mut self, start_sec: f64, duration_sec: f64) -> Self {
66        self.steps.push(FilterStep::FadeOut {
67            start: start_sec,
68            duration: duration_sec,
69        });
70        self
71    }
72
73    /// Fade in from white, starting at `start_sec` seconds and reaching full
74    /// brightness after `duration_sec` seconds.
75    #[must_use]
76    pub fn fade_in_white(mut self, start_sec: f64, duration_sec: f64) -> Self {
77        self.steps.push(FilterStep::FadeInWhite {
78            start: start_sec,
79            duration: duration_sec,
80        });
81        self
82    }
83
84    /// Fade out to white, starting at `start_sec` seconds and reaching full
85    /// white after `duration_sec` seconds.
86    #[must_use]
87    pub fn fade_out_white(mut self, start_sec: f64, duration_sec: f64) -> Self {
88        self.steps.push(FilterStep::FadeOutWhite {
89            start: start_sec,
90            duration: duration_sec,
91        });
92        self
93    }
94
95    /// Rotate the video clockwise by `angle_degrees`, filling exposed corners
96    /// with `fill_color`.
97    ///
98    /// `fill_color` accepts any color string understood by `FFmpeg` — for example
99    /// `"black"`, `"white"`, `"0x00000000"` (transparent), or `"gray"`.
100    /// Pass `"black"` to reproduce the classic solid-background rotation.
101    #[must_use]
102    pub fn rotate(mut self, angle_degrees: f64, fill_color: &str) -> Self {
103        self.steps.push(FilterStep::Rotate {
104            angle_degrees,
105            fill_color: fill_color.to_owned(),
106        });
107        self
108    }
109
110    /// Apply HDR-to-SDR tone mapping using the given `algorithm`.
111    #[must_use]
112    pub fn tone_map(mut self, algorithm: ToneMap) -> Self {
113        self.steps.push(FilterStep::ToneMap(algorithm));
114        self
115    }
116
117    /// Apply a 3D LUT colour grade from a `.cube` or `.3dl` file.
118    ///
119    /// Uses `FFmpeg`'s `lut3d` filter with trilinear interpolation.
120    ///
121    /// # Validation
122    ///
123    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if:
124    /// - the extension is not `.cube` or `.3dl`, or
125    /// - the file does not exist at build time.
126    #[must_use]
127    pub fn lut3d(mut self, path: &str) -> Self {
128        self.steps.push(FilterStep::Lut3d {
129            path: path.to_owned(),
130        });
131        self
132    }
133
134    /// Adjust brightness, contrast, and saturation using `FFmpeg`'s `eq` filter.
135    ///
136    /// Valid ranges:
137    /// - `brightness`: −1.0 – 1.0 (neutral: 0.0)
138    /// - `contrast`: 0.0 – 3.0 (neutral: 1.0)
139    /// - `saturation`: 0.0 – 3.0 (neutral: 1.0; 0.0 = grayscale)
140    ///
141    /// # Validation
142    ///
143    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if any
144    /// value is outside its valid range.
145    #[must_use]
146    pub fn eq(mut self, brightness: f32, contrast: f32, saturation: f32) -> Self {
147        self.steps.push(FilterStep::Eq {
148            brightness,
149            contrast,
150            saturation,
151        });
152        self
153    }
154
155    /// Apply per-channel RGB color curves using `FFmpeg`'s `curves` filter.
156    ///
157    /// Each argument is a list of `(input, output)` control points in `[0.0, 1.0]`.
158    /// Pass an empty `Vec` for any channel that needs no adjustment.
159    ///
160    /// # Validation
161    ///
162    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if any
163    /// control point coordinate is outside `[0.0, 1.0]`.
164    #[must_use]
165    pub fn curves(
166        mut self,
167        master: Vec<(f32, f32)>,
168        r: Vec<(f32, f32)>,
169        g: Vec<(f32, f32)>,
170        b: Vec<(f32, f32)>,
171    ) -> Self {
172        self.steps.push(FilterStep::Curves { master, r, g, b });
173        self
174    }
175
176    /// Correct white balance using `FFmpeg`'s `colorchannelmixer` filter.
177    ///
178    /// RGB channel multipliers are derived from `temperature_k` via Tanner
179    /// Helland's Kelvin-to-RGB algorithm. The `tint` offset shifts the green
180    /// channel (positive = more green, negative = more magenta).
181    ///
182    /// Valid ranges:
183    /// - `temperature_k`: 1000–40000 K (neutral daylight ≈ 6500 K)
184    /// - `tint`: −1.0–1.0
185    ///
186    /// # Validation
187    ///
188    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if either
189    /// value is outside its valid range.
190    #[must_use]
191    pub fn white_balance(mut self, temperature_k: u32, tint: f32) -> Self {
192        self.steps.push(FilterStep::WhiteBalance {
193            temperature_k,
194            tint,
195        });
196        self
197    }
198
199    /// Rotate hue by `degrees` using `FFmpeg`'s `hue` filter.
200    ///
201    /// Valid range: −360.0–360.0. A value of `0.0` is a no-op.
202    ///
203    /// # Validation
204    ///
205    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
206    /// `degrees` is outside `[−360.0, 360.0]`.
207    #[must_use]
208    pub fn hue(mut self, degrees: f32) -> Self {
209        self.steps.push(FilterStep::Hue { degrees });
210        self
211    }
212
213    /// Apply per-channel gamma correction using `FFmpeg`'s `eq` filter.
214    ///
215    /// Valid range per channel: 0.1–10.0. A value of `1.0` is neutral.
216    /// Values above 1.0 brighten midtones; values below 1.0 darken them.
217    ///
218    /// # Validation
219    ///
220    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if any
221    /// channel value is outside `[0.1, 10.0]`.
222    #[must_use]
223    pub fn gamma(mut self, r: f32, g: f32, b: f32) -> Self {
224        self.steps.push(FilterStep::Gamma { r, g, b });
225        self
226    }
227
228    /// Apply a three-way colour corrector (lift / gamma / gain) using `FFmpeg`'s
229    /// `curves` filter.
230    ///
231    /// Each parameter is an [`Rgb`] triplet; neutral for all three is
232    /// [`Rgb::NEUTRAL`] (`r=1.0, g=1.0, b=1.0`).
233    ///
234    /// - **lift**: shifts shadows (blacks). Values below `1.0` darken shadows.
235    /// - **gamma**: shapes midtones via a power curve. Values above `1.0`
236    ///   brighten midtones; values below `1.0` darken them.
237    /// - **gain**: scales highlights (whites). Values above `1.0` boost whites.
238    ///
239    /// # Validation
240    ///
241    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if any
242    /// `gamma` component is `≤ 0.0` (division by zero in the power curve).
243    #[must_use]
244    pub fn three_way_cc(mut self, lift: Rgb, gamma: Rgb, gain: Rgb) -> Self {
245        self.steps
246            .push(FilterStep::ThreeWayCC { lift, gamma, gain });
247        self
248    }
249
250    /// Apply a vignette effect using `FFmpeg`'s `vignette` filter.
251    ///
252    /// Darkens the corners of the frame with a smooth radial falloff.
253    ///
254    /// - `angle`: radius angle in radians (`0.0` – π/2 ≈ 1.5708). Default: π/5 ≈ 0.628.
255    /// - `x0`: horizontal centre of the vignette. Pass `0.0` to use the video
256    ///   centre (`w/2`).
257    /// - `y0`: vertical centre of the vignette. Pass `0.0` to use the video
258    ///   centre (`h/2`).
259    ///
260    /// # Validation
261    ///
262    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
263    /// `angle` is outside `[0.0, π/2]`.
264    #[must_use]
265    pub fn vignette(mut self, angle: f32, x0: f32, y0: f32) -> Self {
266        self.steps.push(FilterStep::Vignette { angle, x0, y0 });
267        self
268    }
269
270    /// Flip the video horizontally (mirror left–right) using `FFmpeg`'s `hflip` filter.
271    #[must_use]
272    pub fn hflip(mut self) -> Self {
273        self.steps.push(FilterStep::HFlip);
274        self
275    }
276
277    /// Flip the video vertically (mirror top–bottom) using `FFmpeg`'s `vflip` filter.
278    #[must_use]
279    pub fn vflip(mut self) -> Self {
280        self.steps.push(FilterStep::VFlip);
281        self
282    }
283
284    /// Reverse video playback using `FFmpeg`'s `reverse` filter.
285    ///
286    /// **Warning**: `reverse` buffers the entire clip in memory before producing
287    /// any output. Only use this on short clips to avoid excessive memory usage.
288    #[must_use]
289    pub fn reverse(mut self) -> Self {
290        self.steps.push(FilterStep::Reverse);
291        self
292    }
293
294    /// Pad the frame to `width × height` pixels, placing the source at `(x, y)`
295    /// and filling the exposed borders with `color`.
296    ///
297    /// Pass a negative value for `x` or `y` to centre the source on that axis
298    /// (`x = -1` → `(width − source_w) / 2`).
299    ///
300    /// `color` accepts any color string understood by `FFmpeg` — for example
301    /// `"black"`, `"white"`, `"0x000000"`.
302    ///
303    /// # Validation
304    ///
305    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
306    /// `width` or `height` is zero.
307    #[must_use]
308    pub fn pad(mut self, width: u32, height: u32, x: i32, y: i32, color: &str) -> Self {
309        self.steps.push(FilterStep::Pad {
310            width,
311            height,
312            x,
313            y,
314            color: color.to_owned(),
315        });
316        self
317    }
318
319    /// Scale the source frame to fit within `width × height` while preserving its
320    /// aspect ratio, then centre it on a `width × height` canvas filled with
321    /// `color` (letterbox / pillarbox).
322    ///
323    /// Wide sources (wider aspect ratio than the target) get horizontal black bars
324    /// (*letterbox*); tall sources get vertical bars (*pillarbox*).
325    ///
326    /// `color` accepts any color string understood by `FFmpeg` — for example
327    /// `"black"`, `"white"`, `"0x000000"`.
328    ///
329    /// # Validation
330    ///
331    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
332    /// `width` or `height` is zero.
333    #[must_use]
334    pub fn fit_to_aspect(mut self, width: u32, height: u32, color: &str) -> Self {
335        self.steps.push(FilterStep::FitToAspect {
336            width,
337            height,
338            color: color.to_owned(),
339        });
340        self
341    }
342
343    /// Apply a Gaussian blur with the given `sigma` (blur radius).
344    ///
345    /// `sigma` controls the standard deviation of the Gaussian kernel.
346    /// Values near `0.0` are nearly a no-op; values up to `10.0` produce
347    /// progressively stronger blur.
348    ///
349    /// # Validation
350    ///
351    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
352    /// `sigma` is negative.
353    #[must_use]
354    pub fn gblur(mut self, sigma: f32) -> Self {
355        self.steps.push(FilterStep::GBlur { sigma });
356        self
357    }
358
359    /// Sharpen or blur the image using an unsharp mask on luma and chroma.
360    ///
361    /// Positive values sharpen; negative values blur. Pass `0.0` for either
362    /// channel to leave it unchanged.
363    ///
364    /// Valid ranges: `luma_strength` and `chroma_strength` each −1.5 – 1.5.
365    ///
366    /// # Validation
367    ///
368    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if either
369    /// value is outside `[−1.5, 1.5]`.
370    #[must_use]
371    pub fn unsharp(mut self, luma_strength: f32, chroma_strength: f32) -> Self {
372        self.steps.push(FilterStep::Unsharp {
373            luma_strength,
374            chroma_strength,
375        });
376        self
377    }
378
379    /// Apply High Quality 3D (`hqdn3d`) noise reduction.
380    ///
381    /// Typical values: `luma_spatial=4.0`, `chroma_spatial=3.0`,
382    /// `luma_tmp=6.0`, `chroma_tmp=4.5`. All values must be ≥ 0.0.
383    ///
384    /// # Validation
385    ///
386    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if any
387    /// value is negative.
388    #[must_use]
389    pub fn hqdn3d(
390        mut self,
391        luma_spatial: f32,
392        chroma_spatial: f32,
393        luma_tmp: f32,
394        chroma_tmp: f32,
395    ) -> Self {
396        self.steps.push(FilterStep::Hqdn3d {
397            luma_spatial,
398            chroma_spatial,
399            luma_tmp,
400            chroma_tmp,
401        });
402        self
403    }
404
405    /// Apply non-local means (`nlmeans`) noise reduction.
406    ///
407    /// `strength` controls denoising intensity; range 1.0–30.0.
408    /// Higher values remove more noise at the cost of significantly more CPU.
409    ///
410    /// NOTE: nlmeans is CPU-intensive; avoid for real-time pipelines.
411    #[must_use]
412    pub fn nlmeans(mut self, strength: f32) -> Self {
413        self.steps.push(FilterStep::Nlmeans { strength });
414        self
415    }
416
417    /// Deinterlace using the `yadif` (Yet Another Deinterlacing Filter).
418    ///
419    /// `mode` controls whether one frame or two fields are emitted per input
420    /// frame and whether the spatial interlacing check is enabled.
421    #[must_use]
422    pub fn yadif(mut self, mode: YadifMode) -> Self {
423        self.steps.push(FilterStep::Yadif { mode });
424        self
425    }
426
427    /// Apply a cross-dissolve transition between two video streams using `xfade`.
428    ///
429    /// Requires two input slots: slot 0 is clip A (first clip), slot 1 is clip B
430    /// (second clip). Call [`FilterGraph::push_video`] with slot 0 for clip A
431    /// frames and slot 1 for clip B frames.
432    ///
433    /// - `transition`: the visual transition style.
434    /// - `duration`: length of the overlap in seconds. Must be > 0.0.
435    /// - `offset`: PTS offset (seconds) at which clip B starts playing.
436    #[must_use]
437    pub fn xfade(mut self, transition: XfadeTransition, duration: f64, offset: f64) -> Self {
438        self.steps.push(FilterStep::XFade {
439            transition,
440            duration,
441            offset,
442        });
443        self
444    }
445
446    /// Join two video streams with a cross-dissolve transition.
447    ///
448    /// Requires two video input slots: push clip A frames to slot 0 and clip B
449    /// frames to slot 1.  Internally expands to
450    /// `trim` + `setpts` → `xfade` ← `setpts` + `trim`.
451    ///
452    /// - `clip_a_end_sec`: timestamp (seconds) where clip A ends. Must be > 0.0.
453    /// - `clip_b_start_sec`: timestamp (seconds) where clip B content starts
454    ///   (before the overlap region).
455    /// - `dissolve_dur_sec`: cross-dissolve overlap length in seconds. Must be > 0.0.
456    ///
457    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
458    /// `dissolve_dur_sec ≤ 0.0` or `clip_a_end_sec ≤ 0.0`.
459    #[must_use]
460    pub fn join_with_dissolve(
461        mut self,
462        clip_a_end_sec: f64,
463        clip_b_start_sec: f64,
464        dissolve_dur_sec: f64,
465    ) -> Self {
466        self.steps.push(FilterStep::JoinWithDissolve {
467            clip_a_end: clip_a_end_sec,
468            clip_b_start: clip_b_start_sec,
469            dissolve_dur: dissolve_dur_sec,
470        });
471        self
472    }
473
474    /// Change playback speed by `factor`.
475    ///
476    /// `factor > 1.0` = fast motion (e.g. `2.0` = double speed).
477    /// `factor < 1.0` = slow motion (e.g. `0.5` = half speed).
478    ///
479    /// **Video**: uses `setpts=PTS/{factor}`.
480    /// **Audio**: uses chained `atempo` filters (each in [0.5, 2.0]) so the
481    /// full range 0.1–100.0 is covered without quality degradation.
482    ///
483    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
484    /// `factor` is outside [0.1, 100.0].
485    #[must_use]
486    pub fn speed(mut self, factor: f64) -> Self {
487        self.steps.push(FilterStep::Speed { factor });
488        self
489    }
490
491    /// Concatenate `n_segments` sequential video inputs using `FFmpeg`'s `concat` filter.
492    ///
493    /// Requires `n_segments` video input slots (push to slots 0 through
494    /// `n_segments - 1` in order). [`build`](Self::build) returns
495    /// [`FilterError::InvalidConfig`] if `n_segments < 2`.
496    #[must_use]
497    pub fn concat_video(mut self, n_segments: u32) -> Self {
498        self.steps.push(FilterStep::ConcatVideo { n: n_segments });
499        self
500    }
501
502    /// Freeze the frame at `pts_sec` for `duration_sec` seconds using `FFmpeg`'s `loop` filter.
503    ///
504    /// The frame nearest to `pts_sec` is held for `duration_sec` seconds before
505    /// playback resumes. Frame numbers are approximated using a 25 fps assumption;
506    /// accuracy depends on the source stream's actual frame rate.
507    ///
508    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
509    /// `pts_sec` is negative or `duration_sec` is ≤ 0.0.
510    #[must_use]
511    pub fn freeze_frame(mut self, pts_sec: f64, duration_sec: f64) -> Self {
512        self.steps.push(FilterStep::FreezeFrame {
513            pts: pts_sec,
514            duration: duration_sec,
515        });
516        self
517    }
518
519    /// Overlay text onto the video using the `drawtext` filter.
520    ///
521    /// See [`DrawTextOptions`] for all configurable fields including position,
522    /// font, size, color, opacity, and optional background box.
523    #[must_use]
524    pub fn drawtext(mut self, opts: DrawTextOptions) -> Self {
525        self.steps.push(FilterStep::DrawText { opts });
526        self
527    }
528
529    /// Scroll text from right to left as a news ticker.
530    ///
531    /// Uses `FFmpeg`'s `drawtext` filter with the expression `x = w - t * speed`
532    /// so the text enters from the right edge at playback start and advances
533    /// left by `speed_px_per_sec` pixels per second.
534    ///
535    /// `y` is an `FFmpeg` expression string for the vertical position,
536    /// e.g. `"h-50"` for 50 pixels above the bottom.
537    ///
538    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if:
539    /// - `text` is empty, or
540    /// - `speed_px_per_sec` is ≤ 0.0.
541    #[must_use]
542    pub fn ticker(
543        mut self,
544        text: &str,
545        y: &str,
546        speed_px_per_sec: f32,
547        font_size: u32,
548        font_color: &str,
549    ) -> Self {
550        self.steps.push(FilterStep::Ticker {
551            text: text.to_owned(),
552            y: y.to_owned(),
553            speed_px_per_sec,
554            font_size,
555            font_color: font_color.to_owned(),
556        });
557        self
558    }
559
560    /// Burn SRT subtitles into the video (hard subtitles).
561    ///
562    /// Subtitles are read from the `.srt` file at `srt_path` and rendered
563    /// at the timecodes defined in the file using `FFmpeg`'s `subtitles` filter.
564    ///
565    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if:
566    /// - the extension is not `.srt`, or
567    /// - the file does not exist at build time.
568    #[must_use]
569    pub fn subtitles_srt(mut self, srt_path: &str) -> Self {
570        self.steps.push(FilterStep::SubtitlesSrt {
571            path: srt_path.to_owned(),
572        });
573        self
574    }
575
576    /// Burn ASS/SSA styled subtitles into the video (hard subtitles).
577    ///
578    /// Subtitles are read from the `.ass` or `.ssa` file at `ass_path` and
579    /// rendered with full styling using `FFmpeg`'s dedicated `ass` filter,
580    /// which preserves fonts, colours, and positioning better than the generic
581    /// `subtitles` filter.
582    ///
583    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if:
584    /// - the extension is not `.ass` or `.ssa`, or
585    /// - the file does not exist at build time.
586    #[must_use]
587    pub fn subtitles_ass(mut self, ass_path: &str) -> Self {
588        self.steps.push(FilterStep::SubtitlesAss {
589            path: ass_path.to_owned(),
590        });
591        self
592    }
593
594    /// Composite a PNG image (watermark / logo) over video.
595    ///
596    /// The image at `path` is loaded once at graph construction time via
597    /// `FFmpeg`'s `movie` source filter. Its alpha channel is scaled by
598    /// `opacity` using a `lut` filter, then composited onto the main stream
599    /// with the `overlay` filter at position `(x, y)`.
600    ///
601    /// `x` and `y` are `FFmpeg` expression strings, e.g. `"10"`, `"W-w-10"`.
602    ///
603    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if:
604    /// - the extension is not `.png`,
605    /// - the file does not exist at build time, or
606    /// - `opacity` is outside `[0.0, 1.0]`.
607    #[must_use]
608    pub fn overlay_image(mut self, path: &str, x: &str, y: &str, opacity: f32) -> Self {
609        self.steps.push(FilterStep::OverlayImage {
610            path: path.to_owned(),
611            x: x.to_owned(),
612            y: y.to_owned(),
613            opacity,
614        });
615        self
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn filter_step_scale_should_produce_correct_args() {
625        let step = FilterStep::Scale {
626            width: 1280,
627            height: 720,
628            algorithm: ScaleAlgorithm::Fast,
629        };
630        assert_eq!(step.filter_name(), "scale");
631        assert_eq!(step.args(), "w=1280:h=720:flags=fast_bilinear");
632    }
633
634    #[test]
635    fn filter_step_scale_lanczos_should_produce_lanczos_flags() {
636        let step = FilterStep::Scale {
637            width: 1920,
638            height: 1080,
639            algorithm: ScaleAlgorithm::Lanczos,
640        };
641        assert_eq!(step.args(), "w=1920:h=1080:flags=lanczos");
642    }
643
644    #[test]
645    fn filter_step_trim_should_produce_correct_args() {
646        let step = FilterStep::Trim {
647            start: 10.0,
648            end: 30.0,
649        };
650        assert_eq!(step.filter_name(), "trim");
651        assert_eq!(step.args(), "start=10:end=30");
652    }
653
654    #[test]
655    fn tone_map_variants_should_have_correct_names() {
656        assert_eq!(ToneMap::Hable.as_str(), "hable");
657        assert_eq!(ToneMap::Reinhard.as_str(), "reinhard");
658        assert_eq!(ToneMap::Mobius.as_str(), "mobius");
659    }
660
661    #[test]
662    fn filter_step_overlay_should_produce_correct_args() {
663        let step = FilterStep::Overlay { x: 10, y: 20 };
664        assert_eq!(step.filter_name(), "overlay");
665        assert_eq!(step.args(), "x=10:y=20");
666    }
667
668    #[test]
669    fn filter_step_crop_should_produce_correct_args() {
670        let step = FilterStep::Crop {
671            x: 0,
672            y: 0,
673            width: 640,
674            height: 360,
675        };
676        assert_eq!(step.filter_name(), "crop");
677        assert_eq!(step.args(), "x=0:y=0:w=640:h=360");
678    }
679
680    #[test]
681    fn filter_step_fade_in_should_produce_correct_filter_name() {
682        let step = FilterStep::FadeIn {
683            start: 0.0,
684            duration: 1.5,
685        };
686        assert_eq!(step.filter_name(), "fade");
687    }
688
689    #[test]
690    fn filter_step_fade_in_should_produce_correct_args() {
691        let step = FilterStep::FadeIn {
692            start: 0.0,
693            duration: 1.5,
694        };
695        assert_eq!(step.args(), "type=in:start_time=0:duration=1.5");
696    }
697
698    #[test]
699    fn filter_step_fade_in_with_nonzero_start_should_produce_correct_args() {
700        let step = FilterStep::FadeIn {
701            start: 2.0,
702            duration: 1.0,
703        };
704        assert_eq!(step.args(), "type=in:start_time=2:duration=1");
705    }
706
707    #[test]
708    fn filter_step_fade_out_should_produce_correct_filter_name() {
709        let step = FilterStep::FadeOut {
710            start: 8.5,
711            duration: 1.5,
712        };
713        assert_eq!(step.filter_name(), "fade");
714    }
715
716    #[test]
717    fn filter_step_fade_out_should_produce_correct_args() {
718        let step = FilterStep::FadeOut {
719            start: 8.5,
720            duration: 1.5,
721        };
722        assert_eq!(step.args(), "type=out:start_time=8.5:duration=1.5");
723    }
724
725    #[test]
726    fn builder_fade_in_with_valid_params_should_succeed() {
727        let result = FilterGraph::builder().fade_in(0.0, 1.5).build();
728        assert!(
729            result.is_ok(),
730            "fade_in(0.0, 1.5) must build successfully, got {result:?}"
731        );
732    }
733
734    #[test]
735    fn builder_fade_out_with_valid_params_should_succeed() {
736        let result = FilterGraph::builder().fade_out(8.5, 1.5).build();
737        assert!(
738            result.is_ok(),
739            "fade_out(8.5, 1.5) must build successfully, got {result:?}"
740        );
741    }
742
743    #[test]
744    fn builder_fade_in_with_zero_duration_should_return_invalid_config() {
745        let result = FilterGraph::builder().fade_in(0.0, 0.0).build();
746        assert!(
747            matches!(result, Err(FilterError::InvalidConfig { .. })),
748            "expected InvalidConfig for zero duration, got {result:?}"
749        );
750        if let Err(FilterError::InvalidConfig { reason }) = result {
751            assert!(
752                reason.contains("duration"),
753                "reason should mention duration: {reason}"
754            );
755        }
756    }
757
758    #[test]
759    fn builder_fade_out_with_negative_duration_should_return_invalid_config() {
760        let result = FilterGraph::builder().fade_out(0.0, -1.0).build();
761        assert!(
762            matches!(result, Err(FilterError::InvalidConfig { .. })),
763            "expected InvalidConfig for negative duration, got {result:?}"
764        );
765    }
766
767    #[test]
768    fn filter_step_fade_in_white_should_produce_correct_filter_name() {
769        let step = FilterStep::FadeInWhite {
770            start: 0.0,
771            duration: 1.0,
772        };
773        assert_eq!(step.filter_name(), "fade");
774    }
775
776    #[test]
777    fn filter_step_fade_in_white_should_produce_correct_args() {
778        let step = FilterStep::FadeInWhite {
779            start: 0.0,
780            duration: 1.0,
781        };
782        assert_eq!(step.args(), "type=in:start_time=0:duration=1:color=white");
783    }
784
785    #[test]
786    fn filter_step_fade_in_white_with_nonzero_start_should_produce_correct_args() {
787        let step = FilterStep::FadeInWhite {
788            start: 2.5,
789            duration: 1.0,
790        };
791        assert_eq!(step.args(), "type=in:start_time=2.5:duration=1:color=white");
792    }
793
794    #[test]
795    fn filter_step_fade_out_white_should_produce_correct_filter_name() {
796        let step = FilterStep::FadeOutWhite {
797            start: 8.0,
798            duration: 1.0,
799        };
800        assert_eq!(step.filter_name(), "fade");
801    }
802
803    #[test]
804    fn filter_step_fade_out_white_should_produce_correct_args() {
805        let step = FilterStep::FadeOutWhite {
806            start: 8.0,
807            duration: 1.0,
808        };
809        assert_eq!(step.args(), "type=out:start_time=8:duration=1:color=white");
810    }
811
812    #[test]
813    fn builder_fade_in_white_with_valid_params_should_succeed() {
814        let result = FilterGraph::builder().fade_in_white(0.0, 1.0).build();
815        assert!(
816            result.is_ok(),
817            "fade_in_white(0.0, 1.0) must build successfully, got {result:?}"
818        );
819    }
820
821    #[test]
822    fn builder_fade_out_white_with_valid_params_should_succeed() {
823        let result = FilterGraph::builder().fade_out_white(8.0, 1.0).build();
824        assert!(
825            result.is_ok(),
826            "fade_out_white(8.0, 1.0) must build successfully, got {result:?}"
827        );
828    }
829
830    #[test]
831    fn builder_fade_in_white_with_zero_duration_should_return_invalid_config() {
832        let result = FilterGraph::builder().fade_in_white(0.0, 0.0).build();
833        assert!(
834            matches!(result, Err(FilterError::InvalidConfig { .. })),
835            "expected InvalidConfig for zero duration, got {result:?}"
836        );
837    }
838
839    #[test]
840    fn builder_fade_out_white_with_negative_duration_should_return_invalid_config() {
841        let result = FilterGraph::builder().fade_out_white(0.0, -1.0).build();
842        assert!(
843            matches!(result, Err(FilterError::InvalidConfig { .. })),
844            "expected InvalidConfig for negative duration, got {result:?}"
845        );
846    }
847
848    #[test]
849    fn filter_step_rotate_should_produce_correct_args() {
850        let step = FilterStep::Rotate {
851            angle_degrees: 90.0,
852            fill_color: "black".to_owned(),
853        };
854        assert_eq!(step.filter_name(), "rotate");
855        assert_eq!(
856            step.args(),
857            format!("angle={}:fillcolor=black", 90_f64.to_radians())
858        );
859    }
860
861    #[test]
862    fn filter_step_rotate_transparent_fill_should_produce_correct_args() {
863        let step = FilterStep::Rotate {
864            angle_degrees: 45.0,
865            fill_color: "0x00000000".to_owned(),
866        };
867        assert_eq!(step.filter_name(), "rotate");
868        let args = step.args();
869        assert!(
870            args.contains("fillcolor=0x00000000"),
871            "args should contain transparent fill: {args}"
872        );
873    }
874
875    #[test]
876    fn filter_step_tone_map_should_produce_correct_args() {
877        let step = FilterStep::ToneMap(ToneMap::Hable);
878        assert_eq!(step.filter_name(), "tonemap");
879        assert_eq!(step.args(), "tonemap=hable");
880    }
881
882    #[test]
883    fn filter_step_lut3d_should_produce_correct_filter_name() {
884        let step = FilterStep::Lut3d {
885            path: "grade.cube".to_owned(),
886        };
887        assert_eq!(step.filter_name(), "lut3d");
888    }
889
890    #[test]
891    fn filter_step_lut3d_should_produce_correct_args() {
892        let step = FilterStep::Lut3d {
893            path: "grade.cube".to_owned(),
894        };
895        assert_eq!(step.args(), "file=grade.cube:interp=trilinear");
896    }
897
898    #[test]
899    fn builder_lut3d_with_unsupported_extension_should_return_invalid_config() {
900        let result = FilterGraph::builder().lut3d("color_grade.txt").build();
901        assert!(
902            matches!(result, Err(FilterError::InvalidConfig { .. })),
903            "expected InvalidConfig for unsupported extension, got {result:?}"
904        );
905        if let Err(FilterError::InvalidConfig { reason }) = result {
906            assert!(
907                reason.contains("unsupported LUT format"),
908                "reason should mention unsupported format: {reason}"
909            );
910        }
911    }
912
913    #[test]
914    fn builder_lut3d_with_no_extension_should_return_invalid_config() {
915        let result = FilterGraph::builder().lut3d("color_grade_no_ext").build();
916        assert!(
917            matches!(result, Err(FilterError::InvalidConfig { .. })),
918            "expected InvalidConfig for missing extension, got {result:?}"
919        );
920    }
921
922    #[test]
923    fn builder_lut3d_with_nonexistent_cube_file_should_return_invalid_config() {
924        let result = FilterGraph::builder()
925            .lut3d("/nonexistent/path/grade_ab12cd.cube")
926            .build();
927        assert!(
928            matches!(result, Err(FilterError::InvalidConfig { .. })),
929            "expected InvalidConfig for nonexistent file, got {result:?}"
930        );
931        if let Err(FilterError::InvalidConfig { reason }) = result {
932            assert!(
933                reason.contains("LUT file not found"),
934                "reason should mention file not found: {reason}"
935            );
936        }
937    }
938
939    #[test]
940    fn builder_lut3d_with_nonexistent_3dl_file_should_return_invalid_config() {
941        let result = FilterGraph::builder()
942            .lut3d("/nonexistent/path/grade_ab12cd.3dl")
943            .build();
944        assert!(
945            matches!(result, Err(FilterError::InvalidConfig { .. })),
946            "expected InvalidConfig for nonexistent .3dl file, got {result:?}"
947        );
948    }
949
950    #[test]
951    fn filter_step_eq_should_produce_correct_filter_name() {
952        let step = FilterStep::Eq {
953            brightness: 0.0,
954            contrast: 1.0,
955            saturation: 1.0,
956        };
957        assert_eq!(step.filter_name(), "eq");
958    }
959
960    #[test]
961    fn filter_step_eq_should_produce_correct_args() {
962        let step = FilterStep::Eq {
963            brightness: 0.1,
964            contrast: 1.5,
965            saturation: 0.8,
966        };
967        assert_eq!(step.args(), "brightness=0.1:contrast=1.5:saturation=0.8");
968    }
969
970    #[test]
971    fn builder_eq_with_valid_params_should_succeed() {
972        let result = FilterGraph::builder().eq(0.0, 1.0, 1.0).build();
973        assert!(
974            result.is_ok(),
975            "neutral eq params must build successfully, got {result:?}"
976        );
977    }
978
979    #[test]
980    fn builder_eq_with_brightness_too_low_should_return_invalid_config() {
981        let result = FilterGraph::builder().eq(-1.5, 1.0, 1.0).build();
982        assert!(
983            matches!(result, Err(FilterError::InvalidConfig { .. })),
984            "expected InvalidConfig for brightness < -1.0, got {result:?}"
985        );
986        if let Err(FilterError::InvalidConfig { reason }) = result {
987            assert!(
988                reason.contains("brightness"),
989                "reason should mention brightness: {reason}"
990            );
991        }
992    }
993
994    #[test]
995    fn builder_eq_with_brightness_too_high_should_return_invalid_config() {
996        let result = FilterGraph::builder().eq(1.5, 1.0, 1.0).build();
997        assert!(
998            matches!(result, Err(FilterError::InvalidConfig { .. })),
999            "expected InvalidConfig for brightness > 1.0, got {result:?}"
1000        );
1001    }
1002
1003    #[test]
1004    fn builder_eq_with_contrast_out_of_range_should_return_invalid_config() {
1005        let result = FilterGraph::builder().eq(0.0, 4.0, 1.0).build();
1006        assert!(
1007            matches!(result, Err(FilterError::InvalidConfig { .. })),
1008            "expected InvalidConfig for contrast > 3.0, got {result:?}"
1009        );
1010        if let Err(FilterError::InvalidConfig { reason }) = result {
1011            assert!(
1012                reason.contains("contrast"),
1013                "reason should mention contrast: {reason}"
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn builder_eq_with_saturation_out_of_range_should_return_invalid_config() {
1020        let result = FilterGraph::builder().eq(0.0, 1.0, -0.5).build();
1021        assert!(
1022            matches!(result, Err(FilterError::InvalidConfig { .. })),
1023            "expected InvalidConfig for saturation < 0.0, got {result:?}"
1024        );
1025        if let Err(FilterError::InvalidConfig { reason }) = result {
1026            assert!(
1027                reason.contains("saturation"),
1028                "reason should mention saturation: {reason}"
1029            );
1030        }
1031    }
1032
1033    #[test]
1034    fn filter_step_curves_should_produce_correct_filter_name() {
1035        let step = FilterStep::Curves {
1036            master: vec![],
1037            r: vec![],
1038            g: vec![],
1039            b: vec![],
1040        };
1041        assert_eq!(step.filter_name(), "curves");
1042    }
1043
1044    #[test]
1045    fn filter_step_curves_should_produce_args_with_all_channels() {
1046        let step = FilterStep::Curves {
1047            master: vec![(0.0, 0.0), (0.5, 0.6), (1.0, 1.0)],
1048            r: vec![(0.0, 0.0), (1.0, 1.0)],
1049            g: vec![],
1050            b: vec![(0.0, 0.0), (1.0, 0.8)],
1051        };
1052        let args = step.args();
1053        assert!(args.contains("master='0/0 0.5/0.6 1/1'"), "args={args}");
1054        assert!(args.contains("r='0/0 1/1'"), "args={args}");
1055        assert!(
1056            !args.contains("g="),
1057            "empty g channel should be omitted: args={args}"
1058        );
1059        assert!(args.contains("b='0/0 1/0.8'"), "args={args}");
1060    }
1061
1062    #[test]
1063    fn filter_step_curves_with_empty_channels_should_produce_empty_args() {
1064        let step = FilterStep::Curves {
1065            master: vec![],
1066            r: vec![],
1067            g: vec![],
1068            b: vec![],
1069        };
1070        assert_eq!(
1071            step.args(),
1072            "",
1073            "all-empty curves should produce empty args string"
1074        );
1075    }
1076
1077    #[test]
1078    fn builder_curves_with_valid_s_curve_should_succeed() {
1079        let result = FilterGraph::builder()
1080            .curves(
1081                vec![
1082                    (0.0, 0.0),
1083                    (0.25, 0.15),
1084                    (0.5, 0.5),
1085                    (0.75, 0.85),
1086                    (1.0, 1.0),
1087                ],
1088                vec![],
1089                vec![],
1090                vec![],
1091            )
1092            .build();
1093        assert!(
1094            result.is_ok(),
1095            "valid S-curve master must build successfully, got {result:?}"
1096        );
1097    }
1098
1099    #[test]
1100    fn builder_curves_with_out_of_range_point_should_return_invalid_config() {
1101        let result = FilterGraph::builder()
1102            .curves(vec![(0.0, 1.5)], vec![], vec![], vec![])
1103            .build();
1104        assert!(
1105            matches!(result, Err(FilterError::InvalidConfig { .. })),
1106            "expected InvalidConfig for out-of-range point, got {result:?}"
1107        );
1108        if let Err(FilterError::InvalidConfig { reason }) = result {
1109            assert!(
1110                reason.contains("curves") && reason.contains("master"),
1111                "reason should mention curves master: {reason}"
1112            );
1113        }
1114    }
1115
1116    #[test]
1117    fn builder_curves_with_out_of_range_r_channel_should_return_invalid_config() {
1118        let result = FilterGraph::builder()
1119            .curves(vec![], vec![(1.2, 0.5)], vec![], vec![])
1120            .build();
1121        assert!(
1122            matches!(result, Err(FilterError::InvalidConfig { .. })),
1123            "expected InvalidConfig for out-of-range r channel point, got {result:?}"
1124        );
1125        if let Err(FilterError::InvalidConfig { reason }) = result {
1126            assert!(
1127                reason.contains("curves") && reason.contains(" r "),
1128                "reason should mention curves r: {reason}"
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn filter_step_white_balance_should_produce_correct_filter_name() {
1135        let step = FilterStep::WhiteBalance {
1136            temperature_k: 6500,
1137            tint: 0.0,
1138        };
1139        assert_eq!(step.filter_name(), "colorchannelmixer");
1140    }
1141
1142    #[test]
1143    fn filter_step_white_balance_6500k_neutral_tint_should_produce_near_unity_args() {
1144        // At 6500 K (daylight), all channels should be close to 1.0.
1145        let step = FilterStep::WhiteBalance {
1146            temperature_k: 6500,
1147            tint: 0.0,
1148        };
1149        let args = step.args();
1150        // Parse rr= value to verify it is close to 1.0.
1151        assert!(args.starts_with("rr="), "args must start with rr=: {args}");
1152        assert!(
1153            args.contains("gg=") && args.contains("bb="),
1154            "args must contain gg and bb: {args}"
1155        );
1156    }
1157
1158    #[test]
1159    fn filter_step_white_balance_3200k_should_produce_warm_shift() {
1160        // At 3200 K (tungsten), red should dominate over blue.
1161        use super::super::super::filter_step::FilterStep as FS;
1162        // Access kelvin_to_rgb indirectly through the WhiteBalance step args
1163        let step_warm = FS::WhiteBalance {
1164            temperature_k: 3200,
1165            tint: 0.0,
1166        };
1167        let step_cool = FS::WhiteBalance {
1168            temperature_k: 10000,
1169            tint: 0.0,
1170        };
1171        let args_warm = step_warm.args();
1172        let args_cool = step_cool.args();
1173        // At warm temperature, rr value should be higher than bb value
1174        // Just verify the args are produced without panicking
1175        assert!(
1176            args_warm.contains("rr=") && args_warm.contains("bb="),
1177            "args={args_warm}"
1178        );
1179        assert!(
1180            args_cool.contains("rr=") && args_cool.contains("bb="),
1181            "args={args_cool}"
1182        );
1183    }
1184
1185    #[test]
1186    fn builder_white_balance_with_valid_params_should_succeed() {
1187        let result = FilterGraph::builder().white_balance(6500, 0.0).build();
1188        assert!(
1189            result.is_ok(),
1190            "valid white_balance params must build successfully, got {result:?}"
1191        );
1192    }
1193
1194    #[test]
1195    fn builder_white_balance_with_temperature_too_low_should_return_invalid_config() {
1196        let result = FilterGraph::builder().white_balance(500, 0.0).build();
1197        assert!(
1198            matches!(result, Err(FilterError::InvalidConfig { .. })),
1199            "expected InvalidConfig for temperature_k < 1000, got {result:?}"
1200        );
1201        if let Err(FilterError::InvalidConfig { reason }) = result {
1202            assert!(
1203                reason.contains("temperature_k"),
1204                "reason should mention temperature_k: {reason}"
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn builder_white_balance_with_temperature_too_high_should_return_invalid_config() {
1211        let result = FilterGraph::builder().white_balance(50000, 0.0).build();
1212        assert!(
1213            matches!(result, Err(FilterError::InvalidConfig { .. })),
1214            "expected InvalidConfig for temperature_k > 40000, got {result:?}"
1215        );
1216    }
1217
1218    #[test]
1219    fn builder_white_balance_with_tint_out_of_range_should_return_invalid_config() {
1220        let result = FilterGraph::builder().white_balance(6500, 1.5).build();
1221        assert!(
1222            matches!(result, Err(FilterError::InvalidConfig { .. })),
1223            "expected InvalidConfig for tint > 1.0, got {result:?}"
1224        );
1225        if let Err(FilterError::InvalidConfig { reason }) = result {
1226            assert!(
1227                reason.contains("tint"),
1228                "reason should mention tint: {reason}"
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn filter_step_hue_should_produce_correct_filter_name() {
1235        let step = FilterStep::Hue { degrees: 90.0 };
1236        assert_eq!(step.filter_name(), "hue");
1237    }
1238
1239    #[test]
1240    fn filter_step_hue_should_produce_correct_args() {
1241        let step = FilterStep::Hue { degrees: 180.0 };
1242        assert_eq!(step.args(), "h=180");
1243    }
1244
1245    #[test]
1246    fn filter_step_hue_zero_should_produce_no_op_args() {
1247        let step = FilterStep::Hue { degrees: 0.0 };
1248        assert_eq!(step.args(), "h=0");
1249    }
1250
1251    #[test]
1252    fn builder_hue_with_valid_degrees_should_succeed() {
1253        let result = FilterGraph::builder().hue(0.0).build();
1254        assert!(
1255            result.is_ok(),
1256            "hue(0.0) must build successfully, got {result:?}"
1257        );
1258    }
1259
1260    #[test]
1261    fn builder_hue_with_degrees_too_high_should_return_invalid_config() {
1262        let result = FilterGraph::builder().hue(400.0).build();
1263        assert!(
1264            matches!(result, Err(FilterError::InvalidConfig { .. })),
1265            "expected InvalidConfig for degrees > 360.0, got {result:?}"
1266        );
1267        if let Err(FilterError::InvalidConfig { reason }) = result {
1268            assert!(
1269                reason.contains("degrees"),
1270                "reason should mention degrees: {reason}"
1271            );
1272        }
1273    }
1274
1275    #[test]
1276    fn builder_hue_with_degrees_too_low_should_return_invalid_config() {
1277        let result = FilterGraph::builder().hue(-400.0).build();
1278        assert!(
1279            matches!(result, Err(FilterError::InvalidConfig { .. })),
1280            "expected InvalidConfig for degrees < -360.0, got {result:?}"
1281        );
1282    }
1283
1284    #[test]
1285    fn filter_step_gamma_should_produce_correct_filter_name() {
1286        let step = FilterStep::Gamma {
1287            r: 1.0,
1288            g: 1.0,
1289            b: 1.0,
1290        };
1291        assert_eq!(step.filter_name(), "eq");
1292    }
1293
1294    #[test]
1295    fn filter_step_gamma_should_produce_correct_args() {
1296        let step = FilterStep::Gamma {
1297            r: 2.2,
1298            g: 2.2,
1299            b: 2.2,
1300        };
1301        assert_eq!(step.args(), "gamma_r=2.2:gamma_g=2.2:gamma_b=2.2");
1302    }
1303
1304    #[test]
1305    fn filter_step_gamma_neutral_should_produce_unity_args() {
1306        let step = FilterStep::Gamma {
1307            r: 1.0,
1308            g: 1.0,
1309            b: 1.0,
1310        };
1311        assert_eq!(step.args(), "gamma_r=1:gamma_g=1:gamma_b=1");
1312    }
1313
1314    #[test]
1315    fn builder_gamma_with_neutral_values_should_succeed() {
1316        let result = FilterGraph::builder().gamma(1.0, 1.0, 1.0).build();
1317        assert!(
1318            result.is_ok(),
1319            "gamma(1.0, 1.0, 1.0) must build successfully, got {result:?}"
1320        );
1321    }
1322
1323    #[test]
1324    fn builder_gamma_with_r_out_of_range_should_return_invalid_config() {
1325        let result = FilterGraph::builder().gamma(0.0, 1.0, 1.0).build();
1326        assert!(
1327            matches!(result, Err(FilterError::InvalidConfig { .. })),
1328            "expected InvalidConfig for r < 0.1, got {result:?}"
1329        );
1330        if let Err(FilterError::InvalidConfig { reason }) = result {
1331            assert!(
1332                reason.contains("gamma") && reason.contains(" r "),
1333                "reason should mention gamma r: {reason}"
1334            );
1335        }
1336    }
1337
1338    #[test]
1339    fn builder_gamma_with_b_out_of_range_should_return_invalid_config() {
1340        let result = FilterGraph::builder().gamma(1.0, 1.0, 11.0).build();
1341        assert!(
1342            matches!(result, Err(FilterError::InvalidConfig { .. })),
1343            "expected InvalidConfig for b > 10.0, got {result:?}"
1344        );
1345    }
1346
1347    #[test]
1348    fn filter_step_three_way_cc_should_produce_correct_filter_name() {
1349        let step = FilterStep::ThreeWayCC {
1350            lift: Rgb::NEUTRAL,
1351            gamma: Rgb::NEUTRAL,
1352            gain: Rgb::NEUTRAL,
1353        };
1354        assert_eq!(step.filter_name(), "curves");
1355    }
1356
1357    #[test]
1358    fn filter_step_three_way_cc_neutral_should_produce_identity_curves() {
1359        let step = FilterStep::ThreeWayCC {
1360            lift: Rgb::NEUTRAL,
1361            gamma: Rgb::NEUTRAL,
1362            gain: Rgb::NEUTRAL,
1363        };
1364        let args = step.args();
1365        // Neutral: 0/0, 0.5/0.5, 1/1 for all channels.
1366        assert!(
1367            args.contains("r='0/0 0.5/0.5 1/1'"),
1368            "neutral r channel must be identity: {args}"
1369        );
1370        assert!(
1371            args.contains("g='0/0 0.5/0.5 1/1'"),
1372            "neutral g channel must be identity: {args}"
1373        );
1374        assert!(
1375            args.contains("b='0/0 0.5/0.5 1/1'"),
1376            "neutral b channel must be identity: {args}"
1377        );
1378    }
1379
1380    #[test]
1381    fn builder_three_way_cc_with_neutral_values_should_succeed() {
1382        let result = FilterGraph::builder()
1383            .three_way_cc(Rgb::NEUTRAL, Rgb::NEUTRAL, Rgb::NEUTRAL)
1384            .build();
1385        assert!(
1386            result.is_ok(),
1387            "neutral three_way_cc must build successfully, got {result:?}"
1388        );
1389    }
1390
1391    #[test]
1392    fn builder_three_way_cc_with_gamma_zero_should_return_invalid_config() {
1393        let result = FilterGraph::builder()
1394            .three_way_cc(
1395                Rgb::NEUTRAL,
1396                Rgb {
1397                    r: 0.0,
1398                    g: 1.0,
1399                    b: 1.0,
1400                },
1401                Rgb::NEUTRAL,
1402            )
1403            .build();
1404        assert!(
1405            matches!(result, Err(FilterError::InvalidConfig { .. })),
1406            "expected InvalidConfig for gamma.r = 0.0, got {result:?}"
1407        );
1408        if let Err(FilterError::InvalidConfig { reason }) = result {
1409            assert!(
1410                reason.contains("gamma.r"),
1411                "reason should mention gamma.r: {reason}"
1412            );
1413        }
1414    }
1415
1416    #[test]
1417    fn builder_three_way_cc_with_negative_gamma_should_return_invalid_config() {
1418        let result = FilterGraph::builder()
1419            .three_way_cc(
1420                Rgb::NEUTRAL,
1421                Rgb {
1422                    r: 1.0,
1423                    g: -0.5,
1424                    b: 1.0,
1425                },
1426                Rgb::NEUTRAL,
1427            )
1428            .build();
1429        assert!(
1430            matches!(result, Err(FilterError::InvalidConfig { .. })),
1431            "expected InvalidConfig for gamma.g < 0.0, got {result:?}"
1432        );
1433        if let Err(FilterError::InvalidConfig { reason }) = result {
1434            assert!(
1435                reason.contains("gamma.g"),
1436                "reason should mention gamma.g: {reason}"
1437            );
1438        }
1439    }
1440
1441    #[test]
1442    fn filter_step_vignette_should_produce_correct_filter_name() {
1443        let step = FilterStep::Vignette {
1444            angle: 0.628,
1445            x0: 0.0,
1446            y0: 0.0,
1447        };
1448        assert_eq!(step.filter_name(), "vignette");
1449    }
1450
1451    #[test]
1452    fn filter_step_vignette_zero_centre_should_use_w2_h2_defaults() {
1453        let step = FilterStep::Vignette {
1454            angle: 0.628,
1455            x0: 0.0,
1456            y0: 0.0,
1457        };
1458        let args = step.args();
1459        assert!(args.contains("x0=w/2"), "x0=0.0 should map to w/2: {args}");
1460        assert!(args.contains("y0=h/2"), "y0=0.0 should map to h/2: {args}");
1461        assert!(
1462            args.contains("angle=0.628"),
1463            "args must contain angle: {args}"
1464        );
1465    }
1466
1467    #[test]
1468    fn filter_step_vignette_custom_centre_should_produce_numeric_coords() {
1469        let step = FilterStep::Vignette {
1470            angle: 0.5,
1471            x0: 320.0,
1472            y0: 240.0,
1473        };
1474        let args = step.args();
1475        assert!(args.contains("x0=320"), "custom x0 should appear: {args}");
1476        assert!(args.contains("y0=240"), "custom y0 should appear: {args}");
1477    }
1478
1479    #[test]
1480    fn builder_vignette_with_valid_angle_should_succeed() {
1481        let result = FilterGraph::builder()
1482            .vignette(std::f32::consts::PI / 5.0, 0.0, 0.0)
1483            .build();
1484        assert!(
1485            result.is_ok(),
1486            "default vignette angle must build successfully, got {result:?}"
1487        );
1488    }
1489
1490    #[test]
1491    fn builder_vignette_with_angle_too_large_should_return_invalid_config() {
1492        let result = FilterGraph::builder().vignette(2.0, 0.0, 0.0).build();
1493        assert!(
1494            matches!(result, Err(FilterError::InvalidConfig { .. })),
1495            "expected InvalidConfig for angle > π/2, got {result:?}"
1496        );
1497        if let Err(FilterError::InvalidConfig { reason }) = result {
1498            assert!(
1499                reason.contains("angle"),
1500                "reason should mention angle: {reason}"
1501            );
1502        }
1503    }
1504
1505    #[test]
1506    fn builder_vignette_with_negative_angle_should_return_invalid_config() {
1507        let result = FilterGraph::builder().vignette(-0.1, 0.0, 0.0).build();
1508        assert!(
1509            matches!(result, Err(FilterError::InvalidConfig { .. })),
1510            "expected InvalidConfig for angle < 0.0, got {result:?}"
1511        );
1512    }
1513
1514    #[test]
1515    fn builder_crop_with_zero_width_should_return_invalid_config() {
1516        let result = FilterGraph::builder().crop(0, 0, 0, 100).build();
1517        assert!(
1518            matches!(result, Err(FilterError::InvalidConfig { .. })),
1519            "expected InvalidConfig for width=0, got {result:?}"
1520        );
1521        if let Err(FilterError::InvalidConfig { reason }) = result {
1522            assert!(
1523                reason.contains("crop width and height must be > 0"),
1524                "reason should mention crop dimensions: {reason}"
1525            );
1526        }
1527    }
1528
1529    #[test]
1530    fn builder_crop_with_zero_height_should_return_invalid_config() {
1531        let result = FilterGraph::builder().crop(0, 0, 100, 0).build();
1532        assert!(
1533            matches!(result, Err(FilterError::InvalidConfig { .. })),
1534            "expected InvalidConfig for height=0, got {result:?}"
1535        );
1536    }
1537
1538    #[test]
1539    fn builder_crop_with_valid_dimensions_should_succeed() {
1540        let result = FilterGraph::builder().crop(0, 0, 64, 64).build();
1541        assert!(
1542            result.is_ok(),
1543            "crop with valid dimensions must build successfully, got {result:?}"
1544        );
1545    }
1546
1547    #[test]
1548    fn filter_step_hflip_should_produce_correct_filter_name_and_empty_args() {
1549        let step = FilterStep::HFlip;
1550        assert_eq!(step.filter_name(), "hflip");
1551        assert_eq!(step.args(), "");
1552    }
1553
1554    #[test]
1555    fn filter_step_vflip_should_produce_correct_filter_name_and_empty_args() {
1556        let step = FilterStep::VFlip;
1557        assert_eq!(step.filter_name(), "vflip");
1558        assert_eq!(step.args(), "");
1559    }
1560
1561    #[test]
1562    fn builder_hflip_should_succeed() {
1563        let result = FilterGraph::builder().hflip().build();
1564        assert!(
1565            result.is_ok(),
1566            "hflip must build successfully, got {result:?}"
1567        );
1568    }
1569
1570    #[test]
1571    fn builder_vflip_should_succeed() {
1572        let result = FilterGraph::builder().vflip().build();
1573        assert!(
1574            result.is_ok(),
1575            "vflip must build successfully, got {result:?}"
1576        );
1577    }
1578
1579    #[test]
1580    fn builder_hflip_twice_should_succeed() {
1581        let result = FilterGraph::builder().hflip().hflip().build();
1582        assert!(
1583            result.is_ok(),
1584            "double hflip (round-trip) must build successfully, got {result:?}"
1585        );
1586    }
1587
1588    #[test]
1589    fn filter_step_pad_should_produce_correct_filter_name() {
1590        let step = FilterStep::Pad {
1591            width: 1920,
1592            height: 1080,
1593            x: -1,
1594            y: -1,
1595            color: "black".to_owned(),
1596        };
1597        assert_eq!(step.filter_name(), "pad");
1598    }
1599
1600    #[test]
1601    fn filter_step_pad_negative_xy_should_produce_centred_args() {
1602        let step = FilterStep::Pad {
1603            width: 1920,
1604            height: 1080,
1605            x: -1,
1606            y: -1,
1607            color: "black".to_owned(),
1608        };
1609        assert_eq!(
1610            step.args(),
1611            "width=1920:height=1080:x=(ow-iw)/2:y=(oh-ih)/2:color=black"
1612        );
1613    }
1614
1615    #[test]
1616    fn filter_step_pad_explicit_xy_should_produce_numeric_args() {
1617        let step = FilterStep::Pad {
1618            width: 1920,
1619            height: 1080,
1620            x: 320,
1621            y: 180,
1622            color: "0x000000".to_owned(),
1623        };
1624        assert_eq!(
1625            step.args(),
1626            "width=1920:height=1080:x=320:y=180:color=0x000000"
1627        );
1628    }
1629
1630    #[test]
1631    fn filter_step_pad_zero_xy_should_produce_zero_offset_args() {
1632        let step = FilterStep::Pad {
1633            width: 1280,
1634            height: 720,
1635            x: 0,
1636            y: 0,
1637            color: "black".to_owned(),
1638        };
1639        assert_eq!(step.args(), "width=1280:height=720:x=0:y=0:color=black");
1640    }
1641
1642    #[test]
1643    fn builder_pad_with_valid_params_should_succeed() {
1644        let result = FilterGraph::builder()
1645            .pad(1920, 1080, -1, -1, "black")
1646            .build();
1647        assert!(
1648            result.is_ok(),
1649            "pad with valid params must build successfully, got {result:?}"
1650        );
1651    }
1652
1653    #[test]
1654    fn builder_pad_with_zero_width_should_return_invalid_config() {
1655        let result = FilterGraph::builder().pad(0, 1080, -1, -1, "black").build();
1656        assert!(
1657            matches!(result, Err(FilterError::InvalidConfig { .. })),
1658            "expected InvalidConfig for width=0, got {result:?}"
1659        );
1660        if let Err(FilterError::InvalidConfig { reason }) = result {
1661            assert!(
1662                reason.contains("pad width and height must be > 0"),
1663                "reason should mention pad dimensions: {reason}"
1664            );
1665        }
1666    }
1667
1668    #[test]
1669    fn builder_pad_with_zero_height_should_return_invalid_config() {
1670        let result = FilterGraph::builder().pad(1920, 0, -1, -1, "black").build();
1671        assert!(
1672            matches!(result, Err(FilterError::InvalidConfig { .. })),
1673            "expected InvalidConfig for height=0, got {result:?}"
1674        );
1675    }
1676
1677    #[test]
1678    fn filter_step_fit_to_aspect_should_produce_correct_filter_name() {
1679        let step = FilterStep::FitToAspect {
1680            width: 1920,
1681            height: 1080,
1682            color: "black".to_owned(),
1683        };
1684        assert_eq!(step.filter_name(), "scale");
1685    }
1686
1687    #[test]
1688    fn filter_step_fit_to_aspect_should_produce_scale_args_with_force_original_aspect_ratio() {
1689        let step = FilterStep::FitToAspect {
1690            width: 1920,
1691            height: 1080,
1692            color: "black".to_owned(),
1693        };
1694        let args = step.args();
1695        assert!(
1696            args.contains("w=1920") && args.contains("h=1080"),
1697            "args must contain target dimensions: {args}"
1698        );
1699        assert!(
1700            args.contains("force_original_aspect_ratio=decrease"),
1701            "args must request aspect-ratio-preserving scale: {args}"
1702        );
1703    }
1704
1705    #[test]
1706    fn builder_fit_to_aspect_with_valid_params_should_succeed() {
1707        let result = FilterGraph::builder()
1708            .fit_to_aspect(1920, 1080, "black")
1709            .build();
1710        assert!(
1711            result.is_ok(),
1712            "fit_to_aspect with valid params must build successfully, got {result:?}"
1713        );
1714    }
1715
1716    #[test]
1717    fn builder_fit_to_aspect_with_zero_width_should_return_invalid_config() {
1718        let result = FilterGraph::builder()
1719            .fit_to_aspect(0, 1080, "black")
1720            .build();
1721        assert!(
1722            matches!(result, Err(FilterError::InvalidConfig { .. })),
1723            "expected InvalidConfig for width=0, got {result:?}"
1724        );
1725        if let Err(FilterError::InvalidConfig { reason }) = result {
1726            assert!(
1727                reason.contains("fit_to_aspect width and height must be > 0"),
1728                "reason should mention fit_to_aspect dimensions: {reason}"
1729            );
1730        }
1731    }
1732
1733    #[test]
1734    fn builder_fit_to_aspect_with_zero_height_should_return_invalid_config() {
1735        let result = FilterGraph::builder()
1736            .fit_to_aspect(1920, 0, "black")
1737            .build();
1738        assert!(
1739            matches!(result, Err(FilterError::InvalidConfig { .. })),
1740            "expected InvalidConfig for height=0, got {result:?}"
1741        );
1742    }
1743
1744    #[test]
1745    fn filter_step_gblur_should_produce_correct_filter_name() {
1746        let step = FilterStep::GBlur { sigma: 5.0 };
1747        assert_eq!(step.filter_name(), "gblur");
1748    }
1749
1750    #[test]
1751    fn filter_step_gblur_should_produce_correct_args() {
1752        let step = FilterStep::GBlur { sigma: 5.0 };
1753        assert_eq!(step.args(), "sigma=5");
1754    }
1755
1756    #[test]
1757    fn filter_step_gblur_small_sigma_should_produce_correct_args() {
1758        let step = FilterStep::GBlur { sigma: 0.1 };
1759        assert_eq!(step.args(), "sigma=0.1");
1760    }
1761
1762    #[test]
1763    fn builder_gblur_with_valid_sigma_should_succeed() {
1764        let result = FilterGraph::builder().gblur(5.0).build();
1765        assert!(
1766            result.is_ok(),
1767            "gblur(5.0) must build successfully, got {result:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn builder_gblur_with_zero_sigma_should_succeed() {
1773        let result = FilterGraph::builder().gblur(0.0).build();
1774        assert!(
1775            result.is_ok(),
1776            "gblur(0.0) must build successfully (no-op), got {result:?}"
1777        );
1778    }
1779
1780    #[test]
1781    fn builder_gblur_with_negative_sigma_should_return_invalid_config() {
1782        let result = FilterGraph::builder().gblur(-1.0).build();
1783        assert!(
1784            matches!(result, Err(FilterError::InvalidConfig { .. })),
1785            "expected InvalidConfig for sigma < 0.0, got {result:?}"
1786        );
1787        if let Err(FilterError::InvalidConfig { reason }) = result {
1788            assert!(
1789                reason.contains("sigma"),
1790                "reason should mention sigma: {reason}"
1791            );
1792        }
1793    }
1794
1795    #[test]
1796    fn filter_step_unsharp_should_produce_correct_filter_name() {
1797        let step = FilterStep::Unsharp {
1798            luma_strength: 1.0,
1799            chroma_strength: 0.0,
1800        };
1801        assert_eq!(step.filter_name(), "unsharp");
1802    }
1803
1804    #[test]
1805    fn filter_step_unsharp_should_produce_correct_args() {
1806        let step = FilterStep::Unsharp {
1807            luma_strength: 1.0,
1808            chroma_strength: 0.5,
1809        };
1810        let args = step.args();
1811        assert!(
1812            args.contains("luma_amount=1") && args.contains("chroma_amount=0.5"),
1813            "args must contain luma and chroma amounts: {args}"
1814        );
1815        assert!(
1816            args.contains("luma_msize_x=5") && args.contains("luma_msize_y=5"),
1817            "args must contain luma matrix size: {args}"
1818        );
1819        assert!(
1820            args.contains("chroma_msize_x=5") && args.contains("chroma_msize_y=5"),
1821            "args must contain chroma matrix size: {args}"
1822        );
1823    }
1824
1825    #[test]
1826    fn builder_unsharp_with_valid_params_should_succeed() {
1827        let result = FilterGraph::builder().unsharp(1.0, 0.0).build();
1828        assert!(
1829            result.is_ok(),
1830            "unsharp(1.0, 0.0) must build successfully, got {result:?}"
1831        );
1832    }
1833
1834    #[test]
1835    fn builder_unsharp_with_negative_luma_should_succeed() {
1836        let result = FilterGraph::builder().unsharp(-1.0, 0.0).build();
1837        assert!(
1838            result.is_ok(),
1839            "unsharp(-1.0, 0.0) (blur) must build successfully, got {result:?}"
1840        );
1841    }
1842
1843    #[test]
1844    fn builder_unsharp_with_luma_too_high_should_return_invalid_config() {
1845        let result = FilterGraph::builder().unsharp(2.0, 0.0).build();
1846        assert!(
1847            matches!(result, Err(FilterError::InvalidConfig { .. })),
1848            "expected InvalidConfig for luma_strength > 1.5, got {result:?}"
1849        );
1850        if let Err(FilterError::InvalidConfig { reason }) = result {
1851            assert!(
1852                reason.contains("luma_strength"),
1853                "reason should mention luma_strength: {reason}"
1854            );
1855        }
1856    }
1857
1858    #[test]
1859    fn builder_unsharp_with_luma_too_low_should_return_invalid_config() {
1860        let result = FilterGraph::builder().unsharp(-2.0, 0.0).build();
1861        assert!(
1862            matches!(result, Err(FilterError::InvalidConfig { .. })),
1863            "expected InvalidConfig for luma_strength < -1.5, got {result:?}"
1864        );
1865    }
1866
1867    #[test]
1868    fn builder_unsharp_with_chroma_too_high_should_return_invalid_config() {
1869        let result = FilterGraph::builder().unsharp(0.0, 2.0).build();
1870        assert!(
1871            matches!(result, Err(FilterError::InvalidConfig { .. })),
1872            "expected InvalidConfig for chroma_strength > 1.5, got {result:?}"
1873        );
1874        if let Err(FilterError::InvalidConfig { reason }) = result {
1875            assert!(
1876                reason.contains("chroma_strength"),
1877                "reason should mention chroma_strength: {reason}"
1878            );
1879        }
1880    }
1881
1882    #[test]
1883    fn filter_step_hqdn3d_should_produce_correct_filter_name() {
1884        let step = FilterStep::Hqdn3d {
1885            luma_spatial: 4.0,
1886            chroma_spatial: 3.0,
1887            luma_tmp: 6.0,
1888            chroma_tmp: 4.5,
1889        };
1890        assert_eq!(step.filter_name(), "hqdn3d");
1891    }
1892
1893    #[test]
1894    fn filter_step_hqdn3d_should_produce_correct_args() {
1895        let step = FilterStep::Hqdn3d {
1896            luma_spatial: 4.0,
1897            chroma_spatial: 3.0,
1898            luma_tmp: 6.0,
1899            chroma_tmp: 4.5,
1900        };
1901        assert_eq!(step.args(), "4:3:6:4.5");
1902    }
1903
1904    #[test]
1905    fn builder_hqdn3d_with_valid_params_should_succeed() {
1906        let result = FilterGraph::builder().hqdn3d(4.0, 3.0, 6.0, 4.5).build();
1907        assert!(
1908            result.is_ok(),
1909            "hqdn3d(4.0, 3.0, 6.0, 4.5) must build successfully, got {result:?}"
1910        );
1911    }
1912
1913    #[test]
1914    fn builder_hqdn3d_with_zero_params_should_succeed() {
1915        let result = FilterGraph::builder().hqdn3d(0.0, 0.0, 0.0, 0.0).build();
1916        assert!(
1917            result.is_ok(),
1918            "hqdn3d(0.0, 0.0, 0.0, 0.0) must build successfully, got {result:?}"
1919        );
1920    }
1921
1922    #[test]
1923    fn builder_hqdn3d_with_negative_luma_spatial_should_return_invalid_config() {
1924        let result = FilterGraph::builder().hqdn3d(-1.0, 3.0, 6.0, 4.5).build();
1925        assert!(
1926            matches!(result, Err(FilterError::InvalidConfig { .. })),
1927            "expected InvalidConfig for negative luma_spatial, got {result:?}"
1928        );
1929        if let Err(FilterError::InvalidConfig { reason }) = result {
1930            assert!(
1931                reason.contains("luma_spatial"),
1932                "reason should mention luma_spatial: {reason}"
1933            );
1934        }
1935    }
1936
1937    #[test]
1938    fn builder_hqdn3d_with_negative_chroma_spatial_should_return_invalid_config() {
1939        let result = FilterGraph::builder().hqdn3d(4.0, -1.0, 6.0, 4.5).build();
1940        assert!(
1941            matches!(result, Err(FilterError::InvalidConfig { .. })),
1942            "expected InvalidConfig for negative chroma_spatial, got {result:?}"
1943        );
1944    }
1945
1946    #[test]
1947    fn builder_hqdn3d_with_negative_luma_tmp_should_return_invalid_config() {
1948        let result = FilterGraph::builder().hqdn3d(4.0, 3.0, -1.0, 4.5).build();
1949        assert!(
1950            matches!(result, Err(FilterError::InvalidConfig { .. })),
1951            "expected InvalidConfig for negative luma_tmp, got {result:?}"
1952        );
1953    }
1954
1955    #[test]
1956    fn builder_hqdn3d_with_negative_chroma_tmp_should_return_invalid_config() {
1957        let result = FilterGraph::builder().hqdn3d(4.0, 3.0, 6.0, -1.0).build();
1958        assert!(
1959            matches!(result, Err(FilterError::InvalidConfig { .. })),
1960            "expected InvalidConfig for negative chroma_tmp, got {result:?}"
1961        );
1962    }
1963
1964    #[test]
1965    fn filter_step_nlmeans_should_produce_correct_filter_name() {
1966        let step = FilterStep::Nlmeans { strength: 8.0 };
1967        assert_eq!(step.filter_name(), "nlmeans");
1968    }
1969
1970    #[test]
1971    fn filter_step_nlmeans_should_produce_correct_args() {
1972        let step = FilterStep::Nlmeans { strength: 8.0 };
1973        assert_eq!(step.args(), "s=8");
1974    }
1975
1976    #[test]
1977    fn builder_nlmeans_with_valid_strength_should_succeed() {
1978        let result = FilterGraph::builder().nlmeans(8.0).build();
1979        assert!(
1980            result.is_ok(),
1981            "nlmeans(8.0) must build successfully, got {result:?}"
1982        );
1983    }
1984
1985    #[test]
1986    fn builder_nlmeans_with_min_strength_should_succeed() {
1987        let result = FilterGraph::builder().nlmeans(1.0).build();
1988        assert!(
1989            result.is_ok(),
1990            "nlmeans(1.0) must build successfully, got {result:?}"
1991        );
1992    }
1993
1994    #[test]
1995    fn builder_nlmeans_with_max_strength_should_succeed() {
1996        let result = FilterGraph::builder().nlmeans(30.0).build();
1997        assert!(
1998            result.is_ok(),
1999            "nlmeans(30.0) must build successfully, got {result:?}"
2000        );
2001    }
2002
2003    #[test]
2004    fn builder_nlmeans_with_strength_too_low_should_return_invalid_config() {
2005        let result = FilterGraph::builder().nlmeans(0.5).build();
2006        assert!(
2007            matches!(result, Err(FilterError::InvalidConfig { .. })),
2008            "expected InvalidConfig for strength < 1.0, got {result:?}"
2009        );
2010        if let Err(FilterError::InvalidConfig { reason }) = result {
2011            assert!(
2012                reason.contains("strength"),
2013                "reason should mention strength: {reason}"
2014            );
2015        }
2016    }
2017
2018    #[test]
2019    fn builder_nlmeans_with_strength_too_high_should_return_invalid_config() {
2020        let result = FilterGraph::builder().nlmeans(31.0).build();
2021        assert!(
2022            matches!(result, Err(FilterError::InvalidConfig { .. })),
2023            "expected InvalidConfig for strength > 30.0, got {result:?}"
2024        );
2025        if let Err(FilterError::InvalidConfig { reason }) = result {
2026            assert!(
2027                reason.contains("strength"),
2028                "reason should mention strength: {reason}"
2029            );
2030        }
2031    }
2032
2033    #[test]
2034    fn yadif_mode_variants_should_have_correct_discriminants() {
2035        assert_eq!(YadifMode::Frame as i32, 0);
2036        assert_eq!(YadifMode::Field as i32, 1);
2037        assert_eq!(YadifMode::FrameNospatial as i32, 2);
2038        assert_eq!(YadifMode::FieldNospatial as i32, 3);
2039    }
2040
2041    #[test]
2042    fn filter_step_yadif_should_produce_correct_filter_name() {
2043        let step = FilterStep::Yadif {
2044            mode: YadifMode::Frame,
2045        };
2046        assert_eq!(step.filter_name(), "yadif");
2047    }
2048
2049    #[test]
2050    fn filter_step_yadif_frame_should_produce_mode_0_args() {
2051        let step = FilterStep::Yadif {
2052            mode: YadifMode::Frame,
2053        };
2054        assert_eq!(step.args(), "mode=0");
2055    }
2056
2057    #[test]
2058    fn filter_step_yadif_field_should_produce_mode_1_args() {
2059        let step = FilterStep::Yadif {
2060            mode: YadifMode::Field,
2061        };
2062        assert_eq!(step.args(), "mode=1");
2063    }
2064
2065    #[test]
2066    fn filter_step_yadif_frame_nospatial_should_produce_mode_2_args() {
2067        let step = FilterStep::Yadif {
2068            mode: YadifMode::FrameNospatial,
2069        };
2070        assert_eq!(step.args(), "mode=2");
2071    }
2072
2073    #[test]
2074    fn filter_step_yadif_field_nospatial_should_produce_mode_3_args() {
2075        let step = FilterStep::Yadif {
2076            mode: YadifMode::FieldNospatial,
2077        };
2078        assert_eq!(step.args(), "mode=3");
2079    }
2080
2081    #[test]
2082    fn builder_yadif_with_frame_mode_should_succeed() {
2083        let result = FilterGraph::builder().yadif(YadifMode::Frame).build();
2084        assert!(
2085            result.is_ok(),
2086            "yadif(Frame) must build successfully, got {result:?}"
2087        );
2088    }
2089
2090    #[test]
2091    fn builder_yadif_with_all_modes_should_succeed() {
2092        for mode in [
2093            YadifMode::Frame,
2094            YadifMode::Field,
2095            YadifMode::FrameNospatial,
2096            YadifMode::FieldNospatial,
2097        ] {
2098            let result = FilterGraph::builder().yadif(mode).build();
2099            assert!(
2100                result.is_ok(),
2101                "yadif({mode:?}) must build successfully, got {result:?}"
2102            );
2103        }
2104    }
2105
2106    #[test]
2107    fn xfade_transition_dissolve_should_produce_correct_str() {
2108        assert_eq!(XfadeTransition::Dissolve.as_str(), "dissolve");
2109    }
2110
2111    #[test]
2112    fn xfade_transition_all_variants_should_produce_unique_strings() {
2113        let variants = [
2114            (XfadeTransition::Dissolve, "dissolve"),
2115            (XfadeTransition::Fade, "fade"),
2116            (XfadeTransition::WipeLeft, "wipeleft"),
2117            (XfadeTransition::WipeRight, "wiperight"),
2118            (XfadeTransition::WipeUp, "wipeup"),
2119            (XfadeTransition::WipeDown, "wipedown"),
2120            (XfadeTransition::SlideLeft, "slideleft"),
2121            (XfadeTransition::SlideRight, "slideright"),
2122            (XfadeTransition::SlideUp, "slideup"),
2123            (XfadeTransition::SlideDown, "slidedown"),
2124            (XfadeTransition::CircleOpen, "circleopen"),
2125            (XfadeTransition::CircleClose, "circleclose"),
2126            (XfadeTransition::FadeGrays, "fadegrays"),
2127            (XfadeTransition::Pixelize, "pixelize"),
2128        ];
2129        for (variant, expected) in variants {
2130            assert_eq!(
2131                variant.as_str(),
2132                expected,
2133                "XfadeTransition::{variant:?} should produce \"{expected}\""
2134            );
2135        }
2136    }
2137
2138    #[test]
2139    fn filter_step_xfade_should_produce_correct_filter_name() {
2140        let step = FilterStep::XFade {
2141            transition: XfadeTransition::Dissolve,
2142            duration: 1.0,
2143            offset: 4.0,
2144        };
2145        assert_eq!(step.filter_name(), "xfade");
2146    }
2147
2148    #[test]
2149    fn filter_step_xfade_should_produce_correct_args() {
2150        let step = FilterStep::XFade {
2151            transition: XfadeTransition::Dissolve,
2152            duration: 1.0,
2153            offset: 4.0,
2154        };
2155        assert_eq!(step.args(), "transition=dissolve:duration=1:offset=4");
2156    }
2157
2158    #[test]
2159    fn filter_step_xfade_wipe_right_should_produce_correct_args() {
2160        let step = FilterStep::XFade {
2161            transition: XfadeTransition::WipeRight,
2162            duration: 0.5,
2163            offset: 9.5,
2164        };
2165        assert_eq!(step.args(), "transition=wiperight:duration=0.5:offset=9.5");
2166    }
2167
2168    #[test]
2169    fn builder_xfade_with_valid_params_should_succeed() {
2170        let result = FilterGraph::builder()
2171            .xfade(XfadeTransition::Dissolve, 1.0, 4.0)
2172            .build();
2173        assert!(
2174            result.is_ok(),
2175            "xfade(Dissolve, 1.0, 4.0) must build successfully, got {result:?}"
2176        );
2177    }
2178
2179    #[test]
2180    fn builder_xfade_with_zero_duration_should_return_invalid_config() {
2181        let result = FilterGraph::builder()
2182            .xfade(XfadeTransition::Dissolve, 0.0, 4.0)
2183            .build();
2184        assert!(
2185            matches!(result, Err(FilterError::InvalidConfig { .. })),
2186            "expected InvalidConfig for zero duration, got {result:?}"
2187        );
2188        if let Err(FilterError::InvalidConfig { reason }) = result {
2189            assert!(
2190                reason.contains("duration"),
2191                "reason should mention duration: {reason}"
2192            );
2193        }
2194    }
2195
2196    #[test]
2197    fn builder_xfade_with_negative_duration_should_return_invalid_config() {
2198        let result = FilterGraph::builder()
2199            .xfade(XfadeTransition::Fade, -1.0, 0.0)
2200            .build();
2201        assert!(
2202            matches!(result, Err(FilterError::InvalidConfig { .. })),
2203            "expected InvalidConfig for negative duration, got {result:?}"
2204        );
2205    }
2206
2207    fn make_drawtext_opts() -> DrawTextOptions {
2208        DrawTextOptions {
2209            text: "Hello".to_string(),
2210            x: "10".to_string(),
2211            y: "10".to_string(),
2212            font_size: 24,
2213            font_color: "white".to_string(),
2214            font_file: None,
2215            opacity: 1.0,
2216            box_color: None,
2217            box_border_width: 0,
2218        }
2219    }
2220
2221    #[test]
2222    fn filter_step_drawtext_should_produce_correct_filter_name() {
2223        let step = FilterStep::DrawText {
2224            opts: make_drawtext_opts(),
2225        };
2226        assert_eq!(step.filter_name(), "drawtext");
2227    }
2228
2229    #[test]
2230    fn filter_step_drawtext_should_produce_correct_args_without_box() {
2231        let step = FilterStep::DrawText {
2232            opts: make_drawtext_opts(),
2233        };
2234        let args = step.args();
2235        assert!(
2236            args.contains("text='Hello'"),
2237            "args must contain text: {args}"
2238        );
2239        assert!(args.contains("x=10"), "args must contain x: {args}");
2240        assert!(args.contains("y=10"), "args must contain y: {args}");
2241        assert!(
2242            args.contains("fontsize=24"),
2243            "args must contain fontsize: {args}"
2244        );
2245        assert!(
2246            args.contains("fontcolor=white@1.00"),
2247            "args must contain fontcolor with opacity: {args}"
2248        );
2249        assert!(
2250            !args.contains("box=1"),
2251            "args must not contain box when box_color is None: {args}"
2252        );
2253    }
2254
2255    #[test]
2256    fn filter_step_drawtext_with_box_should_include_box_args() {
2257        let opts = DrawTextOptions {
2258            box_color: Some("black@0.5".to_string()),
2259            box_border_width: 5,
2260            ..make_drawtext_opts()
2261        };
2262        let step = FilterStep::DrawText { opts };
2263        let args = step.args();
2264        assert!(args.contains("box=1"), "args must contain box=1: {args}");
2265        assert!(
2266            args.contains("boxcolor=black@0.5"),
2267            "args must contain boxcolor: {args}"
2268        );
2269        assert!(
2270            args.contains("boxborderw=5"),
2271            "args must contain boxborderw: {args}"
2272        );
2273    }
2274
2275    #[test]
2276    fn filter_step_drawtext_with_font_file_should_include_fontfile_arg() {
2277        let opts = DrawTextOptions {
2278            font_file: Some("/usr/share/fonts/arial.ttf".to_string()),
2279            ..make_drawtext_opts()
2280        };
2281        let step = FilterStep::DrawText { opts };
2282        let args = step.args();
2283        assert!(
2284            args.contains("fontfile=/usr/share/fonts/arial.ttf"),
2285            "args must contain fontfile: {args}"
2286        );
2287    }
2288
2289    #[test]
2290    fn filter_step_drawtext_should_escape_colon_in_text() {
2291        let opts = DrawTextOptions {
2292            text: "Time: 12:00".to_string(),
2293            ..make_drawtext_opts()
2294        };
2295        let step = FilterStep::DrawText { opts };
2296        let args = step.args();
2297        assert!(
2298            args.contains("Time\\: 12\\:00"),
2299            "colons in text must be escaped: {args}"
2300        );
2301    }
2302
2303    #[test]
2304    fn filter_step_drawtext_should_escape_backslash_in_text() {
2305        let opts = DrawTextOptions {
2306            text: "path\\file".to_string(),
2307            ..make_drawtext_opts()
2308        };
2309        let step = FilterStep::DrawText { opts };
2310        let args = step.args();
2311        assert!(
2312            args.contains("path\\\\file"),
2313            "backslash in text must be escaped: {args}"
2314        );
2315    }
2316
2317    #[test]
2318    fn builder_drawtext_with_valid_opts_should_succeed() {
2319        let result = FilterGraph::builder()
2320            .drawtext(make_drawtext_opts())
2321            .build();
2322        assert!(
2323            result.is_ok(),
2324            "drawtext with valid opts must build successfully, got {result:?}"
2325        );
2326    }
2327
2328    #[test]
2329    fn builder_drawtext_with_empty_text_should_return_invalid_config() {
2330        let opts = DrawTextOptions {
2331            text: String::new(),
2332            ..make_drawtext_opts()
2333        };
2334        let result = FilterGraph::builder().drawtext(opts).build();
2335        assert!(
2336            matches!(result, Err(FilterError::InvalidConfig { .. })),
2337            "expected InvalidConfig for empty text, got {result:?}"
2338        );
2339        if let Err(FilterError::InvalidConfig { reason }) = result {
2340            assert!(
2341                reason.contains("text"),
2342                "reason should mention text: {reason}"
2343            );
2344        }
2345    }
2346
2347    #[test]
2348    fn builder_drawtext_with_opacity_too_high_should_return_invalid_config() {
2349        let opts = DrawTextOptions {
2350            opacity: 1.5,
2351            ..make_drawtext_opts()
2352        };
2353        let result = FilterGraph::builder().drawtext(opts).build();
2354        assert!(
2355            matches!(result, Err(FilterError::InvalidConfig { .. })),
2356            "expected InvalidConfig for opacity > 1.0, got {result:?}"
2357        );
2358    }
2359
2360    #[test]
2361    fn builder_drawtext_with_negative_opacity_should_return_invalid_config() {
2362        let opts = DrawTextOptions {
2363            opacity: -0.1,
2364            ..make_drawtext_opts()
2365        };
2366        let result = FilterGraph::builder().drawtext(opts).build();
2367        assert!(
2368            matches!(result, Err(FilterError::InvalidConfig { .. })),
2369            "expected InvalidConfig for opacity < 0.0, got {result:?}"
2370        );
2371    }
2372
2373    #[test]
2374    fn filter_step_subtitles_srt_should_produce_correct_filter_name() {
2375        let step = FilterStep::SubtitlesSrt {
2376            path: "subs.srt".to_owned(),
2377        };
2378        assert_eq!(step.filter_name(), "subtitles");
2379    }
2380
2381    #[test]
2382    fn filter_step_subtitles_srt_should_produce_correct_args() {
2383        let step = FilterStep::SubtitlesSrt {
2384            path: "subs.srt".to_owned(),
2385        };
2386        assert_eq!(step.args(), "filename=subs.srt");
2387    }
2388
2389    #[test]
2390    fn builder_subtitles_srt_with_wrong_extension_should_return_invalid_config() {
2391        let result = FilterGraph::builder()
2392            .subtitles_srt("subtitles.vtt")
2393            .build();
2394        assert!(
2395            matches!(result, Err(FilterError::InvalidConfig { .. })),
2396            "expected InvalidConfig for wrong extension, got {result:?}"
2397        );
2398        if let Err(FilterError::InvalidConfig { reason }) = result {
2399            assert!(
2400                reason.contains("unsupported subtitle format"),
2401                "reason should mention unsupported format: {reason}"
2402            );
2403        }
2404    }
2405
2406    #[test]
2407    fn builder_subtitles_srt_with_no_extension_should_return_invalid_config() {
2408        let result = FilterGraph::builder()
2409            .subtitles_srt("subtitles_no_ext")
2410            .build();
2411        assert!(
2412            matches!(result, Err(FilterError::InvalidConfig { .. })),
2413            "expected InvalidConfig for missing extension, got {result:?}"
2414        );
2415    }
2416
2417    #[test]
2418    fn builder_subtitles_srt_with_nonexistent_file_should_return_invalid_config() {
2419        let result = FilterGraph::builder()
2420            .subtitles_srt("/nonexistent/path/subs_ab12cd.srt")
2421            .build();
2422        assert!(
2423            matches!(result, Err(FilterError::InvalidConfig { .. })),
2424            "expected InvalidConfig for nonexistent file, got {result:?}"
2425        );
2426        if let Err(FilterError::InvalidConfig { reason }) = result {
2427            assert!(
2428                reason.contains("subtitle file not found"),
2429                "reason should mention file not found: {reason}"
2430            );
2431        }
2432    }
2433
2434    #[test]
2435    fn filter_step_subtitles_ass_should_produce_correct_filter_name() {
2436        let step = FilterStep::SubtitlesAss {
2437            path: "subs.ass".to_owned(),
2438        };
2439        assert_eq!(step.filter_name(), "ass");
2440    }
2441
2442    #[test]
2443    fn filter_step_subtitles_ass_should_produce_correct_args() {
2444        let step = FilterStep::SubtitlesAss {
2445            path: "subs.ass".to_owned(),
2446        };
2447        assert_eq!(step.args(), "filename=subs.ass");
2448    }
2449
2450    #[test]
2451    fn filter_step_subtitles_ssa_should_produce_correct_filter_name() {
2452        let step = FilterStep::SubtitlesAss {
2453            path: "subs.ssa".to_owned(),
2454        };
2455        assert_eq!(step.filter_name(), "ass");
2456    }
2457
2458    #[test]
2459    fn builder_subtitles_ass_with_wrong_extension_should_return_invalid_config() {
2460        let result = FilterGraph::builder()
2461            .subtitles_ass("subtitles.srt")
2462            .build();
2463        assert!(
2464            matches!(result, Err(FilterError::InvalidConfig { .. })),
2465            "expected InvalidConfig for wrong extension, got {result:?}"
2466        );
2467        if let Err(FilterError::InvalidConfig { reason }) = result {
2468            assert!(
2469                reason.contains("unsupported subtitle format"),
2470                "reason should mention unsupported format: {reason}"
2471            );
2472        }
2473    }
2474
2475    #[test]
2476    fn builder_subtitles_ass_with_no_extension_should_return_invalid_config() {
2477        let result = FilterGraph::builder()
2478            .subtitles_ass("subtitles_no_ext")
2479            .build();
2480        assert!(
2481            matches!(result, Err(FilterError::InvalidConfig { .. })),
2482            "expected InvalidConfig for missing extension, got {result:?}"
2483        );
2484    }
2485
2486    #[test]
2487    fn builder_subtitles_ass_with_nonexistent_file_should_return_invalid_config() {
2488        let result = FilterGraph::builder()
2489            .subtitles_ass("/nonexistent/path/subs_ab12cd.ass")
2490            .build();
2491        assert!(
2492            matches!(result, Err(FilterError::InvalidConfig { .. })),
2493            "expected InvalidConfig for nonexistent .ass file, got {result:?}"
2494        );
2495        if let Err(FilterError::InvalidConfig { reason }) = result {
2496            assert!(
2497                reason.contains("subtitle file not found"),
2498                "reason should mention file not found: {reason}"
2499            );
2500        }
2501    }
2502
2503    #[test]
2504    fn builder_subtitles_ssa_with_nonexistent_file_should_return_invalid_config() {
2505        let result = FilterGraph::builder()
2506            .subtitles_ass("/nonexistent/path/subs_ab12cd.ssa")
2507            .build();
2508        assert!(
2509            matches!(result, Err(FilterError::InvalidConfig { .. })),
2510            "expected InvalidConfig for nonexistent .ssa file, got {result:?}"
2511        );
2512    }
2513
2514    #[test]
2515    fn filter_step_overlay_image_should_produce_correct_filter_name() {
2516        let step = FilterStep::OverlayImage {
2517            path: "logo.png".to_owned(),
2518            x: "10".to_owned(),
2519            y: "10".to_owned(),
2520            opacity: 1.0,
2521        };
2522        assert_eq!(step.filter_name(), "overlay");
2523    }
2524
2525    #[test]
2526    fn filter_step_overlay_image_should_produce_correct_args() {
2527        let step = FilterStep::OverlayImage {
2528            path: "logo.png".to_owned(),
2529            x: "W-w-10".to_owned(),
2530            y: "H-h-10".to_owned(),
2531            opacity: 0.7,
2532        };
2533        assert_eq!(step.args(), "W-w-10:H-h-10");
2534    }
2535
2536    #[test]
2537    fn builder_overlay_image_with_wrong_extension_should_return_invalid_config() {
2538        let result = FilterGraph::builder()
2539            .overlay_image("logo.jpg", "10", "10", 1.0)
2540            .build();
2541        assert!(
2542            matches!(result, Err(FilterError::InvalidConfig { .. })),
2543            "expected InvalidConfig for wrong extension, got {result:?}"
2544        );
2545        if let Err(FilterError::InvalidConfig { reason }) = result {
2546            assert!(
2547                reason.contains("unsupported image format"),
2548                "reason should mention unsupported format: {reason}"
2549            );
2550        }
2551    }
2552
2553    #[test]
2554    fn builder_overlay_image_with_no_extension_should_return_invalid_config() {
2555        let result = FilterGraph::builder()
2556            .overlay_image("logo_no_ext", "10", "10", 1.0)
2557            .build();
2558        assert!(
2559            matches!(result, Err(FilterError::InvalidConfig { .. })),
2560            "expected InvalidConfig for missing extension, got {result:?}"
2561        );
2562    }
2563
2564    #[test]
2565    fn builder_overlay_image_with_nonexistent_file_should_return_invalid_config() {
2566        let result = FilterGraph::builder()
2567            .overlay_image("/nonexistent/path/logo_ab12cd.png", "10", "10", 1.0)
2568            .build();
2569        assert!(
2570            matches!(result, Err(FilterError::InvalidConfig { .. })),
2571            "expected InvalidConfig for nonexistent file, got {result:?}"
2572        );
2573        if let Err(FilterError::InvalidConfig { reason }) = result {
2574            assert!(
2575                reason.contains("overlay image not found"),
2576                "reason should mention file not found: {reason}"
2577            );
2578        }
2579    }
2580
2581    #[test]
2582    fn builder_overlay_image_with_opacity_above_1_should_return_invalid_config() {
2583        let result = FilterGraph::builder()
2584            .overlay_image("/nonexistent/logo.png", "10", "10", 1.1)
2585            .build();
2586        assert!(
2587            matches!(result, Err(FilterError::InvalidConfig { .. })),
2588            "expected InvalidConfig for opacity > 1.0, got {result:?}"
2589        );
2590        if let Err(FilterError::InvalidConfig { reason }) = result {
2591            assert!(
2592                reason.contains("opacity"),
2593                "reason should mention opacity: {reason}"
2594            );
2595        }
2596    }
2597
2598    #[test]
2599    fn builder_overlay_image_with_negative_opacity_should_return_invalid_config() {
2600        let result = FilterGraph::builder()
2601            .overlay_image("/nonexistent/logo.png", "10", "10", -0.1)
2602            .build();
2603        assert!(
2604            matches!(result, Err(FilterError::InvalidConfig { .. })),
2605            "expected InvalidConfig for opacity < 0.0, got {result:?}"
2606        );
2607    }
2608
2609    #[test]
2610    fn filter_step_ticker_should_produce_correct_filter_name() {
2611        let step = FilterStep::Ticker {
2612            text: "Breaking news".to_owned(),
2613            y: "h-50".to_owned(),
2614            speed_px_per_sec: 100.0,
2615            font_size: 24,
2616            font_color: "white".to_owned(),
2617        };
2618        assert_eq!(step.filter_name(), "drawtext");
2619    }
2620
2621    #[test]
2622    fn filter_step_ticker_should_produce_correct_args() {
2623        let step = FilterStep::Ticker {
2624            text: "Breaking news".to_owned(),
2625            y: "h-50".to_owned(),
2626            speed_px_per_sec: 100.0,
2627            font_size: 24,
2628            font_color: "white".to_owned(),
2629        };
2630        let args = step.args();
2631        assert!(
2632            args.contains("text='Breaking news'"),
2633            "args should contain escaped text: {args}"
2634        );
2635        assert!(
2636            args.contains("x=w-t*100"),
2637            "args should contain scrolling x expression: {args}"
2638        );
2639        assert!(args.contains("y=h-50"), "args should contain y: {args}");
2640        assert!(
2641            args.contains("fontsize=24"),
2642            "args should contain fontsize: {args}"
2643        );
2644        assert!(
2645            args.contains("fontcolor=white"),
2646            "args should contain fontcolor: {args}"
2647        );
2648    }
2649
2650    #[test]
2651    fn filter_step_ticker_should_escape_special_characters_in_text() {
2652        let step = FilterStep::Ticker {
2653            text: "colon:backslash\\apostrophe'".to_owned(),
2654            y: "10".to_owned(),
2655            speed_px_per_sec: 50.0,
2656            font_size: 20,
2657            font_color: "red".to_owned(),
2658        };
2659        let args = step.args();
2660        assert!(
2661            args.contains("\\:"),
2662            "colon should be escaped in args: {args}"
2663        );
2664        assert!(
2665            args.contains("\\'"),
2666            "apostrophe should be escaped in args: {args}"
2667        );
2668        assert!(
2669            args.contains("\\\\"),
2670            "backslash should be escaped in args: {args}"
2671        );
2672    }
2673
2674    #[test]
2675    fn builder_ticker_with_empty_text_should_return_invalid_config() {
2676        let result = FilterGraph::builder()
2677            .ticker("", "h-50", 100.0, 24, "white")
2678            .build();
2679        assert!(
2680            matches!(result, Err(FilterError::InvalidConfig { .. })),
2681            "expected InvalidConfig for empty text, got {result:?}"
2682        );
2683        if let Err(FilterError::InvalidConfig { reason }) = result {
2684            assert!(
2685                reason.contains("ticker text must not be empty"),
2686                "reason should mention empty text: {reason}"
2687            );
2688        }
2689    }
2690
2691    #[test]
2692    fn builder_ticker_with_zero_speed_should_return_invalid_config() {
2693        let result = FilterGraph::builder()
2694            .ticker("Breaking news", "h-50", 0.0, 24, "white")
2695            .build();
2696        assert!(
2697            matches!(result, Err(FilterError::InvalidConfig { .. })),
2698            "expected InvalidConfig for speed = 0.0, got {result:?}"
2699        );
2700        if let Err(FilterError::InvalidConfig { reason }) = result {
2701            assert!(
2702                reason.contains("speed_px_per_sec"),
2703                "reason should mention speed_px_per_sec: {reason}"
2704            );
2705        }
2706    }
2707
2708    #[test]
2709    fn builder_ticker_with_negative_speed_should_return_invalid_config() {
2710        let result = FilterGraph::builder()
2711            .ticker("Breaking news", "h-50", -50.0, 24, "white")
2712            .build();
2713        assert!(
2714            matches!(result, Err(FilterError::InvalidConfig { .. })),
2715            "expected InvalidConfig for negative speed, got {result:?}"
2716        );
2717    }
2718
2719    #[test]
2720    fn filter_step_speed_should_produce_correct_filter_name() {
2721        let step = FilterStep::Speed { factor: 2.0 };
2722        assert_eq!(step.filter_name(), "setpts");
2723    }
2724
2725    #[test]
2726    fn filter_step_speed_should_produce_correct_args_for_double_speed() {
2727        let step = FilterStep::Speed { factor: 2.0 };
2728        assert_eq!(step.args(), "PTS/2");
2729    }
2730
2731    #[test]
2732    fn filter_step_speed_should_produce_correct_args_for_half_speed() {
2733        let step = FilterStep::Speed { factor: 0.5 };
2734        assert_eq!(step.args(), "PTS/0.5");
2735    }
2736
2737    #[test]
2738    fn builder_speed_with_factor_below_minimum_should_return_invalid_config() {
2739        let result = FilterGraph::builder().speed(0.09).build();
2740        assert!(
2741            matches!(result, Err(FilterError::InvalidConfig { .. })),
2742            "expected InvalidConfig for factor below 0.1, got {result:?}"
2743        );
2744        if let Err(FilterError::InvalidConfig { reason }) = result {
2745            assert!(
2746                reason.contains("speed factor"),
2747                "reason should mention speed factor: {reason}"
2748            );
2749        }
2750    }
2751
2752    #[test]
2753    fn builder_speed_with_factor_above_maximum_should_return_invalid_config() {
2754        let result = FilterGraph::builder().speed(100.1).build();
2755        assert!(
2756            matches!(result, Err(FilterError::InvalidConfig { .. })),
2757            "expected InvalidConfig for factor above 100.0, got {result:?}"
2758        );
2759    }
2760
2761    #[test]
2762    fn builder_speed_with_zero_factor_should_return_invalid_config() {
2763        let result = FilterGraph::builder().speed(0.0).build();
2764        assert!(
2765            matches!(result, Err(FilterError::InvalidConfig { .. })),
2766            "expected InvalidConfig for factor 0.0, got {result:?}"
2767        );
2768    }
2769
2770    #[test]
2771    fn builder_speed_at_boundary_values_should_succeed() {
2772        let low = FilterGraph::builder().speed(0.1).build();
2773        assert!(low.is_ok(), "speed(0.1) should succeed, got {low:?}");
2774        let high = FilterGraph::builder().speed(100.0).build();
2775        assert!(high.is_ok(), "speed(100.0) should succeed, got {high:?}");
2776    }
2777
2778    #[test]
2779    fn filter_step_reverse_should_produce_correct_filter_name_and_empty_args() {
2780        let step = FilterStep::Reverse;
2781        assert_eq!(step.filter_name(), "reverse");
2782        assert_eq!(step.args(), "");
2783    }
2784
2785    #[test]
2786    fn builder_reverse_should_succeed() {
2787        let result = FilterGraph::builder().reverse().build();
2788        assert!(
2789            result.is_ok(),
2790            "reverse must build successfully, got {result:?}"
2791        );
2792    }
2793
2794    #[test]
2795    fn filter_step_freeze_frame_should_produce_correct_filter_name() {
2796        let step = FilterStep::FreezeFrame {
2797            pts: 2.0,
2798            duration: 3.0,
2799        };
2800        assert_eq!(step.filter_name(), "loop");
2801    }
2802
2803    #[test]
2804    fn filter_step_freeze_frame_should_produce_correct_args() {
2805        let step = FilterStep::FreezeFrame {
2806            pts: 2.0,
2807            duration: 3.0,
2808        };
2809        // 2.0s * 25fps = frame 50; 3.0s * 25fps = 75 loop iterations
2810        assert_eq!(step.args(), "loop=75:size=1:start=50");
2811    }
2812
2813    #[test]
2814    fn filter_step_freeze_frame_at_zero_pts_should_produce_start_zero() {
2815        let step = FilterStep::FreezeFrame {
2816            pts: 0.0,
2817            duration: 1.0,
2818        };
2819        assert_eq!(step.args(), "loop=25:size=1:start=0");
2820    }
2821
2822    #[test]
2823    fn builder_freeze_frame_with_valid_params_should_succeed() {
2824        let result = FilterGraph::builder().freeze_frame(2.0, 3.0).build();
2825        assert!(
2826            result.is_ok(),
2827            "freeze_frame(2.0, 3.0) must build successfully, got {result:?}"
2828        );
2829    }
2830
2831    #[test]
2832    fn builder_freeze_frame_with_negative_pts_should_return_invalid_config() {
2833        let result = FilterGraph::builder().freeze_frame(-1.0, 3.0).build();
2834        assert!(
2835            matches!(result, Err(FilterError::InvalidConfig { .. })),
2836            "expected InvalidConfig for negative pts, got {result:?}"
2837        );
2838    }
2839
2840    #[test]
2841    fn builder_freeze_frame_with_zero_duration_should_return_invalid_config() {
2842        let result = FilterGraph::builder().freeze_frame(2.0, 0.0).build();
2843        assert!(
2844            matches!(result, Err(FilterError::InvalidConfig { .. })),
2845            "expected InvalidConfig for zero duration, got {result:?}"
2846        );
2847    }
2848
2849    #[test]
2850    fn builder_freeze_frame_with_negative_duration_should_return_invalid_config() {
2851        let result = FilterGraph::builder().freeze_frame(2.0, -1.0).build();
2852        assert!(
2853            matches!(result, Err(FilterError::InvalidConfig { .. })),
2854            "expected InvalidConfig for negative duration, got {result:?}"
2855        );
2856    }
2857
2858    #[test]
2859    fn filter_step_concat_video_should_have_correct_filter_name() {
2860        let step = FilterStep::ConcatVideo { n: 2 };
2861        assert_eq!(step.filter_name(), "concat");
2862    }
2863
2864    #[test]
2865    fn filter_step_concat_video_should_produce_correct_args_for_n2() {
2866        let step = FilterStep::ConcatVideo { n: 2 };
2867        assert_eq!(step.args(), "n=2:v=1:a=0");
2868    }
2869
2870    #[test]
2871    fn filter_step_concat_video_should_produce_correct_args_for_n3() {
2872        let step = FilterStep::ConcatVideo { n: 3 };
2873        assert_eq!(step.args(), "n=3:v=1:a=0");
2874    }
2875
2876    #[test]
2877    fn builder_concat_video_valid_should_build_successfully() {
2878        let result = FilterGraph::builder().concat_video(2).build();
2879        assert!(
2880            result.is_ok(),
2881            "concat_video(2) must build successfully, got {result:?}"
2882        );
2883    }
2884
2885    #[test]
2886    fn builder_concat_video_with_n1_should_return_invalid_config() {
2887        let result = FilterGraph::builder().concat_video(1).build();
2888        assert!(
2889            matches!(result, Err(FilterError::InvalidConfig { .. })),
2890            "expected InvalidConfig for n=1, got {result:?}"
2891        );
2892    }
2893
2894    #[test]
2895    fn builder_concat_video_with_n0_should_return_invalid_config() {
2896        let result = FilterGraph::builder().concat_video(0).build();
2897        assert!(
2898            matches!(result, Err(FilterError::InvalidConfig { .. })),
2899            "expected InvalidConfig for n=0, got {result:?}"
2900        );
2901    }
2902
2903    #[test]
2904    fn filter_step_join_with_dissolve_should_have_correct_filter_name() {
2905        let step = FilterStep::JoinWithDissolve {
2906            clip_a_end: 4.0,
2907            clip_b_start: 1.0,
2908            dissolve_dur: 1.0,
2909        };
2910        assert_eq!(step.filter_name(), "xfade");
2911    }
2912
2913    #[test]
2914    fn filter_step_join_with_dissolve_should_produce_correct_args() {
2915        let step = FilterStep::JoinWithDissolve {
2916            clip_a_end: 4.0,
2917            clip_b_start: 1.0,
2918            dissolve_dur: 1.0,
2919        };
2920        assert_eq!(
2921            step.args(),
2922            "transition=dissolve:duration=1:offset=4",
2923            "args must match xfade format for join_with_dissolve"
2924        );
2925    }
2926
2927    #[test]
2928    fn builder_join_with_dissolve_valid_should_build_successfully() {
2929        let result = FilterGraph::builder()
2930            .join_with_dissolve(4.0, 1.0, 1.0)
2931            .build();
2932        assert!(
2933            result.is_ok(),
2934            "join_with_dissolve(4.0, 1.0, 1.0) must build successfully, got {result:?}"
2935        );
2936    }
2937
2938    #[test]
2939    fn builder_join_with_dissolve_with_zero_dissolve_dur_should_return_invalid_config() {
2940        let result = FilterGraph::builder()
2941            .join_with_dissolve(4.0, 1.0, 0.0)
2942            .build();
2943        assert!(
2944            matches!(result, Err(FilterError::InvalidConfig { .. })),
2945            "expected InvalidConfig for dissolve_dur=0.0, got {result:?}"
2946        );
2947    }
2948
2949    #[test]
2950    fn builder_join_with_dissolve_with_negative_dissolve_dur_should_return_invalid_config() {
2951        let result = FilterGraph::builder()
2952            .join_with_dissolve(4.0, 1.0, -1.0)
2953            .build();
2954        assert!(
2955            matches!(result, Err(FilterError::InvalidConfig { .. })),
2956            "expected InvalidConfig for dissolve_dur=-1.0, got {result:?}"
2957        );
2958    }
2959
2960    #[test]
2961    fn builder_join_with_dissolve_with_zero_clip_a_end_should_return_invalid_config() {
2962        let result = FilterGraph::builder()
2963            .join_with_dissolve(0.0, 1.0, 1.0)
2964            .build();
2965        assert!(
2966            matches!(result, Err(FilterError::InvalidConfig { .. })),
2967            "expected InvalidConfig for clip_a_end=0.0, got {result:?}"
2968        );
2969    }
2970}