From 7e751468382c8df351379cd7231ea4d95775bb6e Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 13:12:52 -0400 Subject: [PATCH] Timers: real-time frame-accurate LTC display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/propresenter-timers/client.js | 122 +++++++++++++++++++- modules/propresenter-timers/ltc-listener.js | 10 +- modules/propresenter-timers/server.js | 9 +- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index f397668..aac8816 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -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() { diff --git a/modules/propresenter-timers/ltc-listener.js b/modules/propresenter-timers/ltc-listener.js index 2390b61..6cba673 100644 --- a/modules/propresenter-timers/ltc-listener.js +++ b/modules/propresenter-timers/ltc-listener.js @@ -161,7 +161,15 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { evalTimer = setInterval(() => { if (stopped || !child) return; - report({ state: snapshotState(), time: lastTc, fps, df }); + const state = snapshotState(); + // ageMs = how old this report's timecode is (time since that frame + // decoded) — the client pins the anchor to its own clock with it and + // extrapolates frame-accurately between reports. Only while running: + // a held frame has no meaningful age, and a varying field would defeat + // the report dedupe while stopped. + const update = { state, time: lastTc, fps, df }; + if (state === 'running') update.ageMs = Date.now() - lastFrameWall; + report(update); }, EVAL_MS); evalTimer.unref?.(); diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 1e80bad..e0e0233 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -132,7 +132,7 @@ module.exports = { if (!ltcEnabled) { return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' }; } - const audioVerdict = (src, tag) => ({ + const audioVerdict = (src, tag, ageMs) => ({ supported: true, source: tag, time: src.time, @@ -140,16 +140,19 @@ module.exports = { status: src.state, fps: src.fps, df: src.df, + // While running: how old this timecode is, so clients can pin the + // anchor to their own clock and count frames between updates. + ...(src.state === 'running' && Number.isFinite(ageMs) ? { ageMs: Math.max(0, ageMs) } : {}), note: '', }); // The built-in listener while it has actual data … if (listenerLtc && !listenerLtc.error && listenerLtc.state !== 'nosignal') { - return audioVerdict(listenerLtc, 'listener'); + return audioVerdict(listenerLtc, 'listener', listenerLtc.ageMs); } // … else a fresh remote reader (during migrations both may run — // real data beats no data) … if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) { - return audioVerdict(ingest, 'reader'); + return audioVerdict(ingest, 'reader', Date.now() - ingest.at); } // … else a healthy-but-silent listener is believed over the indirect // sources: no audio means the LTC really stopped.