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 collects events into a shared buffer. Audio
7//! detectors are split into separate `asplit` branches so `ebur128`'s 100 ms
8//! re-chunking never perturbs `silencedetect`.
9
10use crate::core::analysis::detector::{AudioDetector, VideoDetector};
11use crate::core::analysis::event::{secs_to_us, MetadataEvent};
12use crate::core::analysis::filter::{EventSink, MetadataEventFilter, SinkError};
13use crate::core::analysis::report::{fold, AnalysisReport, FoldConfig};
14use crate::core::filter::frame_pipeline::FramePipeline;
15use crate::core::filter::frame_pipeline_builder::FramePipelineBuilder;
16use crate::error::Error;
17use crate::{FfmpegContext, FfmpegScheduler, Input, Output};
18use ffmpeg_sys_next::av_guess_format;
19use ffmpeg_sys_next::AVMediaType::{self, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO};
20use std::ffi::CString;
21use std::ptr;
22use std::sync::{Arc, Mutex};
23
24/// A one-shot detection/measurement run over a single input.
25pub struct Analysis {
26    input: Input,
27    video: Vec<VideoDetector>,
28    audio: Vec<AudioDetector>,
29}
30
31/// One mapped detector branch: a filter-graph output label and its media type.
32struct Branch {
33    media: AVMediaType,
34    map: String,
35}
36
37impl Analysis {
38    /// Starts an analysis over `input` (a path, URL, or anything convertible
39    /// into an [`Input`]).
40    pub fn new(input: impl Into<Input>) -> Self {
41        Self {
42            input: input.into(),
43            video: Vec::new(),
44            audio: Vec::new(),
45        }
46    }
47
48    /// Adds a video detector. At most one of each kind is allowed per run.
49    pub fn video_detector(mut self, detector: VideoDetector) -> Self {
50        self.video.push(detector);
51        self
52    }
53
54    /// Adds an audio detector. At most one of each kind is allowed per run.
55    pub fn audio_detector(mut self, detector: AudioDetector) -> Self {
56        self.audio.push(detector);
57        self
58    }
59
60    /// Runs the analysis to completion and folds the events into a report.
61    ///
62    /// # Errors
63    /// - [`Error::InvalidRecipeArg`] if no detectors are configured, a detector
64    ///   kind is duplicated, or a required filter / the `null` muxer is missing.
65    /// - Any error bubbling up from the underlying FFmpeg run.
66    pub fn run(self) -> crate::error::Result<AnalysisReport> {
67        self.validate()?;
68        self.check_capabilities()?;
69
70        let (filter_desc, branches) = self.plan();
71        let cfg = self.fold_config();
72
73        let collector: Arc<Mutex<Vec<MetadataEvent>>> = Arc::new(Mutex::new(Vec::new()));
74        let pipelines: Vec<FramePipeline> = branches
75            .iter()
76            .enumerate()
77            .map(|(index, branch)| make_pipeline(branch.media, index, collector.clone()))
78            .collect();
79
80        let mut output = Output::from("-")
81            .set_format("null")
82            .set_frame_pipelines(pipelines);
83        for branch in &branches {
84            output = output.add_stream_map(branch.map.clone());
85        }
86
87        let context = FfmpegContext::builder()
88            .input(self.input)
89            .filter_desc(filter_desc)
90            .output(output)
91            .build()?;
92        FfmpegScheduler::new(context).start()?.wait()?;
93
94        let events = collector
95            .lock()
96            .map(|mut guard| std::mem::take(&mut *guard))
97            .map_err(|_| {
98                Error::InvalidRecipeArg(
99                    "analysis event collector was poisoned by a panicked pipeline thread"
100                        .to_string(),
101                )
102            })?;
103        Ok(fold(events, &cfg))
104    }
105
106    /// Rejects empty and duplicated detector sets (duplicate detectors of the
107    /// same kind write indistinguishable `lavfi.*` keys).
108    fn validate(&self) -> crate::error::Result<()> {
109        if self.video.is_empty() && self.audio.is_empty() {
110            return Err(Error::InvalidRecipeArg(
111                "Analysis requires at least one detector".to_string(),
112            ));
113        }
114        let mut seen_video = [false; 3];
115        for detector in &self.video {
116            let idx = match detector {
117                VideoDetector::Black { .. } => 0,
118                VideoDetector::Scene { .. } => 1,
119                VideoDetector::Crop { .. } => 2,
120            };
121            if seen_video[idx] {
122                return Err(Error::InvalidRecipeArg(format!(
123                    "duplicate video detector '{}' on the same media",
124                    detector.filter_name()
125                )));
126            }
127            seen_video[idx] = true;
128        }
129        let mut seen_audio = [false; 2];
130        for detector in &self.audio {
131            let idx = match detector {
132                AudioDetector::Silence { .. } => 0,
133                AudioDetector::Ebur128 { .. } => 1,
134            };
135            if seen_audio[idx] {
136                return Err(Error::InvalidRecipeArg(format!(
137                    "duplicate audio detector '{}' on the same media",
138                    detector.filter_name()
139                )));
140            }
141            seen_audio[idx] = true;
142        }
143        for detector in &self.video {
144            detector.validate()?;
145        }
146        for detector in &self.audio {
147            detector.validate()?;
148        }
149        Ok(())
150    }
151
152    /// Verifies the chosen filters, `asplit` (if needed), and the `null` muxer
153    /// exist in the linked FFmpeg build. Best-effort — passing here does not
154    /// guarantee the graph parses.
155    fn check_capabilities(&self) -> crate::error::Result<()> {
156        for detector in &self.video {
157            require_filter(detector.filter_name())?;
158        }
159        for detector in &self.audio {
160            require_filter(detector.filter_name())?;
161        }
162        if self.audio.len() >= 2 {
163            require_filter("asplit")?;
164        }
165        require_null_muxer()
166    }
167
168    /// Builds the `filter_desc` string and the ordered branch list.
169    fn plan(&self) -> (String, Vec<Branch>) {
170        let mut desc_parts: Vec<String> = Vec::new();
171        let mut branches: Vec<Branch> = Vec::new();
172
173        // Video detectors are all passthrough, so they chain on one branch.
174        if !self.video.is_empty() {
175            let chain = self
176                .video
177                .iter()
178                .map(|d| d.to_filter())
179                .collect::<Vec<_>>()
180                .join(",");
181            desc_parts.push(format!("[0:v]{chain}[vdet]"));
182            branches.push(Branch {
183                media: AVMEDIA_TYPE_VIDEO,
184                map: "[vdet]".to_string(),
185            });
186        }
187
188        // Audio detectors must be isolated: split into one branch each.
189        match self.audio.len() {
190            0 => {}
191            1 => {
192                desc_parts.push(format!("[0:a]{}[adet0]", self.audio[0].to_filter()));
193                branches.push(Branch {
194                    media: AVMEDIA_TYPE_AUDIO,
195                    map: "[adet0]".to_string(),
196                });
197            }
198            n => {
199                let labels: String = (0..n).map(|j| format!("[asplit{j}]")).collect();
200                desc_parts.push(format!("[0:a]asplit={n}{labels}"));
201                for (j, detector) in self.audio.iter().enumerate() {
202                    desc_parts.push(format!("[asplit{j}]{}[adet{j}]", detector.to_filter()));
203                    branches.push(Branch {
204                        media: AVMEDIA_TYPE_AUDIO,
205                        map: format!("[adet{j}]"),
206                    });
207                }
208            }
209        }
210
211        (desc_parts.join(";"), branches)
212    }
213
214    /// Collects the min-duration thresholds the folder needs to trim tails.
215    fn fold_config(&self) -> FoldConfig {
216        let mut cfg = FoldConfig::default();
217        for detector in &self.video {
218            if let VideoDetector::Black { min_duration_s, .. } = detector {
219                cfg.black_min_duration_us = secs_to_us(*min_duration_s);
220            }
221        }
222        for detector in &self.audio {
223            if let AudioDetector::Silence { min_duration_s, .. } = detector {
224                cfg.silence_min_duration_us = secs_to_us(*min_duration_s);
225            }
226        }
227        cfg
228    }
229}
230
231/// A `Send`-able sink that appends events into the run's shared buffer.
232#[derive(Clone)]
233struct VecSink {
234    collector: Arc<Mutex<Vec<MetadataEvent>>>,
235}
236
237impl EventSink for VecSink {
238    fn try_emit(&mut self, ev: MetadataEvent) -> Result<(), SinkError> {
239        match self.collector.lock() {
240            Ok(mut guard) => {
241                guard.push(ev);
242                Ok(())
243            }
244            // A poisoned mutex means a pipeline thread panicked; surface it as a
245            // disconnected sink so the run aborts instead of silently dropping.
246            Err(_) => Err(SinkError::Disconnected),
247        }
248    }
249}
250
251fn make_pipeline(
252    media: AVMediaType,
253    stream_index: usize,
254    collector: Arc<Mutex<Vec<MetadataEvent>>>,
255) -> FramePipeline {
256    let filter = MetadataEventFilter::new(media, VecSink { collector });
257    FramePipelineBuilder::new(media)
258        .filter("analysis", Box::new(filter))
259        .set_stream_index(stream_index)
260        .build()
261}
262
263fn require_filter(name: &str) -> crate::error::Result<()> {
264    if crate::hwaccel::is_filter_available(name) {
265        Ok(())
266    } else {
267        Err(Error::InvalidRecipeArg(format!(
268            "FFmpeg filter '{name}' is not available in this build"
269        )))
270    }
271}
272
273fn require_null_muxer() -> crate::error::Result<()> {
274    let name = CString::new("null").expect("literal has no interior NUL");
275    // SAFETY: `name` is a valid C string; null filename/mime are accepted.
276    let ofmt = unsafe { av_guess_format(name.as_ptr(), ptr::null(), ptr::null()) };
277    if ofmt.is_null() {
278        Err(Error::InvalidRecipeArg(
279            "FFmpeg 'null' muxer is not available in this build".to_string(),
280        ))
281    } else {
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn sample() -> Analysis {
291        Analysis::new("input.mp4")
292            .video_detector(VideoDetector::Black {
293                min_duration_s: 0.1,
294                pixel_th: 0.1,
295                picture_th: 0.98,
296            })
297            .audio_detector(AudioDetector::Silence {
298                noise_db: -30.0,
299                min_duration_s: 0.5,
300                mono: false,
301            })
302            .audio_detector(AudioDetector::Ebur128 { true_peak: false })
303    }
304
305    #[test]
306    fn plan_isolates_audio_detectors_into_asplit_branches() {
307        let (desc, branches) = sample().plan();
308        assert!(desc.contains("[0:v]blackdetect=d=0.1:pix_th=0.1:pic_th=0.98[vdet]"));
309        assert!(desc.contains("[0:a]asplit=2[asplit0][asplit1]"));
310        assert!(desc.contains("[asplit0]silencedetect=noise=-30dB:d=0.5[adet0]"));
311        assert!(desc.contains("[asplit1]ebur128=metadata=1[adet1]"));
312        assert_eq!(branches.len(), 3);
313        assert_eq!(branches[0].media, AVMEDIA_TYPE_VIDEO);
314        assert_eq!(branches[1].media, AVMEDIA_TYPE_AUDIO);
315    }
316
317    #[test]
318    fn plan_single_audio_detector_has_no_asplit() {
319        let (desc, branches) =
320            Analysis::new("input.mp4")
321                .audio_detector(AudioDetector::Ebur128 { true_peak: true })
322                .plan();
323        assert_eq!(desc, "[0:a]ebur128=metadata=1:peak=true[adet0]");
324        assert_eq!(branches.len(), 1);
325    }
326
327    #[test]
328    fn empty_analysis_is_rejected() {
329        let result = Analysis::new("input.mp4").validate();
330        assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
331    }
332
333    #[test]
334    fn duplicate_detector_is_rejected() {
335        let result = Analysis::new("input.mp4")
336            .video_detector(VideoDetector::Scene { threshold_pct: 10.0 })
337            .video_detector(VideoDetector::Scene { threshold_pct: 20.0 })
338            .validate();
339        assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
340    }
341
342    #[test]
343    fn fold_config_picks_up_min_durations() {
344        let cfg = sample().fold_config();
345        assert_eq!(cfg.black_min_duration_us, Some(100_000));
346        assert_eq!(cfg.silence_min_duration_us, Some(500_000));
347    }
348}