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

@@ -0,0 +1,382 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile, mkdir, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'bun';
import { Obs, obsAuthentication } from '../obs';
import { ObsScene, windowSetting, type SceneTarget } from './obs-control';
import { encoderFromLog, parseIni, setIni, stringifyIni, writeObsSettings } from './obs-files';
interface Item {
sceneItemId: number;
sourceName: string;
sceneItemIndex: number;
sceneItemEnabled: boolean;
transform: Record<string, unknown>;
}
/** Just enough obs-websocket 5 for the requests ObsScene makes, with an in-memory scene. */
function fakeObs(password: string, options: { ignore?: string[] } = {}) {
const model = {
scenes: ['Scene'],
program: 'Scene',
inputs: new Map<string, { kind: string; settings: Record<string, unknown> }>([
['Desktop Audio', { kind: 'wasapi_output_capture', settings: {} }]
]),
items: new Map<string, Item[]>([['Scene', []]]),
muted: new Set<string>(),
service: {
streamServiceType: 'rtmp_common',
streamServiceSettings: {} as Record<string, unknown>
},
sources: new Map<string, { width: number; height: number }>(),
requests: [] as string[],
nextId: 1
};
const salt = 'salt';
const challenge = 'challenge';
const handle = (type: string, data: Record<string, unknown>): Record<string, unknown> | null => {
const sceneItems = (scene: string) => model.items.get(scene) ?? [];
switch (type) {
case 'GetSceneList':
return {
scenes: model.scenes.map((sceneName) => ({ sceneName })),
currentProgramSceneName: model.program
};
case 'CreateScene':
model.scenes.push(String(data.sceneName));
model.items.set(String(data.sceneName), []);
return {};
case 'SetCurrentProgramScene':
model.program = String(data.sceneName);
return {};
case 'GetInputList':
return { inputs: [...model.inputs.keys()].map((inputName) => ({ inputName })) };
case 'CreateInput': {
const name = String(data.inputName);
model.inputs.set(name, {
kind: String(data.inputKind),
settings: { ...(data.inputSettings as object) }
});
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: name,
sceneItemIndex: items.length,
sceneItemEnabled: data.sceneItemEnabled !== false,
transform: {}
});
return {};
}
case 'GetInputSettings': {
const input = model.inputs.get(String(data.inputName))!;
return { inputKind: input.kind, inputSettings: input.settings };
}
case 'SetInputSettings':
Object.assign(model.inputs.get(String(data.inputName))!.settings, data.inputSettings);
return {};
case 'GetSceneItemList':
return {
sceneItems: sceneItems(String(data.sceneName)).map((item) => ({
sceneItemId: item.sceneItemId,
sourceName: item.sourceName,
sceneItemIndex: item.sceneItemIndex,
sceneItemEnabled: item.sceneItemEnabled
}))
};
case 'CreateSceneItem': {
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: String(data.sourceName),
sceneItemIndex: items.length,
sceneItemEnabled: true,
transform: {}
});
return {};
}
case 'SetSceneItemIndex': {
const items = sceneItems(String(data.sceneName));
const item = items.find((each) => each.sceneItemId === data.sceneItemId)!;
items.splice(items.indexOf(item), 1);
items.splice(Number(data.sceneItemIndex), 0, item);
items.forEach((each, index) => (each.sceneItemIndex = index));
return {};
}
case 'SetSceneItemEnabled':
sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!.sceneItemEnabled = data.sceneItemEnabled === true;
return {};
case 'GetSceneItemTransform': {
const item = sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!;
const size = model.sources.get(item.sourceName) ?? { width: 0, height: 0 };
return {
sceneItemTransform: {
sourceWidth: size.width,
sourceHeight: size.height,
cropBottom: 0,
boundsType: 'OBS_BOUNDS_NONE',
boundsWidth: 0,
boundsHeight: 0,
...item.transform
}
};
}
case 'SetSceneItemTransform':
Object.assign(
sceneItems(String(data.sceneName)).find((each) => each.sceneItemId === data.sceneItemId)!
.transform,
data.sceneItemTransform
);
return {};
case 'GetSpecialInputs':
return { desktop1: 'Desktop Audio', desktop2: null, mic1: null };
case 'SetInputMute':
model.muted.add(String(data.inputName));
return {};
case 'GetStreamServiceSettings':
return model.service;
case 'SetStreamServiceSettings':
model.service = data as typeof model.service;
return {};
default:
return null;
}
};
const server: Server<{ identified: boolean }> = Bun.serve<{ identified: boolean }>({
port: 0,
fetch(request, server) {
return server.upgrade(request, { data: { identified: false } })
? undefined
: new Response('no');
},
websocket: {
open(ws) {
ws.send(
JSON.stringify({ op: 0, d: { rpcVersion: 1, authentication: { salt, challenge } } })
);
},
message(ws, raw) {
const { op, d } = JSON.parse(String(raw));
if (op === 1) {
if (d.authentication !== obsAuthentication(password, salt, challenge)) {
ws.close(4009, 'auth');
return;
}
ws.data.identified = true;
ws.send(JSON.stringify({ op: 2, d: { negotiatedRpcVersion: 1 } }));
return;
}
if (op !== 6 || !ws.data.identified) return;
model.requests.push(d.requestType);
if (options.ignore?.includes(d.requestType)) return;
const result = handle(d.requestType, d.requestData ?? {});
ws.send(
JSON.stringify({
op: 7,
d: {
requestType: d.requestType,
requestId: d.requestId,
requestStatus: result
? { result: true, code: 100 }
: { result: false, code: 204, comment: 'unknown' },
responseData: result ?? undefined
}
})
);
}
}
});
return { server, model, url: `ws://127.0.0.1:${server.port}` };
}
const servers: Server<unknown>[] = [];
afterEach(() => {
for (const server of servers.splice(0)) server.stop(true);
});
const target = (overrides: Partial<SceneTarget> = {}): SceneTarget => ({
canvas: { width: 1920, height: 1080 },
fps: 60,
overlayUrl: 'https://esports.vash.software/broadcast/abc/overlay',
capture: { title: 'Vash osu!tourney (prod)', className: 'WindowsForms10', exe: 'osu!.exe' },
showGameplay: false,
ingest: { server: 'rtmp://127.0.0.1:47103/live', key: 'k' },
...overrides
});
describe('Obs client', () => {
test('reports a wrong websocket password clearly', async () => {
const fake = fakeObs('right');
servers.push(fake.server);
const obs = new Obs(fake.url, 'wrong', 2000);
await expect(obs.connect()).rejects.toThrow('OBS rejected the websocket password');
});
test('connected means authenticated, and a hung request times out', async () => {
const fake = fakeObs('pw', { ignore: ['GetStreamStatus'] });
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw', 300);
expect(obs.connected).toBe(false);
await obs.connect();
expect(obs.connected).toBe(true);
await expect(obs.request('GetStreamStatus')).rejects.toThrow('OBS GetStreamStatus timed out');
await Bun.sleep(400);
obs.close();
expect(obs.connected).toBe(false);
});
});
describe('ObsScene', () => {
test('builds the scene once, keeps the overlay on top, and changes nothing on a second pass', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
const items = fake.model.items.get('Vash')!;
expect(fake.model.program).toBe('Vash');
expect(items.map((item) => item.sourceName)).toEqual(['Gameplay', 'Overlay']);
expect(items[0].sceneItemEnabled).toBe(false);
expect(fake.model.inputs.get('Gameplay')!.settings).toMatchObject({
window: 'Vash osu!tourney (prod):WindowsForms10:osu!.exe',
priority: 1,
capture_audio: true
});
expect(fake.model.inputs.get('Overlay')!.settings).toMatchObject({
width: 1920,
height: 1080,
fps: 60
});
expect(fake.model.muted.has('Desktop Audio')).toBe(true);
expect(fake.model.service.streamServiceSettings).toMatchObject({
server: 'rtmp://127.0.0.1:47103/live',
key: 'k'
});
fake.model.requests.length = 0;
await scene.reconcile(target());
expect(fake.model.requests.filter((type) => /^(Create|Set|Remove)/.test(type))).toEqual([]);
// Someone reorders the scene: a changed target re-checks everything.
scene.reset();
const overlay = items.find((item) => item.sourceName === 'Overlay')!;
items.splice(items.indexOf(overlay), 1);
items.unshift(overlay);
items.forEach((item, index) => (item.sceneItemIndex = index));
await scene.reconcile(target());
expect(fake.model.items.get('Vash')!.at(-1)!.sourceName).toBe('Overlay');
obs.close();
});
test('crops the control panel off the capture and shows it only while spectating', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
// At 125 % display scaling the 1280×920 window captures as 1600×1150.
fake.model.sources.set('Gameplay', { width: 1600, height: 1150 });
await scene.reconcile(target({ showGameplay: true }));
const gameplay = fake.model.items.get('Vash')!.find((item) => item.sourceName === 'Gameplay')!;
expect(gameplay.sceneItemEnabled).toBe(true);
expect(gameplay.transform).toMatchObject({
cropBottom: 250,
boundsType: 'OBS_BOUNDS_SCALE_INNER',
boundsWidth: 1920,
boundsHeight: 1080
});
await scene.reconcile(target({ showGameplay: false }));
expect(gameplay.sceneItemEnabled).toBe(false);
obs.close();
});
});
describe('OBS files', () => {
let dir: string;
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
test('escapes window settings like libobs', () => {
expect(windowSetting({ title: 'a:b#c', className: 'X', exe: 'osu!.exe' })).toBe(
'a#3Ab#22c:X:osu!.exe'
);
});
test('ini round trip keeps unknown keys', () => {
const ini = parseIni('\uFEFF[General]\r\nName=Old\r\nKeep=1\r\n[Video]\r\nBaseCX=1280\r\n');
setIni(ini, { General: { Name: 'Vash' }, Audio: { SampleRate: 48000 } });
expect(stringifyIni(ini)).toBe(
'[General]\r\nName=Vash\r\nKeep=1\r\n\r\n[Video]\r\nBaseCX=1280\r\n\r\n[Audio]\r\nSampleRate=48000\r\n'
);
});
test('writes profile, websocket and first-run settings, and clears the crash sentinel', async () => {
dir = await mkdtemp(join(tmpdir(), 'vash-obs-'));
await mkdir(join(dir, '.sentinel'), { recursive: true });
await writeFile(join(dir, '.sentinel', 'run_123'), '');
await mkdir(join(dir, 'basic', 'profiles', 'Vash'), { recursive: true });
await writeFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), '[Hotkeys]\r\nX=1\r\n');
await writeObsSettings(dir, {
websocketPort: 47101,
websocketPassword: 'pw',
width: 1920,
height: 1080,
fps: 60,
bitrateKbps: 6000,
encoder: 'amd',
ingestUrl: 'rtmp://127.0.0.1:47103/live',
ingestKey: 'key'
});
const profile = parseIni(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), 'utf8')
);
expect(profile.get('SimpleOutput')?.get('StreamEncoder')).toBe('amd');
expect(profile.get('Video')?.get('FPSInt')).toBe('60');
expect(profile.get('Hotkeys')?.get('X')).toBe('1');
const user = parseIni(await readFile(join(dir, 'user.ini'), 'utf8'));
expect(user.get('General')?.get('FirstRun')).toBe('true');
expect(user.get('Basic')?.get('Profile')).toBe('Vash');
const websocket = JSON.parse(
await readFile(join(dir, 'plugin_config', 'obs-websocket', 'config.json'), 'utf8')
);
expect(websocket).toMatchObject({
server_enabled: true,
server_port: 47101,
auth_required: true,
server_password: 'pw',
first_load: false
});
const service = JSON.parse(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'service.json'), 'utf8')
);
expect(service).toEqual({
type: 'rtmp_custom',
settings: {
server: 'rtmp://127.0.0.1:47103/live',
key: 'key',
use_auth: false,
bwtest: false
}
});
expect(await readdir(join(dir, '.sentinel'))).toEqual([]);
});
test('reads which encoder OBS started last from its log', () => {
expect(
encoderFromLog("12:00:00.000: [x264 encoder: 'simple_video_stream'] preset: veryfast")
).toBe('x264');
expect(
encoderFromLog(
"[x264 encoder: 'simple_video_stream'] preset: veryfast\n[texture-amf-h264: 'simple_video_stream'] settings:"
)
).toBe('amd');
expect(encoderFromLog('no encoders here')).toBeNull();
});
});