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:
313
streamer/windows/obs-control.ts
Normal file
313
streamer/windows/obs-control.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user