1use ffmpeg_next::DictionaryRef;
11use ffmpeg_sys_next::AVMediaType::{self, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO};
12use ffmpeg_sys_next::{av_dict_get, av_rescale_q, AVRational};
13use std::ffi::CStr;
14
15const BLACK_START: &str = "lavfi.black_start";
18const BLACK_END: &str = "lavfi.black_end";
19const SILENCE_START: &str = "lavfi.silence_start";
20const SILENCE_END: &str = "lavfi.silence_end";
21const SILENCE_DURATION: &str = "lavfi.silence_duration";
22const SCD_SCORE: &CStr = c"lavfi.scd.score";
26const SCD_TIME: &CStr = c"lavfi.scd.time";
27const CROP_X: &CStr = c"lavfi.cropdetect.x";
28const CROP_Y: &CStr = c"lavfi.cropdetect.y";
29const CROP_W: &CStr = c"lavfi.cropdetect.w";
30const CROP_H: &CStr = c"lavfi.cropdetect.h";
31const R128_M: &CStr = c"lavfi.r128.M";
32const R128_S: &CStr = c"lavfi.r128.S";
33const R128_I: &CStr = c"lavfi.r128.I";
34const R128_LRA: &CStr = c"lavfi.r128.LRA";
35const R128_TRUE_PEAK: &CStr = c"lavfi.r128.true_peak";
40
41const US_PER_SEC: AVRational = AVRational {
43 num: 1,
44 den: 1_000_000,
45};
46
47fn dict_get<'a>(md: &'a DictionaryRef<'_>, key: &CStr) -> Option<&'a str> {
51 unsafe {
57 let entry = av_dict_get(md.as_ptr(), key.as_ptr(), std::ptr::null(), 0);
58 if entry.is_null() {
59 None
60 } else {
61 CStr::from_ptr((*entry).value).to_str().ok()
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct Timestamp {
74 pub time_us: i64,
76 pub pts: Option<i64>,
78 pub time_base: Option<(i32, i32)>,
80}
81
82impl Timestamp {
83 pub fn from_secs(s: f64) -> Option<Self> {
89 if !s.is_finite() {
90 return None;
91 }
92 Some(Self {
93 time_us: (s * 1e6).round() as i64,
94 pts: None,
95 time_base: None,
96 })
97 }
98
99 pub fn from_pts(pts: i64, time_base: (i32, i32)) -> Self {
103 let tb = AVRational {
104 num: time_base.0,
105 den: time_base.1,
106 };
107 let time_us = unsafe { av_rescale_q(pts, tb, US_PER_SEC) };
109 Self {
110 time_us,
111 pts: Some(pts),
112 time_base: Some(time_base),
113 }
114 }
115
116 pub fn as_secs_f64(&self) -> f64 {
118 self.time_us as f64 / 1e6
119 }
120}
121
122#[derive(Debug, Clone, PartialEq)]
129pub enum MetadataEvent {
130 BlackStart { at: Timestamp },
132 BlackEnd { at: Timestamp, duration_us: i64 },
134 SilenceStart {
138 at: Timestamp,
139 channel_number: Option<usize>,
140 },
141 SilenceEnd {
143 at: Timestamp,
144 duration_us: i64,
145 channel_number: Option<usize>,
146 },
147 SceneChange { at: Timestamp, score: f64 },
150 CropDetect {
152 at: Timestamp,
153 x: i32,
154 y: i32,
155 w: i32,
156 h: i32,
157 },
158 R128Frame {
160 at: Timestamp,
161 momentary: Option<f64>,
162 short_term: Option<f64>,
163 integrated: Option<f64>,
164 lra: Option<f64>,
165 true_peak: Option<f64>,
166 },
167 R128Summary {
169 integrated: Option<f64>,
170 lra: Option<f64>,
171 true_peak: Option<f64>,
172 },
173 StreamEnd { media: AVMediaType, at: Timestamp },
176}
177
178#[derive(Default)]
183pub(crate) struct ParseState {
184 pending_black_start: Option<i64>,
185 pending_silence: Vec<(Option<usize>, i64)>,
186 last_ts: Option<Timestamp>,
187 last_end_ts: Option<Timestamp>,
188 last_integrated: Option<f64>,
189 last_lra: Option<f64>,
190 last_true_peak: Option<f64>,
191}
192
193impl ParseState {
194 pub(crate) fn flush(&mut self, media: AVMediaType) -> Vec<MetadataEvent> {
198 let mut out = Vec::new();
199 if let Some(at) = self.last_end_ts.or(self.last_ts) {
203 out.push(MetadataEvent::StreamEnd { media, at });
204 }
205 if self.last_integrated.is_some()
206 || self.last_lra.is_some()
207 || self.last_true_peak.is_some()
208 {
209 out.push(MetadataEvent::R128Summary {
210 integrated: self.last_integrated,
211 lra: self.last_lra,
212 true_peak: self.last_true_peak,
213 });
214 }
215 out
216 }
217
218 pub(crate) fn record_frame_end(&mut self, end: Timestamp) {
222 self.last_end_ts = Some(end);
223 }
224}
225
226pub(crate) fn parse_frame_metadata(
233 md: &DictionaryRef<'_>,
234 frame_ts: Option<Timestamp>,
235 media: AVMediaType,
236 out: &mut Vec<MetadataEvent>,
237 state: &mut ParseState,
238) {
239 if let Some(ts) = frame_ts {
240 state.last_ts = Some(ts);
241 }
242
243 match media {
244 AVMEDIA_TYPE_VIDEO => {
245 parse_black(md, out, state);
246 parse_scene(md, out);
247 parse_crop(md, frame_ts, out);
248 }
249 AVMEDIA_TYPE_AUDIO => {
250 parse_silence(md, out, state);
251 parse_r128(md, frame_ts, out, state);
252 }
253 _ => {}
254 }
255}
256
257fn parse_black(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>, state: &mut ParseState) {
258 if let Some(at) = md.get(BLACK_START).and_then(parse_secs) {
259 state.pending_black_start = Some(at.time_us);
260 out.push(MetadataEvent::BlackStart { at });
261 }
262 if let Some(at) = md.get(BLACK_END).and_then(parse_secs) {
263 let duration_us = state
264 .pending_black_start
265 .take()
266 .map_or(0, |start| at.time_us.saturating_sub(start));
267 out.push(MetadataEvent::BlackEnd { at, duration_us });
268 }
269}
270
271fn parse_silence(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>, state: &mut ParseState) {
272 for (key, value) in md.iter() {
275 if let Some(suffix) = key.strip_prefix(SILENCE_START) {
276 let channel_number = parse_channel_suffix(suffix);
277 if let Some(at) = parse_secs(value) {
278 state.pending_silence.push((channel_number, at.time_us));
279 out.push(MetadataEvent::SilenceStart { at, channel_number });
280 }
281 } else if let Some(suffix) = key.strip_prefix(SILENCE_END) {
282 let channel_number = parse_channel_suffix(suffix);
283 if let Some(at) = parse_secs(value) {
284 let removed = remove_pending(&mut state.pending_silence, channel_number);
285 let duration_us = md
286 .get(&format!("{SILENCE_DURATION}{suffix}"))
287 .and_then(parse_f64)
288 .and_then(secs_to_us)
289 .or_else(|| removed.map(|start| at.time_us.saturating_sub(start)))
290 .unwrap_or(0);
291 out.push(MetadataEvent::SilenceEnd {
292 at,
293 duration_us,
294 channel_number,
295 });
296 }
297 }
298 }
299}
300
301fn parse_scene(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>) {
302 if let Some(at) = dict_get(md, SCD_TIME).and_then(parse_secs) {
305 let score = dict_get(md, SCD_SCORE).and_then(parse_f64).unwrap_or(0.0);
306 out.push(MetadataEvent::SceneChange { at, score });
307 }
308}
309
310fn parse_crop(md: &DictionaryRef<'_>, frame_ts: Option<Timestamp>, out: &mut Vec<MetadataEvent>) {
311 let Some(at) = frame_ts else { return };
312 if let (Some(x), Some(y), Some(w), Some(h)) = (
313 dict_get(md, CROP_X).and_then(parse_i32),
314 dict_get(md, CROP_Y).and_then(parse_i32),
315 dict_get(md, CROP_W).and_then(parse_i32),
316 dict_get(md, CROP_H).and_then(parse_i32),
317 ) {
318 out.push(MetadataEvent::CropDetect { at, x, y, w, h });
319 }
320}
321
322fn parse_r128(
323 md: &DictionaryRef<'_>,
324 frame_ts: Option<Timestamp>,
325 out: &mut Vec<MetadataEvent>,
326 state: &mut ParseState,
327) {
328 let Some(at) = frame_ts else { return };
329 let momentary = dict_get(md, R128_M).and_then(parse_f64);
330 let short_term = dict_get(md, R128_S).and_then(parse_f64);
331 let integrated = dict_get(md, R128_I).and_then(parse_f64);
332 let lra = dict_get(md, R128_LRA).and_then(parse_f64);
333 let true_peak = max_true_peak(md);
334
335 if momentary.is_none()
336 && short_term.is_none()
337 && integrated.is_none()
338 && lra.is_none()
339 && true_peak.is_none()
340 {
341 return;
342 }
343 if integrated.is_some() {
344 state.last_integrated = integrated;
345 }
346 if lra.is_some() {
347 state.last_lra = lra;
348 }
349 if true_peak.is_some() {
350 state.last_true_peak = true_peak;
351 }
352 out.push(MetadataEvent::R128Frame {
353 at,
354 momentary,
355 short_term,
356 integrated,
357 lra,
358 true_peak,
359 });
360}
361
362fn max_true_peak(md: &DictionaryRef<'_>) -> Option<f64> {
365 dict_get(md, R128_TRUE_PEAK).and_then(parse_f64)
366}
367
368fn parse_channel_suffix(suffix: &str) -> Option<usize> {
370 suffix
371 .strip_prefix('.')
372 .and_then(|n| n.parse::<usize>().ok())
373}
374
375fn remove_pending(pending: &mut Vec<(Option<usize>, i64)>, channel: Option<usize>) -> Option<i64> {
376 let pos = pending.iter().position(|(c, _)| *c == channel)?;
377 Some(pending.remove(pos).1)
378}
379
380fn parse_secs(s: &str) -> Option<Timestamp> {
381 s.trim().parse::<f64>().ok().and_then(Timestamp::from_secs)
382}
383
384fn parse_f64(s: &str) -> Option<f64> {
385 s.trim().parse::<f64>().ok()
386}
387
388fn parse_i32(s: &str) -> Option<i32> {
389 s.trim().parse::<i32>().ok()
390}
391
392pub(crate) fn secs_to_us(s: f64) -> Option<i64> {
394 if s.is_finite() {
395 Some((s * 1e6).round() as i64)
396 } else {
397 None
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use ffmpeg_next::{frame, Dictionary};
405
406 fn md_frame(pairs: &[(&str, &str)]) -> frame::Video {
407 let mut dict = Dictionary::new();
408 for &(k, v) in pairs {
409 dict.set(k, v);
410 }
411 let mut f = frame::Video::empty();
412 f.set_metadata(dict);
413 f
414 }
415
416 fn parse(
417 pairs: &[(&str, &str)],
418 media: AVMediaType,
419 state: &mut ParseState,
420 ) -> Vec<MetadataEvent> {
421 let f = md_frame(pairs);
422 let mut out = Vec::new();
423 parse_frame_metadata(&f.metadata(), None, media, &mut out, state);
424 out
425 }
426
427 #[test]
428 fn from_secs_rounds_and_rejects_non_finite() {
429 assert_eq!(Timestamp::from_secs(1.5).unwrap().time_us, 1_500_000);
430 assert_eq!(Timestamp::from_secs(0.000_000_5).unwrap().time_us, 1);
432 assert!(Timestamp::from_secs(f64::NAN).is_none());
433 assert!(Timestamp::from_secs(f64::INFINITY).is_none());
434 }
435
436 #[test]
437 fn black_start_end_pairs_with_duration() {
438 let mut state = ParseState::default();
439 let ev = parse(
440 &[("lavfi.black_start", "1.5")],
441 AVMEDIA_TYPE_VIDEO,
442 &mut state,
443 );
444 assert_eq!(
445 ev,
446 vec![MetadataEvent::BlackStart {
447 at: Timestamp::from_secs(1.5).unwrap()
448 }]
449 );
450 let ev = parse(
451 &[("lavfi.black_end", "3.0")],
452 AVMEDIA_TYPE_VIDEO,
453 &mut state,
454 );
455 assert_eq!(
456 ev,
457 vec![MetadataEvent::BlackEnd {
458 at: Timestamp::from_secs(3.0).unwrap(),
459 duration_us: 1_500_000
460 }]
461 );
462 }
463
464 #[test]
465 fn scene_change_only_when_time_present() {
466 let mut state = ParseState::default();
467 assert!(parse(
469 &[("lavfi.scd.score", "12.0")],
470 AVMEDIA_TYPE_VIDEO,
471 &mut state
472 )
473 .is_empty());
474 let ev = parse(
475 &[("lavfi.scd.score", "12.0"), ("lavfi.scd.time", "2.0")],
476 AVMEDIA_TYPE_VIDEO,
477 &mut state,
478 );
479 assert_eq!(
480 ev,
481 vec![MetadataEvent::SceneChange {
482 at: Timestamp::from_secs(2.0).unwrap(),
483 score: 12.0
484 }]
485 );
486 }
487
488 #[test]
489 fn mono_silence_carries_channel_number() {
490 let mut state = ParseState::default();
491 let ev = parse(
492 &[("lavfi.silence_start.2", "0.5")],
493 AVMEDIA_TYPE_AUDIO,
494 &mut state,
495 );
496 assert_eq!(
497 ev,
498 vec![MetadataEvent::SilenceStart {
499 at: Timestamp::from_secs(0.5).unwrap(),
500 channel_number: Some(2)
501 }]
502 );
503 let ev = parse(
504 &[
505 ("lavfi.silence_end.2", "1.5"),
506 ("lavfi.silence_duration.2", "1.0"),
507 ],
508 AVMEDIA_TYPE_AUDIO,
509 &mut state,
510 );
511 assert_eq!(
512 ev,
513 vec![MetadataEvent::SilenceEnd {
514 at: Timestamp::from_secs(1.5).unwrap(),
515 duration_us: 1_000_000,
516 channel_number: Some(2),
517 }]
518 );
519 }
520
521 #[test]
522 fn combined_silence_has_no_channel() {
523 let mut state = ParseState::default();
524 let ev = parse(
525 &[("lavfi.silence_start", "0.5")],
526 AVMEDIA_TYPE_AUDIO,
527 &mut state,
528 );
529 assert_eq!(
530 ev,
531 vec![MetadataEvent::SilenceStart {
532 at: Timestamp::from_secs(0.5).unwrap(),
533 channel_number: None
534 }]
535 );
536 }
537
538 #[test]
539 fn r128_true_peak_absent_is_none() {
540 let mut state = ParseState::default();
541 let ts = Some(Timestamp::from_secs(1.0).unwrap());
542 let f = md_frame(&[
543 ("lavfi.r128.M", "-20.0"),
544 ("lavfi.r128.I", "-23.0"),
545 ("lavfi.r128.LRA", "5.0"),
546 ]);
547 let mut out = Vec::new();
548 parse_frame_metadata(&f.metadata(), ts, AVMEDIA_TYPE_AUDIO, &mut out, &mut state);
549 match &out[0] {
550 MetadataEvent::R128Frame {
551 true_peak,
552 integrated,
553 ..
554 } => {
555 assert_eq!(*true_peak, None);
556 assert_eq!(*integrated, Some(-23.0));
557 }
558 other => panic!("expected R128Frame, got {other:?}"),
559 }
560 }
561
562 #[test]
563 fn r128_true_peak_read_from_aggregate_key() {
564 let mut state = ParseState::default();
568 let ts = Some(Timestamp::from_secs(1.0).unwrap());
569 let f = md_frame(&[
570 ("lavfi.r128.I", "-23.0"),
571 ("lavfi.r128.true_peaks_ch0", "-2.0"),
572 ("lavfi.r128.true_peaks_ch1", "-1.5"),
573 ("lavfi.r128.true_peak", "-1.5"),
574 ]);
575 let mut out = Vec::new();
576 parse_frame_metadata(&f.metadata(), ts, AVMEDIA_TYPE_AUDIO, &mut out, &mut state);
577 match &out[0] {
578 MetadataEvent::R128Frame { true_peak, .. } => assert_eq!(*true_peak, Some(-1.5)),
579 other => panic!("expected R128Frame, got {other:?}"),
580 }
581 let flushed = state.flush(AVMEDIA_TYPE_AUDIO);
583 assert!(flushed.iter().any(|e| matches!(
584 e,
585 MetadataEvent::R128Summary {
586 integrated: Some(_),
587 true_peak: Some(_),
588 ..
589 }
590 )));
591 }
592}