1use ffmpeg_next::DictionaryRef;
11use ffmpeg_sys_next::AVMediaType::{self, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO};
12use ffmpeg_sys_next::{av_rescale_q, AVRational};
13
14const BLACK_START: &str = "lavfi.black_start";
17const BLACK_END: &str = "lavfi.black_end";
18const SILENCE_START: &str = "lavfi.silence_start";
19const SILENCE_END: &str = "lavfi.silence_end";
20const SILENCE_DURATION: &str = "lavfi.silence_duration";
21const SCD_SCORE: &str = "lavfi.scd.score";
22const SCD_TIME: &str = "lavfi.scd.time";
23const CROP_X: &str = "lavfi.cropdetect.x";
24const CROP_Y: &str = "lavfi.cropdetect.y";
25const CROP_W: &str = "lavfi.cropdetect.w";
26const CROP_H: &str = "lavfi.cropdetect.h";
27const R128_M: &str = "lavfi.r128.M";
28const R128_S: &str = "lavfi.r128.S";
29const R128_I: &str = "lavfi.r128.I";
30const R128_LRA: &str = "lavfi.r128.LRA";
31const R128_TRUE_PEAK: &str = "lavfi.r128.true_peak";
36
37const US_PER_SEC: AVRational = AVRational {
39 num: 1,
40 den: 1_000_000,
41};
42
43#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Timestamp {
51 pub time_us: i64,
53 pub pts: Option<i64>,
55 pub time_base: Option<(i32, i32)>,
57}
58
59impl Timestamp {
60 pub fn from_secs(s: f64) -> Option<Self> {
66 if !s.is_finite() {
67 return None;
68 }
69 Some(Self {
70 time_us: (s * 1e6).round() as i64,
71 pts: None,
72 time_base: None,
73 })
74 }
75
76 pub fn from_pts(pts: i64, time_base: (i32, i32)) -> Self {
80 let tb = AVRational {
81 num: time_base.0,
82 den: time_base.1,
83 };
84 let time_us = unsafe { av_rescale_q(pts, tb, US_PER_SEC) };
86 Self {
87 time_us,
88 pts: Some(pts),
89 time_base: Some(time_base),
90 }
91 }
92
93 pub fn as_secs_f64(&self) -> f64 {
95 self.time_us as f64 / 1e6
96 }
97}
98
99#[derive(Debug, Clone, PartialEq)]
106pub enum MetadataEvent {
107 BlackStart { at: Timestamp },
109 BlackEnd { at: Timestamp, duration_us: i64 },
111 SilenceStart {
115 at: Timestamp,
116 channel_number: Option<usize>,
117 },
118 SilenceEnd {
120 at: Timestamp,
121 duration_us: i64,
122 channel_number: Option<usize>,
123 },
124 SceneChange { at: Timestamp, score: f64 },
127 CropDetect {
129 at: Timestamp,
130 x: i32,
131 y: i32,
132 w: i32,
133 h: i32,
134 },
135 R128Frame {
137 at: Timestamp,
138 momentary: Option<f64>,
139 short_term: Option<f64>,
140 integrated: Option<f64>,
141 lra: Option<f64>,
142 true_peak: Option<f64>,
143 },
144 R128Summary {
146 integrated: Option<f64>,
147 lra: Option<f64>,
148 true_peak: Option<f64>,
149 },
150 StreamEnd { media: AVMediaType, at: Timestamp },
153}
154
155#[derive(Default)]
160pub(crate) struct ParseState {
161 pending_black_start: Option<i64>,
162 pending_silence: Vec<(Option<usize>, i64)>,
163 last_ts: Option<Timestamp>,
164 last_end_ts: Option<Timestamp>,
165 last_integrated: Option<f64>,
166 last_lra: Option<f64>,
167 last_true_peak: Option<f64>,
168}
169
170impl ParseState {
171 pub(crate) fn flush(&mut self, media: AVMediaType) -> Vec<MetadataEvent> {
175 let mut out = Vec::new();
176 if let Some(at) = self.last_end_ts.or(self.last_ts) {
180 out.push(MetadataEvent::StreamEnd { media, at });
181 }
182 if self.last_integrated.is_some() || self.last_lra.is_some() || self.last_true_peak.is_some()
183 {
184 out.push(MetadataEvent::R128Summary {
185 integrated: self.last_integrated,
186 lra: self.last_lra,
187 true_peak: self.last_true_peak,
188 });
189 }
190 out
191 }
192
193 pub(crate) fn record_frame_end(&mut self, end: Timestamp) {
197 self.last_end_ts = Some(end);
198 }
199}
200
201pub(crate) fn parse_frame_metadata(
208 md: &DictionaryRef<'_>,
209 frame_ts: Option<Timestamp>,
210 media: AVMediaType,
211 out: &mut Vec<MetadataEvent>,
212 state: &mut ParseState,
213) {
214 if let Some(ts) = frame_ts {
215 state.last_ts = Some(ts);
216 }
217
218 match media {
219 AVMEDIA_TYPE_VIDEO => {
220 parse_black(md, out, state);
221 parse_scene(md, out);
222 parse_crop(md, frame_ts, out);
223 }
224 AVMEDIA_TYPE_AUDIO => {
225 parse_silence(md, out, state);
226 parse_r128(md, frame_ts, out, state);
227 }
228 _ => {}
229 }
230}
231
232fn parse_black(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>, state: &mut ParseState) {
233 if let Some(at) = md.get(BLACK_START).and_then(parse_secs) {
234 state.pending_black_start = Some(at.time_us);
235 out.push(MetadataEvent::BlackStart { at });
236 }
237 if let Some(at) = md.get(BLACK_END).and_then(parse_secs) {
238 let duration_us = state
239 .pending_black_start
240 .take()
241 .map_or(0, |start| at.time_us.saturating_sub(start));
242 out.push(MetadataEvent::BlackEnd { at, duration_us });
243 }
244}
245
246fn parse_silence(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>, state: &mut ParseState) {
247 for (key, value) in md.iter() {
250 if let Some(suffix) = key.strip_prefix(SILENCE_START) {
251 let channel_number = parse_channel_suffix(suffix);
252 if let Some(at) = parse_secs(value) {
253 state.pending_silence.push((channel_number, at.time_us));
254 out.push(MetadataEvent::SilenceStart {
255 at,
256 channel_number,
257 });
258 }
259 } else if let Some(suffix) = key.strip_prefix(SILENCE_END) {
260 let channel_number = parse_channel_suffix(suffix);
261 if let Some(at) = parse_secs(value) {
262 let removed = remove_pending(&mut state.pending_silence, channel_number);
263 let duration_us = md
264 .get(&format!("{SILENCE_DURATION}{suffix}"))
265 .and_then(parse_f64)
266 .and_then(secs_to_us)
267 .or_else(|| removed.map(|start| at.time_us.saturating_sub(start)))
268 .unwrap_or(0);
269 out.push(MetadataEvent::SilenceEnd {
270 at,
271 duration_us,
272 channel_number,
273 });
274 }
275 }
276 }
277}
278
279fn parse_scene(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>) {
280 if let Some(at) = md.get(SCD_TIME).and_then(parse_secs) {
283 let score = md.get(SCD_SCORE).and_then(parse_f64).unwrap_or(0.0);
284 out.push(MetadataEvent::SceneChange { at, score });
285 }
286}
287
288fn parse_crop(md: &DictionaryRef<'_>, frame_ts: Option<Timestamp>, out: &mut Vec<MetadataEvent>) {
289 let Some(at) = frame_ts else { return };
290 if let (Some(x), Some(y), Some(w), Some(h)) = (
291 md.get(CROP_X).and_then(parse_i32),
292 md.get(CROP_Y).and_then(parse_i32),
293 md.get(CROP_W).and_then(parse_i32),
294 md.get(CROP_H).and_then(parse_i32),
295 ) {
296 out.push(MetadataEvent::CropDetect { at, x, y, w, h });
297 }
298}
299
300fn parse_r128(
301 md: &DictionaryRef<'_>,
302 frame_ts: Option<Timestamp>,
303 out: &mut Vec<MetadataEvent>,
304 state: &mut ParseState,
305) {
306 let Some(at) = frame_ts else { return };
307 let momentary = md.get(R128_M).and_then(parse_f64);
308 let short_term = md.get(R128_S).and_then(parse_f64);
309 let integrated = md.get(R128_I).and_then(parse_f64);
310 let lra = md.get(R128_LRA).and_then(parse_f64);
311 let true_peak = max_true_peak(md);
312
313 if momentary.is_none()
314 && short_term.is_none()
315 && integrated.is_none()
316 && lra.is_none()
317 && true_peak.is_none()
318 {
319 return;
320 }
321 if integrated.is_some() {
322 state.last_integrated = integrated;
323 }
324 if lra.is_some() {
325 state.last_lra = lra;
326 }
327 if true_peak.is_some() {
328 state.last_true_peak = true_peak;
329 }
330 out.push(MetadataEvent::R128Frame {
331 at,
332 momentary,
333 short_term,
334 integrated,
335 lra,
336 true_peak,
337 });
338}
339
340fn max_true_peak(md: &DictionaryRef<'_>) -> Option<f64> {
343 md.get(R128_TRUE_PEAK).and_then(parse_f64)
344}
345
346fn parse_channel_suffix(suffix: &str) -> Option<usize> {
348 suffix.strip_prefix('.').and_then(|n| n.parse::<usize>().ok())
349}
350
351fn remove_pending(pending: &mut Vec<(Option<usize>, i64)>, channel: Option<usize>) -> Option<i64> {
352 let pos = pending.iter().position(|(c, _)| *c == channel)?;
353 Some(pending.remove(pos).1)
354}
355
356fn parse_secs(s: &str) -> Option<Timestamp> {
357 s.trim().parse::<f64>().ok().and_then(Timestamp::from_secs)
358}
359
360fn parse_f64(s: &str) -> Option<f64> {
361 s.trim().parse::<f64>().ok()
362}
363
364fn parse_i32(s: &str) -> Option<i32> {
365 s.trim().parse::<i32>().ok()
366}
367
368pub(crate) fn secs_to_us(s: f64) -> Option<i64> {
370 if s.is_finite() {
371 Some((s * 1e6).round() as i64)
372 } else {
373 None
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use ffmpeg_next::{frame, Dictionary};
381
382 fn md_frame(pairs: &[(&str, &str)]) -> frame::Video {
383 let mut dict = Dictionary::new();
384 for &(k, v) in pairs {
385 dict.set(k, v);
386 }
387 let mut f = frame::Video::empty();
388 f.set_metadata(dict);
389 f
390 }
391
392 fn parse(pairs: &[(&str, &str)], media: AVMediaType, state: &mut ParseState) -> Vec<MetadataEvent> {
393 let f = md_frame(pairs);
394 let mut out = Vec::new();
395 parse_frame_metadata(&f.metadata(), None, media, &mut out, state);
396 out
397 }
398
399 #[test]
400 fn from_secs_rounds_and_rejects_non_finite() {
401 assert_eq!(Timestamp::from_secs(1.5).unwrap().time_us, 1_500_000);
402 assert_eq!(Timestamp::from_secs(0.000_000_5).unwrap().time_us, 1);
404 assert!(Timestamp::from_secs(f64::NAN).is_none());
405 assert!(Timestamp::from_secs(f64::INFINITY).is_none());
406 }
407
408 #[test]
409 fn black_start_end_pairs_with_duration() {
410 let mut state = ParseState::default();
411 let ev = parse(&[("lavfi.black_start", "1.5")], AVMEDIA_TYPE_VIDEO, &mut state);
412 assert_eq!(ev, vec![MetadataEvent::BlackStart { at: Timestamp::from_secs(1.5).unwrap() }]);
413 let ev = parse(&[("lavfi.black_end", "3.0")], AVMEDIA_TYPE_VIDEO, &mut state);
414 assert_eq!(
415 ev,
416 vec![MetadataEvent::BlackEnd { at: Timestamp::from_secs(3.0).unwrap(), duration_us: 1_500_000 }]
417 );
418 }
419
420 #[test]
421 fn scene_change_only_when_time_present() {
422 let mut state = ParseState::default();
423 assert!(parse(&[("lavfi.scd.score", "12.0")], AVMEDIA_TYPE_VIDEO, &mut state).is_empty());
425 let ev = parse(
426 &[("lavfi.scd.score", "12.0"), ("lavfi.scd.time", "2.0")],
427 AVMEDIA_TYPE_VIDEO,
428 &mut state,
429 );
430 assert_eq!(
431 ev,
432 vec![MetadataEvent::SceneChange { at: Timestamp::from_secs(2.0).unwrap(), score: 12.0 }]
433 );
434 }
435
436 #[test]
437 fn mono_silence_carries_channel_number() {
438 let mut state = ParseState::default();
439 let ev = parse(&[("lavfi.silence_start.2", "0.5")], AVMEDIA_TYPE_AUDIO, &mut state);
440 assert_eq!(
441 ev,
442 vec![MetadataEvent::SilenceStart { at: Timestamp::from_secs(0.5).unwrap(), channel_number: Some(2) }]
443 );
444 let ev = parse(
445 &[("lavfi.silence_end.2", "1.5"), ("lavfi.silence_duration.2", "1.0")],
446 AVMEDIA_TYPE_AUDIO,
447 &mut state,
448 );
449 assert_eq!(
450 ev,
451 vec![MetadataEvent::SilenceEnd {
452 at: Timestamp::from_secs(1.5).unwrap(),
453 duration_us: 1_000_000,
454 channel_number: Some(2),
455 }]
456 );
457 }
458
459 #[test]
460 fn combined_silence_has_no_channel() {
461 let mut state = ParseState::default();
462 let ev = parse(&[("lavfi.silence_start", "0.5")], AVMEDIA_TYPE_AUDIO, &mut state);
463 assert_eq!(
464 ev,
465 vec![MetadataEvent::SilenceStart { at: Timestamp::from_secs(0.5).unwrap(), channel_number: None }]
466 );
467 }
468
469 #[test]
470 fn r128_true_peak_absent_is_none() {
471 let mut state = ParseState::default();
472 let ts = Some(Timestamp::from_secs(1.0).unwrap());
473 let f = md_frame(&[("lavfi.r128.M", "-20.0"), ("lavfi.r128.I", "-23.0"), ("lavfi.r128.LRA", "5.0")]);
474 let mut out = Vec::new();
475 parse_frame_metadata(&f.metadata(), ts, AVMEDIA_TYPE_AUDIO, &mut out, &mut state);
476 match &out[0] {
477 MetadataEvent::R128Frame { true_peak, integrated, .. } => {
478 assert_eq!(*true_peak, None);
479 assert_eq!(*integrated, Some(-23.0));
480 }
481 other => panic!("expected R128Frame, got {other:?}"),
482 }
483 }
484
485 #[test]
486 fn r128_true_peak_read_from_aggregate_key() {
487 let mut state = ParseState::default();
491 let ts = Some(Timestamp::from_secs(1.0).unwrap());
492 let f = md_frame(&[
493 ("lavfi.r128.I", "-23.0"),
494 ("lavfi.r128.true_peaks_ch0", "-2.0"),
495 ("lavfi.r128.true_peaks_ch1", "-1.5"),
496 ("lavfi.r128.true_peak", "-1.5"),
497 ]);
498 let mut out = Vec::new();
499 parse_frame_metadata(&f.metadata(), ts, AVMEDIA_TYPE_AUDIO, &mut out, &mut state);
500 match &out[0] {
501 MetadataEvent::R128Frame { true_peak, .. } => assert_eq!(*true_peak, Some(-1.5)),
502 other => panic!("expected R128Frame, got {other:?}"),
503 }
504 let flushed = state.flush(AVMEDIA_TYPE_AUDIO);
506 assert!(flushed.iter().any(|e| matches!(
507 e,
508 MetadataEvent::R128Summary { integrated: Some(_), true_peak: Some(_), .. }
509 )));
510 }
511}