Feat : OBS presets
This commit is contained in:
@@ -7,16 +7,21 @@ import type {
|
||||
AgentToServer,
|
||||
LogLevel,
|
||||
BrowserSettings,
|
||||
PresetApplyResult,
|
||||
RecordingSettings,
|
||||
ServerToAgent,
|
||||
WatchSettings,
|
||||
} from '@stream-control/shared';
|
||||
import {
|
||||
DEFAULT_BROWSER_SETTINGS,
|
||||
DEFAULT_RECORDING_SETTINGS,
|
||||
DEFAULT_WATCH_SETTINGS,
|
||||
PROTOCOL_VERSION,
|
||||
detectPlatform,
|
||||
emptyStatus,
|
||||
isRecordingEncoder,
|
||||
normalizeBrowserSettings,
|
||||
normalizeRecordingSettings,
|
||||
normalizeWatchSettings,
|
||||
safeJsonParse,
|
||||
} from '@stream-control/shared';
|
||||
@@ -56,6 +61,9 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
||||
});
|
||||
|
||||
let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS;
|
||||
let recordingSettings: RecordingSettings = DEFAULT_RECORDING_SETTINGS;
|
||||
/** Un preset attend d'être appliqué : OBS était injoignable ou occupé. */
|
||||
let presetPending = false;
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let statusTimer: NodeJS.Timeout | null = null;
|
||||
@@ -157,6 +165,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
applyBrowserSettings(message.browser);
|
||||
applyRecordingSettings(message.recording);
|
||||
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
||||
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
@@ -170,6 +179,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
applyBrowserSettings(message.browser);
|
||||
applyRecordingSettings(message.recording);
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
obs.connect().catch((err: Error) => report('warn', err.message));
|
||||
}
|
||||
@@ -273,6 +283,8 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
||||
return startCapture(params);
|
||||
case 'capture.stop':
|
||||
return stopCapture();
|
||||
case 'preset.apply':
|
||||
return applyPresetNow(params);
|
||||
case 'agent.update':
|
||||
return selfUpdate(
|
||||
typeof params.url === 'string' && params.url ? params.url : config.packageUrl,
|
||||
@@ -294,6 +306,70 @@ function applyBrowserSettings(raw: BrowserSettings | undefined): void {
|
||||
browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS);
|
||||
}
|
||||
|
||||
// --- Presets d'enregistrement ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Enregistre le preset voulu. L'application effective est différée : OBS peut
|
||||
* être déconnecté ou en pleine capture au moment où la configuration arrive.
|
||||
*/
|
||||
function applyRecordingSettings(raw: RecordingSettings | undefined): void {
|
||||
const next = normalizeRecordingSettings(raw ?? DEFAULT_RECORDING_SETTINGS);
|
||||
const changed =
|
||||
next.enabled !== recordingSettings.enabled ||
|
||||
next.presetId !== recordingSettings.presetId ||
|
||||
next.encoder !== recordingSettings.encoder;
|
||||
|
||||
recordingSettings = next;
|
||||
if (next.enabled && changed) presetPending = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le preset en attente dès que les conditions le permettent.
|
||||
*
|
||||
* Un échec ne relance pas la tentative : une clé refusée par cette version d'OBS
|
||||
* le sera à chaque cycle, et marteler l'API n'y changerait rien. Le prochain
|
||||
* enregistrement de la configuration, ou le bouton du dashboard, réessaiera.
|
||||
*/
|
||||
async function drainPresetApply(): Promise<void> {
|
||||
if (!presetPending || !recordingSettings.enabled || !obs.isConnected) return;
|
||||
|
||||
const state = await obs.recordState().catch(() => null);
|
||||
if (!state || state.active) return; // jamais pendant une capture
|
||||
|
||||
presetPending = false;
|
||||
try {
|
||||
report('info', describePreset(await obs.applyRecordingPreset(recordingSettings)));
|
||||
} catch (err) {
|
||||
report('warn', `Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Application à la demande : passe outre l'interrupteur, l'opérateur a tranché. */
|
||||
async function applyPresetNow(params: Record<string, unknown>): Promise<PresetApplyResult> {
|
||||
const result = await obs.applyRecordingPreset({
|
||||
enabled: true,
|
||||
presetId:
|
||||
typeof params.presetId === 'string' && params.presetId
|
||||
? params.presetId
|
||||
: recordingSettings.presetId,
|
||||
encoder: isRecordingEncoder(params.encoder) ? params.encoder : recordingSettings.encoder,
|
||||
});
|
||||
presetPending = false;
|
||||
report('info', describePreset(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
function describePreset(result: PresetApplyResult): string {
|
||||
const parts = [
|
||||
`Preset « ${result.presetLabel} » appliqué — ${result.encoder}, ${result.video}`,
|
||||
];
|
||||
if (result.skipped.length > 0) parts.push(`refusé par OBS : ${result.skipped.join(', ')}`);
|
||||
if (result.restartRequired) {
|
||||
parts.push("l'encodeur a changé : redémarre OBS pour qu'il le charge");
|
||||
}
|
||||
return parts.join(' — ');
|
||||
}
|
||||
|
||||
// --- Boucle de statut --------------------------------------------------------
|
||||
|
||||
async function buildStatus(): Promise<AgentStatus> {
|
||||
@@ -320,6 +396,9 @@ async function buildStatus(): Promise<AgentStatus> {
|
||||
async function pushStatus(): Promise<void> {
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
// Le cycle de statut sert aussi d'horloge au preset en attente : il tourne
|
||||
// déjà, et il s'exécute justement quand OBS vient de redevenir joignable.
|
||||
await drainPresetApply();
|
||||
send({ type: 'status', status: await buildStatus() });
|
||||
} catch (err) {
|
||||
console.error('Collecte de statut en échec :', err);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user