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_rescale_q, AVRational};
13
14// ---- lavfi metadata keys (verified against FFmpeg n7.1) --------------------
15
16const 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";
31/// Aggregate true-peak key emitted by `ebur128` when `peak=true` is set. Its
32/// `SET_META_PEAK` macro (verified against FFmpeg n7.1 `f_ebur128.c`) writes
33/// `lavfi.r128.true_peak` — the max across channels — alongside the per-channel
34/// `lavfi.r128.true_peaks_chN` keys, so we read the aggregate directly.
35const R128_TRUE_PEAK: &str = "lavfi.r128.true_peak";
36
37/// Microseconds per second, used as the rescale target for pts conversion.
38const US_PER_SEC: AVRational = AVRational {
39    num: 1,
40    den: 1_000_000,
41};
42
43/// A detection event timestamp, normalised to microseconds.
44///
45/// Metadata-derived events (`black`, `silence`, `scd`) carry only a seconds
46/// string, so [`pts`](Self::pts)/[`time_base`](Self::time_base) are `None`.
47/// Per-frame measurement events (`r128`, `cropdetect`) are stamped from the
48/// raw frame `pts` + `time_base`, so both are populated.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Timestamp {
51    /// Authoritative time in microseconds.
52    pub time_us: i64,
53    /// Raw presentation timestamp, when the event came from a frame pts.
54    pub pts: Option<i64>,
55    /// Raw `(num, den)` time base, when the event came from a frame pts.
56    pub time_base: Option<(i32, i32)>,
57}
58
59impl Timestamp {
60    /// Builds a timestamp from a seconds value (e.g. a `lavfi.black_start`
61    /// string parsed to `f64`).
62    ///
63    /// Returns `None` for non-finite input (`NaN`/`±inf`); finite values are
64    /// **rounded** to the nearest microsecond rather than truncated.
65    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    /// Builds a timestamp from a raw frame `pts` and `time_base`, converting to
77    /// microseconds with `av_rescale_q` for full precision. The caller must
78    /// ensure `pts` is valid (not `AV_NOPTS_VALUE`) and `time_base.1 != 0`.
79    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        // SAFETY: pure arithmetic FFI on plain integers; no pointers involved.
85        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    /// The timestamp as fractional seconds.
94    pub fn as_secs_f64(&self) -> f64 {
95        self.time_us as f64 / 1e6
96    }
97}
98
99/// A single typed detection/measurement result decoded from frame metadata.
100///
101/// All loudness fields are `Option<f64>`: `ebur128` may not have produced a
102/// value yet, and `true_peak` is only `Some` when the build emitted the
103/// per-channel true-peak keys (i.e. `peak=true` was requested *and* the keys
104/// are present).
105#[derive(Debug, Clone, PartialEq)]
106pub enum MetadataEvent {
107    /// A black region began at `at` (`blackdetect`).
108    BlackStart { at: Timestamp },
109    /// A black region ended at `at`; `duration_us` is `end - start`.
110    BlackEnd { at: Timestamp, duration_us: i64 },
111    /// A silent region began (`silencedetect`). `channel_number` is the
112    /// 1-based channel from the `mono=1` `.N` key suffix, or `None` in
113    /// combined mode.
114    SilenceStart {
115        at: Timestamp,
116        channel_number: Option<usize>,
117    },
118    /// A silent region ended; `duration_us` from `lavfi.silence_duration`.
119    SilenceEnd {
120        at: Timestamp,
121        duration_us: i64,
122        channel_number: Option<usize>,
123    },
124    /// A scene change was detected (`scdet`); emitted only on frames that
125    /// carry `lavfi.scd.time`. `score` is the `lavfi.scd.score` value.
126    SceneChange { at: Timestamp, score: f64 },
127    /// A crop suggestion for this frame (`cropdetect`).
128    CropDetect {
129        at: Timestamp,
130        x: i32,
131        y: i32,
132        w: i32,
133        h: i32,
134    },
135    /// Per-frame EBU R128 measurement (`ebur128 metadata=1`).
136    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    /// Final R128 values (last frame seen), emitted once at end of stream.
145    R128Summary {
146        integrated: Option<f64>,
147        lra: Option<f64>,
148        true_peak: Option<f64>,
149    },
150    /// End-of-stream marker carrying the last frame timestamp, used by the
151    /// folder to close regions that never received an explicit end.
152    StreamEnd { media: AVMediaType, at: Timestamp },
153}
154
155/// Mutable state threaded through [`parse_frame_metadata`] across frames.
156///
157/// Remembers the open `black`/`silence` starts (so an end event can carry a
158/// duration) and the most recent R128 values (for the end-of-stream summary).
159#[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    /// End-of-stream events, best-effort: a [`MetadataEvent::StreamEnd`] (if any
172    /// frame was seen) and a [`MetadataEvent::R128Summary`] (if R128 data was
173    /// seen). Called from the filter's `uninit`.
174    pub(crate) fn flush(&mut self, media: AVMediaType) -> Vec<MetadataEvent> {
175        let mut out = Vec::new();
176        // Close unterminated regions at the true end of stream (the last frame's
177        // pts + duration, recorded by the filter) rather than at the last
178        // frame's start pts, which would under-report the tail by one frame.
179        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    /// Records the end timestamp (pts + duration) of the most recent frame, so
194    /// [`flush`](Self::flush) can close unterminated black/silence regions at
195    /// the true end of stream. Called by the filter, which owns the raw frame.
196    pub(crate) fn record_frame_end(&mut self, end: Timestamp) {
197        self.last_end_ts = Some(end);
198    }
199}
200
201/// Parses one frame's `lavfi.*` metadata into typed events.
202///
203/// `frame_ts` is the frame's pts-derived [`Timestamp`] (or `None` when the
204/// frame has no usable pts); it stamps the per-frame `r128`/`cropdetect`
205/// events and is also recorded as the latest timestamp for end-of-stream
206/// summarisation. `media` selects which detector family to look for.
207pub(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    // Iterate: `mono=1` emits one `.N`-suffixed key per silent channel, so a
248    // single frame can carry several starts/ends.
249    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    // `scdet` writes `lavfi.scd.score` on every frame but only writes
281    // `lavfi.scd.time` on an actual scene change — key on the latter.
282    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
340/// The frame's aggregate true peak, or `None` if `ebur128` did not emit
341/// true-peak keys (i.e. `peak=true` was not requested).
342fn max_true_peak(md: &DictionaryRef<'_>) -> Option<f64> {
343    md.get(R128_TRUE_PEAK).and_then(parse_f64)
344}
345
346/// Parses a `.N` channel suffix (1-based) into `Some(N)`; `""` yields `None`.
347fn 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
368/// Converts a finite seconds value to rounded microseconds.
369pub(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        // 0.0000005 s = 0.5 us -> rounds to 1 us (truncation would give 0).
403        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        // score alone -> no event
424        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        // ebur128's SET_META_PEAK writes the cross-channel max to
488        // `lavfi.r128.true_peak` alongside the per-channel `_peaks_chN` keys;
489        // we read the aggregate directly.
490        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        // flush should now carry the remembered summary values.
505        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}