Feat : OBS presets
Some checks failed
release / build (push) Successful in 26s
release / verify-windows (push) Failing after 1m0s

This commit is contained in:
jeanotx32
2026-08-11 22:24:45 +02:00
parent 3b614c5d05
commit 03b1963150
11 changed files with 710 additions and 8 deletions

View File

@@ -1,10 +1,19 @@
import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js';
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
import type {
AgentAction,
AgentStatus,
ObsSettings,
PresetApplyResult,
RecordingPreset,
RecordingSettings,
} from '@stream-control/shared';
import { RECORDING_ENCODERS, findRecordingPreset } from '@stream-control/shared';
/**
* Actions relevant d'OBS. `watch.*`, `hotkey.*` et `agent.update` sont traitées
* en amont par l'agent : elles ne concernent pas la session obs-websocket.
* 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,
@@ -15,6 +24,7 @@ export type ObsAction = Exclude<
| 'browser.close'
| 'capture.start'
| 'capture.stop'
| 'preset.apply'
>;
type ObsSnapshot = Pick<
@@ -308,6 +318,123 @@ export class ObsController extends EventEmitter {
}
}
// --- 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 = {
@@ -372,6 +499,11 @@ export class ObsController extends EventEmitter {
}
}
/** 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`);