/** * 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>; export function parseIni(text: string): Ini { const ini: Ini = new Map(); let section = new Map(); 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> ) { for (const [name, entries] of Object.entries(values)) { const section = ini.get(name) ?? new Map(); 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> ) { 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(() => '') : ''; }