Files
esports/streamer/tourney.ts
Stan Runge 54444a77f0 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>
2026-10-18 22:02:25 +00:00

162 lines
4.9 KiB
TypeScript

/** Launches osu!tourney under Wine and selects a room from its control panel. */
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { Subprocess } from 'bun';
interface Word {
line: string;
text: string;
x: number;
y: number;
w: number;
h: number;
}
export const plain = (value: string) =>
value
.normalize('NFKD')
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, '');
/** OCR is imperfect, so require both team names on one control-panel line. */
export function findRoom(tsv: string, roomName: string): { x: number; y: number } | null {
const names = [...roomName.matchAll(/\(([^)]+)\)/g)].map((match) => plain(match[1]));
if (names.length !== 2 || names.some((name) => !name)) return null;
const words: Word[] = tsv
.split('\n')
.slice(1)
.map((row) => row.split('\t'))
.filter((row) => row.length >= 12 && row[0] === '5' && Number(row[10]) >= 30)
.map((row) => ({
line: row.slice(1, 5).join(':'),
text: row.slice(11).join(''),
x: Number(row[6]),
y: Number(row[7]),
w: Number(row[8]),
h: Number(row[9])
}));
const lines = new Map<string, Word[]>();
for (const word of words) lines.set(word.line, [...(lines.get(word.line) ?? []), word]);
for (const line of lines.values()) {
const normalized = plain(line.map((word) => word.text).join(''));
if (!normalized.includes(`vash${names[0]}vs${names[1]}`)) continue;
const left = Math.min(...line.map((word) => word.x));
const right = Math.max(...line.map((word) => word.x + word.w));
const top = Math.min(...line.map((word) => word.y));
const bottom = Math.max(...line.map((word) => word.y + word.h));
return { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) };
}
return null;
}
/** tournament.cfg with our keys replaced and anything else osu! wrote kept. */
export function tournamentConfig(
existing: string,
options: { teamSize: number; height: number; acronym?: string }
) {
const lines = existing
.split(/\r?\n/)
.filter((line) => !/^\s*(TeamSize|acronym|Height)\s*=/i.test(line));
return [
...lines.filter(Boolean),
`TeamSize = ${options.teamSize}`,
`acronym = ${options.acronym ?? 'VASH'}`,
`Height = ${options.height}`,
''
].join('\n');
}
export class Tourney {
private process: Subprocess | null = null;
private size = 0;
private startedAt = 0;
private readonly exe: string;
private readonly directory: string;
private readonly display: string;
private readonly width: number;
private readonly height: number;
constructor(exe: string, options: { display: string; width: number; height: number }) {
this.exe = exe;
this.directory = dirname(exe);
this.display = options.display;
this.width = options.width;
this.height = options.height;
}
/** Config must be written while osu! is closed; a size change restarts it. */
async ensure(teamSize: number) {
if (!Number.isInteger(teamSize) || teamSize < 1 || teamSize > 8)
throw new Error(`Unsupported team size ${teamSize}`);
if (this.size === teamSize && this.process?.exitCode == null) return false;
if (this.process) {
this.process.kill();
await Bun.spawn(['wineserver', '-k'], { stdout: 'ignore', stderr: 'ignore' }).exited;
await this.process.exited;
this.process = null;
}
const configPath = join(this.directory, 'tournament.cfg');
const existing = await readFile(configPath, 'utf8').catch(() => '');
await writeFile(configPath, tournamentConfig(existing, { teamSize, height: this.height }));
this.process = Bun.spawn(['wine', this.exe], {
cwd: this.directory,
env: { ...process.env, DISPLAY: this.display },
stdout: 'ignore',
stderr: 'ignore'
});
this.size = teamSize;
this.startedAt = Date.now();
return true;
}
async select(roomName: string): Promise<boolean> {
if (!this.process || this.process.exitCode != null || Date.now() - this.startedAt < 10_000)
return false;
const capture = Bun.spawn(
[
'ffmpeg',
'-hide_banner',
'-loglevel',
'error',
'-f',
'x11grab',
'-video_size',
`${this.width}x200`,
'-i',
`${this.display}+0,${this.height}`,
'-frames:v',
'1',
'-f',
'image2pipe',
'-vcodec',
'png',
'pipe:1'
],
{ stdout: 'pipe', stderr: 'ignore' }
);
const png = new Uint8Array(await new Response(capture.stdout).arrayBuffer());
if ((await capture.exited) !== 0 || !png.length) return false;
const ocr = Bun.spawn(['tesseract', 'stdin', 'stdout', 'tsv'], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'ignore'
});
ocr.stdin.write(png);
ocr.stdin.end();
const result = await new Response(ocr.stdout).text();
if ((await ocr.exited) !== 0) return false;
const point = findRoom(result, roomName);
if (!point) return false;
return (
(await Bun.spawn(
['xdotool', 'mousemove', String(point.x), String(this.height + point.y), 'click', '1'],
{ env: { ...process.env, DISPLAY: this.display }, stdout: 'ignore', stderr: 'ignore' }
).exited) === 0
);
}
stop() {
this.process?.kill();
}
}