Files
filmcraft/crates/codecs/src/rounded_pts_tests.rs
T
EthanSuquetandClaude Opus 5.5 2b49201e84 codecs: video frames are looked up by the nearest timestamp unit, so a 1 ms WebM shows every frame
Problem (#73): a 30 fps WebM played and exported with every third frame
missing and the frame before it shown twice (20 unique frames a second).
The same happens at 29.97, 60, 59.94, 24 and 23.976 fps, and with MP4
files whose video track has a 1 ms timescale. 25 and 50 fps were fine.

Cause: Matroska stores block timestamps in TimestampScale units (usually
1 ms), rounded to the nearest unit, so at 30 fps frame 2 (66.67 ms) is
stored as 67. MkvSource::video_frame converted the requested time to track
units with a floor (66), and the lookup (last sample with pts <= target)
returned frame 1. Every frame whose timestamp was rounded up was skipped.
Mp4Source::video_at, and the web build's WebCodecs MP4 path, floored the
same way against the track timescale; normal MP4 timescales hold frame
times exactly, so only coarse (1000) timescales were hit.

Fix: new Tick::to_rational_round (nearest unit, halves up, total for zero
and negative timebases), used for the video lookup target and the
catch-up threshold in mkv.rs, mp4.rs and webcodecs.rs. A requested time
now matches a frame whose stored timestamp is within half a unit of it,
the same precision the timestamps have. Truncating muxers (stamps rounded
down) still work. Times on exact timebases (90 kHz, 15360, 30000/1001…)
convert exactly as before.

Test: crates/codecs/src/rounded_pts_tests.rs builds synthetic Motion-JPEG
streams in the test (each picture shows its index as black/white blocks),
muxed as WebM with 1 ms timestamps and as MOV with a 1000 timescale, at
30, 29.97, 60, 59.94, 24, 23.976 and 25 fps with nearest and truncated
stamps. Requesting every frame's start over 3 s must show every frame
exactly once, and every frame's midpoint must show that frame. Before the
fix both tests failed ("30 fps, Nearest ms stamps: 30 of 90 frames never
shown [2, 5, 8, 11]…, 30 shown twice [1, 4, 7, 10]…"). The time crate
tests cover the rounding, ties and hostile timebases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:16:37 -04:00

137 lines
5.7 KiB
Rust

//! Frame lookup against timestamps rounded to the container's timebase (issue #73).
//!
//! WebM stores timestamps in 1 ms units, so at 30 fps frame 2 (66.67 ms) is stored as 67. Flooring
//! the requested time to 66 picked frame 1 instead: every third frame was skipped and the one
//! before it shown twice. MP4 has the same problem when a muxer picks a 1 ms track timescale.
//!
//! The streams are synthetic Motion-JPEG: each picture shows its index as a row of black and
//! white blocks, so a decoded frame says which sample it came from.
use std::io::Cursor;
use std::sync::Arc;
use filmcraft_frame::{PixelData, VideoFrame};
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
use filmcraft_media::{FrameRequest, MediaSource};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
/// Bits of the frame index drawn per picture, one `BLOCK`-pixel square each.
const BITS: u32 = 8;
const BLOCK: u32 = 16;
const SECONDS: i64 = 3;
/// The rates that don't divide 1 ms evenly (affected), and 25 fps (never was).
const RATES: [FrameRate; 7] =
[FrameRate::FPS_30, FrameRate::FPS_29_97, FrameRate::FPS_60, FrameRate::FPS_59_94, FrameRate::FPS_24, FrameRate::FPS_23_976, FrameRate::FPS_25];
/// How the muxer quantised the exact frame times to milliseconds.
#[derive(Clone, Copy, Debug)]
enum Stamp {
/// To the nearest millisecond, halves up (mkvmerge, ffmpeg).
Nearest,
/// Truncated.
Floor,
}
fn picture(index: usize) -> Vec<u8> {
let img = image::GrayImage::from_fn(BITS * BLOCK, BLOCK, |x, _| image::Luma([if (index >> (x / BLOCK)) & 1 == 1 { 255 } else { 0 }]));
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90).encode_image(&img).unwrap();
out
}
fn index_of(f: &VideoFrame) -> usize {
let PixelData::Rgba8(px) = &f.data else { panic!("MJPEG decodes to RGBA8") };
(0..BITS).filter(|b| px[((BLOCK / 2 * f.width + b * BLOCK + BLOCK / 2) * 4) as usize] > 127).map(|b| 1 << b).sum()
}
fn frame_count(rate: FrameRate) -> usize {
rate.frame_at(Tick(SECONDS * TICKS_PER_SECOND)) as usize
}
/// Frame `k`'s start in milliseconds, as the muxer stores it.
fn stamp_ms(rate: FrameRate, k: usize, stamp: Stamp) -> i64 {
let (num, den) = (rate.num as i128, rate.den as i128);
let exact2 = 2 * k as i128 * 1000 * den; // twice the time in ms, times num
(match stamp {
Stamp::Nearest => (exact2 + num) / (2 * num),
Stamp::Floor => exact2 / (2 * num),
}) as i64
}
fn webm(rate: FrameRate, stamp: Stamp) -> Arc<[u8]> {
let mut spec = TrackSpec::new(TrackKind::Video, "V_MJPEG");
spec.video_size = Some((BITS * BLOCK, BLOCK));
spec.default_duration_ns = Some((1_000_000_000 * rate.den / rate.num) as u64);
let opts = MuxOptions { doc_type: "webm".into(), ..MuxOptions::default() };
assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps");
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap();
for k in 0..frame_count(rate) {
w.write_frame(0, stamp_ms(rate, k, stamp) * 1_000_000, true, &picture(k), None).unwrap();
}
w.finish().unwrap().into_inner().into()
}
fn mp4_1ms(rate: FrameRate, stamp: Stamp) -> 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), 1000)).unwrap();
let n = frame_count(rate);
for k in 0..n {
let duration = (stamp_ms(rate, k + 1, stamp) - stamp_ms(rate, k, stamp)) as u32;
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
}
w.finish().unwrap().into_inner().into()
}
/// Plays the stream back at its own rate (every frame's start), and asks for every frame's
/// midpoint too. Returns what went wrong: frames never shown, frames shown twice, wrong midpoints.
fn problems(src: &dyn MediaSource, rate: FrameRate) -> Vec<String> {
let n = frame_count(rate);
let at = |t: Tick| index_of(&src.video_frame(FrameRequest::full(t)).unwrap());
let mut shown = vec![0usize; n];
for k in 0..n {
shown[at(rate.tick_of(k as i64)).min(n - 1)] += 1;
}
let mut out = Vec::new();
let dropped: Vec<usize> = (0..n).filter(|&k| shown[k] == 0).collect();
let repeated: Vec<usize> = (0..n).filter(|&k| shown[k] > 1).collect();
if !dropped.is_empty() || !repeated.is_empty() {
out.push(format!(
"{} of {n} frames never shown {:?}…, {} shown twice {:?}…",
dropped.len(),
&dropped[..dropped.len().min(4)],
repeated.len(),
&repeated[..repeated.len().min(4)]
));
}
let mid: Vec<(usize, usize)> = (0..n).map(|k| (k, at(rate.tick_of(k as i64) + Tick(rate.frame_duration().0 / 2)))).filter(|(k, got)| k != got).collect();
if !mid.is_empty() {
out.push(format!("{} mid-frame requests wrong, e.g. (wanted, got) {:?}", mid.len(), &mid[..mid.len().min(4)]));
}
out
}
fn check(container: &str, open: fn(FrameRate, Stamp) -> Arc<[u8]>) {
let mut failures = Vec::new();
for rate in RATES {
for stamp in [Stamp::Nearest, Stamp::Floor] {
let src = crate::open_bytes(&format!("synthetic.{container}"), open(rate, stamp)).unwrap();
for p in problems(src.as_ref(), rate) {
failures.push(format!("{rate}, {stamp:?} ms stamps: {p}"));
}
}
}
assert!(failures.is_empty(), "{container}:\n{}", failures.join("\n"));
}
#[test]
fn webm_with_1ms_timestamps_shows_every_frame_once() {
check("webm", webm);
}
#[test]
fn mp4_with_a_1ms_timescale_shows_every_frame_once() {
check("mov", mp4_1ms);
}