feat: add bitrate matrix editor with scale macro and uncapped max inputs
This commit is contained in:
@@ -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<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(() => {
|
||||
if (limits) setDraft({ ...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>;
|
||||
|
||||
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 () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
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);
|
||||
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<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('');
|
||||
};
|
||||
|
||||
@@ -99,34 +179,62 @@ export function StreamingPanel() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={draft.maxBitrateKbps - 500}
|
||||
max={Math.min(draft.maxBitrateKbps - 500, 100000)}
|
||||
step={100}
|
||||
value={draft.minBitrateKbps}
|
||||
value={Math.min(draft.minBitrateKbps, 100000)}
|
||||
onChange={(e) => 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
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={draft.minBitrateKbps + 500}
|
||||
max={50000}
|
||||
max={100000}
|
||||
step={500}
|
||||
value={draft.maxBitrateKbps}
|
||||
value={Math.min(draft.maxBitrateKbps, 100000)}
|
||||
onChange={(e) => 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
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
@@ -222,6 +330,144 @@ export function StreamingPanel() {
|
||||
</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">×{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) });
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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 */}
|
||||
{saveError && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
|
||||
|
||||
Reference in New Issue
Block a user