Phase 12: frame-accurate timecode via an overscan band — vgstrip encoder/decoder, sweep writes tmcd + creation_time + sidecar, dumpd /time, Caddy /time routes, MediaMTX microsecond names, /dock/clock/ page, dock band setup via obs-websocket, Go Live band composite + toggle, portal crops the band

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:07:56 -05:00
parent 89841c3d2a
commit 3fcb8fbe99
18 changed files with 515 additions and 25 deletions

View File

@@ -58,6 +58,17 @@
<button id="save">Save settings</button>
</details>
<details id="tcband">
<summary>Timecode band (frame-accurate multicam sync)</summary>
<div class="who" style="margin-top:6px">Adds a thin "overscan" band below your canvas with the server clock encoded in it.
The server decodes it after each stream so editors get a frame-accurate timecode track; the live portal and
editors crop the band, Jellyfin shows it as a thin strip at the bottom. OBS must not be streaming or recording.</div>
<div class="kv" id="tc_state">canvas: — · band: —</div>
<button class="small" id="tc_add">Add band + clock source</button>
<button class="small" id="tc_scene">Add clock to current scene</button>
<button class="small" id="tc_remove">Remove band</button>
</details>
<details id="manual">
<summary>Manual OBS setup (endpoints + token)</summary>
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
@@ -166,6 +177,78 @@ function obs(c){
});
}
// ---- timecode band (obs-websocket) -------------------------------------------------------------------
const CLOCK_INPUT = "Vanguard Clock";
const bandFor = w => { let b = Math.round(w / 160); return b + (b % 2); };
async function tcState() {
try {
const o = await obs(cfg());
const v = await o.call("GetVideoSettings");
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
const has = inputs.inputs.some(i => i.inputName === CLOCK_INPUT);
const band = bandFor(v.baseWidth);
const hasBand = (v.baseHeight % 2 === 0) && [720, 1080, 1440, 2160].some(h => v.baseHeight === h + bandFor(v.baseWidth));
o.close();
$("tc_state").textContent = `canvas: ${v.baseWidth}×${v.baseHeight} (output ${v.outputWidth}×${v.outputHeight}) · band: ${hasBand ? band + " px present" : "not set"} · clock source: ${has ? "present" : "absent"}`;
return { v, has, band, hasBand };
} catch (e) { $("tc_state").textContent = "OBS not reachable: " + e.message; return null; }
}
async function notStreaming(o) {
const s = await o.call("GetStreamStatus"), r = await o.call("GetRecordStatus");
if (s.outputActive || r.outputActive) throw new Error("stop streaming/recording first — the canvas size cannot change while an output is active");
}
async function ensureClockInput(o, width, band, scene) {
const url = `${location.origin}/dock/clock/`;
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
const settings = { url, width, height: band, fps_custom: true, fps: 60, shutdown: false, restart_when_active: false, reroute_audio: false };
if (!inputs.inputs.some(i => i.inputName === CLOCK_INPUT)) {
await o.call("CreateInput", { sceneName: scene, inputName: CLOCK_INPUT, inputKind: "browser_source", inputSettings: settings, sceneItemEnabled: true });
} else {
await o.call("SetInputSettings", { inputName: CLOCK_INPUT, inputSettings: settings, overlay: true });
const items = await o.call("GetSceneItemList", { sceneName: scene });
if (!items.sceneItems.some(i => i.sourceName === CLOCK_INPUT)) await o.call("CreateSceneItem", { sceneName: scene, sourceName: CLOCK_INPUT, sceneItemEnabled: true });
}
const items = await o.call("GetSceneItemList", { sceneName: scene });
const it = items.sceneItems.find(i => i.sourceName === CLOCK_INPUT);
if (it) {
const v = await o.call("GetVideoSettings");
await o.call("SetSceneItemTransform", { sceneName: scene, sceneItemId: it.sceneItemId,
sceneItemTransform: { positionX: 0, positionY: v.baseHeight - band, scaleX: 1, scaleY: 1, boundsType: "OBS_BOUNDS_NONE", cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0 } });
await o.call("SetSceneItemLocked", { sceneName: scene, sceneItemId: it.sceneItemId, sceneItemLocked: true });
}
}
$("tc_add").onclick = async () => {
try {
const o = await obs(cfg()); await notStreaming(o);
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
const scale = v.outputHeight / v.baseHeight;
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH + band, outputWidth: v.outputWidth, outputHeight: Math.round((gameH + band) * scale / 2) * 2 });
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
await ensureClockInput(o, v.baseWidth, band, scene); o.close();
status(`band added: canvas ${v.baseWidth}×${gameH + band}, clock source in "${scene}"`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tc_scene").onclick = async () => {
try {
const o = await obs(cfg()); const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
await ensureClockInput(o, v.baseWidth, band, scene); o.close(); status(`clock source placed in "${scene}"`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tc_remove").onclick = async () => {
try {
const o = await obs(cfg()); await notStreaming(o);
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
try { await o.call("RemoveInput", { inputName: CLOCK_INPUT }); } catch {}
const scale = v.outputHeight / v.baseHeight;
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH, outputWidth: v.outputWidth, outputHeight: Math.round(gameH * scale / 2) * 2 });
o.close(); status(`band removed: canvas ${v.baseWidth}×${gameH}`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tcband").addEventListener("toggle", () => { if ($("tcband").open) tcState(); });
// ---- actions ---------------------------------------------------------------
$("golive").onclick = async () => {
const c = cfg(), title = buildTitle($("title").value);