From f3ff07aa6db834c4974fdbdc5eb141e6e678f818 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:52:40 +0100 Subject: [PATCH] feat: add bitrate matrix editor with scale macro and uncapped max inputs --- .../instanceSettingsPanels/StreamingPanel.tsx | 304 ++++++++++++++++-- 1 file changed, 275 insertions(+), 29 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx index af677f16..ad3cf525 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useSettingsStore } from '../../../stores/settingsStore'; import type { InstanceStreamingLimits } from '@backspace/shared'; import { - STANDARD_FRAMERATES, + STANDARD_RESOLUTIONS, STANDARD_FRAMERATES, ALL_RESOLUTIONS, RESOLUTION_LABELS, HIGH_END_RESOLUTION_THRESHOLD, HIGH_END_FRAMERATE_THRESHOLD, + BITRATE_MATRIX_KBPS, type Resolution, } from '@backspace/shared/src/constants'; @@ -23,20 +24,89 @@ export function StreamingPanel() { const [saveError, setSaveError] = useState(''); const [saveSuccess, setSaveSuccess] = useState(false); + // Matrix editor state: full grid of kbps values (integers only) + const [matrixDraft, setMatrixDraft] = useState>({}); + const [editingCell, setEditingCell] = useState(null); + const [scaleValue, setScaleValue] = useState(1.0); + const scaleSnapshot = useRef | null>(null); + useEffect(() => { if (limits) setDraft({ ...limits }); }, [limits]); + // Initialize matrix draft from limits + overrides + useEffect(() => { + if (!limits) return; + const matrix: Record = {}; + 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
Loading settings...
; - 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 = {}; + 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 () => { setSaving(true); setSaveError(''); setSaveSuccess(false); try { - await updateStreamingLimits(draft); + // Compute sparse overrides: only cells that differ from defaults + const overrides: Record = {}; + 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); setTimeout(() => setSaveSuccess(false), 2000); } catch (err) { @@ -47,7 +117,17 @@ export function StreamingPanel() { }; const handleReset = () => { - if (limits) setDraft({ ...limits }); + if (limits) { + setDraft({ ...limits }); + const matrix: Record = {}; + 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(''); }; @@ -99,34 +179,62 @@ export function StreamingPanel() {
- setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })} - 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" - /> +
+ setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })} + className="flex-1 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" + /> + { + 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" + /> +
{formatKbps(draft.minBitrateKbps)}
- setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })} - 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" - /> +
+ setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })} + className="flex-1 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" + /> + { + 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" + /> +
{formatKbps(draft.maxBitrateKbps)}
@@ -222,6 +330,144 @@ export function StreamingPanel() { )} + {/* Bitrate Matrix */} +
+
Bitrate Matrix
+

Default bitrates per resolution and frame rate. Edit individual cells or scale all values at once.

+
+ {/* Scale slider */} +
+
+
Scale All
+
×{scaleValue.toFixed(2)}
+
+ 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" + /> +
Drag to scale all bitrates. Fine-tune individual cells below.
+
+ + {/* Matrix grid */} +
+ + + + + {enabledFramerates.map((fps) => ( + + ))} + + + + {enabledResolutions.map((res) => ( + + + {enabledFramerates.map((fps) => { + const key = `${res}_${fps}`; + const val = matrixDraft[key] ?? getDefaultKbps(key); + const overridden = isOverridden(key); + const exceedsCap = val > draft.maxBitrateKbps; + + return ( + + ); + })} + + ))} + +
{fps} fps
{RESOLUTION_LABELS[res as Resolution] ?? `${res}p`} + {editingCell === key ? ( + { + 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); + }} + /> + ) : ( + + )} +
+
+ + {/* Reset all button */} + {Object.entries(matrixDraft).some(([key, val]) => val !== getDefaultKbps(key)) && ( + + )} +
+
+ + {/* Coherence warning */} + {Object.entries(matrixDraft).some(([, val]) => val > draft.maxBitrateKbps) && ( +
+
+ + + +
+ Matrix values exceed global cap.{' '} + Some bitrate values exceed your maximum of {formatKbps(draft.maxBitrateKbps)}. + Streams at those combos will be quality-degraded to your cap. +
+
+
+ )} + {/* Save / Reset */} {saveError && (
{saveError}