1use crate::effects::lens_profile::LensProfile;
4use crate::error::FilterError;
5use crate::graph::FilterGraph;
6use crate::graph::filter_step::FilterStep;
7
8impl FilterGraph {
9 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 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 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 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 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 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 #[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 #[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 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 #[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; }
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 #[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 #[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}