Files
frigate/web/src/hooks/use-camera-live-mode.ts
T
Josh Hawkins cddd17f36e 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.
2026-09-26 13:26:27 -05:00

204 lines
6.6 KiB
TypeScript

import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useState, useMemo } from "react";
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,
} from "./use-webrtc-availability";
// Shared by the initial computation and the context-menu "Reset" so the two
// can't diverge.
function resolveLiveMode(
isRestreamed: boolean,
mseSupported: boolean,
webRTCAvailable: boolean,
requested: LivePlayerMode | undefined,
): LivePlayerMode {
let mode: LivePlayerMode;
if (!mseSupported) {
mode = isRestreamed && webRTCAvailable ? "webrtc" : "jsmpeg";
} else {
mode = isRestreamed ? "mse" : "jsmpeg";
}
if (requested === "jsmpeg") {
mode = "jsmpeg";
} else if (requested === "webrtc" && isRestreamed && webRTCAvailable) {
mode = "webrtc";
} else if (requested === "mse" && mseSupported && isRestreamed) {
mode = "mse";
}
return mode;
}
export default function useCameraLiveMode(
cameras: CameraConfig[],
windowVisible: boolean,
activeStreams?: { [cameraName: string]: string },
preferredModes?: { [cameraName: string]: LivePlayerMode | undefined },
) {
const { data: config } = useSWR<FrigateConfig>("config");
// Compute which streams need metadata (restreamed streams only)
const restreamedStreamNames = useMemo(() => {
if (!cameras || !config) return [];
const streamNames = new Set<string>();
cameras.forEach((camera) => {
if (activeStreams && activeStreams[camera.name]) {
const selectedStreamName = activeStreams[camera.name];
const isRestreamed = isRestreamedStream(config, selectedStreamName);
if (isRestreamed) {
streamNames.add(selectedStreamName);
}
} else {
Object.values(camera.live.streams).forEach((streamName) => {
const isRestreamed = isRestreamedStream(config, streamName);
if (isRestreamed) {
streamNames.add(streamName);
}
});
}
});
return Array.from(streamNames);
}, [cameras, config, activeStreams]);
// Fetch stream metadata with deferred loading (doesn't block initial render)
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
// "checking" counts as usable because the probe re-enters it on every mount,
// and treating it as unavailable would downgrade a saved WebRTC choice.
const webRTCUsableStates = useMemo(() => {
const states: { [cameraName: string]: boolean } = {};
cameras.forEach((camera) => {
const streamName =
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
const { available, reason } = evaluateStreamWebRTCAvailability({
globallyAvailable,
globalReason,
metadata: streamMetadata[streamName],
});
states[camera.name] = available || reason === "checking";
});
return states;
}, [cameras, activeStreams, globallyAvailable, globalReason, streamMetadata]);
// Compute live mode states
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
}>({});
const [isRestreamedStates, setIsRestreamedStates] = useState<{
[key: string]: boolean;
}>({});
const [supportsAudioOutputStates, setSupportsAudioOutputStates] = useState<{
[key: string]: {
supportsAudio: boolean;
cameraName: string;
};
}>({});
useEffect(() => {
if (!cameras || cameras.length === 0) return;
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
const newPreferredLiveModes: { [key: string]: LivePlayerMode } = {};
const newIsRestreamedStates: { [key: string]: boolean } = {};
const newSupportsAudioOutputStates: {
[key: string]: { supportsAudio: boolean; cameraName: string };
} = {};
cameras.forEach((camera) => {
const selectedStreamName =
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
const isRestreamed = isRestreamedStream(config, selectedStreamName);
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
// Auto-selected default, overridden by the user's per-camera choice when viable
// Runtime fallback (player errors) still adjusts the mode on top of this base
newPreferredLiveModes[camera.name] = resolveLiveMode(
!!isRestreamed,
mseSupported,
webRTCUsableStates[camera.name] ?? false,
preferredModes?.[camera.name],
);
// Check each stream for audio support
if (isRestreamed) {
Object.values(camera.live.streams).forEach((streamName) => {
const metadata = streamMetadata[streamName];
const audioFeatures = detectCameraAudioFeatures(metadata);
newSupportsAudioOutputStates[streamName] = {
supportsAudio: audioFeatures.audioOutput,
cameraName: camera.name,
};
});
} else {
newSupportsAudioOutputStates[camera.name] = {
supportsAudio: false,
cameraName: camera.name,
};
}
});
setPreferredLiveModes(newPreferredLiveModes);
setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [
activeStreams,
cameras,
config,
windowVisible,
streamMetadata,
webRTCUsableStates,
preferredModes,
]);
const resetPreferredLiveMode = useCallback(
(cameraName: string) => {
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
const cameraConfig = cameras.find((camera) => camera.name === cameraName);
const selectedStreamName =
activeStreams?.[cameraName] ??
(cameraConfig
? Object.values(cameraConfig.live.streams)[0]
: cameraName);
const isRestreamed = isRestreamedStream(config, selectedStreamName);
setPreferredLiveModes((prevModes) => ({
...prevModes,
[cameraName]: resolveLiveMode(
!!isRestreamed,
mseSupported,
webRTCUsableStates[cameraName] ?? false,
preferredModes?.[cameraName],
),
}));
},
[activeStreams, cameras, config, webRTCUsableStates, preferredModes],
);
return {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
webRTCUsableStates,
};
}