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