mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 23:02:49 +03:00
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
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:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user