feat: add native Windows broadcaster and live score modules

- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

View File

@@ -15,8 +15,9 @@
* Build single binaries with `bun run streamer:build`.
*/
import { parseArgs } from 'node:util';
import { VashLink, type Follow } from './link';
import { Obs } from './obs';
import { parseTosu, type TosuFrame } from './tosu';
import { TosuFeed } from './tosu';
import { Tourney } from './tourney';
const { values: args } = parseArgs({
@@ -116,8 +117,6 @@ async function connectObs() {
// ── Vash ────────────────────────────────────────────────────────────────
let vash: WebSocket | null = null;
let vashAttempts = 0;
let current: { scene: string; matchId: string | null; lobbyId: number | null } = {
scene: 'idle',
matchId: null,
@@ -135,38 +134,16 @@ let teamSize = 1;
let selectedLobby: number | null = null;
let lastJoinNotice = 0;
function connectVash() {
const url = `${server.replace(/^http/, 'ws')}/ws/agent`;
const socket = new WebSocket(url);
vash = socket;
socket.onopen = () =>
socket.send(JSON.stringify({ type: 'hello', channelId: channel, token: args.token }));
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
if (message.type === 'welcome') {
vashAttempts = 0;
say(`Connected to ${server} as channel "${message.channel}". Overlay: ${overlayUrl}`);
} else if (message.type === 'follow') void follow(message);
};
socket.onclose = (event) => {
if (event.code === 4001) {
say('Vash refused the channel or token. Check them on the Broadcast page.');
process.exit(1);
}
const wait = Math.min(30_000, 1000 * 2 ** vashAttempts++);
say(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`);
setTimeout(connectVash, wait);
};
}
const link = new VashLink({
server,
channel,
token: args.token!,
log: say,
onFollow: (next) => void follow(next),
onRejected: () => process.exit(1)
});
async function follow(next: {
scene: string;
matchId: string | null;
lobbyId: number | null;
title: string | null;
roomName?: string | null;
teamSize?: number | null;
}) {
async function follow(next: Follow) {
const changedMatch = next.matchId !== current.matchId;
const changedLobby = next.lobbyId != null && next.lobbyId !== current.lobbyId;
if (changedMatch)
@@ -217,48 +194,8 @@ if (tourney && args['auto-join']) {
// ── tosu ────────────────────────────────────────────────────────────────
let tosuAttempts = 0;
let lastSent = 0;
let lastKey = '';
let pending: TosuFrame | null = null;
const tosu = new TosuFeed(args.tosu!, (frame) => link.sendScores(frame), say);
function connectTosu() {
const socket = new WebSocket(args.tosu!);
socket.onopen = () => {
tosuAttempts = 0;
say('tosu connected');
};
socket.onmessage = (event) => {
const frame = parseTosu(JSON.parse(String(event.data)));
if (!frame || !frame.clients.length) return;
pending = frame;
flush();
};
socket.onclose = () => {
// tosu is often started after the agent; keep trying quietly.
const wait = Math.min(15_000, 1000 * 2 ** tosuAttempts++);
if (tosuAttempts === 3)
say(`tosu is not running at ${args.tosu}; live scores will start when it is.`);
setTimeout(connectTosu, wait);
};
}
/** At most four updates a second, and only when something changed. */
function flush() {
if (!pending || vash?.readyState !== WebSocket.OPEN) return;
const wait = 250 - (Date.now() - lastSent);
if (wait > 0) {
setTimeout(flush, wait);
return;
}
const frame = pending;
pending = null;
const key = frame.clients.map((client) => `${client.osuId}:${client.score}`).join('|');
if (key === lastKey) return;
lastKey = key;
lastSent = Date.now();
vash.send(JSON.stringify({ type: 'tosu', progress: frame.progress, clients: frame.clients }));
}
connectVash();
connectTosu();
say(`Overlay: ${overlayUrl}`);
link.start();
tosu.start();