feat: add bitrate matrix editor with scale macro and uncapped max inputs

This commit is contained in:
Jannis Braun
2026-03-22 00:52:40 +01:00
parent c0618a0b75
commit f3ff07aa6d
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore'; import { useSettingsStore } from '../../../stores/settingsStore';
import type { InstanceStreamingLimits } from '@backspace/shared'; import type { InstanceStreamingLimits } from '@backspace/shared';
import { import {
STANDARD_FRAMERATES, STANDARD_RESOLUTIONS, STANDARD_FRAMERATES,
ALL_RESOLUTIONS, RESOLUTION_LABELS, ALL_RESOLUTIONS, RESOLUTION_LABELS,
HIGH_END_RESOLUTION_THRESHOLD, HIGH_END_FRAMERATE_THRESHOLD, HIGH_END_RESOLUTION_THRESHOLD, HIGH_END_FRAMERATE_THRESHOLD,
BITRATE_MATRIX_KBPS,
type Resolution, type Resolution,
} from '@backspace/shared/src/constants'; } from '@backspace/shared/src/constants';
@@ -23,20 +24,89 @@ export function StreamingPanel() {
const [saveError, setSaveError] = useState(''); const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
// Matrix editor state: full grid of kbps values (integers only)
const [matrixDraft, setMatrixDraft] = useState<Record<string, number>>({});
const [editingCell, setEditingCell] = useState<string | null>(null);
const [scaleValue, setScaleValue] = useState(1.0);
const scaleSnapshot = useRef<Record<string, number> | null>(null);
useEffect(() => { useEffect(() => {
if (limits) setDraft({ ...limits }); if (limits) setDraft({ ...limits });
}, [limits]); }, [limits]);
// Initialize matrix draft from limits + overrides
useEffect(() => {
if (!limits) return;
const matrix: Record<string, number> = {};
for (const res of STANDARD_RESOLUTIONS) {
for (const fps of STANDARD_FRAMERATES) {
const key = `${res}_${fps}`;
matrix[key] = limits.bitrateMatrixOverrides?.[key] ?? BITRATE_MATRIX_KBPS[res]![fps as number]!;
}
}
setMatrixDraft(matrix);
}, [limits]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>; if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits); const getDefaultKbps = (key: string): number => {
const parts = key.split('_').map(Number);
const h = parts[0]!;
const f = parts[1]!;
return BITRATE_MATRIX_KBPS[h]?.[f] ?? 8000;
};
const isOverridden = (key: string): boolean => matrixDraft[key] !== getDefaultKbps(key);
const enabledResolutions = draft.allowedResolutions.filter((r): r is number => r !== 'native');
const enabledFramerates = draft.allowedFramerates;
const handleScaleStart = () => {
scaleSnapshot.current = { ...matrixDraft };
};
const handleScaleChange = (newScale: number) => {
setScaleValue(newScale);
if (!scaleSnapshot.current) return;
const scaled: Record<string, number> = {};
for (const [key, val] of Object.entries(scaleSnapshot.current)) {
scaled[key] = Math.round(val * newScale);
}
setMatrixDraft(scaled);
};
const handleScaleEnd = () => {
scaleSnapshot.current = null;
setScaleValue(1.0);
};
const matrixHasChanges = (() => {
if (!limits) return false;
for (const [key, val] of Object.entries(matrixDraft)) {
const serverVal = limits.bitrateMatrixOverrides?.[key] ?? getDefaultKbps(key);
if (val !== serverVal) return true;
}
return false;
})();
const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits) || matrixHasChanges;
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setSaveError(''); setSaveError('');
setSaveSuccess(false); setSaveSuccess(false);
try { try {
await updateStreamingLimits(draft); // Compute sparse overrides: only cells that differ from defaults
const overrides: Record<string, number> = {};
for (const [key, val] of Object.entries(matrixDraft)) {
if (val !== getDefaultKbps(key)) {
overrides[key] = val;
}
}
const payload = {
...draft,
bitrateMatrixOverrides: Object.keys(overrides).length > 0 ? overrides : null,
};
await updateStreamingLimits(payload);
setSaveSuccess(true); setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000); setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) { } catch (err) {
@@ -47,7 +117,17 @@ export function StreamingPanel() {
}; };
const handleReset = () => { const handleReset = () => {
if (limits) setDraft({ ...limits }); if (limits) {
setDraft({ ...limits });
const matrix: Record<string, number> = {};
for (const res of STANDARD_RESOLUTIONS) {
for (const fps of STANDARD_FRAMERATES) {
const key = `${res}_${fps}`;
matrix[key] = limits.bitrateMatrixOverrides?.[key] ?? BITRATE_MATRIX_KBPS[res]![fps as number]!;
}
}
setMatrixDraft(matrix);
}
setSaveError(''); setSaveError('');
}; };
@@ -99,34 +179,62 @@ export function StreamingPanel() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1"> <div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label> <label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
<input <div className="flex items-center gap-2">
type="range" <input
min={100} type="range"
max={draft.maxBitrateKbps - 500} min={100}
step={100} max={Math.min(draft.maxBitrateKbps - 500, 100000)}
value={draft.minBitrateKbps} step={100}
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })} value={Math.min(draft.minBitrateKbps, 100000)}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 className="flex-1 h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0" [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
/> [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<input
type="number"
min={100}
max={1000000}
step={100}
value={draft.minBitrateKbps}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 100 && v <= 1000000) setDraft({ ...draft, minBitrateKbps: v });
}}
className="input-standard w-24 px-2 py-1 text-xs"
/>
</div>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div> <div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label> <label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
<input <div className="flex items-center gap-2">
type="range" <input
min={draft.minBitrateKbps + 500} type="range"
max={50000} min={draft.minBitrateKbps + 500}
step={500} max={100000}
value={draft.maxBitrateKbps} step={500}
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })} value={Math.min(draft.maxBitrateKbps, 100000)}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 className="flex-1 h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0" [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
/> [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<input
type="number"
min={draft.minBitrateKbps + 500}
max={1000000}
step={500}
value={draft.maxBitrateKbps}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 500 && v <= 1000000) setDraft({ ...draft, maxBitrateKbps: v });
}}
className="input-standard w-24 px-2 py-1 text-xs"
/>
</div>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div> <div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
</div> </div>
</div> </div>
@@ -222,6 +330,144 @@ export function StreamingPanel() {
</div> </div>
)} )}
{/* Bitrate Matrix */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Bitrate Matrix</div>
<p className="text-xs text-txt-tertiary mb-2">Default bitrates per resolution and frame rate. Edit individual cells or scale all values at once.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
{/* Scale slider */}
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-xs text-txt-secondary">Scale All</div>
<div className="text-xs text-txt-tertiary font-mono">&times;{scaleValue.toFixed(2)}</div>
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.05}
value={scaleValue}
onPointerDown={handleScaleStart}
onChange={(e) => handleScaleChange(Number(e.target.value))}
onPointerUp={handleScaleEnd}
onPointerCancel={handleScaleEnd}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[10px] text-txt-tertiary mt-0.5">Drag to scale all bitrates. Fine-tune individual cells below.</div>
</div>
{/* Matrix grid */}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
<th className="text-left text-txt-tertiary font-normal pb-1.5 pr-2"></th>
{enabledFramerates.map((fps) => (
<th key={fps} className="text-center text-txt-tertiary font-normal pb-1.5 px-1 min-w-[52px]">{fps} fps</th>
))}
</tr>
</thead>
<tbody>
{enabledResolutions.map((res) => (
<tr key={res}>
<td className="text-txt-secondary font-medium pr-2 py-0.5">{RESOLUTION_LABELS[res as Resolution] ?? `${res}p`}</td>
{enabledFramerates.map((fps) => {
const key = `${res}_${fps}`;
const val = matrixDraft[key] ?? getDefaultKbps(key);
const overridden = isOverridden(key);
const exceedsCap = val > draft.maxBitrateKbps;
return (
<td key={key} className="px-1 py-0.5">
{editingCell === key ? (
<input
autoFocus
type="number"
step={0.1}
className="input-standard w-full px-1.5 py-0.5 text-xs text-center"
defaultValue={(val / 1000).toFixed(1)}
onBlur={(e) => {
const mbps = parseFloat(e.target.value);
if (!isNaN(mbps) && mbps > 0) {
setMatrixDraft({ ...matrixDraft, [key]: Math.round(mbps * 1000) });
}
setEditingCell(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setEditingCell(null);
}}
/>
) : (
<button
onClick={() => setEditingCell(key)}
className={`w-full px-1.5 py-0.5 rounded text-center transition-colors
${exceedsCap ? 'bg-accent-amber/10 text-accent-amber' : ''}
${overridden && !exceedsCap ? 'bg-accent-primary/10 text-accent-primary' : ''}
${!overridden && !exceedsCap ? 'text-txt-secondary hover:bg-interactive-hover' : ''}
`}
title={overridden ? `Default: ${(getDefaultKbps(key) / 1000).toFixed(1)} Mbps (click \u00d7 to reset)` : 'Click to edit'}
>
{(val / 1000).toFixed(1)}
{overridden && (
<span
className="ml-1 text-[10px] text-txt-tertiary hover:text-txt-danger cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setMatrixDraft({ ...matrixDraft, [key]: getDefaultKbps(key) });
}}
>
&times;
</span>
)}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Reset all button */}
{Object.entries(matrixDraft).some(([key, val]) => val !== getDefaultKbps(key)) && (
<button
onClick={() => {
const reset: Record<string, number> = {};
for (const [key] of Object.entries(matrixDraft)) {
reset[key] = getDefaultKbps(key);
}
setMatrixDraft(reset);
}}
className="text-[11px] text-accent-primary hover:text-accent-lavender transition-colors"
>
Reset all to defaults
</button>
)}
</div>
</div>
{/* Coherence warning */}
{Object.entries(matrixDraft).some(([, val]) => val > draft.maxBitrateKbps) && (
<div className="p-3 bg-accent-amber/10 border border-accent-amber/30 rounded-lg">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 text-accent-amber mt-0.5 shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.168 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
<div className="text-xs text-accent-amber/90 leading-relaxed">
<span className="font-semibold">Matrix values exceed global cap.</span>{' '}
Some bitrate values exceed your maximum of {formatKbps(draft.maxBitrateKbps)}.
Streams at those combos will be quality-degraded to your cap.
</div>
</div>
</div>
)}
{/* Save / Reset */} {/* Save / Reset */}
{saveError && ( {saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div> <div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>