Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Waiting to run
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

This commit is contained in:
Blake Blackshear
2026-03-22 17:34:11 -05:00
76 changed files with 1315 additions and 381 deletions
@@ -213,6 +213,7 @@ export function AnimatedEventCard({
playsInline
muted
disableRemotePlayback
disablePictureInPicture
loop
onTimeUpdate={() => {
if (!isLoaded) {
@@ -77,6 +77,7 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useHasFullCameraAccess } from "@/hooks/use-has-full-camera-access";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
@@ -677,7 +678,7 @@ export function CameraGroupEdit({
);
const allowedCameras = useAllowedCameras();
const isAdmin = useIsAdmin();
const hasFullCameraAccess = useHasFullCameraAccess();
const [openCamera, setOpenCamera] = useState<string | null>();
@@ -866,8 +867,7 @@ export function CameraGroupEdit({
<FormDescription>{t("group.cameras.desc")}</FormDescription>
<FormMessage />
{[
...(birdseyeConfig?.enabled &&
(isAdmin || "birdseye" in allowedCameras)
...(birdseyeConfig?.enabled && hasFullCameraAccess
? ["birdseye"]
: []),
...Object.keys(config?.cameras ?? {})
+15 -13
View File
@@ -126,19 +126,21 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
<DropdownMenuSeparator className={isDesktop ? "my-2" : "my-2"} />
{profile?.username && profile.username !== "anonymous" && (
<MenuItem
className={cn(
"flex w-full items-center gap-2",
isDesktop ? "cursor-pointer" : "p-2 text-sm",
)}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
{config?.auth?.enabled !== false &&
profile?.username &&
profile.username !== "anonymous" && (
<MenuItem
className={cn(
"flex w-full items-center gap-2",
isDesktop ? "cursor-pointer" : "p-2 text-sm",
)}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
<MenuItem
className={cn(
+18 -14
View File
@@ -266,20 +266,24 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<DropdownMenuSeparator
className={isDesktop ? "mt-3" : "mt-1"}
/>
{profile?.username && profile.username !== "anonymous" && (
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
{config?.auth?.enabled !== false &&
profile?.username &&
profile.username !== "anonymous" && (
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>
{t("menu.user.setPassword", { ns: "common" })}
</span>
</MenuItem>
)}
<MenuItem
className={
isDesktop
@@ -125,17 +125,23 @@ export default function ClassificationSelectionDialog({
isMobile && "gap-2 pb-4",
)}
>
{classes.sort().map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
{classes
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
@@ -41,6 +41,7 @@ import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator
import ObjectTrackOverlay from "../ObjectTrackOverlay";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { VideoResolutionType } from "@/types/live";
import { VodManifest } from "@/types/playback";
type TrackingDetailsProps = {
className?: string;
@@ -133,19 +134,64 @@ export function TrackingDetails({
},
);
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
// snapping. The backend may snap clipFrom backwards to a keyframe, making
// the video start earlier than the requested time.
const vodManifestUrl = useMemo(() => {
if (!event.camera) return null;
const startTime =
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
const endTime =
(event.end_time ?? Date.now() / 1000) +
annotationOffset / 1000 +
REVIEW_PADDING;
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
}, [event, annotationOffset]);
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30000,
});
// Derive the actual video start time from the VOD manifest's first clip.
// Without this correction the timeline-to-player-time mapping is off by
// the keyframe preroll amount.
const actualVideoStart = useMemo(() => {
const videoStartTime = eventStartRecord - REVIEW_PADDING;
if (!vodManifest?.sequences?.[0]?.clips?.[0] || !recordings?.length) {
return videoStartTime;
}
const firstClip = vodManifest.sequences[0].clips[0];
// Guard: clipFrom is only expected when the first recording starts before
// the requested start. If this doesn't hold, fall back.
if (recordings[0].start_time >= videoStartTime) {
return recordings[0].start_time;
}
if (firstClip.clipFrom !== undefined) {
// clipFrom is in milliseconds from the start of the first recording
return recordings[0].start_time + firstClip.clipFrom / 1000;
}
// clipFrom absent means the full recording is included (keyframe probe failed)
return recordings[0].start_time;
}, [vodManifest, recordings, eventStartRecord]);
// Convert a timeline timestamp to actual video player time, accounting for
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
const timestampToVideoTime = useCallback(
(timestamp: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
return timestamp - (eventStartRecord - REVIEW_PADDING);
return timestamp - actualVideoStart;
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// If timestamp is before video start, return 0
if (timestamp < videoStartTime) return 0;
// If timestamp is before actual video start, return 0
if (timestamp < actualVideoStart) return 0;
// Check if timestamp is before the first recording or after the last
if (
@@ -159,10 +205,10 @@ export function TrackingDetails({
// Calculate the inpoint offset - the HLS video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
actualVideoStart > recordings[0].start_time &&
actualVideoStart < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
inpointOffset = actualVideoStart - recordings[0].start_time;
}
let seekSeconds = 0;
@@ -180,7 +226,7 @@ export function TrackingDetails({
if (segment === recordings[0]) {
// For the first segment, account for the inpoint offset
seekSeconds +=
timestamp - Math.max(segment.start_time, videoStartTime);
timestamp - Math.max(segment.start_time, actualVideoStart);
} else {
seekSeconds += timestamp - segment.start_time;
}
@@ -190,7 +236,7 @@ export function TrackingDetails({
return seekSeconds;
},
[recordings, eventStartRecord],
[recordings, actualVideoStart],
);
// Convert video player time back to timeline timestamp, accounting for
@@ -199,19 +245,16 @@ export function TrackingDetails({
(playerTime: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
const videoStartTime = eventStartRecord - REVIEW_PADDING;
return playerTime + videoStartTime;
return playerTime + actualVideoStart;
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// Calculate the inpoint offset - the video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
actualVideoStart > recordings[0].start_time &&
actualVideoStart < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
inpointOffset = actualVideoStart - recordings[0].start_time;
}
let timestamp = 0;
@@ -228,7 +271,7 @@ export function TrackingDetails({
if (segment === recordings[0]) {
// For the first segment, add the inpoint offset
timestamp =
Math.max(segment.start_time, videoStartTime) +
Math.max(segment.start_time, actualVideoStart) +
(playerTime - totalTime);
} else {
timestamp = segment.start_time + (playerTime - totalTime);
@@ -241,7 +284,7 @@ export function TrackingDetails({
return timestamp;
},
[recordings, eventStartRecord],
[recordings, actualVideoStart],
);
eventSequence?.map((event) => {
@@ -1080,7 +1123,7 @@ function LifecycleIconRow({
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
<div className="flex flex-row items-center gap-3">
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
{isAdmin && config?.plus?.enabled && item.data.box && (
{isAdmin && (config?.plus?.enabled || item.data.box) && (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button">
+1 -1
View File
@@ -357,7 +357,7 @@ export default function HlsVideoPlayer({
{transformedOverlay}
{isDetailMode &&
camera &&
currentTime &&
currentTime != null &&
loadedMetadata &&
videoDimensions.width > 0 &&
videoDimensions.height > 0 && (
@@ -20,7 +20,10 @@ import type {
CameraConfigData,
ConfigSetBody,
} from "@/types/cameraWizard";
import { processCameraName } from "@/utils/cameraUtil";
import {
processCameraName,
calculateDetectDimensions,
} from "@/utils/cameraUtil";
import { cn } from "@/lib/utils";
type WizardState = {
@@ -203,6 +206,25 @@ export default function CameraWizardDialog({
},
};
// Calculate detect dimensions from the detect stream's probed resolution
const detectStream = wizardData.streams.find((stream) =>
stream.roles.includes("detect"),
);
if (detectStream?.testResult?.resolution) {
const [streamWidth, streamHeight] = detectStream.testResult.resolution
.split("x")
.map(Number);
if (streamWidth > 0 && streamHeight > 0) {
const detectDimensions = calculateDetectDimensions(
streamWidth,
streamHeight,
);
if (detectDimensions) {
configData.cameras[finalCameraName].detect = detectDimensions;
}
}
}
// Add live.streams configuration for go2rtc streams
if (wizardData.streams && wizardData.streams.length > 0) {
configData.cameras[finalCameraName].live = {