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

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

View File

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

View File

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