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::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
25/// A one-shot detection/measurement run over a single input.
26pub struct Analysis {
27    input: Input,
28    video: Vec<VideoDetector>,
29    audio: Vec<AudioDetector>,
30}
31
32/// One mapped detector branch: a filter-graph output label and its media type.
33struct Branch {
34    media: AVMediaType,
35    map: String,
36}
37
38impl Analysis {
39    /// Starts an analysis over `input` (a path, URL, or anything convertible
40    /// into an [`Input`]).
41    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    /// Adds a video detector. At most one of each kind is allowed per run.
50    pub fn video_detector(mut self, detector: VideoDetector) -> Self {
51        self.video.push(detector);
52        self
53    }
54
55    /// Adds an audio detector. At most one of each kind is allowed per run.
56    pub fn audio_detector(mut self, detector: AudioDetector) -> Self {
57        self.audio.push(detector);
58        self
59    }
60
61    /// Runs the analysis to completion and folds the events into a report.
62    ///
63    /// # Errors
64    /// - [`Error::InvalidRecipeArg`] if no detectors are configured, a detector
65    ///   kind is duplicated, or a required filter / the `null` muxer is missing.
66    /// - Any error bubbling up from the underlying FFmpeg run.
67    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    /// Rejects empty and duplicated detector sets (duplicate detectors of the
108    /// same kind write indistinguishable `lavfi.*` keys).
109    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    /// Verifies the chosen filters, `asplit` (if needed), and the `null` muxer
154    /// exist in the linked FFmpeg build. Best-effort — passing here does not
155    /// guarantee the graph parses.
156    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    /// Builds the `filter_desc` string and the ordered branch list.
170    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        // Video detectors are all passthrough, so they chain on one branch.
175        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        // Audio detectors must be isolated: split into one branch each.
190        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    /// Collects the min-duration thresholds the folder needs to trim tails.
216    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/// A `Send`-able sink that folds events into the run's shared fold state.
233#[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 on arrival so per-frame events are never buffered.
243                fold_event(&mut guard, ev);
244                Ok(())
245            }
246            // A poisoned mutex means a pipeline thread panicked; surface it as a
247            // disconnected sink so the run aborts instead of silently dropping.
248            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    // SAFETY: `name` is a valid C string; null filename/mime are accepted.
278    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    // Pins the fold-on-arrival contract: FoldSink must fold each event into
356    // the shared FoldState the moment try_emit is called. That is what keeps
357    // analysis memory bounded by DETECTED features rather than media duration;
358    // a buffer-then-fold sink (accumulate Vec<MetadataEvent>, fold at end)
359    // would leave the shared state untouched until finalize and fail the
360    // after-every-emit assertions below.
361    #[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        // Scene changes append a report entry per event: after the k-th emit
377        // the folded report must already hold exactly k scenes — no waiting
378        // for a finalize step.
379        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        // A paired region: the range must appear the moment its END event
396        // arrives (the fold closes it immediately), not at finalize.
397        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        // Last-value events: folded state reflects each one immediately.
421        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        // The end-to-end shape run() relies on: taking the state and
445        // finalizing yields the already-folded report.
446        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}