Feat : OBS presets
This commit is contained in:
@@ -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