Files
frigate/web/src/utils/cameraUtil.ts
T
Josh HawkinsandGitHub 64d6366ac4 Live streaming tech selection (#24374)
* allow users to select live streaming technology

* fix webrtc being downgraded to mse on load

`useUserPersistence` seeds state with the default and loads asynchronously, so the first render always used `mse` instead of the saved choice, and `useWebRTCGloballyAvailable` reports `checking` until the probe settles and re-enters that state on every consumer mount, so a saved `webrtc` was rewritten to `mse` even after the probe had already passed. On Safari the MSE player then timed out and latched the jsmpeg fallback. A pending probe now counts as available, the player waits on `autoLive` until the stored preferences load, and `handleError` gates on the mode in use since the fallback flag no longer implies webrtc is untried. A rejected IndexedDB read also resolves `loaded` now, so a blocked store can't leave the player waiting forever.

* add support for configurable ICE servers in WebRTC player

* add mic error state, fix dialog overwriting saved choice and dashboard ignoring stream

* tweaks
2026-09-16 09:12:06 -05:00

287 lines
7.9 KiB
TypeScript

import { baseUrl } from "@/api/baseUrl";
import { generateFixedHash, isValidId } from "./stringUtil";
import type { LiveStreamMetadata } from "@/types/live";
import type { StreamConfig } from "@/types/cameraWizard";
/**
* Processes a user-entered camera name and returns both the final camera name
* and friendly name for Frigate configuration.
*
* @param userInput - The name entered by the user (could be display name)
* @returns Object with finalCameraName and friendlyName
*/
export function processCameraName(userInput: string): {
finalCameraName: string;
friendlyName?: string;
} {
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
if (isValidId(normalizedInput)) {
return {
finalCameraName: normalizedInput,
friendlyName: userInput.includes(" ") ? userInput : undefined,
};
}
return {
finalCameraName: generateFixedHash(userInput, "cam"),
friendlyName: userInput,
};
}
/**
* Detect Reolink camera capabilities and recommend optimal protocol
*
* Calls the Frigate backend API which queries the Reolink camera to determine
* its resolution and recommends either http-flv (for 5MP and below) or rtsp
* (for higher resolutions).
*
* @param host - Camera IP address or hostname
* @param username - Camera username
* @param password - Camera password
* @returns The recommended protocol key ("http-flv" or "rtsp"), or null if detection failed
*/
export async function detectReolinkCamera(
host: string,
username: string,
password: string,
): Promise<"http-flv" | "rtsp" | null> {
try {
const params = new URLSearchParams({
host,
username,
password,
});
const response = await fetch(
`${baseUrl}api/reolink/detect?${params.toString()}`,
{
method: "GET",
},
);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data.success && data.protocol) {
return data.protocol;
}
return null;
} catch {
return null;
}
}
/**
* Mask credentials in RTSP URIs for display
*/
export function maskUri(uri: string): string {
try {
// Handle RTSP URLs with user:pass@host format
// Use greedy match for password to handle passwords with @
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):(.+)@(.+)/);
if (rtspMatch) {
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
}
// Handle HTTP/HTTPS URLs with password query parameter
const urlObj = new URL(uri);
if (urlObj.searchParams.has("password")) {
urlObj.searchParams.set("password", "*".repeat(4));
return urlObj.toString();
}
} catch {
// ignore
}
return uri;
}
/**
* Represents the audio features supported by a camera stream
*/
export type CameraAudioFeatures = {
twoWayAudio: boolean;
audioOutput: boolean;
};
/**
* Detects camera audio features from go2rtc stream metadata.
* Checks for two-way audio (backchannel) and audio output capabilities.
*
* @param metadata - The LiveStreamMetadata from go2rtc stream
* @param requireSecureContext - If true, two-way audio requires secure context (default: true)
* @returns CameraAudioFeatures object with detected capabilities
*/
/**
* Calculates optimal detect dimensions from stream resolution.
*
* Scales dimensions to an efficient size for object detection while
* preserving the stream's aspect ratio. Does not upscale.
*
* @param streamWidth - Native stream width in pixels
* @param streamHeight - Native stream height in pixels
* @returns Detect dimensions with even values, or null if inputs are invalid
*/
// Target size for the smaller dimension (width or height) for detect streams
export const DETECT_TARGET_PX = 720;
export function calculateDetectDimensions(
streamWidth: number,
streamHeight: number,
): { width: number; height: number } | null {
if (
!Number.isFinite(streamWidth) ||
!Number.isFinite(streamHeight) ||
streamWidth <= 0 ||
streamHeight <= 0
) {
return null;
}
const smallerDim = Math.min(streamWidth, streamHeight);
const target = Math.min(DETECT_TARGET_PX, smallerDim);
const scale = target / smallerDim;
let width = Math.round(streamWidth * scale);
let height = Math.round(streamHeight * scale);
// Round down to even numbers (required for video processing)
width = width - (width % 2);
height = height - (height % 2);
if (width < 2 || height < 2) {
return null;
}
return { width, height };
}
export function detectCameraAudioFeatures(
metadata: LiveStreamMetadata | null | undefined,
requireSecureContext: boolean = true,
): CameraAudioFeatures {
if (!metadata) {
return {
twoWayAudio: false,
audioOutput: false,
};
}
const twoWayAudio =
(!requireSecureContext || window.isSecureContext) &&
(metadata.producers ?? []).find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, sendonly")) !=
undefined,
) != undefined;
const audioOutput =
(metadata.producers ?? []).find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, recvonly")) !=
undefined,
) != undefined;
return {
twoWayAudio: !!twoWayAudio,
audioOutput: !!audioOutput,
};
}
const MEDIA_DIRECTIONS = new Set(["sendonly", "recvonly", "sendrecv"]);
// "sendonly" is the two-way-talk backchannel, not playback audio.
const PLAYBACK_DIRECTIONS = new Set(["recvonly", "sendrecv"]);
// Parses a go2rtc media line: "audio, recvonly, OPUS/48000/2" -> ["OPUS"],
// stripping the "/clockrate[/channels]" suffix.
function codecsFromMedia(media: string): string[] {
const parts = media.split(",").map((p) => p.trim());
return parts
.slice(1)
.filter((p) => !MEDIA_DIRECTIONS.has(p.toLowerCase()))
.map((p) => p.split("/")[0].toUpperCase());
}
function mediaDirection(media: string): string | undefined {
return media
.split(",")
.map((p) => p.trim().toLowerCase())
.find((p) => MEDIA_DIRECTIONS.has(p));
}
function codecsForKind(
metadata: LiveStreamMetadata | null | undefined,
kind: "video" | "audio",
directions?: Set<string>,
): string[] {
if (!metadata) return [];
const codecs = new Set<string>();
for (const producer of metadata.producers ?? []) {
for (const media of producer.medias ?? []) {
if (!media.trim().toLowerCase().startsWith(kind)) continue;
if (directions) {
const direction = mediaDirection(media);
if (!direction || !directions.has(direction)) continue;
}
codecsFromMedia(media).forEach((c) => codecs.add(c));
}
}
return Array.from(codecs);
}
export function getStreamVideoCodecs(
metadata: LiveStreamMetadata | null | undefined,
): string[] {
return codecsForKind(metadata, "video");
}
// go2rtc exposes a WebRTC-capable playback codec only when one is configured
// (e.g. an opus transcode) or the camera streams G.711 natively.
export function getPlaybackAudioCodecs(
metadata: LiveStreamMetadata | null | undefined,
): string[] {
return codecsForKind(metadata, "audio", PLAYBACK_DIRECTIONS);
}
const REPLAY_CAMERA_PREFIX = "_replay_";
/**
* Check if a camera name is a debug replay camera.
*
* @param name - The camera name to check
* @returns true if the camera is a replay camera
*/
export function isReplayCamera(name: string): boolean {
return name.startsWith(REPLAY_CAMERA_PREFIX);
}
const HEVC_CODEC_NAMES = ["hevc", "h265"];
function isHevcCodec(codec?: string): boolean {
return HEVC_CODEC_NAMES.includes((codec ?? "").trim().toLowerCase());
}
function isRecordingStream(stream: StreamConfig): boolean {
return stream.roles.includes("record") || stream.roles.includes("record_sub");
}
/**
* First recording stream probed as H.265. The other record output's codec
* doesn't matter: ffmpeg drops `-tag:v hvc1` on anything that isn't HEVC.
*/
export function hevcRecordingStreamId(
streams: StreamConfig[],
): string | undefined {
return streams.find(
(s) => isRecordingStream(s) && isHevcCodec(s.testResult?.videoCodec),
)?.id;
}