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:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

View 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');
}
}