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

@@ -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). - 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) - [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. (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 ## 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 lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein
écran à restaurer. é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 ## API HTTP
Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de session>`. Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de session>`.
@@ -181,7 +221,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `POST` | `/api/login` | Ouvre une session (`{ password }`) | | `POST` | `/api/login` | Ouvre une session (`{ password }`) |
| `GET` | `/api/agents` | Liste des agents et de leur état | | `GET` | `/api/agents` | Liste des agents et de leur état |
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** | | `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion | | `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset |
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) | | `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
| `DELETE` | `/api/agents/:id` | Supprime l'agent | | `DELETE` | `/api/agents/:id` | Supprime l'agent |
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent | | `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
@@ -192,7 +232,8 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`, Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`,
`record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`, `record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`,
`stream.stop`, `scene.set`, `profile.set`, `collection.set`, `recordDirectory.set`, `stream.stop`, `scene.set`, `profile.set`, `collection.set`, `recordDirectory.set`,
`watch.check`, `hotkey.fullscreen`, `agent.ping`. `watch.check`, `hotkey.fullscreen`, `browser.open`, `browser.close`, `capture.start`,
`capture.stop`, `preset.apply`, `agent.update`, `agent.ping`.
## Structure ## Structure

View File

@@ -7,16 +7,21 @@ import type {
AgentToServer, AgentToServer,
LogLevel, LogLevel,
BrowserSettings, BrowserSettings,
PresetApplyResult,
RecordingSettings,
ServerToAgent, ServerToAgent,
WatchSettings, WatchSettings,
} from '@stream-control/shared'; } from '@stream-control/shared';
import { import {
DEFAULT_BROWSER_SETTINGS, DEFAULT_BROWSER_SETTINGS,
DEFAULT_RECORDING_SETTINGS,
DEFAULT_WATCH_SETTINGS, DEFAULT_WATCH_SETTINGS,
PROTOCOL_VERSION, PROTOCOL_VERSION,
detectPlatform, detectPlatform,
emptyStatus, emptyStatus,
isRecordingEncoder,
normalizeBrowserSettings, normalizeBrowserSettings,
normalizeRecordingSettings,
normalizeWatchSettings, normalizeWatchSettings,
safeJsonParse, safeJsonParse,
} from '@stream-control/shared'; } from '@stream-control/shared';
@@ -56,6 +61,9 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
}); });
let browserSettings: BrowserSettings = DEFAULT_BROWSER_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 socket: WebSocket | null = null;
let statusTimer: NodeJS.Timeout | null = null; let statusTimer: NodeJS.Timeout | null = null;
@@ -157,6 +165,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
obs.applySettings(message.obs); obs.applySettings(message.obs);
applyWatchSettings(message.watch); applyWatchSettings(message.watch);
applyBrowserSettings(message.browser); applyBrowserSettings(message.browser);
applyRecordingSettings(message.recording);
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`); report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
if (message.autoConnectObs && !obs.isConnected) { if (message.autoConnectObs && !obs.isConnected) {
@@ -170,6 +179,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
obs.applySettings(message.obs); obs.applySettings(message.obs);
applyWatchSettings(message.watch); applyWatchSettings(message.watch);
applyBrowserSettings(message.browser); applyBrowserSettings(message.browser);
applyRecordingSettings(message.recording);
if (message.autoConnectObs && !obs.isConnected) { if (message.autoConnectObs && !obs.isConnected) {
obs.connect().catch((err: Error) => report('warn', err.message)); 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); return startCapture(params);
case 'capture.stop': case 'capture.stop':
return stopCapture(); return stopCapture();
case 'preset.apply':
return applyPresetNow(params);
case 'agent.update': case 'agent.update':
return selfUpdate( return selfUpdate(
typeof params.url === 'string' && params.url ? params.url : config.packageUrl, 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); 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 -------------------------------------------------------- // --- Boucle de statut --------------------------------------------------------
async function buildStatus(): Promise<AgentStatus> { async function buildStatus(): Promise<AgentStatus> {
@@ -320,6 +396,9 @@ async function buildStatus(): Promise<AgentStatus> {
async function pushStatus(): Promise<void> { async function pushStatus(): Promise<void> {
if (socket?.readyState !== WebSocket.OPEN) return; if (socket?.readyState !== WebSocket.OPEN) return;
try { 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() }); send({ type: 'status', status: await buildStatus() });
} catch (err) { } catch (err) {
console.error('Collecte de statut en échec :', err); console.error('Collecte de statut en échec :', err);

View File

@@ -1,10 +1,19 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js'; 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 * Actions relevant d'OBS. `watch.*`, `hotkey.*`, `preset.apply` et
* en amont par l'agent : elles ne concernent pas la session obs-websocket. * `agent.update` sont traitées en amont par l'agent : elles ne se réduisent pas
* à un appel obs-websocket.
*/ */
export type ObsAction = Exclude< export type ObsAction = Exclude<
AgentAction, AgentAction,
@@ -15,6 +24,7 @@ export type ObsAction = Exclude<
| 'browser.close' | 'browser.close'
| 'capture.start' | 'capture.start'
| 'capture.stop' | 'capture.stop'
| 'preset.apply'
>; >;
type ObsSnapshot = Pick< 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. */ /** Interroge OBS pour construire l'état courant ; ne lève jamais. */
async snapshot(): Promise<ObsSnapshot> { async snapshot(): Promise<ObsSnapshot> {
const base: 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 { function requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) { if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Paramètre « ${field} » manquant`); throw new Error(`Paramètre « ${field} » manquant`);

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ export const AGENT_ACTIONS = [
'browser.close', 'browser.close',
'capture.start', 'capture.start',
'capture.stop', 'capture.stop',
'preset.apply',
'agent.update', 'agent.update',
'agent.ping', 'agent.ping',
] as const; ] as const;
@@ -65,6 +66,244 @@ export interface AgentActionParams {
'profile.set': { profile: string }; 'profile.set': { profile: string };
'collection.set': { collection: string }; 'collection.set': { collection: string };
'recordDirectory.set': { directory: 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; autoConnectObs: boolean;
watch: WatchSettings; watch: WatchSettings;
browser: BrowserSettings; browser: BrowserSettings;
recording: RecordingSettings;
} }
export interface CommandMessage { export interface CommandMessage {
@@ -356,6 +596,7 @@ export interface ConfigMessage {
autoConnectObs: boolean; autoConnectObs: boolean;
watch: WatchSettings; watch: WatchSettings;
browser: BrowserSettings; browser: BrowserSettings;
recording: RecordingSettings;
} }
export interface PingMessage { export interface PingMessage {
@@ -382,6 +623,7 @@ export interface AgentView {
autoConnectObs: boolean; autoConnectObs: boolean;
watch: WatchSettings; watch: WatchSettings;
browser: BrowserSettings; browser: BrowserSettings;
recording: RecordingSettings;
notes: string | null; notes: string | null;
status: AgentStatus; status: AgentStatus;
} }

View File

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

View File

@@ -3,10 +3,14 @@ import type {
AgentAction, AgentAction,
AgentView, AgentView,
BrowserSettings, BrowserSettings,
PresetApplyResult,
RecordingPreset,
RecordingSettings,
StreamState, StreamState,
WatchSettings, WatchSettings,
WatchTarget, WatchTarget,
} from '@stream-control/shared'; } from '@stream-control/shared';
import { RECORDING_ENCODERS, RECORDING_PRESETS, findRecordingPreset } from '@stream-control/shared';
import { api } from '../api'; import { api } from '../api';
interface Props { interface Props {
@@ -25,6 +29,47 @@ const STATE_HINTS: Record<StreamState, string> = {
unknown: '', 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) { export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Props) {
const [name, setName] = useState(agent.name); const [name, setName] = useState(agent.name);
const [host, setHost] = useState(agent.obs.host); 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 [busy, setBusy] = useState(false);
const [watch, setWatch] = useState<WatchSettings>(agent.watch); const [watch, setWatch] = useState<WatchSettings>(agent.watch);
const [browser, setBrowser] = useState<BrowserSettings>(agent.browser); 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é // Saisie libre : soit l'opérateur la demande, soit le pseudo déjà configuré
// ne fait pas partie des streamers suivis. // 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 } })); 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() { async function save() {
setBusy(true); setBusy(true);
try { try {
@@ -66,6 +137,7 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
obs: { host, port: Number(port), password }, obs: { host, port: Number(port), password },
watch, watch,
browser, browser,
recording,
} as Partial<AgentView>); } as Partial<AgentView>);
notify('Configuration enregistrée'); notify('Configuration enregistrée');
onClose(); onClose();
@@ -167,6 +239,96 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
</button> </button>
</div> </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"> <fieldset className="group">
<legend>Pilotage du navigateur</legend> <legend>Pilotage du navigateur</legend>

View File

@@ -393,6 +393,21 @@ fieldset.group {
text-decoration: underline; 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 { fieldset.group > legend {
padding: 0 6px; padding: 0 6px;
font-size: 12px; font-size: 12px;