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

@@ -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);

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`);

View File

@@ -112,6 +112,7 @@ export function handleAgentConnection(
autoConnectObs: record.autoConnectObs,
watch: record.watch,
browser: record.browser,
recording: record.recording,
});
break;
}

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_OBS_SETTINGS,
isAgentAction,
normalizeBrowserSettings,
normalizeRecordingSettings,
normalizeWatchSettings,
parseStripchatUsername,
} from '@stream-control/shared';
@@ -91,6 +92,7 @@ api.patch('/agents/:id', (req, res) => {
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs,
watch: normalizeWatchSettings(req.body?.watch ?? record.watch),
browser: normalizeBrowserSettings(req.body?.browser ?? record.browser),
recording: normalizeRecordingSettings(req.body?.recording ?? record.recording),
notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes,
});
@@ -308,6 +310,7 @@ api.post('/watchlist/:id/record', async (req, res) => {
autoConnectObs: record.autoConnectObs,
watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }),
browser: record.browser,
recording: record.recording,
notes: record.notes,
});

View File

@@ -6,6 +6,7 @@ import type {
LogLevel,
ObsSettings,
Platform,
RecordingSettings,
StreamState,
BrowserSettings,
WatchSettings,
@@ -14,8 +15,10 @@ import type {
import {
DEFAULT_BROWSER_SETTINGS,
DEFAULT_OBS_SETTINGS,
DEFAULT_RECORDING_SETTINGS,
DEFAULT_WATCH_SETTINGS,
normalizeBrowserSettings,
normalizeRecordingSettings,
normalizeWatchSettings,
safeJsonParse,
stripchatProfileUrl,
@@ -90,6 +93,9 @@ function addColumnIfMissing(table: string, column: string, definition: string):
// que la table (nouveaux fournisseurs, nouveaux statuts).
addColumnIfMissing('agents', 'watch_json', 'TEXT');
addColumnIfMissing('agents', 'browser_json', 'TEXT');
// Preset d'enregistrement : réglable VM par VM, absent = aucune intervention
// de l'agent sur les réglages d'OBS.
addColumnIfMissing('agents', 'recording_json', 'TEXT');
// Enrichissement des profils surveillés : photo et historique de diffusion.
addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT');
@@ -110,6 +116,7 @@ export interface AgentRow {
auto_connect: number;
watch_json: string | null;
browser_json: string | null;
recording_json: string | null;
notes: string | null;
created_at: number;
last_seen_at: number | null;
@@ -126,6 +133,7 @@ export interface AgentRecord {
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
recording: RecordingSettings;
notes: string | null;
createdAt: number;
lastSeenAt: number | null;
@@ -151,6 +159,11 @@ function toRecord(row: AgentRow): AgentRecord {
browser: normalizeBrowserSettings(
row.browser_json ? safeJsonParse<BrowserSettings>(row.browser_json) : DEFAULT_BROWSER_SETTINGS,
),
recording: normalizeRecordingSettings(
row.recording_json
? safeJsonParse<RecordingSettings>(row.recording_json)
: DEFAULT_RECORDING_SETTINGS,
),
notes: row.notes,
createdAt: Number(row.created_at),
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at),
@@ -164,8 +177,8 @@ const stmts = {
insertAgent: db.prepare(`
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
obs_host, obs_port, obs_password, auto_connect, watch_json,
browser_json, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
browser_json, recording_json, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
updateIdentity: db.prepare(`
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
@@ -173,7 +186,8 @@ const stmts = {
`),
updateSettings: db.prepare(`
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
auto_connect = ?, watch_json = ?, browser_json = ?, notes = ?
auto_connect = ?, watch_json = ?, browser_json = ?,
recording_json = ?, notes = ?
WHERE id = ?
`),
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
@@ -217,6 +231,7 @@ export const agentsRepo = {
autoConnectObs?: boolean;
watch?: WatchSettings;
browser?: BrowserSettings;
recording?: RecordingSettings;
notes?: string | null;
}): AgentRecord {
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
@@ -233,6 +248,7 @@ export const agentsRepo = {
input.autoConnectObs === false ? 0 : 1,
JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)),
JSON.stringify(normalizeBrowserSettings(input.browser ?? DEFAULT_BROWSER_SETTINGS)),
JSON.stringify(normalizeRecordingSettings(input.recording ?? DEFAULT_RECORDING_SETTINGS)),
input.notes ?? null,
Date.now(),
);
@@ -262,6 +278,7 @@ export const agentsRepo = {
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
recording: RecordingSettings;
notes: string | null;
},
): void {
@@ -273,6 +290,7 @@ export const agentsRepo = {
settings.autoConnectObs ? 1 : 0,
JSON.stringify(normalizeWatchSettings(settings.watch)),
JSON.stringify(normalizeBrowserSettings(settings.browser)),
JSON.stringify(normalizeRecordingSettings(settings.recording)),
settings.notes,
id,
);

View File

@@ -139,6 +139,7 @@ class Hub {
autoConnectObs: record.autoConnectObs,
watch: record.watch,
browser: record.browser,
recording: record.recording,
};
connection.socket.send(JSON.stringify(message));
}
@@ -177,6 +178,7 @@ class Hub {
autoConnectObs: record.autoConnectObs,
watch: record.watch,
browser: record.browser,
recording: record.recording,
notes: record.notes,
status: this.statusOf(record.id),
};

View File

@@ -49,6 +49,7 @@ export const AGENT_ACTIONS = [
'browser.close',
'capture.start',
'capture.stop',
'preset.apply',
'agent.update',
'agent.ping',
] as const;
@@ -65,6 +66,244 @@ export interface AgentActionParams {
'profile.set': { profile: string };
'collection.set': { collection: string };
'recordDirectory.set': { directory: string };
/** Sans paramètre, l'agent applique le preset configuré côté serveur. */
'preset.apply': { presetId?: string; encoder?: RecordingEncoder };
}
// ---------------------------------------------------------------------------
// Presets d'enregistrement
// ---------------------------------------------------------------------------
/**
* Un preset décrit un compromis qualité / charge CPU. Il se traduit en écritures
* dans le profil OBS courant (`SetProfileParameter`, section `SimpleOutput`) et
* en un réglage vidéo (`SetVideoSettings`).
*
* Deux limites d'OBS qu'aucun contournement propre ne lève :
* - les paramètres de profil sont relus à chaque démarrage d'enregistrement,
* donc un changement de qualité prend effet au prochain enregistrement ;
* - l'objet encodeur, lui, n'est instancié qu'au lancement d'OBS : changer
* `RecEncoder` exige un redémarrage d'OBS pour être réellement pris en compte.
*/
export interface RecordingEncoderInfo {
/** Valeur écrite dans `SimpleOutput/RecEncoder`. */
obsValue: string;
label: string;
/** Clé de profil pilotant le compromis vitesse/qualité ; null si l'encodeur n'en expose pas. */
speedKey: string | null;
/** Jeu de valeurs accepté par cette clé. */
speedFamily: SpeedFamily | null;
hint: string;
}
export type SpeedFamily = 'x264' | 'nvenc' | 'qsv' | 'amd';
/**
* Pas d'auto-détection : obs-websocket ne publie pas la liste des encodeurs
* disponibles, et le matériel diffère d'une VM à l'autre. C'est l'opérateur qui
* choisit, VM par VM.
*/
export const RECORDING_ENCODERS = {
x264: {
obsValue: 'x264',
label: 'x264 — logiciel (CPU)',
speedKey: 'Preset',
speedFamily: 'x264',
hint: 'Toujours disponible. Le seul choix sur un VPS sans GPU.',
},
x264_lowcpu: {
obsValue: 'x264_lowcpu',
label: 'x264 économe — logiciel (CPU)',
speedKey: null,
speedFamily: null,
hint: "Force le réglage le plus rapide de x264 : CPU au plancher, fichiers plus gros. Ignore la vitesse du preset.",
},
nvenc: {
obsValue: 'nvenc',
label: 'NVENC H.264 — NVIDIA',
speedKey: 'NVENCPreset2',
speedFamily: 'nvenc',
hint: 'Encodage déporté sur le GPU : CPU quasi nul. Exige une carte NVIDIA.',
},
nvenc_hevc: {
obsValue: 'nvenc_hevc',
label: 'NVENC HEVC — NVIDIA',
speedKey: 'NVENCPreset2',
speedFamily: 'nvenc',
hint: 'Environ 30 % de poids en moins que H.264, moins universellement lisible.',
},
qsv: {
obsValue: 'qsv',
label: 'Quick Sync H.264 — Intel',
speedKey: 'QSVPreset',
speedFamily: 'qsv',
hint: 'iGPU Intel. Disponible sur beaucoup de VM bureautiques.',
},
amd: {
obsValue: 'amd',
label: 'AMF H.264 — AMD',
speedKey: 'AMDPreset',
speedFamily: 'amd',
hint: 'GPU AMD.',
},
apple_h264: {
obsValue: 'apple_h264',
label: 'VideoToolbox H.264 — macOS',
speedKey: null,
speedFamily: null,
hint: 'macOS uniquement.',
},
} as const satisfies Record<string, RecordingEncoderInfo>;
export type RecordingEncoder = keyof typeof RECORDING_ENCODERS;
export const DEFAULT_RECORDING_ENCODER: RecordingEncoder = 'x264';
export function isRecordingEncoder(value: unknown): value is RecordingEncoder {
return typeof value === 'string' && Object.hasOwn(RECORDING_ENCODERS, value);
}
export interface RecordingPreset {
id: string;
label: string;
summary: string;
/** Charge CPU relative, 1 = la plus légère. Sert à ordonner l'interface. */
cpuCost: 1 | 2 | 3 | 4;
/** Hauteur de sortie ; `null` conserve la résolution de la scène. Jamais d'agrandissement. */
height: number | null;
/**
* Images par seconde, toujours explicite. Un « ne pas toucher » rendrait le
* résultat dépendant du preset appliqué juste avant : passer d'Économe à
* Qualité maximale garderait les 30 fps du premier, sans que rien ne le dise.
*/
fps: number;
/**
* `SimpleOutput/RecQuality` : OBS en déduit un CRF (x264) ou un CQP (matériel).
* On évite volontairement le mode à débit fixe, qui gâche des bits sur les
* plans statiques et sature sur les plans animés.
*/
quality: 'Small' | 'HQ' | 'Lossless';
/** `mkv` survit à un plantage ; `hybrid_mp4` est lisible partout. */
format: 'mkv' | 'hybrid_mp4';
audioBitrateKbps: number;
/** Valeur de vitesse à écrire, selon la famille d'encodeur retenue. */
speed: Record<SpeedFamily, string>;
}
/**
* Catalogue ordonné du plus léger au plus lourd.
*
* Le levier de qualité est le CRF (`quality`), celui de charge CPU est la
* vitesse d'encodage (`speed`) puis la définition. Un preset lourd sur une VM
* sous-dimensionnée fait chuter les images par seconde : la qualité perçue
* baisse alors, malgré un CRF meilleur. Surveille « Frames perdues » sur la
* fiche de l'agent après un changement.
*/
export const RECORDING_PRESETS: RecordingPreset[] = [
{
id: 'light',
label: 'Économe',
summary:
'720p 30 fps, encodage rapide. Pour les VM à petit CPU ou plusieurs captures en parallèle.',
cpuCost: 1,
height: 720,
fps: 30,
quality: 'Small',
format: 'hybrid_mp4',
audioBitrateKbps: 128,
speed: { x264: 'superfast', nvenc: 'p3', qsv: 'speed', amd: 'speed' },
},
{
id: 'balanced',
label: 'Équilibré',
summary: "1080p 30 fps, qualité élevée. Le compromis par défaut : bonne image sans saturer un CPU modeste.",
cpuCost: 2,
height: 1080,
fps: 30,
quality: 'HQ',
format: 'hybrid_mp4',
audioBitrateKbps: 160,
speed: { x264: 'veryfast', nvenc: 'p5', qsv: 'balanced', amd: 'balanced' },
},
{
id: 'archive',
label: 'Qualité maximale',
summary:
"Définition de la scène, 60 fps, encodage lent. Exige un CPU confortable ou un encodeur matériel.",
cpuCost: 3,
height: null,
fps: 60,
quality: 'HQ',
format: 'mkv',
audioBitrateKbps: 192,
speed: { x264: 'medium', nvenc: 'p6', qsv: 'quality', amd: 'quality' },
},
{
id: 'lossless',
label: 'Sans perte',
summary:
'Aucune compression destructrice, 60 fps. Des dizaines de Go par heure : à réserver aux captures courtes.',
cpuCost: 4,
height: null,
fps: 60,
quality: 'Lossless',
format: 'mkv',
audioBitrateKbps: 320,
// OBS impose ultrafast en sans perte ; les autres valeurs sont là pour la forme.
speed: { x264: 'ultrafast', nvenc: 'p1', qsv: 'speed', amd: 'speed' },
},
];
export const DEFAULT_RECORDING_PRESET_ID = 'balanced';
export function findRecordingPreset(id: string): RecordingPreset | null {
return RECORDING_PRESETS.find((preset) => preset.id === id) ?? null;
}
/** Preset choisi pour un agent. Réglable indépendamment sur chaque VM. */
export interface RecordingSettings {
/** Tant que c'est faux, l'agent ne touche jamais aux réglages d'OBS. */
enabled: boolean;
presetId: string;
encoder: RecordingEncoder;
}
export const DEFAULT_RECORDING_SETTINGS: RecordingSettings = {
enabled: false,
presetId: DEFAULT_RECORDING_PRESET_ID,
encoder: DEFAULT_RECORDING_ENCODER,
};
export function normalizeRecordingSettings(raw: unknown): RecordingSettings {
const input = (raw ?? {}) as Partial<RecordingSettings>;
const base = DEFAULT_RECORDING_SETTINGS;
return {
enabled: input.enabled === true,
presetId:
typeof input.presetId === 'string' && findRecordingPreset(input.presetId)
? input.presetId
: base.presetId,
encoder: isRecordingEncoder(input.encoder) ? input.encoder : base.encoder,
};
}
/** Compte rendu détaillé d'une application de preset, remonté au dashboard. */
export interface PresetApplyResult {
presetId: string;
presetLabel: string;
encoder: RecordingEncoder;
/** Paramètres écrits, sous la forme `clé=valeur`. */
applied: string[];
/** Paramètres refusés par OBS, avec le motif. */
skipped: string[];
/** Résumé lisible du réglage vidéo obtenu (définition et fluidité de sortie). */
video: string;
/**
* Vrai si l'encodeur configuré diffère de celui qu'OBS a chargé au démarrage :
* la nouvelle valeur est écrite, mais OBS continuera d'utiliser l'ancienne
* jusqu'à son redémarrage.
*/
restartRequired: boolean;
}
// ---------------------------------------------------------------------------
@@ -341,6 +580,7 @@ export interface WelcomeMessage {
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
recording: RecordingSettings;
}
export interface CommandMessage {
@@ -356,6 +596,7 @@ export interface ConfigMessage {
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
recording: RecordingSettings;
}
export interface PingMessage {
@@ -382,6 +623,7 @@ export interface AgentView {
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
recording: RecordingSettings;
notes: string | null;
status: AgentStatus;
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import type { AgentAction, AgentView, WatchState } from '@stream-control/shared';
import { findRecordingPreset } from '@stream-control/shared';
import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format';
interface Props {
@@ -27,6 +28,7 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
const obsReady = agent.online && status.obsConnected;
const watch = status.watch;
const presetLabel = findRecordingPreset(agent.recording.presetId)?.label ?? null;
const pausedLabel = watch?.autoPaused ? 'Pause · show privé' : 'En pause';
const state = !agent.online
@@ -59,6 +61,11 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
{status.buildId ? ` · build ${status.buildId}` : ''}
</span>
</div>
{agent.recording.enabled && presetLabel && (
<span className="badge" title="Preset d'enregistrement piloté depuis le dashboard">
{presetLabel}
</span>
)}
<span className={`badge ${state.tone}`}>{state.label}</span>
<button className="icon" onClick={() => onOpenSettings(agent)} title="Configuration">

View File

@@ -3,10 +3,14 @@ import type {
AgentAction,
AgentView,
BrowserSettings,
PresetApplyResult,
RecordingPreset,
RecordingSettings,
StreamState,
WatchSettings,
WatchTarget,
} from '@stream-control/shared';
import { RECORDING_ENCODERS, RECORDING_PRESETS, findRecordingPreset } from '@stream-control/shared';
import { api } from '../api';
interface Props {
@@ -25,6 +29,47 @@ const STATE_HINTS: Record<StreamState, string> = {
unknown: '',
};
const QUALITY_LABELS: Record<RecordingPreset['quality'], string> = {
Small: 'qualité haute',
HQ: 'qualité très haute',
Lossless: 'sans perte',
};
/** Résume en une ligne ce que le preset impose réellement à OBS. */
function describePreset(preset: RecordingPreset): string {
return [
preset.height ? `${preset.height}p` : 'définition de la scène',
`${preset.fps} fps`,
QUALITY_LABELS[preset.quality],
preset.format === 'mkv' ? 'MKV' : 'MP4',
`audio ${preset.audioBitrateKbps} kb/s`,
].join(' · ');
}
function cpuGauge(cost: number): string {
return '●'.repeat(cost) + '○'.repeat(4 - cost);
}
/** Compte rendu de l'agent : ce qu'OBS a accepté, refusé, et ce qui reste à faire. */
function PresetReport({ result }: { result: PresetApplyResult }) {
return (
<div className="preset-report">
<p className="small">
<strong>{result.presetLabel}</strong> appliqué · {result.video} ·{' '}
{result.applied.length} paramètre(s) écrit(s)
</p>
{result.skipped.length > 0 && (
<p className="small error">Refusé par OBS : {result.skipped.join(', ')}</p>
)}
{result.restartRequired && (
<p className="small warn-text">
L'encodeur a changé. OBS continuera d'utiliser le précédent jusqu'à son redémarrage.
</p>
)}
</div>
);
}
export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Props) {
const [name, setName] = useState(agent.name);
const [host, setHost] = useState(agent.obs.host);
@@ -37,6 +82,12 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
const [busy, setBusy] = useState(false);
const [watch, setWatch] = useState<WatchSettings>(agent.watch);
const [browser, setBrowser] = useState<BrowserSettings>(agent.browser);
const [recording, setRecording] = useState<RecordingSettings>(agent.recording);
const [presetResult, setPresetResult] = useState<PresetApplyResult | null>(null);
const [applyingPreset, setApplyingPreset] = useState(false);
const preset = findRecordingPreset(recording.presetId) ?? RECORDING_PRESETS[0]!;
const encoderInfo = RECORDING_ENCODERS[recording.encoder];
// Saisie libre : soit l'opérateur la demande, soit le pseudo déjà configuré
// ne fait pas partie des streamers suivis.
@@ -56,6 +107,26 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } }));
}
/**
* Applique le preset actuellement sélectionné, enregistré ou non : c'est un
* essai, on veut voir le compte rendu avant de figer le choix.
*/
async function applyPreset() {
setApplyingPreset(true);
setPresetResult(null);
try {
const { data } = await api.command(agent.id, 'preset.apply', {
presetId: recording.presetId,
encoder: recording.encoder,
});
setPresetResult(data as PresetApplyResult);
} catch (err) {
notify(err instanceof Error ? err.message : 'Application du preset impossible', 'error');
} finally {
setApplyingPreset(false);
}
}
async function save() {
setBusy(true);
try {
@@ -66,6 +137,7 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
obs: { host, port: Number(port), password },
watch,
browser,
recording,
} as Partial<AgentView>);
notify('Configuration enregistrée');
onClose();
@@ -167,6 +239,96 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
</button>
</div>
<fieldset className="group">
<legend>Preset d'enregistrement</legend>
<label className="checkbox">
<input
type="checkbox"
checked={recording.enabled}
onChange={(event) =>
setRecording((current) => ({ ...current, enabled: event.target.checked }))
}
/>
<span>
Piloter les réglages d'OBS depuis cette interface
<span className="muted small">
{' '}
décoché, l'agent ne touche à rien et OBS garde sa configuration manuelle.
</span>
</span>
</label>
<div className="row">
<label className="field grow">
<span>Preset</span>
<select
value={recording.presetId}
onChange={(event) =>
setRecording((current) => ({ ...current, presetId: event.target.value }))
}
>
{RECORDING_PRESETS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label} — CPU {cpuGauge(entry.cpuCost)}
</option>
))}
</select>
<span className="muted small">{preset.summary}</span>
</label>
<label className="field grow">
<span>Encodeur</span>
<select
value={recording.encoder}
onChange={(event) =>
setRecording((current) => ({
...current,
encoder: event.target.value as RecordingSettings['encoder'],
}))
}
>
{Object.entries(RECORDING_ENCODERS).map(([id, info]) => (
<option key={id} value={id}>
{info.label}
</option>
))}
</select>
<span className="muted small">{encoderInfo?.hint}</span>
</label>
</div>
<p className="muted small">
Réglages obtenus : <strong>{describePreset(preset)}</strong>
</p>
<div className="row">
<button
className="ghost"
disabled={applyingPreset || !agent.online || agent.status.recording}
title={
agent.status.recording
? 'Enregistrement en cours un changement de preset le corromprait'
: "Écrire ces réglages dans le profil OBS de cette VM"
}
onClick={() => void applyPreset()}
>
{applyingPreset ? 'Application' : 'Appliquer maintenant'}
</button>
<span className="muted small align-end">
Sinon appliqué à l'enregistrement de cette fiche.
</span>
</div>
{presetResult && <PresetReport result={presetResult} />}
<p className="muted small">
La qualité prend effet au prochain démarrage d'enregistrement. Un changement
d'encodeur, lui, exige un redémarrage d'OBS : celui-ci ne charge le sien qu'au
lancement.
</p>
</fieldset>
<fieldset className="group">
<legend>Pilotage du navigateur</legend>

View File

@@ -393,6 +393,21 @@ fieldset.group {
text-decoration: underline;
}
.preset-report {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
background: var(--panel-2);
border-radius: 8px;
}
.preset-report p {
margin: 0;
}
.warn-text {
color: #fcd34d;
}
fieldset.group > legend {
padding: 0 6px;
font-size: 12px;