From d5cb6b6cd9b5265cb1abc65760ef3bd91075899c Mon Sep 17 00:00:00 2001 From: Waters Church Date: Mon, 31 Aug 2026 15:32:43 -0400 Subject: [PATCH] Timers: heuristically bind LTC when the stage layout omits the field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/propresenter-timers/server.js | 3 +++ modules/propresenter-timers/stage-ws.js | 36 +++++++++++++++++++++++-- tools/pp-timers-mock.js | 5 +++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index e8947f5..f90aeaa 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -308,6 +308,9 @@ module.exports = { port, password: String(config.stagePassword || ''), 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) { stageLtc = update; broadcast(); diff --git a/modules/propresenter-timers/stage-ws.js b/modules/propresenter-timers/stage-ws.js index da29b1d..bebcdca 100644 --- a/modules/propresenter-timers/stage-ws.js +++ b/modules/propresenter-timers/stage-ws.js @@ -43,12 +43,17 @@ const STALE_MS = 2500; 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, + * isTimerUid?:(uid:string)=>boolean, * onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts * @returns {{stop:()=>void}} */ -function createStageLtcClient({ host, port, password, log, onUpdate }) { +function createStageLtcClient({ host, port, password, log, isTimerUid, onUpdate }) { let sock = null; let stopped = false; let reconnectTimer = null; @@ -56,6 +61,9 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) { let staleTimer = null; 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 lastAdvance = 0; // 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 (ltcUid !== frame.uid) { ltcUid = String(frame.uid); + boundBy = 'label'; log(`stage display: bound LTC to field "${frame.nme}" (${ltcUid}) in layout "${layout.nme || layout.uid}"`); 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`); } } + // 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 = ''; - 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) { @@ -166,6 +181,23 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) { default: 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 — // ProPresenter 21's tag for timecode fields is undocumented. if (ltcUid && obj.uid === ltcUid && typeof obj.txt === 'string') { diff --git a/tools/pp-timers-mock.js b/tools/pp-timers-mock.js index 42fa30a..c687e56 100644 --- a/tools/pp-timers-mock.js +++ b/tools/pp-timers-mock.js @@ -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 STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12); 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 startedAt = Date.now(); @@ -164,7 +167,7 @@ const STAGE_LAYOUT = { nme: 'Timers', fme: [ { 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' }, ], };