1use crate::core::analysis::detector::{AudioDetector, VideoDetector};
12use crate::core::analysis::event::{secs_to_us, MetadataEvent};
13use crate::core::analysis::filter::{EventSink, MetadataEventFilter, SinkError};
14use crate::core::analysis::report::{finalize, fold_event, AnalysisReport, FoldConfig, FoldState};
15use crate::core::filter::frame_pipeline::FramePipeline;
16use crate::core::filter::frame_pipeline_builder::FramePipelineBuilder;
17use crate::error::Error;
18use crate::{FfmpegContext, FfmpegScheduler, Input, Output};
19use ffmpeg_sys_next::av_guess_format;
20use ffmpeg_sys_next::AVMediaType::{self, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO};
21use std::ffi::CString;
22use std::ptr;
23use std::sync::{Arc, Mutex};
24
25pub struct Analysis {
27 input: Input,
28 video: Vec<VideoDetector>,
29 audio: Vec<AudioDetector>,
30}
31
32struct Branch {
34 media: AVMediaType,
35 map: String,
36}
37
38impl Analysis {
39 pub fn new(input: impl Into<Input>) -> Self {
42 Self {
43 input: input.into(),
44 video: Vec::new(),
45 audio: Vec::new(),
46 }
47 }
48
49 pub fn video_detector(mut self, detector: VideoDetector) -> Self {
51 self.video.push(detector);
52 self
53 }
54
55 pub fn audio_detector(mut self, detector: AudioDetector) -> Self {
57 self.audio.push(detector);
58 self
59 }
60
61 pub fn run(self) -> crate::error::Result<AnalysisReport> {
68 self.validate()?;
69 self.check_capabilities()?;
70
71 let (filter_desc, branches) = self.plan();
72 let cfg = self.fold_config();
73
74 let collector: Arc<Mutex<FoldState>> = Arc::new(Mutex::new(FoldState::default()));
75 let pipelines: Vec<FramePipeline> = branches
76 .iter()
77 .enumerate()
78 .map(|(index, branch)| make_pipeline(branch.media, index, collector.clone()))
79 .collect();
80
81 let mut output = Output::from("-")
82 .set_format("null")
83 .set_frame_pipelines(pipelines);
84 for branch in &branches {
85 output = output.add_stream_map(branch.map.clone());
86 }
87
88 let context = FfmpegContext::builder()
89 .input(self.input)
90 .filter_desc(filter_desc)
91 .output(output)
92 .build()?;
93 FfmpegScheduler::new(context).start()?.wait()?;
94
95 let state = collector
96 .lock()
97 .map(|mut guard| std::mem::take(&mut *guard))
98 .map_err(|_| {
99 Error::InvalidRecipeArg(
100 "analysis event collector was poisoned by a panicked pipeline thread"
101 .to_string(),
102 )
103 })?;
104 Ok(finalize(state, &cfg))
105 }
106
107 fn validate(&self) -> crate::error::Result<()> {
110 if self.video.is_empty() && self.audio.is_empty() {
111 return Err(Error::InvalidRecipeArg(
112 "Analysis requires at least one detector".to_string(),
113 ));
114 }
115 let mut seen_video = [false; 3];
116 for detector in &self.video {
117 let idx = match detector {
118 VideoDetector::Black { .. } => 0,
119 VideoDetector::Scene { .. } => 1,
120 VideoDetector::Crop { .. } => 2,
121 };
122 if seen_video[idx] {
123 return Err(Error::InvalidRecipeArg(format!(
124 "duplicate video detector '{}' on the same media",
125 detector.filter_name()
126 )));
127 }
128 seen_video[idx] = true;
129 }
130 let mut seen_audio = [false; 2];
131 for detector in &self.audio {
132 let idx = match detector {
133 AudioDetector::Silence { .. } => 0,
134 AudioDetector::Ebur128 { .. } => 1,
135 };
136 if seen_audio[idx] {
137 return Err(Error::InvalidRecipeArg(format!(
138 "duplicate audio detector '{}' on the same media",
139 detector.filter_name()
140 )));
141 }
142 seen_audio[idx] = true;
143 }
144 for detector in &self.video {
145 detector.validate()?;
146 }
147 for detector in &self.audio {
148 detector.validate()?;
149 }
150 Ok(())
151 }
152
153 fn check_capabilities(&self) -> crate::error::Result<()> {
157 for detector in &self.video {
158 require_filter(detector.filter_name())?;
159 }
160 for detector in &self.audio {
161 require_filter(detector.filter_name())?;
162 }
163 if self.audio.len() >= 2 {
164 require_filter("asplit")?;
165 }
166 require_null_muxer()
167 }
168
169 fn plan(&self) -> (String, Vec<Branch>) {
171 let mut desc_parts: Vec<String> = Vec::new();
172 let mut branches: Vec<Branch> = Vec::new();
173
174 if !self.video.is_empty() {
176 let chain = self
177 .video
178 .iter()
179 .map(|d| d.to_filter())
180 .collect::<Vec<_>>()
181 .join(",");
182 desc_parts.push(format!("[0:v]{chain}[vdet]"));
183 branches.push(Branch {
184 media: AVMEDIA_TYPE_VIDEO,
185 map: "[vdet]".to_string(),
186 });
187 }
188
189 match self.audio.len() {
191 0 => {}
192 1 => {
193 desc_parts.push(format!("[0:a]{}[adet0]", self.audio[0].to_filter()));
194 branches.push(Branch {
195 media: AVMEDIA_TYPE_AUDIO,
196 map: "[adet0]".to_string(),
197 });
198 }
199 n => {
200 let labels: String = (0..n).map(|j| format!("[asplit{j}]")).collect();
201 desc_parts.push(format!("[0:a]asplit={n}{labels}"));
202 for (j, detector) in self.audio.iter().enumerate() {
203 desc_parts.push(format!("[asplit{j}]{}[adet{j}]", detector.to_filter()));
204 branches.push(Branch {
205 media: AVMEDIA_TYPE_AUDIO,
206 map: format!("[adet{j}]"),
207 });
208 }
209 }
210 }
211
212 (desc_parts.join(";"), branches)
213 }
214
215 fn fold_config(&self) -> FoldConfig {
217 let mut cfg = FoldConfig::default();
218 for detector in &self.video {
219 if let VideoDetector::Black { min_duration_s, .. } = detector {
220 cfg.black_min_duration_us = secs_to_us(*min_duration_s);
221 }
222 }
223 for detector in &self.audio {
224 if let AudioDetector::Silence { min_duration_s, .. } = detector {
225 cfg.silence_min_duration_us = secs_to_us(*min_duration_s);
226 }
227 }
228 cfg
229 }
230}
231
232#[derive(Clone)]
234struct FoldSink {
235 collector: Arc<Mutex<FoldState>>,
236}
237
238impl EventSink for FoldSink {
239 fn try_emit(&mut self, ev: MetadataEvent) -> Result<(), SinkError> {
240 match self.collector.lock() {
241 Ok(mut guard) => {
242 fold_event(&mut guard, ev);
244 Ok(())
245 }
246 Err(_) => Err(SinkError::Disconnected),
249 }
250 }
251}
252
253fn make_pipeline(
254 media: AVMediaType,
255 stream_index: usize,
256 collector: Arc<Mutex<FoldState>>,
257) -> FramePipeline {
258 let filter = MetadataEventFilter::new(media, FoldSink { collector });
259 FramePipelineBuilder::new(media)
260 .filter("analysis", Box::new(filter))
261 .set_stream_index(stream_index)
262 .build()
263}
264
265fn require_filter(name: &str) -> crate::error::Result<()> {
266 if crate::hwaccel::is_filter_available(name) {
267 Ok(())
268 } else {
269 Err(Error::InvalidRecipeArg(format!(
270 "FFmpeg filter '{name}' is not available in this build"
271 )))
272 }
273}
274
275fn require_null_muxer() -> crate::error::Result<()> {
276 let name = CString::new("null").expect("literal has no interior NUL");
277 let ofmt = unsafe { av_guess_format(name.as_ptr(), ptr::null(), ptr::null()) };
279 if ofmt.is_null() {
280 Err(Error::InvalidRecipeArg(
281 "FFmpeg 'null' muxer is not available in this build".to_string(),
282 ))
283 } else {
284 Ok(())
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::core::analysis::event::Timestamp;
292
293 fn sample() -> Analysis {
294 Analysis::new("input.mp4")
295 .video_detector(VideoDetector::Black {
296 min_duration_s: 0.1,
297 pixel_th: 0.1,
298 picture_th: 0.98,
299 })
300 .audio_detector(AudioDetector::Silence {
301 noise_db: -30.0,
302 min_duration_s: 0.5,
303 mono: false,
304 })
305 .audio_detector(AudioDetector::Ebur128 { true_peak: false })
306 }
307
308 #[test]
309 fn plan_isolates_audio_detectors_into_asplit_branches() {
310 let (desc, branches) = sample().plan();
311 assert!(desc.contains("[0:v]blackdetect=d=0.1:pix_th=0.1:pic_th=0.98[vdet]"));
312 assert!(desc.contains("[0:a]asplit=2[asplit0][asplit1]"));
313 assert!(desc.contains("[asplit0]silencedetect=noise=-30dB:d=0.5[adet0]"));
314 assert!(desc.contains("[asplit1]ebur128=metadata=1[adet1]"));
315 assert_eq!(branches.len(), 3);
316 assert_eq!(branches[0].media, AVMEDIA_TYPE_VIDEO);
317 assert_eq!(branches[1].media, AVMEDIA_TYPE_AUDIO);
318 }
319
320 #[test]
321 fn plan_single_audio_detector_has_no_asplit() {
322 let (desc, branches) = Analysis::new("input.mp4")
323 .audio_detector(AudioDetector::Ebur128 { true_peak: true })
324 .plan();
325 assert_eq!(desc, "[0:a]ebur128=metadata=1:peak=true[adet0]");
326 assert_eq!(branches.len(), 1);
327 }
328
329 #[test]
330 fn empty_analysis_is_rejected() {
331 let result = Analysis::new("input.mp4").validate();
332 assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
333 }
334
335 #[test]
336 fn duplicate_detector_is_rejected() {
337 let result = Analysis::new("input.mp4")
338 .video_detector(VideoDetector::Scene {
339 threshold_pct: 10.0,
340 })
341 .video_detector(VideoDetector::Scene {
342 threshold_pct: 20.0,
343 })
344 .validate();
345 assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
346 }
347
348 #[test]
349 fn fold_config_picks_up_min_durations() {
350 let cfg = sample().fold_config();
351 assert_eq!(cfg.black_min_duration_us, Some(100_000));
352 assert_eq!(cfg.silence_min_duration_us, Some(500_000));
353 }
354
355 #[test]
362 fn fold_sink_folds_each_event_on_arrival() {
363 fn ts(us: i64) -> Timestamp {
364 Timestamp {
365 time_us: us,
366 pts: None,
367 time_base: None,
368 }
369 }
370
371 let collector: Arc<Mutex<FoldState>> = Arc::new(Mutex::new(FoldState::default()));
372 let mut sink = FoldSink {
373 collector: collector.clone(),
374 };
375
376 for k in 1..=4i64 {
380 sink.try_emit(MetadataEvent::SceneChange {
381 at: ts(k * 1_000_000),
382 score: k as f64,
383 })
384 .unwrap();
385 let state = collector.lock().unwrap();
386 let scenes = &state.report_so_far().scenes;
387 assert_eq!(
388 scenes.len(),
389 k as usize,
390 "scene event {k} must be folded on arrival, not buffered"
391 );
392 assert_eq!(scenes[k as usize - 1].at_us, k * 1_000_000);
393 }
394
395 sink.try_emit(MetadataEvent::BlackStart { at: ts(5_000_000) })
398 .unwrap();
399 assert!(
400 collector.lock().unwrap().report_so_far().black.is_empty(),
401 "an open region has nothing to report yet"
402 );
403 sink.try_emit(MetadataEvent::BlackEnd {
404 at: ts(6_000_000),
405 duration_us: 1_000_000,
406 })
407 .unwrap();
408 {
409 let state = collector.lock().unwrap();
410 assert_eq!(
411 state.report_so_far().black,
412 vec![crate::analysis::BlackRange {
413 start_us: 5_000_000,
414 end_us: 6_000_000
415 }],
416 "the range must be visible right after its end event"
417 );
418 }
419
420 sink.try_emit(MetadataEvent::CropDetect {
422 at: ts(7_000_000),
423 x: 2,
424 y: 4,
425 w: 100,
426 h: 90,
427 })
428 .unwrap();
429 assert!(
430 collector.lock().unwrap().report_so_far().crop.is_some(),
431 "crop must be folded on arrival"
432 );
433 sink.try_emit(MetadataEvent::R128Summary {
434 integrated: Some(-23.0),
435 lra: Some(4.0),
436 true_peak: None,
437 })
438 .unwrap();
439 assert!(
440 collector.lock().unwrap().report_so_far().loudness.is_some(),
441 "loudness must be folded on arrival"
442 );
443
444 let state = std::mem::take(&mut *collector.lock().unwrap());
447 let report = finalize(state, &FoldConfig::default());
448 assert_eq!(report.scenes.len(), 4);
449 assert_eq!(report.black.len(), 1);
450 }
451}