Files
esports/streamer/windows/obs-control.ts
Stan Runge 54444a77f0 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>
2026-10-18 22:02:25 +00:00

314 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, unknown>, wanted: Record<string, unknown>) =>
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<Record<string, string | null>>('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<string, unknown> }>(
'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<string>,
inputName: string,
inputKind: string,
inputSettings: Record<string, unknown>,
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<string, unknown>;
}>('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');
}
}