Miscellaneous fixes (#23238)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run

* start audio transcription post processor when enabled on any camera

* Fetch embed key whenever an error occurs in case the llama server was restarted

* mypy

* add tooltips for colored dots in settings menu

* add ability to reorder cameras from management pane

* add ability to reorder birdseye

* add reordering save text to camera management view

* Include NPU in latency performance hint

* Implement turbo for NPU on object detection

* hide order fields

* drop auto-derived field paths from camera value when unset globally

* use correct field type for export hwaccel args

* add debug replay to detail actions menu

* clarify debug replay in docs

* guard get_current_frame_time against missing camera state

* Implement debug reply from export

* Refactor debug replay to use sources for dynamic playback

* Mypy

* fix debug export replay source timestamp handling

* skip replay cameras in stats immediately

* broadcast debug replay state over ws and buffer pre-OPEN sends

- push debug replay session state over the job_state ws topic so the status bar reacts instantly to start/stop without polling
- fix child-effect-before-parent-effect race in WsProvider that silently dropped initial snapshot requests on cold load

* fix debug replay test hang

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins
2026-05-18 22:52:40 -05:00
committed by GitHub
co-authored by Nicolas Mowen
parent d968f00500
commit 43d97acd21
28 changed files with 1176 additions and 170 deletions
+71 -1
View File
@@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu";
import axios from "axios";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
type CaseCardProps = {
className: string;
@@ -123,11 +126,63 @@ export function ExportCard({
onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const { t } = useTranslation(["views/exports", "views/replay"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
const [isStartingReplay, setIsStartingReplay] = useState(false);
const handleDebugReplay = useCallback(() => {
setIsStartingReplay(true);
axios
.post("debug_replay/start_from_export", {
export_id: exportedRecording.id,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
});
} else {
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{ position: "top-center" },
);
}
})
.finally(() => {
setIsStartingReplay(false);
});
}, [exportedRecording.id, navigate, t]);
// Resync the skeleton state whenever the backing export changes. The
// list keys by id now, so in practice the component remounts instead
@@ -301,6 +356,21 @@ export function ExportCard({
{t("tooltip.downloadVideo")}
</a>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("title", { ns: "views/replay" })}
disabled={isStartingReplay}
onClick={(e) => {
e.stopPropagation();
handleDebugReplay();
}}
>
{isStartingReplay
? t("dialog.starting", { ns: "views/replay" })
: t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
@@ -19,7 +19,7 @@ const birdseye: SectionConfigOverrides = {
],
restartRequired: [],
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
hiddenFields: ["order"],
advancedFields: [],
overrideFields: ["enabled", "mode"],
uiSchema: {
@@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = {
uiSchema: {
mode: {
"ui:size": "xs",
"ui:after": { render: "BirdseyeCameraReorder" },
},
},
},
@@ -46,7 +46,11 @@ const record: SectionConfigOverrides = {
uiSchema: {
export: {
hwaccel_args: {
"ui:options": { suppressMultiSchema: true, size: "lg" },
"ui:widget": "FfmpegArgsWidget",
"ui:options": {
suppressMultiSchema: true,
ffmpegPresetField: "hwaccel_args",
},
},
},
"alerts.retain.mode": {
@@ -5,7 +5,7 @@ const ui: SectionConfigOverrides = {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
hiddenFields: ["order"],
advancedFields: [],
overrideFields: [],
},
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import axios from "axios";
import { toast } from "sonner";
import useSWR from "swr";
import { Reorder, useDragControls } from "framer-motion";
import { LuCheck, LuGripVertical } from "react-icons/lu";
import { SplitCardRow } from "@/components/card/SettingsGroupCard";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { FrigateConfig } from "@/types/frigateConfig";
import { cn } from "@/lib/utils";
import type { SectionRendererProps } from "./registry";
const SAVED_INDICATOR_MS = 1500;
type SaveStatus = "idle" | "saving" | "saved";
export default function BirdseyeCameraReorder({
formContext,
}: SectionRendererProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const birdseyeCameras = useMemo(() => {
if (!config) return [];
return Object.keys(config.cameras)
.filter(
(name) =>
config.cameras[name].enabled_in_config &&
config.cameras[name].birdseye?.enabled !== false,
)
.sort((a, b) => {
const orderA = config.cameras[a].birdseye?.order ?? 0;
const orderB = config.cameras[b].birdseye?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b);
});
}, [config]);
const [orderedCameras, setOrderedCameras] =
useState<string[]>(birdseyeCameras);
const orderedCamerasRef = useRef(orderedCameras);
useEffect(() => {
orderedCamerasRef.current = orderedCameras;
}, [orderedCameras]);
useEffect(() => {
setOrderedCameras((prev) => {
if (
prev.length === birdseyeCameras.length &&
prev.every((cam, i) => cam === birdseyeCameras[i])
) {
return prev;
}
return birdseyeCameras;
});
}, [birdseyeCameras]);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const savedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (savedResetTimerRef.current) {
clearTimeout(savedResetTimerRef.current);
}
};
}, []);
const handleDragEnd = useCallback(async () => {
const current = orderedCamerasRef.current;
if (
current.length === birdseyeCameras.length &&
current.every((cam, i) => cam === birdseyeCameras[i])
) {
return;
}
const cameraUpdates: Record<string, { birdseye: { order: number } }> = {};
current.forEach((cam, i) => {
cameraUpdates[cam] = { birdseye: { order: i * 10 } };
});
if (savedResetTimerRef.current) {
clearTimeout(savedResetTimerRef.current);
savedResetTimerRef.current = null;
}
setSaveStatus("saving");
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { cameras: cameraUpdates },
});
await updateConfig();
setSaveStatus("saved");
savedResetTimerRef.current = setTimeout(() => {
setSaveStatus("idle");
savedResetTimerRef.current = null;
}, SAVED_INDICATOR_MS);
} catch (error) {
setOrderedCameras(birdseyeCameras);
setSaveStatus("idle");
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
position: "top-center",
});
}
}, [birdseyeCameras, updateConfig, t]);
if (formContext?.level && formContext.level !== "global") {
return null;
}
if (!config || birdseyeCameras.length < 2) {
return null;
}
return (
<SplitCardRow
label={t("birdseye.cameraOrder.label", { ns: "views/settings" })}
description={t("birdseye.cameraOrder.description", {
ns: "views/settings",
})}
content={
<div className="max-w-md space-y-1.5">
<Reorder.Group
as="div"
axis="y"
values={orderedCameras}
onReorder={setOrderedCameras}
className="space-y-2 rounded-lg bg-secondary p-4"
>
{orderedCameras.map((camera) => (
<BirdseyeCameraRow
key={camera}
camera={camera}
onDragEnd={handleDragEnd}
/>
))}
</Reorder.Group>
<SaveStatusIndicator status={saveStatus} />
</div>
}
/>
);
}
type SaveStatusIndicatorProps = {
status: SaveStatus;
};
function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) {
const { t } = useTranslation(["views/settings"]);
return (
<div
aria-live="polite"
className={cn(
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
status === "idle" ? "opacity-0" : "opacity-100",
)}
>
{status === "saving" && (
<span className="text-muted-foreground">
{t("birdseye.cameraOrder.saving")}
</span>
)}
{status === "saved" && (
<span className="flex items-center gap-1 text-success">
<LuCheck className="size-3.5" />
{t("birdseye.cameraOrder.saved")}
</span>
)}
</div>
);
}
type BirdseyeCameraRowProps = {
camera: string;
onDragEnd: () => void;
};
function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) {
const { t } = useTranslation(["views/settings"]);
const controls = useDragControls();
return (
<Reorder.Item
as="div"
value={camera}
dragListener={false}
dragControls={controls}
onDragEnd={onDragEnd}
className="flex flex-row items-center gap-1"
>
<button
type="button"
onPointerDown={(e) => controls.start(e)}
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
aria-label={t("birdseye.cameraOrder.reorderHandle")}
>
<LuGripVertical className="size-4" />
</button>
<CameraNameLabel camera={camera} />
</Reorder.Item>
);
}
@@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
import BirdseyeCameraReorder from "./BirdseyeCameraReorder";
import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
@@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = {
notifications: {
NotificationsSettingsExtras,
},
birdseye: {
BirdseyeCameraReorder,
},
};
export default sectionRenderers;
@@ -1,4 +1,6 @@
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Event } from "@/types/event";
import { baseUrl } from "@/api/baseUrl";
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
@@ -12,6 +14,7 @@ import {
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { HiDotsHorizontal } from "react-icons/hi";
import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
@@ -33,9 +36,14 @@ export default function DetailActionsMenu({
setSearch,
setSimilarity,
}: Props) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
const { t } = useTranslation([
"views/explore",
"views/faceLibrary",
"views/replay",
]);
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const isAdmin = useIsAdmin();
const clipTimeRange = useMemo(() => {
@@ -49,6 +57,54 @@ export default function DetailActionsMenu({
search.data?.type === "audio" ? null : [`review/event/${search.id}`],
);
const handleDebugReplay = useCallback(() => {
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: search.camera,
start_time: search.start_time,
end_time: search.end_time,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
});
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
}, [navigate, search.camera, search.start_time, search.end_time, t]);
// don't render menu at all if no options are available
const hasSemanticSearchOption =
config?.semantic_search.enabled &&
@@ -172,6 +228,24 @@ export default function DetailActionsMenu({
</div>
</DropdownMenuItem>
)}
{search.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}
disabled={isStarting}
onSelect={() => {
setIsOpen(false);
handleDebugReplay();
}}
>
<span>
{isStarting
? t("dialog.starting", { ns: "views/replay" })
: t("itemMenu.debugReplay.label")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>