- 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>
309 lines
8.9 KiB
TypeScript
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);
|
|
}
|