Merge pull request #4 from Frigate-CN/dev

0.17.0-rc2
This commit is contained in:
GuoQing Liu 2026-02-16 22:08:19 +08:00 committed by GitHub
commit f72418ceb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 378 additions and 141 deletions

View File

@ -166,6 +166,10 @@ In this example:
- If no mapping matches, Frigate falls back to `default_role` if configured. - If no mapping matches, Frigate falls back to `default_role` if configured.
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. - If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
**Note on matching semantics:**
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
#### Port Considerations #### Port Considerations
**Authenticated Port (8971)** **Authenticated Port (8971)**

View File

@ -190,7 +190,7 @@ There are improved capabilities in newer GPU architectures that TensorRT can ben
#### Compatibility References: #### Compatibility References:
[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt/archives/tensorrt-841/support-matrix/index.html) [NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt-rtx/latest/getting-started/support-matrix.html)
[NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html) [NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html)

View File

@ -439,10 +439,11 @@ def resolve_role(
Determine the effective role for a request based on proxy headers and configuration. Determine the effective role for a request based on proxy headers and configuration.
Order of resolution: Order of resolution:
1. If a role header is defined in proxy_config.header_map.role: 1. If a role header is defined in proxy_config.header_map.role:
- If a role_map is configured, treat the header as group claims - If a role_map is configured, treat the header as group claims
(split by proxy_config.separator) and map to roles. (split by proxy_config.separator) and map to roles.
- If no role_map is configured, treat the header as role names directly. Admin matches short-circuit to admin.
- If no role_map is configured, treat the header as role names directly.
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'. 2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
Args: Args:
@ -492,6 +493,12 @@ def resolve_role(
} }
logger.debug("Matched roles from role_map: %s", matched_roles) logger.debug("Matched roles from role_map: %s", matched_roles)
# If admin matches, prioritize it to avoid accidental downgrade when
# users belong to both admin and lower-privilege groups.
if "admin" in matched_roles and "admin" in config_roles:
logger.debug("Resolved role (with role_map) to 'admin'.")
return "admin"
if matched_roles: if matched_roles:
resolved = next( resolved = next(
(r for r in config_roles if r in matched_roles), validated_default (r for r in config_roles if r in matched_roles), validated_default

View File

@ -69,6 +69,25 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.events]) router = APIRouter(tags=[Tags.events])
def _build_attribute_filter_clause(attributes: str):
filtered_attributes = [
attr.strip() for attr in attributes.split(",") if attr.strip()
]
attribute_clauses = []
for attr in filtered_attributes:
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
escaped_attr = json.dumps(attr, ensure_ascii=True)[1:-1]
if escaped_attr != attr:
attribute_clauses.append(Event.data.cast("text") % f'*:"{escaped_attr}"*')
if not attribute_clauses:
return None
return reduce(operator.or_, attribute_clauses)
@router.get( @router.get(
"/events", "/events",
response_model=list[EventResponse], response_model=list[EventResponse],
@ -193,14 +212,9 @@ def events(
if attributes != "all": if attributes != "all":
# Custom classification results are stored as data[model_name] = result_value # Custom classification results are stored as data[model_name] = result_value
filtered_attributes = attributes.split(",") attribute_clause = _build_attribute_filter_clause(attributes)
attribute_clauses = [] if attribute_clause is not None:
clauses.append(attribute_clause)
for attr in filtered_attributes:
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
attribute_clause = reduce(operator.or_, attribute_clauses)
clauses.append(attribute_clause)
if recognized_license_plate != "all": if recognized_license_plate != "all":
filtered_recognized_license_plates = recognized_license_plate.split(",") filtered_recognized_license_plates = recognized_license_plate.split(",")
@ -508,7 +522,7 @@ def events_search(
cameras = params.cameras cameras = params.cameras
labels = params.labels labels = params.labels
sub_labels = params.sub_labels sub_labels = params.sub_labels
attributes = params.attributes attributes = unquote(params.attributes)
zones = params.zones zones = params.zones
after = params.after after = params.after
before = params.before before = params.before
@ -607,13 +621,9 @@ def events_search(
if attributes != "all": if attributes != "all":
# Custom classification results are stored as data[model_name] = result_value # Custom classification results are stored as data[model_name] = result_value
filtered_attributes = attributes.split(",") attribute_clause = _build_attribute_filter_clause(attributes)
attribute_clauses = [] if attribute_clause is not None:
event_filters.append(attribute_clause)
for attr in filtered_attributes:
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
event_filters.append(reduce(operator.or_, attribute_clauses))
if zones != "all": if zones != "all":
zone_clauses = [] zone_clauses = []

View File

@ -658,6 +658,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
def handle_request(self, topic, request_data): def handle_request(self, topic, request_data):
if topic == EmbeddingsRequestEnum.reload_classification_model.value: if topic == EmbeddingsRequestEnum.reload_classification_model.value:
if request_data.get("model_name") == self.model_config.name: if request_data.get("model_name") == self.model_config.name:
self.__build_detector()
logger.info( logger.info(
f"Successfully loaded updated model for {self.model_config.name}" f"Successfully loaded updated model for {self.model_config.name}"
) )

View File

@ -6,6 +6,7 @@ from typing import Dict
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event from frigate.models import Event
from frigate.util.builtin import to_relative_box from frigate.util.builtin import to_relative_box
@ -247,6 +248,18 @@ class EventProcessor(threading.Thread):
"recognized_license_plate" "recognized_license_plate"
][1] ][1]
# only overwrite attribute-type custom model fields in the database if they're set
for name, model_config in self.config.classification.custom.items():
if (
model_config.object_config
and model_config.object_config.classification_type
== ObjectClassificationType.attribute
):
value = event_data.get(name)
if value is not None:
event[Event.data][name] = value[0]
event[Event.data][f"{name}_score"] = value[1]
( (
Event.insert(event) Event.insert(event)
.on_conflict( .on_conflict(

View File

@ -168,6 +168,57 @@ class TestHttpApp(BaseTestHttp):
assert events[0]["id"] == id assert events[0]["id"] == id
assert events[1]["id"] == id2 assert events[1]["id"] == id2
def test_get_event_list_match_multilingual_attribute(self):
event_id = "123456.zh"
attribute = "中文标签"
with AuthTestClient(self.app) as client:
super().insert_mock_event(event_id, data={"custom_attr": attribute})
events = client.get("/events", params={"attributes": attribute}).json()
assert len(events) == 1
assert events[0]["id"] == event_id
events = client.get(
"/events", params={"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE"}
).json()
assert len(events) == 1
assert events[0]["id"] == event_id
def test_events_search_match_multilingual_attribute(self):
event_id = "123456.zh.search"
attribute = "中文标签"
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [(event_id, 0.05)]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
super().insert_mock_event(event_id, data={"custom_attr": attribute})
events = client.get(
"/events/search",
params={
"search_type": "similarity",
"event_id": event_id,
"attributes": attribute,
},
).json()
assert len(events) == 1
assert events[0]["id"] == event_id
events = client.get(
"/events/search",
params={
"search_type": "similarity",
"event_id": event_id,
"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE",
},
).json()
assert len(events) == 1
assert events[0]["id"] == event_id
def test_get_good_event(self): def test_get_good_event(self):
id = "123456.random" id = "123456.random"

View File

@ -31,6 +31,21 @@ class TestProxyRoleResolution(unittest.TestCase):
role = resolve_role(headers, self.proxy_config, self.config_roles) role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, "admin") self.assertEqual(role, "admin")
def test_role_map_or_matching(self):
config = self.proxy_config
config.header_map.role_map = {
"admin": ["group_admin", "group_privileged"],
}
# OR semantics: a single matching group should map to the role
headers = {"x-remote-role": "group_admin"}
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")
headers = {"x-remote-role": "group_admin|group_privileged"}
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")
def test_direct_role_header_with_separator(self): def test_direct_role_header_with_separator(self):
config = self.proxy_config config = self.proxy_config
config.header_map.role_map = None # disable role_map config.header_map.role_map = None # disable role_map

View File

@ -33,6 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
) )
from frigate.config.classification import ObjectClassificationType
from frigate.const import ( from frigate.const import (
FAST_QUEUE_TIMEOUT, FAST_QUEUE_TIMEOUT,
UPDATE_CAMERA_ACTIVITY, UPDATE_CAMERA_ACTIVITY,
@ -759,8 +760,16 @@ class TrackedObjectProcessor(threading.Thread):
self.update_mqtt_motion(camera, frame_time, motion_boxes) self.update_mqtt_motion(camera, frame_time, motion_boxes)
attribute_model_names = [
name
for name, model_config in self.config.classification.custom.items()
if model_config.object_config
and model_config.object_config.classification_type
== ObjectClassificationType.attribute
]
tracked_objects = [ tracked_objects = [
o.to_dict() for o in camera_state.tracked_objects.values() o.to_dict(attribute_model_names=attribute_model_names)
for o in camera_state.tracked_objects.values()
] ]
# publish info on this frame # publish info on this frame

View File

@ -376,7 +376,10 @@ class TrackedObject:
) )
return (thumb_update, significant_change, path_update, autotracker_update) return (thumb_update, significant_change, path_update, autotracker_update)
def to_dict(self) -> dict[str, Any]: def to_dict(
self,
attribute_model_names: list[str] | None = None,
) -> dict[str, Any]:
event = { event = {
"id": self.obj_data["id"], "id": self.obj_data["id"],
"camera": self.camera_config.name, "camera": self.camera_config.name,
@ -411,6 +414,11 @@ class TrackedObject:
"path_data": self.path_data.copy(), "path_data": self.path_data.copy(),
"recognized_license_plate": self.obj_data.get("recognized_license_plate"), "recognized_license_plate": self.obj_data.get("recognized_license_plate"),
} }
if attribute_model_names is not None:
for name in attribute_model_names:
value = self.obj_data.get(name)
if value is not None:
event[name] = value
return event return event

View File

@ -214,6 +214,7 @@ class CameraWatchdog(threading.Thread):
self.latest_valid_segment_time: float = 0 self.latest_valid_segment_time: float = 0
self.latest_invalid_segment_time: float = 0 self.latest_invalid_segment_time: float = 0
self.latest_cache_segment_time: float = 0 self.latest_cache_segment_time: float = 0
self.record_enable_time: datetime | None = None
def _update_enabled_state(self) -> bool: def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state.""" """Fetch the latest config and update enabled state."""
@ -261,6 +262,9 @@ class CameraWatchdog(threading.Thread):
def run(self) -> None: def run(self) -> None:
if self._update_enabled_state(): if self._update_enabled_state():
self.start_all_ffmpeg() self.start_all_ffmpeg()
# If recording is enabled at startup, set the grace period timer
if self.config.record.enabled:
self.record_enable_time = datetime.now().astimezone(timezone.utc)
time.sleep(self.sleeptime) time.sleep(self.sleeptime)
while not self.stop_event.wait(self.sleeptime): while not self.stop_event.wait(self.sleeptime):
@ -270,13 +274,15 @@ class CameraWatchdog(threading.Thread):
self.logger.debug(f"Enabling camera {self.config.name}") self.logger.debug(f"Enabling camera {self.config.name}")
self.start_all_ffmpeg() self.start_all_ffmpeg()
# reset all timestamps # reset all timestamps and record the enable time for grace period
self.latest_valid_segment_time = 0 self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0 self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0 self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(timezone.utc)
else: else:
self.logger.debug(f"Disabling camera {self.config.name}") self.logger.debug(f"Disabling camera {self.config.name}")
self.stop_all_ffmpeg() self.stop_all_ffmpeg()
self.record_enable_time = None
# update camera status # update camera status
self.requestor.send_data( self.requestor.send_data(
@ -361,6 +367,12 @@ class CameraWatchdog(threading.Thread):
if self.config.record.enabled and "record" in p["roles"]: if self.config.record.enabled and "record" in p["roles"]:
now_utc = datetime.now().astimezone(timezone.utc) now_utc = datetime.now().astimezone(timezone.utc)
# Check if we're within the grace period after enabling recording
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
in_grace_period = self.record_enable_time is not None and (
now_utc - self.record_enable_time
) < timedelta(seconds=90)
latest_cache_dt = ( latest_cache_dt = (
datetime.fromtimestamp( datetime.fromtimestamp(
self.latest_cache_segment_time, tz=timezone.utc self.latest_cache_segment_time, tz=timezone.utc
@ -386,10 +398,16 @@ class CameraWatchdog(threading.Thread):
) )
# ensure segments are still being created and that they have valid video data # ensure segments are still being created and that they have valid video data
cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120)) # Skip checks during grace period to allow segments to start being created
valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120)) cache_stale = not in_grace_period and now_utc > (
latest_cache_dt + timedelta(seconds=120)
)
valid_stale = not in_grace_period and now_utc > (
latest_valid_dt + timedelta(seconds=120)
)
invalid_stale_condition = ( invalid_stale_condition = (
self.latest_invalid_segment_time > 0 self.latest_invalid_segment_time > 0
and not in_grace_period
and now_utc > (latest_invalid_dt + timedelta(seconds=120)) and now_utc > (latest_invalid_dt + timedelta(seconds=120))
and self.latest_valid_segment_time and self.latest_valid_segment_time
<= self.latest_invalid_segment_time <= self.latest_invalid_segment_time

View File

@ -1,6 +1,7 @@
{ {
"restart": { "restart": {
"title": "Are you sure you want to restart Frigate?", "title": "Are you sure you want to restart Frigate?",
"description": "This will briefly stop Frigate while it restarts.",
"button": "Restart", "button": "Restart",
"restarting": { "restarting": {
"title": "Frigate is Restarting", "title": "Frigate is Restarting",

View File

@ -19,6 +19,8 @@ import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
type AnimatedEventCardProps = { type AnimatedEventCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -50,26 +52,35 @@ export function AnimatedEventCard({
fetchPreviews: !currentHour, fetchPreviews: !currentHour,
}); });
const getEventType = useCallback(
(text: string) => {
if (event.data.sub_labels?.includes(text)) return "manual";
if (event.data.audio.includes(text)) return "audio";
return "object";
},
[event],
);
const tooltipText = useMemo(() => { const tooltipText = useMemo(() => {
if (event?.data?.metadata?.title) { if (event?.data?.metadata?.title) {
return event.data.metadata.title; return event.data.metadata.title;
} }
return ( return (
`${[ `${formatList(
...new Set([ [
...(event.data.objects || []), ...new Set([
...(event.data.sub_labels || []), ...(event.data.objects || []),
...(event.data.audio || []), ...(event.data.sub_labels || []),
]), ...(event.data.audio || []),
] ]),
.filter((item) => item !== undefined && !item.includes("-verified")) ]
.map((text) => text.charAt(0).toUpperCase() + text.substring(1)) .filter((item) => item !== undefined && !item.includes("-verified"))
.sort() .map((text) => getTranslatedLabel(text, getEventType(text)))
.join(", ") .sort(),
.replaceAll("-verified", "")} ` + t("detected") )} ` + t("detected")
); );
}, [event, t]); }, [event, getEventType, t]);
// visibility // visibility

View File

@ -33,13 +33,14 @@ import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { Button, buttonVariants } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LuCircle } from "react-icons/lu"; import { LuCircle } from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md"; import { MdAutoAwesome } from "react-icons/md";
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip"; import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -123,6 +124,12 @@ export default function ReviewCard({
} }
}, [bypassDialogRef, onDelete]); }, [bypassDialogRef, onDelete]);
const getEventType = (text: string) => {
if (event.data.sub_labels?.includes(text)) return "manual";
if (event.data.audio.includes(text)) return "audio";
return "object";
};
const content = ( const content = (
<div <div
className="relative flex w-full cursor-pointer flex-col gap-1.5" className="relative flex w-full cursor-pointer flex-col gap-1.5"
@ -197,20 +204,20 @@ export default function ReviewCard({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="smart-capitalize"> <TooltipContent className="smart-capitalize">
{[ {formatList(
...new Set([ [
...(event.data.objects || []), ...new Set([
...(event.data.sub_labels || []), ...(event.data.objects || []),
...(event.data.audio || []), ...(event.data.sub_labels || []),
]), ...(event.data.audio || []),
] ]),
.filter( ]
(item) => item !== undefined && !item.includes("-verified"), .filter(
) (item) => item !== undefined && !item.includes("-verified"),
.map((text) => capitalizeFirstLetter(text)) )
.sort() .map((text) => getTranslatedLabel(text, getEventType(text)))
.join(", ") .sort(),
.replaceAll("-verified", "")} )}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<TimeAgo <TimeAgo

View File

@ -42,12 +42,20 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "../ui/drawer";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogPortal, DialogPortal,
DialogTitle,
DialogTrigger, DialogTrigger,
} from "../ui/dialog"; } from "../ui/dialog";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
@ -194,6 +202,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
: "max-h-[75dvh] overflow-hidden p-2" : "max-h-[75dvh] overflow-hidden p-2"
} }
> >
{!isDesktop && (
<>
<DrawerTitle className="sr-only">
{t("menu.settings")}
</DrawerTitle>
<DrawerDescription className="sr-only">
{t("menu.settings")}
</DrawerDescription>
</>
)}
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
{isMobile && ( {isMobile && (
<div className="mb-2"> <div className="mb-2">
@ -355,6 +373,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
: "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl" : "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl"
} }
> >
{!isDesktop && (
<>
<DialogTitle className="sr-only">
{t("menu.languages")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("menu.languages")}
</DialogDescription>
</>
)}
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{languages.map(({ code, label }) => ( {languages.map(({ code, label }) => (
<MenuItem <MenuItem
@ -395,6 +423,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl" isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
} }
> >
{!isDesktop && (
<>
<DialogTitle className="sr-only">
{t("menu.darkMode.label")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("menu.darkMode.label")}
</DialogDescription>
</>
)}
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
<MenuItem <MenuItem
className={ className={
@ -472,6 +510,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl" isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
} }
> >
{!isDesktop && (
<>
<DialogTitle className="sr-only">
{t("menu.theme.label")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("menu.theme.label")}
</DialogDescription>
</>
)}
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => ( {colorSchemes.map((scheme) => (
<MenuItem <MenuItem

View File

@ -4,6 +4,7 @@ import {
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
@ -37,6 +38,12 @@ export default function RestartDialog({
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false); const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
const [countdown, setCountdown] = useState(60); const [countdown, setCountdown] = useState(60);
const clearBodyPointerEvents = () => {
if (typeof document !== "undefined") {
document.body.style.pointerEvents = "";
}
};
useEffect(() => { useEffect(() => {
setRestartDialogOpen(isOpen); setRestartDialogOpen(isOpen);
}, [isOpen]); }, [isOpen]);
@ -74,14 +81,25 @@ export default function RestartDialog({
<> <>
<AlertDialog <AlertDialog
open={restartDialogOpen} open={restartDialogOpen}
onOpenChange={() => { onOpenChange={(open) => {
setRestartDialogOpen(false); if (!open) {
onClose(); setRestartDialogOpen(false);
onClose();
clearBodyPointerEvents();
}
}} }}
> >
<AlertDialogContent> <AlertDialogContent
onCloseAutoFocus={(event) => {
event.preventDefault();
clearBodyPointerEvents();
}}
>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle> <AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
{t("restart.description")}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel> <AlertDialogCancel>

View File

@ -371,22 +371,23 @@ export default function LivePlayer({
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipPortal> <TooltipPortal>
<TooltipContent className="smart-capitalize"> <TooltipContent>
{formatList( {formatList(
[ [
...new Set([ ...new Set(
...(objects || []).map(({ label, sub_label }) => (objects || [])
label.endsWith("verified") .map(({ label, sub_label }) => {
? sub_label const isManual = label.endsWith("verified");
: label.replaceAll("_", " "), const text = isManual ? sub_label : label;
), const type = isManual ? "manual" : "object";
]), return getTranslatedLabel(text, type);
] })
.filter((label) => label?.includes("-verified") == false) .filter(
.map((label) => (translated) =>
getTranslatedLabel(label.replace("-verified", "")), translated && !translated.includes("-verified"),
) ),
.sort(), ),
].sort(),
)} )}
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>

View File

@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
import { FaExclamationTriangle } from "react-icons/fa"; import { FaExclamationTriangle } from "react-icons/fa";
import { MdOutlinePersonSearch } from "react-icons/md"; import { MdOutlinePersonSearch } from "react-icons/md";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
type PreviewPlayerProps = { type PreviewPlayerProps = {
review: ReviewSegment; review: ReviewSegment;
@ -182,9 +183,8 @@ export default function PreviewThumbnailPlayer({
); );
const getEventType = (text: string) => { const getEventType = (text: string) => {
if (review.data.objects.includes(text)) return "object";
if (review.data.audio.includes(text)) return "audio";
if (review.data.sub_labels?.includes(text)) return "manual"; if (review.data.sub_labels?.includes(text)) return "manual";
if (review.data.audio.includes(text)) return "audio";
return "object"; return "object";
}; };
@ -268,13 +268,16 @@ export default function PreviewThumbnailPlayer({
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`} className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`}
onClick={() => onClick(review, false, true)} onClick={() => onClick(review, false, true)}
> >
{review.data.objects.sort().map((object) => { {review.data.objects
return getIconForLabel( .sort()
object, .map((object, idx) =>
"object", getIconForLabel(
"size-3 text-white", object,
); "object",
})} "size-3 text-white",
`${object}-${idx}`,
),
)}
{review.data.audio.map((audio) => { {review.data.audio.map((audio) => {
return getIconForLabel( return getIconForLabel(
audio, audio,
@ -288,23 +291,26 @@ export default function PreviewThumbnailPlayer({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipContent className="smart-capitalize"> <TooltipContent>
{review.data.metadata {review.data.metadata
? review.data.metadata.title ? review.data.metadata.title
: [ : formatList(
...new Set([ [
...(review.data.objects || []), ...new Set([
...(review.data.sub_labels || []), ...(review.data.objects || []),
...(review.data.audio || []), ...(review.data.sub_labels || []),
]), ...(review.data.audio || []),
] ]),
.filter( ]
(item) => .filter(
item !== undefined && !item.includes("-verified"), (item) =>
) item !== undefined && !item.includes("-verified"),
.map((text) => getTranslatedLabel(text, getEventType(text))) )
.sort() .map((text) =>
.join(", ")} getTranslatedLabel(text, getEventType(text)),
)
.sort(),
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{!!( {!!(

View File

@ -26,6 +26,5 @@ export const supportedLanguageKeys = [
"lt", "lt",
"uk", "uk",
"cs", "cs",
"sk",
"hu", "hu",
]; ];

View File

@ -62,83 +62,86 @@ export function getIconForLabel(
label: string, label: string,
type: EventType = "object", type: EventType = "object",
className?: string, className?: string,
key?: string,
) { ) {
const iconKey = key || label;
if (label.endsWith("-verified")) { if (label.endsWith("-verified")) {
return getVerifiedIcon(label, className, type); return getVerifiedIcon(label, className, type, iconKey);
} else if (label.endsWith("-plate")) { } else if (label.endsWith("-plate")) {
return getRecognizedPlateIcon(label, className, type); return getRecognizedPlateIcon(label, className, type, iconKey);
} }
switch (label) { switch (label) {
// objects // objects
case "bear": case "bear":
return <GiPolarBear key={label} className={className} />; return <GiPolarBear key={iconKey} className={className} />;
case "bicycle": case "bicycle":
return <FaBicycle key={label} className={className} />; return <FaBicycle key={iconKey} className={className} />;
case "bird": case "bird":
return <PiBirdFill key={label} className={className} />; return <PiBirdFill key={iconKey} className={className} />;
case "boat": case "boat":
return <GiSailboat key={label} className={className} />; return <GiSailboat key={iconKey} className={className} />;
case "bus": case "bus":
case "school_bus": case "school_bus":
return <FaBus key={label} className={className} />; return <FaBus key={iconKey} className={className} />;
case "car": case "car":
case "vehicle": case "vehicle":
return <FaCarSide key={label} className={className} />; return <FaCarSide key={iconKey} className={className} />;
case "cat": case "cat":
return <FaCat key={label} className={className} />; return <FaCat key={iconKey} className={className} />;
case "deer": case "deer":
return <GiDeer key={label} className={className} />; return <GiDeer key={iconKey} className={className} />;
case "animal": case "animal":
case "bark": case "bark":
case "dog": case "dog":
return <FaDog key={label} className={className} />; return <FaDog key={iconKey} className={className} />;
case "fox": case "fox":
return <GiFox key={label} className={className} />; return <GiFox key={iconKey} className={className} />;
case "goat": case "goat":
return <GiGoat key={label} className={className} />; return <GiGoat key={iconKey} className={className} />;
case "horse": case "horse":
return <FaHorse key={label} className={className} />; return <FaHorse key={iconKey} className={className} />;
case "kangaroo": case "kangaroo":
return <GiKangaroo key={label} className={className} />; return <GiKangaroo key={iconKey} className={className} />;
case "license_plate": case "license_plate":
return <LuScanBarcode key={label} className={className} />; return <LuScanBarcode key={iconKey} className={className} />;
case "motorcycle": case "motorcycle":
return <FaMotorcycle key={label} className={className} />; return <FaMotorcycle key={iconKey} className={className} />;
case "mouse": case "mouse":
return <FaMouse key={label} className={className} />; return <FaMouse key={iconKey} className={className} />;
case "package": case "package":
return <LuBox key={label} className={className} />; return <LuBox key={iconKey} className={className} />;
case "person": case "person":
return <BsPersonWalking key={label} className={className} />; return <BsPersonWalking key={iconKey} className={className} />;
case "rabbit": case "rabbit":
return <GiRabbit key={label} className={className} />; return <GiRabbit key={iconKey} className={className} />;
case "raccoon": case "raccoon":
return <GiRaccoonHead key={label} className={className} />; return <GiRaccoonHead key={iconKey} className={className} />;
case "robot_lawnmower": case "robot_lawnmower":
return <FaHockeyPuck key={label} className={className} />; return <FaHockeyPuck key={iconKey} className={className} />;
case "sports_ball": case "sports_ball":
return <FaFootballBall key={label} className={className} />; return <FaFootballBall key={iconKey} className={className} />;
case "skunk": case "skunk":
return <GiSquirrel key={label} className={className} />; return <GiSquirrel key={iconKey} className={className} />;
case "squirrel": case "squirrel":
return <LuIcons.LuSquirrel key={label} className={className} />; return <LuIcons.LuSquirrel key={iconKey} className={className} />;
case "umbrella": case "umbrella":
return <FaUmbrella key={label} className={className} />; return <FaUmbrella key={iconKey} className={className} />;
case "waste_bin": case "waste_bin":
return <FaRegTrashAlt key={label} className={className} />; return <FaRegTrashAlt key={iconKey} className={className} />;
// audio // audio
case "crying": case "crying":
case "laughter": case "laughter":
case "scream": case "scream":
case "speech": case "speech":
case "yell": case "yell":
return <MdRecordVoiceOver key={label} className={className} />; return <MdRecordVoiceOver key={iconKey} className={className} />;
case "fire_alarm": case "fire_alarm":
return <FaFire key={label} className={className} />; return <FaFire key={iconKey} className={className} />;
// sub labels // sub labels
case "amazon": case "amazon":
return <FaAmazon key={label} className={className} />; return <FaAmazon key={iconKey} className={className} />;
case "an_post": case "an_post":
case "canada_post": case "canada_post":
case "dpd": case "dpd":
@ -148,20 +151,20 @@ export function getIconForLabel(
case "postnord": case "postnord":
case "purolator": case "purolator":
case "royal_mail": case "royal_mail":
return <GiPostStamp key={label} className={className} />; return <GiPostStamp key={iconKey} className={className} />;
case "dhl": case "dhl":
return <FaDhl key={label} className={className} />; return <FaDhl key={iconKey} className={className} />;
case "fedex": case "fedex":
return <FaFedex key={label} className={className} />; return <FaFedex key={iconKey} className={className} />;
case "ups": case "ups":
return <FaUps key={label} className={className} />; return <FaUps key={iconKey} className={className} />;
case "usps": case "usps":
return <FaUsps key={label} className={className} />; return <FaUsps key={iconKey} className={className} />;
default: default:
if (type === "audio") { if (type === "audio") {
return <GiSoundWaves key={label} className={className} />; return <GiSoundWaves key={iconKey} className={className} />;
} }
return <LuLassoSelect key={label} className={className} />; return <LuLassoSelect key={iconKey} className={className} />;
} }
} }
@ -169,11 +172,12 @@ function getVerifiedIcon(
label: string, label: string,
className?: string, className?: string,
type: EventType = "object", type: EventType = "object",
key?: string,
) { ) {
const simpleLabel = label.substring(0, label.lastIndexOf("-")); const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return ( return (
<div key={label} className="relative flex items-center"> <div key={key} className="relative flex items-center">
{getIconForLabel(simpleLabel, type, className)} {getIconForLabel(simpleLabel, type, className)}
<FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" /> <FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" />
</div> </div>
@ -184,11 +188,12 @@ function getRecognizedPlateIcon(
label: string, label: string,
className?: string, className?: string,
type: EventType = "object", type: EventType = "object",
key?: string,
) { ) {
const simpleLabel = label.substring(0, label.lastIndexOf("-")); const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return ( return (
<div key={label} className="relative inline-flex items-center"> <div key={key} className="relative inline-flex items-center">
{getIconForLabel(simpleLabel, type, className)} {getIconForLabel(simpleLabel, type, className)}
<LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" /> <LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" />
</div> </div>

View File

@ -33,7 +33,12 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { isDesktop, isMobile } from "react-device-detect"; import {
isDesktop,
isMobile,
isMobileOnly,
isTablet,
} from "react-device-detect";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
@ -738,7 +743,7 @@ export function RecordingView({
aspectRatio: getCameraAspect(mainCamera), aspectRatio: getCameraAspect(mainCamera),
}} }}
> >
{isDesktop && ( {(isDesktop || isTablet) && (
<GenAISummaryDialog <GenAISummaryDialog
review={activeReviewItem} review={activeReviewItem}
onOpen={onAnalysisOpen} onOpen={onAnalysisOpen}
@ -1001,7 +1006,7 @@ function Timeline({
), ),
)} )}
> >
{isMobile && timelineType == "timeline" && ( {isMobileOnly && timelineType == "timeline" && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen}> <GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen}>
<GenAISummaryChip review={activeReviewItem} /> <GenAISummaryChip review={activeReviewItem} />
</GenAISummaryDialog> </GenAISummaryDialog>