mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:56:52 +03:00
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.
420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
import {
|
|
useAudioDetections,
|
|
useEnabledState,
|
|
useFrigateEvents,
|
|
useInitialCameraState,
|
|
useMotionActivity,
|
|
} from "@/api/ws";
|
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
|
import { MotionData, ReviewSegment } from "@/types/review";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { AudioDetection, ObjectType } from "@/types/ws";
|
|
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";
|
|
|
|
export type MotionOnlyRange = {
|
|
start_time: number;
|
|
end_time: number;
|
|
};
|
|
|
|
type useCameraActivityReturn = {
|
|
enabled?: boolean;
|
|
activeTracking: boolean;
|
|
activeMotion: boolean;
|
|
objects: ObjectType[];
|
|
audio_detections: AudioDetection[];
|
|
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,
|
|
): useCameraActivityReturn {
|
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
revalidateOnFocus: false,
|
|
});
|
|
const attributeLabels = useMemo(() => {
|
|
if (!config) {
|
|
return [];
|
|
}
|
|
|
|
return getAttributeLabels(config);
|
|
}, [config]);
|
|
const [objects, setObjects] = useState<ObjectType[] | undefined>([]);
|
|
const [audioDetections, setAudioDetections] = useState<
|
|
AudioDetection[] | undefined
|
|
>([]);
|
|
|
|
// init camera activity
|
|
|
|
const { payload: updatedCameraState } = useInitialCameraState(
|
|
camera?.name ?? "",
|
|
revalidateOnFocus,
|
|
);
|
|
useEffect(() => {
|
|
if (updatedCameraState) {
|
|
// functional updater keeps `objects` out of the deps: this effect must
|
|
// only run on snapshot arrival, or it would re-assert a stale snapshot
|
|
// over newer event-driven state
|
|
const newObjects = updatedCameraState.objects ?? [];
|
|
setObjects((currentObjects) =>
|
|
isEqual(currentObjects, newObjects) ? currentObjects : newObjects,
|
|
);
|
|
}
|
|
}, [updatedCameraState, camera]);
|
|
|
|
const { payload: updatedAudioState } = useAudioDetections();
|
|
const memoizedAudioState = useDeepMemo(updatedAudioState);
|
|
|
|
useEffect(() => {
|
|
if (memoizedAudioState && camera?.name) {
|
|
setAudioDetections(memoizedAudioState[camera.name]);
|
|
}
|
|
}, [memoizedAudioState, camera]);
|
|
|
|
// handle camera activity
|
|
|
|
const hasActiveObjects = useMemo(
|
|
() => (objects || []).filter((obj) => !obj?.stationary)?.length > 0,
|
|
[objects],
|
|
);
|
|
|
|
const { payload: cameraEnabled } = useEnabledState(camera?.name ?? "");
|
|
const { payload: detectingMotion } = useMotionActivity(camera?.name ?? "");
|
|
const { payload: event } = useFrigateEvents();
|
|
const updatedEvent = useDeepMemo(event);
|
|
|
|
useEffect(() => {
|
|
if (!updatedEvent) {
|
|
return;
|
|
}
|
|
|
|
if (!camera?.name || updatedEvent.after.camera !== camera.name) {
|
|
return;
|
|
}
|
|
|
|
// functional updater keeps `objects` out of the deps: each event applies
|
|
// exactly once against the true current list, so events arriving close
|
|
// together cannot clobber each other's changes with a stale write-back
|
|
setObjects((currentObjects) => {
|
|
const existingObjects = currentObjects ?? [];
|
|
const updatedEventIndex = existingObjects.findIndex(
|
|
(obj) => obj.id === updatedEvent.after.id,
|
|
);
|
|
|
|
if (updatedEvent.type === "end") {
|
|
if (updatedEventIndex === -1) {
|
|
return currentObjects;
|
|
}
|
|
const newObjects = [...existingObjects];
|
|
newObjects.splice(updatedEventIndex, 1);
|
|
return newObjects;
|
|
}
|
|
|
|
let label = updatedEvent.after.label;
|
|
|
|
if (updatedEvent.after.sub_label) {
|
|
const sub_label = updatedEvent.after.sub_label[0];
|
|
|
|
if (attributeLabels.includes(sub_label)) {
|
|
label = sub_label;
|
|
} else {
|
|
label = `${label}-verified`;
|
|
}
|
|
}
|
|
|
|
if (updatedEventIndex === -1) {
|
|
// add unknown updatedEvent to list if not stationary
|
|
if (updatedEvent.after.stationary) {
|
|
return currentObjects;
|
|
}
|
|
const newActiveObject: ObjectType = {
|
|
id: updatedEvent.after.id,
|
|
label,
|
|
stationary: updatedEvent.after.stationary,
|
|
area: updatedEvent.after.area,
|
|
ratio: updatedEvent.after.ratio,
|
|
score: updatedEvent.after.score,
|
|
sub_label: updatedEvent.after.sub_label?.[0] ?? "",
|
|
};
|
|
return [...existingObjects, newActiveObject];
|
|
}
|
|
|
|
const existing = existingObjects[updatedEventIndex];
|
|
|
|
if (
|
|
existing.label === label &&
|
|
existing.stationary === updatedEvent.after.stationary
|
|
) {
|
|
return currentObjects;
|
|
}
|
|
|
|
const newObjects = [...existingObjects];
|
|
newObjects[updatedEventIndex] = {
|
|
...existing,
|
|
label,
|
|
stationary: updatedEvent.after.stationary,
|
|
};
|
|
return newObjects;
|
|
});
|
|
}, [attributeLabels, camera, updatedEvent]);
|
|
|
|
// determine if camera is offline
|
|
|
|
const stats = useAutoFrigateStats();
|
|
|
|
const offline = useMemo(
|
|
() => isCameraOffline(stats, camera?.name),
|
|
[camera, stats],
|
|
);
|
|
|
|
const isCameraEnabled = cameraEnabled ? cameraEnabled === "ON" : true;
|
|
|
|
return {
|
|
enabled: isCameraEnabled,
|
|
activeTracking: isCameraEnabled ? hasActiveObjects : false,
|
|
activeMotion: isCameraEnabled
|
|
? detectingMotion
|
|
? detectingMotion === "ON"
|
|
: updatedCameraState?.motion === true
|
|
: false,
|
|
objects: isCameraEnabled ? (objects ?? []) : [],
|
|
audio_detections: isCameraEnabled ? (audioDetections ?? []) : [],
|
|
offline,
|
|
};
|
|
}
|
|
|
|
export function useCameraMotionNextTimestamp(
|
|
timeRangeSegmentEnd: number,
|
|
segmentDuration: number,
|
|
motionOnly: boolean,
|
|
reviewItems: ReviewSegment[],
|
|
motionData: MotionData[],
|
|
currentTime: number,
|
|
) {
|
|
const { alignStartDateToTimeline } = useTimelineUtils({
|
|
segmentDuration,
|
|
});
|
|
|
|
const noMotionRanges = useMemo(() => {
|
|
if (!motionData?.length || !reviewItems) {
|
|
return;
|
|
}
|
|
|
|
if (!motionOnly) {
|
|
return [];
|
|
}
|
|
|
|
const ranges: [number, number][] = [];
|
|
let currentSegmentStart: number | null = null;
|
|
let currentSegmentEnd: number | null = null;
|
|
|
|
// align motion start to timeline start
|
|
const offset =
|
|
(motionData[0].start_time -
|
|
alignStartDateToTimeline(timeRangeSegmentEnd)) %
|
|
segmentDuration;
|
|
|
|
const startIndex = Math.abs(Math.floor(offset / 15));
|
|
const now = Date.now() / 1000;
|
|
|
|
for (
|
|
let i = startIndex;
|
|
i < motionData.length;
|
|
i = i + segmentDuration / 15
|
|
) {
|
|
const motionStart = motionData[i]?.start_time;
|
|
|
|
if (motionStart == undefined) {
|
|
continue;
|
|
}
|
|
|
|
const motionEnd = motionStart + segmentDuration;
|
|
|
|
const segmentMotion = motionData
|
|
.slice(i, i + segmentDuration / 15)
|
|
.some(({ motion }) => motion !== undefined && motion > 0);
|
|
const overlappingReviewItems = reviewItems.some(
|
|
(item) =>
|
|
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
|
((item.end_time ?? now) > motionStart &&
|
|
(item.end_time ?? now) <= motionEnd) ||
|
|
(item.start_time <= motionStart &&
|
|
(item.end_time ?? now) >= motionEnd),
|
|
);
|
|
|
|
if (!segmentMotion || overlappingReviewItems) {
|
|
if (currentSegmentStart === null) {
|
|
currentSegmentStart = motionStart;
|
|
}
|
|
currentSegmentEnd = motionEnd;
|
|
} else if (currentSegmentStart !== null && currentSegmentEnd !== null) {
|
|
ranges.push([currentSegmentStart, currentSegmentEnd]);
|
|
currentSegmentStart = null;
|
|
currentSegmentEnd = null;
|
|
}
|
|
}
|
|
|
|
if (currentSegmentStart !== null && currentSegmentEnd !== null) {
|
|
ranges.push([currentSegmentStart, currentSegmentEnd]);
|
|
}
|
|
|
|
return ranges;
|
|
}, [
|
|
motionData,
|
|
reviewItems,
|
|
motionOnly,
|
|
alignStartDateToTimeline,
|
|
segmentDuration,
|
|
timeRangeSegmentEnd,
|
|
]);
|
|
|
|
const nextTimestamp = useMemo(() => {
|
|
if (!noMotionRanges) {
|
|
return;
|
|
}
|
|
|
|
if (!motionOnly) {
|
|
return currentTime + 0.5;
|
|
}
|
|
|
|
let currentRange = 0;
|
|
let nextTimestamp = currentTime + 0.5;
|
|
|
|
while (currentRange < noMotionRanges.length) {
|
|
const [start, end] = noMotionRanges[currentRange];
|
|
|
|
if (start && end) {
|
|
// If the current time is before the start of the current range
|
|
if (currentTime < start) {
|
|
// The next timestamp is either the start of the current range or currentTime + 0.5, whichever is smaller
|
|
nextTimestamp = Math.min(start, nextTimestamp);
|
|
break;
|
|
}
|
|
// If the current time is within the current range
|
|
else if (currentTime >= start && currentTime < end) {
|
|
// The next timestamp is the end of the current range
|
|
nextTimestamp = end;
|
|
currentRange++;
|
|
}
|
|
// If the current time is past the end of the current range
|
|
else {
|
|
currentRange++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return nextTimestamp;
|
|
}, [currentTime, noMotionRanges, motionOnly]);
|
|
|
|
return nextTimestamp;
|
|
}
|
|
|
|
export function useCameraMotionOnlyRanges(
|
|
segmentDuration: number,
|
|
reviewItems: ReviewSegment[],
|
|
motionData: MotionData[],
|
|
) {
|
|
const motionOnlyRanges = useMemo(() => {
|
|
if (!motionData?.length || !reviewItems) {
|
|
return [];
|
|
}
|
|
|
|
const fallbackBucketDuration = Math.max(1, segmentDuration / 2);
|
|
const normalizedMotionData = Array.from(
|
|
motionData
|
|
.reduce((accumulator, item) => {
|
|
const currentMotion = accumulator.get(item.start_time) ?? 0;
|
|
accumulator.set(
|
|
item.start_time,
|
|
Math.max(currentMotion, item.motion ?? 0),
|
|
);
|
|
return accumulator;
|
|
}, new Map<number, number>())
|
|
.entries(),
|
|
)
|
|
.map(([start_time, motion]) => ({ start_time, motion }))
|
|
.sort((left, right) => left.start_time - right.start_time);
|
|
|
|
const bucketRanges: MotionOnlyRange[] = [];
|
|
const now = Date.now() / 1000;
|
|
|
|
for (let i = 0; i < normalizedMotionData.length; i++) {
|
|
const motionStart = normalizedMotionData[i].start_time;
|
|
const motionEnd = motionStart + fallbackBucketDuration;
|
|
|
|
const overlappingReviewItems = reviewItems.some(
|
|
(item) =>
|
|
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
|
((item.end_time ?? now) > motionStart &&
|
|
(item.end_time ?? now) <= motionEnd) ||
|
|
(item.start_time <= motionStart &&
|
|
(item.end_time ?? now) >= motionEnd),
|
|
);
|
|
|
|
const isMotionOnlySegment =
|
|
(normalizedMotionData[i].motion ?? 0) > 0 && !overlappingReviewItems;
|
|
|
|
if (!isMotionOnlySegment) {
|
|
continue;
|
|
}
|
|
|
|
bucketRanges.push({
|
|
start_time: motionStart,
|
|
end_time: motionEnd,
|
|
});
|
|
}
|
|
|
|
if (!bucketRanges.length) {
|
|
return [];
|
|
}
|
|
|
|
const mergedRanges = bucketRanges.reduce<MotionOnlyRange[]>(
|
|
(ranges, range) => {
|
|
if (!ranges.length) {
|
|
return [range];
|
|
}
|
|
|
|
const previousRange = ranges[ranges.length - 1];
|
|
const isContiguous =
|
|
range.start_time <= previousRange.end_time + 0.001 &&
|
|
range.start_time >= previousRange.end_time - 0.001;
|
|
|
|
if (isContiguous) {
|
|
previousRange.end_time = Math.max(
|
|
previousRange.end_time,
|
|
range.end_time,
|
|
);
|
|
return ranges;
|
|
}
|
|
|
|
ranges.push(range);
|
|
return ranges;
|
|
},
|
|
[],
|
|
);
|
|
|
|
return mergedRanges;
|
|
}, [motionData, reviewItems, segmentDuration]);
|
|
|
|
return motionOnlyRanges;
|
|
}
|