Timers 1.4.2: ProPresenter digits no longer flick back a second

ProPresenter reports running timers as truncated whole seconds, and the
poller re-anchored the tiles' local count at the bottom of that second on
every 500 ms poll — so the displayed digit ran up to a second ahead and
got yanked back a step whenever a fresh poll sampled the next flip later
than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about
once a second.

phase.js turns the quantized reports into one stable anchor per timer:
the only information in a poll is a FLIP (the report stepping one second
in the direction of travel), which dates the true boundary to the
midpoint of the two polls that straddle it. While reports stay consistent
with the anchor (±450 ms) the anchor holds, so tiles count along one
steady clock; a report the anchor can't explain (reset, seek, resume,
accumulated drift) re-anchors. Timers carry the anchor as a per-timer
asOf the client extrapolates from (optional in the provider contract,
documented in the guide); the client additionally refuses to tick a
running card backwards over sub-1.5 s jitter.

phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated
reports, jittered polls — through the tracker and the client's render
math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing
dwell is trunc's honest behavior), anchors stay within tolerance of the
true value, resets snap, pause/resume re-locks, 0.2% clock drift is
absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms
DOM sampling across four running cards — every step ±1 in the card's own
direction, dwells 998–1002 ms, zero regressions; upstream kill/restart
still recovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-27 08:46:50 -04:00
co-authored by Claude Fable 5
parent 7a736b3db1
commit 2f4c357bf5
6 changed files with 369 additions and 8 deletions
+30 -5
View File
@@ -82,6 +82,7 @@ const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener');
const { createTimerPhase } = require('./phase');
const manifest = require('./module.json');
/** This module's id — left out of provider discovery (we don't consume ourselves). */
@@ -209,6 +210,7 @@ function sanitizeTimer(t) {
label: String(t.label || id).slice(0, 120),
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
state: STATES.has(t.state) ? t.state : 'stopped',
asOf: numOrNull(t.asOf),
startedAt: numOrNull(t.startedAt),
elapsedMs: numOrNull(t.elapsedMs),
remainingMs: numOrNull(t.remainingMs),
@@ -250,6 +252,8 @@ function createProPresenterSource({ config, log }) {
let updatedAt = 0;
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
let timerConfigs = new Map();
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
const phases = new Map();
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
@@ -325,9 +329,21 @@ function createProPresenterSource({ config, log }) {
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
state = atStart ? 'idle' : 'paused';
}
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null;
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null;
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null;
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
// local count at the bottom of that second every poll made the digits run
// ahead and flick back a step about once a second — phase.js turns the
// reports into one stable anchor { valueMs, asOf } the tiles count from.
let phase = phases.get(uuid);
if (!phase) {
phase = createTimerPhase();
phases.set(uuid, phase);
}
const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now });
const v = anchored.valueMs;
const asOf = anchored.asOf;
const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null;
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
return {
@@ -335,6 +351,7 @@ function createProPresenterSource({ config, log }) {
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
kind,
state,
asOf,
startedAt,
elapsedMs,
remainingMs,
@@ -367,6 +384,9 @@ function createProPresenterSource({ config, log }) {
// Timers reported live but missing from the (possibly stale) config
// list still deserve a card — type unknown until the slow lane catches up.
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
for (const uuid of phases.keys()) {
if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter
}
return out;
}
@@ -992,12 +1012,17 @@ module.exports = {
for (const t of v.timers) {
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
if (t.state === 'running' && v.updatedAt > 0) {
const dt = Math.max(0, now - v.updatedAt);
// A timer measured at its own instant (asOf — a phase-locked
// ProPresenter value) re-anchors from there; the rest from the
// source snapshot. Consumers see plain contract values as of `now`.
const at = Number(out.asOf) || v.updatedAt;
if (t.state === 'running' && at > 0) {
const dt = Math.max(0, now - at);
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
}
delete out.asOf;
timers.push(out);
}
}