Skip to main content

ez_ffmpeg/core/analysis/
report.rs

1//! Folded analysis report and the pure event-folding function.
2//!
3//! [`fold`] collapses the flat [`MetadataEvent`] stream produced by a run into
4//! ranges and summaries. It is an online folder that also holds the detector's
5//! `min_duration` config (via [`FoldConfig`]) so it can close regions left open
6//! at end-of-stream and drop truncated tails shorter than the configured
7//! minimum.
8
9use crate::core::analysis::event::MetadataEvent;
10
11/// A detected black region, in microseconds.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct BlackRange {
14    pub start_us: i64,
15    pub end_us: i64,
16}
17
18/// A detected silent region, in microseconds. `channel` is the 1-based channel
19/// number in `mono` mode, or `None` for combined detection.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct SilenceRange {
22    pub start_us: i64,
23    pub end_us: i64,
24    pub channel: Option<usize>,
25}
26
27/// A detected scene change at `at_us`, with the `scdet` score.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct SceneChange {
30    pub at_us: i64,
31    pub score: f64,
32}
33
34/// A suggested crop rectangle (last stable `cropdetect` value).
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct CropSuggestion {
37    pub x: i32,
38    pub y: i32,
39    pub w: i32,
40    pub h: i32,
41}
42
43/// EBU R128 loudness summary. All fields are `Option`: loudness metadata may be
44/// missing, and `true_peak` is only present when `peak=true` was requested and
45/// the build emitted the keys.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct LoudnessReport {
48    pub integrated: Option<f64>,
49    pub lra: Option<f64>,
50    pub true_peak: Option<f64>,
51}
52
53/// The complete folded result of an [`Analysis`](crate::core::analysis::Analysis) run.
54#[derive(Debug, Clone, PartialEq, Default)]
55pub struct AnalysisReport {
56    pub black: Vec<BlackRange>,
57    pub silence: Vec<SilenceRange>,
58    pub scenes: Vec<SceneChange>,
59    pub crop: Option<CropSuggestion>,
60    pub loudness: Option<LoudnessReport>,
61}
62
63/// Minimum-duration thresholds (microseconds) used to discard end-of-stream
64/// truncated tails. `None` means "no detector of that kind", so no filtering.
65#[derive(Debug, Clone, Copy, Default)]
66pub(crate) struct FoldConfig {
67    pub black_min_duration_us: Option<i64>,
68    pub silence_min_duration_us: Option<i64>,
69}
70
71/// Folds a flat event stream into an [`AnalysisReport`].
72///
73/// Start/End events are paired into ranges; a start with no matching end is
74/// closed at the last-seen stream timestamp ([`MetadataEvent::StreamEnd`]) and
75/// kept only if the resulting duration meets the configured minimum.
76pub(crate) fn fold(events: Vec<MetadataEvent>, cfg: &FoldConfig) -> AnalysisReport {
77    let mut report = AnalysisReport::default();
78    let mut pending_black: Option<i64> = None;
79    let mut pending_silence: Vec<(Option<usize>, i64)> = Vec::new();
80    let mut video_end_us: Option<i64> = None;
81    let mut audio_end_us: Option<i64> = None;
82
83    for ev in events {
84        match ev {
85            MetadataEvent::BlackStart { at } => pending_black = Some(at.time_us),
86            MetadataEvent::BlackEnd { at, .. } => {
87                if let Some(start) = pending_black.take() {
88                    report.black.push(BlackRange {
89                        start_us: start,
90                        end_us: at.time_us,
91                    });
92                }
93            }
94            MetadataEvent::SilenceStart { at, channel_number } => {
95                pending_silence.push((channel_number, at.time_us));
96            }
97            MetadataEvent::SilenceEnd { at, channel_number, .. } => {
98                if let Some(pos) = pending_silence.iter().position(|(c, _)| *c == channel_number) {
99                    let (_, start) = pending_silence.remove(pos);
100                    report.silence.push(SilenceRange {
101                        start_us: start,
102                        end_us: at.time_us,
103                        channel: channel_number,
104                    });
105                }
106            }
107            MetadataEvent::SceneChange { at, score } => report.scenes.push(SceneChange {
108                at_us: at.time_us,
109                score,
110            }),
111            MetadataEvent::CropDetect { x, y, w, h, .. } => {
112                report.crop = Some(CropSuggestion { x, y, w, h });
113            }
114            MetadataEvent::R128Summary {
115                integrated,
116                lra,
117                true_peak,
118            } => {
119                report.loudness = Some(LoudnessReport {
120                    integrated,
121                    lra,
122                    true_peak,
123                });
124            }
125            MetadataEvent::StreamEnd { at, media } => {
126                // Close black regions with the video stream's end and silence
127                // regions with the audio stream's end, so mismatched stream
128                // durations don't skew the trailing region.
129                let slot = if media == ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_AUDIO {
130                    &mut audio_end_us
131                } else {
132                    &mut video_end_us
133                };
134                *slot = Some(slot.map_or(at.time_us, |e| e.max(at.time_us)));
135            }
136            MetadataEvent::R128Frame { .. } => {}
137        }
138    }
139
140    // Close a still-open black region at the video stream's end-of-stream.
141    if let (Some(start), Some(end)) = (pending_black, video_end_us) {
142        if keep_tail(start, end, cfg.black_min_duration_us) {
143            report.black.push(BlackRange {
144                start_us: start,
145                end_us: end,
146            });
147        }
148    }
149    // Close still-open silence regions at the audio stream's end-of-stream.
150    if let Some(end) = audio_end_us {
151        for (channel, start) in pending_silence {
152            if keep_tail(start, end, cfg.silence_min_duration_us) {
153                report.silence.push(SilenceRange {
154                    start_us: start,
155                    end_us: end,
156                    channel,
157                });
158            }
159        }
160    }
161
162    report
163}
164
165fn keep_tail(start_us: i64, end_us: i64, min_duration_us: Option<i64>) -> bool {
166    let duration = end_us.saturating_sub(start_us);
167    duration >= 0 && min_duration_us.is_none_or(|min| duration >= min)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::core::analysis::event::Timestamp;
174    use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_VIDEO;
175
176    fn ts(us: i64) -> Timestamp {
177        Timestamp {
178            time_us: us,
179            pts: None,
180            time_base: None,
181        }
182    }
183
184    #[test]
185    fn pairs_black_start_end_into_range() {
186        let events = vec![
187            MetadataEvent::BlackStart { at: ts(1_000_000) },
188            MetadataEvent::BlackEnd {
189                at: ts(3_000_000),
190                duration_us: 2_000_000,
191            },
192        ];
193        let report = fold(events, &FoldConfig::default());
194        assert_eq!(
195            report.black,
196            vec![BlackRange {
197                start_us: 1_000_000,
198                end_us: 3_000_000
199            }]
200        );
201    }
202
203    #[test]
204    fn unpaired_start_closed_at_stream_end() {
205        let events = vec![
206            MetadataEvent::BlackStart { at: ts(1_000_000) },
207            MetadataEvent::StreamEnd {
208                media: AVMEDIA_TYPE_VIDEO,
209                at: ts(5_000_000),
210            },
211        ];
212        let report = fold(events, &FoldConfig::default());
213        assert_eq!(
214            report.black,
215            vec![BlackRange {
216                start_us: 1_000_000,
217                end_us: 5_000_000
218            }]
219        );
220    }
221
222    #[test]
223    fn short_tail_dropped_when_below_min_duration() {
224        let cfg = FoldConfig {
225            black_min_duration_us: Some(2_000_000),
226            silence_min_duration_us: None,
227        };
228        // Tail is only 0.5s, below the 2s minimum -> dropped.
229        let events = vec![
230            MetadataEvent::BlackStart { at: ts(4_500_000) },
231            MetadataEvent::StreamEnd {
232                media: AVMEDIA_TYPE_VIDEO,
233                at: ts(5_000_000),
234            },
235        ];
236        assert!(fold(events, &cfg).black.is_empty());
237    }
238
239    #[test]
240    fn crop_takes_last_value() {
241        let events = vec![
242            MetadataEvent::CropDetect {
243                at: ts(0),
244                x: 0,
245                y: 0,
246                w: 100,
247                h: 100,
248            },
249            MetadataEvent::CropDetect {
250                at: ts(1),
251                x: 0,
252                y: 10,
253                w: 100,
254                h: 80,
255            },
256        ];
257        let report = fold(events, &FoldConfig::default());
258        assert_eq!(
259            report.crop,
260            Some(CropSuggestion {
261                x: 0,
262                y: 10,
263                w: 100,
264                h: 80
265            })
266        );
267    }
268}