diff --git a/README.md b/README.md index ced6ce7..64579e3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. - Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur). - [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés) (Stripchat), avec reprise et rappel du plein écran au retour du flux public. +- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface, + réglables VM par VM : de « Économe » à « Sans perte ». ## Prérequis @@ -172,6 +174,44 @@ Si le rappel du plein écran s'avère fragile sur ta VM, l'alternative sans clav lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein écran à restaurer. +## Presets d'enregistrement + +Chaque VM peut être réglée sur un compromis qualité / charge CPU différent, depuis la +fiche de l'agent (⚙ → « Preset d'enregistrement »). Tant que la case n'est pas cochée, +l'agent ne touche à rien et OBS garde sa configuration manuelle. + +| Preset | Sortie | Qualité | Conteneur | CPU | +| --- | --- | --- | --- | --- | +| Économe | 720p 30 fps | haute (CRF ~23) | MP4 | ● | +| Équilibré | 1080p 30 fps | très haute (CRF ~16) | MP4 | ●● | +| Qualité maximale | définition de la scène, 60 fps | très haute, encodage lent | MKV | ●●● | +| Sans perte | définition de la scène, 60 fps | aucune perte | MKV | ●●●● | + +L'encodeur se choisit séparément : x264 (le seul disponible sur un VPS sans GPU), NVENC, +Quick Sync, AMF ou VideoToolbox. Le preset fournit la valeur de vitesse adaptée à la +famille retenue — `veryfast` pour x264, `p5` pour NVENC, etc. + +### Ce qui se passe à l'application + +L'agent écrit dans le profil OBS courant (`SetProfileParameter`, section `SimpleOutput`) +puis ajuste la sortie vidéo (`SetVideoSettings`). Trois comportements à connaître : + +- **La qualité prend effet au prochain démarrage d'enregistrement.** OBS relit ces + paramètres à ce moment-là ; l'interface d'OBS, elle, ne les rafraîchit qu'au changement + de profil. +- **Changer d'encodeur exige un redémarrage d'OBS.** L'objet encodeur n'est instancié + qu'au lancement. L'agent le détecte et le signale dans son compte rendu. +- **Un preset n'est jamais appliqué pendant une capture** : cela la corromprait. La + demande est refusée, ou différée jusqu'à l'arrêt de l'enregistrement. + +Le mode de sortie du profil est forcé sur « Simple » : c'est la section que ces réglages +pilotent. Un paramètre refusé par la version d'OBS installée n'interrompt pas les autres — +il apparaît dans le compte rendu affiché sous le bouton « Appliquer maintenant ». + +Un preset trop lourd pour la VM fait chuter les images par seconde : la qualité perçue +baisse alors malgré un meilleur CRF. Après un changement, surveille « FPS » et « Frames +perdues » sur la fiche de l'agent. + ## API HTTP Toutes les routes hors `/api/login` exigent `Authorization: Bearer `. @@ -181,7 +221,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer { 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 { 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): 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 { + 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): Promise { + 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 { @@ -320,6 +396,9 @@ async function buildStatus(): Promise { async function pushStatus(): Promise { 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); diff --git a/packages/agent/src/obs.ts b/packages/agent/src/obs.ts index c4cea58..01a1b95 100644 --- a/packages/agent/src/obs.ts +++ b/packages/agent/src/obs.ts @@ -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 { + 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 => { + 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 { + 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 { + 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 { 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`); diff --git a/packages/server/src/agentGateway.ts b/packages/server/src/agentGateway.ts index 70ad23a..d2d321f 100644 --- a/packages/server/src/agentGateway.ts +++ b/packages/server/src/agentGateway.ts @@ -112,6 +112,7 @@ export function handleAgentConnection( autoConnectObs: record.autoConnectObs, watch: record.watch, browser: record.browser, + recording: record.recording, }); break; } diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 40e0cd1..507c253 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -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, }); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 7559a9f..4413931 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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(row.browser_json) : DEFAULT_BROWSER_SETTINGS, ), + recording: normalizeRecordingSettings( + row.recording_json + ? safeJsonParse(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, ); diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index a7cd1c1..804488a 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -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), }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0263331..e48585a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; + +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; +} + +/** + * 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; + 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; } diff --git a/packages/web/src/components/AgentCard.tsx b/packages/web/src/components/AgentCard.tsx index 8e05799..7ede8fb 100644 --- a/packages/web/src/components/AgentCard.tsx +++ b/packages/web/src/components/AgentCard.tsx @@ -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}` : ''} + {agent.recording.enabled && presetLabel && ( + + {presetLabel} + + )} {state.label} +
+ Preset d'enregistrement + + + +
+ + + +
+ +

+ Réglages obtenus : {describePreset(preset)} +

+ +
+ + + Sinon appliqué à l'enregistrement de cette fiche. + +
+ + {presetResult && } + +

+ 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. +

+
+
Pilotage du navigateur diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 35caf6a..913d6b0 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -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;