1use crate::error::{Error, Result};
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum VideoDetector {
13 Black {
19 min_duration_s: f64,
20 pixel_th: f64,
21 picture_th: f64,
22 },
23 Scene { threshold_pct: f64 },
29 Crop { limit: u32, round: u32, reset: u32 },
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub enum AudioDetector {
40 Silence {
48 noise_db: f64,
49 min_duration_s: f64,
50 mono: bool,
51 },
52 Ebur128 { true_peak: bool },
56}
57
58impl VideoDetector {
59 pub(crate) fn filter_name(&self) -> &'static str {
61 match self {
62 VideoDetector::Black { .. } => "blackdetect",
63 VideoDetector::Scene { .. } => "scdet",
64 VideoDetector::Crop { .. } => "cropdetect",
65 }
66 }
67
68 pub(crate) fn to_filter(&self) -> String {
70 match *self {
71 VideoDetector::Black {
72 min_duration_s,
73 pixel_th,
74 picture_th,
75 } => format!("blackdetect=d={min_duration_s}:pix_th={pixel_th}:pic_th={picture_th}"),
76 VideoDetector::Scene { threshold_pct } => {
77 format!("scdet=threshold={threshold_pct}:sc_pass=0")
78 }
79 VideoDetector::Crop {
80 limit,
81 round,
82 reset,
83 } => format!("cropdetect=limit={limit}:round={round}:reset={reset}"),
84 }
85 }
86
87 pub(crate) fn validate(&self) -> Result<()> {
91 let in_range = |v: f64, lo: f64, hi: f64, what: &str| -> Result<()> {
92 if v.is_finite() && v >= lo && v <= hi {
93 Ok(())
94 } else {
95 Err(Error::InvalidRecipeArg(format!(
96 "{what} must be in {lo}..={hi}, got {v}"
97 )))
98 }
99 };
100 match *self {
101 VideoDetector::Black {
102 min_duration_s,
103 pixel_th,
104 picture_th,
105 } => {
106 if !min_duration_s.is_finite() || min_duration_s < 0.0 {
107 return Err(Error::InvalidRecipeArg(format!(
108 "blackdetect min_duration_s must be finite and >= 0, got {min_duration_s}"
109 )));
110 }
111 in_range(pixel_th, 0.0, 1.0, "blackdetect pixel_th")?;
112 in_range(picture_th, 0.0, 1.0, "blackdetect picture_th")?;
113 }
114 VideoDetector::Scene { threshold_pct } => {
115 in_range(threshold_pct, 0.0, 100.0, "scene threshold_pct")?;
116 }
117 VideoDetector::Crop {
118 limit,
119 round,
120 reset,
121 } => {
122 for (v, what) in [
125 (limit, "cropdetect limit"),
126 (round, "cropdetect round"),
127 (reset, "cropdetect reset"),
128 ] {
129 if v > i32::MAX as u32 {
130 return Err(Error::InvalidRecipeArg(format!(
131 "{what} must be <= {}, got {v}",
132 i32::MAX
133 )));
134 }
135 }
136 }
137 }
138 Ok(())
139 }
140}
141
142impl AudioDetector {
143 pub(crate) fn filter_name(&self) -> &'static str {
145 match self {
146 AudioDetector::Silence { .. } => "silencedetect",
147 AudioDetector::Ebur128 { .. } => "ebur128",
148 }
149 }
150
151 pub(crate) fn to_filter(&self) -> String {
153 match *self {
154 AudioDetector::Silence {
155 noise_db,
156 min_duration_s,
157 mono,
158 } => {
159 let mut s = format!("silencedetect=noise={noise_db}dB:d={min_duration_s}");
160 if mono {
161 s.push_str(":mono=1");
162 }
163 s
164 }
165 AudioDetector::Ebur128 { true_peak } => {
166 if true_peak {
167 "ebur128=metadata=1:peak=true".to_string()
168 } else {
169 "ebur128=metadata=1".to_string()
170 }
171 }
172 }
173 }
174
175 pub(crate) fn validate(&self) -> Result<()> {
177 if let AudioDetector::Silence {
178 noise_db,
179 min_duration_s,
180 ..
181 } = *self
182 {
183 if !noise_db.is_finite() {
184 return Err(Error::InvalidRecipeArg(format!(
185 "silencedetect noise_db must be finite, got {noise_db}"
186 )));
187 }
188 if !min_duration_s.is_finite() || min_duration_s < 0.0 {
189 return Err(Error::InvalidRecipeArg(format!(
190 "silencedetect min_duration_s must be finite and >= 0, got {min_duration_s}"
191 )));
192 }
193 }
194 Ok(())
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn black_filter_string() {
204 let d = VideoDetector::Black {
205 min_duration_s: 0.1,
206 pixel_th: 0.1,
207 picture_th: 0.98,
208 };
209 assert_eq!(d.to_filter(), "blackdetect=d=0.1:pix_th=0.1:pic_th=0.98");
210 }
211
212 #[test]
213 fn scene_filter_uses_sc_pass_zero() {
214 let d = VideoDetector::Scene {
215 threshold_pct: 10.0,
216 };
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 {
285 threshold_pct: 101.0
286 }
287 .validate()
288 .is_err());
289 assert!(VideoDetector::Black {
290 min_duration_s: -1.0,
291 pixel_th: 0.1,
292 picture_th: 0.98,
293 }
294 .validate()
295 .is_err());
296 assert!(VideoDetector::Black {
297 min_duration_s: 0.1,
298 pixel_th: 1.5,
299 picture_th: 0.98,
300 }
301 .validate()
302 .is_err());
303 assert!(VideoDetector::Black {
304 min_duration_s: 0.1,
305 pixel_th: 0.1,
306 picture_th: 0.98,
307 }
308 .validate()
309 .is_ok());
310 }
311}