1use crate::error::{Error, Result};
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum VideoDetector {
15 Black {
21 min_duration_s: f64,
22 pixel_th: f64,
23 picture_th: f64,
24 },
25 Scene { threshold_pct: f64 },
31 Crop { limit: u32, round: u32, reset: u32 },
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub enum AudioDetector {
42 Silence {
50 noise_db: f64,
51 min_duration_s: f64,
52 mono: bool,
53 },
54 Ebur128 { true_peak: bool },
58}
59
60impl VideoDetector {
61 pub(crate) fn filter_name(&self) -> &'static str {
63 match self {
64 VideoDetector::Black { .. } => "blackdetect",
65 VideoDetector::Scene { .. } => "scdet",
66 VideoDetector::Crop { .. } => "cropdetect",
67 }
68 }
69
70 pub(crate) fn to_filter(&self) -> String {
72 match *self {
73 VideoDetector::Black {
74 min_duration_s,
75 pixel_th,
76 picture_th,
77 } => format!("blackdetect=d={min_duration_s}:pix_th={pixel_th}:pic_th={picture_th}"),
78 VideoDetector::Scene { threshold_pct } => {
79 format!("scdet=threshold={threshold_pct}:sc_pass=0")
80 }
81 VideoDetector::Crop {
82 limit,
83 round,
84 reset,
85 } => format!("cropdetect=limit={limit}:round={round}:reset={reset}"),
86 }
87 }
88
89 pub(crate) fn validate(&self) -> Result<()> {
93 let in_range = |v: f64, lo: f64, hi: f64, what: &str| -> Result<()> {
94 if v.is_finite() && v >= lo && v <= hi {
95 Ok(())
96 } else {
97 Err(Error::InvalidRecipeArg(format!(
98 "{what} must be in {lo}..={hi}, got {v}"
99 )))
100 }
101 };
102 match *self {
103 VideoDetector::Black {
104 min_duration_s,
105 pixel_th,
106 picture_th,
107 } => {
108 if !min_duration_s.is_finite() || min_duration_s < 0.0 {
109 return Err(Error::InvalidRecipeArg(format!(
110 "blackdetect min_duration_s must be finite and >= 0, got {min_duration_s}"
111 )));
112 }
113 in_range(pixel_th, 0.0, 1.0, "blackdetect pixel_th")?;
114 in_range(picture_th, 0.0, 1.0, "blackdetect picture_th")?;
115 }
116 VideoDetector::Scene { threshold_pct } => {
117 in_range(threshold_pct, 0.0, 100.0, "scene threshold_pct")?;
118 }
119 VideoDetector::Crop {
120 limit,
121 round,
122 reset,
123 } => {
124 for (v, what) in [
127 (limit, "cropdetect limit"),
128 (round, "cropdetect round"),
129 (reset, "cropdetect reset"),
130 ] {
131 if v > i32::MAX as u32 {
132 return Err(Error::InvalidRecipeArg(format!(
133 "{what} must be <= {}, got {v}",
134 i32::MAX
135 )));
136 }
137 }
138 }
139 }
140 Ok(())
141 }
142}
143
144impl AudioDetector {
145 pub(crate) fn filter_name(&self) -> &'static str {
147 match self {
148 AudioDetector::Silence { .. } => "silencedetect",
149 AudioDetector::Ebur128 { .. } => "ebur128",
150 }
151 }
152
153 pub(crate) fn to_filter(&self) -> String {
155 match *self {
156 AudioDetector::Silence {
157 noise_db,
158 min_duration_s,
159 mono,
160 } => {
161 let mut s = format!("silencedetect=noise={noise_db}dB:d={min_duration_s}");
162 if mono {
163 s.push_str(":mono=1");
164 }
165 s
166 }
167 AudioDetector::Ebur128 { true_peak } => {
168 if true_peak {
169 "ebur128=metadata=1:peak=true".to_string()
170 } else {
171 "ebur128=metadata=1".to_string()
172 }
173 }
174 }
175 }
176
177 pub(crate) fn validate(&self) -> Result<()> {
179 if let AudioDetector::Silence {
180 noise_db,
181 min_duration_s,
182 ..
183 } = *self
184 {
185 if !noise_db.is_finite() {
186 return Err(Error::InvalidRecipeArg(format!(
187 "silencedetect noise_db must be finite, got {noise_db}"
188 )));
189 }
190 if !min_duration_s.is_finite() || min_duration_s < 0.0 {
191 return Err(Error::InvalidRecipeArg(format!(
192 "silencedetect min_duration_s must be finite and >= 0, got {min_duration_s}"
193 )));
194 }
195 }
196 Ok(())
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn black_filter_string() {
206 let d = VideoDetector::Black {
207 min_duration_s: 0.1,
208 pixel_th: 0.1,
209 picture_th: 0.98,
210 };
211 assert_eq!(d.to_filter(), "blackdetect=d=0.1:pix_th=0.1:pic_th=0.98");
212 }
213
214 #[test]
215 fn scene_filter_uses_sc_pass_zero() {
216 let d = VideoDetector::Scene { threshold_pct: 10.0 };
217 assert_eq!(d.to_filter(), "scdet=threshold=10:sc_pass=0");
218 }
219
220 #[test]
221 fn crop_filter_string() {
222 let d = VideoDetector::Crop {
223 limit: 24,
224 round: 16,
225 reset: 0,
226 };
227 assert_eq!(d.to_filter(), "cropdetect=limit=24:round=16:reset=0");
228 }
229
230 #[test]
231 fn silence_filter_requires_db_suffix() {
232 let d = AudioDetector::Silence {
233 noise_db: -30.0,
234 min_duration_s: 0.5,
235 mono: false,
236 };
237 assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5");
238 }
239
240 #[test]
241 fn silence_mono_adds_flag() {
242 let d = AudioDetector::Silence {
243 noise_db: -30.0,
244 min_duration_s: 0.5,
245 mono: true,
246 };
247 assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5:mono=1");
248 }
249
250 #[test]
251 fn ebur128_peak_toggle() {
252 assert_eq!(
253 AudioDetector::Ebur128 { true_peak: false }.to_filter(),
254 "ebur128=metadata=1"
255 );
256 assert_eq!(
257 AudioDetector::Ebur128 { true_peak: true }.to_filter(),
258 "ebur128=metadata=1:peak=true"
259 );
260 }
261
262 #[test]
263 fn validate_rejects_non_finite() {
264 assert!(VideoDetector::Black {
265 min_duration_s: f64::NAN,
266 pixel_th: 0.1,
267 picture_th: 0.98,
268 }
269 .validate()
270 .is_err());
271 assert!(VideoDetector::Scene {
272 threshold_pct: f64::INFINITY,
273 }
274 .validate()
275 .is_err());
276 assert!(AudioDetector::Silence {
277 noise_db: f64::NAN,
278 min_duration_s: 0.5,
279 mono: false,
280 }
281 .validate()
282 .is_err());
283 assert!(VideoDetector::Scene { threshold_pct: 101.0 }.validate().is_err());
285 assert!(VideoDetector::Black {
286 min_duration_s: -1.0,
287 pixel_th: 0.1,
288 picture_th: 0.98,
289 }
290 .validate()
291 .is_err());
292 assert!(VideoDetector::Black {
293 min_duration_s: 0.1,
294 pixel_th: 1.5,
295 picture_th: 0.98,
296 }
297 .validate()
298 .is_err());
299 assert!(VideoDetector::Black {
300 min_duration_s: 0.1,
301 pixel_th: 0.1,
302 picture_th: 0.98,
303 }
304 .validate()
305 .is_ok());
306 }
307}