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

122
streamer/windows/fakes.ts Normal file
View File

@@ -0,0 +1,122 @@
/** Test doubles for the desktop helper and tosu. */
import type { TosuFeed, TosuFrame } from '../tosu';
import type { CaptureOptions, Desktop, OcrLine, SessionInfo, WindowInfo } from './desktop';
export function windowInfo(overrides: Partial<WindowInfo> = {}): WindowInfo {
return {
hwnd: 100,
pid: 10,
title: 'osu!',
className: 'WindowsForms10.Window.8.app.0.1',
exe: 'C:\\vash\\osu\\osu!.exe',
visible: true,
minimized: false,
x: 0,
y: 0,
width: 1296,
height: 959,
clientX: 8,
clientY: 31,
clientWidth: 1280,
clientHeight: 920,
...overrides
};
}
export class FakeDesktop implements Desktop {
sessionInfo: SessionInfo = {
sessionId: 1,
consoleSessionId: 1,
inputDesktop: 'Default',
screenWidth: 1920,
screenHeight: 1080,
idleMs: 0
};
running: { pid: number; path: string }[] = [];
windowList: WindowInfo[] = [];
ocrLines: OcrLine[] = [];
calls: { cmd: string; args: unknown[] }[] = [];
focusResult = true;
private note(cmd: string, ...args: unknown[]) {
this.calls.push({ cmd, args });
}
called(cmd: string) {
return this.calls.filter((call) => call.cmd === cmd);
}
async session() {
return this.sessionInfo;
}
async windows(under: string) {
this.note('windows', under);
return this.windowList;
}
async window(hwnd: number) {
return this.windowList.find((window) => window.hwnd === hwnd) ?? null;
}
async processes(under: string) {
return this.running.filter((process) =>
process.path.toLowerCase().startsWith(under.toLowerCase())
);
}
async kill(pids: number[]) {
this.note('kill', pids);
this.running = this.running.filter((process) => !pids.includes(process.pid));
return pids.length;
}
async close(pid: number) {
this.note('close', pid);
this.running = this.running.filter((process) => process.pid !== pid);
this.windowList = this.windowList.filter((window) => window.pid !== pid);
return 1;
}
async focus(hwnd: number) {
this.note('focus', hwnd);
return this.focusResult;
}
async move(hwnd: number, x: number, y: number) {
this.note('move', hwnd, x, y);
return true;
}
async setTitle(hwnd: number, title: string) {
this.note('title', hwnd, title);
const window = this.windowList.find((each) => each.hwnd === hwnd);
if (window) window.title = title;
return true;
}
async capture(options: CaptureOptions) {
this.note('capture', options);
const scale = options.scale ?? 1;
return { width: Math.round(options.width * scale), height: Math.round(options.height * scale) };
}
async ocr(path: string) {
this.note('ocr', path);
return this.ocrLines;
}
async ocrReady() {
return { language: 'en-US', maxDimension: 2600 };
}
async click(x: number, y: number) {
this.note('click', x, y);
return true;
}
async type(text: string) {
return text.length;
}
async key() {}
async stayAwake() {
return true;
}
stop() {}
}
/** Stands in for TosuFeed: whatever frame the test sets is the fresh one. */
export function fakeTosu(frame: TosuFrame | null = null) {
const feed = { frame, fresh: () => feed.frame };
return feed as unknown as TosuFeed & { frame: TosuFrame | null };
}
export function tourneyFrame(left: string, right: string): TosuFrame {
return { ipcState: 1, beatmapId: null, progress: null, teams: { left, right }, clients: [] };
}