Skip to main content

ez_ffmpeg/core/analysis/
event.rs

1//! Typed detection/measurement events and the `lavfi.*` metadata parser.
2//!
3//! FFmpeg's detector filters attach their result to each frame as `lavfi.*`
4//! metadata (an `AVDictionary`). [`parse_frame_metadata`] reads that dictionary
5//! off a decoded/filtered frame and turns the recognised keys into typed
6//! [`MetadataEvent`]s. The metadata keys below were cross-checked against the
7//! FFmpeg n7.1 sources for `blackdetect`, `silencedetect`, `scdet`,
8//! `cropdetect` and `ebur128`.
9
10use 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
15// ---- lavfi metadata keys (verified against FFmpeg n7.1) --------------------
16
17const 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";
22// Hot per-frame lavfi keys as `&CStr` literals so lookups can call
23// `av_dict_get` directly (see `dict_get`), avoiding the `CString::new` heap
24// allocation `DictionaryRef::get` performs on every call.
25const 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";
35/// Aggregate true-peak key emitted by `ebur128` when `peak=true` is set. Its
36/// `SET_META_PEAK` macro (verified against FFmpeg n7.1 `f_ebur128.c`) writes
37/// `lavfi.r128.true_peak` — the max across channels — alongside the per-channel
38/// `lavfi.r128.true_peaks_chN` keys, so we read the aggregate directly.
39const R128_TRUE_PEAK: &CStr = c"lavfi.r128.true_peak";
40
41/// Microseconds per second, used as the rescale target for pts conversion.
42const US_PER_SEC: AVRational = AVRational {
43    num: 1,
44    den: 1_000_000,
45};
46
47/// Direct `av_dict_get` lookup for the hot `&CStr` lavfi keys, avoiding the
48/// per-call `CString::new` heap allocation `DictionaryRef::get` performs on
49/// every lookup (ffmpeg-next util/dictionary/immutable.rs).
50fn dict_get<'a>(md: &'a DictionaryRef<'_>, key: &CStr) -> Option<&'a str> {
51    // SAFETY: `as_ptr()` yields the live `AVDictionary` backing `md`. The
52    // returned `&str` is tied to the `&'a md` borrow (not the dict's own type
53    // lifetime), so it cannot outlive `md`. av_dict_get allocates nothing and
54    // the entry's NUL-terminated `value` is owned by the dict, valid while `md`
55    // is borrowed.
56    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/// A detection event timestamp, normalised to microseconds.
67///
68/// Metadata-derived events (`black`, `silence`, `scd`) carry only a seconds
69/// string, so [`pts`](Self::pts)/[`time_base`](Self::time_base) are `None`.
70/// Per-frame measurement events (`r128`, `cropdetect`) are stamped from the
71/// raw frame `pts` + `time_base`, so both are populated.
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct Timestamp {
74    /// Authoritative time in microseconds.
75    pub time_us: i64,
76    /// Raw presentation timestamp, when the event came from a frame pts.
77    pub pts: Option<i64>,
78    /// Raw `(num, den)` time base, when the event came from a frame pts.
79    pub time_base: Option<(i32, i32)>,
80}
81
82impl Timestamp {
83    /// Builds a timestamp from a seconds value (e.g. a `lavfi.black_start`
84    /// string parsed to `f64`).
85    ///
86    /// Returns `None` for non-finite input (`NaN`/`±inf`); finite values are
87    /// **rounded** to the nearest microsecond rather than truncated.
88    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    /// Builds a timestamp from a raw frame `pts` and `time_base`, converting to
100    /// microseconds with `av_rescale_q` for full precision. The caller must
101    /// ensure `pts` is valid (not `AV_NOPTS_VALUE`) and `time_base.1 != 0`.
102    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        // SAFETY: pure arithmetic FFI on plain integers; no pointers involved.
108        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    /// The timestamp as fractional seconds.
117    pub fn as_secs_f64(&self) -> f64 {
118        self.time_us as f64 / 1e6
119    }
120}
121
122/// A single typed detection/measurement result decoded from frame metadata.
123///
124/// All loudness fields are `Option<f64>`: `ebur128` may not have produced a
125/// value yet, and `true_peak` is only `Some` when the build emitted the
126/// per-channel true-peak keys (i.e. `peak=true` was requested *and* the keys
127/// are present).
128#[derive(Debug, Clone, PartialEq)]
129pub enum MetadataEvent {
130    /// A black region began at `at` (`blackdetect`).
131    BlackStart { at: Timestamp },
132    /// A black region ended at `at`; `duration_us` is `end - start`.
133    BlackEnd { at: Timestamp, duration_us: i64 },
134    /// A silent region began (`silencedetect`). `channel_number` is the
135    /// 1-based channel from the `mono=1` `.N` key suffix, or `None` in
136    /// combined mode.
137    SilenceStart {
138        at: Timestamp,
139        channel_number: Option<usize>,
140    },
141    /// A silent region ended; `duration_us` from `lavfi.silence_duration`.
142    SilenceEnd {
143        at: Timestamp,
144        duration_us: i64,
145        channel_number: Option<usize>,
146    },
147    /// A scene change was detected (`scdet`); emitted only on frames that
148    /// carry `lavfi.scd.time`. `score` is the `lavfi.scd.score` value.
149    SceneChange { at: Timestamp, score: f64 },
150    /// A crop suggestion for this frame (`cropdetect`).
151    CropDetect {
152        at: Timestamp,
153        x: i32,
154        y: i32,
155        w: i32,
156        h: i32,
157    },
158    /// Per-frame EBU R128 measurement (`ebur128 metadata=1`).
159    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    /// Final R128 values (last frame seen), emitted once at end of stream.
168    R128Summary {
169        integrated: Option<f64>,
170        lra: Option<f64>,
171        true_peak: Option<f64>,
172    },
173    /// End-of-stream marker carrying the last frame timestamp, used by the
174    /// folder to close regions that never received an explicit end.
175    StreamEnd { media: AVMediaType, at: Timestamp },
176}
177
178/// Mutable state threaded through [`parse_frame_metadata`] across frames.
179///
180/// Remembers the open `black`/`silence` starts (so an end event can carry a
181/// duration) and the most recent R128 values (for the end-of-stream summary).
182#[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    /// End-of-stream events, best-effort: a [`MetadataEvent::StreamEnd`] (if any
195    /// frame was seen) and a [`MetadataEvent::R128Summary`] (if R128 data was
196    /// seen). Called from the filter's `uninit`.
197    pub(crate) fn flush(&mut self, media: AVMediaType) -> Vec<MetadataEvent> {
198        let mut out = Vec::new();
199        // Close unterminated regions at the true end of stream (the last frame's
200        // pts + duration, recorded by the filter) rather than at the last
201        // frame's start pts, which would under-report the tail by one frame.
202        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    /// Records the end timestamp (pts + duration) of the most recent frame, so
219    /// [`flush`](Self::flush) can close unterminated black/silence regions at
220    /// the true end of stream. Called by the filter, which owns the raw frame.
221    pub(crate) fn record_frame_end(&mut self, end: Timestamp) {
222        self.last_end_ts = Some(end);
223    }
224}
225
226/// Parses one frame's `lavfi.*` metadata into typed events.
227///
228/// `frame_ts` is the frame's pts-derived [`Timestamp`] (or `None` when the
229/// frame has no usable pts); it stamps the per-frame `r128`/`cropdetect`
230/// events and is also recorded as the latest timestamp for end-of-stream
231/// summarisation. `media` selects which detector family to look for.
232pub(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    // Iterate: `mono=1` emits one `.N`-suffixed key per silent channel, so a
273    // single frame can carry several starts/ends.
274    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    // `scdet` writes `lavfi.scd.score` on every frame but only writes
303    // `lavfi.scd.time` on an actual scene change — key on the latter.
304    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
362/// The frame's aggregate true peak, or `None` if `ebur128` did not emit
363/// true-peak keys (i.e. `peak=true` was not requested).
364fn max_true_peak(md: &DictionaryRef<'_>) -> Option<f64> {
365    dict_get(md, R128_TRUE_PEAK).and_then(parse_f64)
366}
367
368/// Parses a `.N` channel suffix (1-based) into `Some(N)`; `""` yields `None`.
369fn 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
392/// Converts a finite seconds value to rounded microseconds.
393pub(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        // 0.0000005 s = 0.5 us -> rounds to 1 us (truncation would give 0).
431        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        // score alone -> no event
468        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        // ebur128's SET_META_PEAK writes the cross-channel max to
565        // `lavfi.r128.true_peak` alongside the per-channel `_peaks_chN` keys;
566        // we read the aggregate directly.
567        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        // flush should now carry the remembered summary values.
582        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}