- {t(`stream.auto.reason.${autoReason}`, {
- stream: labelFor(playingStream),
- })}
+
+
+
+
+ {t(`stream.auto.reason.${autoReason}`, {
+ stream: labelFor(playingStream),
+ })}
+
+
+ {/* data saver pins the lowest stream, so there is no higher one to try */}
+ {autoReason !== "saveData" && (
+
+ )}
)}
>
diff --git a/web/src/components/settings/CameraStreamingDialog.tsx b/web/src/components/settings/CameraStreamingDialog.tsx
index b5a5a2b529..e8bdc9bdd2 100644
--- a/web/src/components/settings/CameraStreamingDialog.tsx
+++ b/web/src/components/settings/CameraStreamingDialog.tsx
@@ -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],
);
diff --git a/web/src/hooks/use-auto-live-stream.ts b/web/src/hooks/use-auto-live-stream.ts
index a59bca9c04..3a90f8a237 100644
--- a/web/src/hooks/use-auto-live-stream.ts
+++ b/web/src/hooks/use-auto-live-stream.ts
@@ -1,4 +1,6 @@
import { LiveStreamGovernor } from "@/components/player/LiveStreamGovernor";
+import { isCameraOffline } from "@/hooks/use-camera-activity";
+import { useAutoFrigateStats } from "@/hooks/use-stats";
import {
LiveAutoReason,
LiveHealthSample,
@@ -57,6 +59,9 @@ export function useAutoLiveStream({
);
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) {
@@ -102,8 +107,8 @@ export function useAutoLiveStream({
);
const handleError = useCallback(
- (error: LivePlayerError) => active && governor.playerError(error),
- [active, governor],
+ (error: LivePlayerError) => active && governor.playerError(error, !offline),
+ [active, governor, offline],
);
const reset = useCallback(() => governor.reset(), [governor]);
diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts
index cd8fd8f38d..8dd3c54f26 100644
--- a/web/src/hooks/use-camera-activity.ts
+++ b/web/src/hooks/use-camera-activity.ts
@@ -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;
diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts
index 7811a4e78d..845b27b75d 100644
--- a/web/src/hooks/use-camera-live-mode.ts
+++ b/web/src/hooks/use-camera-live-mode.ts
@@ -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,
diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts
index 2fe1080cbd..0313db733c 100644
--- a/web/src/types/configForm.ts
+++ b/web/src/types/configForm.ts
@@ -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
;
onPendingDataChange?: (
sectionKey: string,
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts
index 21754a91c8..bce9cb4a60 100644
--- a/web/src/types/frigateConfig.ts
+++ b/web/src/types/frigateConfig.ts
@@ -36,6 +36,17 @@ export interface FaceRecognitionConfig {
export type SearchModel = "jinav1" | "jinav2" | (string & NonNullable);
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;
diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts
index 48854a84e9..78b12eaa03 100644
--- a/web/src/utils/configUtil.ts
+++ b/web/src/utils/configUtil.ts
@@ -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
// ---------------------------------------------------------------------------
diff --git a/web/src/utils/liveTranscode.ts b/web/src/utils/liveTranscode.ts
new file mode 100644
index 0000000000..199a9a9477
--- /dev/null
+++ b/web/src/utils/liveTranscode.ts
@@ -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 = {
+ 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,
+ transcode: LiveTranscodeConfig,
+ go2rtcStreams: string[],
+): Record {
+ 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),
+ );
+}
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx
index 3f328b45a4..b04eafc414 100644
--- a/web/src/views/live/LiveCameraView.tsx
+++ b/web/src/views/live/LiveCameraView.tsx
@@ -108,6 +108,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";
@@ -218,9 +219,7 @@ export default function LiveCameraView({
);
const isRestreamed = useMemo(
- () =>
- config &&
- Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
+ () => isRestreamedStream(config, streamName),
[config, streamName],
);
@@ -1404,6 +1403,7 @@ function FrigateCameraFeatures({
autoAvailable={autoAvailable}
autoReason={autoReason}
onSelect={onSelectStream}
+ onRetry={onResetStream}
disabled={debug || forceLowBandwidth}
/>
@@ -1795,6 +1795,7 @@ function FrigateCameraFeatures({
autoAvailable={autoAvailable}
autoReason={autoReason}
onSelect={onSelectStream}
+ onRetry={onResetStream}
disabled={debug || forceLowBandwidth}
/>