Files
stream-control/packages/agent/src/obs.ts
jeanotx32 1dd3959f29
Some checks failed
release / build (push) Successful in 29s
release / verify-windows (push) Failing after 1m0s
Feat : Log recorder
2026-08-11 22:34:45 +02:00

543 lines
18 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.
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;
}