use ffmpeg_next::DictionaryRef;
use ffmpeg_sys_next::AVMediaType::{self, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO};
use ffmpeg_sys_next::{av_rescale_q, AVRational};
const BLACK_START: &str = "lavfi.black_start";
const BLACK_END: &str = "lavfi.black_end";
const SILENCE_START: &str = "lavfi.silence_start";
const SILENCE_END: &str = "lavfi.silence_end";
const SILENCE_DURATION: &str = "lavfi.silence_duration";
const SCD_SCORE: &str = "lavfi.scd.score";
const SCD_TIME: &str = "lavfi.scd.time";
const CROP_X: &str = "lavfi.cropdetect.x";
const CROP_Y: &str = "lavfi.cropdetect.y";
const CROP_W: &str = "lavfi.cropdetect.w";
const CROP_H: &str = "lavfi.cropdetect.h";
const R128_M: &str = "lavfi.r128.M";
const R128_S: &str = "lavfi.r128.S";
const R128_I: &str = "lavfi.r128.I";
const R128_LRA: &str = "lavfi.r128.LRA";
const R128_TRUE_PEAK: &str = "lavfi.r128.true_peak";
const US_PER_SEC: AVRational = AVRational {
num: 1,
den: 1_000_000,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Timestamp {
pub time_us: i64,
pub pts: Option<i64>,
pub time_base: Option<(i32, i32)>,
}
impl Timestamp {
pub fn from_secs(s: f64) -> Option<Self> {
if !s.is_finite() {
return None;
}
Some(Self {
time_us: (s * 1e6).round() as i64,
pts: None,
time_base: None,
})
}
pub fn from_pts(pts: i64, time_base: (i32, i32)) -> Self {
let tb = AVRational {
num: time_base.0,
den: time_base.1,
};
let time_us = unsafe { av_rescale_q(pts, tb, US_PER_SEC) };
Self {
time_us,
pts: Some(pts),
time_base: Some(time_base),
}
}
pub fn as_secs_f64(&self) -> f64 {
self.time_us as f64 / 1e6
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MetadataEvent {
BlackStart { at: Timestamp },
BlackEnd { at: Timestamp, duration_us: i64 },
SilenceStart {
at: Timestamp,
channel_number: Option<usize>,
},
SilenceEnd {
at: Timestamp,
duration_us: i64,
channel_number: Option<usize>,
},
SceneChange { at: Timestamp, score: f64 },
CropDetect {
at: Timestamp,
x: i32,
y: i32,
w: i32,
h: i32,
},
R128Frame {
at: Timestamp,
momentary: Option<f64>,
short_term: Option<f64>,
integrated: Option<f64>,
lra: Option<f64>,
true_peak: Option<f64>,
},
R128Summary {
integrated: Option<f64>,
lra: Option<f64>,
true_peak: Option<f64>,
},
StreamEnd { media: AVMediaType, at: Timestamp },
}
#[derive(Default)]
pub(crate) struct ParseState {
pending_black_start: Option<i64>,
pending_silence: Vec<(Option<usize>, i64)>,
last_ts: Option<Timestamp>,
last_end_ts: Option<Timestamp>,
last_integrated: Option<f64>,
last_lra: Option<f64>,
last_true_peak: Option<f64>,
}
impl ParseState {
pub(crate) fn flush(&mut self, media: AVMediaType) -> Vec<MetadataEvent> {
let mut out = Vec::new();
if let Some(at) = self.last_end_ts.or(self.last_ts) {
out.push(MetadataEvent::StreamEnd { media, at });
}
if self.last_integrated.is_some()
|| self.last_lra.is_some()
|| self.last_true_peak.is_some()
{
out.push(MetadataEvent::R128Summary {
integrated: self.last_integrated,
lra: self.last_lra,
true_peak: self.last_true_peak,
});
}
out
}
pub(crate) fn record_frame_end(&mut self, end: Timestamp) {
self.last_end_ts = Some(end);
}
}
pub(crate) fn parse_frame_metadata(
md: &DictionaryRef<'_>,
frame_ts: Option<Timestamp>,
media: AVMediaType,
out: &mut Vec<MetadataEvent>,
state: &mut ParseState,
) {
if let Some(ts) = frame_ts {
state.last_ts = Some(ts);
}
match media {
AVMEDIA_TYPE_VIDEO => {
parse_black(md, out, state);
parse_scene(md, out);
parse_crop(md, frame_ts, out);
}
AVMEDIA_TYPE_AUDIO => {
parse_silence(md, out, state);
parse_r128(md, frame_ts, out, state);
}
_ => {}
}
}
fn parse_black(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>, state: &mut ParseState) {
if let Some(at) = md.get(BLACK_START).and_then(parse_secs) {
state.pending_black_start = Some(at.time_us);
out.push(MetadataEvent::BlackStart { at });
}
if let Some(at) = md.get(BLACK_END).and_then(parse_secs) {
let duration_us = state
.pending_black_start
.take()
.map_or(0, |start| at.time_us.saturating_sub(start));
out.push(MetadataEvent::BlackEnd { at, duration_us });
}
}
fn parse_silence(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>, state: &mut ParseState) {
for (key, value) in md.iter() {
if let Some(suffix) = key.strip_prefix(SILENCE_START) {
let channel_number = parse_channel_suffix(suffix);
if let Some(at) = parse_secs(value) {
state.pending_silence.push((channel_number, at.time_us));
out.push(MetadataEvent::SilenceStart { at, channel_number });
}
} else if let Some(suffix) = key.strip_prefix(SILENCE_END) {
let channel_number = parse_channel_suffix(suffix);
if let Some(at) = parse_secs(value) {
let removed = remove_pending(&mut state.pending_silence, channel_number);
let duration_us = md
.get(&format!("{SILENCE_DURATION}{suffix}"))
.and_then(parse_f64)
.and_then(secs_to_us)
.or_else(|| removed.map(|start| at.time_us.saturating_sub(start)))
.unwrap_or(0);
out.push(MetadataEvent::SilenceEnd {
at,
duration_us,
channel_number,
});
}
}
}
}
fn parse_scene(md: &DictionaryRef<'_>, out: &mut Vec<MetadataEvent>) {
if let Some(at) = md.get(SCD_TIME).and_then(parse_secs) {
let score = md.get(SCD_SCORE).and_then(parse_f64).unwrap_or(0.0);
out.push(MetadataEvent::SceneChange { at, score });
}
}
fn parse_crop(md: &DictionaryRef<'_>, frame_ts: Option<Timestamp>, out: &mut Vec<MetadataEvent>) {
let Some(at) = frame_ts else { return };
if let (Some(x), Some(y), Some(w), Some(h)) = (
md.get(CROP_X).and_then(parse_i32),
md.get(CROP_Y).and_then(parse_i32),
md.get(CROP_W).and_then(parse_i32),
md.get(CROP_H).and_then(parse_i32),
) {
out.push(MetadataEvent::CropDetect { at, x, y, w, h });
}
}
fn parse_r128(
md: &DictionaryRef<'_>,
frame_ts: Option<Timestamp>,
out: &mut Vec<MetadataEvent>,
state: &mut ParseState,
) {
let Some(at) = frame_ts else { return };
let momentary = md.get(R128_M).and_then(parse_f64);
let short_term = md.get(R128_S).and_then(parse_f64);
let integrated = md.get(R128_I).and_then(parse_f64);
let lra = md.get(R128_LRA).and_then(parse_f64);
let true_peak = max_true_peak(md);
if momentary.is_none()
&& short_term.is_none()
&& integrated.is_none()
&& lra.is_none()
&& true_peak.is_none()
{
return;
}
if integrated.is_some() {
state.last_integrated = integrated;
}
if lra.is_some() {
state.last_lra = lra;
}
if true_peak.is_some() {
state.last_true_peak = true_peak;
}
out.push(MetadataEvent::R128Frame {
at,
momentary,
short_term,
integrated,
lra,
true_peak,
});
}
fn max_true_peak(md: &DictionaryRef<'_>) -> Option<f64> {
md.get(R128_TRUE_PEAK).and_then(parse_f64)
}
fn parse_channel_suffix(suffix: &str) -> Option<usize> {
suffix
.strip_prefix('.')
.and_then(|n| n.parse::<usize>().ok())
}
fn remove_pending(pending: &mut Vec<(Option<usize>, i64)>, channel: Option<usize>) -> Option<i64> {
let pos = pending.iter().position(|(c, _)| *c == channel)?;
Some(pending.remove(pos).1)
}
fn parse_secs(s: &str) -> Option<Timestamp> {
s.trim().parse::<f64>().ok().and_then(Timestamp::from_secs)
}
fn parse_f64(s: &str) -> Option<f64> {
s.trim().parse::<f64>().ok()
}
fn parse_i32(s: &str) -> Option<i32> {
s.trim().parse::<i32>().ok()
}
pub(crate) fn secs_to_us(s: f64) -> Option<i64> {
if s.is_finite() {
Some((s * 1e6).round() as i64)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use ffmpeg_next::{frame, Dictionary};
fn md_frame(pairs: &[(&str, &str)]) -> frame::Video {
let mut dict = Dictionary::new();
for &(k, v) in pairs {
dict.set(k, v);
}
let mut f = frame::Video::empty();
f.set_metadata(dict);
f
}
fn parse(
pairs: &[(&str, &str)],
media: AVMediaType,
state: &mut ParseState,
) -> Vec<MetadataEvent> {
let f = md_frame(pairs);
let mut out = Vec::new();
parse_frame_metadata(&f.metadata(), None, media, &mut out, state);
out
}
#[test]
fn from_secs_rounds_and_rejects_non_finite() {
assert_eq!(Timestamp::from_secs(1.5).unwrap().time_us, 1_500_000);
assert_eq!(Timestamp::from_secs(0.000_000_5).unwrap().time_us, 1);
assert!(Timestamp::from_secs(f64::NAN).is_none());
assert!(Timestamp::from_secs(f64::INFINITY).is_none());
}
#[test]
fn black_start_end_pairs_with_duration() {
let mut state = ParseState::default();
let ev = parse(
&[("lavfi.black_start", "1.5")],
AVMEDIA_TYPE_VIDEO,
&mut state,
);
assert_eq!(
ev,
vec![MetadataEvent::BlackStart {
at: Timestamp::from_secs(1.5).unwrap()
}]
);
let ev = parse(
&[("lavfi.black_end", "3.0")],
AVMEDIA_TYPE_VIDEO,
&mut state,
);
assert_eq!(
ev,
vec![MetadataEvent::BlackEnd {
at: Timestamp::from_secs(3.0).unwrap(),
duration_us: 1_500_000
}]
);
}
#[test]
fn scene_change_only_when_time_present() {
let mut state = ParseState::default();
assert!(parse(
&[("lavfi.scd.score", "12.0")],
AVMEDIA_TYPE_VIDEO,
&mut state
)
.is_empty());
let ev = parse(
&[("lavfi.scd.score", "12.0"), ("lavfi.scd.time", "2.0")],
AVMEDIA_TYPE_VIDEO,
&mut state,
);
assert_eq!(
ev,
vec![MetadataEvent::SceneChange {
at: Timestamp::from_secs(2.0).unwrap(),
score: 12.0
}]
);
}
#[test]
fn mono_silence_carries_channel_number() {
let mut state = ParseState::default();
let ev = parse(
&[("lavfi.silence_start.2", "0.5")],
AVMEDIA_TYPE_AUDIO,
&mut state,
);
assert_eq!(
ev,
vec![MetadataEvent::SilenceStart {
at: Timestamp::from_secs(0.5).unwrap(),
channel_number: Some(2)
}]
);
let ev = parse(
&[
("lavfi.silence_end.2", "1.5"),
("lavfi.silence_duration.2", "1.0"),
],
AVMEDIA_TYPE_AUDIO,
&mut state,
);
assert_eq!(
ev,
vec![MetadataEvent::SilenceEnd {
at: Timestamp::from_secs(1.5).unwrap(),
duration_us: 1_000_000,
channel_number: Some(2),
}]
);
}
#[test]
fn combined_silence_has_no_channel() {
let mut state = ParseState::default();
let ev = parse(
&[("lavfi.silence_start", "0.5")],
AVMEDIA_TYPE_AUDIO,
&mut state,
);
assert_eq!(
ev,
vec![MetadataEvent::SilenceStart {
at: Timestamp::from_secs(0.5).unwrap(),
channel_number: None
}]
);
}
#[test]
fn r128_true_peak_absent_is_none() {
let mut state = ParseState::default();
let ts = Some(Timestamp::from_secs(1.0).unwrap());
let f = md_frame(&[
("lavfi.r128.M", "-20.0"),
("lavfi.r128.I", "-23.0"),
("lavfi.r128.LRA", "5.0"),
]);
let mut out = Vec::new();
parse_frame_metadata(&f.metadata(), ts, AVMEDIA_TYPE_AUDIO, &mut out, &mut state);
match &out[0] {
MetadataEvent::R128Frame {
true_peak,
integrated,
..
} => {
assert_eq!(*true_peak, None);
assert_eq!(*integrated, Some(-23.0));
}
other => panic!("expected R128Frame, got {other:?}"),
}
}
#[test]
fn r128_true_peak_read_from_aggregate_key() {
let mut state = ParseState::default();
let ts = Some(Timestamp::from_secs(1.0).unwrap());
let f = md_frame(&[
("lavfi.r128.I", "-23.0"),
("lavfi.r128.true_peaks_ch0", "-2.0"),
("lavfi.r128.true_peaks_ch1", "-1.5"),
("lavfi.r128.true_peak", "-1.5"),
]);
let mut out = Vec::new();
parse_frame_metadata(&f.metadata(), ts, AVMEDIA_TYPE_AUDIO, &mut out, &mut state);
match &out[0] {
MetadataEvent::R128Frame { true_peak, .. } => assert_eq!(*true_peak, Some(-1.5)),
other => panic!("expected R128Frame, got {other:?}"),
}
let flushed = state.flush(AVMEDIA_TYPE_AUDIO);
assert!(flushed.iter().any(|e| matches!(
e,
MetadataEvent::R128Summary {
integrated: Some(_),
true_peak: Some(_),
..
}
)));
}
}