Add Auto live view option with transcoding options (#24519)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* implement auto mode for single camera live view

* add transcoded live streams and stream ordering for auto mode

Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.

The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.

Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.

Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.

* fixes
This commit is contained in:
Josh Hawkins
2026-09-30 15:10:49 -06:00
committed by GitHub
parent 1bb61eb808
commit 2754f788cc
46 changed files with 3659 additions and 286 deletions
@@ -4,17 +4,18 @@ const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
restartRequired: [],
fieldOrder: ["streams", "height", "quality"],
fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality"],
},
global: {
restartRequired: ["streams", "height", "quality"],
hiddenFields: ["streams"],
hiddenFields: ["streams", "transcode"],
},
camera: {
restartRequired: ["height", "quality"],
orderedMaps: ["streams"],
uiSchema: {
streams: {
"ui:field": "LiveStreamsField",
@@ -23,6 +24,13 @@ const live: SectionConfigOverrides = {
suppressDescription: true,
},
},
transcode: {
"ui:field": "LiveTranscodeField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
},
},
};
@@ -66,6 +66,8 @@ import {
cameraUpdateTopicMap,
globalCameraDefaultSections,
buildOverrides,
applyOrderedMaps,
changedOrderedMapPaths,
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
@@ -121,6 +123,8 @@ export interface SectionConfig {
messages?: ConditionalMessage[];
/** Conditional messages displayed inline with specific fields */
fieldMessages?: FieldConditionalMessage[];
/** Maps whose key order is saved (e.g. live stream ladder order) */
orderedMaps?: string[];
}
export interface BaseSectionProps {
@@ -488,6 +492,18 @@ export function ConfigSection({
[rawFormData, sanitizeSectionData],
);
const orderedMaps = sectionConfig.orderedMaps;
const buildSectionOverrides = useCallback(
(current: unknown, base: unknown, defaults: unknown) =>
applyOrderedMaps(
buildOverrides(current, base, defaults),
current,
base,
orderedMaps ?? [],
),
[orderedMaps],
);
// Clear pendingData whenever the section/camera key changes (e.g., switching
// cameras) or when there is no pending data yet (initialization).
// This prevents RJSF's initial onChange call from being treated as a user edit.
@@ -528,10 +544,12 @@ export function ConfigSection({
// Track if there are unsaved changes
const hasChanges = useMemo(() => {
const pendingChanged = pendingData
? !isEqual(formData, pendingData)
? !isEqual(formData, pendingData) ||
changedOrderedMapPaths(pendingData, formData, orderedMaps ?? [])
.length > 0
: false;
return pendingChanged || extraHasChanges;
}, [formData, pendingData, extraHasChanges]);
}, [formData, pendingData, extraHasChanges, orderedMaps]);
useEffect(() => {
onStatusChange?.({
@@ -564,7 +582,7 @@ export function ConfigSection({
}
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
const nextBaselineFormData = baselineSnapshot;
const overrides = buildOverrides(
const overrides = buildSectionOverrides(
sanitizedData,
compareBaseData,
effectiveSchemaDefaults,
@@ -579,7 +597,7 @@ export function ConfigSection({
return;
}
}
const dirty = buildOverrides(
const dirty = buildSectionOverrides(
sanitizedData,
nextBaselineFormData,
undefined,
@@ -602,12 +620,24 @@ export function ConfigSection({
setPendingOverrides,
setDirtyOverrides,
baselineSnapshot,
buildSectionOverrides,
],
);
const currentFormData = pendingData || formData;
const effectiveBaselineFormData = baselineSnapshot;
// RJSF memoizes fields with an order-blind deep compare, so a pure reorder
// of an ordered map would not re-render; the key order breaks the tie
const orderedMapsKeyOrder = useMemo(
() =>
(orderedMaps ?? []).map((path) => {
const value = get(currentFormData, path);
return value && typeof value === "object" ? Object.keys(value) : [];
}),
[currentFormData, orderedMaps],
);
// Build context for conditional messages
const messageContext = useMemo<MessageConditionContext | undefined>(() => {
if (!config || !currentFormData) return undefined;
@@ -636,7 +666,7 @@ export function ConfigSection({
const sanitizedData = sanitizeSectionData(
currentFormData as ConfigSectionData,
);
return buildOverrides(
return buildSectionOverrides(
sanitizedData,
compareBaseData,
effectiveSchemaDefaults,
@@ -646,6 +676,7 @@ export function ConfigSection({
sanitizeSectionData,
compareBaseData,
effectiveSchemaDefaults,
buildSectionOverrides,
]);
const effectiveOverrides = pendingData
@@ -684,7 +715,12 @@ export function ConfigSection({
? `cameras.${cameraName}.${effectiveSectionPath}`
: effectiveSectionPath;
const rawData = sanitizeSectionData(rawFormData);
const overrides = buildOverrides(
const replacePaths = changedOrderedMapPaths(
pendingData,
rawData,
orderedMaps ?? [],
).map((path) => `${basePath}.${path}`);
const overrides = buildSectionOverrides(
pendingData,
rawData,
effectiveSchemaDefaults,
@@ -711,10 +747,11 @@ export function ConfigSection({
: requiresRestartForOverrides(sanitizedOverrides);
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
await axios.put("config/set", {
const response = await axios.put("config/set", {
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
config_data: configData,
...(replacePaths.length > 0 ? { replace_paths: replacePaths } : {}),
...(skipSave ? { skip_save: true } : {}),
});
@@ -752,6 +789,8 @@ export function ConfigSection({
),
},
);
} else if (response.data?.go2rtc_synced === false) {
toast.warning(t("toast.go2rtcSyncFailed", { ns: "views/settings" }));
} else {
toast.success(
t("toast.success", {
@@ -823,6 +862,8 @@ export function ConfigSection({
requiresRestartForOverrides,
skipSave,
onSavingChange,
orderedMaps,
buildSectionOverrides,
]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
@@ -1030,6 +1071,7 @@ export function ConfigSection({
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
orderedMapsKeyOrder,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
@@ -1,8 +1,12 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import axios from "axios";
import { Reorder, useDragControls } from "framer-motion";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
@@ -16,16 +20,35 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import {
LuArrowDownWideNarrow,
LuGripVertical,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import { MapKeyInput } from "../components";
import type { ConfigFormContext } from "@/types/configForm";
import type { LiveTranscodeConfig } from "@/types/frigateConfig";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import { isSubtreeModified } from "../utils";
import {
isTranscodeStreamName,
transcodeBitrateFor,
} from "@/utils/liveTranscode";
type LiveStreamsData = Record<string, string>;
// kbps measured by the server, or configured for a transcoded stream
type StreamRate = { kbps: number; set: boolean } | { failed: true };
function rateValue(rate: StreamRate | undefined): number {
return rate && "kbps" in rate ? rate.kbps : -1;
}
type StreamValueComboboxProps = {
id: string;
value: string;
@@ -156,6 +179,154 @@ function StreamValueCombobox({
);
}
type StreamRowProps = {
entryKey: string;
value: string;
entryId: string;
data: LiveStreamsData;
generated: boolean;
rate?: StreamRate;
options: string[];
disabled?: boolean;
readonly?: boolean;
onRename: (oldKey: string, newKey: string) => void;
onUpdateValue: (key: string, value: string) => void;
onRemove: (key: string) => void;
onDragEnd: () => void;
};
function StreamRow({
entryKey,
value,
entryId,
data,
generated,
rate,
options,
disabled,
readonly,
onRename,
onUpdateValue,
onRemove,
onDragEnd,
}: StreamRowProps) {
const { t } = useTranslation(["views/settings", "common"]);
const controls = useDragControls();
const locked = disabled || readonly;
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
let rateLabel: string | undefined;
if (rate && "failed" in rate) {
rateLabel = t("configForm.liveStreams.rateFailed", {
ns: "views/settings",
});
} else if (rate?.set) {
rateLabel = t("configForm.liveStreams.rateSet", {
ns: "views/settings",
kbps: rate.kbps,
});
} else if (rate) {
rateLabel = t("configForm.liveStreams.rate", {
ns: "views/settings",
kbps: rate.kbps,
});
}
return (
<Reorder.Item
as="div"
value={entryKey}
dragListener={false}
dragControls={controls}
onDragEnd={onDragEnd}
className="grid grid-cols-12 items-end gap-2 rounded-md border bg-background p-3"
>
<div className="col-span-12 flex items-end gap-1 md:col-span-5">
<button
type="button"
disabled={locked}
onPointerDown={(e) => controls.start(e)}
className="mb-2 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing disabled:cursor-not-allowed"
aria-label={t("configForm.liveStreams.reorderHandle", {
ns: "views/settings",
})}
>
<LuGripVertical className="size-4" />
</button>
<div className="flex-1 space-y-2">
<Label htmlFor={`${entryId}-key`}>
{t("configForm.liveStreams.streamNameLabel", {
ns: "views/settings",
})}
</Label>
<MapKeyInput
id={`${entryId}-key`}
value={entryKey}
placeholder={t("configForm.liveStreams.streamNamePlaceholder", {
ns: "views/settings",
})}
disabled={locked}
onCommit={(next) => onRename(entryKey, next)}
isKeyTaken={(next) =>
next !== entryKey &&
Object.prototype.hasOwnProperty.call(data, next)
}
/>
</div>
</div>
<div className="col-span-10 space-y-2 md:col-span-6">
<Label htmlFor={`${entryId}-value`} className="flex items-center gap-2">
{t("configForm.liveStreams.go2rtcStreamLabel", {
ns: "views/settings",
})}
{generated && (
<Badge variant="secondary">
{t("configForm.liveStreams.transcodedBadge", {
ns: "views/settings",
})}
</Badge>
)}
</Label>
{generated ? (
<Input id={`${entryId}-value`} value={value} disabled readOnly />
) : (
<StreamValueCombobox
id={`${entryId}-value`}
value={value}
options={options}
disabled={disabled}
readonly={readonly}
onChange={(next) => onUpdateValue(entryKey, next)}
/>
)}
{rateLabel && (
<p className="text-xs text-muted-foreground">{rateLabel}</p>
)}
</div>
<div className="col-span-2 flex justify-end md:col-span-1">
{!generated && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(entryKey)}
disabled={locked}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
</Reorder.Item>
);
}
export function LiveStreamsField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
@@ -166,6 +337,7 @@ export function LiveStreamsField(props: FieldProps) {
(formContext?.level === "camera" ? "config/cameras" : "config/global");
const { t: fallbackT } = useTranslation(["common", configNamespace]);
const t = formContext?.t ?? fallbackT;
const { t: tSettings } = useTranslation(["views/settings"]);
const data: LiveStreamsData = useMemo(() => {
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
@@ -175,6 +347,7 @@ export function LiveStreamsField(props: FieldProps) {
}, [formData]);
const entries = useMemo(() => Object.entries(data), [data]);
const keys = useMemo(() => entries.map(([key]) => key), [entries]);
const id = idSchema?.$id ?? props.name;
const sectionPrefix = formContext?.sectionI18nPrefix;
@@ -185,12 +358,22 @@ export function LiveStreamsField(props: FieldProps) {
t(`${sectionPrefix}.${id}.description`) ??
(schema as RJSFSchema).description;
const camera = formContext?.cameraName ?? "";
const transcode = formContext?.formData?.transcode as
LiveTranscodeConfig | undefined;
const go2rtcStreamNames = useMemo<string[]>(() => {
const streams = formContext?.fullConfig?.go2rtc?.streams;
if (!streams || typeof streams !== "object") return [];
return Object.keys(streams).sort();
}, [formContext?.fullConfig?.go2rtc?.streams]);
const isGenerated = useCallback(
(name: string) =>
isTranscodeStreamName(camera, name) && !go2rtcStreamNames.includes(name),
[camera, go2rtcStreamNames],
);
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
@@ -210,6 +393,51 @@ export function LiveStreamsField(props: FieldProps) {
);
}, [fieldPath, formContext, data]);
const commitOrder = useCallback(
(order: string[]) => {
const next: LiveStreamsData = {};
order.forEach((key) => {
next[key] = data[key];
});
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
// rows keep one React key through renames, so the name input stays mounted
const rowIdsRef = useRef(new Map<string, number>());
const nextRowIdRef = useRef(0);
const rowIdFor = (key: string) => {
let id = rowIdsRef.current.get(key);
if (id === undefined) {
id = nextRowIdRef.current++;
rowIdsRef.current.set(key, id);
}
return id;
};
// local order while dragging; committed to the form on drop
const [dragOrder, setDragOrder] = useState<string[] | null>(null);
const dragOrderRef = useRef<string[] | null>(null);
const handleReorder = useCallback((next: string[]) => {
dragOrderRef.current = next;
setDragOrder(next);
}, []);
const handleDragEnd = useCallback(() => {
const next = dragOrderRef.current;
dragOrderRef.current = null;
setDragOrder(null);
if (next && !isEqual(next, keys)) {
commitOrder(next);
}
}, [commitOrder, keys]);
const handleAddEntry = useCallback(() => {
const next = { ...data, "": "" };
onChange(next, fieldPath);
@@ -227,6 +455,13 @@ export function LiveStreamsField(props: FieldProps) {
const handleRenameKey = useCallback(
(oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const id = rowIdsRef.current.get(oldKey);
if (id !== undefined) {
rowIdsRef.current.delete(oldKey);
rowIdsRef.current.set(newKey, id);
}
const next: LiveStreamsData = {};
for (const [k, v] of Object.entries(data)) {
if (k === oldKey) {
@@ -248,24 +483,84 @@ export function LiveStreamsField(props: FieldProps) {
[data, fieldPath, onChange],
);
// Auto order commits after measuring, so edits made meanwhile must survive
// and a camera switched to meanwhile must not receive the result
const dataRef = useRef(data);
const cameraRef = useRef(camera);
useEffect(() => {
dataRef.current = data;
cameraRef.current = camera;
}, [data, camera]);
const [measurement, setMeasurement] = useState<{
data: LiveStreamsData;
transcode: LiveTranscodeConfig | undefined;
rates: Record<string, StreamRate>;
} | null>(null);
const [measuring, setMeasuring] = useState(false);
// rates describe the streams as measured; a later edit hides them, while a
// reorder alone keeps them since isEqual ignores key order
const rates =
measurement &&
isEqual(measurement.data, data) &&
isEqual(measurement.transcode, transcode)
? measurement.rates
: {};
const handleAutoOrder = useCallback(async () => {
setMeasuring(true);
const measured = await Promise.all(
entries.map(async ([, name]): Promise<[string, StreamRate]> => {
const setRate = transcodeBitrateFor(camera, name, transcode);
if (setRate !== undefined) {
return [name, { kbps: setRate, set: true }];
}
try {
const response = await axios.get(
`go2rtc/streams/${encodeURIComponent(name)}/bitrate`,
);
return [name, { kbps: response.data.kbps, set: false }];
} catch {
return [name, { failed: true }];
}
}),
);
if (cameraRef.current !== camera) {
setMeasuring(false);
return;
}
const next = Object.fromEntries(measured);
const latest = dataRef.current;
setMeasurement({ data: latest, transcode, rates: next });
setMeasuring(false);
// stable sort, so ties and unmeasured rows keep their order; rows added
// or renamed while measuring go last
const sorted = [...entries]
.sort(([, a], [, b]) => rateValue(next[b]) - rateValue(next[a]))
.map(([key]) => key)
.filter((key) => key in latest);
const order = [
...sorted,
...Object.keys(latest).filter((key) => !sorted.includes(key)),
];
if (!isEqual(order, Object.keys(latest))) {
onChange(
Object.fromEntries(order.map((key) => [key, latest[key]])),
fieldPath,
);
}
}, [camera, entries, fieldPath, onChange, transcode]);
const baseId = idSchema?.$id || "live_streams";
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
ns: "views/settings",
});
const streamNamePlaceholder = t(
"configForm.liveStreams.streamNamePlaceholder",
{ ns: "views/settings" },
);
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
ns: "views/settings",
});
const addStreamLabel = t("configForm.liveStreams.addStream", {
ns: "views/settings",
});
const order = dragOrder ?? keys;
return (
<Card className="w-full">
@@ -278,57 +573,34 @@ export function LiveStreamsField(props: FieldProps) {
)}
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{entries.map(([key, value], entryIndex) => {
const entryId = `${baseId}-${entryIndex}`;
return (
<div
key={entryIndex}
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
>
<div className="col-span-12 space-y-2 md:col-span-5">
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
<MapKeyInput
id={`${entryId}-key`}
value={key}
placeholder={streamNamePlaceholder}
disabled={disabled || readonly}
onCommit={(next) => handleRenameKey(key, next)}
isKeyTaken={(next) =>
next !== key &&
Object.prototype.hasOwnProperty.call(data, next)
}
/>
</div>
<div className="col-span-10 space-y-2 md:col-span-6">
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
<StreamValueCombobox
id={`${entryId}-value`}
value={value}
options={go2rtcStreamNames}
disabled={disabled}
readonly={readonly}
onChange={(next) => handleUpdateValue(key, next)}
/>
</div>
<div className="col-span-2 flex justify-end md:col-span-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveEntry(key)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<Reorder.Group
as="div"
axis="y"
values={order}
onReorder={handleReorder}
className="space-y-3"
>
{order.map((key, entryIndex) => (
<StreamRow
key={rowIdFor(key)}
entryKey={key}
value={data[key] ?? ""}
entryId={`${baseId}-${entryIndex}`}
data={data}
generated={isGenerated(data[key] ?? "")}
rate={rates[data[key] ?? ""]}
options={go2rtcStreamNames}
disabled={disabled}
readonly={readonly}
onRename={handleRenameKey}
onUpdateValue={handleUpdateValue}
onRemove={handleRemoveEntry}
onDragEnd={handleDragEnd}
/>
))}
</Reorder.Group>
<div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
@@ -338,7 +610,25 @@ export function LiveStreamsField(props: FieldProps) {
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{addStreamLabel}
{tSettings("configForm.liveStreams.addStream")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAutoOrder}
disabled={disabled || readonly || measuring || entries.length < 2}
title={tSettings("configForm.liveStreams.autoOrderTips")}
className="gap-2"
>
{measuring ? (
<ActivityIndicator className="size-4" />
) : (
<LuArrowDownWideNarrow className="h-4 w-4" />
)}
{measuring
? tSettings("configForm.liveStreams.measuring")
: tSettings("configForm.liveStreams.autoOrder")}
</Button>
</div>
</CardContent>
@@ -0,0 +1,280 @@
import type { FieldPathList, FieldProps } from "@rjsf/utils";
import { useCallback, useMemo } from "react";
import get from "lodash/get";
import { useTranslation } from "react-i18next";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ConfigFormContext } from "@/types/configForm";
import type {
LiveTranscodeConfig,
LiveTranscodeQuality,
} from "@/types/frigateConfig";
import {
TRANSCODE_HEIGHTS,
defaultTranscodeBitrate,
reconcileTranscodeStreams,
} from "@/utils/liveTranscode";
const EMPTY_TRANSCODE: LiveTranscodeConfig = {
enabled: false,
source: null,
qualities: [],
};
// presets plus a height set in yaml, minus heights other rows use
function heightOptions(current: number, used: number[]): number[] {
return [...new Set([...TRANSCODE_HEIGHTS, current])]
.filter((height) => height === current || !used.includes(height))
.sort((a, b) => b - a);
}
export function LiveTranscodeField(props: FieldProps) {
const { formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
ConfigFormContext | undefined;
const { t } = useTranslation(["views/settings"]);
const transcode =
(formData as LiveTranscodeConfig | undefined) ?? EMPTY_TRANSCODE;
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
emptyPath;
const camera = formContext?.cameraName ?? "";
const streams = useMemo(
() => (formContext?.formData?.streams ?? {}) as Record<string, string>,
[formContext?.formData?.streams],
);
const go2rtcStreams = useMemo(
() => Object.keys(formContext?.fullConfig?.go2rtc?.streams ?? {}),
[formContext?.fullConfig?.go2rtc?.streams],
);
const sources = useMemo(
() =>
[...new Set(Object.values(streams))].filter((name) =>
go2rtcStreams.includes(name),
),
[streams, go2rtcStreams],
);
const savedSource = get(formContext?.baselineFormData, [
...fieldPath,
"source",
]) as string | null | undefined;
const available = sources.length > 0;
const locked = disabled || readonly || !available;
const baseId = idSchema?.$id || "live_transcode";
// the stream list follows the transcode config in the same form change
const update = useCallback(
(next: LiveTranscodeConfig) => {
onChange(next, fieldPath);
onChange(
reconcileTranscodeStreams(camera, streams, next, go2rtcStreams),
[...fieldPath.slice(0, -1), "streams"],
);
},
[camera, fieldPath, go2rtcStreams, onChange, streams],
);
const updateQuality = useCallback(
(index: number, quality: LiveTranscodeQuality) => {
const qualities = [...transcode.qualities];
qualities[index] = quality;
update({ ...transcode, qualities });
},
[transcode, update],
);
const addQuality = useCallback(() => {
const used = new Set(transcode.qualities.map((q) => q.height));
const height = TRANSCODE_HEIGHTS.find((h) => !used.has(h));
if (height === undefined) return;
update({
...transcode,
qualities: [
...transcode.qualities,
{ height, bitrate: defaultTranscodeBitrate(height) },
],
});
}, [transcode, update]);
const removeQuality = useCallback(
(index: number) =>
update({
...transcode,
qualities: transcode.qualities.filter((_, i) => i !== index),
}),
[transcode, update],
);
const usedHeights = transcode.qualities.map((q) => q.height);
return (
<Card className="w-full">
<CardHeader className="p-4">
<CardTitle className="text-sm">
{t("configForm.liveTranscode.title")}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{available
? t("configForm.liveTranscode.description")
: t("configForm.liveTranscode.unavailable")}
</p>
</CardHeader>
<CardContent className="space-y-4 p-4 pt-0">
<div className="flex items-center gap-3">
<Switch
id={`${baseId}-enabled`}
checked={transcode.enabled}
disabled={locked}
onCheckedChange={(enabled) =>
update({
...transcode,
enabled,
// pin the shown source so reordering streams can't change it;
// turning off drops the pin
source: enabled
? (transcode.source ?? sources[0])
: savedSource,
})
}
/>
<Label htmlFor={`${baseId}-enabled`}>
{t("configForm.liveTranscode.enable")}
</Label>
</div>
{transcode.enabled && available && (
<>
<div className="space-y-2">
<Label htmlFor={`${baseId}-source`}>
{t("configForm.liveTranscode.source")}
</Label>
<Select
value={transcode.source ?? sources[0]}
disabled={locked}
onValueChange={(source) => update({ ...transcode, source })}
>
<SelectTrigger id={`${baseId}-source`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sources.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{transcode.qualities.map((quality, index) => (
<div
key={quality.height}
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
>
<div className="col-span-5 space-y-2">
<Label htmlFor={`${baseId}-${index}-height`}>
{t("configForm.liveTranscode.height")}
</Label>
<Select
value={String(quality.height)}
disabled={locked}
onValueChange={(value) =>
updateQuality(index, {
height: Number(value),
bitrate: defaultTranscodeBitrate(Number(value)),
})
}
>
<SelectTrigger id={`${baseId}-${index}-height`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{heightOptions(quality.height, usedHeights).map(
(height) => (
<SelectItem key={height} value={String(height)}>
{t("configForm.liveTranscode.heightOption", {
height,
})}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="col-span-5 space-y-2">
<Label htmlFor={`${baseId}-${index}-bitrate`}>
{t("configForm.liveTranscode.bitrate")}
</Label>
<Input
id={`${baseId}-${index}-bitrate`}
type="number"
min={64}
value={quality.bitrate}
disabled={locked}
onChange={(e) => {
// an empty input would save 0, which the backend rejects
if (e.target.value === "") return;
updateQuality(index, {
...quality,
bitrate: Number(e.target.value),
});
}}
/>
</div>
<div className="col-span-2 flex justify-end">
<Button
type="button"
variant="ghost"
size="icon"
disabled={locked}
onClick={() => removeQuality(index)}
aria-label={t("configForm.liveTranscode.removeQuality")}
title={t("configForm.liveTranscode.removeQuality")}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={
locked || usedHeights.length >= TRANSCODE_HEIGHTS.length
}
onClick={addQuality}
>
<LuPlus className="h-4 w-4" />
{t("configForm.liveTranscode.addQuality")}
</Button>
</>
)}
</CardContent>
</Card>
);
}
export default LiveTranscodeField;
@@ -3,3 +3,4 @@ export { LayoutGridField } from "./LayoutGridField";
export { ModelsField } from "./ModelsField";
export { ReplaceRulesField } from "./ReplaceRulesField";
export { LiveStreamsField } from "./LiveStreamsField";
export { LiveTranscodeField } from "./LiveTranscodeField";
@@ -57,6 +57,7 @@ import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField";
import { KnownPlatesField } from "./fields/KnownPlatesField";
import { LiveStreamsField } from "./fields/LiveStreamsField";
import { LiveTranscodeField } from "./fields/LiveTranscodeField";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
@@ -121,5 +122,6 @@ export const frigateTheme: FrigateTheme = {
DictAsYamlField: DictAsYamlField,
KnownPlatesField: KnownPlatesField,
LiveStreamsField: LiveStreamsField,
LiveTranscodeField: LiveTranscodeField,
},
};
+32 -16
View File
@@ -10,6 +10,7 @@ import { MdCircle } from "react-icons/md";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import {
LiveHealthSample,
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
@@ -52,6 +53,8 @@ type LivePlayerProps = {
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
onHealthSample?: (sample: LiveHealthSample) => void;
streamAuto?: boolean;
onMicrophoneError?: (error: TwoWayTalkError) => void;
onResetLiveMode?: () => void;
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
@@ -79,6 +82,8 @@ export default function LivePlayer({
onClick,
setFullResolution,
onError,
onHealthSample,
streamAuto = false,
onMicrophoneError,
onResetLiveMode,
onLiveAspectChange,
@@ -109,6 +114,14 @@ export default function LivePlayer({
droppedFrameRate: 0, // percentage
});
const streamLabel = useMemo(
() =>
Object.keys(cameraConfig.live.streams).find(
(label) => cameraConfig.live.streams[label] === streamName,
),
[cameraConfig.live.streams, streamName],
);
// camera activity
const {
@@ -230,21 +243,17 @@ export default function LivePlayer({
}, [preferredLiveMode]);
const [key, setKey] = useState(0);
const prevStreamNameRef = useRef(streamName);
const resetPlayer = () => {
setLiveReady(false);
setKey((prevKey) => prevKey + 1);
};
useEffect(() => {
if (prevStreamNameRef.current !== streamName) {
prevStreamNameRef.current = streamName;
if (streamName) {
resetPlayer();
}
// the stream is part of the MSE and WebRTC keys, so a stream change
// remounts them in the same render and the new player is hidden until it
// plays. jsmpeg plays the camera, not the stream, and keeps playing
const [renderedStream, setRenderedStream] = useState(streamName);
if (renderedStream !== streamName) {
setRenderedStream(streamName);
if (preferredLiveMode !== "jsmpeg") {
setLiveReady(false);
}
}, [streamName]);
}
useEffect(() => {
if (showStillWithoutActivity && !autoLive) {
@@ -294,7 +303,7 @@ export default function LivePlayer({
} else if (preferredLiveMode == "webrtc") {
player = (
<WebRtcPlayer
key={"webrtc_" + key}
key={`webrtc_${streamName}_${key}`}
className={`size-full ${liveReady ? "" : "hidden"}`}
camera={streamName}
playbackEnabled={cameraActive || liveReady}
@@ -308,6 +317,7 @@ export default function LivePlayer({
onPlaying={playerIsPlaying}
pip={pip}
onError={onError}
onHealthSample={onHealthSample}
onMicrophoneError={onMicrophoneError}
/>
);
@@ -315,7 +325,7 @@ export default function LivePlayer({
if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = (
<MSEPlayer
key={"mse_" + key}
key={`mse_${streamName}_${key}`}
className={`size-full ${liveReady ? "" : "hidden"}`}
camera={streamName}
playbackEnabled={cameraActive || liveReady}
@@ -328,6 +338,7 @@ export default function LivePlayer({
pip={pip}
setFullResolution={handleFullResolution}
onError={onError}
onHealthSample={onHealthSample}
/>
);
} else {
@@ -561,7 +572,12 @@ export default function LivePlayer({
)}
</div>
{showStats && (
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
<PlayerStats
stats={stats}
minimal={cameraRef !== undefined}
streamLabel={preferredLiveMode === "jsmpeg" ? undefined : streamLabel}
streamAuto={streamAuto}
/>
)}
</div>
);
@@ -0,0 +1,286 @@
/**
* Policy engine for auto live stream selection.
*
* Rungs index the camera's live streams in config order, followed by
* the jsmpeg floor. Evidence is media delivery falling behind wall
* time while bytes still arrive, which separates congestion from a
* dead camera or a go2rtc restart. No browser API reports spare
* bandwidth, so upswitching is try-and-see with backoff.
*
* Time comes from an injected clock and the owner calls tick() once a
* second, so the class holds no timers.
*/
import type {
LiveAutoReason,
LiveHealthSample,
LivePlayerError,
} from "@/types/live";
// go2rtc bursts its GOP cache when a consumer attaches
const WARMUP_MS = 3000;
// media per wall second below this is a low sample. Low samples reset
// the upswitch clock and feed the chronic window
const LOW_RATE = 0.85;
// net shortfall within the window that downswitches on sustained
// under-delivery. Keyframes make per-second delivery bursty, so the
// window lets a fast second offset a slow one
const SUSTAINED_DEFICIT_S = 2;
const SUSTAINED_WINDOW_MS = 10000;
// low-sample deficit within the window that downswitches on repeated
// short stalls
const CHRONIC_DEFICIT_S = 6;
const CHRONIC_WINDOW_MS = 60000;
// clean time before trying one rung up, doubled after each failed try
const UPSWITCH_WAIT_MS = 60000;
const UPSWITCH_WAIT_MAX_MS = 600000;
// a downswitch this soon after an upswitch counts as a failed try
const PROBATION_MS = 30000;
export type LiveGovernorSnapshot = {
rung: number;
reason: LiveAutoReason | undefined;
};
type Shortfall = {
ts: number;
seconds: number;
};
const total = (entries: Shortfall[]) =>
entries.reduce((sum, entry) => sum + entry.seconds, 0);
export class LiveStreamGovernor {
private readonly floor: number;
private readonly now: () => number;
private readonly listeners = new Set<() => void>();
private readonly unplayable = new Set<number>();
private snapshot: LiveGovernorSnapshot = { rung: 0, reason: undefined };
private holdLow = false;
private rungStartTs: number;
private cleanSinceTs: number;
// every sample, signed, for the sustained window
private shortfalls: Shortfall[] = [];
// low samples only, for the chronic window
private deficits: Shortfall[] = [];
private upswitchWaitMs = UPSWITCH_WAIT_MS;
private probationUntil = 0;
/** rungCount includes the jsmpeg floor. */
constructor(rungCount: number, now: () => number = Date.now) {
this.floor = rungCount - 1;
this.now = now;
this.rungStartTs = now();
this.cleanSinceTs = this.rungStartTs;
}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): LiveGovernorSnapshot => this.snapshot;
/**
* One health sample from the playing transport. Zero-byte samples
* belong to the stall watchdog, and warmup samples only prove that
* bytes are arriving.
*/
sample({ bytes, mediaSeconds, wallSeconds }: LiveHealthSample) {
if (bytes <= 0 || wallSeconds <= 0) {
return;
}
const now = this.now();
if (now - this.rungStartTs < WARMUP_MS) {
return;
}
// WebRTC reports a finished freeze in one sample, so media can be
// negative and one sample can carry more shortfall than its wall time
const shortfall = { ts: now, seconds: wallSeconds - mediaSeconds };
this.shortfalls.push(shortfall);
if (mediaSeconds / wallSeconds < LOW_RATE) {
this.cleanSinceTs = now;
this.deficits.push(shortfall);
}
this.shortfalls = this.shortfalls.filter(
(entry) => entry.ts > now - SUSTAINED_WINDOW_MS,
);
this.deficits = this.deficits.filter(
(entry) => entry.ts > now - CHRONIC_WINDOW_MS,
);
if (
total(this.shortfalls) >= SUSTAINED_DEFICIT_S ||
total(this.deficits) >= CHRONIC_DEFICIT_S
) {
this.stepDown();
}
}
/**
* A player error. Returns true when auto handled it by stepping down,
* false to hand it to the error fallback.
*/
playerError(error: LivePlayerError, sourceOnline: boolean): boolean {
const { rung } = this.snapshot;
if (rung === this.floor) {
return false;
}
// go2rtc's codec reply is proof, so the rung is not retried this session
if (error === "mse-codec") {
this.unplayable.add(rung);
return this.stepDown();
}
// the rung below played a moment ago, so a failed upswitch returns to it
if (this.now() < this.probationUntil) {
return this.stepDown();
}
// an offline camera fails every stream alike; a stall on a live camera
// is the viewer's link, even when congestion stops delivery outright
if (!sourceOnline) {
return false;
}
return this.stepDown();
}
/** Called once a second while auto is active and the page is visible. */
tick() {
const now = this.now();
// the higher rung survived probation, so backoff starts over
if (this.probationUntil > 0 && now >= this.probationUntil) {
this.probationUntil = 0;
this.upswitchWaitMs = UPSWITCH_WAIT_MS;
}
if (now - this.cleanSinceTs < this.upswitchWaitMs) {
return;
}
// data saver may climb out of the floor, but never past its stream
const target = this.nextPlayableAbove(this.snapshot.rung);
if (
target === undefined ||
(this.holdLow && target < this.lowestPlayable())
) {
return;
}
this.probationUntil = now + PROBATION_MS;
this.moveTo(target);
}
/**
* Data saver holds the lowest playable stream and never upswitches past
* it, whatever the connection could carry.
*/
setHoldLow(hold: boolean) {
this.holdLow = hold;
this.moveTo(
hold
? Math.max(this.snapshot.rung, this.lowestPlayable())
: this.snapshot.rung,
);
}
/** A remount or unpause on the same rung starts with clean evidence. */
resetHistory() {
const now = this.now();
this.rungStartTs = now;
this.cleanSinceTs = now;
this.shortfalls = [];
this.deficits = [];
}
/** Returns to the top rung and clears every session fact. */
reset() {
this.unplayable.clear();
this.upswitchWaitMs = UPSWITCH_WAIT_MS;
this.probationUntil = 0;
this.moveTo(this.holdLow ? this.lowestPlayable() : 0);
}
private stepDown(): boolean {
const target = this.nextPlayableBelow(this.snapshot.rung);
if (target === undefined) {
return false;
}
if (this.now() < this.probationUntil) {
this.upswitchWaitMs = Math.min(
this.upswitchWaitMs * 2,
UPSWITCH_WAIT_MAX_MS,
);
}
this.probationUntil = 0;
this.moveTo(target);
return true;
}
private moveTo(rung: number) {
this.resetHistory();
const reason = this.reasonAt(rung);
if (rung === this.snapshot.rung && reason === this.snapshot.reason) {
return;
}
this.snapshot = { rung, reason };
this.listeners.forEach((listener) => listener());
}
private reasonAt(rung: number): LiveAutoReason | undefined {
if (rung === this.floor) {
return "floor";
}
if (this.nextPlayableAbove(rung) === undefined) {
return [...this.unplayable].some((unplayable) => unplayable < rung)
? "codec"
: undefined;
}
return this.holdLow ? "saveData" : "bandwidth";
}
private nextPlayableAbove(rung: number): number | undefined {
for (let candidate = rung - 1; candidate >= 0; candidate--) {
if (!this.unplayable.has(candidate)) {
return candidate;
}
}
return undefined;
}
// the floor is always playable
private nextPlayableBelow(rung: number): number | undefined {
for (let candidate = rung + 1; candidate <= this.floor; candidate++) {
if (candidate === this.floor || !this.unplayable.has(candidate)) {
return candidate;
}
}
return undefined;
}
private lowestPlayable(): number {
for (let candidate = this.floor - 1; candidate >= 0; candidate--) {
if (!this.unplayable.has(candidate)) {
return candidate;
}
}
return this.floor;
}
}
@@ -0,0 +1,128 @@
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LiveAutoReason } from "@/types/live";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu";
import { MdOutlineRestartAlt } from "react-icons/md";
import { Button } from "@/components/ui/button";
// Radix Select needs a non-empty value. Auto itself is stored as the
// absence of a pinned stream
const AUTO_VALUE = "__frigate_auto__";
type LiveStreamSelectProps = {
// friendly name to go2rtc stream name, in ladder order
streams: Record<string, string>;
pinnedStream: string | undefined;
playingStream: string;
autoAvailable: boolean;
autoReason?: LiveAutoReason;
// undefined selects auto
onSelect: (stream: string | undefined) => void;
// sends auto back to the top of the ladder
onRetry: () => void;
disabled: boolean;
};
export default function LiveStreamSelect({
streams,
pinnedStream,
playingStream,
autoAvailable,
autoReason,
onSelect,
onRetry,
disabled,
}: LiveStreamSelectProps) {
const { t } = useTranslation(["views/live"]);
const labelFor = useCallback(
(name: string) =>
Object.keys(streams).find((label) => streams[label] === name) ?? name,
[streams],
);
const auto = autoAvailable && pinnedStream == undefined;
const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream);
return (
<>
<Select
value={value}
disabled={disabled}
onValueChange={(next) =>
onSelect(next === AUTO_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue>
{auto
? t("stream.auto.selected", {
stream:
autoReason === "floor"
? t("stream.auto.floorLabel")
: labelFor(playingStream),
})
: labelFor(value)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{autoAvailable && (
<SelectItem className="cursor-pointer" value={AUTO_VALUE}>
<span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.auto.label")}</span>
<span className="text-xs text-muted-foreground">
{t("stream.auto.tips")}
</span>
</span>
</SelectItem>
)}
{Object.entries(streams).map(([label, name]) => (
<SelectItem key={label} className="cursor-pointer" value={name}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{auto && autoReason && !disabled && (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuInfo className="size-4 shrink-0" />
<div>
{t(`stream.auto.reason.${autoReason}`, {
stream: labelFor(playingStream),
})}
</div>
</div>
{/* data saver pins the lowest stream, so there is no higher one to try */}
{autoReason !== "saveData" && (
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("stream.auto.retry")}
variant="outline"
size="sm"
onClick={onRetry}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.auto.retry")}
</div>
</Button>
)}
</div>
)}
</>
);
}
+110 -19
View File
@@ -1,6 +1,7 @@
import { baseUrl } from "@/api/baseUrl";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import {
LiveHealthSample,
LivePlayerError,
PlayerStatsType,
VideoResolutionType,
@@ -28,6 +29,7 @@ type MSEPlayerProps = {
onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
onHealthSample?: (sample: LiveHealthSample) => void;
};
function MSEPlayer({
@@ -43,6 +45,7 @@ function MSEPlayer({
onPlaying,
setFullResolution,
onError,
onHealthSample,
}: MSEPlayerProps) {
const RECONNECT_TIMEOUT: number = 10000;
const BUFFERING_COOLDOWN_TIMEOUT: number = 5000;
@@ -71,6 +74,7 @@ function MSEPlayer({
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0);
const appendingRef = useRef(false);
const [fallbackTimeout] = useUserPersistence<number>(
"liveFallbackTimeout",
@@ -95,6 +99,13 @@ function MSEPlayer({
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
}, [camera]);
// socket handlers are bound once per connection, so they reach the
// current onError through a ref
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
const handleError = useCallback(
(error: LivePlayerError, description: string = "Unknown error") => {
// eslint-disable-next-line no-console
@@ -110,11 +121,11 @@ function MSEPlayer({
// eslint-disable-next-line no-console
console.error(`${camera} - Supported codecs: ${CODECS.join(", ")}`);
}
onError?.(error);
onErrorRef.current?.(error);
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[camera, onError],
[camera],
);
const handleLoadedMetadata = useCallback(() => {
@@ -470,7 +481,12 @@ function MSEPlayer({
const data = buf.slice(0, bufLen);
bufLen = 0;
sb.appendBuffer(data);
} else if (sb.buffered && sb.buffered.length) {
return;
}
appendingRef.current = false;
if (sb.buffered && sb.buffered.length) {
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
const start = sb.buffered.start(0);
if (end > start) {
@@ -488,6 +504,7 @@ function MSEPlayer({
ondataRef.current = (data) => {
totalBytesLoaded.current += data.byteLength;
appendingRef.current = true;
if (sb?.updating || bufLen > 0) {
const b = new Uint8Array(data);
@@ -503,6 +520,19 @@ function MSEPlayer({
}
};
};
// go2rtc answers a codec it cannot provide with an error message
// instead of a negotiation reply
onmessageRef.current["error"] = (msg) => {
if (msg.type !== "error" || !msg.value?.includes("codecs not matched")) {
return;
}
if (wsRef.current) {
onDisconnect();
}
handleError("mse-codec", msg.value);
};
};
const getBufferedTime = (video: HTMLVideoElement | null) => {
@@ -740,19 +770,38 @@ function MSEPlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]);
// stats
// stats and health samples
const onHealthSampleRef = useRef(onHealthSample);
useEffect(() => {
onHealthSampleRef.current = onHealthSample;
}, [onHealthSample]);
const sampleHealth = onHealthSample !== undefined;
useEffect(() => {
const video = videoRef.current;
let lastLoadedBytes = totalBytesLoaded.current;
let lastTimestamp = Date.now();
let healthBytes = lastLoadedBytes;
let healthTimestamp = lastTimestamp;
// a restart mid-stream must not count the existing buffer as new media
let lastBufferedEnd =
video && video.buffered.length > 0
? video.buffered.end(video.buffered.length - 1)
: 0;
let deferred = false;
if (!getStats) return;
if (!getStats && !sampleHealth) return;
const updateStats = () => {
if (video) {
const now = Date.now();
const bytesLoaded = totalBytesLoaded.current;
const update = () => {
if (!video) {
return;
}
const now = Date.now();
const bytesLoaded = totalBytesLoaded.current;
if (getStats) {
const timeElapsed = (now - lastTimestamp) / 1000; // seconds
const bandwidth = (bytesLoaded - lastLoadedBytes) / timeElapsed / 1000; // kBps
@@ -774,22 +823,64 @@ function MSEPlayer({
droppedFrameRate,
});
}
if (!sampleHealth) {
return;
}
// a sample taken mid-append would count the bytes without their
// media, so it waits one tick for the append to land
if (appendingRef.current && !deferred) {
deferred = true;
return;
}
deferred = false;
// the buffered end tracks delivered media, so playback-rate
// catch-up does not skew it
const bufferedEnd =
video.buffered.length > 0
? video.buffered.end(video.buffered.length - 1)
: 0;
const mediaSeconds = bufferedEnd - lastBufferedEnd;
const bytes = bytesLoaded - healthBytes;
const wallSeconds = (now - healthTimestamp) / 1000;
lastBufferedEnd = bufferedEnd;
healthBytes = bytesLoaded;
healthTimestamp = now;
// a reconnect starts a new MediaSource timeline
if (mediaSeconds < 0) {
return;
}
if (
document.visibilityState !== "visible" ||
(video.paused && bufferedEnd > 0)
) {
return;
}
onHealthSampleRef.current?.({ bytes, mediaSeconds, wallSeconds });
};
const interval = setInterval(updateStats, 1000); // Update every second
const interval = setInterval(update, 1000);
return () => {
clearInterval(interval);
setStats?.({
streamType: "-",
bandwidth: 0,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
if (getStats) {
setStats?.({
streamType: "-",
bandwidth: 0,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
}
};
}, [setStats, getStats]);
}, [setStats, getStats, sampleHealth]);
return (
<video
+18 -1
View File
@@ -5,9 +5,16 @@ import { useTranslation } from "react-i18next";
type PlayerStatsProps = {
stats: PlayerStatsType;
minimal: boolean;
streamLabel?: string;
streamAuto?: boolean;
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
export function PlayerStats({
stats,
minimal,
streamLabel,
streamAuto = false,
}: PlayerStatsProps) {
const { t } = useTranslation(["components/player", "views/live"]);
const streamTypeLabel = t(
`stream.technology.name.${stats.streamType.toLowerCase()}`,
@@ -15,6 +22,16 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
);
const fullStatsContent = (
<>
{streamLabel != undefined && (
<p>
<span className="text-white/70">{t("stats.stream")}</span>{" "}
<span className="text-white">
{streamAuto
? t("stats.streamAuto", { stream: streamLabel })
: streamLabel}
</span>
</p>
)}
<p>
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
<span className="text-white">{streamTypeLabel}</span>
+63 -23
View File
@@ -1,5 +1,6 @@
import { baseUrl } from "@/api/baseUrl";
import {
LiveHealthSample,
LivePlayerError,
PlayerStatsType,
TwoWayTalkError,
@@ -24,6 +25,7 @@ type WebRtcPlayerProps = {
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
onHealthSample?: (sample: LiveHealthSample) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
};
@@ -41,6 +43,7 @@ export default function WebRtcPlayer({
setFullResolution,
onPlaying,
onError,
onHealthSample,
onMicrophoneError,
}: WebRtcPlayerProps) {
// metadata
@@ -354,13 +357,20 @@ export default function WebRtcPlayer({
onPlaying?.();
};
// stats
// stats and health samples
const onHealthSampleRef = useRef(onHealthSample);
useEffect(() => {
onHealthSampleRef.current = onHealthSample;
}, [onHealthSample]);
const sampleHealth = onHealthSample !== undefined;
useEffect(() => {
if (!pcRef.current || !getStats) return;
if (!pcRef.current || (!getStats && !sampleHealth)) return;
let lastBytesReceived = 0;
let lastTimestamp = 0;
let lastFreezeSeconds: number | undefined;
const interval = setInterval(async () => {
if (pcRef.current && videoRef.current && !videoRef.current.paused) {
@@ -369,6 +379,7 @@ export default function WebRtcPlayer({
let timestamp = 0;
let framesReceived = 0;
let framesDecoded = 0;
let freezeSeconds: number | undefined;
report.forEach((stat) => {
if (stat.type === "inbound-rtp" && stat.kind === "video") {
@@ -376,43 +387,72 @@ export default function WebRtcPlayer({
timestamp = stat.timestamp;
framesReceived = stat.framesReceived;
framesDecoded = stat.framesDecoded;
freezeSeconds = stat.totalFreezesDuration;
}
});
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
const bitrate =
timeDiff > 0
? (bytesReceived - lastBytesReceived) / timeDiff / 1000
: 0; // in kBps
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
totalFrames: framesReceived,
droppedFrames: undefined,
decodedFrames: framesDecoded,
droppedFrameRate: undefined,
});
if (getStats) {
const bitrate =
timeDiff > 0
? (bytesReceived - lastBytesReceived) / timeDiff / 1000
: 0; // in kBps
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
totalFrames: framesReceived,
droppedFrames: undefined,
decodedFrames: framesDecoded,
droppedFrameRate: undefined,
});
}
if (
sampleHealth &&
lastTimestamp > 0 &&
timeDiff > 0 &&
document.visibilityState === "visible"
) {
// freeze time does not depend on the camera's frame rate. A
// freeze is credited only when it ends, so media goes negative
// to carry its full length. Browsers without the field report
// full delivery and rely on the stall watchdog
const frozen =
freezeSeconds !== undefined && lastFreezeSeconds !== undefined
? Math.max(0, freezeSeconds - lastFreezeSeconds)
: 0;
onHealthSampleRef.current?.({
bytes: bytesReceived - lastBytesReceived,
mediaSeconds: timeDiff - frozen,
wallSeconds: timeDiff,
});
}
lastBytesReceived = bytesReceived;
lastTimestamp = timestamp;
lastFreezeSeconds = freezeSeconds;
}
}, 1000);
return () => {
clearInterval(interval);
setStats?.({
streamType: "-",
bandwidth: 0,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
if (getStats) {
setStats?.({
streamType: "-",
bandwidth: 0,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
}
};
// we need to listen on the value of the ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pcRef, pcRef.current, getStats]);
}, [pcRef, pcRef.current, getStats, sampleHealth]);
return (
<video
@@ -35,6 +35,7 @@ import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import { isRestreamedStream } from "@/utils/liveTranscode";
import { Switch } from "@/components/ui/switch";
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
@@ -82,9 +83,7 @@ export function CameraStreamingDialog({
// metadata
const isRestreamed = useMemo(
() =>
config &&
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
() => isRestreamedStream(config, streamName),
[config, streamName],
);
+126
View File
@@ -0,0 +1,126 @@
import { LiveStreamGovernor } from "@/components/player/LiveStreamGovernor";
import { isCameraOffline } from "@/hooks/use-camera-activity";
import { useAutoFrigateStats } from "@/hooks/use-stats";
import {
LiveAutoReason,
LiveHealthSample,
LivePlayerError,
} from "@/types/live";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
// one governor per camera ladder for the tab session, so reopening a
// camera resumes where auto settled
const governors = new Map<string, LiveStreamGovernor>();
function governorFor(cameraName: string, streams: string[]) {
const key = `${cameraName}|${streams.join(",")}`;
let governor = governors.get(key);
if (!governor) {
// the extra rung is the jsmpeg floor
governor = new LiveStreamGovernor(streams.length + 1);
governors.set(key, governor);
}
return governor;
}
type UseAutoLiveStreamProps = {
cameraName: string;
// go2rtc stream names in config order
streams: string[];
// auto is the stream setting
selected: boolean;
// mic, debug, force low-bandwidth, the error fallback latch, or unloaded
// preferences own the player
paused: boolean;
};
export type AutoLiveStream = {
streamName: string | undefined;
atFloor: boolean;
reason: LiveAutoReason | undefined;
onHealthSample: (sample: LiveHealthSample) => void;
handleError: (error: LivePlayerError) => boolean;
reset: () => void;
};
export function useAutoLiveStream({
cameraName,
streams,
selected,
paused,
}: UseAutoLiveStreamProps): AutoLiveStream {
const governor = useMemo(
() => governorFor(cameraName, streams),
[cameraName, streams],
);
const { rung, reason } = useSyncExternalStore(
governor.subscribe,
governor.getSnapshot,
);
const active = selected && !paused;
const stats = useAutoFrigateStats();
const offline = isCameraOffline(stats, cameraName);
// evidence from before a pin, a pause, or a remount does not count
useEffect(() => {
if (active) {
governor.resetHistory();
}
}, [active, governor]);
useEffect(() => {
if (!selected) {
return;
}
const saveData =
(navigator as Navigator & { connection?: { saveData?: boolean } })
.connection?.saveData === true;
governor.setHoldLow(saveData);
}, [selected, governor]);
useEffect(() => {
if (!active) {
return;
}
// a hidden tab delivers no samples, so its time is not clean time
const interval = setInterval(() => {
if (document.visibilityState === "visible") {
governor.tick();
} else {
governor.resetHistory();
}
}, 1000);
return () => clearInterval(interval);
}, [active, governor]);
const onHealthSample = useCallback(
(sample: LiveHealthSample) => {
if (active) {
governor.sample(sample);
}
},
[active, governor],
);
const handleError = useCallback(
(error: LivePlayerError) => active && governor.playerError(error, !offline),
[active, governor, offline],
);
const reset = useCallback(() => governor.reset(), [governor]);
const floor = streams.length;
return {
streamName: selected ? streams[Math.min(rung, floor - 1)] : undefined,
atFloor: selected && rung === floor,
reason: selected ? reason : undefined,
onHealthSample,
handleError,
reset,
};
}
+18 -19
View File
@@ -13,6 +13,7 @@ import { useTimelineUtils } from "./use-timeline-utils";
import useDeepMemo from "./use-deep-memo";
import { isEqual } from "lodash";
import { useAutoFrigateStats } from "./use-stats";
import { FrigateStats } from "@/types/stats";
import useSWR from "swr";
import { getAttributeLabels } from "@/utils/iconUtil";
@@ -30,6 +31,19 @@ type useCameraActivityReturn = {
offline: boolean;
};
export function isCameraOffline(
stats: FrigateStats | undefined,
cameraName: string | undefined,
): boolean {
if (!stats?.cameras || !cameraName) {
return false;
}
return (
stats.cameras[cameraName]?.camera_fps == 0 && stats.service.uptime > 60
);
}
export function useCameraActivity(
camera: CameraConfig | undefined,
revalidateOnFocus: boolean = true,
@@ -167,25 +181,10 @@ export function useCameraActivity(
const stats = useAutoFrigateStats();
const offline = useMemo(() => {
if (!stats) {
return false;
}
const cameras = stats["cameras"];
if (!cameras) {
return false;
}
if (!camera?.name) {
return false;
}
return (
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
);
}, [camera, stats]);
const offline = useMemo(
() => isCameraOffline(stats, camera?.name),
[camera, stats],
);
const isCameraEnabled = cameraEnabled ? cameraEnabled === "ON" : true;
+5 -12
View File
@@ -4,6 +4,7 @@ import useSWR from "swr";
import { LivePlayerMode } from "@/types/live";
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import { isRestreamedStream } from "@/utils/liveTranscode";
import {
evaluateStreamWebRTCAvailability,
useWebRTCGloballyAvailable,
@@ -51,18 +52,14 @@ export default function useCameraLiveMode(
cameras.forEach((camera) => {
if (activeStreams && activeStreams[camera.name]) {
const selectedStreamName = activeStreams[camera.name];
const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes(
selectedStreamName,
);
const isRestreamed = isRestreamedStream(config, selectedStreamName);
if (isRestreamed) {
streamNames.add(selectedStreamName);
}
} else {
Object.values(camera.live.streams).forEach((streamName) => {
const isRestreamed = Object.keys(
config.go2rtc.streams || {},
).includes(streamName);
const isRestreamed = isRestreamedStream(config, streamName);
if (isRestreamed) {
streamNames.add(streamName);
@@ -125,9 +122,7 @@ export default function useCameraLiveMode(
cameras.forEach((camera) => {
const selectedStreamName =
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
const isRestreamed = isRestreamedStream(config, selectedStreamName);
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
@@ -181,9 +176,7 @@ export default function useCameraLiveMode(
(cameraConfig
? Object.values(cameraConfig.live.streams)[0]
: cameraName);
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
const isRestreamed = isRestreamedStream(config, selectedStreamName);
setPreferredLiveModes((prevModes) => ({
...prevModes,
+3
View File
@@ -875,6 +875,9 @@ export default function Settings() {
requires_restart: payload.needsRestart ? 1 : 0,
update_topic: payload.updateTopic,
config_data: configData,
...(payload.replacePaths?.length
? { replace_paths: payload.replacePaths }
: {}),
});
if (payload.needsRestart) {
+2
View File
@@ -39,6 +39,8 @@ export type ConfigFormContext = {
extraHasChanges?: boolean;
setExtraHasChanges?: (hasChanges: boolean) => void;
formData?: JsonObject;
/** Key order of each ordered map, so reorders re-render fields */
orderedMapsKeyOrder?: string[][];
pendingDataBySection?: Record<string, ConfigSectionData>;
onPendingDataChange?: (
sectionKey: string,
+12
View File
@@ -36,6 +36,17 @@ export interface FaceRecognitionConfig {
export type SearchModel = "jinav1" | "jinav2" | (string & NonNullable<unknown>);
export type SearchModelSize = "small" | "large";
export type LiveTranscodeQuality = {
height: number;
bitrate: number;
};
export type LiveTranscodeConfig = {
enabled: boolean;
source?: string | null;
qualities: LiveTranscodeQuality[];
};
export interface CameraConfig {
friendly_name: string;
audio: {
@@ -109,6 +120,7 @@ export interface CameraConfig {
height: number;
quality: number;
streams: { [key: string]: string };
transcode: LiveTranscodeConfig;
};
lpr: {
enabled: boolean;
+14 -1
View File
@@ -31,7 +31,20 @@ export type LiveStreamMetadata = {
consumers: LiveConsumerMetadata[];
};
export type LivePlayerError = "stalled" | "startup" | "mse-decode";
// mse-codec is go2rtc's proof that it has no codec this browser plays;
// mse-decode also covers transient Safari failures
export type LivePlayerError =
"stalled" | "startup" | "mse-decode" | "mse-codec";
// one second of delivery from the playing transport
export type LiveHealthSample = {
bytes: number;
mediaSeconds: number;
wallSeconds: number;
};
// why auto is playing below the top stream
export type LiveAutoReason = "bandwidth" | "codec" | "saveData" | "floor";
export type TwoWayTalkError = "microphone" | "refused";
+54 -1
View File
@@ -306,6 +306,49 @@ export function buildOverrides(
return current;
}
// ---------------------------------------------------------------------------
// Ordered maps: config maps whose key order is meaningful
// ---------------------------------------------------------------------------
// lodash isEqual ignores key order, so ordered maps compare entry lists.
export function changedOrderedMapPaths(
current: unknown,
base: unknown,
paths: string[],
): string[] {
return paths.filter((path) => {
const value = get(current, path);
if (!isJsonObject(value)) {
return false;
}
const baseValue = get(base, path);
return !isEqual(
Object.entries(value),
isJsonObject(baseValue) ? Object.entries(baseValue) : undefined,
);
});
}
// Send a changed ordered map whole; per-key overrides cannot carry order.
export function applyOrderedMaps(
overrides: unknown,
current: unknown,
base: unknown,
paths: string[],
): unknown {
const changed = changedOrderedMapPaths(current, base, paths);
if (changed.length === 0) {
return overrides;
}
const result = isJsonObject(overrides) ? cloneDeep(overrides) : {};
changed.forEach((path) => set(result, path, cloneDeep(get(current, path))));
return result;
}
// ---------------------------------------------------------------------------
// flattenOverrides — turn an overrides object into a list of leaf paths
// ---------------------------------------------------------------------------
@@ -513,6 +556,7 @@ export interface SectionSavePayload {
updateTopic: string | undefined;
needsRestart: boolean;
pendingDataKey: string;
replacePaths?: string[];
}
// ---------------------------------------------------------------------------
@@ -722,7 +766,13 @@ export function prepareSectionSavePayload(opts: {
);
// Build overrides
const overrides = buildOverrides(pendingData, rawData, effectiveDefaults);
const orderedMaps = sectionConfig.orderedMaps ?? [];
const overrides = applyOrderedMaps(
buildOverrides(pendingData, rawData, effectiveDefaults),
pendingData,
rawData,
orderedMaps,
);
const sanitizedOverrides = sanitizeOverridesForSection(
schemaSection,
level,
@@ -772,6 +822,9 @@ export function prepareSectionSavePayload(opts: {
updateTopic,
needsRestart,
pendingDataKey,
replacePaths: changedOrderedMapPaths(pendingData, rawData, orderedMaps).map(
(path) => `${basePath}.${path}`,
),
};
}
+101
View File
@@ -0,0 +1,101 @@
import type { FrigateConfig, LiveTranscodeConfig } from "@/types/frigateConfig";
// Mirrors frigate/util/live_streams.py and apply_live_transcode_streams.
export const TRANSCODE_HEIGHTS = [1080, 720, 480, 360, 240];
const DEFAULT_BITRATES: Record<number, number> = {
1080: 2500,
720: 1200,
480: 500,
360: 250,
240: 150,
};
export function defaultTranscodeBitrate(height: number): number {
return DEFAULT_BITRATES[height] ?? 500;
}
export function transcodeStreamName(camera: string, height: number): string {
return `${camera}_transcode_${height}p`;
}
export function isTranscodeStreamName(camera: string, name: string): boolean {
const prefix = `${camera}_transcode_`;
return name.startsWith(prefix) && /^\d+p$/.test(name.slice(prefix.length));
}
/** A generated stream's configured bitrate, or undefined for other streams. */
export function transcodeBitrateFor(
camera: string,
name: string,
transcode: LiveTranscodeConfig | undefined,
): number | undefined {
if (!transcode?.enabled) {
return undefined;
}
return transcode.qualities.find(
(quality) => transcodeStreamName(camera, quality.height) === name,
)?.bitrate;
}
/**
* Drop transcoded entries that are no longer generated and append generated
* streams that are missing. Placed entries keep their position and label.
*/
export function reconcileTranscodeStreams(
camera: string,
streams: Record<string, string>,
transcode: LiveTranscodeConfig,
go2rtcStreams: string[],
): Record<string, string> {
const generated = transcode.enabled
? transcode.qualities.map((quality) => ({
label: `${quality.height}p`,
name: transcodeStreamName(camera, quality.height),
}))
: [];
const generatedNames = new Set(generated.map(({ name }) => name));
const kept = Object.entries(streams).filter(
([, name]) =>
generatedNames.has(name) ||
go2rtcStreams.includes(name) ||
!isTranscodeStreamName(camera, name),
);
const placed = new Set(kept.map(([, name]) => name));
const labels = new Set(kept.map(([label]) => label));
generated.forEach(({ label, name }) => {
// a taken label is left for the backend to reject on save
if (!placed.has(name) && !labels.has(label)) {
kept.push([label, name]);
}
});
return Object.fromEntries(kept);
}
/**
* Whether go2rtc serves a live stream. config.go2rtc.streams lists only the
* yaml streams, so transcoded streams of enabled cameras count too.
*/
export function isRestreamedStream(
config: FrigateConfig | undefined,
name: string | undefined,
): boolean {
if (!config || !name) {
return false;
}
if (Object.keys(config.go2rtc.streams || {}).includes(name)) {
return true;
}
return Object.values(config.cameras).some(
(camera) =>
camera.live.transcode?.enabled &&
isTranscodeStreamName(camera.name, name),
);
}
+115 -104
View File
@@ -13,6 +13,8 @@ import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
import FilterSwitch from "@/components/filter/FilterSwitch";
import LivePlayer from "@/components/player/LivePlayer";
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
import LiveStreamSelect from "@/components/player/LiveStreamSelect";
import { useAutoLiveStream } from "@/hooks/use-auto-live-stream";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
@@ -30,6 +32,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import {
LiveAutoReason,
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
@@ -38,13 +41,7 @@ import {
WebRTCUnavailableReason,
} from "@/types/live";
import { RecordingStartingPoint } from "@/types/record";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
isDesktop,
isFirefox,
@@ -98,14 +95,6 @@ import useSWR from "swr";
import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -116,6 +105,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import { isRestreamedStream } from "@/utils/liveTranscode";
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
import ObjectSettingsView from "../settings/ObjectSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
@@ -157,11 +147,9 @@ export default function LiveCameraView({
// supported features
const [streamName, setStreamName, streamNameLoaded] =
useUserPersistence<string>(
`${camera.name}-stream`,
Object.values(camera.live.streams)[0],
);
// an absent saved stream means auto
const [pinnedStream, setPinnedStream, streamNameLoaded, clearPinnedStream] =
useUserPersistence<string>(`${camera.name}-stream`);
const [
userPreferredLiveMode,
@@ -180,25 +168,67 @@ export default function LiveCameraView({
const preferencesLoaded =
streamNameLoaded && userPreferredLiveModeLoaded && forceLowBandwidthLoaded;
const [mic, setMic] = useState(false);
const [debug, setDebug] = useState(false);
// error fallback latches, layered on top in preferredLiveMode
const [webRTC, setWebRTC] = useState(false);
const [lowBandwidth, setLowBandwidth] = useState(false);
const liveStreams = useMemo(
() => Object.values(camera.live.streams || {}),
[camera.live.streams],
);
const autoAvailable = liveStreams.length > 1;
const autoSelected = autoAvailable && pinnedStream == undefined;
const autoStream = useAutoLiveStream({
cameraName: camera.name,
streams: liveStreams,
selected: autoSelected,
paused:
!preferencesLoaded ||
mic ||
debug ||
webRTC ||
lowBandwidth ||
(forceLowBandwidth ?? false),
});
const {
handleError: autoHandleError,
reset: resetAutoStream,
onHealthSample,
} = autoStream;
// the stream actually playing; everything downstream keys off it
const streamName = autoSelected
? autoStream.streamName
: (pinnedStream ?? liveStreams[0]);
const selectStream = useCallback(
(stream: string | undefined) => {
if (stream == undefined) {
clearPinnedStream();
} else {
setPinnedStream(stream);
}
},
[clearPinnedStream, setPinnedStream],
);
const isRestreamed = useMemo(
() =>
config &&
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
() => isRestreamedStream(config, streamName),
[config, streamName],
);
// validate stored stream name and reset if now invalid
// a pin to a stream no longer in config falls back to auto
useEffect(() => {
if (!streamNameLoaded) return;
if (!streamNameLoaded || pinnedStream == undefined) return;
const available = Object.values(camera.live.streams || {});
if (available.length === 0) return;
if (streamName != null && !available.includes(streamName)) {
setStreamName(available[0]);
if (liveStreams.length > 0 && !liveStreams.includes(pinnedStream)) {
clearPinnedStream();
}
}, [streamNameLoaded, camera.live.streams, streamName, setStreamName]);
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
@@ -395,7 +425,6 @@ export default function LiveCameraView({
// playback state
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
const [mic, setMic] = useState(false);
// the mic only connects through the WebRTC player, so it can't stay on
// for a stream that has lost it
@@ -404,9 +433,8 @@ export default function LiveCameraView({
setMic(false);
}
}, [talkAvailable]);
const [webRTC, setWebRTC] = useState(false);
const [pip, setPip] = useState(false);
const [lowBandwidth, setLowBandwidth] = useState(false);
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
`${camera.name}-background-play`,
@@ -414,7 +442,6 @@ export default function LiveCameraView({
);
const [showStats, setShowStats] = useState(false);
const [debug, setDebug] = useState(false);
useSearchEffect("debug", (value: string) => {
if (value === "true") {
@@ -434,7 +461,7 @@ export default function LiveCameraView({
return "webrtc";
}
if (forceLowBandwidth) {
if (forceLowBandwidth || autoStream.atFloor) {
return "jsmpeg";
}
@@ -467,6 +494,7 @@ export default function LiveCameraView({
isRestreamed,
resolvedUserMode,
isWebRTCAvailable,
autoStream.atFloor,
]);
// A latched error fallback would keep overriding the user's new choice.
@@ -593,6 +621,11 @@ export default function LiveCameraView({
const handleError = useCallback(
(e: LivePlayerError) => {
if (e) {
// auto answers congestion and codec failures with a lower stream
if (autoHandleError(e)) {
return;
}
// WebRTC can now be the user's own choice, so the fallback flag no
// longer implies untried: hopping to the mode that just failed sticks.
if (preferredLiveMode !== "webrtc" && webRTCUsable) {
@@ -603,9 +636,14 @@ export default function LiveCameraView({
}
}
},
[preferredLiveMode, webRTCUsable],
[autoHandleError, preferredLiveMode, webRTCUsable],
);
const resetStream = useCallback(() => {
setLowBandwidth(false);
resetAutoStream();
}, [resetAutoStream]);
const handleMicrophoneError = useCallback(
(error: TwoWayTalkError) => {
setMic(false);
@@ -787,8 +825,11 @@ export default function LiveCameraView({
camera.audio_transcription.enabled_in_config
}
fullscreen={fullscreen}
streamName={streamName ?? ""}
setStreamName={setStreamName}
pinnedStream={pinnedStream}
playingStream={streamName ?? ""}
autoAvailable={autoAvailable}
autoReason={autoStream.reason}
onSelectStream={selectStream}
userPreferredLiveMode={resolvedUserMode}
setUserPreferredLiveMode={setUserPreferredLiveMode}
forceLowBandwidth={forceLowBandwidth ?? false}
@@ -801,7 +842,7 @@ export default function LiveCameraView({
isRestreamed={isRestreamed ?? false}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCAvailability.reason}
setLowBandwidth={setLowBandwidth}
onResetStream={resetStream}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled}
@@ -889,6 +930,8 @@ export default function LiveCameraView({
containerRef={containerRef}
setFullResolution={setFullResolution}
onError={handleError}
onHealthSample={autoSelected ? onHealthSample : undefined}
streamAuto={autoSelected}
onMicrophoneError={handleMicrophoneError}
/>
</div>
@@ -942,8 +985,11 @@ type FrigateCameraFeaturesProps = {
autotrackingEnabled: boolean;
transcriptionEnabled: boolean;
fullscreen: boolean;
streamName: string;
setStreamName?: (value: string | undefined) => void;
pinnedStream: string | undefined;
playingStream: string;
autoAvailable: boolean;
autoReason?: LiveAutoReason;
onSelectStream: (stream: string | undefined) => void;
userPreferredLiveMode: LivePlayerMode;
setUserPreferredLiveMode: (value: LivePlayerMode | undefined) => void;
forceLowBandwidth: boolean;
@@ -956,7 +1002,7 @@ type FrigateCameraFeaturesProps = {
isRestreamed: boolean;
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
onResetStream: () => void;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
cameraEnabled: boolean;
@@ -970,8 +1016,11 @@ function FrigateCameraFeatures({
autotrackingEnabled,
transcriptionEnabled,
fullscreen,
streamName,
setStreamName,
pinnedStream,
playingStream,
autoAvailable,
autoReason,
onSelectStream,
userPreferredLiveMode,
setUserPreferredLiveMode,
forceLowBandwidth,
@@ -984,7 +1033,7 @@ function FrigateCameraFeatures({
isRestreamed,
isWebRTCAvailable,
webRTCUnavailableReason,
setLowBandwidth,
onResetStream,
supportsAudioOutput,
supports2WayTalk,
cameraEnabled,
@@ -1016,10 +1065,6 @@ function FrigateCameraFeatures({
const isAdmin = useIsAdmin();
const streamSelectLabel = Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
);
// manual event
const recordingEventIdRef = useRef<string | null>(null);
@@ -1363,33 +1408,16 @@ function FrigateCameraFeatures({
<Label htmlFor="streaming-method">
{t("stream.title")}
</Label>
<Select
value={streamName}
<LiveStreamSelect
streams={camera.live.streams}
pinnedStream={pinnedStream}
playingStream={playingStream}
autoAvailable={autoAvailable}
autoReason={autoReason}
onSelect={onSelectStream}
onRetry={onResetStream}
disabled={debug || forceLowBandwidth}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>{streamSelectLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
/>
{debug && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
@@ -1510,7 +1538,7 @@ function FrigateCameraFeatures({
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
onClick={onResetStream}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
@@ -1772,33 +1800,16 @@ function FrigateCameraFeatures({
Object.values(camera.live.streams).length > 0 && (
<div className="mt-1 p-2">
<div className="mb-1 text-sm">{t("stream.title")}</div>
<Select
value={streamName}
onValueChange={(value) => {
setStreamName?.(value);
}}
<LiveStreamSelect
streams={camera.live.streams}
pinnedStream={pinnedStream}
playingStream={playingStream}
autoAvailable={autoAvailable}
autoReason={autoReason}
onSelect={onSelectStream}
onRetry={onResetStream}
disabled={debug || forceLowBandwidth}
>
<SelectTrigger className="w-full">
<SelectValue>{streamSelectLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
/>
{debug && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
@@ -1915,7 +1926,7 @@ function FrigateCameraFeatures({
variant="outline"
size="sm"
disabled={debug}
onClick={() => setLowBandwidth(false)}
onClick={onResetStream}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
+4 -1
View File
@@ -298,7 +298,10 @@ export default function LiveDashboardView({
(cameraName: string, error: LivePlayerError) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (error === "mse-decode" && webRTCUsableStates[cameraName]) {
if (
(error === "mse-decode" || error === "mse-codec") &&
webRTCUsableStates[cameraName]
) {
newModes[cameraName] = "webrtc";
} else {
newModes[cameraName] = "jsmpeg";