Feat : OBS presets
This commit is contained in:
45
README.md
45
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 <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 }`) |
|
||||
| `GET` | `/api/agents` | Liste des agents et de leur état |
|
||||
| `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) |
|
||||
| `DELETE` | `/api/agents/:id` | Supprime 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`,
|
||||
`record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`,
|
||||
`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
|
||||
|
||||
|
||||
@@ -7,16 +7,21 @@ import type {
|
||||
AgentToServer,
|
||||
LogLevel,
|
||||
BrowserSettings,
|
||||
PresetApplyResult,
|
||||
RecordingSettings,
|
||||
ServerToAgent,
|
||||
WatchSettings,
|
||||
} from '@stream-control/shared';
|
||||
import {
|
||||
DEFAULT_BROWSER_SETTINGS,
|
||||
DEFAULT_RECORDING_SETTINGS,
|
||||
DEFAULT_WATCH_SETTINGS,
|
||||
PROTOCOL_VERSION,
|
||||
detectPlatform,
|
||||
emptyStatus,
|
||||
isRecordingEncoder,
|
||||
normalizeBrowserSettings,
|
||||
normalizeRecordingSettings,
|
||||
normalizeWatchSettings,
|
||||
safeJsonParse,
|
||||
} from '@stream-control/shared';
|
||||
@@ -56,6 +61,9 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
||||
});
|
||||
|
||||
let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS;
|
||||
let recordingSettings: RecordingSettings = DEFAULT_RECORDING_SETTINGS;
|
||||
/** Un preset attend d'être appliqué : OBS était injoignable ou occupé. */
|
||||
let presetPending = false;
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let statusTimer: NodeJS.Timeout | null = null;
|
||||
@@ -157,6 +165,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
applyBrowserSettings(message.browser);
|
||||
applyRecordingSettings(message.recording);
|
||||
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
||||
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
@@ -170,6 +179,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
applyBrowserSettings(message.browser);
|
||||
applyRecordingSettings(message.recording);
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
obs.connect().catch((err: Error) => report('warn', err.message));
|
||||
}
|
||||
@@ -273,6 +283,8 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
||||
return startCapture(params);
|
||||
case 'capture.stop':
|
||||
return stopCapture();
|
||||
case 'preset.apply':
|
||||
return applyPresetNow(params);
|
||||
case 'agent.update':
|
||||
return selfUpdate(
|
||||
typeof params.url === 'string' && params.url ? params.url : config.packageUrl,
|
||||
@@ -294,6 +306,70 @@ function applyBrowserSettings(raw: BrowserSettings | undefined): void {
|
||||
browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS);
|
||||
}
|
||||
|
||||
// --- Presets d'enregistrement ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Enregistre le preset voulu. L'application effective est différée : OBS peut
|
||||
* être déconnecté ou en pleine capture au moment où la configuration arrive.
|
||||
*/
|
||||
function applyRecordingSettings(raw: RecordingSettings | undefined): void {
|
||||
const next = normalizeRecordingSettings(raw ?? DEFAULT_RECORDING_SETTINGS);
|
||||
const changed =
|
||||
next.enabled !== recordingSettings.enabled ||
|
||||
next.presetId !== recordingSettings.presetId ||
|
||||
next.encoder !== recordingSettings.encoder;
|
||||
|
||||
recordingSettings = next;
|
||||
if (next.enabled && changed) presetPending = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le preset en attente dès que les conditions le permettent.
|
||||
*
|
||||
* Un échec ne relance pas la tentative : une clé refusée par cette version d'OBS
|
||||
* le sera à chaque cycle, et marteler l'API n'y changerait rien. Le prochain
|
||||
* enregistrement de la configuration, ou le bouton du dashboard, réessaiera.
|
||||
*/
|
||||
async function drainPresetApply(): Promise<void> {
|
||||
if (!presetPending || !recordingSettings.enabled || !obs.isConnected) return;
|
||||
|
||||
const state = await obs.recordState().catch(() => null);
|
||||
if (!state || state.active) return; // jamais pendant une capture
|
||||
|
||||
presetPending = false;
|
||||
try {
|
||||
report('info', describePreset(await obs.applyRecordingPreset(recordingSettings)));
|
||||
} catch (err) {
|
||||
report('warn', `Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Application à la demande : passe outre l'interrupteur, l'opérateur a tranché. */
|
||||
async function applyPresetNow(params: Record<string, unknown>): Promise<PresetApplyResult> {
|
||||
const result = await obs.applyRecordingPreset({
|
||||
enabled: true,
|
||||
presetId:
|
||||
typeof params.presetId === 'string' && params.presetId
|
||||
? params.presetId
|
||||
: recordingSettings.presetId,
|
||||
encoder: isRecordingEncoder(params.encoder) ? params.encoder : recordingSettings.encoder,
|
||||
});
|
||||
presetPending = false;
|
||||
report('info', describePreset(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
function describePreset(result: PresetApplyResult): string {
|
||||
const parts = [
|
||||
`Preset « ${result.presetLabel} » appliqué — ${result.encoder}, ${result.video}`,
|
||||
];
|
||||
if (result.skipped.length > 0) parts.push(`refusé par OBS : ${result.skipped.join(', ')}`);
|
||||
if (result.restartRequired) {
|
||||
parts.push("l'encodeur a changé : redémarre OBS pour qu'il le charge");
|
||||
}
|
||||
return parts.join(' — ');
|
||||
}
|
||||
|
||||
// --- Boucle de statut --------------------------------------------------------
|
||||
|
||||
async function buildStatus(): Promise<AgentStatus> {
|
||||
@@ -320,6 +396,9 @@ async function buildStatus(): Promise<AgentStatus> {
|
||||
async function pushStatus(): Promise<void> {
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
// Le cycle de statut sert aussi d'horloge au preset en attente : il tourne
|
||||
// déjà, et il s'exécute justement quand OBS vient de redevenir joignable.
|
||||
await drainPresetApply();
|
||||
send({ type: 'status', status: await buildStatus() });
|
||||
} catch (err) {
|
||||
console.error('Collecte de statut en échec :', err);
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import OBSWebSocket from 'obs-websocket-js';
|
||||
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
|
||||
import type {
|
||||
AgentAction,
|
||||
AgentStatus,
|
||||
ObsSettings,
|
||||
PresetApplyResult,
|
||||
RecordingPreset,
|
||||
RecordingSettings,
|
||||
} from '@stream-control/shared';
|
||||
import { RECORDING_ENCODERS, findRecordingPreset } from '@stream-control/shared';
|
||||
|
||||
/**
|
||||
* Actions relevant d'OBS. `watch.*`, `hotkey.*` et `agent.update` sont traitées
|
||||
* en amont par l'agent : elles ne concernent pas la session obs-websocket.
|
||||
* Actions relevant d'OBS. `watch.*`, `hotkey.*`, `preset.apply` et
|
||||
* `agent.update` sont traitées en amont par l'agent : elles ne se réduisent pas
|
||||
* à un appel obs-websocket.
|
||||
*/
|
||||
export type ObsAction = Exclude<
|
||||
AgentAction,
|
||||
@@ -15,6 +24,7 @@ export type ObsAction = Exclude<
|
||||
| 'browser.close'
|
||||
| 'capture.start'
|
||||
| 'capture.stop'
|
||||
| 'preset.apply'
|
||||
>;
|
||||
|
||||
type ObsSnapshot = Pick<
|
||||
@@ -308,6 +318,123 @@ export class ObsController extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Presets d'enregistrement --------------------------------------------
|
||||
|
||||
/**
|
||||
* Traduit un preset en réglages OBS : paramètres de profil pour l'encodeur et
|
||||
* la qualité, `SetVideoSettings` pour la définition et la fluidité.
|
||||
*
|
||||
* Chaque paramètre est écrit isolément et un refus n'interrompt pas la série :
|
||||
* les clés varient d'une version d'OBS à l'autre, et il vaut mieux appliquer
|
||||
* quatre réglages sur cinq en le disant que tout abandonner sur un détail. Le
|
||||
* compte rendu liste ce qui est passé et ce qui a été refusé.
|
||||
*/
|
||||
async applyRecordingPreset(settings: RecordingSettings): Promise<PresetApplyResult> {
|
||||
this.requireConnection();
|
||||
|
||||
const preset = findRecordingPreset(settings.presetId);
|
||||
if (!preset) throw new Error(`Preset d'enregistrement inconnu : ${settings.presetId}`);
|
||||
const encoder = RECORDING_ENCODERS[settings.encoder];
|
||||
if (!encoder) throw new Error(`Encodeur inconnu : ${settings.encoder}`);
|
||||
|
||||
// Changer d'encodeur ou de définition pendant une capture la corromprait.
|
||||
const state = await this.obs.call('GetRecordStatus');
|
||||
if (state.outputActive) {
|
||||
throw new Error("Enregistrement en cours : arrête-le avant d'appliquer un preset.");
|
||||
}
|
||||
|
||||
const applied: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
const setParam = async (category: string, name: string, value: string): Promise<void> => {
|
||||
try {
|
||||
await this.obs.call('SetProfileParameter', {
|
||||
parameterCategory: category,
|
||||
parameterName: name,
|
||||
parameterValue: value,
|
||||
});
|
||||
applied.push(`${name}=${value}`);
|
||||
} catch (err) {
|
||||
skipped.push(`${name} (${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
};
|
||||
|
||||
// L'encodeur réellement en service est celui chargé au lancement d'OBS.
|
||||
const loadedEncoder = await this.readParam('SimpleOutput', 'RecEncoder');
|
||||
|
||||
// Les clés ci-dessous n'ont d'effet qu'en mode de sortie simple.
|
||||
await setParam('Output', 'Mode', 'Simple');
|
||||
await setParam('SimpleOutput', 'RecEncoder', encoder.obsValue);
|
||||
await setParam('SimpleOutput', 'RecQuality', preset.quality);
|
||||
await setParam('SimpleOutput', 'RecFormat2', preset.format);
|
||||
// `RecFormat` est la clé des OBS antérieurs à la 30 ; l'écrire ne coûte rien.
|
||||
await setParam('SimpleOutput', 'RecFormat', preset.format === 'mkv' ? 'mkv' : 'mp4');
|
||||
await setParam('SimpleOutput', 'ABitrate', String(preset.audioBitrateKbps));
|
||||
|
||||
if (encoder.speedKey && encoder.speedFamily) {
|
||||
await setParam('SimpleOutput', encoder.speedKey, preset.speed[encoder.speedFamily]);
|
||||
}
|
||||
|
||||
let video: string;
|
||||
try {
|
||||
video = await this.applyVideoSettings(preset);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
skipped.push(`vidéo (${message})`);
|
||||
video = `non appliqué : ${message}`;
|
||||
}
|
||||
|
||||
return {
|
||||
presetId: preset.id,
|
||||
presetLabel: preset.label,
|
||||
encoder: settings.encoder,
|
||||
applied,
|
||||
skipped,
|
||||
video,
|
||||
restartRequired: loadedEncoder !== null && loadedEncoder !== encoder.obsValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligne la sortie vidéo sur le preset, sans jamais agrandir : au-delà de la
|
||||
* définition de la scène il n'y a rien à gagner, seulement des pixels inventés.
|
||||
*/
|
||||
private async applyVideoSettings(preset: RecordingPreset): Promise<string> {
|
||||
const current = await this.obs.call('GetVideoSettings');
|
||||
const { baseWidth, baseHeight } = current;
|
||||
|
||||
let outputWidth = baseWidth;
|
||||
let outputHeight = baseHeight;
|
||||
if (preset.height !== null && preset.height < baseHeight) {
|
||||
outputHeight = even(preset.height);
|
||||
outputWidth = even((baseWidth * preset.height) / baseHeight);
|
||||
}
|
||||
|
||||
await this.obs.call('SetVideoSettings', {
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
fpsNumerator: preset.fps,
|
||||
fpsDenominator: 1,
|
||||
});
|
||||
|
||||
return `${outputWidth}×${outputHeight} @ ${preset.fps} fps`;
|
||||
}
|
||||
|
||||
/** Valeur courante d'un paramètre de profil, sa valeur par défaut à défaut. */
|
||||
private async readParam(category: string, name: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await this.obs.call('GetProfileParameter', {
|
||||
parameterCategory: category,
|
||||
parameterName: name,
|
||||
});
|
||||
return result.parameterValue || result.defaultParameterValue || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Interroge OBS pour construire l'état courant ; ne lève jamais. */
|
||||
async snapshot(): Promise<ObsSnapshot> {
|
||||
const base: ObsSnapshot = {
|
||||
@@ -372,6 +499,11 @@ export class ObsController extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** H.264 exige des dimensions paires ; on arrondit au pair le plus proche. */
|
||||
function even(value: number): number {
|
||||
return Math.max(2, Math.round(value / 2) * 2);
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`Paramètre « ${field} » manquant`);
|
||||
|
||||
@@ -112,6 +112,7 @@ export function handleAgentConnection(
|
||||
autoConnectObs: record.autoConnectObs,
|
||||
watch: record.watch,
|
||||
browser: record.browser,
|
||||
recording: record.recording,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
⚙
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user