#!/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= VASH_TOKEN= * Or, without Vash managing it, one overlay to one destination: * OVERLAY_URL=https://…/overlay RTMP_URL=rtmp://…/ * Or relay OBS: INGEST_KEY= makes it listen on rtmp://: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 { 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); }