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 {
217 threshold_pct: 10.0,
218 };
219 assert_eq!(d.to_filter(), "scdet=threshold=10:sc_pass=0");
220 }
221
222 #[test]
223 fn crop_filter_string() {
224 let d = VideoDetector::Crop {
225 limit: 24,
226 round: 16,
227 reset: 0,
228 };
229 assert_eq!(d.to_filter(), "cropdetect=limit=24:round=16:reset=0");
230 }
231
232 #[test]
233 fn silence_filter_requires_db_suffix() {
234 let d = AudioDetector::Silence {
235 noise_db: -30.0,
236 min_duration_s: 0.5,
237 mono: false,
238 };
239 assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5");
240 }
241
242 #[test]
243 fn silence_mono_adds_flag() {
244 let d = AudioDetector::Silence {
245 noise_db: -30.0,
246 min_duration_s: 0.5,
247 mono: true,
248 };
249 assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5:mono=1");
250 }
251
252 #[test]
253 fn ebur128_peak_toggle() {
254 assert_eq!(
255 AudioDetector::Ebur128 { true_peak: false }.to_filter(),
256 "ebur128=metadata=1"
257 );
258 assert_eq!(
259 AudioDetector::Ebur128 { true_peak: true }.to_filter(),
260 "ebur128=metadata=1:peak=true"
261 );
262 }
263
264 #[test]
265 fn validate_rejects_non_finite() {
266 assert!(VideoDetector::Black {
267 min_duration_s: f64::NAN,
268 pixel_th: 0.1,
269 picture_th: 0.98,
270 }
271 .validate()
272 .is_err());
273 assert!(VideoDetector::Scene {
274 threshold_pct: f64::INFINITY,
275 }
276 .validate()
277 .is_err());
278 assert!(AudioDetector::Silence {
279 noise_db: f64::NAN,
280 min_duration_s: 0.5,
281 mono: false,
282 }
283 .validate()
284 .is_err());
285 assert!(VideoDetector::Scene {
287 threshold_pct: 101.0
288 }
289 .validate()
290 .is_err());
291 assert!(VideoDetector::Black {
292 min_duration_s: -1.0,
293 pixel_th: 0.1,
294 picture_th: 0.98,
295 }
296 .validate()
297 .is_err());
298 assert!(VideoDetector::Black {
299 min_duration_s: 0.1,
300 pixel_th: 1.5,
301 picture_th: 0.98,
302 }
303 .validate()
304 .is_err());
305 assert!(VideoDetector::Black {
306 min_duration_s: 0.1,
307 pixel_th: 0.1,
308 picture_th: 0.98,
309 }
310 .validate()
311 .is_ok());
312 }
313}