mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
9 Commits
ec6a149755
...
a8d97faa2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d97faa2f | ||
|
|
d44340eca6 | ||
|
|
aff82f809c | ||
|
|
1e50d83d06 | ||
|
|
36fb27ef56 | ||
|
|
9937a7cc3d | ||
|
|
7aac6b4f21 | ||
|
|
338b681ed0 | ||
|
|
f2797b1e83 |
@ -21,7 +21,7 @@ FROM deps AS frigate-tensorrt
|
|||||||
ARG PIP_BREAK_SYSTEM_PACKAGES
|
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||||
|
|
||||||
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||||
pip3 uninstall -y onnxruntime tensorflow-cpu \
|
pip3 uninstall -y onnxruntime \
|
||||||
&& pip3 install -U /deps/trt-wheels/*.whl
|
&& pip3 install -U /deps/trt-wheels/*.whl
|
||||||
|
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
|||||||
@ -13,7 +13,6 @@ nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
|
|||||||
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
|
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
|
||||||
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
|
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
|
||||||
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
|
||||||
tensorflow==2.19.*; platform_machine == 'x86_64'
|
|
||||||
onnx==1.16.*; platform_machine == 'x86_64'
|
onnx==1.16.*; platform_machine == 'x86_64'
|
||||||
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
|
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
|
||||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||||
|
|||||||
@ -10,7 +10,6 @@ Object classification allows you to train a custom MobileNetV2 classification mo
|
|||||||
Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
|
Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
|
||||||
|
|
||||||
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
||||||
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
|
|
||||||
|
|
||||||
## Classes
|
## Classes
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,6 @@ State classification allows you to train a custom MobileNetV2 classification mod
|
|||||||
State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
|
State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
|
||||||
|
|
||||||
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
||||||
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
|
|
||||||
|
|
||||||
## Classes
|
## Classes
|
||||||
|
|
||||||
|
|||||||
@ -804,3 +804,42 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
|||||||
content={"success": True, "message": "Example generation completed"},
|
content={"success": True, "message": "Example generation completed"},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/classification/{name}",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
summary="Delete a classification model",
|
||||||
|
description="""Deletes a specific classification model and all its associated data.
|
||||||
|
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||||
|
)
|
||||||
|
def delete_classification_model(request: Request, name: str):
|
||||||
|
config: FrigateConfig = request.app.frigate_config
|
||||||
|
|
||||||
|
if name not in config.classification.custom:
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"{name} is not a known classification model.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the classification model's data directory
|
||||||
|
model_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
|
||||||
|
|
||||||
|
if os.path.exists(model_dir):
|
||||||
|
shutil.rmtree(model_dir)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Successfully deleted classification model {name}.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|||||||
@ -114,7 +114,7 @@ Your response MUST be a flat JSON object with:
|
|||||||
|
|
||||||
## Objects in Scene
|
## Objects in Scene
|
||||||
|
|
||||||
Each line represents a detection state, not necessarily unique individuals. Objects with names in parentheses (e.g., "Name (person)") are verified identities. Objects without names (e.g., "Person") are detected but not identified.
|
Each line represents a detection state, not necessarily unique individuals. Parentheses indicate object type or category, use only the name/label in your response, not the parentheses.
|
||||||
|
|
||||||
**CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.**
|
**CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.**
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
"""Generative AI client for Frigate using OpenAI."""
|
"""Generative AI client for Frigate using OpenAI."""
|
||||||
|
|
||||||
provider: OpenAI
|
provider: OpenAI
|
||||||
|
context_size: Optional[int] = None
|
||||||
|
|
||||||
def _init_provider(self):
|
def _init_provider(self):
|
||||||
"""Initialize the client."""
|
"""Initialize the client."""
|
||||||
@ -69,5 +70,33 @@ class OpenAIClient(GenAIClient):
|
|||||||
|
|
||||||
def get_context_size(self) -> int:
|
def get_context_size(self) -> int:
|
||||||
"""Get the context window size for OpenAI."""
|
"""Get the context window size for OpenAI."""
|
||||||
# OpenAI GPT-4 Vision models have 128K token context window
|
if self.context_size is not None:
|
||||||
return 128000
|
return self.context_size
|
||||||
|
|
||||||
|
try:
|
||||||
|
models = self.provider.models.list()
|
||||||
|
for model in models.data:
|
||||||
|
if model.id == self.genai_config.model:
|
||||||
|
if hasattr(model, "max_model_len") and model.max_model_len:
|
||||||
|
self.context_size = model.max_model_len
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved context size {self.context_size} for model {self.genai_config.model}"
|
||||||
|
)
|
||||||
|
return self.context_size
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
f"Failed to fetch model context size from API: {e}, using default"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default to 128K for ChatGPT models, 8K for others
|
||||||
|
model_name = self.genai_config.model.lower()
|
||||||
|
if "gpt" in model_name:
|
||||||
|
self.context_size = 128000
|
||||||
|
else:
|
||||||
|
self.context_size = 8192
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Using default context size {self.context_size} for model {self.genai_config.model}"
|
||||||
|
)
|
||||||
|
return self.context_size
|
||||||
|
|||||||
@ -384,10 +384,10 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
|||||||
new_object_config["genai"] = {}
|
new_object_config["genai"] = {}
|
||||||
|
|
||||||
for key in global_genai.keys():
|
for key in global_genai.keys():
|
||||||
if key not in ["enabled", "model", "provider", "base_url", "api_key"]:
|
if key in ["model", "provider", "base_url", "api_key"]:
|
||||||
new_object_config["genai"][key] = global_genai[key]
|
|
||||||
else:
|
|
||||||
new_genai_config[key] = global_genai[key]
|
new_genai_config[key] = global_genai[key]
|
||||||
|
else:
|
||||||
|
new_object_config["genai"][key] = global_genai[key]
|
||||||
|
|
||||||
config["genai"] = new_genai_config
|
config["genai"] = new_genai_config
|
||||||
|
|
||||||
|
|||||||
8
web/package-lock.json
generated
8
web/package-lock.json
generated
@ -49,7 +49,7 @@
|
|||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"konva": "^9.3.18",
|
"konva": "^9.3.18",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.548.0",
|
||||||
"monaco-yaml": "^5.3.1",
|
"monaco-yaml": "^5.3.1",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
@ -7094,9 +7094,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.477.0",
|
"version": "0.548.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.477.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz",
|
||||||
"integrity": "sha512-yCf7aYxerFZAbd8jHJxjwe1j7jEMPptjnaOqdYeirFnEy85cNR3/L+o0I875CYFYya+eEVzZSbNuRk8BZPDpVw==",
|
"integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|||||||
@ -55,7 +55,7 @@
|
|||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"konva": "^9.3.18",
|
"konva": "^9.3.18",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.548.0",
|
||||||
"monaco-yaml": "^5.3.1",
|
"monaco-yaml": "^5.3.1",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
|
|||||||
@ -100,7 +100,8 @@
|
|||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"two": "{{0}} and {{1}}",
|
"two": "{{0}} and {{1}}",
|
||||||
"many": "{{items}}, and {{last}}"
|
"many": "{{items}}, and {{last}}",
|
||||||
|
"separatorWithSpace": ", "
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
|
|||||||
@ -5,12 +5,15 @@
|
|||||||
"renameCategory": "Rename Class",
|
"renameCategory": "Rename Class",
|
||||||
"deleteCategory": "Delete Class",
|
"deleteCategory": "Delete Class",
|
||||||
"deleteImages": "Delete Images",
|
"deleteImages": "Delete Images",
|
||||||
"trainModel": "Train Model"
|
"trainModel": "Train Model",
|
||||||
|
"addClassification": "Add Classification",
|
||||||
|
"deleteModels": "Delete Models"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"deletedCategory": "Deleted Class",
|
"deletedCategory": "Deleted Class",
|
||||||
"deletedImage": "Deleted Images",
|
"deletedImage": "Deleted Images",
|
||||||
|
"deletedModel": "Successfully deleted {{count}} model(s)",
|
||||||
"categorizedImage": "Successfully Classified Image",
|
"categorizedImage": "Successfully Classified Image",
|
||||||
"trainedModel": "Successfully trained model.",
|
"trainedModel": "Successfully trained model.",
|
||||||
"trainingModel": "Successfully started model training."
|
"trainingModel": "Successfully started model training."
|
||||||
@ -18,6 +21,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
||||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||||
|
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||||
"trainingFailed": "Failed to start model training: {{errorMessage}}"
|
"trainingFailed": "Failed to start model training: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
@ -26,6 +30,11 @@
|
|||||||
"title": "Delete Class",
|
"title": "Delete Class",
|
||||||
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
|
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
|
||||||
},
|
},
|
||||||
|
"deleteModel": {
|
||||||
|
"title": "Delete Classification Model",
|
||||||
|
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
||||||
|
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
||||||
|
},
|
||||||
"deleteDatasetImages": {
|
"deleteDatasetImages": {
|
||||||
"title": "Delete Dataset Images",
|
"title": "Delete Dataset Images",
|
||||||
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
||||||
@ -52,6 +61,10 @@
|
|||||||
},
|
},
|
||||||
"categorizeImageAs": "Classify Image As:",
|
"categorizeImageAs": "Classify Image As:",
|
||||||
"categorizeImage": "Classify Image",
|
"categorizeImage": "Classify Image",
|
||||||
|
"menu": {
|
||||||
|
"objects": "Objects",
|
||||||
|
"states": "States"
|
||||||
|
},
|
||||||
"noModels": {
|
"noModels": {
|
||||||
"object": {
|
"object": {
|
||||||
"title": "No Object Classification Models",
|
"title": "No Object Classification Models",
|
||||||
@ -86,6 +99,7 @@
|
|||||||
"classificationSubLabel": "Sub Label",
|
"classificationSubLabel": "Sub Label",
|
||||||
"classificationAttribute": "Attribute",
|
"classificationAttribute": "Attribute",
|
||||||
"classes": "Classes",
|
"classes": "Classes",
|
||||||
|
"states": "States",
|
||||||
"classesTip": "Learn about classes",
|
"classesTip": "Learn about classes",
|
||||||
"classesStateDesc": "Define the different states your camera area can be in. For example: 'open' and 'closed' for a garage door.",
|
"classesStateDesc": "Define the different states your camera area can be in. For example: 'open' and 'closed' for a garage door.",
|
||||||
"classesObjectDesc": "Define the different categories to classify detected objects into. For example: 'delivery_person', 'resident', 'stranger' for person classification.",
|
"classesObjectDesc": "Define the different categories to classify detected objects into. For example: 'delivery_person', 'resident', 'stranger' for person classification.",
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"details": "details",
|
"details": "details",
|
||||||
"snapshot": "snapshot",
|
"snapshot": "snapshot",
|
||||||
|
"thumbnail": "thumbnail",
|
||||||
"video": "video",
|
"video": "video",
|
||||||
"object_lifecycle": "object lifecycle"
|
"object_lifecycle": "object lifecycle"
|
||||||
},
|
},
|
||||||
@ -41,7 +42,7 @@
|
|||||||
"noImageFound": "No image found for this timestamp.",
|
"noImageFound": "No image found for this timestamp.",
|
||||||
"createObjectMask": "Create Object Mask",
|
"createObjectMask": "Create Object Mask",
|
||||||
"adjustAnnotationSettings": "Adjust annotation settings",
|
"adjustAnnotationSettings": "Adjust annotation settings",
|
||||||
"scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
|
"scrollViewTips": "Click to view the significant moments of this object's lifecycle.",
|
||||||
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
|
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
|
||||||
"count": "{{first}} of {{second}}",
|
"count": "{{first}} of {{second}}",
|
||||||
"trackedPoint": "Tracked Point",
|
"trackedPoint": "Tracked Point",
|
||||||
|
|||||||
@ -394,7 +394,9 @@ export default function Step1NameAndDefine({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FormLabel className="text-primary-variant">
|
<FormLabel className="text-primary-variant">
|
||||||
{t("wizard.step1.classes")}
|
{watchedModelType === "state"
|
||||||
|
? t("wizard.step1.states")
|
||||||
|
: t("wizard.step1.classes")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|||||||
@ -348,6 +348,26 @@ export function GeneralFilterContent({
|
|||||||
onClose,
|
onClose,
|
||||||
}: GeneralFilterContentProps) {
|
}: GeneralFilterContentProps) {
|
||||||
const { t } = useTranslation(["components/filter"]);
|
const { t } = useTranslation(["components/filter"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allAudioListenLabels = useMemo<string[]>(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = new Set<string>();
|
||||||
|
Object.values(config.cameras).forEach((camera) => {
|
||||||
|
if (camera?.audio?.enabled) {
|
||||||
|
camera.audio.listen.forEach((label) => {
|
||||||
|
labels.add(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...labels].sort();
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-hidden">
|
<div className="overflow-x-hidden">
|
||||||
@ -373,7 +393,10 @@ export function GeneralFilterContent({
|
|||||||
{allLabels.map((item) => (
|
{allLabels.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={item}
|
key={item}
|
||||||
label={getTranslatedLabel(item)}
|
label={getTranslatedLabel(
|
||||||
|
item,
|
||||||
|
allAudioListenLabels.includes(item) ? "audio" : "object",
|
||||||
|
)}
|
||||||
isChecked={currentLabels?.includes(item) ?? false}
|
isChecked={currentLabels?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
|
|||||||
@ -13,6 +13,9 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
|
|
||||||
|
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
|
||||||
|
const TOLERANCE = 0.01;
|
||||||
|
|
||||||
type ObjectTrackOverlayProps = {
|
type ObjectTrackOverlayProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
showBoundingBoxes?: boolean;
|
showBoundingBoxes?: boolean;
|
||||||
@ -55,6 +58,47 @@ export default function ObjectTrackOverlay({
|
|||||||
|
|
||||||
const effectiveCurrentTime = currentTime - annotationOffset / 1000;
|
const effectiveCurrentTime = currentTime - annotationOffset / 1000;
|
||||||
|
|
||||||
|
const {
|
||||||
|
pathStroke,
|
||||||
|
pointRadius,
|
||||||
|
pointStroke,
|
||||||
|
zoneStroke,
|
||||||
|
boxStroke,
|
||||||
|
highlightRadius,
|
||||||
|
} = useMemo(() => {
|
||||||
|
const BASE_WIDTH = 1280;
|
||||||
|
const BASE_HEIGHT = 720;
|
||||||
|
const BASE_PATH_STROKE = 5;
|
||||||
|
const BASE_POINT_RADIUS = 7;
|
||||||
|
const BASE_POINT_STROKE = 3;
|
||||||
|
const BASE_ZONE_STROKE = 5;
|
||||||
|
const BASE_BOX_STROKE = 5;
|
||||||
|
const BASE_HIGHLIGHT_RADIUS = 5;
|
||||||
|
|
||||||
|
const scale = Math.sqrt(
|
||||||
|
(videoWidth * videoHeight) / (BASE_WIDTH * BASE_HEIGHT),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pathStroke = Math.max(1, Math.round(BASE_PATH_STROKE * scale));
|
||||||
|
const pointRadius = Math.max(2, Math.round(BASE_POINT_RADIUS * scale));
|
||||||
|
const pointStroke = Math.max(1, Math.round(BASE_POINT_STROKE * scale));
|
||||||
|
const zoneStroke = Math.max(1, Math.round(BASE_ZONE_STROKE * scale));
|
||||||
|
const boxStroke = Math.max(1, Math.round(BASE_BOX_STROKE * scale));
|
||||||
|
const highlightRadius = Math.max(
|
||||||
|
2,
|
||||||
|
Math.round(BASE_HIGHLIGHT_RADIUS * scale),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pathStroke,
|
||||||
|
pointRadius,
|
||||||
|
pointStroke,
|
||||||
|
zoneStroke,
|
||||||
|
boxStroke,
|
||||||
|
highlightRadius,
|
||||||
|
};
|
||||||
|
}, [videoWidth, videoHeight]);
|
||||||
|
|
||||||
// Fetch all event data in a single request (CSV ids)
|
// Fetch all event data in a single request (CSV ids)
|
||||||
const { data: eventsData } = useSWR<Event[]>(
|
const { data: eventsData } = useSWR<Event[]>(
|
||||||
selectedObjectIds.length > 0
|
selectedObjectIds.length > 0
|
||||||
@ -166,41 +210,50 @@ export default function ObjectTrackOverlay({
|
|||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
// show full path once current time has reached the object's start time
|
// show full path once current time has reached the object's start time
|
||||||
const combinedPoints = [...savedPathPoints, ...eventSequencePoints]
|
// event.start_time is in DETECT stream time, so convert it to record stream time for comparison
|
||||||
.sort((a, b) => a.timestamp - b.timestamp)
|
const eventStartTimeRecord =
|
||||||
.filter(
|
(eventData?.start_time ?? 0) + annotationOffset / 1000;
|
||||||
(point) =>
|
|
||||||
currentTime >= (eventData?.start_time ?? 0) &&
|
const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
|
||||||
point.timestamp >= (eventData?.start_time ?? 0) &&
|
(a, b) => a.timestamp - b.timestamp,
|
||||||
point.timestamp <= (eventData?.end_time ?? Infinity),
|
);
|
||||||
);
|
const combinedPoints = allPoints.filter(
|
||||||
|
(point) =>
|
||||||
|
currentTime >= eventStartTimeRecord - TOLERANCE &&
|
||||||
|
point.timestamp <= effectiveCurrentTime + TOLERANCE,
|
||||||
|
);
|
||||||
|
|
||||||
// Get color for this object
|
// Get color for this object
|
||||||
const label = eventData?.label || "unknown";
|
const label = eventData?.label || "unknown";
|
||||||
const color = getObjectColor(label, objectId);
|
const color = getObjectColor(label, objectId);
|
||||||
|
|
||||||
// Get current zones
|
// zones (with tolerance for browsers with seek precision by-design issues)
|
||||||
const currentZones =
|
const currentZones =
|
||||||
timelineData
|
timelineData
|
||||||
?.filter(
|
?.filter(
|
||||||
(event: TrackingDetailsSequence) =>
|
(event: TrackingDetailsSequence) =>
|
||||||
event.timestamp <= effectiveCurrentTime,
|
event.timestamp <= effectiveCurrentTime + TOLERANCE,
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||||
b.timestamp - a.timestamp,
|
b.timestamp - a.timestamp,
|
||||||
)[0]?.data?.zones || [];
|
)[0]?.data?.zones || [];
|
||||||
|
|
||||||
// Get current bounding box
|
// bounding box - only show if there's a timeline event at/near the current time with a box
|
||||||
const currentBox = timelineData
|
// Search all timeline events (not just those before current time) to find one matching the seek position
|
||||||
?.filter(
|
const nearbyTimelineEvent = timelineData
|
||||||
(event: TrackingDetailsSequence) =>
|
?.filter((event: TrackingDetailsSequence) => event.data.box)
|
||||||
event.timestamp <= effectiveCurrentTime && event.data.box,
|
|
||||||
)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||||
b.timestamp - a.timestamp,
|
Math.abs(a.timestamp - effectiveCurrentTime) -
|
||||||
)[0]?.data?.box;
|
Math.abs(b.timestamp - effectiveCurrentTime),
|
||||||
|
)
|
||||||
|
.find(
|
||||||
|
(event: TrackingDetailsSequence) =>
|
||||||
|
Math.abs(event.timestamp - effectiveCurrentTime) <= TOLERANCE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentBox = nearbyTimelineEvent?.data?.box;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
objectId,
|
objectId,
|
||||||
@ -221,6 +274,7 @@ export default function ObjectTrackOverlay({
|
|||||||
getObjectColor,
|
getObjectColor,
|
||||||
config,
|
config,
|
||||||
camera,
|
camera,
|
||||||
|
annotationOffset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Collect all zones across all objects
|
// Collect all zones across all objects
|
||||||
@ -274,9 +328,10 @@ export default function ObjectTrackOverlay({
|
|||||||
|
|
||||||
const handlePointClick = useCallback(
|
const handlePointClick = useCallback(
|
||||||
(timestamp: number) => {
|
(timestamp: number) => {
|
||||||
onSeekToTime?.(timestamp, false);
|
// Convert detect stream timestamp to record stream timestamp before seeking
|
||||||
|
onSeekToTime?.(timestamp + annotationOffset / 1000, false);
|
||||||
},
|
},
|
||||||
[onSeekToTime],
|
[onSeekToTime, annotationOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const zonePolygons = useMemo(() => {
|
const zonePolygons = useMemo(() => {
|
||||||
@ -324,7 +379,7 @@ export default function ObjectTrackOverlay({
|
|||||||
points={zone.points}
|
points={zone.points}
|
||||||
fill={zone.fill}
|
fill={zone.fill}
|
||||||
stroke={zone.stroke}
|
stroke={zone.stroke}
|
||||||
strokeWidth="5"
|
strokeWidth={zoneStroke}
|
||||||
opacity="0.7"
|
opacity="0.7"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -344,7 +399,7 @@ export default function ObjectTrackOverlay({
|
|||||||
d={generateStraightPath(absolutePositions)}
|
d={generateStraightPath(absolutePositions)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={objData.color}
|
stroke={objData.color}
|
||||||
strokeWidth="5"
|
strokeWidth={pathStroke}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
@ -356,13 +411,13 @@ export default function ObjectTrackOverlay({
|
|||||||
<circle
|
<circle
|
||||||
cx={pos.x}
|
cx={pos.x}
|
||||||
cy={pos.y}
|
cy={pos.y}
|
||||||
r="7"
|
r={pointRadius}
|
||||||
fill={getPointColor(
|
fill={getPointColor(
|
||||||
objData.color,
|
objData.color,
|
||||||
pos.lifecycle_item?.class_type,
|
pos.lifecycle_item?.class_type,
|
||||||
)}
|
)}
|
||||||
stroke="white"
|
stroke="white"
|
||||||
strokeWidth="3"
|
strokeWidth={pointStroke}
|
||||||
style={{ cursor: onSeekToTime ? "pointer" : "default" }}
|
style={{ cursor: onSeekToTime ? "pointer" : "default" }}
|
||||||
onClick={() => handlePointClick(pos.timestamp)}
|
onClick={() => handlePointClick(pos.timestamp)}
|
||||||
/>
|
/>
|
||||||
@ -391,7 +446,7 @@ export default function ObjectTrackOverlay({
|
|||||||
height={objData.currentBox[3] * videoHeight}
|
height={objData.currentBox[3] * videoHeight}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={objData.color}
|
stroke={objData.color}
|
||||||
strokeWidth="5"
|
strokeWidth={boxStroke}
|
||||||
opacity="0.9"
|
opacity="0.9"
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
@ -403,10 +458,10 @@ export default function ObjectTrackOverlay({
|
|||||||
(objData.currentBox[1] + objData.currentBox[3]) *
|
(objData.currentBox[1] + objData.currentBox[3]) *
|
||||||
videoHeight
|
videoHeight
|
||||||
}
|
}
|
||||||
r="5"
|
r={highlightRadius}
|
||||||
fill="rgb(255, 255, 0)" // yellow highlight
|
fill="rgb(255, 255, 0)" // yellow highlight
|
||||||
stroke={objData.color}
|
stroke={objData.color}
|
||||||
strokeWidth="5"
|
strokeWidth={boxStroke}
|
||||||
opacity="1"
|
opacity="1"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
|||||||
<div className="w-full flex-1 landscape:flex">
|
<div className="w-full flex-1 landscape:flex">
|
||||||
<Slider
|
<Slider
|
||||||
value={[annotationOffset]}
|
value={[annotationOffset]}
|
||||||
min={-1500}
|
min={-2500}
|
||||||
max={1500}
|
max={2500}
|
||||||
step={50}
|
step={50}
|
||||||
onValueChange={handleChange}
|
onValueChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,577 +0,0 @@
|
|||||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "../../ui/sheet";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
|
||||||
import { useApiHost } from "@/api";
|
|
||||||
import {
|
|
||||||
ReviewDetailPaneType,
|
|
||||||
ReviewSegment,
|
|
||||||
ThreatLevel,
|
|
||||||
} from "@/types/review";
|
|
||||||
import { Event } from "@/types/event";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
|
||||||
import TrackingDetails from "./TrackingDetails";
|
|
||||||
import Chip from "@/components/indicators/Chip";
|
|
||||||
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
|
||||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
|
||||||
import { FaArrowsRotate } from "react-icons/fa6";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
|
||||||
import { shareOrCopy } from "@/utils/browserUtil";
|
|
||||||
import {
|
|
||||||
MobilePage,
|
|
||||||
MobilePageContent,
|
|
||||||
MobilePageDescription,
|
|
||||||
MobilePageHeader,
|
|
||||||
MobilePageTitle,
|
|
||||||
} from "@/components/mobile/MobilePage";
|
|
||||||
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
|
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|
||||||
import { LuSearch } from "react-icons/lu";
|
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
|
||||||
|
|
||||||
type ReviewDetailDialogProps = {
|
|
||||||
review?: ReviewSegment;
|
|
||||||
setReview: (review: ReviewSegment | undefined) => void;
|
|
||||||
};
|
|
||||||
export default function ReviewDetailDialog({
|
|
||||||
review,
|
|
||||||
setReview,
|
|
||||||
}: ReviewDetailDialogProps) {
|
|
||||||
const { t } = useTranslation(["views/explore"]);
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// upload
|
|
||||||
|
|
||||||
const [upload, setUpload] = useState<Event>();
|
|
||||||
|
|
||||||
// data
|
|
||||||
|
|
||||||
const { data: events } = useSWR<Event[]>(
|
|
||||||
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
|
|
||||||
|
|
||||||
const aiThreatLevel = useMemo(() => {
|
|
||||||
if (
|
|
||||||
!aiAnalysis ||
|
|
||||||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
|
|
||||||
) {
|
|
||||||
return "None";
|
|
||||||
}
|
|
||||||
|
|
||||||
let concerns = "";
|
|
||||||
switch (aiAnalysis.potential_threat_level) {
|
|
||||||
case ThreatLevel.SUSPICIOUS:
|
|
||||||
concerns = `• ${t("suspiciousActivity", { ns: "views/events" })}\n`;
|
|
||||||
break;
|
|
||||||
case ThreatLevel.DANGER:
|
|
||||||
concerns = `• ${t("threateningActivity", { ns: "views/events" })}\n`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
(aiAnalysis.other_concerns ?? []).forEach((c) => {
|
|
||||||
concerns += `• ${c}\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return concerns || "None";
|
|
||||||
}, [aiAnalysis, t]);
|
|
||||||
|
|
||||||
const hasMismatch = useMemo(() => {
|
|
||||||
if (!review || !events) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return events.length != review?.data.detections.length;
|
|
||||||
}, [review, events]);
|
|
||||||
|
|
||||||
const missingObjects = useMemo(() => {
|
|
||||||
if (!review || !events) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const detectedIds = review.data.detections;
|
|
||||||
const missing = Array.from(
|
|
||||||
new Set(
|
|
||||||
events
|
|
||||||
.filter((event) => !detectedIds.includes(event.id))
|
|
||||||
.map((event) => event.label),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return missing;
|
|
||||||
}, [review, events]);
|
|
||||||
|
|
||||||
const formattedDate = useFormattedTimestamp(
|
|
||||||
review?.start_time ?? 0,
|
|
||||||
config?.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
|
||||||
ns: "common",
|
|
||||||
})
|
|
||||||
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
|
||||||
ns: "common",
|
|
||||||
}),
|
|
||||||
config?.ui.timezone,
|
|
||||||
);
|
|
||||||
|
|
||||||
// content
|
|
||||||
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
|
||||||
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
|
|
||||||
|
|
||||||
// dialog and mobile page
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(review != undefined);
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
|
||||||
(open: boolean) => {
|
|
||||||
setIsOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
// short timeout to allow the mobile page animation
|
|
||||||
// to complete before updating the state
|
|
||||||
setTimeout(() => {
|
|
||||||
setReview(undefined);
|
|
||||||
setSelectedEvent(undefined);
|
|
||||||
setPane("overview");
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setReview, setIsOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOpen(review != undefined);
|
|
||||||
// we know that these deps are correct
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [review]);
|
|
||||||
|
|
||||||
// keyboard listener
|
|
||||||
|
|
||||||
useKeyboardListener(["Esc"], (key, modifiers) => {
|
|
||||||
if (key == "Esc" && modifiers.down && !modifiers.repeat) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const Overlay = isDesktop ? Sheet : MobilePage;
|
|
||||||
const Content = isDesktop ? SheetContent : MobilePageContent;
|
|
||||||
const Header = isDesktop ? SheetHeader : MobilePageHeader;
|
|
||||||
const Title = isDesktop ? SheetTitle : MobilePageTitle;
|
|
||||||
const Description = isDesktop ? SheetDescription : MobilePageDescription;
|
|
||||||
|
|
||||||
if (!review) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Overlay
|
|
||||||
open={isOpen ?? false}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
enableHistoryBack={true}
|
|
||||||
>
|
|
||||||
<FrigatePlusDialog
|
|
||||||
upload={upload}
|
|
||||||
onClose={() => setUpload(undefined)}
|
|
||||||
onEventUploaded={() => {
|
|
||||||
if (upload) {
|
|
||||||
upload.plus_id = "new_upload";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Content
|
|
||||||
className={cn(
|
|
||||||
"scrollbar-container overflow-y-auto",
|
|
||||||
isDesktop && pane == "overview"
|
|
||||||
? "sm:max-w-xl"
|
|
||||||
: "pt-2 sm:max-w-4xl",
|
|
||||||
isMobile && "px-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span tabIndex={0} className="sr-only" />
|
|
||||||
{pane == "overview" && (
|
|
||||||
<Header className="justify-center">
|
|
||||||
<Title>{t("details.item.title")}</Title>
|
|
||||||
<Description className="sr-only">
|
|
||||||
{t("details.item.desc")}
|
|
||||||
</Description>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute flex gap-2 lg:flex-col",
|
|
||||||
isDesktop && "right-1 top-8",
|
|
||||||
isMobile && "right-0 top-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
aria-label={t("details.item.button.share")}
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
shareOrCopy(`${baseUrl}review?id=${review.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FaShareAlt className="size-4 text-secondary-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("details.item.button.share")}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<DownloadVideoButton
|
|
||||||
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
|
|
||||||
camera={review.camera}
|
|
||||||
startTime={review.start_time}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("button.download", { ns: "common" })}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Header>
|
|
||||||
)}
|
|
||||||
{pane == "overview" && (
|
|
||||||
<div className="flex flex-col gap-5 md:mt-3">
|
|
||||||
{aiAnalysis != undefined && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
|
|
||||||
isDesktop && "m-2 w-[90%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("aiAnalysis.title")}
|
|
||||||
<div className="text-sm text-primary/40">
|
|
||||||
{t("details.description.label")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">{aiAnalysis.scene}</div>
|
|
||||||
<div className="text-sm text-primary/40">
|
|
||||||
{t("details.score.label")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
|
|
||||||
<div className="text-sm text-primary/40">
|
|
||||||
{t("concerns.label")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">{aiThreatLevel}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex w-full flex-row">
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm text-primary/40">
|
|
||||||
{t("details.camera")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm smart-capitalize">
|
|
||||||
<CameraNameLabel camera={review.camera} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm text-primary/40">
|
|
||||||
{t("details.timestamp")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">{formattedDate}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
|
||||||
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
|
|
||||||
<div className="text-sm text-primary/40">
|
|
||||||
{t("details.objects")}
|
|
||||||
</div>
|
|
||||||
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
|
|
||||||
{events?.map((event) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-row items-center gap-2 smart-capitalize"
|
|
||||||
>
|
|
||||||
{getIconForLabel(
|
|
||||||
event.label,
|
|
||||||
"size-3 text-primary",
|
|
||||||
)}
|
|
||||||
{event.sub_label ??
|
|
||||||
event.label.replaceAll("_", " ")}{" "}
|
|
||||||
({Math.round(event.data.top_score * 100)}%)
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/explore?event_id=${event.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LuSearch className="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("details.item.button.viewInExplore")}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{review.data.zones.length > 0 && (
|
|
||||||
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
|
|
||||||
<div className="text-sm text-primary/40">
|
|
||||||
{t("details.zones")}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
|
|
||||||
{review.data.zones.map((zone) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={zone}
|
|
||||||
className="flex flex-row items-center gap-2 smart-capitalize"
|
|
||||||
>
|
|
||||||
{zone.replaceAll("_", " ")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasMismatch && (
|
|
||||||
<div className="p-4 text-center text-sm">
|
|
||||||
{(() => {
|
|
||||||
const detectedCount = Math.abs(
|
|
||||||
(events?.length ?? 0) -
|
|
||||||
(review?.data.detections.length ?? 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
return t("details.item.tips.mismatch", {
|
|
||||||
count: detectedCount,
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
{missingObjects.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<Trans
|
|
||||||
ns="views/explore"
|
|
||||||
values={{
|
|
||||||
objects: missingObjects
|
|
||||||
.map((x) => getTranslatedLabel(x))
|
|
||||||
.join(", "),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
details.item.tips.hasMissingObjects
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative flex size-full flex-col gap-2">
|
|
||||||
{events?.map((event) => (
|
|
||||||
<EventItem
|
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
setPane={setPane}
|
|
||||||
setSelectedEvent={setSelectedEvent}
|
|
||||||
setUpload={setUpload}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pane == "details" && selectedEvent && (
|
|
||||||
<div className="mt-0 flex size-full flex-col gap-2">
|
|
||||||
<TrackingDetails event={selectedEvent} setPane={setPane} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Content>
|
|
||||||
</Overlay>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventItemProps = {
|
|
||||||
event: Event;
|
|
||||||
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
|
||||||
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
|
|
||||||
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function EventItem({
|
|
||||||
event,
|
|
||||||
setPane,
|
|
||||||
setSelectedEvent,
|
|
||||||
setUpload,
|
|
||||||
}: EventItemProps) {
|
|
||||||
const { t } = useTranslation(["views/explore"]);
|
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const apiHost = useApiHost();
|
|
||||||
|
|
||||||
const imgRef = useRef(null);
|
|
||||||
|
|
||||||
const [hovered, setHovered] = useState(isMobile);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative mr-auto",
|
|
||||||
!event.has_snapshot && "flex flex-row items-center justify-center",
|
|
||||||
)}
|
|
||||||
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
|
|
||||||
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
|
||||||
key={event.id}
|
|
||||||
>
|
|
||||||
{event.has_snapshot && (
|
|
||||||
<>
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<img
|
|
||||||
ref={imgRef}
|
|
||||||
className={cn(
|
|
||||||
"select-none rounded-lg object-contain transition-opacity",
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
isIOS
|
|
||||||
? {
|
|
||||||
WebkitUserSelect: "none",
|
|
||||||
WebkitTouchCallout: "none",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
draggable={false}
|
|
||||||
src={
|
|
||||||
event.has_snapshot
|
|
||||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
|
||||||
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{hovered && (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={cn("absolute right-1 top-1 flex items-center gap-2")}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
|
||||||
download
|
|
||||||
href={
|
|
||||||
event.has_snapshot
|
|
||||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
|
||||||
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
|
|
||||||
<FaDownload className="size-4 text-white" />
|
|
||||||
</Chip>
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("button.download", { ns: "common" })}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{event.has_snapshot &&
|
|
||||||
event.plus_id == undefined &&
|
|
||||||
event.data.type == "object" &&
|
|
||||||
config?.plus.enabled && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Chip
|
|
||||||
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
|
||||||
onClick={() => {
|
|
||||||
setUpload?.(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FrigatePlusIcon className="size-4 text-white" />
|
|
||||||
</Chip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("itemMenu.submitToPlus.label")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.has_clip && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Chip
|
|
||||||
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
|
||||||
onClick={() => {
|
|
||||||
setPane("details");
|
|
||||||
setSelectedEvent(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaArrowsRotate className="size-4 text-white" />
|
|
||||||
</Chip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("itemMenu.viewTrackingDetails.label")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.has_snapshot && config?.semantic_search.enabled && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Chip
|
|
||||||
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(
|
|
||||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaImages className="size-4 text-white" />
|
|
||||||
</Chip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("itemMenu.findSimilar.label")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -31,10 +31,9 @@ import {
|
|||||||
FaDownload,
|
FaDownload,
|
||||||
FaHistory,
|
FaHistory,
|
||||||
FaImage,
|
FaImage,
|
||||||
FaRegListAlt,
|
|
||||||
FaVideo,
|
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import TrackingDetails from "./TrackingDetails";
|
import { TrackingDetails } from "./TrackingDetails";
|
||||||
|
import { DetailStreamProvider } from "@/context/detail-stream-context";
|
||||||
import {
|
import {
|
||||||
MobilePage,
|
MobilePage,
|
||||||
MobilePageContent,
|
MobilePageContent,
|
||||||
@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
|
|||||||
import { CgTranscript } from "react-icons/cg";
|
import { CgTranscript } from "react-icons/cg";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||||
import { PiPath } from "react-icons/pi";
|
import { PiPath } from "react-icons/pi";
|
||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
|
||||||
const SEARCH_TABS = [
|
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
|
||||||
"details",
|
|
||||||
"snapshot",
|
|
||||||
"video",
|
|
||||||
"tracking_details",
|
|
||||||
] as const;
|
|
||||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||||
|
|
||||||
type SearchDetailDialogProps = {
|
type SearchDetailDialogProps = {
|
||||||
@ -109,6 +104,7 @@ export default function SearchDetailDialog({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
// tabs
|
// tabs
|
||||||
|
|
||||||
@ -149,16 +145,6 @@ export default function SearchDetailDialog({
|
|||||||
|
|
||||||
const views = [...SEARCH_TABS];
|
const views = [...SEARCH_TABS];
|
||||||
|
|
||||||
if (!search.has_snapshot) {
|
|
||||||
const index = views.indexOf("snapshot");
|
|
||||||
views.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!search.has_clip) {
|
|
||||||
const index = views.indexOf("video");
|
|
||||||
views.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search.data.type != "object" || !search.has_clip) {
|
if (search.data.type != "object" || !search.has_clip) {
|
||||||
const index = views.indexOf("tracking_details");
|
const index = views.indexOf("tracking_details");
|
||||||
views.splice(index, 1);
|
views.splice(index, 1);
|
||||||
@ -173,10 +159,50 @@ export default function SearchDetailDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchTabs.includes(pageToggle)) {
|
if (!searchTabs.includes(pageToggle)) {
|
||||||
setSearchPage("details");
|
setSearchPage("snapshot");
|
||||||
}
|
}
|
||||||
}, [pageToggle, searchTabs, setSearchPage]);
|
}, [pageToggle, searchTabs, setSearchPage]);
|
||||||
|
|
||||||
|
// Tabs component for reuse
|
||||||
|
const tabsComponent = (
|
||||||
|
<ScrollArea className="w-full whitespace-nowrap">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<ToggleGroup
|
||||||
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
value={pageToggle}
|
||||||
|
onValueChange={(value: SearchTab) => {
|
||||||
|
if (value) {
|
||||||
|
setPageToggle(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.values(searchTabs).map((item) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={item}
|
||||||
|
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
|
value={item}
|
||||||
|
data-nav-item={item}
|
||||||
|
aria-label={`Select ${item}`}
|
||||||
|
>
|
||||||
|
{item == "snapshot" && <FaImage className="size-4" />}
|
||||||
|
{item == "tracking_details" && <PiPath className="size-4" />}
|
||||||
|
<div className="smart-capitalize">
|
||||||
|
{item === "snapshot"
|
||||||
|
? search?.has_snapshot
|
||||||
|
? t("type.snapshot")
|
||||||
|
: t("type.thumbnail")
|
||||||
|
: t(`type.${item}`)}
|
||||||
|
</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -190,92 +216,188 @@ export default function SearchDetailDialog({
|
|||||||
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay
|
<DetailStreamProvider
|
||||||
open={isOpen}
|
isDetailMode={true}
|
||||||
onOpenChange={handleOpenChange}
|
currentTime={(search as unknown as Event)?.start_time ?? 0}
|
||||||
enableHistoryBack={true}
|
camera={(search as unknown as Event)?.camera ?? ""}
|
||||||
|
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
|
||||||
>
|
>
|
||||||
<Content
|
<Overlay
|
||||||
className={cn(
|
open={isOpen}
|
||||||
"scrollbar-container overflow-y-auto",
|
onOpenChange={handleOpenChange}
|
||||||
isDesktop &&
|
enableHistoryBack={true}
|
||||||
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
|
||||||
isMobile && "px-4",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Header>
|
<Content
|
||||||
<Title>{t("trackedObjectDetails")}</Title>
|
className={cn(
|
||||||
<Description className="sr-only">
|
"scrollbar-container overflow-y-auto",
|
||||||
{t("trackedObjectDetails")}
|
isDesktop &&
|
||||||
</Description>
|
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
||||||
</Header>
|
isDesktop &&
|
||||||
<ScrollArea
|
page == "tracking_details" &&
|
||||||
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
|
"lg:max-w-[75%] xl:max-w-[80%]",
|
||||||
|
isMobile && "px-4",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row">
|
<Header>
|
||||||
<ToggleGroup
|
<Title>{t("trackedObjectDetails")}</Title>
|
||||||
className="*:rounded-md *:px-3 *:py-4"
|
<Description className="sr-only">
|
||||||
type="single"
|
{t("trackedObjectDetails")}
|
||||||
size="sm"
|
</Description>
|
||||||
value={pageToggle}
|
</Header>
|
||||||
onValueChange={(value: SearchTab) => {
|
{isDesktop ? (
|
||||||
if (value) {
|
page === "tracking_details" ? (
|
||||||
setPageToggle(value);
|
<TrackingDetails
|
||||||
}
|
className="size-full"
|
||||||
}}
|
event={search as unknown as Event}
|
||||||
>
|
tabs={tabsComponent}
|
||||||
{Object.values(searchTabs).map((item) => (
|
/>
|
||||||
<ToggleGroupItem
|
) : (
|
||||||
key={item}
|
<div className="flex h-full gap-4 overflow-hidden">
|
||||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
<div
|
||||||
value={item}
|
className={cn(
|
||||||
data-nav-item={item}
|
"scrollbar-container flex-[3] overflow-y-hidden",
|
||||||
aria-label={`Select ${item}`}
|
page === "snapshot" && !search.has_snapshot && "flex-[2]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{item == "details" && <FaRegListAlt className="size-4" />}
|
{page === "snapshot" && search.has_snapshot && (
|
||||||
{item == "snapshot" && <FaImage className="size-4" />}
|
<ObjectSnapshotTab
|
||||||
{item == "video" && <FaVideo className="size-4" />}
|
search={
|
||||||
{item == "tracking_details" && <PiPath className="size-4" />}
|
{
|
||||||
<div className="smart-capitalize">{t(`type.${item}`)}</div>
|
...search,
|
||||||
</ToggleGroupItem>
|
plus_id: config?.plus?.enabled
|
||||||
))}
|
? search.plus_id
|
||||||
</ToggleGroup>
|
: "not_enabled",
|
||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
} as unknown as Event
|
||||||
</div>
|
}
|
||||||
</ScrollArea>
|
onEventUploaded={() => {
|
||||||
{page == "details" && (
|
search.plus_id = "new_upload";
|
||||||
<ObjectDetailsTab
|
}}
|
||||||
search={search}
|
/>
|
||||||
config={config}
|
)}
|
||||||
setSearch={setSearch}
|
{page === "snapshot" && !search.has_snapshot && (
|
||||||
setSimilarity={setSimilarity}
|
<img
|
||||||
setInputFocused={setInputFocused}
|
className="size-full select-none rounded-lg object-contain transition-opacity"
|
||||||
/>
|
style={
|
||||||
)}
|
isIOS
|
||||||
{page == "snapshot" && (
|
? {
|
||||||
<ObjectSnapshotTab
|
WebkitUserSelect: "none",
|
||||||
search={
|
WebkitTouchCallout: "none",
|
||||||
{
|
}
|
||||||
...search,
|
: undefined
|
||||||
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
|
}
|
||||||
} as unknown as Event
|
draggable={false}
|
||||||
}
|
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||||
onEventUploaded={() => {
|
/>
|
||||||
search.plus_id = "new_upload";
|
)}
|
||||||
}}
|
</div>
|
||||||
/>
|
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
|
||||||
)}
|
{tabsComponent}
|
||||||
{page == "video" && <VideoTab search={search} />}
|
<div className="scrollbar-container flex-1 overflow-y-auto">
|
||||||
{page == "tracking_details" && (
|
{page == "snapshot" && (
|
||||||
<TrackingDetails
|
<ObjectDetailsTab
|
||||||
className="w-full overflow-x-hidden"
|
search={search}
|
||||||
event={search as unknown as Event}
|
config={config}
|
||||||
fullscreen={true}
|
setSearch={setSearch}
|
||||||
setPane={() => {}}
|
setSimilarity={setSimilarity}
|
||||||
/>
|
setInputFocused={setInputFocused}
|
||||||
)}
|
showThumbnail={false}
|
||||||
</Content>
|
/>
|
||||||
</Overlay>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ScrollArea
|
||||||
|
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<ToggleGroup
|
||||||
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
value={pageToggle}
|
||||||
|
onValueChange={(value: SearchTab) => {
|
||||||
|
if (value) {
|
||||||
|
setPageToggle(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.values(searchTabs).map((item) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={item}
|
||||||
|
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
|
value={item}
|
||||||
|
data-nav-item={item}
|
||||||
|
aria-label={`Select ${item}`}
|
||||||
|
>
|
||||||
|
{item == "snapshot" && <FaImage className="size-4" />}
|
||||||
|
{item == "tracking_details" && (
|
||||||
|
<PiPath className="size-4" />
|
||||||
|
)}
|
||||||
|
<div className="smart-capitalize">
|
||||||
|
{t(`type.${item}`)}
|
||||||
|
</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
{page == "snapshot" && (
|
||||||
|
<>
|
||||||
|
{search.has_snapshot && (
|
||||||
|
<ObjectSnapshotTab
|
||||||
|
search={
|
||||||
|
{
|
||||||
|
...search,
|
||||||
|
plus_id: config?.plus?.enabled
|
||||||
|
? search.plus_id
|
||||||
|
: "not_enabled",
|
||||||
|
} as unknown as Event
|
||||||
|
}
|
||||||
|
onEventUploaded={() => {
|
||||||
|
search.plus_id = "new_upload";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page == "snapshot" && !search.has_snapshot && (
|
||||||
|
<img
|
||||||
|
className="w-full select-none rounded-lg object-contain transition-opacity"
|
||||||
|
style={
|
||||||
|
isIOS
|
||||||
|
? {
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
WebkitTouchCallout: "none",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
draggable={false}
|
||||||
|
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Heading as="h3" className="mt-2 smart-capitalize">
|
||||||
|
{t("type.details")}
|
||||||
|
</Heading>
|
||||||
|
<ObjectDetailsTab
|
||||||
|
search={search}
|
||||||
|
config={config}
|
||||||
|
setSearch={setSearch}
|
||||||
|
setSimilarity={setSimilarity}
|
||||||
|
setInputFocused={setInputFocused}
|
||||||
|
showThumbnail={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{page == "tracking_details" && (
|
||||||
|
<TrackingDetails event={search as unknown as Event} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Overlay>
|
||||||
|
</DetailStreamProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,6 +407,7 @@ type ObjectDetailsTabProps = {
|
|||||||
setSearch: (search: SearchResult | undefined) => void;
|
setSearch: (search: SearchResult | undefined) => void;
|
||||||
setSimilarity?: () => void;
|
setSimilarity?: () => void;
|
||||||
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
showThumbnail?: boolean;
|
||||||
};
|
};
|
||||||
function ObjectDetailsTab({
|
function ObjectDetailsTab({
|
||||||
search,
|
search,
|
||||||
@ -292,6 +415,7 @@ function ObjectDetailsTab({
|
|||||||
setSearch,
|
setSearch,
|
||||||
setSimilarity,
|
setSimilarity,
|
||||||
setInputFocused,
|
setInputFocused,
|
||||||
|
showThumbnail = true,
|
||||||
}: ObjectDetailsTabProps) {
|
}: ObjectDetailsTabProps) {
|
||||||
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
|
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
|
||||||
|
|
||||||
@ -873,66 +997,71 @@ function ObjectDetailsTab({
|
|||||||
<div className="text-sm">{formattedDate}</div>
|
<div className="text-sm">{formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-2 pl-6">
|
{showThumbnail && (
|
||||||
<img
|
<div className="flex w-full flex-col gap-2 pl-6">
|
||||||
className="aspect-video select-none rounded-lg object-contain transition-opacity"
|
<img
|
||||||
style={
|
className="aspect-video select-none rounded-lg object-contain transition-opacity"
|
||||||
isIOS
|
style={
|
||||||
? {
|
isIOS
|
||||||
WebkitUserSelect: "none",
|
? {
|
||||||
WebkitTouchCallout: "none",
|
WebkitUserSelect: "none",
|
||||||
}
|
WebkitTouchCallout: "none",
|
||||||
: undefined
|
}
|
||||||
}
|
: undefined
|
||||||
draggable={false}
|
}
|
||||||
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
draggable={false}
|
||||||
/>
|
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||||
<div
|
/>
|
||||||
className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")}
|
<div
|
||||||
>
|
className={cn(
|
||||||
{config?.semantic_search.enabled &&
|
"flex w-full flex-row gap-2",
|
||||||
setSimilarity != undefined &&
|
isMobile && "flex-col",
|
||||||
search.data.type == "object" && (
|
)}
|
||||||
<Button
|
>
|
||||||
|
{config?.semantic_search.enabled &&
|
||||||
|
setSimilarity != undefined &&
|
||||||
|
search.data.type == "object" && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
aria-label={t("itemMenu.findSimilar.aria")}
|
||||||
|
onClick={() => {
|
||||||
|
setSearch(undefined);
|
||||||
|
setSimilarity();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<LuSearch />
|
||||||
|
{t("itemMenu.findSimilar.label")}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasFace && (
|
||||||
|
<FaceSelectionDialog
|
||||||
className="w-full"
|
className="w-full"
|
||||||
aria-label={t("itemMenu.findSimilar.aria")}
|
faceNames={faceNames}
|
||||||
onClick={() => {
|
onTrainAttempt={onTrainFace}
|
||||||
setSearch(undefined);
|
|
||||||
setSimilarity();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex gap-1">
|
<Button className="w-full">
|
||||||
<LuSearch />
|
<div className="flex gap-1">
|
||||||
{t("itemMenu.findSimilar.label")}
|
<TbFaceId />
|
||||||
</div>
|
{t("trainFace", { ns: "views/faceLibrary" })}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
</Button>
|
||||||
{hasFace && (
|
</FaceSelectionDialog>
|
||||||
<FaceSelectionDialog
|
|
||||||
className="w-full"
|
|
||||||
faceNames={faceNames}
|
|
||||||
onTrainAttempt={onTrainFace}
|
|
||||||
>
|
|
||||||
<Button className="w-full">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<TbFaceId />
|
|
||||||
{t("trainFace", { ns: "views/faceLibrary" })}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</FaceSelectionDialog>
|
|
||||||
)}
|
|
||||||
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
|
||||||
search?.label == "speech" &&
|
|
||||||
search?.end_time && (
|
|
||||||
<Button className="w-full" onClick={onTranscribe}>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<CgTranscript />
|
|
||||||
{t("itemMenu.audioTranscription.label")}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
||||||
|
search?.label == "speech" &&
|
||||||
|
search?.end_time && (
|
||||||
|
<Button className="w-full" onClick={onTranscribe}>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<CgTranscript />
|
||||||
|
{t("itemMenu.audioTranscription.label")}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{config?.cameras[search.camera].objects.genai.enabled &&
|
{config?.cameras[search.camera].objects.genai.enabled &&
|
||||||
@ -1167,7 +1296,7 @@ export function ObjectSnapshotTab({
|
|||||||
search.label != "on_demand" && (
|
search.label != "on_demand" && (
|
||||||
<Card className="p-1 text-sm md:p-2">
|
<Card className="p-1 text-sm md:p-2">
|
||||||
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
|
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
|
||||||
<div className={cn("flex flex-col space-y-3")}>
|
<div className={cn("flex max-w-sm flex-col space-y-3")}>
|
||||||
<div className={"text-lg leading-none"}>
|
<div className={"text-lg leading-none"}>
|
||||||
{t("explore.plus.submitToPlus.label")}
|
{t("explore.plus.submitToPlus.label")}
|
||||||
</div>
|
</div>
|
||||||
@ -1176,7 +1305,7 @@ export function ObjectSnapshotTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
|
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end">
|
||||||
{state == "reviewing" && (
|
{state == "reviewing" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -57,7 +57,7 @@ export default function DetailStream({
|
|||||||
elementRef: scrollRef,
|
elementRef: scrollRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
const effectiveTime = currentTime + annotationOffset / 1000;
|
const effectiveTime = currentTime - annotationOffset / 1000;
|
||||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||||
const [controlsExpanded, setControlsExpanded] = useState(false);
|
const [controlsExpanded, setControlsExpanded] = useState(false);
|
||||||
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
||||||
@ -213,6 +213,7 @@ export default function DetailStream({
|
|||||||
config={config}
|
config={config}
|
||||||
onSeek={onSeekCheckPlaying}
|
onSeek={onSeekCheckPlaying}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
|
annotationOffset={annotationOffset}
|
||||||
isActive={activeReviewId == id}
|
isActive={activeReviewId == id}
|
||||||
onActivate={() => setActiveReviewId(id)}
|
onActivate={() => setActiveReviewId(id)}
|
||||||
onOpenUpload={(e) => setUpload(e)}
|
onOpenUpload={(e) => setUpload(e)}
|
||||||
@ -278,6 +279,7 @@ type ReviewGroupProps = {
|
|||||||
onActivate?: () => void;
|
onActivate?: () => void;
|
||||||
onOpenUpload?: (e: Event) => void;
|
onOpenUpload?: (e: Event) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
|
annotationOffset: number;
|
||||||
alwaysExpandActive?: boolean;
|
alwaysExpandActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -290,11 +292,14 @@ function ReviewGroup({
|
|||||||
onActivate,
|
onActivate,
|
||||||
onOpenUpload,
|
onOpenUpload,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
|
annotationOffset,
|
||||||
alwaysExpandActive = false,
|
alwaysExpandActive = false,
|
||||||
}: ReviewGroupProps) {
|
}: ReviewGroupProps) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const start = review.start_time ?? 0;
|
const start = review.start_time ?? 0;
|
||||||
|
// review.start_time is in detect time, convert to record for seeking
|
||||||
|
const startRecord = start + annotationOffset / 1000;
|
||||||
|
|
||||||
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
|
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -371,7 +376,7 @@ function ReviewGroup({
|
|||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onActivate?.();
|
onActivate?.();
|
||||||
onSeek(start);
|
onSeek(startRecord);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
|
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
|
||||||
@ -450,6 +455,7 @@ function ReviewGroup({
|
|||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
|
annotationOffset={annotationOffset}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
onOpenUpload={onOpenUpload}
|
onOpenUpload={onOpenUpload}
|
||||||
/>
|
/>
|
||||||
@ -483,12 +489,14 @@ function ReviewGroup({
|
|||||||
type EventListProps = {
|
type EventListProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
|
annotationOffset: number;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
onOpenUpload?: (e: Event) => void;
|
onOpenUpload?: (e: Event) => void;
|
||||||
};
|
};
|
||||||
function EventList({
|
function EventList({
|
||||||
event,
|
event,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
|
annotationOffset,
|
||||||
onSeek,
|
onSeek,
|
||||||
onOpenUpload,
|
onOpenUpload,
|
||||||
}: EventListProps) {
|
}: EventListProps) {
|
||||||
@ -505,14 +513,17 @@ function EventList({
|
|||||||
if (event) {
|
if (event) {
|
||||||
setSelectedObjectIds([]);
|
setSelectedObjectIds([]);
|
||||||
setSelectedObjectIds([event.id]);
|
setSelectedObjectIds([event.id]);
|
||||||
onSeek(event.start_time);
|
// event.start_time is detect time, convert to record
|
||||||
|
const recordTime = event.start_time + annotationOffset / 1000;
|
||||||
|
onSeek(recordTime);
|
||||||
} else {
|
} else {
|
||||||
setSelectedObjectIds([]);
|
setSelectedObjectIds([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimelineClick = (ts: number, play?: boolean) => {
|
const handleTimelineClick = (ts: number, play?: boolean) => {
|
||||||
handleObjectSelect(event);
|
setSelectedObjectIds([]);
|
||||||
|
setSelectedObjectIds([event.id]);
|
||||||
onSeek(ts, play);
|
onSeek(ts, play);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -554,7 +565,6 @@ function EventList({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSeek(event.start_time);
|
|
||||||
handleObjectSelect(event);
|
handleObjectSelect(event);
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
@ -568,7 +578,6 @@ function EventList({
|
|||||||
className="flex flex-1 items-center gap-2"
|
className="flex flex-1 items-center gap-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSeek(event.start_time);
|
|
||||||
handleObjectSelect(event);
|
handleObjectSelect(event);
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
@ -607,6 +616,7 @@ function EventList({
|
|||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
onSeek={handleTimelineClick}
|
onSeek={handleTimelineClick}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
|
annotationOffset={annotationOffset}
|
||||||
startTime={event.start_time}
|
startTime={event.start_time}
|
||||||
endTime={event.end_time}
|
endTime={event.end_time}
|
||||||
/>
|
/>
|
||||||
@ -621,6 +631,7 @@ type LifecycleItemProps = {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
onSeek?: (timestamp: number, play?: boolean) => void;
|
onSeek?: (timestamp: number, play?: boolean) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
|
annotationOffset: number;
|
||||||
isTimelineActive?: boolean;
|
isTimelineActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -629,6 +640,7 @@ function LifecycleItem({
|
|||||||
isActive,
|
isActive,
|
||||||
onSeek,
|
onSeek,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
|
annotationOffset,
|
||||||
isTimelineActive = false,
|
isTimelineActive = false,
|
||||||
}: LifecycleItemProps) {
|
}: LifecycleItemProps) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
@ -682,7 +694,8 @@ function LifecycleItem({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSeek?.(item.timestamp, false);
|
const recordTimestamp = item.timestamp + annotationOffset / 1000;
|
||||||
|
onSeek?.(recordTimestamp, false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
|
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
|
||||||
@ -751,12 +764,14 @@ function ObjectTimeline({
|
|||||||
eventId,
|
eventId,
|
||||||
onSeek,
|
onSeek,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
|
annotationOffset,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
}: {
|
}: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
|
annotationOffset: number;
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
endTime?: number;
|
endTime?: number;
|
||||||
}) {
|
}) {
|
||||||
@ -857,6 +872,7 @@ function ObjectTimeline({
|
|||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
|
annotationOffset={annotationOffset}
|
||||||
isTimelineActive={isWithinEventRange}
|
isTimelineActive={isWithinEventRange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
|
|||||||
isDetailMode: boolean;
|
isDetailMode: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
camera: string;
|
camera: string;
|
||||||
|
initialSelectedObjectIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailStreamProvider({
|
export function DetailStreamProvider({
|
||||||
@ -29,8 +30,11 @@ export function DetailStreamProvider({
|
|||||||
isDetailMode,
|
isDetailMode,
|
||||||
currentTime,
|
currentTime,
|
||||||
camera,
|
camera,
|
||||||
|
initialSelectedObjectIds,
|
||||||
}: DetailStreamProviderProps) {
|
}: DetailStreamProviderProps) {
|
||||||
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]);
|
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>(
|
||||||
|
() => initialSelectedObjectIds ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleObjectSelection = (id: string | undefined) => {
|
const toggleObjectSelection = (id: string | undefined) => {
|
||||||
if (id === undefined) {
|
if (id === undefined) {
|
||||||
|
|||||||
@ -13,7 +13,8 @@ function formatZonesList(zones: string[]): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const allButLast = zones.slice(0, -1).join(", ");
|
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
|
||||||
|
const allButLast = zones.slice(0, -1).join(separatorWithSpace);
|
||||||
return t("list.many", {
|
return t("list.many", {
|
||||||
items: allButLast,
|
items: allButLast,
|
||||||
last: zones[zones.length - 1],
|
last: zones[zones.length - 1],
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { baseUrl } from "@/api/baseUrl";
|
|||||||
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
|
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -10,13 +10,35 @@ import {
|
|||||||
CustomClassificationModelConfig,
|
CustomClassificationModelConfig,
|
||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
} from "@/types/frigateConfig";
|
} from "@/types/frigateConfig";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaFolderPlus } from "react-icons/fa";
|
import { FaFolderPlus } from "react-icons/fa";
|
||||||
import { MdModelTraining } from "react-icons/md";
|
import { MdModelTraining } from "react-icons/md";
|
||||||
|
import { LuTrash2 } from "react-icons/lu";
|
||||||
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import BlurredIconButton from "@/components/button/BlurredIconButton";
|
||||||
|
|
||||||
const allModelTypes = ["objects", "states"] as const;
|
const allModelTypes = ["objects", "states"] as const;
|
||||||
type ModelType = (typeof allModelTypes)[number];
|
type ModelType = (typeof allModelTypes)[number];
|
||||||
@ -126,7 +148,7 @@ export default function ModelSelectionView({
|
|||||||
onClick={() => setNewModel(true)}
|
onClick={() => setNewModel(true)}
|
||||||
>
|
>
|
||||||
<FaFolderPlus />
|
<FaFolderPlus />
|
||||||
Add Classification
|
{t("button.addClassification")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -142,6 +164,7 @@ export default function ModelSelectionView({
|
|||||||
key={config.name}
|
key={config.name}
|
||||||
config={config}
|
config={config}
|
||||||
onClick={() => onClick(config)}
|
onClick={() => onClick(config)}
|
||||||
|
onDelete={() => refreshConfig()}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -179,12 +202,53 @@ function NoModelsView({
|
|||||||
type ModelCardProps = {
|
type ModelCardProps = {
|
||||||
config: CustomClassificationModelConfig;
|
config: CustomClassificationModelConfig;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
function ModelCard({ config, onClick }: ModelCardProps) {
|
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||||
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
|
||||||
const { data: dataset } = useSWR<{
|
const { data: dataset } = useSWR<{
|
||||||
[id: string]: string[];
|
[id: string]: string[];
|
||||||
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
||||||
|
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const bypassDialogRef = useRef(false);
|
||||||
|
|
||||||
|
useKeyboardListener(["Shift"], (_, modifiers) => {
|
||||||
|
bypassDialogRef.current = modifiers.shift;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
await axios
|
||||||
|
.delete(`classification/${config.name}`)
|
||||||
|
.then((resp) => {
|
||||||
|
if (resp.status == 200) {
|
||||||
|
toast.success(t("toast.success.deletedModel", { count: 1 }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.deleteModelFailed", { errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [config, onDelete, t]);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback(() => {
|
||||||
|
if (bypassDialogRef.current) {
|
||||||
|
handleDelete();
|
||||||
|
} else {
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}
|
||||||
|
}, [handleDelete]);
|
||||||
|
|
||||||
const coverImage = useMemo(() => {
|
const coverImage = useMemo(() => {
|
||||||
if (!dataset) {
|
if (!dataset) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -204,22 +268,66 @@ function ModelCard({ config, onClick }: ModelCardProps) {
|
|||||||
}, [dataset]);
|
}, [dataset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
key={config.name}
|
<AlertDialog
|
||||||
className={cn(
|
open={deleteDialogOpen}
|
||||||
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
|
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||||
"outline-transparent duration-500",
|
>
|
||||||
)}
|
<AlertDialogContent>
|
||||||
onClick={() => onClick()}
|
<AlertDialogHeader>
|
||||||
>
|
<AlertDialogTitle>{t("deleteModel.title")}</AlertDialogTitle>
|
||||||
<img
|
</AlertDialogHeader>
|
||||||
className="size-full"
|
<AlertDialogDescription>
|
||||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
|
{t("deleteModel.single", { name: config.name })}
|
||||||
/>
|
</AlertDialogDescription>
|
||||||
<ImageShadowOverlay />
|
<AlertDialogFooter>
|
||||||
<div className="absolute bottom-2 left-3 text-lg smart-capitalize">
|
<AlertDialogCancel>
|
||||||
{config.name}
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="size-full"
|
||||||
|
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
|
||||||
|
/>
|
||||||
|
<ImageShadowOverlay />
|
||||||
|
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
|
||||||
|
{config.name}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 right-2 z-40">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<BlurredIconButton>
|
||||||
|
<FiMoreVertical className="size-5 text-white" />
|
||||||
|
</BlurredIconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||||
|
<LuTrash2 className="mr-2 size-4" />
|
||||||
|
<span>
|
||||||
|
{bypassDialogRef.current
|
||||||
|
? t("button.deleteNow", { ns: "common" })
|
||||||
|
: t("button.delete", { ns: "common" })}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -893,7 +893,7 @@ function ObjectTrainGrid({
|
|||||||
// selection
|
// selection
|
||||||
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
||||||
const [dialogTab, setDialogTab] = useState<SearchTab>("details");
|
const [dialogTab, setDialogTab] = useState<SearchTab>("snapshot");
|
||||||
|
|
||||||
// handlers
|
// handlers
|
||||||
|
|
||||||
|
|||||||
@ -214,7 +214,7 @@ export default function SearchView({
|
|||||||
// detail
|
// detail
|
||||||
|
|
||||||
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
||||||
const [page, setPage] = useState<SearchTab>("details");
|
const [page, setPage] = useState<SearchTab>("snapshot");
|
||||||
|
|
||||||
// search interaction
|
// search interaction
|
||||||
|
|
||||||
@ -222,7 +222,7 @@ export default function SearchView({
|
|||||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
const onSelectSearch = useCallback(
|
const onSelectSearch = useCallback(
|
||||||
(item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
|
(item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => {
|
||||||
if (selectedObjects.length > 1 || ctrl) {
|
if (selectedObjects.length > 1 || ctrl) {
|
||||||
const index = selectedObjects.indexOf(item.id);
|
const index = selectedObjects.indexOf(item.id);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user