1use crate::core::analysis::crop::{CropDetectionOptions, CropObservation, DetailedAnalysisReport};
12use crate::core::analysis::detector::{AudioDetector, VideoDetector};
13use crate::core::analysis::event::{secs_to_us, MetadataEvent};
14use crate::core::analysis::filter::{EventSink, MetadataEventFilter, SinkError};
15use crate::core::analysis::report::{finalize, fold_event, AnalysisReport, FoldConfig, FoldState};
16use crate::core::filter::frame_pipeline::FramePipeline;
17use crate::core::filter::frame_pipeline_builder::FramePipelineBuilder;
18use crate::error::Error;
19use crate::{FfmpegContext, FfmpegScheduler, Input, Output};
20use ffmpeg_sys_next::av_guess_format;
21use ffmpeg_sys_next::AVMediaType::{self, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO};
22use std::ffi::CString;
23use std::ptr;
24use std::sync::{Arc, Mutex};
25
26fn map_analysis_terminal(e: Error) -> Error {
31 match e {
32 Error::FrameFilterProcess(boxed) => match boxed.downcast::<Error>() {
33 Ok(inner) => match *inner {
34 Error::AnalysisFrame(msg) => Error::AnalysisFrame(msg),
35 Error::InvalidRecipeArg(msg) => Error::InvalidRecipeArg(msg),
36 other => Error::FrameFilterProcess(Box::new(other)),
37 },
38 Err(other) => Error::FrameFilterProcess(other),
39 },
40 other => other,
41 }
42}
43
44pub struct Analysis {
46 input: Input,
47 video: Vec<VideoDetector>,
48 audio: Vec<AudioDetector>,
49 crop_options: Option<CropDetectionOptions>,
50}
51
52struct Branch {
54 media: AVMediaType,
55 map: String,
56}
57
58impl Analysis {
59 pub fn new(input: impl Into<Input>) -> Self {
62 Self {
63 input: input.into(),
64 video: Vec::new(),
65 audio: Vec::new(),
66 crop_options: None,
67 }
68 }
69
70 pub fn video_detector(mut self, detector: VideoDetector) -> Self {
72 self.video.push(detector);
73 self
74 }
75
76 pub fn crop_detection(mut self, options: CropDetectionOptions) -> Self {
80 self.crop_options = Some(options);
81 self
82 }
83
84 pub fn audio_detector(mut self, detector: AudioDetector) -> Self {
86 self.audio.push(detector);
87 self
88 }
89
90 pub fn run(self) -> crate::error::Result<AnalysisReport> {
100 Ok(self.run_detailed()?.report)
101 }
102
103 pub fn run_detailed(self) -> crate::error::Result<DetailedAnalysisReport> {
106 self.validate()?;
107 self.check_capabilities()?;
108
109 let crop = self.resolved_crop()?;
110 let (filter_desc, branches) = self.plan();
111 let cfg = self.fold_config();
112
113 let collector: Arc<Mutex<FoldState>> = Arc::new(Mutex::new(FoldState::default()));
114 let pipelines: Vec<FramePipeline> = branches
115 .iter()
116 .enumerate()
117 .map(|(index, branch)| {
118 let crop = if branch.media == AVMEDIA_TYPE_VIDEO {
119 crop.clone()
120 } else {
121 None
122 };
123 make_pipeline(branch.media, index, collector.clone(), crop)
124 })
125 .collect();
126
127 let mut output = Output::from("-")
128 .set_format("null")
129 .set_frame_pipelines(pipelines);
130 for branch in &branches {
131 output = output.add_stream_map(branch.map.clone());
132 }
133
134 let context = FfmpegContext::builder()
135 .input(self.input)
136 .filter_desc(filter_desc)
137 .output(output)
138 .build()?;
139 FfmpegScheduler::new(context)
140 .start()
141 .map_err(map_analysis_terminal)?
142 .wait()
143 .map_err(map_analysis_terminal)?;
144
145 let state = collector
146 .lock()
147 .map(|mut guard| std::mem::take(&mut *guard))
148 .map_err(|_| {
149 Error::InvalidRecipeArg(
150 "analysis event collector was poisoned by a panicked pipeline thread"
151 .to_string(),
152 )
153 })?;
154 let last_crop_observation = state.last_crop_observation;
155 Ok(DetailedAnalysisReport {
156 report: finalize(state, &cfg),
157 last_crop_observation,
158 })
159 }
160
161 fn validate(&self) -> crate::error::Result<()> {
164 if self.video.is_empty() && self.audio.is_empty() && self.crop_options.is_none() {
165 return Err(Error::InvalidRecipeArg(
166 "Analysis requires at least one detector".to_string(),
167 ));
168 }
169 let mut seen_video = [false; 3];
170 for detector in &self.video {
171 let idx = match detector {
172 VideoDetector::Black { .. } => 0,
173 VideoDetector::Scene { .. } => 1,
174 VideoDetector::Crop { .. } => 2,
175 };
176 if seen_video[idx] {
177 return Err(Error::InvalidRecipeArg(format!(
178 "duplicate video detector '{}' on the same media",
179 detector.filter_name().unwrap_or("crop")
180 )));
181 }
182 seen_video[idx] = true;
183 }
184 if seen_video[2] && self.crop_options.is_some() {
185 return Err(Error::InvalidRecipeArg(
186 "crop detection is configured twice (VideoDetector::Crop and Analysis::crop_detection)"
187 .to_string(),
188 ));
189 }
190 let mut seen_audio = [false; 2];
191 for detector in &self.audio {
192 let idx = match detector {
193 AudioDetector::Silence { .. } => 0,
194 AudioDetector::Ebur128 { .. } => 1,
195 };
196 if seen_audio[idx] {
197 return Err(Error::InvalidRecipeArg(format!(
198 "duplicate audio detector '{}' on the same media",
199 detector.filter_name()
200 )));
201 }
202 seen_audio[idx] = true;
203 }
204 for detector in &self.video {
205 detector.validate()?;
206 }
207 for detector in &self.audio {
208 detector.validate()?;
209 }
210 if let Some(opts) = &self.crop_options {
211 opts.validate()?;
212 }
213 Ok(())
214 }
215
216 fn resolved_crop(&self) -> crate::error::Result<Option<CropDetectionOptions>> {
217 if let Some(opts) = &self.crop_options {
218 return Ok(Some(opts.clone()));
219 }
220 for detector in &self.video {
221 if let VideoDetector::Crop {
222 limit,
223 round,
224 reset,
225 } = *detector
226 {
227 return Ok(Some(CropDetectionOptions::from_legacy(limit, round, reset)));
228 }
229 }
230 Ok(None)
231 }
232
233 fn check_capabilities(&self) -> crate::error::Result<()> {
240 for detector in &self.video {
241 if let Some(name) = detector.filter_name() {
242 require_filter(name)?;
243 }
244 }
245 for detector in &self.audio {
246 require_filter(detector.filter_name())?;
247 }
248 if self.audio.len() >= 2 {
249 require_filter("asplit")?;
250 }
251 let has_lavfi_video = self.video.iter().any(|d| d.to_filter().is_some());
252 let has_crop = self.crop_options.is_some() || self.video.iter().any(|d| d.is_native_crop());
253 if has_crop && !has_lavfi_video {
254 require_filter("null")?;
255 }
256 require_null_muxer()
257 }
258
259 fn plan(&self) -> (String, Vec<Branch>) {
261 let mut desc_parts: Vec<String> = Vec::new();
262 let mut branches: Vec<Branch> = Vec::new();
263
264 let lavfi_video: Vec<String> = self.video.iter().filter_map(|d| d.to_filter()).collect();
265 let has_crop = self.crop_options.is_some() || self.video.iter().any(|d| d.is_native_crop());
266 if !lavfi_video.is_empty() {
267 let chain = lavfi_video.join(",");
268 desc_parts.push(format!("[0:v]{chain}[vdet]"));
269 branches.push(Branch {
270 media: AVMEDIA_TYPE_VIDEO,
271 map: "[vdet]".to_string(),
272 });
273 } else if has_crop {
274 desc_parts.push("[0:v]null[vdet]".to_string());
275 branches.push(Branch {
276 media: AVMEDIA_TYPE_VIDEO,
277 map: "[vdet]".to_string(),
278 });
279 }
280
281 match self.audio.len() {
282 0 => {}
283 1 => {
284 desc_parts.push(format!("[0:a]{}[adet0]", self.audio[0].to_filter()));
285 branches.push(Branch {
286 media: AVMEDIA_TYPE_AUDIO,
287 map: "[adet0]".to_string(),
288 });
289 }
290 n => {
291 let labels: String = (0..n).map(|j| format!("[asplit{j}]")).collect();
292 desc_parts.push(format!("[0:a]asplit={n}{labels}"));
293 for (j, detector) in self.audio.iter().enumerate() {
294 desc_parts.push(format!("[asplit{j}]{}[adet{j}]", detector.to_filter()));
295 branches.push(Branch {
296 media: AVMEDIA_TYPE_AUDIO,
297 map: format!("[adet{j}]"),
298 });
299 }
300 }
301 }
302
303 (desc_parts.join(";"), branches)
304 }
305
306 fn fold_config(&self) -> FoldConfig {
308 let mut cfg = FoldConfig::default();
309 for detector in &self.video {
310 if let VideoDetector::Black { min_duration_s, .. } = detector {
311 cfg.black_min_duration_us = secs_to_us(*min_duration_s);
312 }
313 }
314 for detector in &self.audio {
315 if let AudioDetector::Silence { min_duration_s, .. } = detector {
316 cfg.silence_min_duration_us = secs_to_us(*min_duration_s);
317 }
318 }
319 cfg
320 }
321}
322
323#[derive(Clone)]
325struct FoldSink {
326 collector: Arc<Mutex<FoldState>>,
327}
328
329impl EventSink for FoldSink {
330 fn try_emit(&mut self, ev: MetadataEvent) -> Result<(), SinkError> {
331 match self.collector.lock() {
332 Ok(mut guard) => {
333 fold_event(&mut guard, ev);
335 Ok(())
336 }
337 Err(_) => Err(SinkError::Disconnected),
340 }
341 }
342}
343
344fn make_pipeline(
345 media: AVMediaType,
346 stream_index: usize,
347 collector: Arc<Mutex<FoldState>>,
348 crop: Option<CropDetectionOptions>,
349) -> FramePipeline {
350 let obs_collector = collector.clone();
351 let mut filter = MetadataEventFilter::new(media, FoldSink { collector });
352 if let Some(options) = crop {
353 filter =
354 filter
355 .with_crop_detection(options)
356 .with_crop_observer(move |obs: CropObservation| {
357 if let Ok(mut guard) = obs_collector.lock() {
358 guard.last_crop_observation = Some(obs);
359 }
360 });
361 }
362 FramePipelineBuilder::new(media)
363 .filter("analysis", Box::new(filter))
364 .set_stream_index(stream_index)
365 .build()
366}
367
368fn require_filter(name: &str) -> crate::error::Result<()> {
369 if crate::hwaccel::is_filter_available(name) {
370 Ok(())
371 } else {
372 Err(Error::InvalidRecipeArg(format!(
373 "FFmpeg filter '{name}' is not available in this build"
374 )))
375 }
376}
377
378fn require_null_muxer() -> crate::error::Result<()> {
379 let name = CString::new("null").expect("literal has no interior NUL");
380 let ofmt = unsafe { av_guess_format(name.as_ptr(), ptr::null(), ptr::null()) };
382 if ofmt.is_null() {
383 Err(Error::InvalidRecipeArg(
384 "FFmpeg 'null' muxer is not available in this build".to_string(),
385 ))
386 } else {
387 Ok(())
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::core::analysis::event::Timestamp;
395
396 fn sample() -> Analysis {
397 Analysis::new("input.mp4")
398 .video_detector(VideoDetector::Black {
399 min_duration_s: 0.1,
400 pixel_th: 0.1,
401 picture_th: 0.98,
402 })
403 .audio_detector(AudioDetector::Silence {
404 noise_db: -30.0,
405 min_duration_s: 0.5,
406 mono: false,
407 })
408 .audio_detector(AudioDetector::Ebur128 { true_peak: false })
409 }
410
411 #[test]
412 fn plan_isolates_audio_detectors_into_asplit_branches() {
413 let (desc, branches) = sample().plan();
414 assert!(desc.contains("[0:v]blackdetect=d=0.1:pix_th=0.1:pic_th=0.98[vdet]"));
415 assert!(desc.contains("[0:a]asplit=2[asplit0][asplit1]"));
416 assert!(desc.contains("[asplit0]silencedetect=noise=-30dB:d=0.5[adet0]"));
417 assert!(desc.contains("[asplit1]ebur128=metadata=1[adet1]"));
418 assert_eq!(branches.len(), 3);
419 assert_eq!(branches[0].media, AVMEDIA_TYPE_VIDEO);
420 assert_eq!(branches[1].media, AVMEDIA_TYPE_AUDIO);
421 }
422
423 #[test]
424 fn plan_single_audio_detector_has_no_asplit() {
425 let (desc, branches) = Analysis::new("input.mp4")
426 .audio_detector(AudioDetector::Ebur128 { true_peak: true })
427 .plan();
428 assert_eq!(desc, "[0:a]ebur128=metadata=1:peak=true[adet0]");
429 assert_eq!(branches.len(), 1);
430 }
431
432 #[test]
433 fn plan_crop_only_uses_null_passthrough() {
434 let (desc, branches) = Analysis::new("input.mp4")
435 .video_detector(VideoDetector::Crop {
436 limit: 24,
437 round: 16,
438 reset: 0,
439 })
440 .plan();
441 assert_eq!(desc, "[0:v]null[vdet]");
442 assert!(!desc.contains("cropdetect"));
443 assert_eq!(branches.len(), 1);
444 assert_eq!(branches[0].media, AVMEDIA_TYPE_VIDEO);
445 }
446
447 #[test]
448 fn plan_crop_with_black_omits_cropdetect() {
449 let (desc, _) = Analysis::new("input.mp4")
450 .video_detector(VideoDetector::Black {
451 min_duration_s: 0.1,
452 pixel_th: 0.1,
453 picture_th: 0.98,
454 })
455 .video_detector(VideoDetector::Crop {
456 limit: 24,
457 round: 16,
458 reset: 0,
459 })
460 .plan();
461 assert!(desc.contains("blackdetect"));
462 assert!(!desc.contains("cropdetect"));
463 assert!(!desc.contains("null[vdet]"));
464 }
465
466 #[test]
467 fn duplicate_crop_api_is_rejected() {
468 let result = Analysis::new("input.mp4")
469 .video_detector(VideoDetector::Crop {
470 limit: 24,
471 round: 16,
472 reset: 0,
473 })
474 .crop_detection(CropDetectionOptions::new())
475 .validate();
476 assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
477 }
478
479 #[test]
480 fn empty_analysis_is_rejected() {
481 let result = Analysis::new("input.mp4").validate();
482 assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
483 }
484
485 #[test]
486 fn duplicate_detector_is_rejected() {
487 let result = Analysis::new("input.mp4")
488 .video_detector(VideoDetector::Scene {
489 threshold_pct: 10.0,
490 })
491 .video_detector(VideoDetector::Scene {
492 threshold_pct: 20.0,
493 })
494 .validate();
495 assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
496 }
497
498 #[test]
499 fn fold_config_picks_up_min_durations() {
500 let cfg = sample().fold_config();
501 assert_eq!(cfg.black_min_duration_us, Some(100_000));
502 assert_eq!(cfg.silence_min_duration_us, Some(500_000));
503 }
504
505 #[test]
512 fn fold_sink_folds_each_event_on_arrival() {
513 fn ts(us: i64) -> Timestamp {
514 Timestamp {
515 time_us: us,
516 pts: None,
517 time_base: None,
518 }
519 }
520
521 let collector: Arc<Mutex<FoldState>> = Arc::new(Mutex::new(FoldState::default()));
522 let mut sink = FoldSink {
523 collector: collector.clone(),
524 };
525
526 for k in 1..=4i64 {
530 sink.try_emit(MetadataEvent::SceneChange {
531 at: ts(k * 1_000_000),
532 score: k as f64,
533 })
534 .unwrap();
535 let state = collector.lock().unwrap();
536 let scenes = &state.report_so_far().scenes;
537 assert_eq!(
538 scenes.len(),
539 k as usize,
540 "scene event {k} must be folded on arrival, not buffered"
541 );
542 assert_eq!(scenes[k as usize - 1].at_us, k * 1_000_000);
543 }
544
545 sink.try_emit(MetadataEvent::BlackStart { at: ts(5_000_000) })
548 .unwrap();
549 assert!(
550 collector.lock().unwrap().report_so_far().black.is_empty(),
551 "an open region has nothing to report yet"
552 );
553 sink.try_emit(MetadataEvent::BlackEnd {
554 at: ts(6_000_000),
555 duration_us: 1_000_000,
556 })
557 .unwrap();
558 {
559 let state = collector.lock().unwrap();
560 assert_eq!(
561 state.report_so_far().black,
562 vec![crate::analysis::BlackRange {
563 start_us: 5_000_000,
564 end_us: 6_000_000
565 }],
566 "the range must be visible right after its end event"
567 );
568 }
569
570 sink.try_emit(MetadataEvent::CropDetect {
572 at: ts(7_000_000),
573 x: 2,
574 y: 4,
575 w: 100,
576 h: 90,
577 })
578 .unwrap();
579 assert!(
580 collector.lock().unwrap().report_so_far().crop.is_some(),
581 "crop must be folded on arrival"
582 );
583 sink.try_emit(MetadataEvent::R128Summary {
584 integrated: Some(-23.0),
585 lra: Some(4.0),
586 true_peak: None,
587 })
588 .unwrap();
589 assert!(
590 collector.lock().unwrap().report_so_far().loudness.is_some(),
591 "loudness must be folded on arrival"
592 );
593
594 let state = std::mem::take(&mut *collector.lock().unwrap());
597 let report = finalize(state, &FoldConfig::default());
598 assert_eq!(report.scenes.len(), 4);
599 assert_eq!(report.black.len(), 1);
600 }
601
602 #[test]
603 fn map_analysis_terminal_restores_analysis_frame() {
604 let boxed: Box<dyn std::error::Error + Send + Sync> =
605 Box::new(Error::AnalysisFrame("interlaced".into()));
606 let mapped = map_analysis_terminal(Error::FrameFilterProcess(boxed));
607 assert!(
608 matches!(mapped, Error::AnalysisFrame(_)),
609 "typed AnalysisFrame must survive the filter boundary, got {mapped}"
610 );
611 }
612}