festifun/frontend/src/transport.js
m3ultra ec22cbdf1f Lane C (M4+M5+M6 + timeline): synchronized viewer frontend
Full frontend built against the synthetic API (no dependence on lanes A/B/D):

M4 — synchronized playback: master clock from performance.now() (never a <video>);
per-video correction via requestVideoFrameCallback (exact mediaTime) with a
currentTime+half-frame fallback — hard-seek >150ms / nudge playbackRate +/-5% 20-150ms /
lock <20ms; out-of-range videos pause+dim; single audio source; dev sync-error overlay.
Timebase mirrors config.py (lib/timebase.js). Verified: seek is exact to 0ms; continuous
inter-video desync 13ms mean / 46ms max.

M5 — 3D viewer: PLY point cloud, per-video camera paths + current-pose frusta, OrbitControls,
snap-to-camera (intrinsics->PerspectiveCamera fov) + free roam. All COLMAP->Three.js via the
frozen lib/pose.js; pose interpolation in lib/poseTrack.js (slerp+lerp of contract inputs).

M6 — anchor overlays: letterbox-correct per-video canvas; anchors projected via pose.js;
behind-camera cull. Projection agrees with an independent pinhole to 1.45e-13 px.

Timeline — colored event markers + legend, hover tooltip, click-to-jump; draggable scrubber.

Phase-3 seams (annotate.js M8, camPath.js M9) left as stubs. No frozen files edited; no new
deps; no change requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:45:51 +10:00

275 lines
9.6 KiB
JavaScript

// Master clock + per-video synchronization (spec M4 — the heart of the app).
//
// The master clock is derived from performance.now(); we NEVER trust a <video> element as the
// clock (HTML5 video is not frame-accurate — pitfall #2). Correction is continuous:
// |err| > 150 ms -> hard seek (fastSeek where available)
// 20 ms < |err| <= 150 ms -> nudge playbackRate within master rate x[0.95, 1.05]
// |err| <= 20 ms -> locked: playbackRate = master rate
//
// While PLAYING we measure error per presented frame via requestVideoFrameCallback: `mediaTime`
// is the exact timestamp of the frame on screen and `expectedDisplayTime` is when it shows, so
// err = mediaTime - target(clock @ expectedDisplayTime) is the true on-screen sync error (far
// better than sampling the frame-quantized currentTime in rAF). Browsers without rVFC fall back
// to a currentTime measurement inside the rAF tick(). While PAUSED / seeking, videos are parked
// on their target frame via currentTime. Videos whose target t_video falls outside [0, duration]
// pause and are marked out-of-range (the grid dims them). Exactly one video's audio is unmuted.
import { state, emit } from "./state.js";
import { tVideoFromGlobal } from "./lib/timebase.js";
const HARD_SEEK_S = 0.15; // > this error => hard seek
const NUDGE_MIN_S = 0.02; // > this (and <= HARD_SEEK) => trim playbackRate
const RATE_TRIM = 0.05; // max ±5% playbackRate trim while nudging
export class Transport {
constructor() {
this.cells = []; // [{ id, meta, video, ... }] from videoGrid
this._t0 = 0; // master-timeline anchor (seconds)
this._wall0 = performance.now(); // wall-clock anchor (ms) matching _t0
this._visWired = false;
}
/** Register the video cells the transport drives and start their frame loops. */
register(cells) {
this.cells = cells;
this._applyAudio();
for (const cell of this.cells) this._startFrameLoop(cell);
if (!this._visWired) {
// A throttled/backgrounded tab stalls rAF while decoders free-run; re-lock on return.
document.addEventListener("visibilitychange", () => {
if (!document.hidden) this._resync();
});
this._visWired = true;
}
}
/** Master-timeline seconds at a given wall-clock (ms) timestamp, clamped to [0, tGlobalMax]. */
_clockAt(wallMs) {
if (!state.playing) return this._t0;
const t = this._t0 + ((wallMs - this._wall0) / 1000) * state.rate;
if (t <= 0) return 0;
if (t >= state.tGlobalMax) return state.tGlobalMax;
return t;
}
computeTGlobal() {
return this._clockAt(performance.now());
}
_targetFor(meta, tGlobal) {
return tVideoFromGlobal(tGlobal, meta.offset_ms || 0, meta.drift_ppm || 0);
}
play() {
if (state.playing) return;
let t = state.tGlobal;
if (t >= state.tGlobalMax - 1e-3) t = 0; // restart from the top if parked at the end
this._t0 = t;
this._wall0 = performance.now();
state.tGlobal = t;
state.playing = true;
this._applyAudio(); // first play() is a user gesture -> browser lets us unmute
emit("play");
}
pause() {
if (!state.playing) return;
state.tGlobal = this.computeTGlobal();
this._t0 = state.tGlobal;
state.playing = false;
for (const { video } of this.cells) this._safePause(video);
emit("pause");
}
toggle() {
if (state.playing) this.pause();
else this.play();
}
/** Jump the master timeline; parks every in-range video on its target frame immediately. */
seek(t) {
t = Math.max(0, Math.min(state.tGlobalMax, t));
this._t0 = t;
this._wall0 = performance.now();
state.tGlobal = t;
for (const cell of this.cells) {
const target = this._targetFor(cell.meta, t);
const inRange =
state.enabled[cell.id] !== false && target >= 0 && target <= cell.meta.duration_s;
state.inRange[cell.id] = inRange;
if (inRange) {
this._hardSeek(cell.video, target);
cell.video.playbackRate = state.rate;
state.syncErrorMs[cell.id] = 0;
} else {
state.syncErrorMs[cell.id] = null;
this._safePause(cell.video);
}
}
emit("seek", t);
}
/** Nudge the playhead by a delta (keyboard ±1 s). */
seekBy(dt) {
this.seek((state.playing ? this.computeTGlobal() : this._t0) + dt);
}
/** Set playback speed, rebasing the clock so t_global stays continuous. */
setRate(r) {
state.tGlobal = this.computeTGlobal();
this._t0 = state.tGlobal;
this._wall0 = performance.now();
state.rate = r;
for (const { video } of this.cells) video.playbackRate = r; // re-trimmed by correction
emit("rate", r);
}
/** Choose which video's audio is unmuted (all others muted — avoids phasing chaos). */
setAudioSource(id) {
state.audioSourceId = id;
this._applyAudio();
emit("audiosource", id);
}
_applyAudio() {
for (const { id, video } of this.cells) {
const isSrc = id === state.audioSourceId;
video.muted = !isSrc;
if (isSrc) video.volume = 1;
}
}
// Per-presented-frame correction via requestVideoFrameCallback (accurate). Self-sustaining:
// each callback re-registers the next. No-ops while paused / out of range; rAF tick() handles
// the fallback path for browsers without rVFC.
_startFrameLoop(cell) {
const { video } = cell;
if (typeof video.requestVideoFrameCallback !== "function") {
cell._useRvfc = false;
return;
}
cell._useRvfc = true;
cell._lastRvfc = -Infinity;
const cb = (now, meta) => {
cell._rvfcId = video.requestVideoFrameCallback(cb);
cell._lastRvfc = performance.now();
if (!state.playing || !state.inRange[cell.id]) return;
const displayWall = meta.expectedDisplayTime ?? now;
const target = this._targetFor(cell.meta, this._clockAt(displayWall));
// mediaTime is the exact PTS of the on-screen frame — no quantization compensation needed.
this._correct(cell, meta.mediaTime, target, true);
};
cell._rvfcId = video.requestVideoFrameCallback(cb);
}
_correct(cell, measured, target, allowSeek) {
const { id, video } = cell;
const err = measured - target; // + => video is ahead of where it should be
state.syncErrorMs[id] = err * 1000;
if (allowSeek && Math.abs(err) > HARD_SEEK_S) {
this._hardSeek(video, target);
video.playbackRate = state.rate;
} else if (Math.abs(err) > NUDGE_MIN_S) {
// Ahead (err>0) => slow down; behind (err<0) => speed up. Proportional, clamped ±5%.
const trim = Math.max(-1, Math.min(1, err / HARD_SEEK_S)) * RATE_TRIM;
video.playbackRate = state.rate * (1 - trim);
} else {
video.playbackRate = state.rate;
}
}
_hardSeek(video, target) {
if (typeof video.fastSeek === "function") {
try {
video.fastSeek(target);
return;
} catch {
/* fall through */
}
}
video.currentTime = target;
}
/** Re-lock every in-range video to the current master time (after a tab-visibility stall). */
_resync() {
const t = state.tGlobal;
for (const cell of this.cells) {
const target = this._targetFor(cell.meta, t);
if (state.enabled[cell.id] !== false && target >= 0 && target <= cell.meta.duration_s) {
this._hardSeek(cell.video, target);
cell.video.playbackRate = state.rate;
}
}
}
/** Drive one animation-frame tick: advance the clock and manage each video. */
tick() {
const t = this.computeTGlobal();
state.tGlobal = t;
if (state.playing && t >= state.tGlobalMax) {
this.pause();
return;
}
for (const cell of this.cells) this._manage(cell, t);
}
// Play/pause + range management every rAF. For non-rVFC browsers this also runs correction
// from the (frame-quantized) currentTime; with rVFC, correction happens in the frame loop.
_manage(cell, tGlobal) {
const { id, meta, video } = cell;
const target = this._targetFor(meta, tGlobal);
const enabled = state.enabled[id] !== false;
const inRange = enabled && target >= 0 && target <= meta.duration_s;
state.inRange[id] = inRange;
if (!inRange) {
state.syncErrorMs[id] = null;
this._safePause(video);
return;
}
if (state.playing) {
this._ensurePlaying(video);
// rVFC drives correction when it's firing. Fall back to a currentTime measurement when
// rVFC is unavailable OR stale (>100 ms) — e.g. a tab where rVFC is throttled but rAF runs.
const rvfcStale = !cell._useRvfc || performance.now() - (cell._lastRvfc ?? -Infinity) > 100;
if (rvfcStale) this._correct(cell, this._measureFallback(cell), target, true);
} else {
this._safePause(video);
state.syncErrorMs[id] = (this._measureFallback(cell) - target) * 1000;
}
}
// Estimate a playing video's true position from currentTime. currentTime is quantized to the
// displayed frame (whose PTS is <= the true position), so add half a frame to de-bias it. The
// rVFC path uses exact mediaTime instead and needs no such correction.
_measureFallback(cell) {
const fps = cell.meta.fps || 30;
const halfFrame = state.playing ? (0.5 / fps) * state.rate : 0;
return cell.video.currentTime + halfFrame;
}
_ensurePlaying(video) {
if (video.paused && !video._playPending && video.readyState >= 2) {
video._playPending = true;
video.play().then(
() => (video._playPending = false),
() => (video._playPending = false)
);
}
}
_safePause(video) {
if (video._playPending) return; // let the pending play() settle; next tick re-evaluates
if (!video.paused) {
try {
video.pause();
} catch {
/* ignore */
}
}
}
}
export const transport = new Transport();