1use crate::core::analysis::event::MetadataEvent;
10
11#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct BlackRange {
14 pub start_us: i64,
15 pub end_us: i64,
16}
17
18#[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#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct SceneChange {
30 pub at_us: i64,
31 pub score: f64,
32}
33
34#[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#[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#[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#[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#[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 pub(crate) fn report_so_far(&self) -> &AnalysisReport {
94 &self.report
95 }
96}
97
98pub(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 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
163pub(crate) fn finalize(mut state: FoldState, cfg: &FoldConfig) -> AnalysisReport {
165 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 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#[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 #[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 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 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}