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>
275 lines
9.6 KiB
JavaScript
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();
|