543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
||
import OBSWebSocket from 'obs-websocket-js';
|
||
import type {
|
||
AgentAction,
|
||
AgentEvent,
|
||
AgentStatus,
|
||
ObsSettings,
|
||
PresetApplyResult,
|
||
RecordingPreset,
|
||
RecordingSettings,
|
||
} from '@stream-control/shared';
|
||
import { RECORDING_ENCODERS, findRecordingPreset } from '@stream-control/shared';
|
||
|
||
/** Traduit l'état d'une sortie OBS en évènement d'historique et en libellé. */
|
||
const RECORD_STATES: Record<string, { event: AgentEvent; label: string }> = {
|
||
STARTED: { event: 'record.started', label: 'Enregistrement démarré' },
|
||
STOPPED: { event: 'record.stopped', label: 'Enregistrement arrêté' },
|
||
PAUSED: { event: 'record.paused', label: 'Enregistrement mis en pause' },
|
||
RESUMED: { event: 'record.resumed', label: 'Enregistrement repris' },
|
||
};
|
||
|
||
const STREAM_STATES: Record<string, { event: AgentEvent; label: string }> = {
|
||
STARTED: { event: 'stream.started', label: 'Diffusion démarrée' },
|
||
STOPPED: { event: 'stream.stopped', label: 'Diffusion arrêtée' },
|
||
};
|
||
|
||
/**
|
||
* Actions relevant d'OBS. `watch.*`, `hotkey.*`, `preset.apply` et
|
||
* `agent.update` sont traitées en amont par l'agent : elles ne se réduisent pas
|
||
* à un appel obs-websocket.
|
||
*/
|
||
export type ObsAction = Exclude<
|
||
AgentAction,
|
||
| 'watch.check'
|
||
| 'hotkey.fullscreen'
|
||
| 'agent.update'
|
||
| 'browser.open'
|
||
| 'browser.close'
|
||
| 'capture.start'
|
||
| 'capture.stop'
|
||
| 'preset.apply'
|
||
>;
|
||
|
||
type ObsSnapshot = Pick<
|
||
AgentStatus,
|
||
| 'obsConnected'
|
||
| 'obsVersion'
|
||
| 'obsError'
|
||
| 'recording'
|
||
| 'recordPaused'
|
||
| 'recordTimecode'
|
||
| 'recordBytes'
|
||
| 'recordDirectory'
|
||
| 'streaming'
|
||
| 'streamTimecode'
|
||
| 'currentScene'
|
||
| 'scenes'
|
||
| 'currentProfile'
|
||
| 'profiles'
|
||
| 'currentCollection'
|
||
| 'collections'
|
||
| 'cpuUsage'
|
||
| 'fps'
|
||
| 'droppedFrames'
|
||
| 'renderSkippedFrames'
|
||
>;
|
||
|
||
const RECONNECT_DELAY_MS = 5000;
|
||
const CONNECT_TIMEOUT_MS = 10_000;
|
||
|
||
/** Borne une promesse qui pourrait ne jamais se résoudre. */
|
||
async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||
let timer: NodeJS.Timeout | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(message)), ms);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Traduit une erreur de connexion en message actionnable. Le brut
|
||
* (« ECONNREFUSED ») dit ce qui a échoué, jamais quoi faire ensuite.
|
||
*/
|
||
function describeConnectionError(err: unknown): string {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
const sysCode = (err as NodeJS.ErrnoException).code;
|
||
const obsCode = (err as { code?: unknown }).code;
|
||
|
||
if (sysCode === 'ECONNREFUSED' || message.includes('ECONNREFUSED')) {
|
||
return `${message} — rien n'écoute sur ce port. Lance OBS, puis Outils → Paramètres du serveur WebSocket → coche « Activer le serveur WebSocket ».`;
|
||
}
|
||
if (obsCode === 4009 || /authent/i.test(message)) {
|
||
return `${message} — mot de passe obs-websocket incorrect ou manquant. Dans OBS : Outils → Paramètres du serveur WebSocket → Afficher les informations de connexion.`;
|
||
}
|
||
if (sysCode === 'ENOTFOUND' || sysCode === 'EHOSTUNREACH' || sysCode === 'ETIMEDOUT') {
|
||
return `${message} — hôte injoignable. Vérifie l'adresse configurée pour cet agent.`;
|
||
}
|
||
return message;
|
||
}
|
||
|
||
/**
|
||
* Enveloppe obs-websocket : maintient la session, expose les actions du
|
||
* protocole et produit un instantané d'état à chaque cycle de statut.
|
||
*/
|
||
export class ObsController extends EventEmitter {
|
||
private readonly obs = new OBSWebSocket();
|
||
private settings: ObsSettings;
|
||
private connected = false;
|
||
private connecting: Promise<void> | null = null;
|
||
private autoReconnect = false;
|
||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||
private lastError: string | undefined;
|
||
private lastRecordingPath: string | undefined;
|
||
private cachedVersion: string | undefined;
|
||
|
||
constructor(settings: ObsSettings) {
|
||
super();
|
||
this.settings = settings;
|
||
|
||
this.obs.on('ConnectionClosed', (err: unknown) => {
|
||
const wasConnected = this.connected;
|
||
this.connected = false;
|
||
this.cachedVersion = undefined;
|
||
if (wasConnected) {
|
||
this.lastError = err instanceof Error ? err.message : undefined;
|
||
this.emit(
|
||
'log',
|
||
'warn',
|
||
`Session OBS fermée${this.lastError ? ` : ${this.lastError}` : ''}`,
|
||
'obs.disconnected',
|
||
);
|
||
}
|
||
this.scheduleReconnect();
|
||
});
|
||
|
||
this.obs.on('RecordStateChanged', (event) => {
|
||
if (typeof event.outputPath === 'string' && event.outputPath) {
|
||
this.lastRecordingPath = event.outputPath;
|
||
}
|
||
// La source fait autorité : elle capte aussi les pauses déclenchées depuis
|
||
// l'interface d'OBS, que le dashboard ne verrait pas autrement.
|
||
const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '');
|
||
const known = RECORD_STATES[raw];
|
||
this.emit('log', 'info', known?.label ?? `Enregistrement : ${raw}`, known?.event);
|
||
});
|
||
|
||
this.obs.on('StreamStateChanged', (event) => {
|
||
const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '');
|
||
const known = STREAM_STATES[raw];
|
||
this.emit('log', 'info', known?.label ?? `Diffusion : ${raw}`, known?.event);
|
||
});
|
||
}
|
||
|
||
get isConnected(): boolean {
|
||
return this.connected;
|
||
}
|
||
|
||
applySettings(settings: ObsSettings): void {
|
||
const changed =
|
||
settings.host !== this.settings.host ||
|
||
settings.port !== this.settings.port ||
|
||
settings.password !== this.settings.password;
|
||
this.settings = settings;
|
||
if (changed && this.connected) {
|
||
this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion', 'config.changed');
|
||
void this.reconnect();
|
||
}
|
||
}
|
||
|
||
async connect(): Promise<void> {
|
||
if (this.connected) return;
|
||
if (this.connecting) return this.connecting;
|
||
|
||
this.autoReconnect = true;
|
||
const url = `ws://${this.settings.host}:${this.settings.port}`;
|
||
|
||
this.connecting = (async () => {
|
||
try {
|
||
// Un port ouvert par autre chose qu'obs-websocket ne renvoie jamais de
|
||
// poignée de main : sans ce délai, la promesse resterait éternellement
|
||
// en attente et, comme elle est réutilisée ci-dessus, toute tentative
|
||
// ultérieure serait bloquée jusqu'au redémarrage de l'agent.
|
||
const info = await withTimeout(
|
||
this.obs.connect(url, this.settings.password || undefined, { rpcVersion: 1 }),
|
||
CONNECT_TIMEOUT_MS,
|
||
`aucune réponse après ${CONNECT_TIMEOUT_MS / 1000} s — le port répond mais ne parle pas obs-websocket ?`,
|
||
);
|
||
this.connected = true;
|
||
this.lastError = undefined;
|
||
this.cachedVersion = info.obsWebSocketVersion;
|
||
this.emit(
|
||
'log',
|
||
'info',
|
||
`Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`,
|
||
'obs.connected',
|
||
);
|
||
} catch (err) {
|
||
this.connected = false;
|
||
this.lastError = describeConnectionError(err);
|
||
// Après un délai dépassé, le socket peut rester à demi ouvert.
|
||
await this.obs.disconnect().catch(() => undefined);
|
||
throw new Error(`Connexion à OBS impossible (${url}) : ${this.lastError}`);
|
||
} finally {
|
||
this.connecting = null;
|
||
}
|
||
})();
|
||
|
||
return this.connecting;
|
||
}
|
||
|
||
async disconnect(): Promise<void> {
|
||
this.autoReconnect = false;
|
||
this.clearReconnect();
|
||
if (this.connected) await this.obs.disconnect();
|
||
this.connected = false;
|
||
this.emit('log', 'info', 'Déconnecté d\'OBS', 'obs.disconnected');
|
||
}
|
||
|
||
private async reconnect(): Promise<void> {
|
||
try {
|
||
await this.obs.disconnect();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
this.connected = false;
|
||
await this.connect().catch((err: Error) => this.emit('log', 'warn', err.message));
|
||
}
|
||
|
||
private scheduleReconnect(): void {
|
||
if (!this.autoReconnect || this.reconnectTimer) return;
|
||
this.reconnectTimer = setTimeout(() => {
|
||
this.reconnectTimer = null;
|
||
if (!this.autoReconnect || this.connected) return;
|
||
this.connect().catch(() => {
|
||
/* la prochaine fermeture reprogrammera un essai */
|
||
});
|
||
}, RECONNECT_DELAY_MS);
|
||
this.reconnectTimer.unref?.();
|
||
}
|
||
|
||
private clearReconnect(): void {
|
||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||
this.reconnectTimer = null;
|
||
}
|
||
|
||
/** État d'enregistrement à la demande, sans passer par l'instantané complet. */
|
||
async recordState(): Promise<{ active: boolean; paused: boolean }> {
|
||
if (!this.connected) return { active: false, paused: false };
|
||
const status = await this.obs.call('GetRecordStatus');
|
||
return { active: status.outputActive, paused: status.outputPaused };
|
||
}
|
||
|
||
// --- Exécution des actions du protocole ---------------------------------
|
||
|
||
async execute(action: ObsAction, params: Record<string, unknown> = {}): Promise<unknown> {
|
||
switch (action) {
|
||
case 'obs.connect':
|
||
await this.connect();
|
||
return { connected: true };
|
||
|
||
case 'obs.disconnect':
|
||
await this.disconnect();
|
||
return { connected: false };
|
||
|
||
case 'obs.refresh':
|
||
this.requireConnection();
|
||
return this.snapshot();
|
||
|
||
case 'record.start': {
|
||
this.requireConnection();
|
||
await this.obs.call('StartRecord');
|
||
return { recording: true };
|
||
}
|
||
|
||
case 'record.stop': {
|
||
this.requireConnection();
|
||
const result = await this.obs.call('StopRecord');
|
||
this.lastRecordingPath = result.outputPath;
|
||
return { recording: false, outputPath: result.outputPath };
|
||
}
|
||
|
||
case 'record.pause':
|
||
this.requireConnection();
|
||
await this.obs.call('PauseRecord');
|
||
return { paused: true };
|
||
|
||
case 'record.resume':
|
||
this.requireConnection();
|
||
await this.obs.call('ResumeRecord');
|
||
return { paused: false };
|
||
|
||
case 'record.split':
|
||
this.requireConnection();
|
||
await this.obs.call('SplitRecordFile');
|
||
return { split: true };
|
||
|
||
case 'stream.start':
|
||
this.requireConnection();
|
||
await this.obs.call('StartStream');
|
||
return { streaming: true };
|
||
|
||
case 'stream.stop':
|
||
this.requireConnection();
|
||
await this.obs.call('StopStream');
|
||
return { streaming: false };
|
||
|
||
case 'scene.set': {
|
||
this.requireConnection();
|
||
const sceneName = requireString(params.scene, 'scene');
|
||
await this.obs.call('SetCurrentProgramScene', { sceneName });
|
||
return { scene: sceneName };
|
||
}
|
||
|
||
case 'profile.set': {
|
||
this.requireConnection();
|
||
const profileName = requireString(params.profile, 'profile');
|
||
await this.obs.call('SetCurrentProfile', { profileName });
|
||
return { profile: profileName };
|
||
}
|
||
|
||
case 'collection.set': {
|
||
this.requireConnection();
|
||
const sceneCollectionName = requireString(params.collection, 'collection');
|
||
await this.obs.call('SetCurrentSceneCollection', { sceneCollectionName });
|
||
return { collection: sceneCollectionName };
|
||
}
|
||
|
||
case 'recordDirectory.set': {
|
||
this.requireConnection();
|
||
const recordDirectory = requireString(params.directory, 'directory');
|
||
await this.obs.call('SetRecordDirectory', { recordDirectory });
|
||
return { recordDirectory };
|
||
}
|
||
|
||
case 'agent.ping':
|
||
return { pong: Date.now() };
|
||
|
||
default: {
|
||
const exhaustive: never = action;
|
||
throw new Error(`Action non gérée : ${String(exhaustive)}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Presets d'enregistrement --------------------------------------------
|
||
|
||
/**
|
||
* Traduit un preset en réglages OBS : paramètres de profil pour l'encodeur et
|
||
* la qualité, `SetVideoSettings` pour la définition et la fluidité.
|
||
*
|
||
* Chaque paramètre est écrit isolément et un refus n'interrompt pas la série :
|
||
* les clés varient d'une version d'OBS à l'autre, et il vaut mieux appliquer
|
||
* quatre réglages sur cinq en le disant que tout abandonner sur un détail. Le
|
||
* compte rendu liste ce qui est passé et ce qui a été refusé.
|
||
*/
|
||
async applyRecordingPreset(settings: RecordingSettings): Promise<PresetApplyResult> {
|
||
this.requireConnection();
|
||
|
||
const preset = findRecordingPreset(settings.presetId);
|
||
if (!preset) throw new Error(`Preset d'enregistrement inconnu : ${settings.presetId}`);
|
||
const encoder = RECORDING_ENCODERS[settings.encoder];
|
||
if (!encoder) throw new Error(`Encodeur inconnu : ${settings.encoder}`);
|
||
|
||
// Changer d'encodeur ou de définition pendant une capture la corromprait.
|
||
const state = await this.obs.call('GetRecordStatus');
|
||
if (state.outputActive) {
|
||
throw new Error("Enregistrement en cours : arrête-le avant d'appliquer un preset.");
|
||
}
|
||
|
||
const applied: string[] = [];
|
||
const skipped: string[] = [];
|
||
|
||
const setParam = async (category: string, name: string, value: string): Promise<void> => {
|
||
try {
|
||
await this.obs.call('SetProfileParameter', {
|
||
parameterCategory: category,
|
||
parameterName: name,
|
||
parameterValue: value,
|
||
});
|
||
applied.push(`${name}=${value}`);
|
||
} catch (err) {
|
||
skipped.push(`${name} (${err instanceof Error ? err.message : String(err)})`);
|
||
}
|
||
};
|
||
|
||
// L'encodeur réellement en service est celui chargé au lancement d'OBS.
|
||
const loadedEncoder = await this.readParam('SimpleOutput', 'RecEncoder');
|
||
|
||
// Les clés ci-dessous n'ont d'effet qu'en mode de sortie simple.
|
||
await setParam('Output', 'Mode', 'Simple');
|
||
await setParam('SimpleOutput', 'RecEncoder', encoder.obsValue);
|
||
await setParam('SimpleOutput', 'RecQuality', preset.quality);
|
||
await setParam('SimpleOutput', 'RecFormat2', preset.format);
|
||
// `RecFormat` est la clé des OBS antérieurs à la 30 ; l'écrire ne coûte rien.
|
||
await setParam('SimpleOutput', 'RecFormat', preset.format === 'mkv' ? 'mkv' : 'mp4');
|
||
await setParam('SimpleOutput', 'ABitrate', String(preset.audioBitrateKbps));
|
||
|
||
if (encoder.speedKey && encoder.speedFamily) {
|
||
await setParam('SimpleOutput', encoder.speedKey, preset.speed[encoder.speedFamily]);
|
||
}
|
||
|
||
let video: string;
|
||
try {
|
||
video = await this.applyVideoSettings(preset);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
skipped.push(`vidéo (${message})`);
|
||
video = `non appliqué : ${message}`;
|
||
}
|
||
|
||
return {
|
||
presetId: preset.id,
|
||
presetLabel: preset.label,
|
||
encoder: settings.encoder,
|
||
applied,
|
||
skipped,
|
||
video,
|
||
restartRequired: loadedEncoder !== null && loadedEncoder !== encoder.obsValue,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Aligne la sortie vidéo sur le preset, sans jamais agrandir : au-delà de la
|
||
* définition de la scène il n'y a rien à gagner, seulement des pixels inventés.
|
||
*/
|
||
private async applyVideoSettings(preset: RecordingPreset): Promise<string> {
|
||
const current = await this.obs.call('GetVideoSettings');
|
||
const { baseWidth, baseHeight } = current;
|
||
|
||
let outputWidth = baseWidth;
|
||
let outputHeight = baseHeight;
|
||
if (preset.height !== null && preset.height < baseHeight) {
|
||
outputHeight = even(preset.height);
|
||
outputWidth = even((baseWidth * preset.height) / baseHeight);
|
||
}
|
||
|
||
await this.obs.call('SetVideoSettings', {
|
||
baseWidth,
|
||
baseHeight,
|
||
outputWidth,
|
||
outputHeight,
|
||
fpsNumerator: preset.fps,
|
||
fpsDenominator: 1,
|
||
});
|
||
|
||
return `${outputWidth}×${outputHeight} @ ${preset.fps} fps`;
|
||
}
|
||
|
||
/** Valeur courante d'un paramètre de profil, sa valeur par défaut à défaut. */
|
||
private async readParam(category: string, name: string): Promise<string | null> {
|
||
try {
|
||
const result = await this.obs.call('GetProfileParameter', {
|
||
parameterCategory: category,
|
||
parameterName: name,
|
||
});
|
||
return result.parameterValue || result.defaultParameterValue || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Interroge OBS pour construire l'état courant ; ne lève jamais. */
|
||
async snapshot(): Promise<ObsSnapshot> {
|
||
const base: ObsSnapshot = {
|
||
obsConnected: this.connected,
|
||
obsVersion: this.cachedVersion,
|
||
obsError: this.connected ? undefined : this.lastError,
|
||
recording: false,
|
||
recordPaused: false,
|
||
streaming: false,
|
||
scenes: [],
|
||
profiles: [],
|
||
collections: [],
|
||
};
|
||
if (!this.connected) return base;
|
||
|
||
try {
|
||
const [record, stream, sceneList, profileList, collectionList, stats, directory] =
|
||
await Promise.all([
|
||
this.obs.call('GetRecordStatus'),
|
||
this.obs.call('GetStreamStatus'),
|
||
this.obs.call('GetSceneList'),
|
||
this.obs.call('GetProfileList'),
|
||
this.obs.call('GetSceneCollectionList'),
|
||
this.obs.call('GetStats'),
|
||
this.obs.call('GetRecordDirectory').catch(() => null),
|
||
]);
|
||
|
||
return {
|
||
...base,
|
||
recording: record.outputActive,
|
||
recordPaused: record.outputPaused,
|
||
recordTimecode: record.outputTimecode,
|
||
recordBytes: record.outputBytes,
|
||
recordDirectory: directory?.recordDirectory,
|
||
streaming: stream.outputActive,
|
||
streamTimecode: stream.outputTimecode,
|
||
currentScene: sceneList.currentProgramSceneName,
|
||
scenes: (sceneList.scenes as Array<{ sceneName?: string }>)
|
||
.map((scene) => scene.sceneName)
|
||
.filter((name): name is string => typeof name === 'string'),
|
||
currentProfile: profileList.currentProfileName,
|
||
profiles: profileList.profiles,
|
||
currentCollection: collectionList.currentSceneCollectionName,
|
||
collections: collectionList.sceneCollections,
|
||
cpuUsage: stats.cpuUsage,
|
||
fps: stats.activeFps,
|
||
droppedFrames: stats.outputSkippedFrames,
|
||
renderSkippedFrames: stats.renderSkippedFrames,
|
||
};
|
||
} catch (err) {
|
||
this.lastError = err instanceof Error ? err.message : String(err);
|
||
return { ...base, obsError: this.lastError };
|
||
}
|
||
}
|
||
|
||
get recordingPath(): string | undefined {
|
||
return this.lastRecordingPath;
|
||
}
|
||
|
||
private requireConnection(): void {
|
||
if (!this.connected) throw new Error('OBS n\'est pas connecté sur cet agent');
|
||
}
|
||
}
|
||
|
||
/** H.264 exige des dimensions paires ; on arrondit au pair le plus proche. */
|
||
function even(value: number): number {
|
||
return Math.max(2, Math.round(value / 2) * 2);
|
||
}
|
||
|
||
function requireString(value: unknown, field: string): string {
|
||
if (typeof value !== 'string' || !value.trim()) {
|
||
throw new Error(`Paramètre « ${field} » manquant`);
|
||
}
|
||
return value;
|
||
}
|