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)]
36pub struct CropSuggestion {
37 pub x: i32,
38 pub y: i32,
39 pub w: i32,
40 pub h: i32,
41}
42
43#[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#[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#[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#[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 pub(crate) fn report_so_far(&self) -> &AnalysisReport {
92 &self.report
93 }
94}
95
96pub(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 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
161pub(crate) fn finalize(mut state: FoldState, cfg: &FoldConfig) -> AnalysisReport {
163 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 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#[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 #[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 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 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}