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]
399 fn film_grain_animated_static_should_render_the_zero_time_strength() {
400 use crate::animation::AnimatedValue;
401 let step = FilterStep::FilmGrainAnimated {
402 luma_strength: AnimatedValue::Static(20.0_f64),
403 chroma_strength: AnimatedValue::Static(5.0_f64),
404 };
405 assert_eq!(step.filter_name(), "noise");
406 assert_eq!(step.args(), "alls=20:c0s=5:c1s=5:allf=t");
408 }
409
410 #[test]
413 fn lens_profile_gopro_hero9_wide_should_push_two_steps() {
414 let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
415 let result = graph.lens_profile(LensProfile::GoproHero9Wide);
416 let _ = result; }
418
419 #[test]
420 fn lens_profile_custom_should_push_lens_correction_step() {
421 let step = FilterStep::LensCorrection { k1: -0.1, k2: 0.02 };
422 assert_eq!(step.filter_name(), "lenscorrection");
423 assert!(step.args().contains("k1=-0.1"));
424 assert!(step.args().contains("k2=0.02"));
425 }
426
427 #[test]
428 fn lens_profile_scale_multiplier_should_have_scale_filter_name() {
429 let step = FilterStep::ScaleMultiplier { factor: 1.05 };
430 assert_eq!(step.filter_name(), "scale");
431 }
432
433 #[test]
434 fn lens_profile_scale_multiplier_args_should_contain_factor() {
435 let step = FilterStep::ScaleMultiplier { factor: 1.05 };
436 let args = step.args();
437 assert!(
438 args.contains("iw*1.05") && args.contains("ih*1.05"),
439 "ScaleMultiplier args must reference iw*factor and ih*factor: {args}"
440 );
441 }
442
443 #[test]
444 fn lens_profile_identity_custom_should_use_unit_scale() {
445 let step = FilterStep::ScaleMultiplier { factor: 1.0 };
446 let args = step.args();
447 assert_eq!(args, "w=iw*1:h=ih*1");
448 }
449
450 #[test]
453 fn fix_chromatic_aberration_with_valid_scales_should_succeed() {
454 let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
455 let result = graph.fix_chromatic_aberration(1.002, 0.998);
456 assert!(
457 result.is_ok(),
458 "fix_chromatic_aberration(1.002, 0.998) must succeed, got {result:?}"
459 );
460 }
461
462 #[test]
463 fn fix_chromatic_aberration_identity_should_succeed() {
464 let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
465 let result = graph.fix_chromatic_aberration(1.0, 1.0);
466 assert!(
467 result.is_ok(),
468 "fix_chromatic_aberration(1.0, 1.0) identity must succeed, got {result:?}"
469 );
470 }
471
472 #[test]
473 fn fix_chromatic_aberration_red_scale_out_of_range_should_return_ffmpeg_error() {
474 let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
475 let result = graph.fix_chromatic_aberration(1.2, 1.0);
476 assert!(
477 matches!(result, Err(FilterError::Ffmpeg { .. })),
478 "red_scale=1.2 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
479 );
480 }
481
482 #[test]
483 fn fix_chromatic_aberration_blue_scale_out_of_range_should_return_ffmpeg_error() {
484 let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
485 let result = graph.fix_chromatic_aberration(1.0, 0.8);
486 assert!(
487 matches!(result, Err(FilterError::Ffmpeg { .. })),
488 "blue_scale=0.8 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
489 );
490 }
491
492 #[test]
493 fn filter_step_chromatic_aberration_should_have_rgbashift_filter_name() {
494 let step = FilterStep::ChromaticAberration { rh: 2, bh: -2 };
495 assert_eq!(step.filter_name(), "rgbashift");
496 }
497
498 #[test]
499 fn fix_chromatic_aberration_args_should_contain_rh_bh_and_edge_smear() {
500 let step = FilterStep::ChromaticAberration { rh: 2, bh: -2 };
501 let args = step.args();
502 assert!(args.contains("rh=2"), "args must contain rh=2: {args}");
503 assert!(args.contains("bh=-2"), "args must contain bh=-2: {args}");
504 assert!(
505 args.contains("edge=smear"),
506 "args must contain edge=smear: {args}"
507 );
508 }
509
510 #[test]
511 fn fix_chromatic_aberration_identity_scale_should_produce_zero_shifts() {
512 let step = FilterStep::ChromaticAberration { rh: 0, bh: 0 };
513 let args = step.args();
514 assert_eq!(args, "rh=0:bh=0:edge=smear");
515 }
516
517 #[test]
520 fn glow_with_valid_params_should_return_mutable_self() {
521 let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
522 let result = graph.glow(0.8, 10.0, 0.8);
523 let _ = result;
524 }
525
526 #[test]
527 fn glow_identity_zero_intensity_should_succeed() {
528 let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
529 let result = graph.glow(0.8, 10.0, 0.0);
530 let _ = result;
531 }
532
533 #[test]
534 fn filter_step_glow_should_have_split_filter_name() {
535 let step = FilterStep::Glow {
536 threshold: 0.8,
537 radius: 10.0,
538 intensity: 0.8,
539 };
540 assert_eq!(step.filter_name(), "split");
541 }
542
543 #[test]
544 fn glow_args_should_contain_threshold_radius_intensity() {
545 let step = FilterStep::Glow {
546 threshold: 0.8,
547 radius: 10.0,
548 intensity: 0.8,
549 };
550 let args = step.args();
551 assert!(
552 args.contains("0.8/0"),
553 "args must contain threshold in curve: {args}"
554 );
555 assert!(
556 args.contains("sigma=10"),
557 "args must contain sigma=10: {args}"
558 );
559 assert!(
560 args.contains("all_opacity=0.8"),
561 "args must contain all_opacity=0.8: {args}"
562 );
563 assert!(
564 args.contains("all_mode=addition"),
565 "args must contain all_mode=addition: {args}"
566 );
567 }
568
569 #[test]
570 fn glow_animated_static_should_render_the_zero_time_compound() {
571 use crate::animation::AnimatedValue;
572 let step = FilterStep::GlowAnimated {
573 threshold: AnimatedValue::Static(0.8_f64),
574 radius: AnimatedValue::Static(10.0_f64),
575 intensity: AnimatedValue::Static(0.8_f64),
576 };
577 assert_eq!(step.filter_name(), "split");
578 let args = step.args();
580 assert!(
581 args.contains("0.8/0") && args.contains("sigma=10"),
582 "{args}"
583 );
584 assert!(args.contains("all_opacity=0.8"), "{args}");
585 }
586
587 #[test]
588 fn glow_threshold_above_one_should_be_clamped() {
589 let step = FilterStep::Glow {
590 threshold: 1.1,
591 radius: 5.0,
592 intensity: 1.0,
593 };
594 let args = step.args();
595 assert!(
596 args.contains("1/0"),
597 "threshold=1.1 must clamp to 1.0 in curve (1/0): {args}"
598 );
599 }
600
601 #[test]
602 fn glow_radius_below_min_should_be_clamped_to_half() {
603 let step = FilterStep::Glow {
604 threshold: 0.5,
605 radius: 0.1,
606 intensity: 1.0,
607 };
608 let args = step.args();
609 assert!(
610 args.contains("sigma=0.5"),
611 "radius=0.1 must clamp to 0.5: {args}"
612 );
613 }
614
615 #[test]
616 fn glow_intensity_above_two_should_be_clamped() {
617 let step = FilterStep::Glow {
618 threshold: 0.5,
619 radius: 5.0,
620 intensity: 5.0,
621 };
622 let args = step.args();
623 assert!(
624 args.contains("all_opacity=2"),
625 "intensity=5.0 must clamp to 2.0: {args}"
626 );
627 }
628}