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