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:
26
dock/clock/index.html
Normal file
26
dock/clock/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Vanguard Clock</title>
|
||||
<style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<script src="vgclock.js"></script>
|
||||
<script>
|
||||
// OBS browser source for the overscan band: size it to the band (width = canvas width, height = band).
|
||||
// It draws the server-synced strip + readable clock at the display rate. ?text=0 hides the readable clock.
|
||||
(async () => {
|
||||
const c = document.getElementById('c'), ctx = c.getContext('2d', { alpha: false });
|
||||
const q = new URLSearchParams(location.search);
|
||||
const showText = q.get('text') !== '0';
|
||||
function fit() { c.width = Math.max(160, window.innerWidth); const { band } = VGClock.geometry(c.width); c.height = Math.max(band, window.innerHeight || band); }
|
||||
fit(); window.addEventListener('resize', fit);
|
||||
await VGClock.resync('/time');
|
||||
setInterval(() => VGClock.resync('/time'), 60000);
|
||||
(function frame() { VGClock.drawBand(ctx, c.width, 0, { text: showText }); requestAnimationFrame(frame); })();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
56
dock/clock/vgclock.js
Normal file
56
dock/clock/vgclock.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Vanguard timecode strip — browser side. Shared by the OBS browser source (/dock/clock/) and the live portal.
|
||||
// Format: see bin/vgstrip.py. cell = W/160; band = round_even(cell); 64 cells from x = cell:
|
||||
// [1,0,1,0] · 48-bit server ms (MSB first) · CRC-8 (poly 0x07) · [0,1,0,1]; 1 = white, 0 = black.
|
||||
(function (global) {
|
||||
function crc8(bytes) { let c = 0; for (const b of bytes) { c ^= b; for (let i = 0; i < 8; i++) c = (c & 0x80) ? ((c << 1) ^ 0x07) & 0xff : (c << 1) & 0xff; } return c; }
|
||||
function bitsFor(ms) {
|
||||
const bytes = []; let v = BigInt(ms); for (let i = 5; i >= 0; i--) { bytes[i] = Number(v & 0xffn); v >>= 8n; }
|
||||
const bits = [1, 0, 1, 0];
|
||||
for (const b of bytes) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1);
|
||||
const c = crc8(bytes); for (let i = 7; i >= 0; i--) bits.push((c >> i) & 1);
|
||||
return bits.concat([0, 1, 0, 1]);
|
||||
}
|
||||
function geometry(w) { const cell = w / 160; let band = Math.round(cell); band += band % 2; return { cell, band }; }
|
||||
|
||||
// ---- server clock offset: offset = serverNow - localNow, from the lowest-RTT of several /time samples
|
||||
const sync = { offset: 0, rtt: null, ok: false, samples: 0 };
|
||||
async function sampleOnce(url) {
|
||||
const t0 = performance.now(), wall0 = Date.now();
|
||||
const r = await fetch(url, { cache: 'no-store', credentials: 'same-origin' }); const j = await r.json();
|
||||
const t1 = performance.now(); const rtt = t1 - t0;
|
||||
const localMid = wall0 + rtt / 2;
|
||||
return { offset: j.now_ms - localMid, rtt };
|
||||
}
|
||||
async function resync(url, n = 7) {
|
||||
let best = null;
|
||||
for (let i = 0; i < n; i++) { try { const s = await sampleOnce(url); if (!best || s.rtt < best.rtt) best = s; } catch (e) {} }
|
||||
if (best) { sync.offset = best.offset; sync.rtt = best.rtt; sync.ok = true; sync.samples++; }
|
||||
return sync;
|
||||
}
|
||||
function serverNow() { return Date.now() + sync.offset; }
|
||||
|
||||
// ---- draw the band into a 2D context: strip + readable clock. (w = canvas width, y0 = band top)
|
||||
function drawBand(ctx, w, y0, opts = {}) {
|
||||
const { cell, band } = geometry(w);
|
||||
const ms = Math.round(serverNow());
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(0, y0, w, band);
|
||||
const bits = bitsFor(ms);
|
||||
const pad = Math.max(1, Math.floor(cell * 0.12));
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
if (!bits[i]) continue;
|
||||
const x0 = Math.round(cell * (1 + i)), x1 = Math.round(cell * (2 + i));
|
||||
ctx.fillStyle = '#fff'; ctx.fillRect(x0 + pad, y0 + pad, x1 - x0 - 2 * pad, band - 2 * pad);
|
||||
}
|
||||
if (opts.text !== false) {
|
||||
const d = new Date(ms);
|
||||
const tz = opts.tz || 'America/New_York';
|
||||
const hms = d.toLocaleTimeString('en-US', { hour12: false, timeZone: tz });
|
||||
const txt = `${hms}.${String(ms % 1000).padStart(3, '0')} ${opts.label || 'SRV'}${sync.ok ? '' : ' (unsynced)'}`;
|
||||
ctx.fillStyle = sync.ok ? '#9f9' : '#f66'; ctx.font = `${Math.max(8, band - 2 * pad)}px ui-monospace, monospace`; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(txt, Math.round(cell * (CELLS + 2.5)), y0 + band / 2);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
const CELLS = 64;
|
||||
global.VGClock = { crc8, bitsFor, geometry, resync, serverNow, drawBand, sync, CELLS };
|
||||
})(window);
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user