- 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>
162 lines
4.9 KiB
TypeScript
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();
|
|
}
|
|
}
|