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 {
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):(.+)@(.+)/);
if (rtspMatch) {
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
}
const urlObj = new URL(uri);
if (urlObj.searchParams.has("password")) {
urlObj.searchParams.set("password", "*".repeat(4));
return urlObj.toString();
}
} catch {
}
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
*/
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);
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"]);
const PLAYBACK_DIRECTIONS = new Set(["recvonly", "sendrecv"]);
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");
}
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;
}