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:
382
streamer/windows/obs.test.ts
Normal file
382
streamer/windows/obs.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user