Skip to main content

ez_ffmpeg/core/analysis/
report.rs

1//! Folded analysis report and the streaming event folder.
2//!
3//! [`fold_event`] folds each [`MetadataEvent`] into a running [`FoldState`] as it
4//! arrives — collapsing the stream into ranges and summaries without buffering the
5//! per-frame events. [`finalize`] then closes regions left open at end-of-stream,
6//! using the detector's `min_duration` config (via [`FoldConfig`]) to drop
7//! truncated tails shorter than the configured 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/// Running state of a streaming fold: the report accumulated so far plus the
72/// cross-event bookkeeping needed to close ranges. Folding each event AS it
73/// arrives (rather than buffering every per-frame `MetadataEvent` and folding at
74/// the end) keeps analysis memory bounded by the number of DETECTED features, not
75/// the media duration — a long live input used to grow the event buffer without
76/// bound, since per-frame events the report discards were still retained.
77#[derive(Default)]
78pub(crate) struct FoldState {
79    report: AnalysisReport,
80    pending_black: Option<i64>,
81    pending_silence: Vec<(Option<usize>, i64)>,
82    video_end_us: Option<i64>,
83    audio_end_us: Option<i64>,
84}
85
86#[cfg(test)]
87impl FoldState {
88    /// Test-only window into the incrementally folded report: lets sink
89    /// tests assert an event is folded the moment it arrives (rather than
90    /// buffered until finalize) without consuming the state.
91    pub(crate) fn report_so_far(&self) -> &AnalysisReport {
92        &self.report
93    }
94}
95
96/// Folds a single event into the running state. Per-frame events the report does
97/// not retain (`R128Frame`) are dropped here instead of being buffered.
98pub(crate) fn fold_event(state: &mut FoldState, ev: MetadataEvent) {
99    match ev {
100        MetadataEvent::BlackStart { at } => state.pending_black = Some(at.time_us),
101        MetadataEvent::BlackEnd { at, .. } => {
102            if let Some(start) = state.pending_black.take() {
103                state.report.black.push(BlackRange {
104                    start_us: start,
105                    end_us: at.time_us,
106                });
107            }
108        }
109        MetadataEvent::SilenceStart { at, channel_number } => {
110            state.pending_silence.push((channel_number, at.time_us));
111        }
112        MetadataEvent::SilenceEnd {
113            at, channel_number, ..
114        } => {
115            if let Some(pos) = state
116                .pending_silence
117                .iter()
118                .position(|(c, _)| *c == channel_number)
119            {
120                let (_, start) = state.pending_silence.remove(pos);
121                state.report.silence.push(SilenceRange {
122                    start_us: start,
123                    end_us: at.time_us,
124                    channel: channel_number,
125                });
126            }
127        }
128        MetadataEvent::SceneChange { at, score } => state.report.scenes.push(SceneChange {
129            at_us: at.time_us,
130            score,
131        }),
132        MetadataEvent::CropDetect { x, y, w, h, .. } => {
133            state.report.crop = Some(CropSuggestion { x, y, w, h });
134        }
135        MetadataEvent::R128Summary {
136            integrated,
137            lra,
138            true_peak,
139        } => {
140            state.report.loudness = Some(LoudnessReport {
141                integrated,
142                lra,
143                true_peak,
144            });
145        }
146        MetadataEvent::StreamEnd { at, media } => {
147            // Close black regions with the video stream's end and silence
148            // regions with the audio stream's end, so mismatched stream
149            // durations don't skew the trailing region.
150            let slot = if media == ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_AUDIO {
151                &mut state.audio_end_us
152            } else {
153                &mut state.video_end_us
154            };
155            *slot = Some(slot.map_or(at.time_us, |e| e.max(at.time_us)));
156        }
157        MetadataEvent::R128Frame { .. } => {}
158    }
159}
160
161/// Closes any still-open ranges at the stream ends and returns the report.
162pub(crate) fn finalize(mut state: FoldState, cfg: &FoldConfig) -> AnalysisReport {
163    // Close a still-open black region at the video stream's end-of-stream.
164    if let (Some(start), Some(end)) = (state.pending_black, state.video_end_us) {
165        if keep_tail(start, end, cfg.black_min_duration_us) {
166            state.report.black.push(BlackRange {
167                start_us: start,
168                end_us: end,
169            });
170        }
171    }
172    // Close still-open silence regions at the audio stream's end-of-stream.
173    if let Some(end) = state.audio_end_us {
174        for (channel, start) in state.pending_silence {
175            if keep_tail(start, end, cfg.silence_min_duration_us) {
176                state.report.silence.push(SilenceRange {
177                    start_us: start,
178                    end_us: end,
179                    channel,
180                });
181            }
182        }
183    }
184
185    state.report
186}
187
188/// Folds a whole event vector into a report (the streaming `fold_event` +
189/// `finalize` applied in sequence). Test-only: the live run folds incrementally to
190/// bound memory, so this batch form exists only for the event-list tests below.
191#[cfg(test)]
192pub(crate) fn fold(events: Vec<MetadataEvent>, cfg: &FoldConfig) -> AnalysisReport {
193    let mut state = FoldState::default();
194    for ev in events {
195        fold_event(&mut state, ev);
196    }
197    finalize(state, cfg)
198}
199
200fn keep_tail(start_us: i64, end_us: i64, min_duration_us: Option<i64>) -> bool {
201    let duration = end_us.saturating_sub(start_us);
202    duration >= 0 && min_duration_us.is_none_or(|min| duration >= min)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::core::analysis::event::Timestamp;
209    use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_VIDEO;
210
211    fn ts(us: i64) -> Timestamp {
212        Timestamp {
213            time_us: us,
214            pts: None,
215            time_base: None,
216        }
217    }
218
219    #[test]
220    fn pairs_black_start_end_into_range() {
221        let events = vec![
222            MetadataEvent::BlackStart { at: ts(1_000_000) },
223            MetadataEvent::BlackEnd {
224                at: ts(3_000_000),
225                duration_us: 2_000_000,
226            },
227        ];
228        let report = fold(events, &FoldConfig::default());
229        assert_eq!(
230            report.black,
231            vec![BlackRange {
232                start_us: 1_000_000,
233                end_us: 3_000_000
234            }]
235        );
236    }
237
238    // The streaming fold drops per-frame R128Frame events instead of buffering
239    // them (the point of the change: analysis memory is bounded by detected
240    // features, not frame count). A flood of R128Frame must not affect the report,
241    // and folding incrementally via fold_event/finalize must produce the right one.
242    #[test]
243    fn streaming_fold_drops_per_frame_r128_events() {
244        let mut state = FoldState::default();
245        for i in 0..10_000 {
246            fold_event(
247                &mut state,
248                MetadataEvent::R128Frame {
249                    at: ts(i),
250                    momentary: Some(-20.0),
251                    short_term: Some(-20.0),
252                    integrated: Some(-23.0),
253                    lra: Some(1.0),
254                    true_peak: Some(-1.0),
255                },
256            );
257        }
258        // Real events still fold normally after the flood.
259        fold_event(&mut state, MetadataEvent::BlackStart { at: ts(1_000_000) });
260        fold_event(
261            &mut state,
262            MetadataEvent::BlackEnd {
263                at: ts(2_000_000),
264                duration_us: 1_000_000,
265            },
266        );
267        fold_event(
268            &mut state,
269            MetadataEvent::R128Summary {
270                integrated: Some(-23.0),
271                lra: Some(1.0),
272                true_peak: Some(-1.0),
273            },
274        );
275        let report = finalize(state, &FoldConfig::default());
276
277        assert!(report.loudness.is_some(), "the summary must be retained");
278        assert_eq!(report.black.len(), 1, "the black range must be retained");
279        assert!(
280            report.scenes.is_empty() && report.silence.is_empty(),
281            "per-frame R128 events must not leak into the report"
282        );
283    }
284
285    #[test]
286    fn unpaired_start_closed_at_stream_end() {
287        let events = vec![
288            MetadataEvent::BlackStart { at: ts(1_000_000) },
289            MetadataEvent::StreamEnd {
290                media: AVMEDIA_TYPE_VIDEO,
291                at: ts(5_000_000),
292            },
293        ];
294        let report = fold(events, &FoldConfig::default());
295        assert_eq!(
296            report.black,
297            vec![BlackRange {
298                start_us: 1_000_000,
299                end_us: 5_000_000
300            }]
301        );
302    }
303
304    #[test]
305    fn short_tail_dropped_when_below_min_duration() {
306        let cfg = FoldConfig {
307            black_min_duration_us: Some(2_000_000),
308            silence_min_duration_us: None,
309        };
310        // Tail is only 0.5s, below the 2s minimum -> dropped.
311        let events = vec![
312            MetadataEvent::BlackStart { at: ts(4_500_000) },
313            MetadataEvent::StreamEnd {
314                media: AVMEDIA_TYPE_VIDEO,
315                at: ts(5_000_000),
316            },
317        ];
318        assert!(fold(events, &cfg).black.is_empty());
319    }
320
321    #[test]
322    fn crop_takes_last_value() {
323        let events = vec![
324            MetadataEvent::CropDetect {
325                at: ts(0),
326                x: 0,
327                y: 0,
328                w: 100,
329                h: 100,
330            },
331            MetadataEvent::CropDetect {
332                at: ts(1),
333                x: 0,
334                y: 10,
335                w: 100,
336                h: 80,
337            },
338        ];
339        let report = fold(events, &FoldConfig::default());
340        assert_eq!(
341            report.crop,
342            Some(CropSuggestion {
343                x: 0,
344                y: 10,
345                w: 100,
346                h: 80
347            })
348        );
349    }
350}