Files
filmcraft/apps/filmcraft-web/src/webcodecs.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

536 lines
21 KiB
Rust

//! WebCodecs (hardware) video decoding for MP4/MOV, with our own decoders as the fallback.
//!
//! WebCodecs decodes asynchronously, and our [`VideoDecoder`] trait is synchronous, so the
//! WebCodecs path plugs in one level up, as a media source: [`reader_opener`] (registered ahead of
//! the built-in openers with `filmcraft_codecs::register_reader_opener`) opens H.264, HEVC, VP9 and
//! AV1 MP4/MOV files as a [`WcSource`]. Its audio and media info come from our own
//! [`filmcraft_codecs::Mp4Source`]; its video frames from a browser `VideoDecoder`:
//!
//! - A frame request finds the frame in the decoded-frame cache, or (re)starts a decode session at
//! the sample's sync sample, feeds samples up to a little past it (read through the Blob chunk
//! cache), marks the request [`filmcraft_media::pending`] and returns; the frame server retries
//! the job on a later frame, when the decoder's output callback has delivered the picture.
//! - A session that has not delivered the frame it was started for is not restarted by other
//! requests (thumbnails and the monitor would otherwise keep resetting each other).
//! - Outputs are converted to RGBA by drawing the `VideoFrame` on an `OffscreenCanvas` (the
//! browser applies the stream's colour description).
//! - Any decoder error or unsupported configuration switches the source to our decoder.
//!
//! `?nowebcodecs` disables it. Support is probed at start-up with `VideoDecoder.isConfigSupported`.
//!
//! [`VideoDecoder`]: filmcraft_codecs::VideoDecoder
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind};
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaSource, SharedReader, SharedSource};
use filmcraft_time::Tick;
use serde_json::json;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
/// Samples fed past the wanted one (decoders hold pictures for reordering).
const LOOKAHEAD: usize = 8;
/// Decoded frames kept per source.
const CACHE_FRAMES: usize = 40;
/// Chunks allowed in the decoder's queue before feeding pauses.
const MAX_QUEUE: u32 = 24;
/// A session that delivered nothing for this long may be restarted by another request.
const STALE_MS: f64 = 2000.0;
thread_local! {
/// Codec families the browser decodes ("avc", "hevc", "vp9", "av1").
static SUPPORTED: RefCell<Vec<&'static str>> = const { RefCell::new(Vec::new()) };
static SESSIONS: RefCell<HashMap<u32, Session>> = RefCell::new(HashMap::new());
static NEXT_ID: Cell<u32> = const { Cell::new(1) };
static CANVAS: RefCell<Option<(web_sys::OffscreenCanvas, web_sys::OffscreenCanvasRenderingContext2d)>> = const { RefCell::new(None) };
static DECODED: Cell<u64> = const { Cell::new(0) };
static FALLBACKS: Cell<u64> = const { Cell::new(0) };
static SOURCES: Cell<u64> = const { Cell::new(0) };
}
fn now_ms() -> f64 {
js_sys::Date::now()
}
fn get(o: &JsValue, k: &str) -> JsValue {
js_sys::Reflect::get(o, &k.into()).unwrap_or(JsValue::UNDEFINED)
}
fn call(o: &JsValue, method: &str, args: &[&JsValue]) -> Result<JsValue, JsValue> {
let f: js_sys::Function = get(o, method).dyn_into()?;
let a = js_sys::Array::new();
for x in args {
a.push(x);
}
f.apply(o, &a)
}
fn ctor(name: &str) -> Option<js_sys::Function> {
get(&js_sys::global(), name).dyn_into().ok()
}
fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
let o = js_sys::Object::new();
for (k, v) in pairs {
let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
}
o.into()
}
/// WebCodecs codec string and decoder `description` for a sample entry.
pub fn codec_string(c: &CodecConfig, fourcc: &str) -> Option<(&'static str, String, Option<Vec<u8>>)> {
match c {
CodecConfig::Avc(a) => Some(("avc", format!("avc1.{:02x}{:02x}{:02x}", a.profile, a.compatibility, a.level), Some(a.to_bytes()))),
CodecConfig::Hevc(h) => {
let space = ["", "A", "B", "C"][(h.general_profile_space & 3) as usize];
let compat = h.general_profile_compatibility_flags.reverse_bits();
let mut s = format!(
"{}.{space}{}.{compat:x}.{}{}",
if fourcc == "hev1" { "hev1" } else { "hvc1" },
h.general_profile_idc,
if h.general_tier_flag { 'H' } else { 'L' },
h.general_level_idc
);
let bytes = h.general_constraint_indicator_flags.to_be_bytes();
let cons = &bytes[2..8];
let last = cons.iter().rposition(|b| *b != 0);
if let Some(l) = last {
for b in &cons[..=l] {
s.push_str(&format!(".{b:x}"));
}
}
Some(("hevc", s, Some(h.to_bytes())))
}
CodecConfig::Vp9(v) => Some(("vp9", format!("vp09.{:02}.{:02}.{:02}", v.profile, v.level, v.bit_depth.max(8)), None)),
CodecConfig::Av1(a) => {
let depth = if a.twelve_bit {
12
} else if a.high_bitdepth {
10
} else {
8
};
Some(("av1", format!("av01.{}.{:02}{}.{depth:02}", a.seq_profile, a.seq_level_idx_0, if a.seq_tier_0 { 'H' } else { 'M' }), Some(a.to_bytes())))
}
_ => None,
}
}
/// Probe which codec families the browser decodes (sets `filmcraft.info().webcodecs`).
pub async fn probe() {
let Some(vd) = ctor("VideoDecoder") else {
crate::set_info("webcodecs", json!(false));
return;
};
if web_sys::window().and_then(|w| w.location().search().ok()).is_some_and(|s| s.contains("nowebcodecs")) {
crate::set_info("webcodecs", json!("disabled"));
return;
}
let mut ok = Vec::new();
for (family, codec) in [("avc", "avc1.640028"), ("hevc", "hvc1.1.6.L120.90"), ("vp9", "vp09.00.40.08"), ("av1", "av01.0.08M.08")] {
let cfg = obj(&[("codec", codec.into()), ("codedWidth", 1920.into()), ("codedHeight", 1080.into())]);
let Ok(p) = call(&vd, "isConfigSupported", &[&cfg]).and_then(|p| p.dyn_into::<js_sys::Promise>()) else { continue };
if let Ok(r) = JsFuture::from(p).await
&& get(&r, "supported").as_bool() == Some(true)
{
ok.push(family);
}
}
crate::set_info("webcodecs", json!(ok));
SUPPORTED.with(|s| *s.borrow_mut() = ok);
}
/// Register the WebCodecs opener (when the browser decodes anything).
pub fn install() {
if SUPPORTED.with(|s| !s.borrow().is_empty()) {
filmcraft_codecs::register_reader_opener(reader_opener);
}
}
/// Live counters for `filmcraft.info()`.
pub fn stats() -> serde_json::Value {
let sessions: Vec<serde_json::Value> = SESSIONS.with(|ss| {
ss.borrow()
.iter()
.map(|(id, s)| {
json!({"id": id, "start": s.start, "next": s.next, "target": s.target, "needsKey": s.needs_key, "flushing": s.flushing.get(),
"errored": s.errored.get(), "queue": s.queue(), "idleMs": now_ms() - s.last_output.get()})
})
.collect()
});
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions})
}
/// A [`SharedReader`] as the demuxer's byte source.
struct ReaderSrc(SharedReader);
impl filmcraft_isobmff::ByteSource for ReaderSrc {
fn len(&self) -> u64 {
self.0.len()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
self.0.read_at(offset, buf)
}
}
/// MP4/MOV with a WebCodecs-decodable video track → [`WcSource`].
pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<Result<SharedSource, MediaError>> {
if !filmcraft_codecs::mp4::sniff(head) {
return None;
}
let inner: SharedSource = match filmcraft_codecs::Mp4Source::open_reader(name, reader.clone()) {
Ok(s) => Arc::new(s),
Err(e) => return Some(Err(e.into())),
};
let file = match filmcraft_isobmff::open(ReaderSrc(reader.clone())) {
Ok(f) => f,
Err(_) => return Some(Ok(inner)),
};
let Some(track) = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty()) else { return Some(Ok(inner)) };
let Some(entry) = file.tracks[track].entries.first() else { return Some(Ok(inner)) };
let fourcc = entry.format.to_string();
let Some((family, codec, description)) = codec_string(&entry.codec, &fourcc) else { return Some(Ok(inner)) };
if !SUPPORTED.with(|s| s.borrow().contains(&family)) {
return Some(Ok(inner));
}
let info = inner.info().clone();
// the decoder wants the coded size; `info` reports the display size after the track's rotation
let rotation = file.tracks[track].display_rotation().unwrap_or(0);
let (w, h) = info.video.as_ref().map(|v| if rotation % 2 == 1 { (v.height, v.width) } else { (v.width, v.height) }).unwrap_or((0, 0));
let id = NEXT_ID.with(|n| {
let id = n.get();
n.set(id + 1);
id
});
SOURCES.with(|c| c.set(c.get() + 1));
log::info!("{name}: video decoded with WebCodecs ({codec})");
Some(Ok(Arc::new(WcSource {
inner,
info,
reader: reader.clone(),
file,
track,
id,
config: Config { codec, description, width: w, height: h, rotation },
state: Mutex::new(Cache::default()),
})))
}
struct Config {
codec: String,
description: Option<Vec<u8>>,
width: u32,
height: u32,
/// Clockwise quarter turns from the track matrix, applied to decoded frames.
rotation: u8,
}
#[derive(Default)]
struct Cache {
frames: BTreeMap<i64, Arc<VideoFrame>>,
failed: bool,
}
/// The browser decoder of one source (UI thread only; JS objects are not `Send`).
struct Session {
decoder: JsValue,
/// Sync sample the session started at, next sample to feed.
start: usize,
next: usize,
/// The decoder must restart at a key frame (new, or after a flush).
needs_key: bool,
flushing: Rc<Cell<bool>>,
errored: Rc<Cell<bool>>,
/// The frame (pts) this session was started for, and when it last delivered anything.
target: i64,
last_output: Rc<Cell<f64>>,
/// Highest presentation time output since the session (re)started.
out_max: Rc<Cell<i64>>,
out: Rc<RefCell<Vec<(i64, VideoFrame)>>>,
_closures: (Closure<dyn FnMut(JsValue)>, Closure<dyn FnMut(JsValue)>),
}
/// Draw a WebCodecs `VideoFrame` and read it back as RGBA.
fn to_rgba(frame: &JsValue) -> Option<VideoFrame> {
let w = get(frame, "displayWidth").as_f64()? as u32;
let h = get(frame, "displayHeight").as_f64()? as u32;
CANVAS.with(|c| {
let mut c = c.borrow_mut();
if c.is_none() {
let canvas = web_sys::OffscreenCanvas::new(w.max(1), h.max(1)).ok()?;
let opts = obj(&[("willReadFrequently", true.into())]);
let ctx = canvas.get_context_with_context_options("2d", &opts).ok()??.dyn_into::<web_sys::OffscreenCanvasRenderingContext2d>().ok()?;
*c = Some((canvas, ctx));
}
let (canvas, ctx) = c.as_ref()?;
if canvas.width() != w || canvas.height() != h {
canvas.set_width(w);
canvas.set_height(h);
}
call(ctx, "drawImage", &[frame, &0.into(), &0.into(), &w.into(), &h.into()]).ok()?;
let img = ctx.get_image_data(0.0, 0.0, w as f64, h as f64).ok()?;
Some(VideoFrame::rgba8(w, h, img.data().0))
})
}
impl Session {
fn new(cfg: &Config) -> Result<Session, JsValue> {
let vd = ctor("VideoDecoder").ok_or("no VideoDecoder")?;
let out: Rc<RefCell<Vec<(i64, VideoFrame)>>> = Rc::default();
let errored = Rc::new(Cell::new(false));
let last_output = Rc::new(Cell::new(now_ms()));
let out_max = Rc::new(Cell::new(i64::MIN));
let (o, lo, om) = (out.clone(), last_output.clone(), out_max.clone());
let on_output = Closure::<dyn FnMut(JsValue)>::new(move |frame: JsValue| {
let ts = get(&frame, "timestamp").as_f64().unwrap_or(0.0) as i64;
om.set(om.get().max(ts));
if let Some(f) = to_rgba(&frame) {
o.borrow_mut().push((ts, f));
DECODED.with(|c| c.set(c.get() + 1));
}
let _ = call(&frame, "close", &[]);
lo.set(now_ms());
crate::repaint();
});
let e = errored.clone();
let on_error = Closure::<dyn FnMut(JsValue)>::new(move |err: JsValue| {
log::warn!("WebCodecs decoder error: {err:?}");
e.set(true);
crate::repaint();
});
let init = obj(&[("output", on_output.as_ref().clone()), ("error", on_error.as_ref().clone())]);
let decoder = js_sys::Reflect::construct(&vd, &js_sys::Array::of1(&init))?;
let s = Session {
decoder,
start: 0,
next: 0,
needs_key: true,
flushing: Rc::new(Cell::new(false)),
errored,
target: i64::MIN,
last_output,
out_max,
out,
_closures: (on_output, on_error),
};
s.configure(cfg)?;
Ok(s)
}
fn configure(&self, cfg: &Config) -> Result<(), JsValue> {
let mut pairs = vec![
("codec", JsValue::from_str(&cfg.codec)),
("codedWidth", cfg.width.into()),
("codedHeight", cfg.height.into()),
("optimizeForLatency", true.into()),
];
if let Some(d) = &cfg.description {
pairs.push(("description", js_sys::Uint8Array::from(&d[..]).into()));
}
call(&self.decoder, "configure", &[&obj(&pairs)]).map(|_| ())
}
fn queue(&self) -> u32 {
get(&self.decoder, "decodeQueueSize").as_f64().unwrap_or(0.0) as u32
}
}
/// An MP4/MOV source whose video is decoded by the browser.
pub struct WcSource {
inner: SharedSource,
info: MediaInfo,
reader: SharedReader,
file: Mp4File,
track: usize,
id: u32,
config: Config,
state: Mutex<Cache>,
}
enum Drive {
/// Work is under way (or bytes are loading): retry later.
Waiting,
/// The decoder failed: use our own.
Failed,
}
impl WcSource {
/// Make progress towards sample `i` (presentation `want`).
fn drive(&self, i: usize, want: i64) -> Drive {
let t = &self.file.tracks[self.track];
let n = t.samples.len();
SESSIONS.with(|ss| {
let mut ss = ss.borrow_mut();
let s = match ss.entry(self.id) {
std::collections::hash_map::Entry::Occupied(o) => o.into_mut(),
std::collections::hash_map::Entry::Vacant(v) => match Session::new(&self.config) {
Ok(s) => v.insert(s),
Err(e) => {
log::warn!("WebCodecs: {e:?}");
return Drive::Failed;
}
},
};
if s.errored.get() {
return Drive::Failed;
}
if s.flushing.get() {
return Drive::Waiting;
}
let key = t.sync_sample_before(i);
// The session reaches the frame by feeding on: it has not output it yet (a frame it
// already output but the cache dropped needs a restart).
let covers = !s.needs_key && s.start <= i && key <= s.next && (i >= s.next || want > s.out_max.get());
if !covers {
// Don't interrupt a session that is still working towards its own frame.
let busy = !s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS;
if busy {
return Drive::Waiting;
}
if !s.needs_key || s.next > 0 {
let _ = call(&s.decoder, "reset", &[]);
if s.configure(&self.config).is_err() {
return Drive::Failed;
}
}
s.out.borrow_mut().clear();
s.start = key;
s.next = key;
s.needs_key = false;
s.target = want;
s.out_max.set(i64::MIN);
s.last_output.set(now_ms());
} else if s.target == i64::MIN {
s.target = want;
}
let upto = (i + LOOKAHEAD).min(n - 1);
while s.next <= upto && s.queue() < MAX_QUEUE {
let smp = &t.samples[s.next];
let mut data = vec![0u8; smp.size as usize];
match self.reader.read_at(smp.offset, &mut data) {
Ok(()) => {}
// the bytes are being fetched (pending is marked): feed on the next try
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Drive::Waiting,
Err(_) => return Drive::Failed,
}
let Some(chunk_ctor) = ctor("EncodedVideoChunk") else { return Drive::Failed };
let init = obj(&[
("type", (if smp.is_sync || s.next == s.start { "key" } else { "delta" }).into()),
("timestamp", (smp.pts as f64).into()),
("duration", (smp.duration as f64).into()),
("data", js_sys::Uint8Array::from(&data[..]).into()),
]);
let Ok(chunk) = js_sys::Reflect::construct(&chunk_ctor, &js_sys::Array::of1(&init)) else { return Drive::Failed };
if call(&s.decoder, "decode", &[&chunk]).is_err() {
return Drive::Failed;
}
s.next += 1;
}
// End of the stream: pictures held for reordering only come out on a flush.
if s.next >= n && s.queue() == 0 {
s.flushing.set(true);
s.needs_key = true;
if let Ok(p) = call(&s.decoder, "flush", &[]).and_then(|p| p.dyn_into::<js_sys::Promise>()) {
let f = s.flushing.clone();
wasm_bindgen_futures::spawn_local(async move {
let _ = JsFuture::from(p).await;
f.set(false);
crate::repaint();
});
} else {
s.flushing.set(false);
}
}
Drive::Waiting
})
}
/// Move decoded pictures into the cache.
fn collect(&self, c: &mut Cache, want: i64) {
let out = SESSIONS.with(|ss| {
let ss = ss.borrow();
let Some(s) = ss.get(&self.id) else { return Vec::new() };
std::mem::take(&mut *s.out.borrow_mut())
});
for (pts, f) in out {
let f = if self.config.rotation != 0 { f.rotated(self.config.rotation) } else { f };
c.frames.insert(pts, Arc::new(f));
}
while c.frames.len() > CACHE_FRAMES {
let Some(far) = c.frames.keys().copied().max_by_key(|p| (p - want).abs()) else { break };
c.frames.remove(&far);
}
// the session's own frame came out: other requests may restart it now
SESSIONS.with(|ss| {
if let Some(s) = ss.borrow_mut().get_mut(&self.id)
&& s.out_max.get() >= s.target
{
s.target = i64::MIN;
}
});
}
}
impl Drop for WcSource {
fn drop(&mut self) {
SESSIONS.with(|ss| {
if let Ok(mut ss) = ss.try_borrow_mut()
&& let Some(s) = ss.remove(&self.id)
{
let _ = call(&s.decoder, "close", &[]);
}
});
}
}
impl MediaSource for WcSource {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
let mut c = self.state.lock().unwrap_or_else(|e| e.into_inner());
if c.failed {
drop(c);
return self.inner.video_frame(req);
}
let t = &self.file.tracks[self.track];
let n = t.samples.len();
// nearest, as `Mp4Source` looks frames up (sample times are rounded to the timescale)
let target = req.time.max(Tick::ZERO).to_rational_round(1, t.timescale.max(1) as i64);
let i = t.sample_at_presentation_time(target).unwrap_or(n - 1).min(n - 1);
let want = t.samples[i].pts;
self.collect(&mut c, want);
if let Some(f) = c.frames.get(&want) {
return Ok(f.clone());
}
match self.drive(i, want) {
Drive::Waiting => {
self.collect(&mut c, want);
if let Some(f) = c.frames.get(&want) {
return Ok(f.clone());
}
filmcraft_media::pending::mark();
Err(MediaError::Decode("WebCodecs: decoding".into()))
}
Drive::Failed => {
log::warn!("{}: WebCodecs failed, using FilmCraft's decoder", self.info.name);
FALLBACKS.with(|f| f.set(f.get() + 1));
c.failed = true;
drop(c);
self.inner.video_frame(req)
}
}
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
self.inner.audio(start, frames, sample_rate)
}
}