codecs: MP4 frames stamped just after the frame grid show on their own frame (fixes #1282)
Phone footage stamps its frames up to a millisecond after the frame grid (a first frame 34.1 ms long at 30 fps). The frame shown at a time is the last one starting at or before it, so a sequence frame asked at its exact start got the picture before: the clip ran one frame behind and showed frame 0 twice. The request is already rounded to the track timescale (#73, as WebM in #83), but at 90 kHz a millisecond is 90 units. When opening an MP4, the video samples of a file that is a constant rate gone slightly off its grid are put back on it: the grid starts at the first picture and steps by the nominal frame duration, and the file is snapped only when every picture is less than a quarter of a frame from its own grid point and no two pictures share a grid point. Anything further off is variable frame rate and keeps all its times; a picture one unit off is left where it is, since that can't be told from timescale rounding. Only the video sample times move: the audio, the first picture and the clip duration are unchanged.
This commit is contained in:
parent
0541d4d182
commit
3f61c036ff
|
|
@ -347,6 +347,7 @@ impl Mp4Source {
|
|||
Some(Mp4Audio { track: i, state, starts, offset, preroll })
|
||||
})
|
||||
.collect();
|
||||
snap_to_frame_grid(&mut file, vtrack);
|
||||
rebase_unedited_video(&mut file, vtrack, &mut audios);
|
||||
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios })
|
||||
}
|
||||
|
|
@ -666,6 +667,66 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, Media
|
|||
Some(Mp4Source::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
/// Put the video samples of a constant-rate file whose pictures sit just off its frame grid back
|
||||
/// on the grid (#1282). Phone footage stamps frames up to a millisecond after where they belong (a
|
||||
/// first frame 34.1 ms long at 30 fps), and the frame shown at a time is the last one that starts at
|
||||
/// or before it: a sequence frame asked at the exact grid time got the picture before.
|
||||
///
|
||||
/// The grid starts at the first picture and steps by the nominal frame duration. A file is snapped
|
||||
/// only when every picture is less than a quarter of a frame from its own grid point and no two land
|
||||
/// on the same one: a frame that far off was placed there (variable frame rate), so a file with one
|
||||
/// such picture keeps all its times. A picture at most one unit off is left where it is: that can't
|
||||
/// be told from the timescale's rounding. Snapping keeps the samples' presentation order. Only the video samples move: the first picture, which the audio and
|
||||
/// `rebase_unedited_video` go by, is the grid's origin, and the clip's duration comes from the
|
||||
/// track header.
|
||||
fn snap_to_frame_grid(file: &mut Mp4File, vtrack: Option<usize>) {
|
||||
let Some(t) = vtrack.and_then(|i| file.tracks.get_mut(i)) else { return };
|
||||
let Some(first) = t.samples.iter().map(|s| s.pts).min() else { return };
|
||||
// The nominal duration: the median sample duration, averaged with the durations one unit from
|
||||
// it. Those are the same duration rounded to the timescale (1501 and 1502 at 59.94 fps over
|
||||
// 90 kHz): the median alone drifts half a unit a frame, enough to snap a short clip onto the
|
||||
// wrong points.
|
||||
let mut durs: Vec<u32> = t.samples.iter().map(|s| s.duration).collect();
|
||||
durs.sort_unstable();
|
||||
let Some(&median) = durs.get(durs.len() / 2) else { return };
|
||||
let near: Vec<f64> = durs.iter().filter(|&&d| d.abs_diff(median) <= 1).map(|&d| f64::from(d)).collect();
|
||||
let mean = near.iter().sum::<f64>() / near.len().max(1) as f64;
|
||||
if mean <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let rate = FrameRate::from_f64(f64::from(t.timescale) / mean);
|
||||
// one frame lasts `step_num / step_den` track units
|
||||
let (step_num, step_den) = (i128::from(t.timescale) * i128::from(rate.den), i128::from(rate.num));
|
||||
if step_num <= 0 || step_den <= 0 {
|
||||
return;
|
||||
}
|
||||
// a sample's grid index and its new time
|
||||
let snap = |pts: i64| -> Option<(i128, i64)> {
|
||||
let off = i128::from(pts) - i128::from(first);
|
||||
// the nearest grid index, then its grid point (both rounded half up)
|
||||
let n = (2 * off * step_den + step_num).div_euclid(2 * step_num);
|
||||
let at = i128::from(first) + (2 * n * step_num + step_den).div_euclid(2 * step_den);
|
||||
let off_grid = (i128::from(pts) - at).abs();
|
||||
if off_grid * 4 * step_den >= step_num {
|
||||
return None;
|
||||
}
|
||||
Some((n, if off_grid <= 1 { pts } else { i64::try_from(at).ok()? }))
|
||||
};
|
||||
let Some(snapped) = t.samples.iter().map(|s| snap(s.pts)).collect::<Option<Vec<_>>>() else { return };
|
||||
// Two pictures by one grid point: not a constant rate. Checked on the index, not the new time:
|
||||
// one picture kept a unit after the point and another moved onto it would be distinct times in
|
||||
// the wrong order.
|
||||
let mut points: Vec<i128> = snapped.iter().map(|&(n, _)| n).collect();
|
||||
points.sort_unstable();
|
||||
points.dedup();
|
||||
if points.len() != snapped.len() {
|
||||
return;
|
||||
}
|
||||
for (s, (_, p)) in t.samples.iter_mut().zip(snapped) {
|
||||
s.pts = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the file at its first picture when its video track has no edit list (#714). Without an
|
||||
/// edit list the media timeline is the presentation, so a B-frame stream's first picture sits at its
|
||||
/// composition delay (two frames on an x264 MP4 written without `elst`, and on fragmented MP4s):
|
||||
|
|
|
|||
|
|
@ -134,3 +134,104 @@ fn webm_with_1ms_timestamps_shows_every_frame_once() {
|
|||
fn mp4_with_a_1ms_timescale_shows_every_frame_once() {
|
||||
check("mov", mp4_1ms);
|
||||
}
|
||||
|
||||
/// An MP4 at a 90 kHz timescale whose frames after the first start `late` units after the frame
|
||||
/// grid, as phone footage does (issue #1282): the first sample lasts `late` units longer.
|
||||
fn mp4_90k_late(rate: FrameRate, late: i64) -> Arc<[u8]> {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
|
||||
let start = |k: usize| {
|
||||
let grid = (k as i128 * 90_000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128;
|
||||
grid as i64 + if k > 0 { late } else { 0 }
|
||||
};
|
||||
for k in 0..frame_count(rate) {
|
||||
let duration = (start(k + 1) - start(k)) as u32;
|
||||
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
fn check_90k_late(late: i64) {
|
||||
let mut failures = Vec::new();
|
||||
for rate in RATES {
|
||||
let src = crate::open_bytes("synthetic.mov", mp4_90k_late(rate, late)).unwrap();
|
||||
for p in problems(src.as_ref(), rate) {
|
||||
failures.push(format!("{rate}, frames {late}/90000 s late: {p}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "mov:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_with_frames_1ms_after_the_grid_shows_every_frame_once() {
|
||||
check_90k_late(90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_with_frames_on_the_grid_shows_every_frame_once() {
|
||||
check_90k_late(0);
|
||||
}
|
||||
|
||||
/// An MP4 at 90 kHz with these sample durations.
|
||||
fn mp4_90k(durations: &[u32]) -> Arc<[u8]> {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
|
||||
for (k, &duration) in durations.iter().enumerate() {
|
||||
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
/// Files that are not a constant rate gone slightly off its grid keep their own times: whatever is
|
||||
/// asked (each frame's start, the unit before it, every 30 fps grid point), the frame shown is the
|
||||
/// last one starting at or before it.
|
||||
#[test]
|
||||
fn mp4_variable_rate_and_on_grid_frame_times_stay_put() {
|
||||
let cases: [(&str, Vec<u32>); 5] = [
|
||||
// 1/30 s and 1/20 s in turn
|
||||
("alternating", (0..60).map(|k| if k % 2 == 0 { 3000 } else { 4500 }).collect()),
|
||||
// 30 fps, then every frame 0.4 frame later (one frame lasts 1.4 frames)
|
||||
("shifted", (0..80).map(|k| if k == 40 { 4200 } else { 3000 }).collect()),
|
||||
// 30 fps with a frame shown for a fifth of a frame: it and the next are both near one grid point
|
||||
(
|
||||
"stutter",
|
||||
(0..80)
|
||||
.map(|k| match k {
|
||||
40 => 600,
|
||||
41 => 5400,
|
||||
_ => 3000,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// 30 fps with two frames by one grid point, one a unit after it and one five units after
|
||||
(
|
||||
"one unit and five after one point",
|
||||
(0..80)
|
||||
.map(|k| match k {
|
||||
39 => 3001,
|
||||
40 => 4,
|
||||
41 => 2995,
|
||||
_ => 3000,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// 59.94 fps on its grid, stamps truncated to the timescale (1501, 1502, …)
|
||||
("59.94 truncated", (0..120).map(|k: u64| ((k + 1) * 1_501_500 / 1000 - k * 1_501_500 / 1000) as u32).collect()),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
for (name, durations) in cases {
|
||||
let src = crate::open_bytes("synthetic.mov", mp4_90k(&durations)).unwrap();
|
||||
let starts: Vec<i64> = durations.iter().scan(0i64, |t, &d| Some(std::mem::replace(t, *t + i64::from(d)))).collect();
|
||||
let end = *starts.last().unwrap();
|
||||
let mut asked: Vec<i64> = starts.iter().flat_map(|&s| [s, s - 1]).filter(|&s| s >= 0).collect();
|
||||
asked.extend((0..).map(|k| k * 3000).take_while(|&t| t <= end));
|
||||
for t in asked {
|
||||
let want = starts.iter().rposition(|&s| s <= t).unwrap();
|
||||
let got = index_of(&src.video_frame(FrameRequest::full(Tick::from_rational(t, 1, 90_000))).unwrap());
|
||||
if got != want {
|
||||
failures.push(format!("{name}: at {t}/90000 s wanted frame {want}, got {got}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures[..failures.len().min(8)].join("\n"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue