Skip to main content

ff_filter/effects/
video_effects.rs

1//! Frame-level video effects added to [`FilterGraph`] after construction.
2
3use crate::effects::lens_profile::LensProfile;
4use crate::error::FilterError;
5use crate::graph::FilterGraph;
6use crate::graph::filter_step::FilterStep;
7
8impl FilterGraph {
9    /// Simulate motion blur by blending multiple consecutive frames.
10    ///
11    /// `shutter_angle_degrees` controls the blend ratio (360° = full
12    /// frame-period exposure). `sub_frames` sets the number of frames blended
13    /// and must be in [2, 16].
14    ///
15    /// Uses `FFmpeg`'s `tblend` filter with `all_expr`:
16    /// the normalised shutter angle becomes the weight for the previous frame
17    /// (`B`), and its complement weights the current frame (`A`).
18    ///
19    /// Call this method after [`FilterGraph::builder()`] / [`build()`] but
20    /// **before** the first [`push_video`] call.
21    ///
22    /// # Errors
23    ///
24    /// Returns [`FilterError::Ffmpeg`] if `sub_frames` is outside [2, 16].
25    ///
26    /// [`build()`]: crate::FilterGraphBuilder::build
27    /// [`push_video`]: FilterGraph::push_video
28    pub fn motion_blur(
29        &mut self,
30        shutter_angle_degrees: f32,
31        sub_frames: u8,
32    ) -> Result<&mut Self, FilterError> {
33        if !(2..=16).contains(&sub_frames) {
34            return Err(FilterError::Ffmpeg {
35                code: 0,
36                message: format!("sub_frames must be 2–16, got {sub_frames}"),
37            });
38        }
39        self.inner.push_step(FilterStep::MotionBlur {
40            shutter_angle_degrees,
41            sub_frames,
42        });
43        Ok(self)
44    }
45
46    /// Correct radial lens distortion using two polynomial coefficients.
47    ///
48    /// `k1` and `k2` are the first- and second-order radial distortion
49    /// coefficients. Negative values correct barrel distortion; positive values
50    /// correct pincushion distortion.
51    ///
52    /// Uses `FFmpeg`'s `lenscorrection` filter.
53    ///
54    /// Call this method after [`FilterGraph::builder()`] / [`build()`] but
55    /// **before** the first [`push_video`] call.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`FilterError::Ffmpeg`] if either coefficient is outside [−1.0, 1.0].
60    ///
61    /// [`build()`]: crate::FilterGraphBuilder::build
62    /// [`push_video`]: FilterGraph::push_video
63    pub fn lens_correction(&mut self, k1: f32, k2: f32) -> Result<&mut Self, FilterError> {
64        if !(-1.0..=1.0).contains(&k1) || !(-1.0..=1.0).contains(&k2) {
65            return Err(FilterError::Ffmpeg {
66                code: 0,
67                message: format!("k1/k2 must be in −1.0..=1.0, got k1={k1} k2={k2}"),
68            });
69        }
70        self.inner.push_step(FilterStep::LensCorrection { k1, k2 });
71        Ok(self)
72    }
73
74    /// Add random per-frame film grain to luma and chroma channels.
75    ///
76    /// `luma_strength` and `chroma_strength` control grain intensity and are
77    /// clamped to [0.0, 100.0]. The `allf=t` flag varies the noise seed each
78    /// frame to simulate real film grain temporal variation.
79    ///
80    /// Uses `FFmpeg`'s `noise` filter with `alls` (luma), `c0s`/`c1s` (Cb/Cr),
81    /// and `allf=t` (per-frame seed).
82    ///
83    /// Call this method after [`FilterGraph::builder()`] / [`build()`] but
84    /// **before** the first [`push_video`] call.
85    ///
86    /// [`build()`]: crate::FilterGraphBuilder::build
87    /// [`push_video`]: FilterGraph::push_video
88    pub fn film_grain(&mut self, luma_strength: f32, chroma_strength: f32) -> &mut Self {
89        self.inner.push_step(FilterStep::FilmGrain {
90            luma_strength,
91            chroma_strength,
92        });
93        self
94    }
95
96    /// Reduce lateral chromatic aberration by independently scaling R and B channels.
97    ///
98    /// `red_scale` and `blue_scale` are fractional adjustments relative to 1.0
99    /// (e.g. `red_scale = 1.002` scales R by 0.2%). Valid range for each: 0.9–1.1.
100    ///
101    /// The scale deviation is converted to an integer pixel shift for `FFmpeg`'s
102    /// `rgbashift` filter: `shift = ((scale - 1.0) * 100.0).round()`.
103    ///
104    /// Uses `FFmpeg`'s `rgbashift` filter with `edge=smear`.
105    ///
106    /// Call this method after [`FilterGraph::builder()`] / [`build()`] but
107    /// **before** the first [`push_video`] call.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`FilterError::Ffmpeg`] if either scale is outside [0.9, 1.1].
112    ///
113    /// [`build()`]: crate::FilterGraphBuilder::build
114    /// [`push_video`]: FilterGraph::push_video
115    pub fn fix_chromatic_aberration(
116        &mut self,
117        red_scale: f32,
118        blue_scale: f32,
119    ) -> Result<&mut Self, FilterError> {
120        if !(0.9..=1.1).contains(&red_scale) || !(0.9..=1.1).contains(&blue_scale) {
121            return Err(FilterError::Ffmpeg {
122                code: 0,
123                message: format!(
124                    "red_scale/blue_scale must be in 0.9–1.1, got red={red_scale} blue={blue_scale}"
125                ),
126            });
127        }
128        #[allow(clippy::cast_possible_truncation)]
129        let rh = ((red_scale - 1.0) * 100.0).round() as i32;
130        #[allow(clippy::cast_possible_truncation)]
131        let bh = ((blue_scale - 1.0) * 100.0).round() as i32;
132        self.inner
133            .push_step(FilterStep::ChromaticAberration { rh, bh });
134        Ok(self)
135    }
136
137    /// Add a glow / bloom effect by blending blurred highlights back over the image.
138    ///
139    /// `threshold` controls which luminance level triggers glow (clamped to [0.0, 1.0]).
140    /// `radius` is the Gaussian blur sigma in pixels (clamped to [0.5, 50.0]).
141    /// `intensity` is the additive blend strength (clamped to [0.0, 2.0]).
142    ///
143    /// Values outside the valid ranges are silently clamped — no error is returned.
144    ///
145    /// Uses `FFmpeg`'s `split`, `curves`, `gblur`, and `blend` filters.
146    ///
147    /// Call this method after [`FilterGraph::builder()`] / [`build()`] but
148    /// **before** the first [`push_video`] call.
149    ///
150    /// [`build()`]: crate::FilterGraphBuilder::build
151    /// [`push_video`]: FilterGraph::push_video
152    pub fn glow(&mut self, threshold: f32, radius: f32, intensity: f32) -> &mut Self {
153        self.inner.push_step(FilterStep::Glow {
154            threshold,
155            radius,
156            intensity,
157        });
158        self
159    }
160
161    /// Apply a predefined camera lens distortion correction profile.
162    ///
163    /// Looks up the radial coefficients (`k1`, `k2`) and `scale` from the
164    /// profile and pushes a `lenscorrection` step followed by a `scale` step
165    /// that zooms slightly to hide the warped border pixels.
166    ///
167    /// Uses `FFmpeg`'s `lenscorrection` and `scale` filters.
168    ///
169    /// Call this method after [`FilterGraph::builder()`] / [`build()`] but
170    /// **before** the first [`push_video`] call.
171    ///
172    /// [`build()`]: crate::FilterGraphBuilder::build
173    /// [`push_video`]: FilterGraph::push_video
174    pub fn lens_profile(&mut self, profile: LensProfile) -> &mut Self {
175        let (k1, k2, scale) = profile.coefficients();
176        self.inner.push_step(FilterStep::LensCorrection { k1, k2 });
177        self.inner
178            .push_step(FilterStep::ScaleMultiplier { factor: scale });
179        self
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use crate::effects::lens_profile::LensProfile;
186    use crate::graph::filter_step::FilterStep;
187    use crate::{FilterError, FilterGraph};
188
189    #[test]
190    fn motion_blur_with_valid_params_should_succeed() {
191        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
192        let result = graph.motion_blur(180.0, 2);
193        assert!(
194            result.is_ok(),
195            "motion_blur(180.0, 2) must succeed, got {result:?}"
196        );
197    }
198
199    #[test]
200    fn motion_blur_with_sub_frames_one_should_return_ffmpeg_error() {
201        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
202        let result = graph.motion_blur(180.0, 1);
203        assert!(
204            matches!(result, Err(FilterError::Ffmpeg { .. })),
205            "sub_frames=1 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
206        );
207    }
208
209    #[test]
210    fn motion_blur_with_sub_frames_seventeen_should_return_ffmpeg_error() {
211        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
212        let result = graph.motion_blur(180.0, 17);
213        assert!(
214            matches!(result, Err(FilterError::Ffmpeg { .. })),
215            "sub_frames=17 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
216        );
217    }
218
219    #[test]
220    fn filter_step_motion_blur_should_have_tblend_filter_name() {
221        let step = FilterStep::MotionBlur {
222            shutter_angle_degrees: 180.0,
223            sub_frames: 4,
224        };
225        assert_eq!(step.filter_name(), "tblend");
226    }
227
228    #[test]
229    fn motion_blur_zero_angle_should_produce_identity_blend_args() {
230        let step = FilterStep::MotionBlur {
231            shutter_angle_degrees: 0.0,
232            sub_frames: 2,
233        };
234        let args = step.args();
235        assert!(
236            args.contains("A*1") && args.contains("B*0"),
237            "0° shutter angle must produce identity blend (A*1+B*0): {args}"
238        );
239    }
240
241    #[test]
242    fn motion_blur_full_angle_should_produce_full_blend_args() {
243        let step = FilterStep::MotionBlur {
244            shutter_angle_degrees: 360.0,
245            sub_frames: 2,
246        };
247        let args = step.args();
248        assert!(
249            args.contains("A*0+B*1"),
250            "360° shutter angle must produce full blend (A*0+B*1): {args}"
251        );
252    }
253
254    #[test]
255    fn motion_blur_half_angle_should_produce_equal_blend_args() {
256        let step = FilterStep::MotionBlur {
257            shutter_angle_degrees: 180.0,
258            sub_frames: 2,
259        };
260        let args = step.args();
261        assert!(
262            args.contains("A*0.5+B*0.5"),
263            "180° shutter angle must produce equal blend (A*0.5+B*0.5): {args}"
264        );
265    }
266
267    // ── lens_correction ───────────────────────────────────────────────────────
268
269    #[test]
270    fn lens_correction_with_valid_coefficients_should_succeed() {
271        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
272        let result = graph.lens_correction(-0.2, 0.0);
273        assert!(
274            result.is_ok(),
275            "lens_correction(-0.2, 0.0) must succeed, got {result:?}"
276        );
277    }
278
279    #[test]
280    fn lens_correction_identity_k1_zero_k2_zero_should_succeed() {
281        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
282        let result = graph.lens_correction(0.0, 0.0);
283        assert!(
284            result.is_ok(),
285            "lens_correction(0.0, 0.0) identity must succeed, got {result:?}"
286        );
287    }
288
289    #[test]
290    fn lens_correction_k1_out_of_range_should_return_ffmpeg_error() {
291        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
292        let result = graph.lens_correction(1.5, 0.0);
293        assert!(
294            matches!(result, Err(FilterError::Ffmpeg { .. })),
295            "k1=1.5 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
296        );
297    }
298
299    #[test]
300    fn lens_correction_k2_out_of_range_should_return_ffmpeg_error() {
301        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
302        let result = graph.lens_correction(0.0, -1.5);
303        assert!(
304            matches!(result, Err(FilterError::Ffmpeg { .. })),
305            "k2=-1.5 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
306        );
307    }
308
309    #[test]
310    fn filter_step_lens_correction_should_have_lenscorrection_filter_name() {
311        let step = FilterStep::LensCorrection { k1: -0.2, k2: 0.0 };
312        assert_eq!(step.filter_name(), "lenscorrection");
313    }
314
315    #[test]
316    fn lens_correction_args_should_contain_k1_and_k2() {
317        let step = FilterStep::LensCorrection { k1: -0.2, k2: 0.1 };
318        let args = step.args();
319        assert!(
320            args.contains("k1=-0.2"),
321            "args must contain k1=-0.2: {args}"
322        );
323        assert!(args.contains("k2=0.1"), "args must contain k2=0.1: {args}");
324    }
325
326    // ── film_grain ────────────────────────────────────────────────────────────
327
328    #[test]
329    fn film_grain_with_valid_params_should_return_mutable_self() {
330        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
331        let result = graph.film_grain(20.0, 5.0);
332        // Method returns &mut Self — confirm it compiles and doesn't panic.
333        let _ = result;
334    }
335
336    #[test]
337    fn filter_step_film_grain_should_have_noise_filter_name() {
338        let step = FilterStep::FilmGrain {
339            luma_strength: 20.0,
340            chroma_strength: 5.0,
341        };
342        assert_eq!(step.filter_name(), "noise");
343    }
344
345    #[test]
346    fn film_grain_args_should_contain_alls_c0s_c1s_and_allf_t() {
347        let step = FilterStep::FilmGrain {
348            luma_strength: 20.0,
349            chroma_strength: 5.0,
350        };
351        let args = step.args();
352        assert!(
353            args.contains("alls=20"),
354            "args must contain alls=20: {args}"
355        );
356        assert!(args.contains("c0s=5"), "args must contain c0s=5: {args}");
357        assert!(args.contains("c1s=5"), "args must contain c1s=5: {args}");
358        assert!(args.contains("allf=t"), "args must contain allf=t: {args}");
359    }
360
361    #[test]
362    fn film_grain_zero_strength_should_produce_zero_alls() {
363        let step = FilterStep::FilmGrain {
364            luma_strength: 0.0,
365            chroma_strength: 0.0,
366        };
367        let args = step.args();
368        assert_eq!(args, "alls=0:c0s=0:c1s=0:allf=t");
369    }
370
371    #[test]
372    fn film_grain_values_above_100_should_be_clamped_to_100() {
373        let step = FilterStep::FilmGrain {
374            luma_strength: 200.0,
375            chroma_strength: 999.0,
376        };
377        let args = step.args();
378        assert!(
379            args.contains("alls=100"),
380            "luma_strength > 100 must clamp to 100: {args}"
381        );
382        assert!(
383            args.contains("c0s=100") && args.contains("c1s=100"),
384            "chroma_strength > 100 must clamp to 100: {args}"
385        );
386    }
387
388    #[test]
389    fn film_grain_negative_values_should_be_clamped_to_zero() {
390        let step = FilterStep::FilmGrain {
391            luma_strength: -50.0,
392            chroma_strength: -10.0,
393        };
394        let args = step.args();
395        assert_eq!(args, "alls=0:c0s=0:c1s=0:allf=t");
396    }
397
398    // ── lens_profile ──────────────────────────────────────────────────────────
399
400    #[test]
401    fn lens_profile_gopro_hero9_wide_should_push_two_steps() {
402        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
403        let result = graph.lens_profile(LensProfile::GoproHero9Wide);
404        let _ = result; // returns &mut Self
405    }
406
407    #[test]
408    fn lens_profile_custom_should_push_lens_correction_step() {
409        let step = FilterStep::LensCorrection { k1: -0.1, k2: 0.02 };
410        assert_eq!(step.filter_name(), "lenscorrection");
411        assert!(step.args().contains("k1=-0.1"));
412        assert!(step.args().contains("k2=0.02"));
413    }
414
415    #[test]
416    fn lens_profile_scale_multiplier_should_have_scale_filter_name() {
417        let step = FilterStep::ScaleMultiplier { factor: 1.05 };
418        assert_eq!(step.filter_name(), "scale");
419    }
420
421    #[test]
422    fn lens_profile_scale_multiplier_args_should_contain_factor() {
423        let step = FilterStep::ScaleMultiplier { factor: 1.05 };
424        let args = step.args();
425        assert!(
426            args.contains("iw*1.05") && args.contains("ih*1.05"),
427            "ScaleMultiplier args must reference iw*factor and ih*factor: {args}"
428        );
429    }
430
431    #[test]
432    fn lens_profile_identity_custom_should_use_unit_scale() {
433        let step = FilterStep::ScaleMultiplier { factor: 1.0 };
434        let args = step.args();
435        assert_eq!(args, "w=iw*1:h=ih*1");
436    }
437
438    // ── fix_chromatic_aberration ──────────────────────────────────────────────
439
440    #[test]
441    fn fix_chromatic_aberration_with_valid_scales_should_succeed() {
442        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
443        let result = graph.fix_chromatic_aberration(1.002, 0.998);
444        assert!(
445            result.is_ok(),
446            "fix_chromatic_aberration(1.002, 0.998) must succeed, got {result:?}"
447        );
448    }
449
450    #[test]
451    fn fix_chromatic_aberration_identity_should_succeed() {
452        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
453        let result = graph.fix_chromatic_aberration(1.0, 1.0);
454        assert!(
455            result.is_ok(),
456            "fix_chromatic_aberration(1.0, 1.0) identity must succeed, got {result:?}"
457        );
458    }
459
460    #[test]
461    fn fix_chromatic_aberration_red_scale_out_of_range_should_return_ffmpeg_error() {
462        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
463        let result = graph.fix_chromatic_aberration(1.2, 1.0);
464        assert!(
465            matches!(result, Err(FilterError::Ffmpeg { .. })),
466            "red_scale=1.2 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
467        );
468    }
469
470    #[test]
471    fn fix_chromatic_aberration_blue_scale_out_of_range_should_return_ffmpeg_error() {
472        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
473        let result = graph.fix_chromatic_aberration(1.0, 0.8);
474        assert!(
475            matches!(result, Err(FilterError::Ffmpeg { .. })),
476            "blue_scale=0.8 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
477        );
478    }
479
480    #[test]
481    fn filter_step_chromatic_aberration_should_have_rgbashift_filter_name() {
482        let step = FilterStep::ChromaticAberration { rh: 2, bh: -2 };
483        assert_eq!(step.filter_name(), "rgbashift");
484    }
485
486    #[test]
487    fn fix_chromatic_aberration_args_should_contain_rh_bh_and_edge_smear() {
488        let step = FilterStep::ChromaticAberration { rh: 2, bh: -2 };
489        let args = step.args();
490        assert!(args.contains("rh=2"), "args must contain rh=2: {args}");
491        assert!(args.contains("bh=-2"), "args must contain bh=-2: {args}");
492        assert!(
493            args.contains("edge=smear"),
494            "args must contain edge=smear: {args}"
495        );
496    }
497
498    #[test]
499    fn fix_chromatic_aberration_identity_scale_should_produce_zero_shifts() {
500        let step = FilterStep::ChromaticAberration { rh: 0, bh: 0 };
501        let args = step.args();
502        assert_eq!(args, "rh=0:bh=0:edge=smear");
503    }
504
505    // ── glow ──────────────────────────────────────────────────────────────────
506
507    #[test]
508    fn glow_with_valid_params_should_return_mutable_self() {
509        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
510        let result = graph.glow(0.8, 10.0, 0.8);
511        let _ = result;
512    }
513
514    #[test]
515    fn glow_identity_zero_intensity_should_succeed() {
516        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
517        let result = graph.glow(0.8, 10.0, 0.0);
518        let _ = result;
519    }
520
521    #[test]
522    fn filter_step_glow_should_have_split_filter_name() {
523        let step = FilterStep::Glow {
524            threshold: 0.8,
525            radius: 10.0,
526            intensity: 0.8,
527        };
528        assert_eq!(step.filter_name(), "split");
529    }
530
531    #[test]
532    fn glow_args_should_contain_threshold_radius_intensity() {
533        let step = FilterStep::Glow {
534            threshold: 0.8,
535            radius: 10.0,
536            intensity: 0.8,
537        };
538        let args = step.args();
539        assert!(
540            args.contains("0.8/0"),
541            "args must contain threshold in curve: {args}"
542        );
543        assert!(
544            args.contains("sigma=10"),
545            "args must contain sigma=10: {args}"
546        );
547        assert!(
548            args.contains("all_opacity=0.8"),
549            "args must contain all_opacity=0.8: {args}"
550        );
551        assert!(
552            args.contains("all_mode=addition"),
553            "args must contain all_mode=addition: {args}"
554        );
555    }
556
557    #[test]
558    fn glow_threshold_above_one_should_be_clamped() {
559        let step = FilterStep::Glow {
560            threshold: 1.1,
561            radius: 5.0,
562            intensity: 1.0,
563        };
564        let args = step.args();
565        assert!(
566            args.contains("1/0"),
567            "threshold=1.1 must clamp to 1.0 in curve (1/0): {args}"
568        );
569    }
570
571    #[test]
572    fn glow_radius_below_min_should_be_clamped_to_half() {
573        let step = FilterStep::Glow {
574            threshold: 0.5,
575            radius: 0.1,
576            intensity: 1.0,
577        };
578        let args = step.args();
579        assert!(
580            args.contains("sigma=0.5"),
581            "radius=0.1 must clamp to 0.5: {args}"
582        );
583    }
584
585    #[test]
586    fn glow_intensity_above_two_should_be_clamped() {
587        let step = FilterStep::Glow {
588            threshold: 0.5,
589            radius: 5.0,
590            intensity: 5.0,
591        };
592        let args = step.args();
593        assert!(
594            args.contains("all_opacity=2"),
595            "intensity=5.0 must clamp to 2.0: {args}"
596        );
597    }
598}