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>
This commit is contained in:
212
streamer/windows/obs-files.ts
Normal file
212
streamer/windows/obs-files.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Settings for an instance's portable OBS, written while OBS is closed:
|
||||
* no first-run wizard or crash dialog, websocket on the instance's port with
|
||||
* its own password, and a "Vash" profile with the channel's size, frame rate
|
||||
* and bitrate, AMD AMF hardware encoding, and the local relay as its server.
|
||||
* Keys OBS wrote itself are kept.
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type Ini = Map<string, Map<string, string>>;
|
||||
|
||||
export function parseIni(text: string): Ini {
|
||||
const ini: Ini = new Map();
|
||||
let section = new Map<string, string>();
|
||||
ini.set('', section);
|
||||
for (const raw of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith(';') || line.startsWith('#')) continue;
|
||||
const header = /^\[(.+)\]$/.exec(line);
|
||||
if (header) {
|
||||
section = ini.get(header[1]) ?? new Map();
|
||||
ini.set(header[1], section);
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf('=');
|
||||
if (eq > 0) section.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
|
||||
}
|
||||
return ini;
|
||||
}
|
||||
|
||||
export function stringifyIni(ini: Ini) {
|
||||
const parts: string[] = [];
|
||||
for (const [name, section] of ini) {
|
||||
if (!section.size) continue;
|
||||
if (name) parts.push(`[${name}]`);
|
||||
for (const [key, value] of section) parts.push(`${key}=${value}`);
|
||||
parts.push('');
|
||||
}
|
||||
return parts.join('\r\n');
|
||||
}
|
||||
|
||||
export function setIni(
|
||||
ini: Ini,
|
||||
values: Record<string, Record<string, string | number | boolean>>
|
||||
) {
|
||||
for (const [name, entries] of Object.entries(values)) {
|
||||
const section = ini.get(name) ?? new Map<string, string>();
|
||||
ini.set(name, section);
|
||||
for (const [key, value] of Object.entries(entries)) section.set(key, String(value));
|
||||
}
|
||||
return ini;
|
||||
}
|
||||
|
||||
async function updateIni(
|
||||
path: string,
|
||||
values: Record<string, Record<string, string | number | boolean>>
|
||||
) {
|
||||
const existing = existsSync(path) ? await readFile(path, 'utf8') : '';
|
||||
const next = stringifyIni(setIni(parseIni(existing), values));
|
||||
await mkdir(join(path, '..'), { recursive: true });
|
||||
if (next !== existing) await writeFile(path, next);
|
||||
}
|
||||
|
||||
export const PROFILE = 'Vash';
|
||||
export const COLLECTION = 'Vash';
|
||||
|
||||
export interface ObsSettings {
|
||||
websocketPort: number;
|
||||
websocketPassword: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
bitrateKbps: number;
|
||||
encoder: 'amd' | 'x264';
|
||||
ingestUrl: string;
|
||||
ingestKey: string;
|
||||
}
|
||||
|
||||
/** Everything a fresh or existing portable OBS needs; safe to run before every start. */
|
||||
export async function writeObsSettings(configDir: string, settings: ObsSettings) {
|
||||
// Settings OBS reads from the app (global.ini) and user (user.ini) files;
|
||||
// written to both so either OBS layout picks them up.
|
||||
const general = {
|
||||
General: {
|
||||
FirstRun: true,
|
||||
ConfirmOnExit: false,
|
||||
WarnBeforeStartingStream: false,
|
||||
WarnBeforeStoppingStream: false,
|
||||
WarnBeforeStoppingRecord: false,
|
||||
EnableAutoUpdates: false,
|
||||
OpenStatsOnStartup: false
|
||||
},
|
||||
BasicWindow: {
|
||||
PreviewEnabled: false,
|
||||
SysTrayEnabled: true,
|
||||
SysTrayWhenStarted: true,
|
||||
SysTrayMinimizeToTray: true
|
||||
},
|
||||
Basic: {
|
||||
Profile: PROFILE,
|
||||
ProfileDir: PROFILE,
|
||||
SceneCollection: COLLECTION,
|
||||
SceneCollectionFile: COLLECTION,
|
||||
ConfigOnNewProfile: false
|
||||
}
|
||||
};
|
||||
await updateIni(join(configDir, 'user.ini'), general);
|
||||
await updateIni(join(configDir, 'global.ini'), general);
|
||||
|
||||
const profile = join(configDir, 'basic', 'profiles', PROFILE);
|
||||
await updateIni(join(profile, 'basic.ini'), {
|
||||
General: { Name: PROFILE },
|
||||
Output: {
|
||||
Mode: 'Simple',
|
||||
Reconnect: true,
|
||||
RetryDelay: 2,
|
||||
MaxRetries: 10000,
|
||||
DelayEnable: false
|
||||
},
|
||||
SimpleOutput: {
|
||||
StreamEncoder: settings.encoder,
|
||||
VBitrate: settings.bitrateKbps,
|
||||
ABitrate: 160,
|
||||
UseAdvanced: false,
|
||||
StreamAudioEncoder: 'aac',
|
||||
Preset: 'veryfast',
|
||||
AMDPreset: 'quality',
|
||||
RecQuality: 'Stream'
|
||||
},
|
||||
Stream1: { IgnoreRecommended: true },
|
||||
Video: {
|
||||
BaseCX: settings.width,
|
||||
BaseCY: settings.height,
|
||||
OutputCX: settings.width,
|
||||
OutputCY: settings.height,
|
||||
FPSType: 1,
|
||||
FPSInt: settings.fps,
|
||||
ScaleType: 'bicubic',
|
||||
ColorFormat: 'NV12',
|
||||
ColorSpace: '709',
|
||||
ColorRange: 'Partial'
|
||||
},
|
||||
Audio: { SampleRate: 48000, ChannelSetup: 'Stereo' }
|
||||
});
|
||||
await writeFile(
|
||||
join(profile, 'service.json'),
|
||||
JSON.stringify({
|
||||
type: 'rtmp_custom',
|
||||
settings: {
|
||||
server: settings.ingestUrl,
|
||||
key: settings.ingestKey,
|
||||
use_auth: false,
|
||||
bwtest: false
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const websocket = join(configDir, 'plugin_config', 'obs-websocket');
|
||||
await mkdir(websocket, { recursive: true });
|
||||
await writeFile(
|
||||
join(websocket, 'config.json'),
|
||||
JSON.stringify({
|
||||
first_load: false,
|
||||
server_enabled: true,
|
||||
server_port: settings.websocketPort,
|
||||
alerts_enabled: false,
|
||||
auth_required: true,
|
||||
server_password: settings.websocketPassword
|
||||
})
|
||||
);
|
||||
|
||||
// A leftover run sentinel makes OBS ask about safe mode after a forced stop; the
|
||||
// worker only force-stops its own OBS, so clear it.
|
||||
const sentinel = join(configDir, '.sentinel');
|
||||
for (const name of await readdir(sentinel).catch(() => [] as string[]))
|
||||
if (name.startsWith('run_')) await rm(join(sentinel, name), { force: true });
|
||||
}
|
||||
|
||||
/** Arguments for obs64.exe, started with its bin\64bit folder as the working directory. */
|
||||
export const OBS_ARGS = [
|
||||
'--portable',
|
||||
'--multi',
|
||||
'--disable-updater',
|
||||
'--disable-missing-files-check',
|
||||
'--minimize-to-tray'
|
||||
];
|
||||
|
||||
/** Which video encoder the latest OBS log shows starting, if any. */
|
||||
export function encoderFromLog(log: string): 'amd' | 'x264' | 'other' | null {
|
||||
let latest: { index: number; kind: 'amd' | 'x264' | 'other' } | null = null;
|
||||
const patterns: [RegExp, 'amd' | 'x264' | 'other'][] = [
|
||||
[/\[(texture|fallback)-amf-(h264|h265|av1): /g, 'amd'],
|
||||
[/\[x264 encoder: /g, 'x264'],
|
||||
[/\[(obs_nvenc|jim-nvenc|NVENC|qsv|obs-qsv11)[^\]]*: /gi, 'other']
|
||||
];
|
||||
for (const [pattern, kind] of patterns)
|
||||
for (const match of log.matchAll(pattern))
|
||||
if (!latest || match.index! > latest.index) latest = { index: match.index!, kind };
|
||||
return latest?.kind ?? null;
|
||||
}
|
||||
|
||||
export async function latestObsLog(configDir: string) {
|
||||
const dir = join(configDir, 'logs');
|
||||
const names = (await readdir(dir).catch(() => [] as string[])).filter((name) =>
|
||||
name.endsWith('.txt')
|
||||
);
|
||||
names.sort();
|
||||
const latest = names.at(-1);
|
||||
return latest ? readFile(join(dir, latest), 'utf8').catch(() => '') : '';
|
||||
}
|
||||
Reference in New Issue
Block a user