Files
esports/streamer/tv/main.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

309 lines
8.9 KiB
TypeScript

#!/usr/bin/env bun
/**
* vash-tv: streams a Vash broadcast channel with no PC.
*
* A virtual screen shows the channel's overlay in Chromium. One ffmpeg encodes
* it; its output is copied to one small ffmpeg per destination (Twitch,
* YouTube, …), so a destination that fails reconnects on its own without
* touching the others. The same stream is cut into 2-second HLS segments and
* uploaded to storage: that is the live stream on the Vash site and the
* recording afterwards. (Outputs and recording live in ../relay.ts.)
*
* It follows the channel's "Go live" switch on the site:
* VASH_SERVER=https://esports.vash.software VASH_CHANNEL=<id> VASH_TOKEN=<agent token>
* Or, without Vash managing it, one overlay to one destination:
* OVERLAY_URL=https://…/overlay RTMP_URL=rtmp://…/<key>
* Or relay OBS: INGEST_KEY=<secret> makes it listen on rtmp://<host>:1935/live
* (INGEST_PORT to change) and record and restream what OBS sends, unchanged.
* Optional: MUSIC_URL (looped audio), OVERLAY_ORIGIN (load the overlay from another
* address, e.g. http://127.0.0.1:3102 next to Vash), DURATION (seconds, for tests).
*/
import type { Subprocess } from 'bun';
import { Broadcaster, RtmpIngest, type Source } from '../relay';
import { VashApi, type StreamConfig } from '../vash-api';
const env = process.env;
const server = env.VASH_SERVER?.replace(/\/$/, '');
const channelId = env.VASH_CHANNEL;
const token = env.VASH_TOKEN;
const managed = !!(server && channelId && token);
const HLS = '/tmp/vash-hls';
/** A second virtual display with osu!tourney's streamable area at its top left. */
const gameplayDisplay = env.GAMEPLAY_DISPLAY;
const gameplayWidth = Number(env.GAMEPLAY_WIDTH ?? 1280);
const gameplayHeight = Number(env.GAMEPLAY_HEIGHT ?? 720);
const say = (...parts: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...parts);
type Config = StreamConfig;
if (!managed && !((env.OVERLAY_URL || env.INGEST_KEY) && env.RTMP_URL)) {
console.error('Set VASH_SERVER, VASH_CHANNEL and VASH_TOKEN, or OVERLAY_URL and RTMP_URL.');
process.exit(1);
}
// ── Vash API ────────────────────────────────────────────────────────────
const api = managed ? new VashApi(server!, channelId!, token!) : null;
async function loadConfig(): Promise<Config> {
if (!api) {
return {
live: true,
width: Number(env.WIDTH ?? 1280),
height: Number(env.HEIGHT ?? 720),
fps: Number(env.FPS ?? 30),
bitrateKbps: Number((env.VIDEO_BITRATE ?? '3000k').replace(/k$/, '')),
overlayUrl: env.OVERLAY_URL ?? '',
record: false,
destinations: [{ id: 'rtmp', platform: 'custom', label: 'RTMP', url: env.RTMP_URL! }]
};
}
const config = await api.stream();
// A container next to Vash (or a dev site behind a login) can load the overlay internally.
if (env.OVERLAY_ORIGIN)
config.overlayUrl = config.overlayUrl.replace(
/^https?:\/\/[^/]+/,
env.OVERLAY_ORIGIN.replace(/\/$/, '')
);
return config;
}
// ── Screen ──────────────────────────────────────────────────────────────
let screen: {
width: number;
height: number;
url: string;
xvfb: Subprocess;
browser: Subprocess | null;
} | null = null;
function spawnBrowser(url: string, width: number, height: number) {
const background = gameplayDisplay ? 'key' : 'solid';
const page = url.includes('?') ? `${url}&bg=${background}` : `${url}?bg=${background}`;
return Bun.spawn(
[
'chromium',
'--no-sandbox',
'--kiosk',
'--incognito',
'--no-first-run',
'--noerrdialogs',
'--disable-infobars',
'--disable-dev-shm-usage',
'--disable-gpu',
'--hide-scrollbars',
'--force-device-scale-factor=1',
'--autoplay-policy=no-user-gesture-required',
'--window-position=0,0',
`--window-size=${width},${height}`,
page
],
{ env: { ...env, DISPLAY: ':99' }, stdout: 'ignore', stderr: 'ignore' }
);
}
async function showScreen(config: Config) {
if (
screen &&
screen.width === config.width &&
screen.height === config.height &&
screen.url === config.overlayUrl
)
return;
stopScreen();
const xvfb = Bun.spawn(
['Xvfb', ':99', '-screen', '0', `${config.width}x${config.height}x24`, '-nolisten', 'tcp'],
{ stdout: 'ignore', stderr: 'ignore' }
);
await Bun.sleep(800);
screen = {
width: config.width,
height: config.height,
url: config.overlayUrl,
xvfb,
browser: null
};
const keepBrowser = async () => {
const current = screen;
if (!current || current.xvfb !== xvfb) return;
current.browser = spawnBrowser(config.overlayUrl, config.width, config.height);
await current.browser.exited;
// Chromium should never exit; bring it back if it does.
if (screen === current) setTimeout(keepBrowser, 2000);
};
void keepBrowser();
await Bun.sleep(5000);
say(`Screen ${config.width}x${config.height} showing ${config.overlayUrl}`);
}
function stopScreen() {
if (!screen) return;
const old = screen;
screen = null;
old.browser?.kill();
old.xvfb.kill();
}
// ── Encoder ─────────────────────────────────────────────────────────────
/** Renders the overlay screen (and gameplay, if any) and encodes it once. */
class ScreenEncoder implements Source {
private process: Subprocess<'ignore', 'pipe', 'pipe'> | null = null;
get running() {
return !!this.process;
}
async start(config: Config, write: (chunk: Uint8Array) => void) {
if (this.process) return;
await showScreen(config);
const audio = env.PULSE_SOURCE
? ['-f', 'pulse', '-i', env.PULSE_SOURCE]
: env.MUSIC_URL
? ['-stream_loop', '-1', '-i', env.MUSIC_URL]
: ['-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'];
const kbps = config.bitrateKbps;
const gameplay = gameplayDisplay
? [
'-f',
'x11grab',
'-draw_mouse',
'0',
'-framerate',
String(config.fps),
'-video_size',
`${gameplayWidth}x${gameplayHeight}`,
'-i',
gameplayDisplay
]
: [];
const video = gameplayDisplay
? [
'-filter_complex',
`[1:v]scale=${config.width}:${config.height}:force_original_aspect_ratio=decrease,pad=${config.width}:${config.height}:(ow-iw)/2:(oh-ih)/2[game];[0:v]chromakey=0x00ff00:0.04:0.0[overlay];[game][overlay]overlay=0:0:shortest=1[v]`,
'-map',
'[v]'
]
: ['-map', '0:v'];
const process = Bun.spawn(
[
'ffmpeg',
'-hide_banner',
'-loglevel',
'error',
'-f',
'x11grab',
'-draw_mouse',
'0',
'-framerate',
String(config.fps),
'-video_size',
`${config.width}x${config.height}`,
'-i',
':99',
...gameplay,
...audio,
...video,
'-map',
gameplayDisplay ? '2:a' : '1:a',
'-c:v',
'libx264',
'-preset',
env.PRESET ?? 'veryfast',
'-b:v',
`${kbps}k`,
'-maxrate',
`${kbps}k`,
'-bufsize',
`${kbps * 2}k`,
'-pix_fmt',
'yuv420p',
'-g',
String(config.fps * 2),
'-keyint_min',
String(config.fps * 2),
'-sc_threshold',
'0',
'-c:a',
'aac',
'-b:a',
'160k',
'-ar',
'44100',
'-f',
'mpegts',
'pipe:1'
],
{ stdout: 'pipe', stderr: 'pipe', env: { ...env, DISPLAY: ':99' } }
);
this.process = process;
void (async () => {
for await (const chunk of process.stdout) write(chunk);
})();
void (async () => {
let last = '';
for await (const chunk of process.stderr)
last = new TextDecoder().decode(chunk).trim() || last;
const code = await process.exited;
if (this.process === process) {
say(`Encoder stopped (${code}): ${last}`);
this.process = null;
}
})();
}
stop() {
const process = this.process;
this.process = null;
process?.kill();
}
}
/** OBS (or anything) streams here instead of the built-in screen; nothing is re-encoded. */
const source: Source = env.INGEST_KEY
? new RtmpIngest({
ffmpeg: 'ffmpeg',
host: '0.0.0.0',
port: Number(env.INGEST_PORT ?? 1935),
key: env.INGEST_KEY,
log: say
})
: new ScreenEncoder();
const broadcaster = new Broadcaster({ api, ffmpeg: 'ffmpeg', hlsDir: HLS, source, log: say });
let stopping = false;
async function shutdown() {
if (stopping) return;
stopping = true;
await broadcaster.stop();
stopScreen();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
say(
managed
? `Following channel ${channelId} on ${server}`
: env.INGEST_KEY
? 'Relaying OBS to one RTMP destination'
: `Streaming ${env.OVERLAY_URL}`
);
if (env.DURATION) setTimeout(shutdown, Number(env.DURATION) * 1000);
let lastConfig = 0;
while (!stopping) {
if (Date.now() - lastConfig >= 5000) {
lastConfig = Date.now();
try {
await broadcaster.apply(await loadConfig());
} catch (err) {
say('Vash unreachable:', (err as Error).message);
}
}
await broadcaster.tick();
await Bun.sleep(1000);
}