1use std::path::Path;
4
5pub(super) use super::FilterGraph;
6pub(super) use super::filter_step::FilterStep;
7pub(super) use super::types::{
8 DrawTextOptions, EqBand, HwAccel, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
9};
10pub(super) use crate::error::FilterError;
11use crate::filter_inner::FilterGraphInner;
12
13mod audio;
14mod video;
15
16#[derive(Debug, Default)]
34pub struct FilterGraphBuilder {
35 pub(super) steps: Vec<FilterStep>,
36 pub(super) hw: Option<HwAccel>,
37}
38
39impl FilterGraphBuilder {
40 #[must_use]
42 pub fn new() -> Self {
43 Self::default()
44 }
45
46 #[must_use]
51 pub fn hardware(mut self, hw: HwAccel) -> Self {
52 self.hw = Some(hw);
53 self
54 }
55
56 pub fn build(self) -> Result<FilterGraph, FilterError> {
67 if self.steps.is_empty() {
68 return Err(FilterError::BuildFailed);
69 }
70
71 for step in &self.steps {
76 if let FilterStep::ParametricEq { bands } = step
77 && bands.is_empty()
78 {
79 return Err(FilterError::InvalidConfig {
80 reason: "equalizer bands must not be empty".to_string(),
81 });
82 }
83 if let FilterStep::Speed { factor } = step
84 && !(0.1..=100.0).contains(factor)
85 {
86 return Err(FilterError::InvalidConfig {
87 reason: format!("speed factor {factor} out of range [0.1, 100.0]"),
88 });
89 }
90 if let FilterStep::LoudnessNormalize {
91 target_lufs,
92 true_peak_db,
93 lra,
94 } = step
95 {
96 if *target_lufs >= 0.0 {
97 return Err(FilterError::InvalidConfig {
98 reason: format!(
99 "loudness_normalize target_lufs {target_lufs} must be < 0.0"
100 ),
101 });
102 }
103 if *true_peak_db > 0.0 {
104 return Err(FilterError::InvalidConfig {
105 reason: format!(
106 "loudness_normalize true_peak_db {true_peak_db} must be <= 0.0"
107 ),
108 });
109 }
110 if *lra <= 0.0 {
111 return Err(FilterError::InvalidConfig {
112 reason: format!("loudness_normalize lra {lra} must be > 0.0"),
113 });
114 }
115 }
116 if let FilterStep::NormalizePeak { target_db } = step
117 && *target_db > 0.0
118 {
119 return Err(FilterError::InvalidConfig {
120 reason: format!("normalize_peak target_db {target_db} must be <= 0.0"),
121 });
122 }
123 if let FilterStep::FreezeFrame { pts, duration } = step {
124 if *pts < 0.0 {
125 return Err(FilterError::InvalidConfig {
126 reason: format!("freeze_frame pts {pts} must be >= 0.0"),
127 });
128 }
129 if *duration <= 0.0 {
130 return Err(FilterError::InvalidConfig {
131 reason: format!("freeze_frame duration {duration} must be > 0.0"),
132 });
133 }
134 }
135 if let FilterStep::Crop { width, height, .. } = step
136 && (*width == 0 || *height == 0)
137 {
138 return Err(FilterError::InvalidConfig {
139 reason: "crop width and height must be > 0".to_string(),
140 });
141 }
142 if let FilterStep::FadeIn { duration, .. }
143 | FilterStep::FadeOut { duration, .. }
144 | FilterStep::FadeInWhite { duration, .. }
145 | FilterStep::FadeOutWhite { duration, .. } = step
146 && *duration <= 0.0
147 {
148 return Err(FilterError::InvalidConfig {
149 reason: format!("fade duration {duration} must be > 0.0"),
150 });
151 }
152 if let FilterStep::AFadeIn { duration, .. } | FilterStep::AFadeOut { duration, .. } =
153 step
154 && *duration <= 0.0
155 {
156 return Err(FilterError::InvalidConfig {
157 reason: format!("afade duration {duration} must be > 0.0"),
158 });
159 }
160 if let FilterStep::XFade { duration, .. } = step
161 && *duration <= 0.0
162 {
163 return Err(FilterError::InvalidConfig {
164 reason: format!("xfade duration {duration} must be > 0.0"),
165 });
166 }
167 if let FilterStep::JoinWithDissolve {
168 dissolve_dur,
169 clip_a_end,
170 ..
171 } = step
172 {
173 if *dissolve_dur <= 0.0 {
174 return Err(FilterError::InvalidConfig {
175 reason: format!(
176 "join_with_dissolve dissolve_dur={dissolve_dur} must be > 0.0"
177 ),
178 });
179 }
180 if *clip_a_end <= 0.0 {
181 return Err(FilterError::InvalidConfig {
182 reason: format!("join_with_dissolve clip_a_end={clip_a_end} must be > 0.0"),
183 });
184 }
185 }
186 if let FilterStep::ANoiseGate {
187 attack_ms,
188 release_ms,
189 ..
190 } = step
191 {
192 if *attack_ms <= 0.0 {
193 return Err(FilterError::InvalidConfig {
194 reason: format!("agate attack_ms {attack_ms} must be > 0.0"),
195 });
196 }
197 if *release_ms <= 0.0 {
198 return Err(FilterError::InvalidConfig {
199 reason: format!("agate release_ms {release_ms} must be > 0.0"),
200 });
201 }
202 }
203 if let FilterStep::ACompressor {
204 ratio,
205 attack_ms,
206 release_ms,
207 ..
208 } = step
209 {
210 if *ratio < 1.0 {
211 return Err(FilterError::InvalidConfig {
212 reason: format!("compressor ratio {ratio} must be >= 1.0"),
213 });
214 }
215 if *attack_ms <= 0.0 {
216 return Err(FilterError::InvalidConfig {
217 reason: format!("compressor attack_ms {attack_ms} must be > 0.0"),
218 });
219 }
220 if *release_ms <= 0.0 {
221 return Err(FilterError::InvalidConfig {
222 reason: format!("compressor release_ms {release_ms} must be > 0.0"),
223 });
224 }
225 }
226 if let FilterStep::ChannelMap { mapping } = step
227 && mapping.is_empty()
228 {
229 return Err(FilterError::InvalidConfig {
230 reason: "channel_map mapping must not be empty".to_string(),
231 });
232 }
233 if let FilterStep::ConcatVideo { n } = step
234 && *n < 2
235 {
236 return Err(FilterError::InvalidConfig {
237 reason: format!("concat_video n={n} must be >= 2"),
238 });
239 }
240 if let FilterStep::ConcatAudio { n } = step
241 && *n < 2
242 {
243 return Err(FilterError::InvalidConfig {
244 reason: format!("concat_audio n={n} must be >= 2"),
245 });
246 }
247 if let FilterStep::DrawText { opts } = step {
248 if opts.text.is_empty() {
249 return Err(FilterError::InvalidConfig {
250 reason: "drawtext text must not be empty".to_string(),
251 });
252 }
253 if !(0.0..=1.0).contains(&opts.opacity) {
254 return Err(FilterError::InvalidConfig {
255 reason: format!(
256 "drawtext opacity {} out of range [0.0, 1.0]",
257 opts.opacity
258 ),
259 });
260 }
261 }
262 if let FilterStep::Ticker {
263 text,
264 speed_px_per_sec,
265 ..
266 } = step
267 {
268 if text.is_empty() {
269 return Err(FilterError::InvalidConfig {
270 reason: "ticker text must not be empty".to_string(),
271 });
272 }
273 if *speed_px_per_sec <= 0.0 {
274 return Err(FilterError::InvalidConfig {
275 reason: format!("ticker speed_px_per_sec {speed_px_per_sec} must be > 0.0"),
276 });
277 }
278 }
279 if let FilterStep::Overlay { x, y } = step
280 && (*x < 0 || *y < 0)
281 {
282 return Err(FilterError::InvalidConfig {
283 reason: format!(
284 "overlay position ({x}, {y}) is off-screen; \
285 ensure the watermark fits within the video dimensions"
286 ),
287 });
288 }
289 if let FilterStep::Lut3d { path } = step {
290 let ext = Path::new(path)
291 .extension()
292 .and_then(|e| e.to_str())
293 .unwrap_or("");
294 if !matches!(ext, "cube" | "3dl") {
295 return Err(FilterError::InvalidConfig {
296 reason: format!("unsupported LUT format: .{ext}; expected .cube or .3dl"),
297 });
298 }
299 if !Path::new(path).exists() {
300 return Err(FilterError::InvalidConfig {
301 reason: format!("LUT file not found: {path}"),
302 });
303 }
304 }
305 if let FilterStep::SubtitlesSrt { path } = step {
306 let ext = Path::new(path)
307 .extension()
308 .and_then(|e| e.to_str())
309 .unwrap_or("");
310 if ext != "srt" {
311 return Err(FilterError::InvalidConfig {
312 reason: format!("unsupported subtitle format: .{ext}; expected .srt"),
313 });
314 }
315 if !Path::new(path).exists() {
316 return Err(FilterError::InvalidConfig {
317 reason: format!("subtitle file not found: {path}"),
318 });
319 }
320 }
321 if let FilterStep::SubtitlesAss { path } = step {
322 let ext = Path::new(path)
323 .extension()
324 .and_then(|e| e.to_str())
325 .unwrap_or("");
326 if !matches!(ext, "ass" | "ssa") {
327 return Err(FilterError::InvalidConfig {
328 reason: format!(
329 "unsupported subtitle format: .{ext}; expected .ass or .ssa"
330 ),
331 });
332 }
333 if !Path::new(path).exists() {
334 return Err(FilterError::InvalidConfig {
335 reason: format!("subtitle file not found: {path}"),
336 });
337 }
338 }
339 if let FilterStep::OverlayImage { path, opacity, .. } = step {
340 let ext = Path::new(path)
341 .extension()
342 .and_then(|e| e.to_str())
343 .unwrap_or("");
344 if ext != "png" {
345 return Err(FilterError::InvalidConfig {
346 reason: format!("unsupported image format: .{ext}; expected .png"),
347 });
348 }
349 if !(0.0..=1.0).contains(opacity) {
350 return Err(FilterError::InvalidConfig {
351 reason: format!("overlay_image opacity {opacity} out of range [0.0, 1.0]"),
352 });
353 }
354 if !Path::new(path).exists() {
355 return Err(FilterError::InvalidConfig {
356 reason: format!("overlay image not found: {path}"),
357 });
358 }
359 }
360 if let FilterStep::Eq {
361 brightness,
362 contrast,
363 saturation,
364 } = step
365 {
366 if !(-1.0..=1.0).contains(brightness) {
367 return Err(FilterError::InvalidConfig {
368 reason: format!("eq brightness {brightness} out of range [-1.0, 1.0]"),
369 });
370 }
371 if !(0.0..=3.0).contains(contrast) {
372 return Err(FilterError::InvalidConfig {
373 reason: format!("eq contrast {contrast} out of range [0.0, 3.0]"),
374 });
375 }
376 if !(0.0..=3.0).contains(saturation) {
377 return Err(FilterError::InvalidConfig {
378 reason: format!("eq saturation {saturation} out of range [0.0, 3.0]"),
379 });
380 }
381 }
382 if let FilterStep::Curves { master, r, g, b } = step {
383 for (channel, pts) in [
384 ("master", master.as_slice()),
385 ("r", r.as_slice()),
386 ("g", g.as_slice()),
387 ("b", b.as_slice()),
388 ] {
389 for &(x, y) in pts {
390 if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
391 return Err(FilterError::InvalidConfig {
392 reason: format!(
393 "curves {channel} control point ({x}, {y}) out of range [0.0, 1.0]"
394 ),
395 });
396 }
397 }
398 }
399 }
400 if let FilterStep::WhiteBalance {
401 temperature_k,
402 tint,
403 } = step
404 {
405 if !(1000..=40000).contains(temperature_k) {
406 return Err(FilterError::InvalidConfig {
407 reason: format!(
408 "white_balance temperature_k {temperature_k} out of range [1000, 40000]"
409 ),
410 });
411 }
412 if !(-1.0..=1.0).contains(tint) {
413 return Err(FilterError::InvalidConfig {
414 reason: format!("white_balance tint {tint} out of range [-1.0, 1.0]"),
415 });
416 }
417 }
418 if let FilterStep::Hue { degrees } = step
419 && !(-360.0..=360.0).contains(degrees)
420 {
421 return Err(FilterError::InvalidConfig {
422 reason: format!("hue degrees {degrees} out of range [-360.0, 360.0]"),
423 });
424 }
425 if let FilterStep::Gamma { r, g, b } = step {
426 for (channel, val) in [("r", r), ("g", g), ("b", b)] {
427 if !(0.1..=10.0).contains(val) {
428 return Err(FilterError::InvalidConfig {
429 reason: format!("gamma {channel} {val} out of range [0.1, 10.0]"),
430 });
431 }
432 }
433 }
434 if let FilterStep::ThreeWayCC { gamma, .. } = step {
435 for (channel, val) in [("r", gamma.r), ("g", gamma.g), ("b", gamma.b)] {
436 if val <= 0.0 {
437 return Err(FilterError::InvalidConfig {
438 reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
439 });
440 }
441 }
442 }
443 if let FilterStep::Vignette { angle, .. } = step
444 && !((0.0)..=std::f32::consts::FRAC_PI_2).contains(angle)
445 {
446 return Err(FilterError::InvalidConfig {
447 reason: format!("vignette angle {angle} out of range [0.0, π/2]"),
448 });
449 }
450 if let FilterStep::Pad { width, height, .. } = step
451 && (*width == 0 || *height == 0)
452 {
453 return Err(FilterError::InvalidConfig {
454 reason: "pad width and height must be > 0".to_string(),
455 });
456 }
457 if let FilterStep::FitToAspect { width, height, .. } = step
458 && (*width == 0 || *height == 0)
459 {
460 return Err(FilterError::InvalidConfig {
461 reason: "fit_to_aspect width and height must be > 0".to_string(),
462 });
463 }
464 if let FilterStep::GBlur { sigma } = step
465 && *sigma < 0.0
466 {
467 return Err(FilterError::InvalidConfig {
468 reason: format!("gblur sigma {sigma} must be >= 0.0"),
469 });
470 }
471 if let FilterStep::Unsharp {
472 luma_strength,
473 chroma_strength,
474 } = step
475 {
476 if !(-1.5..=1.5).contains(luma_strength) {
477 return Err(FilterError::InvalidConfig {
478 reason: format!(
479 "unsharp luma_strength {luma_strength} out of range [-1.5, 1.5]"
480 ),
481 });
482 }
483 if !(-1.5..=1.5).contains(chroma_strength) {
484 return Err(FilterError::InvalidConfig {
485 reason: format!(
486 "unsharp chroma_strength {chroma_strength} out of range [-1.5, 1.5]"
487 ),
488 });
489 }
490 }
491 if let FilterStep::Hqdn3d {
492 luma_spatial,
493 chroma_spatial,
494 luma_tmp,
495 chroma_tmp,
496 } = step
497 {
498 for (name, val) in [
499 ("luma_spatial", luma_spatial),
500 ("chroma_spatial", chroma_spatial),
501 ("luma_tmp", luma_tmp),
502 ("chroma_tmp", chroma_tmp),
503 ] {
504 if *val < 0.0 {
505 return Err(FilterError::InvalidConfig {
506 reason: format!("hqdn3d {name} {val} must be >= 0.0"),
507 });
508 }
509 }
510 }
511 if let FilterStep::Nlmeans { strength } = step
512 && (*strength < 1.0 || *strength > 30.0)
513 {
514 return Err(FilterError::InvalidConfig {
515 reason: format!("nlmeans strength {strength} out of range [1.0, 30.0]"),
516 });
517 }
518 }
519
520 crate::filter_inner::validate_filter_steps(&self.steps)?;
521 let output_resolution = self.steps.iter().rev().find_map(|s| {
522 if let FilterStep::Scale { width, height, .. } = s {
523 Some((*width, *height))
524 } else {
525 None
526 }
527 });
528 Ok(FilterGraph {
529 inner: FilterGraphInner::new(self.steps, self.hw),
530 output_resolution,
531 })
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn builder_empty_steps_should_return_error() {
541 let result = FilterGraph::builder().build();
542 assert!(
543 matches!(result, Err(FilterError::BuildFailed)),
544 "expected BuildFailed, got {result:?}"
545 );
546 }
547
548 #[test]
549 fn builder_steps_should_accumulate_in_order() {
550 let result = FilterGraph::builder()
551 .trim(0.0, 5.0)
552 .scale(1280, 720, ScaleAlgorithm::Fast)
553 .volume(-3.0)
554 .build();
555 assert!(
556 result.is_ok(),
557 "builder with multiple valid steps must succeed, got {result:?}"
558 );
559 }
560
561 #[test]
562 fn builder_with_valid_steps_should_succeed() {
563 let result = FilterGraph::builder()
564 .scale(1280, 720, ScaleAlgorithm::Fast)
565 .build();
566 assert!(
567 result.is_ok(),
568 "builder with a known filter step must succeed, got {result:?}"
569 );
570 }
571
572 #[test]
573 fn output_resolution_should_be_none_when_no_scale() {
574 let fg = FilterGraph::builder().trim(0.0, 5.0).build().unwrap();
575 assert_eq!(fg.output_resolution(), None);
576 }
577
578 #[test]
579 fn output_resolution_should_be_last_scale_dimensions() {
580 let fg = FilterGraph::builder()
581 .scale(1280, 720, ScaleAlgorithm::Fast)
582 .build()
583 .unwrap();
584 assert_eq!(fg.output_resolution(), Some((1280, 720)));
585 }
586
587 #[test]
588 fn output_resolution_should_use_last_scale_when_multiple_present() {
589 let fg = FilterGraph::builder()
590 .scale(1920, 1080, ScaleAlgorithm::Fast)
591 .scale(1280, 720, ScaleAlgorithm::Bicubic)
592 .build()
593 .unwrap();
594 assert_eq!(fg.output_resolution(), Some((1280, 720)));
595 }
596
597 #[test]
598 fn rgb_neutral_constant_should_have_all_channels_one() {
599 assert_eq!(Rgb::NEUTRAL.r, 1.0);
600 assert_eq!(Rgb::NEUTRAL.g, 1.0);
601 assert_eq!(Rgb::NEUTRAL.b, 1.0);
602 }
603}