Media: millisecond-rounded MP4 frame times read at the rate they average to (#1442)

Phones and messaging apps store frame times rounded to whole milliseconds
(33/34 ms at 30 fps, 16/17 ms at 60 fps). FilmCraft's reader takes the
median duration, so such clips imported at 30.303 or 58.824 fps, and New
Comp from Selection and renders followed that rate. When the median is no
standard rate, the durations near it are averaged and snapped to the
standard rate within 0.5 %; exact and variable rates keep the reader's
estimate.
This commit is contained in:
tylerjenningsw
2026-10-14 11:33:49 -04:00
parent 05955c08b4
commit eb95f732e5
+53 -1
View File
@@ -19,8 +19,10 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
// A movie is read in place (its index), not loaded whole.
if let Some(src) = crate::stream::probe_source(path) {
let mut f = footage_from_info(&path.to_string_lossy(), src?.info());
let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h));
let header = crate::stream::movie_header(path);
let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn);
turn_footage(&mut f, turn);
correct_rate(&mut f, header.as_deref().and_then(video_rate));
return Ok(f);
}
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
@@ -95,12 +97,24 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
}
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let turn = video_turn(&bytes);
let rate = video_rate(&bytes);
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
let mut f = footage_from_info(path, src.info());
turn_footage(&mut f, turn);
correct_rate(&mut f, rate);
Ok(f)
}
/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's
/// own estimate misreads it.
fn correct_rate(f: &mut Footage, rate: Option<FrameRate>) {
if f.kind == FootageKind::Video
&& let Some(rate) = rate
{
f.frame_rate = rate;
}
}
/// The size as displayed: a movie shot with the camera on its side trades width and height.
fn turn_footage(f: &mut Footage, turn: Orientation) {
if swaps_sides(turn) {
@@ -254,3 +268,41 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation {
(false, _, false) => Orientation::Rotate270,
}
}
/// The frame rate of an MP4/MOV movie's video track when the reader's own estimate, the median
/// frame duration, misreads it. Phones and messaging apps store frame times rounded to whole
/// milliseconds: a 30 fps clip's frames last 33 or 34 ms and a 60 fps clip's 16 or 17 ms, whose
/// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median
/// reads right.
fn video_rate(bytes: &[u8]) -> Option<FrameRate> {
let file = filmcraft_isobmff::open(bytes).ok()?;
let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?;
averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration))
}
/// The standard rate ([`FrameRate::COMMON`]) within 0.5 % of what the frame durations near their
/// median average to (`durations` in `1 / timescale` s); `None` when the median duration is a
/// standard rate already, or when the average is no standard rate (variable or unusual rates keep
/// the reader's estimate).
fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> Option<FrameRate> {
let ts = i128::from(timescale);
let mut durs: Vec<u64> = durations.filter(|&d| d > 0).map(u64::from).collect();
durs.sort_unstable();
let median = *durs.get(durs.len() / 2)?;
if FrameRate::COMMON.iter().any(|r| ts * i128::from(r.den) == i128::from(median) * i128::from(r.num)) {
return None;
}
// Durations within a quarter of the median (widened, so extreme durations can't overflow).
let (n, sum) = durs.iter().filter(|&&d| d.abs_diff(median).saturating_mul(4) <= median).fold((0u64, 0u64), |(n, s), &d| (n + 1, s.saturating_add(d)));
if sum == 0 {
return None;
}
let fps = timescale as f64 * n as f64 / sum as f64;
FrameRate::COMMON
.into_iter()
.map(|r| (r, (fps / r.as_f64() - 1.0).abs()))
.filter(|&(_, off)| off <= 0.005)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(r, _)| r)
}