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__';
|
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 }) {
|
export default function create({ root, moduleApi }) {
|
||||||
let state = null;
|
let state = null;
|
||||||
let feedOffline = false;
|
let feedOffline = false;
|
||||||
@@ -207,9 +246,78 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.el.className = `tm-card is-${kind}`;
|
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) {
|
function updateLtcCard(card, ltc) {
|
||||||
let mode;
|
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.
|
// 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` : '';
|
const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : '';
|
||||||
if (ltc.supported === false) {
|
if (ltc.supported === false) {
|
||||||
@@ -225,7 +333,12 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.stateEl.textContent = 'Waiting for ProPresenter…';
|
card.stateEl.textContent = 'Waiting for ProPresenter…';
|
||||||
} else if (ltc.receiving) {
|
} else if (ltc.receiving) {
|
||||||
mode = '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';
|
card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running';
|
||||||
} else if (ltc.status === 'stopped' || ltc.time) {
|
} else if (ltc.status === 'stopped' || ltc.time) {
|
||||||
// LTC ceased but we know where it stopped — hold the last frame.
|
// LTC ceased but we know where it stopped — hold the last frame.
|
||||||
@@ -285,6 +398,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
function render() {
|
function render() {
|
||||||
renderStatus();
|
renderStatus();
|
||||||
rebuildMenu();
|
rebuildMenu();
|
||||||
|
updateLtcAnchor();
|
||||||
|
|
||||||
let hint = '';
|
let hint = '';
|
||||||
if (state && !state.enabled && !state.ltc?.supported) {
|
if (state && !state.enabled && !state.ltc?.supported) {
|
||||||
@@ -434,6 +548,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
stream = null;
|
stream = null;
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
resizeObserver = null;
|
resizeObserver = null;
|
||||||
|
if (ltcRaf) {
|
||||||
|
cancelAnimationFrame(ltcRaf);
|
||||||
|
ltcRaf = 0;
|
||||||
|
}
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
onResize() {
|
onResize() {
|
||||||
|
|||||||
@@ -161,7 +161,15 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
|
|
||||||
evalTimer = setInterval(() => {
|
evalTimer = setInterval(() => {
|
||||||
if (stopped || !child) return;
|
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);
|
}, EVAL_MS);
|
||||||
evalTimer.unref?.();
|
evalTimer.unref?.();
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ module.exports = {
|
|||||||
if (!ltcEnabled) {
|
if (!ltcEnabled) {
|
||||||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
|
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,
|
supported: true,
|
||||||
source: tag,
|
source: tag,
|
||||||
time: src.time,
|
time: src.time,
|
||||||
@@ -140,16 +140,19 @@ module.exports = {
|
|||||||
status: src.state,
|
status: src.state,
|
||||||
fps: src.fps,
|
fps: src.fps,
|
||||||
df: src.df,
|
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: '',
|
note: '',
|
||||||
});
|
});
|
||||||
// The built-in listener while it has actual data …
|
// The built-in listener while it has actual data …
|
||||||
if (listenerLtc && !listenerLtc.error && listenerLtc.state !== 'nosignal') {
|
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 —
|
// … else a fresh remote reader (during migrations both may run —
|
||||||
// real data beats no data) …
|
// real data beats no data) …
|
||||||
if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) {
|
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
|
// … else a healthy-but-silent listener is believed over the indirect
|
||||||
// sources: no audio means the LTC really stopped.
|
// sources: no audio means the LTC really stopped.
|
||||||
|
|||||||
Reference in New Issue
Block a user