/** * Keeps the instance's OBS scene as the broadcast needs it, over obs-websocket: * * scene "Vash" * Overlay browser source, the channel's overlay at canvas size (on top) * Gameplay window capture of this instance's osu!tourney window, with its * audio, the control panel cropped off, fitted to the canvas; * hidden until the requested room is being spectated * * Every call is idempotent: it creates what is missing and corrects what * drifted, so it runs after every OBS start and whenever something changes. */ import type { Obs } from '../obs'; import { gameplayHeight } from './tourney'; export const SCENE = 'Vash'; export const GAMEPLAY = 'Gameplay'; export const OVERLAY = 'Overlay'; /** OBS's window setting: "title:class:exe" with '#' and ':' escaped (libobs window-helpers.c). */ export function windowSetting(target: { title: string; className: string; exe: string }) { const encode = (value: string) => value.replace(/#/g, '#22').replace(/:/g, '#3A'); return `${encode(target.title)}:${encode(target.className)}:${encode(target.exe)}`; } /** Match the window by exact title only; another osu! window can never be picked up instead. */ const WINDOW_PRIORITY_TITLE = 1; const METHOD_WGC = 2; const OBS_ALIGN_TOP_LEFT = 5; export interface SceneTarget { canvas: { width: number; height: number }; fps: number; overlayUrl: string; /** The tourney window to capture, when one exists. */ capture: { title: string; className: string; exe: string } | null; showGameplay: boolean; ingest: { server: string; key: string }; } interface SceneItem { sceneItemId: number; sourceName: string; sceneItemIndex: number; sceneItemEnabled: boolean; } interface Transform { sourceWidth: number; sourceHeight: number; cropBottom: number; boundsType: string; boundsWidth: number; boundsHeight: number; } const differs = (current: Record, wanted: Record) => Object.entries(wanted).some( ([key, value]) => JSON.stringify(current[key]) !== JSON.stringify(value) ); export class ObsScene { private lastFull = ''; constructor( private obs: Obs, private log: (...parts: unknown[]) => void ) {} /** Creates or corrects the scene; cheap when nothing changed. */ async reconcile(target: SceneTarget) { const key = JSON.stringify({ ...target, showGameplay: undefined }); if (key !== this.lastFull) { await this.full(target); this.lastFull = key; } await this.fitGameplay(target); } /** Forget what was applied, e.g. after OBS restarted. */ reset() { this.lastFull = ''; } private async full(target: SceneTarget) { const obs = this.obs; const { scenes, currentProgramSceneName } = await obs.request<{ scenes: { sceneName: string }[]; currentProgramSceneName: string; }>('GetSceneList'); if (!scenes.some((scene) => scene.sceneName === SCENE)) { await obs.request('CreateScene', { sceneName: SCENE }); this.log(`OBS: created scene ${SCENE}`); } if (currentProgramSceneName !== SCENE) await obs.request('SetCurrentProgramScene', { sceneName: SCENE }); const { inputs } = await obs.request<{ inputs: { inputName: string }[] }>('GetInputList'); const names = new Set(inputs.map((input) => input.inputName)); const gameplaySettings = { window: target.capture ? windowSetting(target.capture) : '', priority: WINDOW_PRIORITY_TITLE, method: METHOD_WGC, cursor: false, client_area: true, capture_audio: true, compatibility: false }; const overlaySettings = { url: target.overlayUrl, width: target.canvas.width, height: target.canvas.height, fps_custom: true, fps: target.fps, reroute_audio: true, shutdown: false, restart_when_active: false }; await this.ensureInput( names, GAMEPLAY, 'window_capture', gameplaySettings, target.showGameplay ); await this.ensureInput(names, OVERLAY, 'browser_source', overlaySettings, true); // Both in the scene, the overlay above gameplay. let items = await this.items(); for (const name of [GAMEPLAY, OVERLAY]) if (!items.some((item) => item.sourceName === name)) await obs.request('CreateSceneItem', { sceneName: SCENE, sourceName: name }); items = await this.items(); const overlay = items.find((item) => item.sourceName === OVERLAY)!; const top = Math.max(...items.map((item) => item.sceneItemIndex)); if (overlay.sceneItemIndex !== top) await obs.request('SetSceneItemIndex', { sceneName: SCENE, sceneItemId: overlay.sceneItemId, sceneItemIndex: top }); await obs.request('SetSceneItemTransform', { sceneName: SCENE, sceneItemId: overlay.sceneItemId, sceneItemTransform: { positionX: 0, positionY: 0, alignment: OBS_ALIGN_TOP_LEFT, boundsType: 'OBS_BOUNDS_NONE', scaleX: 1, scaleY: 1, rotation: 0 } }); if (!overlay.sceneItemEnabled) await obs.request('SetSceneItemEnabled', { sceneName: SCENE, sceneItemId: overlay.sceneItemId, sceneItemEnabled: true }); // Only the capture's own audio and the overlay: never the PC's desktop audio or microphone. const special = await obs.request>('GetSpecialInputs'); for (const name of Object.values(special)) if (name) await obs.request('SetInputMute', { inputName: name, inputMuted: true }).catch(() => {}); const service = await obs .request<{ streamServiceType: string; streamServiceSettings: Record }>( 'GetStreamServiceSettings' ) .catch(() => null); const wanted = { server: target.ingest.server, key: target.ingest.key }; if ( !service || service.streamServiceType !== 'rtmp_custom' || differs(service.streamServiceSettings, wanted) ) await obs.request('SetStreamServiceSettings', { streamServiceType: 'rtmp_custom', streamServiceSettings: { ...wanted, use_auth: false, bwtest: false } }); } private async ensureInput( names: Set, inputName: string, inputKind: string, inputSettings: Record, enabled: boolean ) { const obs = this.obs; if (!names.has(inputName)) { await obs.request('CreateInput', { sceneName: SCENE, inputName, inputKind, inputSettings, sceneItemEnabled: enabled }); this.log(`OBS: added ${inputName}`); return; } const current = await obs.request<{ inputKind: string; inputSettings: Record; }>('GetInputSettings', { inputName }); if (current.inputKind !== inputKind) { // Someone replaced it with another kind of source; start over. await obs.request('RemoveInput', { inputName }); await obs.request('CreateInput', { sceneName: SCENE, inputName, inputKind, inputSettings, sceneItemEnabled: enabled }); this.log(`OBS: replaced ${inputName}`); return; } if (differs(current.inputSettings, inputSettings)) await obs.request('SetInputSettings', { inputName, inputSettings, overlay: true }); } private async items() { const { sceneItems } = await this.obs.request<{ sceneItems: SceneItem[] }>('GetSceneItemList', { sceneName: SCENE }); return sceneItems; } /** Crops the control panel off the capture and fits the gameplay to the canvas; shows or hides it. */ private async fitGameplay(target: SceneTarget) { const obs = this.obs; const item = (await this.items()).find((each) => each.sourceName === GAMEPLAY); if (!item) return; if (item.sceneItemEnabled !== target.showGameplay) await obs.request('SetSceneItemEnabled', { sceneName: SCENE, sceneItemId: item.sceneItemId, sceneItemEnabled: target.showGameplay }); const { sceneItemTransform: transform } = await obs.request<{ sceneItemTransform: Transform }>( 'GetSceneItemTransform', { sceneName: SCENE, sceneItemId: item.sceneItemId } ); if (!transform.sourceWidth || !transform.sourceHeight) return; const cropBottom = Math.max(0, transform.sourceHeight - gameplayHeight(transform.sourceWidth)); if ( transform.cropBottom === cropBottom && transform.boundsType === 'OBS_BOUNDS_SCALE_INNER' && transform.boundsWidth === target.canvas.width && transform.boundsHeight === target.canvas.height ) return; await obs.request('SetSceneItemTransform', { sceneName: SCENE, sceneItemId: item.sceneItemId, sceneItemTransform: { positionX: 0, positionY: 0, alignment: OBS_ALIGN_TOP_LEFT, cropTop: 0, cropLeft: 0, cropRight: 0, cropBottom, boundsType: 'OBS_BOUNDS_SCALE_INNER', boundsAlignment: 0, boundsWidth: target.canvas.width, boundsHeight: target.canvas.height, rotation: 0 } }); this.log( `OBS: gameplay ${transform.sourceWidth}×${transform.sourceHeight}, control panel cropped (${cropBottom} px)` ); } async streaming() { const status = await this.obs.request<{ outputActive: boolean; outputReconnecting: boolean }>( 'GetStreamStatus' ); return status; } async startStream() { await this.obs.request('StartStream'); } async stopStream() { await this.obs.request('StopStream'); } /** The profile's simple-mode encoder; OBS switches it to x264 by itself when AMF is unavailable. */ async profileEncoder() { const { currentValue } = await this.obs.request<{ currentValue: string | null }>( 'GetProfileParameter', { parameterCategory: 'SimpleOutput', parameterName: 'StreamEncoder' } ); return currentValue; } /** A PNG of the program output, for diagnostics. */ async screenshot(width = 1280) { const { imageData } = await this.obs.request<{ imageData: string }>('GetSourceScreenshot', { sourceName: SCENE, imageFormat: 'png', imageWidth: width }); return Buffer.from(imageData.replace(/^data:image\/png;base64,/, ''), 'base64'); } }