- 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>
314 lines
9.5 KiB
TypeScript
314 lines
9.5 KiB
TypeScript
/**
|
||
* 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');
|
||
}
|
||
}
|