ez_ffmpeg/core/analysis/
runner.rs1use 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
24pub struct Analysis {
26 input: Input,
27 video: Vec<VideoDetector>,
28 audio: Vec<AudioDetector>,
29}
30
31struct Branch {
33 media: AVMediaType,
34 map: String,
35}
36
37impl Analysis {
38 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 pub fn video_detector(mut self, detector: VideoDetector) -> Self {
50 self.video.push(detector);
51 self
52 }
53
54 pub fn audio_detector(mut self, detector: AudioDetector) -> Self {
56 self.audio.push(detector);
57 self
58 }
59
60 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 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 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 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 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 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 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#[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 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 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}