Skip to main content

ez_ffmpeg/core/analysis/
runner.rs

1//! The one-shot [`Analysis`] builder: configure detectors, run to completion,
2//! and get a folded [`AnalysisReport`].
3//!
4//! `run()` builds an isolation topology (§C2): every detector branch is mapped
5//! to its own stream on a single `null` output, each carrying a
6//! [`MetadataEventFilter`] that folds events into a shared fold state as they
7//! arrive (so per-frame events are never buffered). Audio
8//! detectors are split into separate `asplit` branches so `ebur128`'s 100 ms
9//! re-chunking never perturbs `silencedetect`.
10
11use 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
26/// Re-surfaces a typed analysis error that crossed the frame-filter
27/// boundary. [`MetadataEventFilter`] boxes [`Error`] so interlaced /
28/// hardware crop failures stay [`Error::AnalysisFrame`] (and config
29/// failures stay [`Error::InvalidRecipeArg`]) after `wait()`.
30fn 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
44/// A one-shot detection/measurement run over a single input.
45pub struct Analysis {
46    input: Input,
47    video: Vec<VideoDetector>,
48    audio: Vec<AudioDetector>,
49    crop_options: Option<CropDetectionOptions>,
50}
51
52/// One mapped detector branch: a filter-graph output label and its media type.
53struct Branch {
54    media: AVMediaType,
55    map: String,
56}
57
58impl Analysis {
59    /// Starts an analysis over `input` (a path, URL, or anything convertible
60    /// into an [`Input`]).
61    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    /// Adds a video detector. At most one of each kind is allowed per run.
71    pub fn video_detector(mut self, detector: VideoDetector) -> Self {
72        self.video.push(detector);
73        self
74    }
75
76    /// Adds native crop detection via [`CropDetectionOptions`].
77    ///
78    /// Mutually exclusive with [`VideoDetector::Crop`] on the same run.
79    pub fn crop_detection(mut self, options: CropDetectionOptions) -> Self {
80        self.crop_options = Some(options);
81        self
82    }
83
84    /// Adds an audio detector. At most one of each kind is allowed per run.
85    pub fn audio_detector(mut self, detector: AudioDetector) -> Self {
86        self.audio.push(detector);
87        self
88    }
89
90    /// Runs the analysis to completion and folds the events into a report.
91    ///
92    /// # Errors
93    /// - [`Error::InvalidRecipeArg`] if no detectors are configured, a detector
94    ///   kind is duplicated, or a required filter / the `null` muxer is missing.
95    /// - [`Error::AnalysisFrame`] if native crop detection cannot read a
96    ///   decoded frame (interlaced fields, hardware surface, unsupported
97    ///   format).
98    /// - Any error bubbling up from the underlying FFmpeg run.
99    pub fn run(self) -> crate::error::Result<AnalysisReport> {
100        Ok(self.run_detailed()?.report)
101    }
102
103    /// Like [`run`](Self::run), but also returns the last raw/aligned crop
104    /// observation when native crop detection ran.
105    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    /// Rejects empty and duplicated detector sets (duplicate detectors of the
162    /// same kind write indistinguishable `lavfi.*` keys).
163    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    /// Verifies the chosen filters, `asplit` (if needed), and the `null` muxer
234    /// exist in the linked FFmpeg build. Best-effort — passing here does not
235    /// guarantee the graph parses.
236    ///
237    /// Native crop does **not** require `cropdetect`. A crop-only video branch
238    /// uses the lavfi `null` passthrough.
239    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    /// Builds the `filter_desc` string and the ordered branch list.
260    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    /// Collects the min-duration thresholds the folder needs to trim tails.
307    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/// A `Send`-able sink that folds events into the run's shared fold state.
324#[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 on arrival so per-frame events are never buffered.
334                fold_event(&mut guard, ev);
335                Ok(())
336            }
337            // A poisoned mutex means a pipeline thread panicked; surface it as a
338            // disconnected sink so the run aborts instead of silently dropping.
339            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    // SAFETY: `name` is a valid C string; null filename/mime are accepted.
381    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    // Pins the fold-on-arrival contract: FoldSink must fold each event into
506    // the shared FoldState the moment try_emit is called. That is what keeps
507    // analysis memory bounded by DETECTED features rather than media duration;
508    // a buffer-then-fold sink (accumulate Vec<MetadataEvent>, fold at end)
509    // would leave the shared state untouched until finalize and fail the
510    // after-every-emit assertions below.
511    #[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        // Scene changes append a report entry per event: after the k-th emit
527        // the folded report must already hold exactly k scenes — no waiting
528        // for a finalize step.
529        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        // A paired region: the range must appear the moment its END event
546        // arrives (the fold closes it immediately), not at finalize.
547        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        // Last-value events: folded state reflects each one immediately.
571        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        // The end-to-end shape run() relies on: taking the state and
595        // finalizing yields the already-folded report.
596        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}