Timers: real-time frame-accurate LTC display
The LTC card repainted only on server anchors (~4x/s), so the frame counter jumped in ~7-frame steps. Now the client counts every frame locally between anchors and re-syncs on each one — frame-accurate and smooth, with no extra network traffic. - Server anchors carry ageMs (how old the decoded timecode is) so clients can pin the anchor to their own clock. ltc-listener reports it while running; composeLtc passes it through (listener) or derives it (reader). - Client does exact SMPTE frame<->timecode math (parseTc/tcToFrames/ framesToTc, incl. 29.97 drop-frame minute counting) and advances the display in a requestAnimationFrame loop at the LTC's own rate. - Robust when rAF is throttled (backgrounded tab): render() paints the extrapolated frame at the 4x/s anchor rate too, so the display never freezes — the rAF loop only refines it to every frame when visible. - Anchor jitter can't tick the counter backwards (small regressions clamped; genuine seeks/re-strikes pass); freshness cap stops freewheel into phantom frames when anchors stop arriving. Verified: SMPTE math against the encoder over 200k drop-frame frames plus round-trips; the extrapolation pipeline simulated at 30/25/29.97DF showing strict +1-per-frame advance and correct DF boundary crossing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
dba10a30fe
commit
7e75146838
@@ -11,6 +11,45 @@
|
||||
|
||||
const LTC_KEY = '__ltc__';
|
||||
|
||||
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
|
||||
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
|
||||
counts every frame in between locally. That needs exact frame↔timecode
|
||||
conversion, including 29.97 drop-frame (frames 00 and 01 don't exist at
|
||||
the top of a minute, except every tenth minute). */
|
||||
|
||||
export function parseTc(str) {
|
||||
const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;.](\d{1,2})$/.exec(String(str || '').trim());
|
||||
if (!m) return null;
|
||||
return { h: Number(m[1]), m: Number(m[2]), s: Number(m[3]), f: Number(m[4]) };
|
||||
}
|
||||
|
||||
export function tcToFrames(tc, nominal, df) {
|
||||
let n = (tc.h * 3600 + tc.m * 60 + tc.s) * nominal + tc.f;
|
||||
if (df) {
|
||||
const mins = tc.h * 60 + tc.m;
|
||||
n -= 2 * (mins - Math.floor(mins / 10));
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function framesToTc(n, nominal, df) {
|
||||
// 10 DF minutes hold 17982 frames (the first minute 1800, the rest 1798).
|
||||
const perDay = df ? 24 * 6 * 17982 : 24 * 3600 * nominal;
|
||||
n = ((n % perDay) + perDay) % perDay;
|
||||
if (df) {
|
||||
const d = Math.floor(n / 17982);
|
||||
const m10 = n % 17982;
|
||||
const extra = m10 < 1800 ? 0 : Math.floor((m10 - 1800) / 1798) + 1;
|
||||
const totalMin = d * 10 + extra;
|
||||
// Re-add the two dropped frames per dropped minute, then read as 30fps.
|
||||
n += 2 * (totalMin - Math.floor(totalMin / 10));
|
||||
}
|
||||
const f = n % nominal;
|
||||
const secs = Math.floor(n / nominal);
|
||||
const pad = (v) => String(v).padStart(2, '0');
|
||||
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`;
|
||||
}
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
@@ -207,9 +246,78 @@ export default function create({ root, moduleApi }) {
|
||||
card.el.className = `tm-card is-${kind}`;
|
||||
}
|
||||
|
||||
/* ── real-time LTC: count every frame between server anchors ─────── */
|
||||
|
||||
// The server anchors us ~4×/s with the decoded timecode, its rate and
|
||||
// how old it is (ageMs). Between anchors a requestAnimationFrame loop
|
||||
// advances the display at the LTC's own rate, so every frame paints —
|
||||
// without pushing 30 SSE messages a second at every dashboard.
|
||||
let ltcAnchor = null; // { frames, at, recvAt, rate, nominal, df }
|
||||
let ltcRaf = 0;
|
||||
let ltcShownFrames = -1;
|
||||
/** Anchors arrive ~every 250 ms while running. None for this long means
|
||||
the feed state is changing — hold rather than freewheel into phantom
|
||||
frames the next update would visibly rewind. */
|
||||
const ANCHOR_FRESH_MS = 350;
|
||||
|
||||
function updateLtcAnchor() {
|
||||
const l = state?.ltc;
|
||||
// Only the audio sources are extrapolatable — they know the frame rate.
|
||||
const live = l && l.status === 'running' && l.fps > 0
|
||||
&& (l.source === 'listener' || l.source === 'reader');
|
||||
const tc = live ? parseTc(l.time) : null;
|
||||
if (!tc) {
|
||||
ltcAnchor = null;
|
||||
ltcShownFrames = -1;
|
||||
if (ltcRaf) {
|
||||
cancelAnimationFrame(ltcRaf);
|
||||
ltcRaf = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nominal = l.df ? 30 : Math.round(l.fps);
|
||||
ltcAnchor = {
|
||||
frames: tcToFrames(tc, nominal, l.df),
|
||||
at: performance.now() - (Number(l.ageMs) || 0),
|
||||
recvAt: performance.now(),
|
||||
rate: l.df ? 30000 / 1001 : nominal,
|
||||
nominal,
|
||||
df: l.df,
|
||||
};
|
||||
if (!ltcRaf) ltcRaf = requestAnimationFrame(ltcTick);
|
||||
}
|
||||
|
||||
/** Paint the frame the anchor implies for right now. Called both by the
|
||||
rAF loop (smooth, per-frame, on visible tabs) and by render() at the
|
||||
4×/s anchor rate — so a throttled/backgrounded tab, where rAF is
|
||||
paused, still advances at the anchor rate instead of freezing. */
|
||||
function paintLtcFrame() {
|
||||
const a = ltcAnchor;
|
||||
const card = cards.get(LTC_KEY);
|
||||
if (!a || !card) return;
|
||||
const limit = Math.min(performance.now(), a.recvAt + ANCHOR_FRESH_MS);
|
||||
let n = a.frames + Math.round(((limit - a.at) / 1000) * a.rate);
|
||||
// Anchor jitter (±1 frame) must never tick the display backwards;
|
||||
// a genuine jump back (re-strike, seek) is far larger and passes.
|
||||
if (ltcShownFrames >= 0 && n < ltcShownFrames && ltcShownFrames - n <= 2) n = ltcShownFrames;
|
||||
if (n !== ltcShownFrames) {
|
||||
ltcShownFrames = n;
|
||||
card.timeEl.textContent = framesToTc(n, a.nominal, a.df);
|
||||
}
|
||||
}
|
||||
|
||||
function ltcTick() {
|
||||
ltcRaf = 0;
|
||||
// No anchor, or the LTC card is hidden in this tile: stop — the next
|
||||
// server anchor (4×/s while running) re-arms the loop via render().
|
||||
if (!ltcAnchor || !cards.get(LTC_KEY)) return;
|
||||
paintLtcFrame();
|
||||
ltcRaf = requestAnimationFrame(ltcTick);
|
||||
}
|
||||
|
||||
function updateLtcCard(card, ltc) {
|
||||
let mode;
|
||||
// Only the reader source knows the frame rate (decoded from the LTC
|
||||
// Only the audio sources know the frame rate (decoded from the LTC
|
||||
// bits); a set drop-frame flag implies 29.97 whatever fps rounds to.
|
||||
const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : '';
|
||||
if (ltc.supported === false) {
|
||||
@@ -225,7 +333,12 @@ export default function create({ root, moduleApi }) {
|
||||
card.stateEl.textContent = 'Waiting for ProPresenter…';
|
||||
} else if (ltc.receiving) {
|
||||
mode = 'receiving';
|
||||
card.timeEl.textContent = displayTime(ltc.time);
|
||||
// With an anchor active, paint the frame it implies for now (this
|
||||
// keeps a throttled tab advancing at the 4×/s anchor rate); the rAF
|
||||
// loop refines it to every frame when the tab is visible. Without an
|
||||
// anchor (rate unknown), fall back to the raw anchor timecode.
|
||||
if (ltcAnchor) paintLtcFrame();
|
||||
else card.timeEl.textContent = displayTime(ltc.time);
|
||||
card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running';
|
||||
} else if (ltc.status === 'stopped' || ltc.time) {
|
||||
// LTC ceased but we know where it stopped — hold the last frame.
|
||||
@@ -285,6 +398,7 @@ export default function create({ root, moduleApi }) {
|
||||
function render() {
|
||||
renderStatus();
|
||||
rebuildMenu();
|
||||
updateLtcAnchor();
|
||||
|
||||
let hint = '';
|
||||
if (state && !state.enabled && !state.ltc?.supported) {
|
||||
@@ -434,6 +548,10 @@ export default function create({ root, moduleApi }) {
|
||||
stream = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
if (ltcRaf) {
|
||||
cancelAnimationFrame(ltcRaf);
|
||||
ltcRaf = 0;
|
||||
}
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
onResize() {
|
||||
|
||||
Reference in New Issue
Block a user