Timers: heuristically bind LTC when the stage layout omits the field

Verified against ProPresenter 21.4: the legacy stage-display protocol
(ptl 610 — the only version PP21 accepts; newer values get 'out of
date') omits stage objects it predates, so a timecode field labeled LTC
never appears in any layout's frame list, and no timecode messages were
observed while idle. Fallback: an unknown uid streaming timecode-shaped
text (a fourth :FF group; not any configured timer's uuid) is bound as
LTC after two advancing samples, and that binding survives layout
refreshes. pp-timers-mock gains --ltc-unlisted to exercise the path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-08-31 17:05:23 -04:00
co-authored by Claude Fable 5
parent 444f03938d
commit d5cb6b6cd9
3 changed files with 41 additions and 3 deletions
+3
View File
@@ -308,6 +308,9 @@ module.exports = {
port, port,
password: String(config.stagePassword || ''), password: String(config.stagePassword || ''),
log, log,
// The HTTP poller knows every configured timer's uuid — anything
// else streaming timecode-shaped text can be heuristically bound.
isTimerUid: (uid) => timerConfigs.has(uid),
onUpdate(update) { onUpdate(update) {
stageLtc = update; stageLtc = update;
broadcast(); broadcast();
+34 -2
View File
@@ -43,12 +43,17 @@ const STALE_MS = 2500;
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const ZERO_UID = '00000000-0000-0000-0000-000000000000';
/** Timecode-shaped text: a fourth (frames) group separates it from timers. */
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
/** /**
* @param {{host:string, port:number, password:string, log:Function, * @param {{host:string, port:number, password:string, log:Function,
* isTimerUid?:(uid:string)=>boolean,
* onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts * onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts
* @returns {{stop:()=>void}} * @returns {{stop:()=>void}}
*/ */
function createStageLtcClient({ host, port, password, log, onUpdate }) { function createStageLtcClient({ host, port, password, log, isTimerUid, onUpdate }) {
let sock = null; let sock = null;
let stopped = false; let stopped = false;
let reconnectTimer = null; let reconnectTimer = null;
@@ -56,6 +61,9 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) {
let staleTimer = null; let staleTimer = null;
let ltcUid = ''; // uid of the frame labeled LTC, once bound let ltcUid = ''; // uid of the frame labeled LTC, once bound
let boundBy = ''; // 'label' (layout frame) or 'heuristic' (sniffed)
let candidateUid = ''; // heuristic candidate awaiting a second, advancing sample
let candidateText = '';
let lastText = ''; let lastText = '';
let lastAdvance = 0; let lastAdvance = 0;
// Known-but-unused message types; anything else is logged once so a new // Known-but-unused message types; anything else is logged once so a new
@@ -119,6 +127,7 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) {
if (frame && frame.uid) { if (frame && frame.uid) {
if (ltcUid !== frame.uid) { if (ltcUid !== frame.uid) {
ltcUid = String(frame.uid); ltcUid = String(frame.uid);
boundBy = 'label';
log(`stage display: bound LTC to field "${frame.nme}" (${ltcUid}) in layout "${layout.nme || layout.uid}"`); log(`stage display: bound LTC to field "${frame.nme}" (${ltcUid}) in layout "${layout.nme || layout.uid}"`);
emit({ receiving: false }); emit({ receiving: false });
} }
@@ -128,8 +137,14 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) {
log(`stage display: layout "${layout.nme}" has an LTC field with no uid — it never streams; link it to a timecode/timer source`); log(`stage display: layout "${layout.nme}" has an LTC field with no uid — it never streams; link it to a timecode/timer source`);
} }
} }
// Verified on ProPresenter 21.4: the legacy protocol (the only version
// it accepts, ptl 610) omits stage objects it predates — a timecode
// field never appears in any layout's frame list. So a heuristic
// binding sniffed from the live stream must survive layout refreshes.
if (boundBy === 'heuristic' && ltcUid) return;
ltcUid = ''; ltcUid = '';
emit({ bound: false, time: '', note: 'No stage layout field labeled "LTC"' }); boundBy = '';
emit({ bound: false, time: '', note: 'No LTC field visible on the stage-display feed' });
} }
function handleMessage(obj) { function handleMessage(obj) {
@@ -166,6 +181,23 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) {
default: default:
break; break;
} }
// Heuristic fallback: with no LTC-labeled frame to bind to, an unknown
// uid streaming timecode-shaped text (a fourth :FF group — timers never
// have one) that is NOT a configured timer can only be the timecode
// field. Require two advancing samples before trusting it.
if (!ltcUid && obj.uid && obj.uid !== ZERO_UID && typeof obj.txt === 'string'
&& TC_SHAPE.test(obj.txt.trim()) && !(isTimerUid && isTimerUid(obj.uid))) {
if (candidateUid === obj.uid && candidateText !== obj.txt) {
ltcUid = String(obj.uid);
boundBy = 'heuristic';
log(`stage display: no LTC-labeled field in any layout, but ${obj.uid} streams timecode-shaped text (acn "${acn}") — binding it as LTC`);
// fall through to the normal update path below
} else {
candidateUid = String(obj.uid);
candidateText = obj.txt;
return;
}
}
// Anything carrying our bound uid is the LTC text, whatever its acn — // Anything carrying our bound uid is the LTC text, whatever its acn —
// ProPresenter 21's tag for timecode fields is undocumented. // ProPresenter 21's tag for timecode fields is undocumented.
if (ltcUid && obj.uid === ltcUid && typeof obj.txt === 'string') { if (ltcUid && obj.uid === ltcUid && typeof obj.txt === 'string') {
+4 -1
View File
@@ -46,6 +46,9 @@ const NO_LTC = args.includes('--no-ltc');
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : []))); const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12); const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12);
const NO_LTC_FIELD = args.includes('--no-ltc-field'); const NO_LTC_FIELD = args.includes('--no-ltc-field');
// Like real ProPresenter 21: the layout list omits the timecode field, but
// its updates still stream — exercises the module's heuristic binding.
const LTC_UNLISTED = args.includes('--ltc-unlisted');
const FPS = 30; const FPS = 30;
const startedAt = Date.now(); const startedAt = Date.now();
@@ -164,7 +167,7 @@ const STAGE_LAYOUT = {
nme: 'Timers', nme: 'Timers',
fme: [ fme: [
{ nme: 'Current Slide', typ: 1 }, { nme: 'Current Slide', typ: 1 },
...(NO_LTC_FIELD ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]), ...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]),
{ nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' }, { nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' },
], ],
}; };