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 { 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={100}
|
min={100}
|
||||||
max={draft.maxBitrateKbps - 500}
|
max={Math.min(draft.maxBitrateKbps - 500, 100000)}
|
||||||
step={100}
|
step={100}
|
||||||
value={draft.minBitrateKbps}
|
value={Math.min(draft.minBitrateKbps, 100000)}
|
||||||
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
|
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]: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]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
||||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
[&::-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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={draft.minBitrateKbps + 500}
|
min={draft.minBitrateKbps + 500}
|
||||||
max={50000}
|
max={100000}
|
||||||
step={500}
|
step={500}
|
||||||
value={draft.maxBitrateKbps}
|
value={Math.min(draft.maxBitrateKbps, 100000)}
|
||||||
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
|
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]: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]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
||||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
[&::-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">×{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 */}
|
{/* 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user