use crate::core::analysis::crop::{CropDetectionOptions, CropObservation, DetailedAnalysisReport};
use crate::core::analysis::detector::{AudioDetector, VideoDetector};
use crate::core::analysis::event::{secs_to_us, MetadataEvent};
use crate::core::analysis::filter::{EventSink, MetadataEventFilter, SinkError};
use crate::core::analysis::report::{finalize, fold_event, AnalysisReport, FoldConfig, FoldState};
use crate::core::filter::frame_pipeline::FramePipeline;
use crate::core::filter::frame_pipeline_builder::FramePipelineBuilder;
use crate::error::Error;
use crate::{FfmpegContext, FfmpegScheduler, Input, Output};
use ffmpeg_sys_next::av_guess_format;
use ffmpeg_sys_next::AVMediaType::{self, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO};
use std::ffi::CString;
use std::ptr;
use std::sync::{Arc, Mutex};
fn map_analysis_terminal(e: Error) -> Error {
match e {
Error::FrameFilterProcess(boxed) => match boxed.downcast::<Error>() {
Ok(inner) => match *inner {
Error::AnalysisFrame(msg) => Error::AnalysisFrame(msg),
Error::InvalidRecipeArg(msg) => Error::InvalidRecipeArg(msg),
other => Error::FrameFilterProcess(Box::new(other)),
},
Err(other) => Error::FrameFilterProcess(other),
},
other => other,
}
}
pub struct Analysis {
input: Input,
video: Vec<VideoDetector>,
audio: Vec<AudioDetector>,
crop_options: Option<CropDetectionOptions>,
}
struct Branch {
media: AVMediaType,
map: String,
}
impl Analysis {
pub fn new(input: impl Into<Input>) -> Self {
Self {
input: input.into(),
video: Vec::new(),
audio: Vec::new(),
crop_options: None,
}
}
pub fn video_detector(mut self, detector: VideoDetector) -> Self {
self.video.push(detector);
self
}
pub fn crop_detection(mut self, options: CropDetectionOptions) -> Self {
self.crop_options = Some(options);
self
}
pub fn audio_detector(mut self, detector: AudioDetector) -> Self {
self.audio.push(detector);
self
}
pub fn run(self) -> crate::error::Result<AnalysisReport> {
Ok(self.run_detailed()?.report)
}
pub fn run_detailed(self) -> crate::error::Result<DetailedAnalysisReport> {
self.validate()?;
self.check_capabilities()?;
let crop = self.resolved_crop()?;
let (filter_desc, branches) = self.plan();
let cfg = self.fold_config();
let collector: Arc<Mutex<FoldState>> = Arc::new(Mutex::new(FoldState::default()));
let pipelines: Vec<FramePipeline> = branches
.iter()
.enumerate()
.map(|(index, branch)| {
let crop = if branch.media == AVMEDIA_TYPE_VIDEO {
crop.clone()
} else {
None
};
make_pipeline(branch.media, index, collector.clone(), crop)
})
.collect();
let mut output = Output::from("-")
.set_format("null")
.set_frame_pipelines(pipelines);
for branch in &branches {
output = output.add_stream_map(branch.map.clone());
}
let context = FfmpegContext::builder()
.input(self.input)
.filter_desc(filter_desc)
.output(output)
.build()?;
FfmpegScheduler::new(context)
.start()
.map_err(map_analysis_terminal)?
.wait()
.map_err(map_analysis_terminal)?;
let state = collector
.lock()
.map(|mut guard| std::mem::take(&mut *guard))
.map_err(|_| {
Error::InvalidRecipeArg(
"analysis event collector was poisoned by a panicked pipeline thread"
.to_string(),
)
})?;
let last_crop_observation = state.last_crop_observation;
Ok(DetailedAnalysisReport {
report: finalize(state, &cfg),
last_crop_observation,
})
}
fn validate(&self) -> crate::error::Result<()> {
if self.video.is_empty() && self.audio.is_empty() && self.crop_options.is_none() {
return Err(Error::InvalidRecipeArg(
"Analysis requires at least one detector".to_string(),
));
}
let mut seen_video = [false; 3];
for detector in &self.video {
let idx = match detector {
VideoDetector::Black { .. } => 0,
VideoDetector::Scene { .. } => 1,
VideoDetector::Crop { .. } => 2,
};
if seen_video[idx] {
return Err(Error::InvalidRecipeArg(format!(
"duplicate video detector '{}' on the same media",
detector.filter_name().unwrap_or("crop")
)));
}
seen_video[idx] = true;
}
if seen_video[2] && self.crop_options.is_some() {
return Err(Error::InvalidRecipeArg(
"crop detection is configured twice (VideoDetector::Crop and Analysis::crop_detection)"
.to_string(),
));
}
let mut seen_audio = [false; 2];
for detector in &self.audio {
let idx = match detector {
AudioDetector::Silence { .. } => 0,
AudioDetector::Ebur128 { .. } => 1,
};
if seen_audio[idx] {
return Err(Error::InvalidRecipeArg(format!(
"duplicate audio detector '{}' on the same media",
detector.filter_name()
)));
}
seen_audio[idx] = true;
}
for detector in &self.video {
detector.validate()?;
}
for detector in &self.audio {
detector.validate()?;
}
if let Some(opts) = &self.crop_options {
opts.validate()?;
}
Ok(())
}
fn resolved_crop(&self) -> crate::error::Result<Option<CropDetectionOptions>> {
if let Some(opts) = &self.crop_options {
return Ok(Some(opts.clone()));
}
for detector in &self.video {
if let VideoDetector::Crop {
limit,
round,
reset,
} = *detector
{
return Ok(Some(CropDetectionOptions::from_legacy(limit, round, reset)));
}
}
Ok(None)
}
fn check_capabilities(&self) -> crate::error::Result<()> {
for detector in &self.video {
if let Some(name) = detector.filter_name() {
require_filter(name)?;
}
}
for detector in &self.audio {
require_filter(detector.filter_name())?;
}
if self.audio.len() >= 2 {
require_filter("asplit")?;
}
let has_lavfi_video = self.video.iter().any(|d| d.to_filter().is_some());
let has_crop = self.crop_options.is_some() || self.video.iter().any(|d| d.is_native_crop());
if has_crop && !has_lavfi_video {
require_filter("null")?;
}
require_null_muxer()
}
fn plan(&self) -> (String, Vec<Branch>) {
let mut desc_parts: Vec<String> = Vec::new();
let mut branches: Vec<Branch> = Vec::new();
let lavfi_video: Vec<String> = self.video.iter().filter_map(|d| d.to_filter()).collect();
let has_crop = self.crop_options.is_some() || self.video.iter().any(|d| d.is_native_crop());
if !lavfi_video.is_empty() {
let chain = lavfi_video.join(",");
desc_parts.push(format!("[0:v]{chain}[vdet]"));
branches.push(Branch {
media: AVMEDIA_TYPE_VIDEO,
map: "[vdet]".to_string(),
});
} else if has_crop {
desc_parts.push("[0:v]null[vdet]".to_string());
branches.push(Branch {
media: AVMEDIA_TYPE_VIDEO,
map: "[vdet]".to_string(),
});
}
match self.audio.len() {
0 => {}
1 => {
desc_parts.push(format!("[0:a]{}[adet0]", self.audio[0].to_filter()));
branches.push(Branch {
media: AVMEDIA_TYPE_AUDIO,
map: "[adet0]".to_string(),
});
}
n => {
let labels: String = (0..n).map(|j| format!("[asplit{j}]")).collect();
desc_parts.push(format!("[0:a]asplit={n}{labels}"));
for (j, detector) in self.audio.iter().enumerate() {
desc_parts.push(format!("[asplit{j}]{}[adet{j}]", detector.to_filter()));
branches.push(Branch {
media: AVMEDIA_TYPE_AUDIO,
map: format!("[adet{j}]"),
});
}
}
}
(desc_parts.join(";"), branches)
}
fn fold_config(&self) -> FoldConfig {
let mut cfg = FoldConfig::default();
for detector in &self.video {
if let VideoDetector::Black { min_duration_s, .. } = detector {
cfg.black_min_duration_us = secs_to_us(*min_duration_s);
}
}
for detector in &self.audio {
if let AudioDetector::Silence { min_duration_s, .. } = detector {
cfg.silence_min_duration_us = secs_to_us(*min_duration_s);
}
}
cfg
}
}
#[derive(Clone)]
struct FoldSink {
collector: Arc<Mutex<FoldState>>,
}
impl EventSink for FoldSink {
fn try_emit(&mut self, ev: MetadataEvent) -> Result<(), SinkError> {
match self.collector.lock() {
Ok(mut guard) => {
fold_event(&mut guard, ev);
Ok(())
}
Err(_) => Err(SinkError::Disconnected),
}
}
}
fn make_pipeline(
media: AVMediaType,
stream_index: usize,
collector: Arc<Mutex<FoldState>>,
crop: Option<CropDetectionOptions>,
) -> FramePipeline {
let obs_collector = collector.clone();
let mut filter = MetadataEventFilter::new(media, FoldSink { collector });
if let Some(options) = crop {
filter =
filter
.with_crop_detection(options)
.with_crop_observer(move |obs: CropObservation| {
if let Ok(mut guard) = obs_collector.lock() {
guard.last_crop_observation = Some(obs);
}
});
}
FramePipelineBuilder::new(media)
.filter("analysis", Box::new(filter))
.set_stream_index(stream_index)
.build()
}
fn require_filter(name: &str) -> crate::error::Result<()> {
if crate::hwaccel::is_filter_available(name) {
Ok(())
} else {
Err(Error::InvalidRecipeArg(format!(
"FFmpeg filter '{name}' is not available in this build"
)))
}
}
fn require_null_muxer() -> crate::error::Result<()> {
let name = CString::new("null").expect("literal has no interior NUL");
let ofmt = unsafe { av_guess_format(name.as_ptr(), ptr::null(), ptr::null()) };
if ofmt.is_null() {
Err(Error::InvalidRecipeArg(
"FFmpeg 'null' muxer is not available in this build".to_string(),
))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::analysis::event::Timestamp;
fn sample() -> Analysis {
Analysis::new("input.mp4")
.video_detector(VideoDetector::Black {
min_duration_s: 0.1,
pixel_th: 0.1,
picture_th: 0.98,
})
.audio_detector(AudioDetector::Silence {
noise_db: -30.0,
min_duration_s: 0.5,
mono: false,
})
.audio_detector(AudioDetector::Ebur128 { true_peak: false })
}
#[test]
fn plan_isolates_audio_detectors_into_asplit_branches() {
let (desc, branches) = sample().plan();
assert!(desc.contains("[0:v]blackdetect=d=0.1:pix_th=0.1:pic_th=0.98[vdet]"));
assert!(desc.contains("[0:a]asplit=2[asplit0][asplit1]"));
assert!(desc.contains("[asplit0]silencedetect=noise=-30dB:d=0.5[adet0]"));
assert!(desc.contains("[asplit1]ebur128=metadata=1[adet1]"));
assert_eq!(branches.len(), 3);
assert_eq!(branches[0].media, AVMEDIA_TYPE_VIDEO);
assert_eq!(branches[1].media, AVMEDIA_TYPE_AUDIO);
}
#[test]
fn plan_single_audio_detector_has_no_asplit() {
let (desc, branches) = Analysis::new("input.mp4")
.audio_detector(AudioDetector::Ebur128 { true_peak: true })
.plan();
assert_eq!(desc, "[0:a]ebur128=metadata=1:peak=true[adet0]");
assert_eq!(branches.len(), 1);
}
#[test]
fn plan_crop_only_uses_null_passthrough() {
let (desc, branches) = Analysis::new("input.mp4")
.video_detector(VideoDetector::Crop {
limit: 24,
round: 16,
reset: 0,
})
.plan();
assert_eq!(desc, "[0:v]null[vdet]");
assert!(!desc.contains("cropdetect"));
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].media, AVMEDIA_TYPE_VIDEO);
}
#[test]
fn plan_crop_with_black_omits_cropdetect() {
let (desc, _) = Analysis::new("input.mp4")
.video_detector(VideoDetector::Black {
min_duration_s: 0.1,
pixel_th: 0.1,
picture_th: 0.98,
})
.video_detector(VideoDetector::Crop {
limit: 24,
round: 16,
reset: 0,
})
.plan();
assert!(desc.contains("blackdetect"));
assert!(!desc.contains("cropdetect"));
assert!(!desc.contains("null[vdet]"));
}
#[test]
fn duplicate_crop_api_is_rejected() {
let result = Analysis::new("input.mp4")
.video_detector(VideoDetector::Crop {
limit: 24,
round: 16,
reset: 0,
})
.crop_detection(CropDetectionOptions::new())
.validate();
assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn empty_analysis_is_rejected() {
let result = Analysis::new("input.mp4").validate();
assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn duplicate_detector_is_rejected() {
let result = Analysis::new("input.mp4")
.video_detector(VideoDetector::Scene {
threshold_pct: 10.0,
})
.video_detector(VideoDetector::Scene {
threshold_pct: 20.0,
})
.validate();
assert!(matches!(result, Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn fold_config_picks_up_min_durations() {
let cfg = sample().fold_config();
assert_eq!(cfg.black_min_duration_us, Some(100_000));
assert_eq!(cfg.silence_min_duration_us, Some(500_000));
}
#[test]
fn fold_sink_folds_each_event_on_arrival() {
fn ts(us: i64) -> Timestamp {
Timestamp {
time_us: us,
pts: None,
time_base: None,
}
}
let collector: Arc<Mutex<FoldState>> = Arc::new(Mutex::new(FoldState::default()));
let mut sink = FoldSink {
collector: collector.clone(),
};
for k in 1..=4i64 {
sink.try_emit(MetadataEvent::SceneChange {
at: ts(k * 1_000_000),
score: k as f64,
})
.unwrap();
let state = collector.lock().unwrap();
let scenes = &state.report_so_far().scenes;
assert_eq!(
scenes.len(),
k as usize,
"scene event {k} must be folded on arrival, not buffered"
);
assert_eq!(scenes[k as usize - 1].at_us, k * 1_000_000);
}
sink.try_emit(MetadataEvent::BlackStart { at: ts(5_000_000) })
.unwrap();
assert!(
collector.lock().unwrap().report_so_far().black.is_empty(),
"an open region has nothing to report yet"
);
sink.try_emit(MetadataEvent::BlackEnd {
at: ts(6_000_000),
duration_us: 1_000_000,
})
.unwrap();
{
let state = collector.lock().unwrap();
assert_eq!(
state.report_so_far().black,
vec![crate::analysis::BlackRange {
start_us: 5_000_000,
end_us: 6_000_000
}],
"the range must be visible right after its end event"
);
}
sink.try_emit(MetadataEvent::CropDetect {
at: ts(7_000_000),
x: 2,
y: 4,
w: 100,
h: 90,
})
.unwrap();
assert!(
collector.lock().unwrap().report_so_far().crop.is_some(),
"crop must be folded on arrival"
);
sink.try_emit(MetadataEvent::R128Summary {
integrated: Some(-23.0),
lra: Some(4.0),
true_peak: None,
})
.unwrap();
assert!(
collector.lock().unwrap().report_so_far().loudness.is_some(),
"loudness must be folded on arrival"
);
let state = std::mem::take(&mut *collector.lock().unwrap());
let report = finalize(state, &FoldConfig::default());
assert_eq!(report.scenes.len(), 4);
assert_eq!(report.black.len(), 1);
}
#[test]
fn map_analysis_terminal_restores_analysis_frame() {
let boxed: Box<dyn std::error::Error + Send + Sync> =
Box::new(Error::AnalysisFrame("interlaced".into()));
let mapped = map_analysis_terminal(Error::FrameFilterProcess(boxed));
assert!(
matches!(mapped, Error::AnalysisFrame(_)),
"typed AnalysisFrame must survive the filter boundary, got {mapped}"
);
}
}