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:
co-authored by
Claude Fable 5
parent
444f03938d
commit
d5cb6b6cd9
@@ -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();
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user