mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
14 Commits
cfd94106d9
...
c8d04ae2c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d04ae2c0 | ||
|
|
de066d0062 | ||
|
|
f1a05d0f9b | ||
|
|
a623150811 | ||
|
|
e4eac4ac81 | ||
|
|
c371fc0c87 | ||
|
|
99a363c047 | ||
|
|
a374a60756 | ||
|
|
d41ee4ff88 | ||
|
|
c99ada8f6a | ||
|
|
01452e4c51 | ||
|
|
ef19332fe5 | ||
|
|
530b69b877 | ||
|
|
9044a61f47 |
@ -68,36 +68,6 @@ The mere presence of an unidentified person in private areas during late night h
|
||||
|
||||
</details>
|
||||
|
||||
### Camera Spatial Context
|
||||
|
||||
In addition to defining activity patterns, you can provide spatial context for specific cameras to help the LLM generate more accurate and descriptive titles and scene descriptions. The `camera_context` field allows you to describe physical features and locations that are outside the camera's field of view but are relevant for understanding the scene.
|
||||
|
||||
**Important Guidelines:**
|
||||
|
||||
- This context is used **only for descriptive purposes** to help the LLM write better titles and scene descriptions
|
||||
- It should describe **physical features and spatial relationships** (e.g., "front door is to the right", "driveway on the left")
|
||||
- It should **NOT** include subjective assessments or threat evaluations (e.g., "high-crime area")
|
||||
- Threat level determination remains based solely on observable actions defined in the activity patterns
|
||||
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
front_door:
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
camera_context: |
|
||||
- Front door entrance is to the right of the frame
|
||||
- Driveway and street are to the left
|
||||
- Steps in the center lead from the sidewalk to the front door
|
||||
- Garage is located beyond the left edge of the frame
|
||||
```
|
||||
|
||||
This helps the LLM generate more natural descriptions like "Person approaching front door" instead of "Person walking toward right side of frame".
|
||||
|
||||
The `camera_context` can be defined globally under `genai.review` and overridden per camera for specific spatial details.
|
||||
|
||||
### Image Source
|
||||
|
||||
By default, review summaries use preview images (cached preview frames) which have a lower resolution but use fewer tokens per image. For better image quality and more detailed analysis, you can configure Frigate to extract frames directly from recordings at a higher resolution:
|
||||
|
||||
@ -5,7 +5,7 @@ title: Enrichments
|
||||
|
||||
# Enrichments
|
||||
|
||||
Some of Frigate's enrichments can use a discrete GPU / NPU for accelerated processing.
|
||||
Some of Frigate's enrichments can use a discrete GPU or integrated GPU for accelerated processing.
|
||||
|
||||
## Requirements
|
||||
|
||||
@ -18,8 +18,10 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li
|
||||
- **Intel**
|
||||
|
||||
- OpenVINO will automatically be detected and used for enrichments in the default Frigate image.
|
||||
- **Note:** Intel NPUs have limited model support for enrichments. GPU is recommended for enrichments when available.
|
||||
|
||||
- **Nvidia**
|
||||
|
||||
- Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image.
|
||||
- Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image.
|
||||
|
||||
|
||||
@ -261,6 +261,8 @@ OpenVINO is supported on 6th Gen Intel platforms (Skylake) and newer. It will al
|
||||
|
||||
:::tip
|
||||
|
||||
**NPU + GPU Systems:** If you have both NPU and GPU available (Intel Core Ultra processors), use NPU for object detection and GPU for enrichments (semantic search, face recognition, etc.) for best performance and compatibility.
|
||||
|
||||
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be:
|
||||
|
||||
```yaml
|
||||
@ -283,7 +285,7 @@ detectors:
|
||||
| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc |
|
||||
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
||||
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
||||
|
||||
#### SSDLite MobileNet v2
|
||||
|
||||
@ -810,6 +810,8 @@ cameras:
|
||||
# NOTE: This must be different than any camera names, but can match with another zone on another
|
||||
# camera.
|
||||
front_steps:
|
||||
# Optional: A friendly name or descriptive text for the zones
|
||||
friendly_name: ""
|
||||
# Required: List of x,y coordinates to define the polygon of the zone.
|
||||
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
|
||||
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
|
||||
|
||||
@ -78,7 +78,7 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
||||
|
||||
### GPU Acceleration
|
||||
|
||||
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU / NPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
|
||||
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
|
||||
|
||||
```yaml
|
||||
semantic_search:
|
||||
@ -90,7 +90,7 @@ semantic_search:
|
||||
|
||||
:::info
|
||||
|
||||
If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU / NPU will be detected and used automatically.
|
||||
If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU will be detected and used automatically.
|
||||
Specify the `device` option to target a specific GPU in a multi-GPU system (see [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/)).
|
||||
If you do not specify a device, the first available GPU will be used.
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ cameras:
|
||||
- entire_yard
|
||||
zones:
|
||||
entire_yard:
|
||||
friendly_name: Entire yard # You can use characters from any language text
|
||||
coordinates: ...
|
||||
```
|
||||
|
||||
@ -44,8 +45,10 @@ cameras:
|
||||
- edge_yard
|
||||
zones:
|
||||
edge_yard:
|
||||
friendly_name: Edge yard # You can use characters from any language text
|
||||
coordinates: ...
|
||||
inner_yard:
|
||||
friendly_name: Inner yard # You can use characters from any language text
|
||||
coordinates: ...
|
||||
```
|
||||
|
||||
@ -59,6 +62,7 @@ cameras:
|
||||
- entire_yard
|
||||
zones:
|
||||
entire_yard:
|
||||
friendly_name: Entire yard
|
||||
coordinates: ...
|
||||
```
|
||||
|
||||
@ -82,6 +86,7 @@ cameras:
|
||||
|
||||
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
|
||||
|
||||
|
||||
### Zone Loitering
|
||||
|
||||
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.
|
||||
|
||||
@ -3,11 +3,17 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from onvif import ONVIFCamera, ONVIFError
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
@ -452,3 +458,537 @@ def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
return round(float(num) / float(den), 2)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onvif/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Probe ONVIF device",
|
||||
description=(
|
||||
"Probe an ONVIF device to determine capabilities and optionally test available stream URIs. "
|
||||
"Query params: host (required), port (default 80), username, password, test (boolean), "
|
||||
"auth_type (basic or digest, default basic)."
|
||||
),
|
||||
)
|
||||
async def onvif_probe(
|
||||
request: Request,
|
||||
host: str = Query(None),
|
||||
port: int = Query(80),
|
||||
username: str = Query(""),
|
||||
password: str = Query(""),
|
||||
test: bool = Query(False),
|
||||
auth_type: str = Query("basic"), # Add auth_type parameter
|
||||
):
|
||||
"""
|
||||
Probe a single ONVIF device to determine capabilities.
|
||||
|
||||
Connects to an ONVIF device and queries for:
|
||||
- Device information (manufacturer, model)
|
||||
- Media profiles count
|
||||
- PTZ support
|
||||
- Available presets
|
||||
- Autotracking support
|
||||
|
||||
Query Parameters:
|
||||
host: Device host/IP address (required)
|
||||
port: Device port (default 80)
|
||||
username: ONVIF username (optional)
|
||||
password: ONVIF password (optional)
|
||||
test: run ffprobe on the stream (optional)
|
||||
auth_type: Authentication type - "basic" or "digest" (default "basic")
|
||||
|
||||
Returns:
|
||||
JSON with device capabilities information
|
||||
"""
|
||||
if not host:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "host parameter is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate host format
|
||||
if not _is_valid_host(host):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid host format"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate auth_type
|
||||
if auth_type not in ["basic", "digest"]:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "auth_type must be 'basic' or 'digest'",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
onvif_camera = None
|
||||
|
||||
try:
|
||||
logger.debug(f"Probing ONVIF device at {host}:{port} with {auth_type} auth")
|
||||
|
||||
try:
|
||||
wsdl_base = None
|
||||
spec = find_spec("onvif")
|
||||
if spec and getattr(spec, "origin", None):
|
||||
wsdl_base = str(Path(spec.origin).parent / "wsdl")
|
||||
except Exception:
|
||||
wsdl_base = None
|
||||
|
||||
onvif_camera = ONVIFCamera(
|
||||
host, port, username or "", password or "", wsdl_dir=wsdl_base
|
||||
)
|
||||
|
||||
# Configure digest authentication if requested
|
||||
if auth_type == "digest" and username and password:
|
||||
# Create httpx client with digest auth
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
|
||||
# Replace the transport in the zeep client
|
||||
transport = AsyncTransport(client=client)
|
||||
|
||||
# Update the xaddr before setting transport
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Replace transport in all services
|
||||
if hasattr(onvif_camera, "devicemgmt"):
|
||||
onvif_camera.devicemgmt.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "media"):
|
||||
onvif_camera.media.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "ptz"):
|
||||
onvif_camera.ptz.zeep_client.transport = transport
|
||||
|
||||
logger.debug("Configured digest authentication")
|
||||
else:
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Get device information
|
||||
device_info = {
|
||||
"manufacturer": "Unknown",
|
||||
"model": "Unknown",
|
||||
"firmware_version": "Unknown",
|
||||
}
|
||||
try:
|
||||
device_service = await onvif_camera.create_devicemgmt_service()
|
||||
|
||||
# Update transport for device service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
device_service.zeep_client.transport = transport
|
||||
|
||||
device_info_resp = await device_service.GetDeviceInformation()
|
||||
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
|
||||
device_info_resp.get("Manufacturer")
|
||||
if isinstance(device_info_resp, dict)
|
||||
else None
|
||||
)
|
||||
model = getattr(device_info_resp, "Model", None) or (
|
||||
device_info_resp.get("Model")
|
||||
if isinstance(device_info_resp, dict)
|
||||
else None
|
||||
)
|
||||
firmware = getattr(device_info_resp, "FirmwareVersion", None) or (
|
||||
device_info_resp.get("FirmwareVersion")
|
||||
if isinstance(device_info_resp, dict)
|
||||
else None
|
||||
)
|
||||
device_info.update(
|
||||
{
|
||||
"manufacturer": manufacturer or "Unknown",
|
||||
"model": model or "Unknown",
|
||||
"firmware_version": firmware or "Unknown",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get device info: {e}")
|
||||
|
||||
# Get media profiles
|
||||
profiles = []
|
||||
profiles_count = 0
|
||||
first_profile_token = None
|
||||
ptz_config_token = None
|
||||
try:
|
||||
media_service = await onvif_camera.create_media_service()
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
|
||||
profiles = await media_service.GetProfiles()
|
||||
profiles_count = len(profiles) if profiles else 0
|
||||
if profiles and len(profiles) > 0:
|
||||
p = profiles[0]
|
||||
first_profile_token = getattr(p, "token", None) or (
|
||||
p.get("token") if isinstance(p, dict) else None
|
||||
)
|
||||
# Get PTZ configuration token from the profile
|
||||
ptz_configuration = getattr(p, "PTZConfiguration", None) or (
|
||||
p.get("PTZConfiguration") if isinstance(p, dict) else None
|
||||
)
|
||||
if ptz_configuration:
|
||||
ptz_config_token = getattr(ptz_configuration, "token", None) or (
|
||||
ptz_configuration.get("token")
|
||||
if isinstance(ptz_configuration, dict)
|
||||
else None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get media profiles: {e}")
|
||||
|
||||
# Check PTZ support and capabilities
|
||||
ptz_supported = False
|
||||
presets_count = 0
|
||||
autotrack_supported = False
|
||||
|
||||
try:
|
||||
ptz_service = await onvif_camera.create_ptz_service()
|
||||
|
||||
# Update transport for PTZ service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
ptz_service.zeep_client.transport = transport
|
||||
|
||||
# Check if PTZ service is available
|
||||
try:
|
||||
await ptz_service.GetServiceCapabilities()
|
||||
ptz_supported = True
|
||||
logger.debug("PTZ service is available")
|
||||
except Exception as e:
|
||||
logger.debug(f"PTZ service not available: {e}")
|
||||
ptz_supported = False
|
||||
|
||||
# Try to get presets if PTZ is supported and we have a profile
|
||||
if ptz_supported and first_profile_token:
|
||||
try:
|
||||
presets_resp = await ptz_service.GetPresets(
|
||||
{"ProfileToken": first_profile_token}
|
||||
)
|
||||
presets_count = len(presets_resp) if presets_resp else 0
|
||||
logger.debug(f"Found {presets_count} presets")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get presets: {e}")
|
||||
presets_count = 0
|
||||
|
||||
# Check for autotracking support - requires both FOV relative movement and MoveStatus
|
||||
if ptz_supported and first_profile_token and ptz_config_token:
|
||||
# First check for FOV relative movement support
|
||||
pt_r_fov_supported = False
|
||||
try:
|
||||
config_request = ptz_service.create_type("GetConfigurationOptions")
|
||||
config_request.ConfigurationToken = ptz_config_token
|
||||
ptz_config = await ptz_service.GetConfigurationOptions(
|
||||
config_request
|
||||
)
|
||||
|
||||
if ptz_config:
|
||||
# Check for pt-r-fov support
|
||||
spaces = getattr(ptz_config, "Spaces", None) or (
|
||||
ptz_config.get("Spaces")
|
||||
if isinstance(ptz_config, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
if spaces:
|
||||
rel_pan_tilt_space = getattr(
|
||||
spaces, "RelativePanTiltTranslationSpace", None
|
||||
) or (
|
||||
spaces.get("RelativePanTiltTranslationSpace")
|
||||
if isinstance(spaces, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
if rel_pan_tilt_space:
|
||||
# Look for FOV space
|
||||
for i, space in enumerate(rel_pan_tilt_space):
|
||||
uri = None
|
||||
if isinstance(space, dict):
|
||||
uri = space.get("URI")
|
||||
else:
|
||||
uri = getattr(space, "URI", None)
|
||||
|
||||
if uri and "TranslationSpaceFov" in uri:
|
||||
pt_r_fov_supported = True
|
||||
logger.debug(
|
||||
"FOV relative movement (pt-r-fov) supported"
|
||||
)
|
||||
break
|
||||
|
||||
logger.debug(f"PTZ config spaces: {ptz_config}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to check FOV relative movement: {e}")
|
||||
pt_r_fov_supported = False
|
||||
|
||||
# Now check for MoveStatus support via GetServiceCapabilities
|
||||
if pt_r_fov_supported:
|
||||
try:
|
||||
service_capabilities_request = ptz_service.create_type(
|
||||
"GetServiceCapabilities"
|
||||
)
|
||||
service_capabilities = await ptz_service.GetServiceCapabilities(
|
||||
service_capabilities_request
|
||||
)
|
||||
|
||||
# Look for MoveStatus in the capabilities
|
||||
move_status_capable = False
|
||||
if service_capabilities:
|
||||
# Try to find MoveStatus key recursively
|
||||
def find_move_status(obj, key="MoveStatus"):
|
||||
if isinstance(obj, dict):
|
||||
if key in obj:
|
||||
return obj[key]
|
||||
for v in obj.values():
|
||||
result = find_move_status(v, key)
|
||||
if result is not None:
|
||||
return result
|
||||
elif hasattr(obj, key):
|
||||
return getattr(obj, key)
|
||||
elif hasattr(obj, "__dict__"):
|
||||
for v in vars(obj).values():
|
||||
result = find_move_status(v, key)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
move_status_value = find_move_status(service_capabilities)
|
||||
|
||||
# MoveStatus should return "true" if supported
|
||||
if isinstance(move_status_value, bool):
|
||||
move_status_capable = move_status_value
|
||||
elif isinstance(move_status_value, str):
|
||||
move_status_capable = (
|
||||
move_status_value.lower() == "true"
|
||||
)
|
||||
|
||||
logger.debug(f"MoveStatus capability: {move_status_value}")
|
||||
|
||||
# Autotracking is supported if both conditions are met
|
||||
autotrack_supported = pt_r_fov_supported and move_status_capable
|
||||
|
||||
if autotrack_supported:
|
||||
logger.debug(
|
||||
"Autotracking fully supported (pt-r-fov + MoveStatus)"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Autotracking not fully supported - pt-r-fov: {pt_r_fov_supported}, MoveStatus: {move_status_capable}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to check MoveStatus support: {e}")
|
||||
autotrack_supported = False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to probe PTZ service: {e}")
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"manufacturer": device_info["manufacturer"],
|
||||
"model": device_info["model"],
|
||||
"firmware_version": device_info["firmware_version"],
|
||||
"profiles_count": profiles_count,
|
||||
"ptz_supported": ptz_supported,
|
||||
"presets_count": presets_count,
|
||||
"autotrack_supported": autotrack_supported,
|
||||
}
|
||||
|
||||
# Gather RTSP candidates
|
||||
rtsp_candidates: list[dict] = []
|
||||
try:
|
||||
media_service = await onvif_camera.create_media_service()
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
|
||||
if profiles_count and media_service:
|
||||
for p in profiles or []:
|
||||
token = getattr(p, "token", None) or (
|
||||
p.get("token") if isinstance(p, dict) else None
|
||||
)
|
||||
if not token:
|
||||
continue
|
||||
try:
|
||||
stream_setup = {
|
||||
"Stream": "RTP-Unicast",
|
||||
"Transport": {"Protocol": "RTSP"},
|
||||
}
|
||||
stream_req = {
|
||||
"ProfileToken": token,
|
||||
"StreamSetup": stream_setup,
|
||||
}
|
||||
stream_uri_resp = await media_service.GetStreamUri(stream_req)
|
||||
uri = (
|
||||
stream_uri_resp.get("Uri")
|
||||
if isinstance(stream_uri_resp, dict)
|
||||
else getattr(stream_uri_resp, "Uri", None)
|
||||
)
|
||||
if uri:
|
||||
logger.debug(
|
||||
f"GetStreamUri returned for token {token}: {uri}"
|
||||
)
|
||||
# If credentials were provided, do NOT add the unauthenticated URI.
|
||||
try:
|
||||
if isinstance(uri, str) and uri.startswith("rtsp://"):
|
||||
if username and password and "@" not in uri:
|
||||
# Inject URL-encoded credentials and add only the
|
||||
# authenticated version.
|
||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
injected = uri.replace(
|
||||
"rtsp://", f"rtsp://{cred}", 1
|
||||
)
|
||||
rtsp_candidates.append(
|
||||
{
|
||||
"source": "GetStreamUri",
|
||||
"profile_token": token,
|
||||
"uri": injected,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No credentials provided or URI already contains
|
||||
# credentials — add the URI as returned.
|
||||
rtsp_candidates.append(
|
||||
{
|
||||
"source": "GetStreamUri",
|
||||
"profile_token": token,
|
||||
"uri": uri,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Non-RTSP URIs (e.g., http-flv) — add as returned.
|
||||
rtsp_candidates.append(
|
||||
{
|
||||
"source": "GetStreamUri",
|
||||
"profile_token": token,
|
||||
"uri": uri,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Skipping stream URI for token {token} due to processing error: {e}"
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
logger.debug(
|
||||
f"GetStreamUri failed for token {token}", exc_info=True
|
||||
)
|
||||
continue
|
||||
|
||||
# Add common RTSP patterns as fallback
|
||||
if not rtsp_candidates:
|
||||
common_paths = [
|
||||
"/h264",
|
||||
"/live.sdp",
|
||||
"/media.amp",
|
||||
"/Streaming/Channels/101",
|
||||
"/Streaming/Channels/1",
|
||||
"/stream1",
|
||||
"/cam/realmonitor?channel=1&subtype=0",
|
||||
"/11",
|
||||
]
|
||||
# Use URL-encoded credentials for pattern fallback URIs when provided
|
||||
auth_str = (
|
||||
f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
if username and password
|
||||
else ""
|
||||
)
|
||||
rtsp_port = 554
|
||||
for path in common_paths:
|
||||
uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}"
|
||||
rtsp_candidates.append({"source": "pattern", "uri": uri})
|
||||
except Exception:
|
||||
logger.debug("Failed to collect RTSP candidates")
|
||||
|
||||
# Optionally test RTSP candidates using ffprobe_stream
|
||||
tested_candidates = []
|
||||
if test and rtsp_candidates:
|
||||
for c in rtsp_candidates:
|
||||
uri = c["uri"]
|
||||
to_test = [uri]
|
||||
try:
|
||||
if (
|
||||
username
|
||||
and password
|
||||
and isinstance(uri, str)
|
||||
and uri.startswith("rtsp://")
|
||||
and "@" not in uri
|
||||
):
|
||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1)
|
||||
if cred_uri not in to_test:
|
||||
to_test.append(cred_uri)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for test_uri in to_test:
|
||||
try:
|
||||
probe = ffprobe_stream(
|
||||
request.app.frigate_config.ffmpeg, test_uri, detailed=False
|
||||
)
|
||||
print(probe)
|
||||
ok = probe is not None and getattr(probe, "returncode", 1) == 0
|
||||
tested_candidates.append(
|
||||
{
|
||||
"uri": test_uri,
|
||||
"source": c.get("source"),
|
||||
"ok": ok,
|
||||
"profile_token": c.get("profile_token"),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to probe stream: {e}")
|
||||
tested_candidates.append(
|
||||
{
|
||||
"uri": test_uri,
|
||||
"source": c.get("source"),
|
||||
"ok": False,
|
||||
"profile_token": c.get("profile_token"),
|
||||
}
|
||||
)
|
||||
|
||||
result["rtsp_candidates"] = rtsp_candidates
|
||||
if test:
|
||||
result["rtsp_tested"] = tested_candidates
|
||||
|
||||
logger.debug(f"ONVIF probe successful: {result}")
|
||||
return JSONResponse(content=result)
|
||||
|
||||
except ONVIFError as e:
|
||||
logger.warning(f"ONVIF error probing {host}:{port}: {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "ONVIF error"},
|
||||
status_code=400,
|
||||
)
|
||||
except (Fault, TransportError) as e:
|
||||
logger.warning(f"Connection error probing {host}:{port}: {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Connection error"},
|
||||
status_code=503,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error probing ONVIF device at {host}:{port}, {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Probe failed"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Best-effort cleanup of ONVIF camera client session
|
||||
if onvif_camera is not None:
|
||||
try:
|
||||
# Check if the camera has a close method and call it
|
||||
if hasattr(onvif_camera, "close"):
|
||||
await onvif_camera.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing ONVIF camera session: {e}")
|
||||
|
||||
@ -37,6 +37,8 @@ from frigate.models import Event
|
||||
from frigate.util.classification import (
|
||||
collect_object_classification_examples,
|
||||
collect_state_classification_examples,
|
||||
get_dataset_image_count,
|
||||
read_training_metadata,
|
||||
)
|
||||
from frigate.util.file import get_event_snapshot
|
||||
|
||||
@ -112,9 +114,18 @@ def reclassify_face(request: Request, body: dict = None):
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
response = context.reprocess_face(training_file)
|
||||
|
||||
if not isinstance(response, dict):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Could not process request.",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200 if response.get("success", True) else 400,
|
||||
content=response,
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@ -555,23 +566,59 @@ def get_classification_dataset(name: str):
|
||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset")
|
||||
|
||||
if not os.path.exists(dataset_dir):
|
||||
return JSONResponse(status_code=200, content={})
|
||||
return JSONResponse(
|
||||
status_code=200, content={"categories": {}, "training_metadata": None}
|
||||
)
|
||||
|
||||
for name in os.listdir(dataset_dir):
|
||||
category_dir = os.path.join(dataset_dir, name)
|
||||
for category_name in os.listdir(dataset_dir):
|
||||
category_dir = os.path.join(dataset_dir, category_name)
|
||||
|
||||
if not os.path.isdir(category_dir):
|
||||
continue
|
||||
|
||||
dataset_dict[name] = []
|
||||
dataset_dict[category_name] = []
|
||||
|
||||
for file in filter(
|
||||
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
|
||||
os.listdir(category_dir),
|
||||
):
|
||||
dataset_dict[name].append(file)
|
||||
dataset_dict[category_name].append(file)
|
||||
|
||||
return JSONResponse(status_code=200, content=dataset_dict)
|
||||
# Get training metadata
|
||||
metadata = read_training_metadata(sanitize_filename(name))
|
||||
current_image_count = get_dataset_image_count(sanitize_filename(name))
|
||||
|
||||
if metadata is None:
|
||||
training_metadata = {
|
||||
"has_trained": False,
|
||||
"last_training_date": None,
|
||||
"last_training_image_count": 0,
|
||||
"current_image_count": current_image_count,
|
||||
"new_images_count": current_image_count,
|
||||
"dataset_changed": current_image_count > 0,
|
||||
}
|
||||
else:
|
||||
last_training_count = metadata.get("last_training_image_count", 0)
|
||||
# Dataset has changed if count is different (either added or deleted images)
|
||||
dataset_changed = current_image_count != last_training_count
|
||||
# Only show positive count for new images (ignore deletions in the count display)
|
||||
new_images_count = max(0, current_image_count - last_training_count)
|
||||
training_metadata = {
|
||||
"has_trained": True,
|
||||
"last_training_date": metadata.get("last_training_date"),
|
||||
"last_training_image_count": last_training_count,
|
||||
"current_image_count": current_image_count,
|
||||
"new_images_count": new_images_count,
|
||||
"dataset_changed": dataset_changed,
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"categories": dataset_dict,
|
||||
"training_metadata": training_metadata,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -662,12 +709,106 @@ def delete_classification_dataset_images(
|
||||
if os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
|
||||
if os.path.exists(folder) and not os.listdir(folder):
|
||||
os.rmdir(folder)
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||
content=({"success": True, "message": "Successfully deleted images."}),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/classification/{name}/dataset/{old_category}/rename",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Rename a classification category",
|
||||
description="""Renames a classification category for a given classification model.
|
||||
The old category must exist and the new name must be valid. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
def rename_classification_category(
|
||||
request: Request, name: str, old_category: str, body: dict = None
|
||||
):
|
||||
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,
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
new_category = sanitize_filename(json.get("new_category", ""))
|
||||
|
||||
if not new_category:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "New category name is required.",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
old_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(old_category)
|
||||
)
|
||||
new_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", new_category
|
||||
)
|
||||
|
||||
if not os.path.exists(old_folder):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Category {old_category} does not exist.",
|
||||
}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if os.path.exists(new_folder):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Category {new_category} already exists.",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
os.rename(old_folder, new_folder)
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Successfully renamed category to {new_category}.",
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error renaming category: {e}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Failed to rename category",
|
||||
}
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/dataset/categorize",
|
||||
response_model=GenericResponse,
|
||||
@ -723,7 +864,7 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
||||
os.unlink(training_file)
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||
content=({"success": True, "message": "Successfully categorized image."}),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
@ -761,7 +902,7 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
|
||||
os.unlink(file_path)
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||
content=({"success": True, "message": "Successfully deleted images."}),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
@ -812,31 +953,29 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
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.""",
|
||||
Works even if the model is not in the config (e.g., partially created during wizard).
|
||||
Returns a success message.""",
|
||||
)
|
||||
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,
|
||||
)
|
||||
sanitized_name = sanitize_filename(name)
|
||||
|
||||
# Delete the classification model's data directory in clips
|
||||
data_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
|
||||
data_dir = os.path.join(CLIPS_DIR, sanitized_name)
|
||||
if os.path.exists(data_dir):
|
||||
shutil.rmtree(data_dir)
|
||||
try:
|
||||
shutil.rmtree(data_dir)
|
||||
logger.info(f"Deleted classification data directory for {name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to delete data directory for {name}: {e}")
|
||||
|
||||
# Delete the classification model's files in model_cache
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, sanitize_filename(name))
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, sanitized_name)
|
||||
if os.path.exists(model_dir):
|
||||
shutil.rmtree(model_dir)
|
||||
try:
|
||||
shutil.rmtree(model_dir)
|
||||
logger.info(f"Deleted classification model directory for {name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to delete model directory for {name}: {e}")
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
|
||||
@ -177,6 +177,12 @@ class CameraConfig(FrigateBaseModel):
|
||||
def ffmpeg_cmds(self) -> list[dict[str, list[str]]]:
|
||||
return self._ffmpeg_cmds
|
||||
|
||||
def get_formatted_name(self) -> str:
|
||||
"""Return the friendly name if set, otherwise return a formatted version of the camera name."""
|
||||
if self.friendly_name:
|
||||
return self.friendly_name
|
||||
return self.name.replace("_", " ").title() if self.name else ""
|
||||
|
||||
def create_ffmpeg_cmds(self):
|
||||
if "_ffmpeg_cmds" in self:
|
||||
return
|
||||
|
||||
@ -140,10 +140,6 @@ Evaluate in this order:
|
||||
The mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.""",
|
||||
title="Custom activity context prompt defining normal and suspicious activity patterns for this property.",
|
||||
)
|
||||
camera_context: str = Field(
|
||||
default="",
|
||||
title="Spatial context about the camera's field of view to help with descriptive accuracy. Should describe physical features and locations outside the frame.",
|
||||
)
|
||||
|
||||
|
||||
class ReviewConfig(FrigateBaseModel):
|
||||
|
||||
@ -13,6 +13,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZoneConfig(BaseModel):
|
||||
friendly_name: Optional[str] = Field(
|
||||
None, title="Zone friendly name used in the Frigate UI."
|
||||
)
|
||||
filters: dict[str, FilterConfig] = Field(
|
||||
default_factory=dict, title="Zone filters."
|
||||
)
|
||||
@ -53,6 +56,12 @@ class ZoneConfig(BaseModel):
|
||||
def contour(self) -> np.ndarray:
|
||||
return self._contour
|
||||
|
||||
def get_formatted_name(self, zone_name: str) -> str:
|
||||
"""Return the friendly name if set, otherwise return a formatted version of the zone name."""
|
||||
if self.friendly_name:
|
||||
return self.friendly_name
|
||||
return zone_name.replace("_", " ").title()
|
||||
|
||||
@field_validator("objects", mode="before")
|
||||
@classmethod
|
||||
def validate_objects(cls, v):
|
||||
|
||||
@ -4,7 +4,6 @@ import logging
|
||||
import os
|
||||
|
||||
import sherpa_onnx
|
||||
from faster_whisper.utils import download_model
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
@ -25,6 +24,9 @@ class AudioTranscriptionModelRunner:
|
||||
|
||||
if model_size == "large":
|
||||
# use the Whisper download function instead of our own
|
||||
# Import dynamically to avoid crashes on systems without AVX support
|
||||
from faster_whisper.utils import download_model
|
||||
|
||||
logger.debug("Downloading Whisper audio transcription model")
|
||||
download_model(
|
||||
size_or_id="small" if device == "cuda" else "tiny",
|
||||
|
||||
@ -6,10 +6,8 @@ import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import (
|
||||
@ -32,11 +30,13 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
requestor: InterProcessRequestor,
|
||||
embeddings,
|
||||
metrics: DataProcessorMetrics,
|
||||
):
|
||||
super().__init__(config, metrics, None)
|
||||
self.config = config
|
||||
self.requestor = requestor
|
||||
self.embeddings = embeddings
|
||||
self.recognizer = None
|
||||
self.transcription_lock = threading.Lock()
|
||||
self.transcription_thread = None
|
||||
@ -50,6 +50,9 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
|
||||
def __build_recognizer(self) -> None:
|
||||
try:
|
||||
# Import dynamically to avoid crashes on systems without AVX support
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
self.recognizer = WhisperModel(
|
||||
model_size_or_path="small",
|
||||
device="cuda"
|
||||
@ -128,10 +131,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
)
|
||||
|
||||
# Embed the description
|
||||
self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.embed_description.value,
|
||||
{"id": event_id, "description": transcription},
|
||||
)
|
||||
self.embeddings.embed_description(event_id, transcription)
|
||||
|
||||
except DoesNotExist:
|
||||
logger.debug("No recording found for audio transcription post-processing")
|
||||
|
||||
@ -16,6 +16,7 @@ from peewee import DoesNotExist
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera import CameraConfig
|
||||
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
@ -30,6 +31,7 @@ from ..types import DataProcessorMetrics
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
||||
MIN_RECORDING_DURATION = 10
|
||||
|
||||
|
||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
@ -90,7 +92,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
pixels_per_image = width * height
|
||||
tokens_per_image = pixels_per_image / 1250
|
||||
prompt_tokens = 3500
|
||||
available_tokens = context_size * 0.98 - prompt_tokens
|
||||
response_tokens = 300
|
||||
available_tokens = context_size - prompt_tokens - response_tokens
|
||||
max_frames = int(available_tokens / tokens_per_image)
|
||||
|
||||
return min(max(max_frames, 3), 20)
|
||||
@ -129,7 +132,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
if image_source == ImageSourceEnum.recordings:
|
||||
duration = final_data["end_time"] - final_data["start_time"]
|
||||
buffer_extension = duration * RECORDING_BUFFER_EXTENSION_PERCENT
|
||||
buffer_extension = min(
|
||||
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||
)
|
||||
|
||||
# Ensure minimum total duration for short review items
|
||||
# This provides better context for brief events
|
||||
total_duration = duration + (2 * buffer_extension)
|
||||
if total_duration < MIN_RECORDING_DURATION:
|
||||
# Expand buffer to reach minimum duration, still respecting max of 10s per side
|
||||
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
||||
buffer_extension = min(10, additional_buffer_per_side)
|
||||
|
||||
thumbs = self.get_recording_frames(
|
||||
camera,
|
||||
@ -181,7 +194,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
self.requestor,
|
||||
self.genai_client,
|
||||
self.review_desc_speed,
|
||||
camera,
|
||||
camera_config,
|
||||
final_data,
|
||||
thumbs,
|
||||
camera_config.review.genai,
|
||||
@ -410,7 +423,7 @@ def run_analysis(
|
||||
requestor: InterProcessRequestor,
|
||||
genai_client: GenAIClient,
|
||||
review_inference_speed: InferenceSpeed,
|
||||
camera: str,
|
||||
camera_config: CameraConfig,
|
||||
final_data: dict[str, str],
|
||||
thumbs: list[bytes],
|
||||
genai_config: GenAIReviewConfig,
|
||||
@ -418,10 +431,19 @@ def run_analysis(
|
||||
attribute_labels: list[str],
|
||||
) -> None:
|
||||
start = datetime.datetime.now().timestamp()
|
||||
|
||||
# Format zone names using zone config friendly names if available
|
||||
formatted_zones = []
|
||||
for zone_name in final_data["data"]["zones"]:
|
||||
if zone_name in camera_config.zones:
|
||||
formatted_zones.append(
|
||||
camera_config.zones[zone_name].get_formatted_name(zone_name)
|
||||
)
|
||||
|
||||
analytics_data = {
|
||||
"id": final_data["id"],
|
||||
"camera": camera,
|
||||
"zones": final_data["data"]["zones"],
|
||||
"camera": camera_config.get_formatted_name(),
|
||||
"zones": formatted_zones,
|
||||
"start": datetime.datetime.fromtimestamp(final_data["start_time"]).strftime(
|
||||
"%A, %I:%M %p"
|
||||
),
|
||||
@ -458,7 +480,6 @@ def run_analysis(
|
||||
genai_config.preferred_language,
|
||||
genai_config.debug_save_thumbnails,
|
||||
genai_config.activity_context_prompt,
|
||||
genai_config.camera_context,
|
||||
)
|
||||
review_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
|
||||
@ -227,6 +227,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
self.tensor_output_details[0]["index"]
|
||||
)[0]
|
||||
probs = res / res.sum(axis=0)
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran state classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
@ -418,8 +421,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
obj_data["box"][2],
|
||||
obj_data["box"][3],
|
||||
max(
|
||||
obj_data["box"][1] - obj_data["box"][0],
|
||||
obj_data["box"][3] - obj_data["box"][2],
|
||||
obj_data["box"][2] - obj_data["box"][0],
|
||||
obj_data["box"][3] - obj_data["box"][1],
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
@ -455,6 +458,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
self.tensor_output_details[0]["index"]
|
||||
)[0]
|
||||
probs = res / res.sum(axis=0)
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran object classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
@ -546,5 +552,8 @@ def write_classification_attempt(
|
||||
)
|
||||
|
||||
# delete oldest face image if maximum is reached
|
||||
if len(files) > max_files:
|
||||
os.unlink(os.path.join(folder, files[-1]))
|
||||
try:
|
||||
if len(files) > max_files:
|
||||
os.unlink(os.path.join(folder, files[-1]))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@ -423,7 +423,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
res = self.recognizer.classify(img)
|
||||
|
||||
if not res:
|
||||
return
|
||||
return {
|
||||
"message": "No face was recognized.",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
sub_label, score = res
|
||||
|
||||
@ -442,6 +445,13 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
shutil.move(current_file, new_file)
|
||||
|
||||
return {
|
||||
"message": f"Successfully reprocessed face. Result: {sub_label} (score: {score:.2f})",
|
||||
"success": True,
|
||||
"face_name": sub_label,
|
||||
"score": score,
|
||||
}
|
||||
|
||||
def expire_object(self, object_id: str, camera: str):
|
||||
if object_id in self.person_face_history:
|
||||
self.person_face_history.pop(object_id)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
@ -161,12 +162,12 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_complex_model(model_type: str) -> bool:
|
||||
def is_model_supported(model_type: str) -> bool:
|
||||
# Import here to avoid circular imports
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
return model_type in [
|
||||
return model_type not in [
|
||||
ModelTypeEnum.yolonas.value,
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v1.value,
|
||||
@ -239,9 +240,31 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
EnrichmentModelTypeEnum.jina_v2.value,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def is_model_npu_supported(model_type: str) -> bool:
|
||||
# Import here to avoid circular imports
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
return model_type not in [
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v1.value,
|
||||
EnrichmentModelTypeEnum.jina_v2.value,
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
]
|
||||
|
||||
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||
self.model_path = model_path
|
||||
self.device = device
|
||||
self.model_type = model_type
|
||||
|
||||
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
|
||||
model_type
|
||||
):
|
||||
logger.warning(
|
||||
f"OpenVINO model {model_type} is not supported on NPU, using GPU instead"
|
||||
)
|
||||
device = "GPU"
|
||||
|
||||
self.complex_model = OpenVINOModelRunner.is_complex_model(model_type)
|
||||
|
||||
if not os.path.isfile(model_path):
|
||||
@ -269,6 +292,10 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
self.infer_request = self.compiled_model.create_infer_request()
|
||||
self.input_tensor: ov.Tensor | None = None
|
||||
|
||||
# Thread lock to prevent concurrent inference (needed for JinaV2 which shares
|
||||
# one runner between text and vision embeddings called from different threads)
|
||||
self._inference_lock = threading.Lock()
|
||||
|
||||
if not self.complex_model:
|
||||
try:
|
||||
input_shape = self.compiled_model.inputs[0].get_shape()
|
||||
@ -312,67 +339,81 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
Returns:
|
||||
List of output tensors
|
||||
"""
|
||||
# Handle single input case for backward compatibility
|
||||
if (
|
||||
len(inputs) == 1
|
||||
and len(self.compiled_model.inputs) == 1
|
||||
and self.input_tensor is not None
|
||||
):
|
||||
# Single input case - use the pre-allocated tensor for efficiency
|
||||
input_data = list(inputs.values())[0]
|
||||
np.copyto(self.input_tensor.data, input_data)
|
||||
self.infer_request.infer(self.input_tensor)
|
||||
else:
|
||||
if self.complex_model:
|
||||
# Lock prevents concurrent access to infer_request
|
||||
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
|
||||
with self._inference_lock:
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
if self.model_type in [EnrichmentModelTypeEnum.arcface.value]:
|
||||
# For face recognition models, create a fresh infer_request
|
||||
# for each inference to avoid state pollution that causes incorrect results.
|
||||
self.infer_request = self.compiled_model.create_infer_request()
|
||||
|
||||
# Handle single input case for backward compatibility
|
||||
if (
|
||||
len(inputs) == 1
|
||||
and len(self.compiled_model.inputs) == 1
|
||||
and self.input_tensor is not None
|
||||
):
|
||||
# Single input case - use the pre-allocated tensor for efficiency
|
||||
input_data = list(inputs.values())[0]
|
||||
np.copyto(self.input_tensor.data, input_data)
|
||||
self.infer_request.infer(self.input_tensor)
|
||||
else:
|
||||
if self.complex_model:
|
||||
try:
|
||||
# This ensures the model starts with a clean state for each sequence
|
||||
# Important for RNN models like PaddleOCR recognition
|
||||
self.infer_request.reset_state()
|
||||
except Exception:
|
||||
# this will raise an exception for models with AUTO set as the device
|
||||
pass
|
||||
|
||||
# Multiple inputs case - set each input by name
|
||||
for input_name, input_data in inputs.items():
|
||||
# Find the input by name and its index
|
||||
input_port = None
|
||||
input_index = None
|
||||
for idx, port in enumerate(self.compiled_model.inputs):
|
||||
if port.get_any_name() == input_name:
|
||||
input_port = port
|
||||
input_index = idx
|
||||
break
|
||||
|
||||
if input_port is None:
|
||||
raise ValueError(f"Input '{input_name}' not found in model")
|
||||
|
||||
# Create tensor with the correct element type
|
||||
input_element_type = input_port.get_element_type()
|
||||
|
||||
# Ensure input data matches the expected dtype to prevent type mismatches
|
||||
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
||||
expected_dtype = input_element_type.to_dtype()
|
||||
if input_data.dtype != expected_dtype:
|
||||
logger.debug(
|
||||
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
||||
)
|
||||
input_data = input_data.astype(expected_dtype)
|
||||
|
||||
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
||||
np.copyto(input_tensor.data, input_data)
|
||||
|
||||
# Set the input tensor for the specific port index
|
||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||
|
||||
# Run inference
|
||||
try:
|
||||
# This ensures the model starts with a clean state for each sequence
|
||||
# Important for RNN models like PaddleOCR recognition
|
||||
self.infer_request.reset_state()
|
||||
except Exception:
|
||||
# this will raise an exception for models with AUTO set as the device
|
||||
pass
|
||||
self.infer_request.infer()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during OpenVINO inference: {e}")
|
||||
return []
|
||||
|
||||
# Multiple inputs case - set each input by name
|
||||
for input_name, input_data in inputs.items():
|
||||
# Find the input by name and its index
|
||||
input_port = None
|
||||
input_index = None
|
||||
for idx, port in enumerate(self.compiled_model.inputs):
|
||||
if port.get_any_name() == input_name:
|
||||
input_port = port
|
||||
input_index = idx
|
||||
break
|
||||
# Get all output tensors
|
||||
outputs = []
|
||||
for i in range(len(self.compiled_model.outputs)):
|
||||
outputs.append(self.infer_request.get_output_tensor(i).data)
|
||||
|
||||
if input_port is None:
|
||||
raise ValueError(f"Input '{input_name}' not found in model")
|
||||
|
||||
# Create tensor with the correct element type
|
||||
input_element_type = input_port.get_element_type()
|
||||
|
||||
# Ensure input data matches the expected dtype to prevent type mismatches
|
||||
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
||||
expected_dtype = input_element_type.to_dtype()
|
||||
if input_data.dtype != expected_dtype:
|
||||
logger.debug(
|
||||
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
||||
)
|
||||
input_data = input_data.astype(expected_dtype)
|
||||
|
||||
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
||||
np.copyto(input_tensor.data, input_data)
|
||||
|
||||
# Set the input tensor for the specific port index
|
||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||
|
||||
# Run inference
|
||||
self.infer_request.infer()
|
||||
|
||||
# Get all output tensors
|
||||
outputs = []
|
||||
for i in range(len(self.compiled_model.outputs)):
|
||||
outputs.append(self.infer_request.get_output_tensor(i).data)
|
||||
|
||||
return outputs
|
||||
return outputs
|
||||
|
||||
|
||||
class RKNNModelRunner(BaseModelRunner):
|
||||
@ -500,7 +541,7 @@ def get_optimized_runner(
|
||||
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
||||
|
||||
if (
|
||||
not CudaGraphRunner.is_complex_model(model_type)
|
||||
CudaGraphRunner.is_model_supported(model_type)
|
||||
and providers[0] == "CUDAExecutionProvider"
|
||||
):
|
||||
options[0] = {
|
||||
|
||||
@ -472,7 +472,7 @@ class Embeddings:
|
||||
)
|
||||
thumbnail_missing = True
|
||||
except DoesNotExist:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
|
||||
)
|
||||
continue
|
||||
|
||||
@ -226,7 +226,9 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
for c in self.config.cameras.values()
|
||||
):
|
||||
self.post_processors.append(
|
||||
AudioTranscriptionPostProcessor(self.config, self.requestor, metrics)
|
||||
AudioTranscriptionPostProcessor(
|
||||
self.config, self.requestor, self.embeddings, metrics
|
||||
)
|
||||
)
|
||||
|
||||
semantic_trigger_processor: SemanticTriggerProcessor | None = None
|
||||
|
||||
@ -45,15 +45,13 @@ class GenAIClient:
|
||||
preferred_language: str | None,
|
||||
debug_save: bool,
|
||||
activity_context_prompt: str,
|
||||
camera_context: str = "",
|
||||
) -> ReviewMetadata | None:
|
||||
"""Generate a description for the review item activity."""
|
||||
|
||||
def get_concern_prompt() -> str:
|
||||
if concerns:
|
||||
concern_list = "\n - ".join(concerns)
|
||||
return f"""
|
||||
- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
||||
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
||||
- {concern_list}"""
|
||||
else:
|
||||
return ""
|
||||
@ -70,25 +68,13 @@ class GenAIClient:
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
def get_camera_context_section() -> str:
|
||||
if camera_context:
|
||||
return f"""## Camera Spatial Context
|
||||
|
||||
Use this spatial information when writing the title and scene description to provide more accurate context about where activity is occurring or where people/objects are moving to/from.
|
||||
|
||||
{camera_context}"""
|
||||
return ""
|
||||
|
||||
camera_context_section = get_camera_context_section()
|
||||
context_prompt = f"""
|
||||
Your task is to analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"].replace("_", " ")} security camera.
|
||||
Your task is to analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"]} security camera.
|
||||
|
||||
## Normal Activity Patterns for This Property
|
||||
|
||||
{activity_context_prompt}
|
||||
|
||||
{camera_context_section}
|
||||
|
||||
## Task Instructions
|
||||
|
||||
Your task is to provide a clear, accurate description of the scene that:
|
||||
@ -113,8 +99,8 @@ When forming your description:
|
||||
## Response Format
|
||||
|
||||
Your response MUST be a flat JSON object with:
|
||||
- `title` (string): A concise, direct title that describes the purpose or overall action, not just what you literally see. {"Use spatial context when available to make titles more meaningful." if camera_context_section else ""} Use names from "Objects in Scene" based on what you visually observe. If you see both a name and an unidentified object of the same type but visually observe only one person/object, use ONLY the name. Examples: "Joe walking dog", "Person taking out trash", "Joe accessing vehicle", "Person leaving porch for driveway", "Joe and person on front porch".
|
||||
- `scene` (string): A narrative description of what happens across the sequence from start to finish. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign.
|
||||
- `title` (string): A concise, direct title that describes the primary action or event in the sequence, not just what you literally see. Use spatial context when available to make titles more meaningful. When multiple objects/actions are present, prioritize whichever is most prominent or occurs first. Use names from "Objects in Scene" based on what you visually observe. If you see both a name and an unidentified object of the same type but visually observe only one person/object, use ONLY the name. Examples: "Joe walking dog", "Person taking out trash", "Vehicle arriving in driveway", "Joe accessing vehicle", "Person leaving porch for driveway".
|
||||
- `scene` (string): A narrative description of what happens across the sequence from start to finish, in chronological order. Start by describing how the sequence begins, then describe the progression of events. **Describe all significant movements and actions in the order they occur.** For example, if a vehicle arrives and then a person exits, describe both actions sequentially. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign.
|
||||
- `confidence` (float): 0-1 confidence in your analysis. Higher confidence when objects/actions are clearly visible and context is unambiguous. Lower confidence when the sequence is unclear, objects are partially obscured, or context is ambiguous.
|
||||
- `potential_threat_level` (integer): 0, 1, or 2 as defined in "Normal Activity Patterns for This Property" above. Your threat level must be consistent with your scene description and the guidance above.
|
||||
{get_concern_prompt()}
|
||||
@ -123,7 +109,7 @@ Your response MUST be a flat JSON object with:
|
||||
|
||||
- Frame 1 = earliest, Frame {len(thumbnails)} = latest
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(z.replace("_", " ").title() for z in review_data["zones"]) or "None"}
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
|
||||
@ -407,6 +407,19 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment.last_detection_time = frame_time
|
||||
|
||||
for object in activity.get_all_objects():
|
||||
# Alert-level objects should always be added (they extend/upgrade the segment)
|
||||
# Detection-level objects should only be added if:
|
||||
# - The segment is a detection segment (matching severity), OR
|
||||
# - The segment is an alert AND the object started before the alert ended
|
||||
# (objects starting after will be in the new detection segment)
|
||||
is_alert_object = object in activity.categorized_objects["alerts"]
|
||||
|
||||
if not is_alert_object and segment.severity == SeverityEnum.alert:
|
||||
# This is a detection-level object
|
||||
# Only add if it started during the alert's active period
|
||||
if object["start_time"] > segment.last_alert_time:
|
||||
continue
|
||||
|
||||
if not object["sub_label"]:
|
||||
segment.detections[object["id"]] = object["label"]
|
||||
elif object["sub_label"][0] in self.config.model.all_attributes:
|
||||
|
||||
@ -23,6 +23,7 @@ class ModelStatusTypesEnum(str, Enum):
|
||||
error = "error"
|
||||
training = "training"
|
||||
complete = "complete"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class TrackedObjectUpdateTypesEnum(str, Enum):
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""Util for classification models."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
@ -27,10 +29,96 @@ from frigate.util.process import FrigateProcess
|
||||
BATCH_SIZE = 16
|
||||
EPOCHS = 50
|
||||
LEARNING_RATE = 0.001
|
||||
TRAINING_METADATA_FILE = ".training_metadata.json"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write_training_metadata(model_name: str, image_count: int) -> None:
|
||||
"""
|
||||
Write training metadata to a hidden file in the model's clips directory.
|
||||
|
||||
Args:
|
||||
model_name: Name of the classification model
|
||||
image_count: Number of images used in training
|
||||
"""
|
||||
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
||||
os.makedirs(clips_model_dir, exist_ok=True)
|
||||
|
||||
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
|
||||
metadata = {
|
||||
"last_training_date": datetime.datetime.now().isoformat(),
|
||||
"last_training_image_count": image_count,
|
||||
}
|
||||
|
||||
try:
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
logger.info(f"Wrote training metadata for {model_name}: {image_count} images")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write training metadata for {model_name}: {e}")
|
||||
|
||||
|
||||
def read_training_metadata(model_name: str) -> dict[str, any] | None:
|
||||
"""
|
||||
Read training metadata from the hidden file in the model's clips directory.
|
||||
|
||||
Args:
|
||||
model_name: Name of the classification model
|
||||
|
||||
Returns:
|
||||
Dictionary with last_training_date and last_training_image_count, or None if not found
|
||||
"""
|
||||
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
||||
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
|
||||
|
||||
if not os.path.exists(metadata_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path, "r") as f:
|
||||
metadata = json.load(f)
|
||||
return metadata
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read training metadata for {model_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_dataset_image_count(model_name: str) -> int:
|
||||
"""
|
||||
Count the total number of images in the model's dataset directory.
|
||||
|
||||
Args:
|
||||
model_name: Name of the classification model
|
||||
|
||||
Returns:
|
||||
Total count of images across all categories
|
||||
"""
|
||||
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
||||
|
||||
if not os.path.exists(dataset_dir):
|
||||
return 0
|
||||
|
||||
total_count = 0
|
||||
try:
|
||||
for category in os.listdir(dataset_dir):
|
||||
category_dir = os.path.join(dataset_dir, category)
|
||||
if not os.path.isdir(category_dir):
|
||||
continue
|
||||
|
||||
image_files = [
|
||||
f
|
||||
for f in os.listdir(category_dir)
|
||||
if f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))
|
||||
]
|
||||
total_count += len(image_files)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to count dataset images for {model_name}: {e}")
|
||||
return 0
|
||||
|
||||
return total_count
|
||||
|
||||
|
||||
class ClassificationTrainingProcess(FrigateProcess):
|
||||
def __init__(self, model_name: str) -> None:
|
||||
super().__init__(
|
||||
@ -42,7 +130,8 @@ class ClassificationTrainingProcess(FrigateProcess):
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup()
|
||||
self.__train_classification_model()
|
||||
success = self.__train_classification_model()
|
||||
exit(0 if success else 1)
|
||||
|
||||
def __generate_representative_dataset_factory(self, dataset_dir: str):
|
||||
def generate_representative_dataset():
|
||||
@ -65,85 +154,117 @@ class ClassificationTrainingProcess(FrigateProcess):
|
||||
@redirect_output_to_logger(logger, logging.DEBUG)
|
||||
def __train_classification_model(self) -> bool:
|
||||
"""Train a classification model."""
|
||||
try:
|
||||
# import in the function so that tensorflow is not initialized multiple times
|
||||
import tensorflow as tf
|
||||
from tensorflow.keras import layers, models, optimizers
|
||||
from tensorflow.keras.applications import MobileNetV2
|
||||
from tensorflow.keras.preprocessing.image import ImageDataGenerator
|
||||
|
||||
# import in the function so that tensorflow is not initialized multiple times
|
||||
import tensorflow as tf
|
||||
from tensorflow.keras import layers, models, optimizers
|
||||
from tensorflow.keras.applications import MobileNetV2
|
||||
from tensorflow.keras.preprocessing.image import ImageDataGenerator
|
||||
dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset")
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
|
||||
logger.info(f"Kicking off classification training for {self.model_name}.")
|
||||
dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset")
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
num_classes = len(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(dataset_dir)
|
||||
if os.path.isdir(os.path.join(dataset_dir, d))
|
||||
]
|
||||
)
|
||||
num_classes = len(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(dataset_dir)
|
||||
if os.path.isdir(os.path.join(dataset_dir, d))
|
||||
]
|
||||
)
|
||||
|
||||
# Start with imagenet base model with 35% of channels in each layer
|
||||
base_model = MobileNetV2(
|
||||
input_shape=(224, 224, 3),
|
||||
include_top=False,
|
||||
weights="imagenet",
|
||||
alpha=0.35,
|
||||
)
|
||||
base_model.trainable = False # Freeze pre-trained layers
|
||||
if num_classes < 2:
|
||||
logger.error(
|
||||
f"Training failed for {self.model_name}: Need at least 2 classes, found {num_classes}"
|
||||
)
|
||||
return False
|
||||
|
||||
model = models.Sequential(
|
||||
[
|
||||
base_model,
|
||||
layers.GlobalAveragePooling2D(),
|
||||
layers.Dense(128, activation="relu"),
|
||||
layers.Dropout(0.3),
|
||||
layers.Dense(num_classes, activation="softmax"),
|
||||
]
|
||||
)
|
||||
# Start with imagenet base model with 35% of channels in each layer
|
||||
base_model = MobileNetV2(
|
||||
input_shape=(224, 224, 3),
|
||||
include_top=False,
|
||||
weights="imagenet",
|
||||
alpha=0.35,
|
||||
)
|
||||
base_model.trainable = False # Freeze pre-trained layers
|
||||
|
||||
model.compile(
|
||||
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE),
|
||||
loss="categorical_crossentropy",
|
||||
metrics=["accuracy"],
|
||||
)
|
||||
model = models.Sequential(
|
||||
[
|
||||
base_model,
|
||||
layers.GlobalAveragePooling2D(),
|
||||
layers.Dense(128, activation="relu"),
|
||||
layers.Dropout(0.3),
|
||||
layers.Dense(num_classes, activation="softmax"),
|
||||
]
|
||||
)
|
||||
|
||||
# create training set
|
||||
datagen = ImageDataGenerator(rescale=1.0 / 255, validation_split=0.2)
|
||||
train_gen = datagen.flow_from_directory(
|
||||
dataset_dir,
|
||||
target_size=(224, 224),
|
||||
batch_size=BATCH_SIZE,
|
||||
class_mode="categorical",
|
||||
subset="training",
|
||||
)
|
||||
model.compile(
|
||||
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE),
|
||||
loss="categorical_crossentropy",
|
||||
metrics=["accuracy"],
|
||||
)
|
||||
|
||||
# write labelmap
|
||||
class_indices = train_gen.class_indices
|
||||
index_to_class = {v: k for k, v in class_indices.items()}
|
||||
sorted_classes = [index_to_class[i] for i in range(len(index_to_class))]
|
||||
with open(os.path.join(model_dir, "labelmap.txt"), "w") as f:
|
||||
for class_name in sorted_classes:
|
||||
f.write(f"{class_name}\n")
|
||||
# create training set
|
||||
datagen = ImageDataGenerator(rescale=1.0 / 255, validation_split=0.2)
|
||||
train_gen = datagen.flow_from_directory(
|
||||
dataset_dir,
|
||||
target_size=(224, 224),
|
||||
batch_size=BATCH_SIZE,
|
||||
class_mode="categorical",
|
||||
subset="training",
|
||||
)
|
||||
|
||||
# train the model
|
||||
model.fit(train_gen, epochs=EPOCHS, verbose=0)
|
||||
total_images = train_gen.samples
|
||||
logger.debug(
|
||||
f"Training {self.model_name}: {total_images} images across {num_classes} classes"
|
||||
)
|
||||
|
||||
# convert model to tflite
|
||||
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
||||
converter.optimizations = [tf.lite.Optimize.DEFAULT]
|
||||
converter.representative_dataset = (
|
||||
self.__generate_representative_dataset_factory(dataset_dir)
|
||||
)
|
||||
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
|
||||
converter.inference_input_type = tf.uint8
|
||||
converter.inference_output_type = tf.uint8
|
||||
tflite_model = converter.convert()
|
||||
# write labelmap
|
||||
class_indices = train_gen.class_indices
|
||||
index_to_class = {v: k for k, v in class_indices.items()}
|
||||
sorted_classes = [index_to_class[i] for i in range(len(index_to_class))]
|
||||
with open(os.path.join(model_dir, "labelmap.txt"), "w") as f:
|
||||
for class_name in sorted_classes:
|
||||
f.write(f"{class_name}\n")
|
||||
|
||||
# write model
|
||||
with open(os.path.join(model_dir, "model.tflite"), "wb") as f:
|
||||
f.write(tflite_model)
|
||||
# train the model
|
||||
logger.debug(f"Training {self.model_name} for {EPOCHS} epochs...")
|
||||
model.fit(train_gen, epochs=EPOCHS, verbose=0)
|
||||
logger.debug(f"Converting {self.model_name} to TFLite...")
|
||||
|
||||
# convert model to tflite
|
||||
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
||||
converter.optimizations = [tf.lite.Optimize.DEFAULT]
|
||||
converter.representative_dataset = (
|
||||
self.__generate_representative_dataset_factory(dataset_dir)
|
||||
)
|
||||
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
|
||||
converter.inference_input_type = tf.uint8
|
||||
converter.inference_output_type = tf.uint8
|
||||
tflite_model = converter.convert()
|
||||
|
||||
# write model
|
||||
model_path = os.path.join(model_dir, "model.tflite")
|
||||
with open(model_path, "wb") as f:
|
||||
f.write(tflite_model)
|
||||
|
||||
# verify model file was written successfully
|
||||
if not os.path.exists(model_path) or os.path.getsize(model_path) == 0:
|
||||
logger.error(
|
||||
f"Training failed for {self.model_name}: Model file was not created or is empty"
|
||||
)
|
||||
return False
|
||||
|
||||
# write training metadata with image count
|
||||
dataset_image_count = get_dataset_image_count(self.model_name)
|
||||
write_training_metadata(self.model_name, dataset_image_count)
|
||||
|
||||
logger.info(f"Finished training {self.model_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Training failed for {self.model_name}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def kickoff_model_training(
|
||||
@ -165,18 +286,36 @@ def kickoff_model_training(
|
||||
training_process.start()
|
||||
training_process.join()
|
||||
|
||||
# reload model and mark training as complete
|
||||
embeddingRequestor.send_data(
|
||||
EmbeddingsRequestEnum.reload_classification_model.value,
|
||||
{"model_name": model_name},
|
||||
)
|
||||
requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
{
|
||||
"model": model_name,
|
||||
"state": ModelStatusTypesEnum.complete,
|
||||
},
|
||||
)
|
||||
# check if training succeeded by examining the exit code
|
||||
training_success = training_process.exitcode == 0
|
||||
|
||||
if training_success:
|
||||
# reload model and mark training as complete
|
||||
embeddingRequestor.send_data(
|
||||
EmbeddingsRequestEnum.reload_classification_model.value,
|
||||
{"model_name": model_name},
|
||||
)
|
||||
requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
{
|
||||
"model": model_name,
|
||||
"state": ModelStatusTypesEnum.complete,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Training subprocess failed for {model_name} (exit code: {training_process.exitcode})"
|
||||
)
|
||||
# mark training as failed so UI shows error state
|
||||
# don't reload the model since it failed
|
||||
requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
{
|
||||
"model": model_name,
|
||||
"state": ModelStatusTypesEnum.failed,
|
||||
},
|
||||
)
|
||||
|
||||
requestor.stop()
|
||||
|
||||
|
||||
|
||||
@ -369,6 +369,10 @@ def get_ort_providers(
|
||||
"enable_cpu_mem_arena": False,
|
||||
}
|
||||
)
|
||||
elif provider == "AzureExecutionProvider":
|
||||
# Skip Azure provider - not typically available on local hardware
|
||||
# and prevents fallback to OpenVINO when it's the first provider
|
||||
continue
|
||||
else:
|
||||
providers.append(provider)
|
||||
options.append({})
|
||||
|
||||
479
web/package-lock.json
generated
479
web/package-lock.json
generated
@ -95,7 +95,7 @@
|
||||
"@types/react-icons": "^3.0.0",
|
||||
"@types/react-transition-group": "^4.4.10",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
@ -701,16 +701,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
||||
"integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": "^3.3.0"
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
|
||||
}
|
||||
@ -3956,88 +3960,119 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.12.0.tgz",
|
||||
"integrity": "sha512-7F91fcbuDf/d3S8o21+r3ZncGIke/+eWk0EpO21LXhDfLahriZF9CGj4fbAetEjlaBdjdSm9a6VeXbpbT6Z40Q==",
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
|
||||
"integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "7.12.0",
|
||||
"@typescript-eslint/type-utils": "7.12.0",
|
||||
"@typescript-eslint/utils": "7.12.0",
|
||||
"@typescript-eslint/visitor-keys": "7.12.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/type-utils": "8.46.2",
|
||||
"@typescript-eslint/utils": "8.46.2",
|
||||
"@typescript-eslint/visitor-keys": "8.46.2",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.3.1",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^1.3.0"
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || >=20.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.56.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.12.0.tgz",
|
||||
"integrity": "sha512-lib96tyRtMhLxwauDWUp/uW3FMhLA6D0rJ8T7HmH7x23Gk1Gwwu8UZ94NMXBvOELn6flSPiBrCKlehkiXyaqwA==",
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
|
||||
"integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/typescript-estree": "7.12.0",
|
||||
"@typescript-eslint/utils": "7.12.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^1.3.0"
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"@typescript-eslint/visitor-keys": "8.46.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || >=20.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.56.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.12.0.tgz",
|
||||
"integrity": "sha512-Y6hhwxwDx41HNpjuYswYp6gDbkiZ8Hin9Bf5aJQn1bpTs3afYY4GX+MPYxma8jtoIV2GRwTM/UJm/2uGCVv+DQ==",
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
|
||||
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
|
||||
"integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"@typescript-eslint/scope-manager": "7.12.0",
|
||||
"@typescript-eslint/types": "7.12.0",
|
||||
"@typescript-eslint/typescript-estree": "7.12.0"
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || >=20.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.56.0"
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
@ -4069,6 +4104,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz",
|
||||
"integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.2",
|
||||
"@typescript-eslint/types": "^8.46.2",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
|
||||
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.12.0.tgz",
|
||||
@ -4087,6 +4158,161 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz",
|
||||
"integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz",
|
||||
"integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"@typescript-eslint/typescript-estree": "8.46.2",
|
||||
"@typescript-eslint/utils": "8.46.2",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
|
||||
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
|
||||
"integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.46.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"@typescript-eslint/visitor-keys": "8.46.2",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
|
||||
"integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.12.0.tgz",
|
||||
@ -4156,6 +4382,161 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz",
|
||||
"integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"@typescript-eslint/typescript-estree": "8.46.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
|
||||
"integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"@typescript-eslint/visitor-keys": "8.46.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
|
||||
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
|
||||
"integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.46.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"@typescript-eslint/visitor-keys": "8.46.2",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
|
||||
"integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.12.0.tgz",
|
||||
|
||||
@ -101,7 +101,7 @@
|
||||
"@types/react-icons": "^3.0.0",
|
||||
"@types/react-transition-group": "^4.4.10",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
|
||||
@ -72,7 +72,10 @@
|
||||
"formattedTimestampFilename": {
|
||||
"12hour": "MM-dd-yy-h-mm-ss-a",
|
||||
"24hour": "MM-dd-yy-HH-mm-ss"
|
||||
}
|
||||
},
|
||||
"inProgress": "In progress",
|
||||
"invalidStartTime": "Invalid start time",
|
||||
"invalidEndTime": "Invalid end time"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
@ -96,7 +99,9 @@
|
||||
"back": "Go back",
|
||||
"hide": "Hide {{item}}",
|
||||
"show": "Show {{item}}",
|
||||
"ID": "ID"
|
||||
"ID": "ID",
|
||||
"none": "None",
|
||||
"all": "All"
|
||||
},
|
||||
"list": {
|
||||
"two": "{{0}} and {{1}}",
|
||||
@ -142,7 +147,8 @@
|
||||
"unselect": "Unselect",
|
||||
"export": "Export",
|
||||
"deleteNow": "Delete Now",
|
||||
"next": "Next"
|
||||
"next": "Next",
|
||||
"continue": "Continue"
|
||||
},
|
||||
"menu": {
|
||||
"system": "System",
|
||||
@ -235,6 +241,7 @@
|
||||
"export": "Export",
|
||||
"uiPlayground": "UI Playground",
|
||||
"faceLibrary": "Face Library",
|
||||
"classification": "Classification",
|
||||
"user": {
|
||||
"title": "User",
|
||||
"account": "Account",
|
||||
|
||||
@ -67,9 +67,6 @@
|
||||
},
|
||||
"activity_context_prompt": {
|
||||
"label": "Custom activity context prompt defining normal activity patterns for this property."
|
||||
},
|
||||
"camera_context": {
|
||||
"label": "Spatial context about the camera's field of view to help with descriptive accuracy. Should describe physical features and locations outside the frame. This is for spatial reference only and should NOT include subjective assessments."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,12 @@
|
||||
"deleteModels": "Delete Models",
|
||||
"editModel": "Edit Model"
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "Model is currently training",
|
||||
"noNewImages": "No new images to train. Classify more images in the dataset first.",
|
||||
"noChanges": "No changes to the dataset since last training.",
|
||||
"modelNotReady": "Model is not ready for training"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Deleted Class",
|
||||
@ -22,20 +28,25 @@
|
||||
"categorizedImage": "Successfully Classified Image",
|
||||
"trainedModel": "Successfully trained model.",
|
||||
"trainingModel": "Successfully started model training.",
|
||||
"updatedModel": "Successfully updated model configuration"
|
||||
"updatedModel": "Successfully updated model configuration",
|
||||
"renamedCategory": "Successfully renamed class to {{name}}"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||
"trainingFailed": "Failed to start model training: {{errorMessage}}",
|
||||
"updateModelFailed": "Failed to update model: {{errorMessage}}"
|
||||
"trainingFailed": "Model training failed. Check Frigate logs for details.",
|
||||
"trainingFailedToStart": "Failed to start model training: {{errorMessage}}",
|
||||
"updateModelFailed": "Failed to update model: {{errorMessage}}",
|
||||
"renameCategoryFailed": "Failed to rename class: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
"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.",
|
||||
"minClassesTitle": "Cannot Delete Class",
|
||||
"minClassesDesc": "A classification model must have at least 2 classes. Add another class before deleting this one."
|
||||
},
|
||||
"deleteModel": {
|
||||
"title": "Delete Classification Model",
|
||||
@ -141,6 +152,8 @@
|
||||
"step3": {
|
||||
"selectImagesPrompt": "Select all images with: {{className}}",
|
||||
"selectImagesDescription": "Click on images to select them. Click Continue when you're done with this class.",
|
||||
"allImagesRequired_one": "Please classify all images. {{count}} image remaining.",
|
||||
"allImagesRequired_other": "Please classify all images. {{count}} images remaining.",
|
||||
"generating": {
|
||||
"title": "Generating Sample Images",
|
||||
"description": "Frigate is pulling representative images from your recordings. This may take a moment..."
|
||||
|
||||
@ -24,8 +24,8 @@
|
||||
"label": "Detail",
|
||||
"noDataFound": "No detail data to review",
|
||||
"aria": "Toggle detail view",
|
||||
"trackedObject_one": "object",
|
||||
"trackedObject_other": "objects",
|
||||
"trackedObject_one": "{{count}} object",
|
||||
"trackedObject_other": "{{count}} objects",
|
||||
"noObjectDetailData": "No object detail data available.",
|
||||
"settings": "Detail View Settings",
|
||||
"alwaysExpandActive": {
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
"snapshot": "snapshot",
|
||||
"thumbnail": "thumbnail",
|
||||
"video": "video",
|
||||
"object_lifecycle": "object lifecycle"
|
||||
"tracking_details": "tracking details"
|
||||
},
|
||||
"trackingDetails": {
|
||||
"title": "Tracking Details",
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
"deletedName_other": "{{count}} faces have been successfully deleted.",
|
||||
"renamedFace": "Successfully renamed face to {{name}}",
|
||||
"trainedFace": "Successfully trained face.",
|
||||
"updatedFaceScore": "Successfully updated face score."
|
||||
"updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})."
|
||||
},
|
||||
"error": {
|
||||
"uploadingImageFailed": "Failed to upload image: {{errorMessage}}",
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"masksAndZones": "Mask and Zone Editor - Frigate",
|
||||
"motionTuner": "Motion Tuner - Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
"general": "General Settings - Frigate",
|
||||
"general": "UI Settings - Frigate",
|
||||
"frigatePlus": "Frigate+ Settings - Frigate",
|
||||
"notifications": "Notification Settings - Frigate"
|
||||
},
|
||||
@ -37,7 +37,7 @@
|
||||
"noCamera": "No Camera"
|
||||
},
|
||||
"general": {
|
||||
"title": "General Settings",
|
||||
"title": "UI Settings",
|
||||
"liveDashboard": {
|
||||
"title": "Live Dashboard",
|
||||
"automaticLiveView": {
|
||||
@ -51,6 +51,10 @@
|
||||
"displayCameraNames": {
|
||||
"label": "Always Show Camera Names",
|
||||
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
||||
},
|
||||
"liveFallbackTimeout": {
|
||||
"label": "Live Player Fallback Timeout",
|
||||
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
||||
}
|
||||
},
|
||||
"storedLayouts": {
|
||||
@ -154,6 +158,7 @@
|
||||
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
||||
"steps": {
|
||||
"nameAndConnection": "Name & Connection",
|
||||
"probeOrSnapshot": "Probe or Snapshot",
|
||||
"streamConfiguration": "Stream Configuration",
|
||||
"validationAndTesting": "Validation & Testing"
|
||||
},
|
||||
@ -172,7 +177,7 @@
|
||||
"testFailed": "Stream test failed: {{error}}"
|
||||
},
|
||||
"step1": {
|
||||
"description": "Enter your camera details and test the connection.",
|
||||
"description": "Enter your camera details and choose to probe the camera or manually select the brand.",
|
||||
"cameraName": "Camera Name",
|
||||
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||
"host": "Host/IP Address",
|
||||
@ -188,33 +193,65 @@
|
||||
"brandInformation": "Brand information",
|
||||
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
|
||||
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||
"testConnection": "Test Connection",
|
||||
"testSuccess": "Connection test successful!",
|
||||
"testFailed": "Connection test failed. Please check your input and try again.",
|
||||
"streamDetails": "Stream Details",
|
||||
"testing": {
|
||||
"probingMetadata": "Probing camera metadata...",
|
||||
"fetchingSnapshot": "Fetching camera snapshot..."
|
||||
},
|
||||
"warnings": {
|
||||
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
|
||||
},
|
||||
"connectionSettings": "Connection Settings",
|
||||
"detectionMethod": "Stream Detection Method",
|
||||
"onvifPort": "ONVIF Port",
|
||||
"probeMode": "Probe camera",
|
||||
"manualMode": "Manual selection",
|
||||
"detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".",
|
||||
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
|
||||
"useDigestAuth": "Use digest authentication",
|
||||
"useDigestAuthDescription": "Use HTTP digest authentication for ONVIF. Some cameras may require a dedicated ONVIF username/password instead of the standard admin user.",
|
||||
"errors": {
|
||||
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
||||
"nameRequired": "Camera name is required",
|
||||
"nameLength": "Camera name must be 64 characters or less",
|
||||
"invalidCharacters": "Camera name contains invalid characters",
|
||||
"nameExists": "Camera name already exists",
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams."
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"description": "Probe the camera for available streams or configure manual settings based on your selected detection method.",
|
||||
"testSuccess": "Connection test successful!",
|
||||
"testFailed": "Connection test failed. Please check your input and try again.",
|
||||
"testFailedTitle": "Test Failed",
|
||||
"streamDetails": "Stream Details",
|
||||
"probing": "Probing camera...",
|
||||
"retry": "Retry",
|
||||
"testing": {
|
||||
"probingMetadata": "Probing camera metadata...",
|
||||
"fetchingSnapshot": "Fetching camera snapshot..."
|
||||
},
|
||||
"probeFailed": "Failed to probe camera: {{error}}",
|
||||
"probingDevice": "Probing device...",
|
||||
"probeSuccessful": "Probe successful",
|
||||
"probeError": "Probe Error",
|
||||
"probeNoSuccess": "Probe unsuccessful",
|
||||
"deviceInfo": "Device Information",
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"firmware": "Firmware",
|
||||
"profiles": "Profiles",
|
||||
"ptzSupport": "PTZ Support",
|
||||
"autotrackingSupport": "Autotracking Support",
|
||||
"presets": "Presets",
|
||||
"rtspCandidates": "RTSP Candidates",
|
||||
"rtspCandidatesDescription": "The following RTSP URLs were found from the camera probe. Test the connection to view stream metadata.",
|
||||
"noRtspCandidates": "No RTSP URLs were found from the camera. Your credentials may be incorrect, or the camera may not support ONVIF or the method used to retrieve RTSP URLs. Go back and enter the RTSP URL manually.",
|
||||
"candidateStreamTitle": "Candidate {{number}}",
|
||||
"useCandidate": "Use",
|
||||
"uriCopy": "Copy",
|
||||
"uriCopied": "URI copied to clipboard",
|
||||
"testConnection": "Test Connection",
|
||||
"toggleUriView": "Click to toggle full URI view",
|
||||
"connected": "Connected",
|
||||
"notConnected": "Not Connected",
|
||||
"errors": {
|
||||
"hostRequired": "Host/IP address is required"
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"description": "Configure stream roles and add additional streams for your camera.",
|
||||
"streamsTitle": "Camera Streams",
|
||||
"addStream": "Add Stream",
|
||||
@ -222,6 +259,9 @@
|
||||
"streamTitle": "Stream {{number}}",
|
||||
"streamUrl": "Stream URL",
|
||||
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||
"selectStream": "Select a stream",
|
||||
"searchCandidates": "Search candidates...",
|
||||
"noStreamFound": "No stream found",
|
||||
"url": "URL",
|
||||
"resolution": "Resolution",
|
||||
"selectResolution": "Select resolution",
|
||||
@ -253,7 +293,7 @@
|
||||
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"step4": {
|
||||
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
|
||||
"validationTitle": "Stream Validation",
|
||||
"connectAllStreams": "Connect All Streams",
|
||||
@ -289,6 +329,9 @@
|
||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
|
||||
},
|
||||
"dahua": {
|
||||
"substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available."
|
||||
},
|
||||
|
||||
@ -2,12 +2,19 @@ import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
|
||||
|
||||
interface CameraNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
camera?: string | CameraConfig;
|
||||
}
|
||||
|
||||
interface ZoneNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
zone: string;
|
||||
camera?: string;
|
||||
}
|
||||
|
||||
const CameraNameLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
CameraNameLabelProps
|
||||
@ -21,4 +28,17 @@ const CameraNameLabel = React.forwardRef<
|
||||
});
|
||||
CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { CameraNameLabel };
|
||||
const ZoneNameLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
ZoneNameLabelProps
|
||||
>(({ className, zone, camera, ...props }, ref) => {
|
||||
const displayName = useZoneFriendlyName(zone, camera);
|
||||
return (
|
||||
<LabelPrimitive.Root ref={ref} className={className} {...props}>
|
||||
{displayName}
|
||||
</LabelPrimitive.Root>
|
||||
);
|
||||
});
|
||||
ZoneNameLabel.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { CameraNameLabel, ZoneNameLabel };
|
||||
@ -148,13 +148,13 @@ export const ClassificationCard = forwardRef<
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start text-white",
|
||||
data.score ? "text-xs" : "text-sm",
|
||||
data.score != undefined ? "text-xs" : "text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="smart-capitalize">
|
||||
{data.name == "unknown" ? t("details.unknown") : data.name}
|
||||
</div>
|
||||
{data.score && (
|
||||
{data.score != undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
|
||||
@ -14,7 +14,6 @@ type SearchThumbnailProps = {
|
||||
findSimilar: () => void;
|
||||
refreshResults: () => void;
|
||||
showTrackingDetails: () => void;
|
||||
showSnapshot: () => void;
|
||||
addTrigger: () => void;
|
||||
};
|
||||
|
||||
@ -24,7 +23,6 @@ export default function SearchThumbnailFooter({
|
||||
findSimilar,
|
||||
refreshResults,
|
||||
showTrackingDetails,
|
||||
showSnapshot,
|
||||
addTrigger,
|
||||
}: SearchThumbnailProps) {
|
||||
const { t } = useTranslation(["views/search"]);
|
||||
@ -62,7 +60,6 @@ export default function SearchThumbnailFooter({
|
||||
findSimilar={findSimilar}
|
||||
refreshResults={refreshResults}
|
||||
showTrackingDetails={showTrackingDetails}
|
||||
showSnapshot={showSnapshot}
|
||||
addTrigger={addTrigger}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
CustomClassificationModelConfig,
|
||||
FrigateConfig,
|
||||
} from "@/types/frigateConfig";
|
||||
import { ClassificationDatasetResponse } from "@/types/classification";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
@ -140,16 +141,19 @@ export default function ClassificationModelEditDialog({
|
||||
});
|
||||
|
||||
// Fetch dataset to get current classes for state models
|
||||
const { data: dataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
}>(isStateModel ? `classification/${model.name}/dataset` : null, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: dataset } = useSWR<ClassificationDatasetResponse>(
|
||||
isStateModel ? `classification/${model.name}/dataset` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Update form with classes from dataset when loaded
|
||||
useEffect(() => {
|
||||
if (isStateModel && dataset) {
|
||||
const classes = Object.keys(dataset).filter((key) => key !== "none");
|
||||
if (isStateModel && dataset?.categories) {
|
||||
const classes = Object.keys(dataset.categories).filter(
|
||||
(key) => key !== "none",
|
||||
);
|
||||
if (classes.length > 0) {
|
||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||
"classes",
|
||||
|
||||
@ -15,6 +15,7 @@ import Step3ChooseExamples, {
|
||||
} from "./wizard/Step3ChooseExamples";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import axios from "axios";
|
||||
|
||||
const OBJECT_STEPS = [
|
||||
"wizard.steps.nameAndDefine",
|
||||
@ -120,7 +121,18 @@ export default function ClassificationModelWizardDialog({
|
||||
dispatch({ type: "PREVIOUS_STEP" });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const handleCancel = async () => {
|
||||
// Clean up any generated training images if we're cancelling from Step 3
|
||||
if (wizardState.step1Data && wizardState.step3Data?.examplesGenerated) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/classification/${wizardState.step1Data.modelName}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Silently fail - user is already cancelling
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: "RESET" });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@ -10,6 +10,12 @@ import useSWR from "swr";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
export type Step3FormData = {
|
||||
examplesGenerated: boolean;
|
||||
@ -159,18 +165,15 @@ export default function Step3ChooseExamples({
|
||||
const isLastClass = currentClassIndex === allClasses.length - 1;
|
||||
|
||||
if (isLastClass) {
|
||||
// Assign remaining unclassified images
|
||||
unknownImages.slice(0, 24).forEach((imageName) => {
|
||||
if (!newClassifications[imageName]) {
|
||||
// For state models with 2 classes, assign to the last class
|
||||
// For object models, assign to "none"
|
||||
if (step1Data.modelType === "state" && allClasses.length === 2) {
|
||||
newClassifications[imageName] = allClasses[allClasses.length - 1];
|
||||
} else {
|
||||
// For object models, assign remaining unclassified images to "none"
|
||||
// For state models, this should never happen since we require all images to be classified
|
||||
if (step1Data.modelType !== "state") {
|
||||
unknownImages.slice(0, 24).forEach((imageName) => {
|
||||
if (!newClassifications[imageName]) {
|
||||
newClassifications[imageName] = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// All done, trigger training immediately
|
||||
setImageClassifications(newClassifications);
|
||||
@ -310,13 +313,44 @@ export default function Step3ChooseExamples({
|
||||
return images;
|
||||
}
|
||||
|
||||
return images.filter((img) => !imageClassifications[img]);
|
||||
}, [unknownImages, imageClassifications]);
|
||||
// If we're viewing a previous class (going back), show images for that class
|
||||
// Otherwise show only unclassified images
|
||||
const currentClassInView = allClasses[currentClassIndex];
|
||||
return images.filter((img) => {
|
||||
const imgClass = imageClassifications[img];
|
||||
// Show if: unclassified OR classified with current class we're viewing
|
||||
return !imgClass || imgClass === currentClassInView;
|
||||
});
|
||||
}, [unknownImages, imageClassifications, allClasses, currentClassIndex]);
|
||||
|
||||
const allImagesClassified = useMemo(() => {
|
||||
return unclassifiedImages.length === 0;
|
||||
}, [unclassifiedImages]);
|
||||
|
||||
// For state models on the last class, require all images to be classified
|
||||
const isLastClass = currentClassIndex === allClasses.length - 1;
|
||||
const canProceed = useMemo(() => {
|
||||
if (step1Data.modelType === "state" && isLastClass) {
|
||||
// Check if all 24 images will be classified after current selections are applied
|
||||
const totalImages = unknownImages.slice(0, 24).length;
|
||||
|
||||
// Count images that will be classified (either already classified or currently selected)
|
||||
const allImages = unknownImages.slice(0, 24);
|
||||
const willBeClassified = allImages.filter((img) => {
|
||||
return imageClassifications[img] || selectedImages.has(img);
|
||||
}).length;
|
||||
|
||||
return willBeClassified >= totalImages;
|
||||
}
|
||||
return true;
|
||||
}, [
|
||||
step1Data.modelType,
|
||||
isLastClass,
|
||||
unknownImages,
|
||||
imageClassifications,
|
||||
selectedImages,
|
||||
]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentClassIndex > 0) {
|
||||
const previousClass = allClasses[currentClassIndex - 1];
|
||||
@ -438,20 +472,35 @@ export default function Step3ChooseExamples({
|
||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={
|
||||
allImagesClassified
|
||||
? handleContinue
|
||||
: handleContinueClassification
|
||||
}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={!hasGenerated || isGenerating || isProcessing}
|
||||
>
|
||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={
|
||||
allImagesClassified
|
||||
? handleContinue
|
||||
: handleContinueClassification
|
||||
}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={
|
||||
!hasGenerated || isGenerating || isProcessing || !canProceed
|
||||
}
|
||||
>
|
||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canProceed && (
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("wizard.step3.allImagesRequired", {
|
||||
count: unclassifiedImages.length,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -76,7 +76,7 @@ import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
||||
import { DialogTrigger } from "@radix-ui/react-dialog";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||
|
||||
|
||||
@ -190,7 +190,7 @@ export function CamerasFilterContent({
|
||||
key={item}
|
||||
isChecked={currentCameras?.includes(item) ?? false}
|
||||
label={item}
|
||||
isCameraName={true}
|
||||
type={"camera"}
|
||||
disabled={
|
||||
mainCamera !== undefined &&
|
||||
currentCameras !== undefined &&
|
||||
|
||||
@ -1,29 +1,39 @@
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Label } from "../ui/label";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type FilterSwitchProps = {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
isChecked: boolean;
|
||||
isCameraName?: boolean;
|
||||
type?: string;
|
||||
extraValue?: string;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
};
|
||||
export default function FilterSwitch({
|
||||
label,
|
||||
disabled = false,
|
||||
isChecked,
|
||||
isCameraName = false,
|
||||
type = "",
|
||||
extraValue = "",
|
||||
onCheckedChange,
|
||||
}: FilterSwitchProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
{isCameraName ? (
|
||||
{type === "camera" ? (
|
||||
<CameraNameLabel
|
||||
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
|
||||
htmlFor={label}
|
||||
camera={label}
|
||||
/>
|
||||
) : type === "zone" ? (
|
||||
<ZoneNameLabel
|
||||
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
|
||||
htmlFor={label}
|
||||
camera={extraValue}
|
||||
zone={label}
|
||||
/>
|
||||
) : (
|
||||
<Label
|
||||
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`}
|
||||
|
||||
@ -454,6 +454,24 @@ export function GeneralFilterContent({
|
||||
onClose,
|
||||
}: GeneralFilterContentProps) {
|
||||
const { t } = useTranslation(["components/filter", "views/events"]);
|
||||
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 (
|
||||
<>
|
||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
||||
@ -504,7 +522,10 @@ export function GeneralFilterContent({
|
||||
{allLabels.map((item) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
label={getTranslatedLabel(item)}
|
||||
label={getTranslatedLabel(
|
||||
item,
|
||||
allAudioListenLabels.includes(item) ? "audio" : "object",
|
||||
)}
|
||||
isChecked={filter.labels?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
@ -550,7 +571,8 @@ export function GeneralFilterContent({
|
||||
{allZones.map((item) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
label={item.replaceAll("_", " ")}
|
||||
label={item}
|
||||
type={"zone"}
|
||||
isChecked={filter.zones?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
|
||||
@ -53,7 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { MdImageSearch } from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type InputWithTagsProps = {
|
||||
inputFocused: boolean;
|
||||
@ -81,6 +81,43 @@ export default function InputWithTags({
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const allAudioListenLabels = useMemo<Set<string>>(() => {
|
||||
if (!config) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
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;
|
||||
}, [config]);
|
||||
|
||||
const translatedAudioLabelMap = useMemo<Map<string, string>>(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (!config) return map;
|
||||
|
||||
allAudioListenLabels.forEach((label) => {
|
||||
// getTranslatedLabel likely depends on i18n internally; including `lang`
|
||||
// in deps ensures this map is rebuilt when language changes
|
||||
map.set(label, getTranslatedLabel(label, "audio"));
|
||||
});
|
||||
return map;
|
||||
}, [allAudioListenLabels, config]);
|
||||
|
||||
function resolveLabel(value: string) {
|
||||
const mapped = translatedAudioLabelMap.get(value);
|
||||
if (mapped) return mapped;
|
||||
return getTranslatedLabel(
|
||||
value,
|
||||
allAudioListenLabels.has(value) ? "audio" : "object",
|
||||
);
|
||||
}
|
||||
|
||||
const [inputValue, setInputValue] = useState(search || "");
|
||||
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
||||
null,
|
||||
@ -421,7 +458,8 @@ export default function InputWithTags({
|
||||
? t("button.yes", { ns: "common" })
|
||||
: t("button.no", { ns: "common" });
|
||||
} else if (filterType === "labels") {
|
||||
return getTranslatedLabel(String(filterValues));
|
||||
const value = String(filterValues);
|
||||
return resolveLabel(value);
|
||||
} else if (filterType === "search_type") {
|
||||
return t("filter.searchType." + String(filterValues));
|
||||
} else {
|
||||
@ -828,9 +866,11 @@ export default function InputWithTags({
|
||||
>
|
||||
{t("filter.label." + filterType)}:{" "}
|
||||
{filterType === "labels" ? (
|
||||
getTranslatedLabel(value)
|
||||
resolveLabel(value)
|
||||
) : filterType === "cameras" ? (
|
||||
<CameraNameLabel camera={value} />
|
||||
) : filterType === "zones" ? (
|
||||
<ZoneNameLabel zone={value} />
|
||||
) : (
|
||||
value.replaceAll("_", " ")
|
||||
)}
|
||||
@ -934,6 +974,11 @@ export default function InputWithTags({
|
||||
<CameraNameLabel camera={suggestion} />
|
||||
{")"}
|
||||
</>
|
||||
) : currentFilterType === "zones" ? (
|
||||
<>
|
||||
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
|
||||
{")"}
|
||||
</>
|
||||
) : (
|
||||
suggestion
|
||||
)
|
||||
@ -943,6 +988,8 @@ export default function InputWithTags({
|
||||
{currentFilterType ? (
|
||||
currentFilterType === "cameras" ? (
|
||||
<CameraNameLabel camera={suggestion} />
|
||||
) : currentFilterType === "zones" ? (
|
||||
<ZoneNameLabel zone={suggestion} />
|
||||
) : (
|
||||
formatFilterValues(currentFilterType, suggestion)
|
||||
)
|
||||
|
||||
@ -47,7 +47,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type LiveContextMenuProps = {
|
||||
className?: string;
|
||||
|
||||
@ -6,10 +6,7 @@ import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { FaArrowsRotate } from "react-icons/fa6";
|
||||
import { MdImageSearch } from "react-icons/md";
|
||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@ -33,23 +30,18 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { BsFillLightningFill } from "react-icons/bs";
|
||||
import BlurredIconButton from "../button/BlurredIconButton";
|
||||
import { PiPath } from "react-icons/pi";
|
||||
|
||||
type SearchResultActionsProps = {
|
||||
searchResult: SearchResult;
|
||||
findSimilar: () => void;
|
||||
refreshResults: () => void;
|
||||
showTrackingDetails: () => void;
|
||||
showSnapshot: () => void;
|
||||
addTrigger: () => void;
|
||||
isContextMenu?: boolean;
|
||||
children?: ReactNode;
|
||||
@ -60,7 +52,6 @@ export default function SearchResultActions({
|
||||
findSimilar,
|
||||
refreshResults,
|
||||
showTrackingDetails,
|
||||
showSnapshot,
|
||||
addTrigger,
|
||||
isContextMenu = false,
|
||||
children,
|
||||
@ -129,7 +120,7 @@ export default function SearchResultActions({
|
||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||
onClick={showTrackingDetails}
|
||||
>
|
||||
<FaArrowsRotate className="mr-2 size-4" />
|
||||
<PiPath className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
@ -152,18 +143,14 @@ export default function SearchResultActions({
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isMobileOnly &&
|
||||
config?.plus?.enabled &&
|
||||
searchResult.has_snapshot &&
|
||||
searchResult.end_time &&
|
||||
searchResult.data.type == "object" &&
|
||||
!searchResult.plus_id && (
|
||||
{config?.semantic_search?.enabled &&
|
||||
searchResult.data.type == "object" && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.submitToPlus.aria")}
|
||||
onClick={showSnapshot}
|
||||
aria-label={t("itemMenu.findSimilar.aria")}
|
||||
onClick={findSimilar}
|
||||
>
|
||||
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
|
||||
<span>{t("itemMenu.submitToPlus.label")}</span>
|
||||
<MdImageSearch className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
@ -211,44 +198,6 @@ export default function SearchResultActions({
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<>
|
||||
{config?.semantic_search?.enabled &&
|
||||
searchResult.data.type == "object" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={findSimilar}
|
||||
aria-label={t("itemMenu.findSimilar.aria")}
|
||||
>
|
||||
<MdImageSearch className="size-5" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isMobileOnly &&
|
||||
config?.plus?.enabled &&
|
||||
searchResult.has_snapshot &&
|
||||
searchResult.end_time &&
|
||||
searchResult.data.type == "object" &&
|
||||
!searchResult.plus_id && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={showSnapshot}
|
||||
aria-label={t("itemMenu.submitToPlus.aria")}
|
||||
>
|
||||
<FrigatePlusIcon className="size-5" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.submitToPlus.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<BlurredIconButton aria-label={t("itemMenu.more.aria")}>
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@ -121,17 +122,20 @@ export function MobilePagePortal({
|
||||
type MobilePageContentProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
scrollerRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function MobilePageContent({
|
||||
children,
|
||||
className,
|
||||
scrollerRef,
|
||||
}: MobilePageContentProps) {
|
||||
const context = useContext(MobilePageContext);
|
||||
if (!context)
|
||||
throw new Error("MobilePageContent must be used within MobilePage");
|
||||
|
||||
const [isVisible, setIsVisible] = useState(context.open);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (context.open) {
|
||||
@ -140,15 +144,27 @@ export function MobilePageContent({
|
||||
}, [context.open]);
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (!context.open) {
|
||||
if (context.open) {
|
||||
// After opening animation completes, ensure scroller is at the top
|
||||
if (scrollerRef?.current) {
|
||||
scrollerRef.current.scrollTop = 0;
|
||||
}
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (context.open && scrollerRef?.current) {
|
||||
scrollerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [context.open, scrollerRef]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 mb-12 bg-background",
|
||||
isPWA && "mb-16",
|
||||
|
||||
@ -97,14 +97,12 @@ export default function ClassificationSelectionDialog({
|
||||
|
||||
return (
|
||||
<div className={className ?? "flex"}>
|
||||
{newClass && (
|
||||
<TextEntryDialog
|
||||
open={true}
|
||||
setOpen={setNewClass}
|
||||
title={t("createCategory.new")}
|
||||
onSave={(newCat) => onCategorizeImage(newCat)}
|
||||
/>
|
||||
)}
|
||||
<TextEntryDialog
|
||||
open={newClass}
|
||||
setOpen={setNewClass}
|
||||
title={t("createCategory.new")}
|
||||
onSave={(newCat) => onCategorizeImage(newCat)}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<Selector>
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
||||
@ -159,7 +159,7 @@ export default function CreateTriggerDialog({
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (trigger) {
|
||||
if (trigger && existingTriggerNames.includes(trigger.name)) {
|
||||
onEdit({ ...values });
|
||||
} else {
|
||||
onCreate(
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
|
||||
type EditRoleCamerasOverlayProps = {
|
||||
show: boolean;
|
||||
|
||||
@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl",
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
|
||||
upperClassName,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
lowerClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -4,7 +4,7 @@ import { Button } from "../ui/button";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type MobileCameraDrawerProps = {
|
||||
allCameras: string[];
|
||||
|
||||
@ -12,6 +12,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
|
||||
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
|
||||
const TOLERANCE = 0.01;
|
||||
@ -114,6 +115,10 @@ export default function ObjectTrackOverlay({
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
|
||||
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
|
||||
};
|
||||
|
||||
const timelineResults = useMemo(() => {
|
||||
// Group timeline entries by source_id
|
||||
if (!timelineData) return selectedObjectIds.map(() => []);
|
||||
@ -127,8 +132,19 @@ export default function ObjectTrackOverlay({
|
||||
}
|
||||
|
||||
// Return timeline arrays in the same order as selectedObjectIds
|
||||
return selectedObjectIds.map((id) => grouped[id] || []);
|
||||
}, [selectedObjectIds, timelineData]);
|
||||
return selectedObjectIds.map((id) => {
|
||||
const entries = grouped[id] || [];
|
||||
return entries.map((event) => ({
|
||||
...event,
|
||||
data: {
|
||||
...event.data,
|
||||
zones_friendly_names: config
|
||||
? getZonesFriendlyNames(event.data?.zones, config)
|
||||
: [],
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, [selectedObjectIds, timelineData, config]);
|
||||
|
||||
const typeColorMap = useMemo(
|
||||
() => ({
|
||||
|
||||
@ -141,50 +141,52 @@ export function AnnotationSettingsPane({
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-1 flex-col space-y-6"
|
||||
className="flex flex-1 flex-col space-y-3"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="annotationOffset"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start justify-between space-x-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<FormLabel>
|
||||
{t("trackingDetails.annotationSettings.offset.label")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans ns="views/explore">
|
||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||
</Trans>
|
||||
<FormMessage />
|
||||
<div className="mt-2">
|
||||
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/reference")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<>
|
||||
<FormItem className="flex flex-row items-start justify-between space-x-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<FormLabel>
|
||||
{t("trackingDetails.annotationSettings.offset.label")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans ns="views/explore">
|
||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||
</Trans>
|
||||
<FormMessage />
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-24">
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="0"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-24">
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="0"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
<div className="mt-1 text-sm text-secondary-foreground">
|
||||
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||
<div className="mt-2 flex items-center text-primary-variant">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/reference")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@ -55,29 +55,32 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
|
||||
download={`${search.camera}_${search.label}.jpg`}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
||||
download
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
|
||||
download={`${search.camera}_${search.label}.jpg`}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{search.has_clip && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
||||
download
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{config?.semantic_search.enabled &&
|
||||
setSimilarity != undefined &&
|
||||
@ -111,6 +114,23 @@ export default function DetailActionsMenu({
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{config?.semantic_search.enabled && search.data.type == "object" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
navigate(
|
||||
`/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`,
|
||||
);
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -8,6 +8,9 @@ import {
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
|
||||
type ObjectPathProps = {
|
||||
positions?: Position[];
|
||||
@ -42,16 +45,31 @@ export function ObjectPath({
|
||||
visible = true,
|
||||
}: ObjectPathProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const getAbsolutePositions = useCallback(() => {
|
||||
if (!imgRef.current || !positions) return [];
|
||||
const imgRect = imgRef.current.getBoundingClientRect();
|
||||
return positions.map((pos) => ({
|
||||
x: pos.x * imgRect.width,
|
||||
y: pos.y * imgRect.height,
|
||||
timestamp: pos.timestamp,
|
||||
lifecycle_item: pos.lifecycle_item,
|
||||
}));
|
||||
}, [positions, imgRef]);
|
||||
return positions.map((pos) => {
|
||||
return {
|
||||
x: pos.x * imgRect.width,
|
||||
y: pos.y * imgRect.height,
|
||||
timestamp: pos.timestamp,
|
||||
lifecycle_item: pos.lifecycle_item?.data?.zones
|
||||
? {
|
||||
...pos.lifecycle_item,
|
||||
data: {
|
||||
...pos.lifecycle_item?.data,
|
||||
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
|
||||
(zone) => {
|
||||
return resolveZoneName(config, zone);
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
: pos.lifecycle_item,
|
||||
};
|
||||
});
|
||||
}, [imgRef, positions, config]);
|
||||
|
||||
const generateStraightPath = useCallback((points: Position[]) => {
|
||||
if (!points || points.length < 2) return "";
|
||||
|
||||
@ -34,9 +34,11 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaCheckCircle,
|
||||
FaChevronDown,
|
||||
FaChevronLeft,
|
||||
FaChevronRight,
|
||||
FaMicrophone,
|
||||
FaCheck,
|
||||
FaTimes,
|
||||
} from "react-icons/fa";
|
||||
import { TrackingDetails } from "./TrackingDetails";
|
||||
import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
|
||||
@ -72,7 +74,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { FaPencilAlt } from "react-icons/fa";
|
||||
@ -80,10 +87,11 @@ import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { DialogPortal } from "@radix-ui/react-dialog";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
import { HiSparkles } from "react-icons/hi";
|
||||
|
||||
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
@ -126,7 +134,7 @@ function TabsWithActions({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<ScrollArea className="flex-1 whitespace-nowrap">
|
||||
<div className="mb-2 flex flex-row md:mb-0">
|
||||
<div className="mb-2 flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
@ -224,6 +232,7 @@ function AnnotationSettings({
|
||||
const Overlay = isDesktop ? Popover : Drawer;
|
||||
const Trigger = isDesktop ? PopoverTrigger : DrawerTrigger;
|
||||
const Content = isDesktop ? PopoverContent : DrawerContent;
|
||||
const Title = isDesktop ? "div" : DrawerTitle;
|
||||
const contentProps = isDesktop
|
||||
? { align: "end" as const, container: container ?? undefined }
|
||||
: {};
|
||||
@ -248,7 +257,9 @@ function AnnotationSettings({
|
||||
<PiSlidersHorizontalBold className="size-5" />
|
||||
</Button>
|
||||
</Trigger>
|
||||
|
||||
<Title className="sr-only">
|
||||
{t("trackingDetails.adjustAnnotationSettings")}
|
||||
</Title>
|
||||
<Content
|
||||
className={
|
||||
isDesktop
|
||||
@ -306,7 +317,7 @@ function DialogContentComponent({
|
||||
if (page === "tracking_details") {
|
||||
return (
|
||||
<TrackingDetails
|
||||
className={cn("size-full", !isDesktop && "flex flex-col gap-4")}
|
||||
className={cn(isDesktop ? "size-full" : "flex flex-col gap-4")}
|
||||
event={search as unknown as Event}
|
||||
tabs={
|
||||
isDesktop ? (
|
||||
@ -340,7 +351,12 @@ function DialogContentComponent({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(!isDesktop ? "mb-4 w-full" : "size-full")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-lg",
|
||||
!isDesktop ? "mb-4 w-full" : "mx-auto size-full",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
className="w-full select-none rounded-lg object-contain transition-opacity"
|
||||
style={
|
||||
@ -359,16 +375,11 @@ function DialogContentComponent({
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className="flex h-full gap-4 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-container flex-[3] overflow-y-hidden",
|
||||
!search.has_snapshot && "flex-[2]",
|
||||
)}
|
||||
>
|
||||
<div className="grid h-full w-full grid-cols-[60%_40%] gap-4">
|
||||
<div className="scrollbar-container min-w-0 overflow-y-auto overflow-x-hidden">
|
||||
{snapshotElement}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 overflow-hidden md:basis-2/5">
|
||||
<div className="flex min-w-0 flex-col gap-4 pr-2">
|
||||
<TabsWithActions
|
||||
search={search}
|
||||
searchTabs={searchTabs}
|
||||
@ -381,7 +392,7 @@ function DialogContentComponent({
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
dialogContainer={dialogContainer}
|
||||
/>
|
||||
<div className="scrollbar-container flex-1 overflow-y-auto">
|
||||
<div className="scrollbar-container min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-4">
|
||||
<ObjectDetailsTab
|
||||
search={search}
|
||||
config={config}
|
||||
@ -584,8 +595,13 @@ export default function SearchDetailDialog({
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop &&
|
||||
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
|
||||
isMobile && "px-4",
|
||||
isMobile && "flex h-full flex-col px-4",
|
||||
)}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isPopoverOpen) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isPopoverOpen) {
|
||||
e.preventDefault();
|
||||
@ -596,7 +612,7 @@ export default function SearchDetailDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Header>
|
||||
<Header className={cn(!isDesktop && "top-0 z-[60] mb-0")}>
|
||||
<Title>{t("trackedObjectDetails")}</Title>
|
||||
<Description className="sr-only">
|
||||
{t("trackedObjectDetails")}
|
||||
@ -676,6 +692,8 @@ function ObjectDetailsTab({
|
||||
const [desc, setDesc] = useState(search?.data.description);
|
||||
const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false);
|
||||
const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false);
|
||||
const [isEditingDesc, setIsEditingDesc] = useState(false);
|
||||
const originalDescRef = useRef<string | null>(null);
|
||||
|
||||
const handleDescriptionFocus = useCallback(() => {
|
||||
setInputFocused(true);
|
||||
@ -1078,15 +1096,51 @@ function ObjectDetailsTab({
|
||||
});
|
||||
|
||||
setState("submitted");
|
||||
setSearch({
|
||||
...search,
|
||||
plus_id: "new_upload",
|
||||
});
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === "string" &&
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||
if (!currentData) return currentData;
|
||||
// optimistic update
|
||||
return currentData
|
||||
.flat()
|
||||
.map((event) =>
|
||||
event.id === search.id
|
||||
? { ...event, plus_id: "new_upload" }
|
||||
: event,
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
revalidate: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
[search, setSearch],
|
||||
[search, mutate],
|
||||
);
|
||||
|
||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canRegenerate = !!(
|
||||
config?.cameras[search.camera].objects.genai.enabled && search.end_time
|
||||
);
|
||||
const showGenAIPlaceholder = !!(
|
||||
config?.cameras[search.camera].objects.genai.enabled &&
|
||||
!search.end_time &&
|
||||
(config.cameras[search.camera].objects.genai.required_zones.length === 0 ||
|
||||
search.zones.some((zone) =>
|
||||
config.cameras[search.camera].objects.genai.required_zones.includes(
|
||||
zone,
|
||||
),
|
||||
)) &&
|
||||
(config.cameras[search.camera].objects.genai.objects.length === 0 ||
|
||||
config.cameras[search.camera].objects.genai.objects.includes(
|
||||
search.label,
|
||||
))
|
||||
);
|
||||
return (
|
||||
<div ref={popoverContainerRef} className="flex flex-col gap-5">
|
||||
<div className="flex w-full flex-row">
|
||||
@ -1101,7 +1155,7 @@ function ObjectDetailsTab({
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
|
||||
{getIconForLabel(search.label, "size-4 text-primary")}
|
||||
{getTranslatedLabel(search.label)}
|
||||
{getTranslatedLabel(search.label, search.data.type)}
|
||||
{search.sub_label && ` (${search.sub_label})`}
|
||||
{isAdmin && search.end_time && (
|
||||
<Tooltip>
|
||||
@ -1242,176 +1296,175 @@ function ObjectDetailsTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"my-2 flex w-full flex-col justify-between gap-1.5",
|
||||
state == "submitted" && "flex-row",
|
||||
)}
|
||||
>
|
||||
<div className="text-sm text-primary/40">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{t("explore.plus.submitToPlus.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
container={popoverContainerRef.current}
|
||||
className="w-80 text-xs"
|
||||
>
|
||||
{t("explore.plus.submitToPlus.desc", {
|
||||
{search.data.type === "object" &&
|
||||
config?.plus?.enabled &&
|
||||
search.has_snapshot && (
|
||||
<div
|
||||
className={cn(
|
||||
"my-2 flex w-full flex-col justify-between gap-1.5",
|
||||
state == "submitted" && "flex-row",
|
||||
)}
|
||||
>
|
||||
<div className="text-sm text-primary/40">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{t("explore.plus.submitToPlus.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
container={popoverContainerRef.current}
|
||||
className="w-80 text-xs"
|
||||
>
|
||||
{t("explore.plus.submitToPlus.desc", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2 text-sm">
|
||||
{state == "reviewing" && (
|
||||
<>
|
||||
<div>
|
||||
{i18n.language === "en" ? (
|
||||
// English with a/an logic plus label
|
||||
<>
|
||||
{/^[aeiou]/i.test(search?.label || "") ? (
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{ label: search?.label }}
|
||||
>
|
||||
explore.plus.review.question.ask_an
|
||||
</Trans>
|
||||
<div className="flex flex-row items-center justify-between gap-2 text-sm">
|
||||
{state == "reviewing" && (
|
||||
<>
|
||||
<div>
|
||||
{i18n.language === "en" ? (
|
||||
// English with a/an logic plus label
|
||||
<>
|
||||
{/^[aeiou]/i.test(search?.label || "") ? (
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{ label: search?.label }}
|
||||
>
|
||||
explore.plus.review.question.ask_an
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{ label: search?.label }}
|
||||
>
|
||||
explore.plus.review.question.ask_a
|
||||
</Trans>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// For other languages
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{ label: search?.label }}
|
||||
values={{
|
||||
untranslatedLabel: search?.label,
|
||||
translatedLabel: getTranslatedLabel(search?.label),
|
||||
}}
|
||||
>
|
||||
explore.plus.review.question.ask_a
|
||||
explore.plus.review.question.ask_full
|
||||
</Trans>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// For other languages
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{
|
||||
untranslatedLabel: search?.label,
|
||||
translatedLabel: getTranslatedLabel(search?.label),
|
||||
}}
|
||||
>
|
||||
explore.plus.review.question.ask_full
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex max-w-xl flex-row gap-2">
|
||||
<Button
|
||||
className="flex-1 bg-success"
|
||||
aria-label={t("button.yes", { ns: "common" })}
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(false);
|
||||
}}
|
||||
>
|
||||
{t("button.yes", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 text-white"
|
||||
aria-label={t("button.no", { ns: "common" })}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(true);
|
||||
}}
|
||||
>
|
||||
{t("button.no", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state == "uploading" && <ActivityIndicator />}
|
||||
{state == "submitted" && (
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<FaCheckCircle className="size-4 text-success" />
|
||||
{t("explore.plus.review.state.submitted")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{config?.cameras[search.camera].objects.genai.enabled &&
|
||||
!search.end_time &&
|
||||
(config.cameras[search.camera].objects.genai.required_zones.length ===
|
||||
0 ||
|
||||
search.zones.some((zone) =>
|
||||
config.cameras[search.camera].objects.genai.required_zones.includes(
|
||||
zone,
|
||||
),
|
||||
)) &&
|
||||
(config.cameras[search.camera].objects.genai.objects.length === 0 ||
|
||||
config.cameras[search.camera].objects.genai.objects.includes(
|
||||
search.label,
|
||||
)) ? (
|
||||
<>
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.description.label")}
|
||||
</div>
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 border p-4 text-sm text-primary/40">
|
||||
<div className="flex">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
<div className="flex">{t("details.description.aiTips")}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-primary/40"></div>
|
||||
<Textarea
|
||||
className="text-md h-64"
|
||||
placeholder={t("details.description.placeholder")}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
onFocus={handleDescriptionFocus}
|
||||
onBlur={handleDescriptionBlur}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-row justify-end gap-2">
|
||||
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
||||
search?.label == "speech" &&
|
||||
search?.end_time && (
|
||||
<Button onClick={onTranscribe}>
|
||||
<div className="flex gap-1">
|
||||
{t("itemMenu.audioTranscription.label")}
|
||||
</div>
|
||||
<div className="flex max-w-xl flex-row gap-2">
|
||||
<Button
|
||||
className="flex-1 bg-success"
|
||||
aria-label={t("button.yes", { ns: "common" })}
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(false);
|
||||
}}
|
||||
>
|
||||
{t("button.yes", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 text-white"
|
||||
aria-label={t("button.no", { ns: "common" })}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(true);
|
||||
}}
|
||||
>
|
||||
{t("button.no", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state == "uploading" && <ActivityIndicator />}
|
||||
{state == "submitted" && (
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<FaCheckCircle className="size-4 text-success" />
|
||||
{t("explore.plus.review.state.submitted", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
{config?.cameras[search.camera].objects.genai.enabled &&
|
||||
search.end_time && (
|
||||
<div className="flex items-start">
|
||||
<Button
|
||||
className="rounded-r-none border-r-0"
|
||||
aria-label={t("details.button.regenerate.label")}
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.description.label")}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.edit", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
onClick={() => {
|
||||
originalDescRef.current = desc ?? "";
|
||||
setIsEditingDesc(true);
|
||||
}}
|
||||
>
|
||||
{t("details.button.regenerate.title")}
|
||||
</Button>
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="rounded-l-none border-l-0 px-2"
|
||||
aria-label={t("details.expandRegenerationMenu")}
|
||||
>
|
||||
<FaChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<FaPencilAlt className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.edit", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
||||
search?.label == "speech" &&
|
||||
search?.end_time && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("itemMenu.audioTranscription.label")}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
onClick={onTranscribe}
|
||||
>
|
||||
<FaMicrophone className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.audioTranscription.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{canRegenerate && (
|
||||
<div className="relative">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={t("details.button.regenerate.label")}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
>
|
||||
<HiSparkles className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("details.button.regenerate.title")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("details.regenerateFromSnapshot")}
|
||||
@ -1419,61 +1472,115 @@ function ObjectDetailsTab({
|
||||
>
|
||||
{t("details.regenerateFromSnapshot")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("details.regenerateFromThumbnails")}
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
>
|
||||
{t("details.regenerateFromThumbnails")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("details.regenerateFromThumbnails")}
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
>
|
||||
{t("details.regenerateFromThumbnails")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
{((config?.cameras[search.camera].objects.genai.enabled &&
|
||||
search.end_time) ||
|
||||
!config?.cameras[search.camera].objects.genai.enabled) && (
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
onClick={updateDescription}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TextEntryDialog
|
||||
open={isSubLabelDialogOpen}
|
||||
setOpen={setIsSubLabelDialogOpen}
|
||||
title={t("details.editSubLabel.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editSubLabel.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editSubLabel.descNoLabel")
|
||||
}
|
||||
onSave={handleSubLabelSave}
|
||||
defaultValue={search?.sub_label || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
<TextEntryDialog
|
||||
open={isLPRDialogOpen}
|
||||
setOpen={setIsLPRDialogOpen}
|
||||
title={t("details.editLPR.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editLPR.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editLPR.descNoLabel")
|
||||
}
|
||||
onSave={handleLPRSave}
|
||||
defaultValue={search?.data.recognized_license_plate || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditingDesc ? (
|
||||
showGenAIPlaceholder ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-3 border p-4 text-sm text-primary/40">
|
||||
<div className="flex">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
<div className="flex">{t("details.description.aiTips")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-auto text-sm text-primary">
|
||||
{desc || t("label.none", { ns: "common" })}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
className="text-md h-32"
|
||||
placeholder={t("details.description.placeholder")}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
onFocus={handleDescriptionFocus}
|
||||
onBlur={handleDescriptionBlur}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-row justify-end gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
onClick={() => {
|
||||
setIsEditingDesc(false);
|
||||
updateDescription();
|
||||
}}
|
||||
>
|
||||
<FaCheck className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary"
|
||||
onClick={() => {
|
||||
setIsEditingDesc(false);
|
||||
setDesc(originalDescRef.current ?? "");
|
||||
}}
|
||||
>
|
||||
<FaTimes className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextEntryDialog
|
||||
open={isSubLabelDialogOpen}
|
||||
setOpen={setIsSubLabelDialogOpen}
|
||||
title={t("details.editSubLabel.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editSubLabel.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editSubLabel.descNoLabel")
|
||||
}
|
||||
onSave={handleSubLabelSave}
|
||||
defaultValue={search?.sub_label || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
<TextEntryDialog
|
||||
open={isLPRDialogOpen}
|
||||
setOpen={setIsLPRDialogOpen}
|
||||
title={t("details.editLPR.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editLPR.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editLPR.descNoLabel")
|
||||
}
|
||||
onSave={handleLPRSave}
|
||||
defaultValue={search?.data.recognized_license_plate || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -23,6 +23,7 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import axios from "axios";
|
||||
@ -73,6 +74,12 @@ export function TrackingDetails({
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
eventSequence?.map((event) => {
|
||||
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
||||
return resolveZoneName(config, zone);
|
||||
});
|
||||
});
|
||||
|
||||
// Use manualOverride (set when seeking in image mode) if present so
|
||||
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
|
||||
// back to currentTime used for video mode.
|
||||
@ -336,6 +343,10 @@ export function TrackingDetails({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [displayedRecordTime]);
|
||||
|
||||
const onUploadFrameToPlus = useCallback(() => {
|
||||
return axios.post(`/${event.camera}/plus/${currentTime}`);
|
||||
}, [event.camera, currentTime]);
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
@ -345,7 +356,8 @@ export function TrackingDetails({
|
||||
className={cn(
|
||||
isDesktop
|
||||
? "flex size-full justify-evenly gap-4 overflow-hidden"
|
||||
: "flex size-full flex-col gap-2",
|
||||
: "flex flex-col gap-2",
|
||||
!isDesktop && cameraAspect === "tall" && "size-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@ -380,6 +392,7 @@ export function TrackingDetails({
|
||||
frigateControls={true}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeekToTime={handleSeekToTime}
|
||||
onUploadFrame={onUploadFrameToPlus}
|
||||
isDetailMode={true}
|
||||
camera={event.camera}
|
||||
currentTimeOverride={currentTime}
|
||||
@ -446,7 +459,7 @@ export function TrackingDetails({
|
||||
)}
|
||||
>
|
||||
{isDesktop && tabs && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex-1">{tabs}</div>
|
||||
</div>
|
||||
)}
|
||||
@ -457,7 +470,7 @@ export function TrackingDetails({
|
||||
>
|
||||
{config?.cameras[event.camera]?.onvif.autotracking
|
||||
.enabled_in_config && (
|
||||
<div className="mb-2 text-sm text-danger">
|
||||
<div className="mb-2 ml-3 text-sm text-danger">
|
||||
{t("trackingDetails.autoTrackingTips")}
|
||||
</div>
|
||||
)}
|
||||
@ -712,8 +725,13 @@ function LifecycleIconRow({
|
||||
backgroundColor: `rgb(${color})`,
|
||||
}}
|
||||
/>
|
||||
<span className="smart-capitalize">
|
||||
{zone.replaceAll("_", " ")}
|
||||
<span
|
||||
className={cn(
|
||||
item.data?.zones_friendly_names?.[zidx] === zone &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{item.data?.zones_friendly_names?.[zidx]}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@ -20,7 +20,9 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useRef } from "react";
|
||||
|
||||
type PlatformAwareDialogProps = {
|
||||
trigger: JSX.Element;
|
||||
@ -79,6 +81,8 @@ export function PlatformAwareSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PlatformAwareSheetProps) {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobilePage open={open} onOpenChange={onOpenChange}>
|
||||
@ -86,14 +90,22 @@ export function PlatformAwareSheet({
|
||||
{trigger}
|
||||
</MobilePageTrigger>
|
||||
<MobilePagePortal>
|
||||
<MobilePageContent className="h-full overflow-hidden">
|
||||
<MobilePageContent
|
||||
className="flex h-full flex-col"
|
||||
scrollerRef={scrollerRef}
|
||||
>
|
||||
<MobilePageHeader
|
||||
className="mx-2"
|
||||
onClose={() => onOpenChange(false)}
|
||||
>
|
||||
<MobilePageTitle>{title}</MobilePageTitle>
|
||||
</MobilePageHeader>
|
||||
<div className={contentClassName}>{content}</div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className={cn("flex-1 overflow-y-auto", contentClassName)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</MobilePageContent>
|
||||
</MobilePagePortal>
|
||||
</MobilePage>
|
||||
|
||||
@ -98,7 +98,11 @@ export default function RestartDialog({
|
||||
open={restartingSheetOpen}
|
||||
onOpenChange={() => setRestartingSheetOpen(false)}
|
||||
>
|
||||
<SheetContent side="top" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<SheetContent
|
||||
side="top"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
className="[&>button:first-of-type]:hidden"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<ActivityIndicator />
|
||||
<SheetHeader className="mt-5 text-center">
|
||||
|
||||
@ -230,6 +230,7 @@ export default function SearchFilterDialog({
|
||||
<PlatformAwareSheet
|
||||
trigger={trigger}
|
||||
title={t("more")}
|
||||
titleClassName="mb-5 -mt-3"
|
||||
content={content}
|
||||
contentClassName={cn(
|
||||
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
|
||||
@ -429,7 +430,8 @@ export function ZoneFilterContent({
|
||||
{allZones.map((item) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
label={item.replaceAll("_", " ")}
|
||||
label={item}
|
||||
type={"zone"}
|
||||
isChecked={zones?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
|
||||
@ -77,7 +77,10 @@ export default function BirdseyeLivePlayer({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ImageShadowOverlay />
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div className="size-full" ref={playerRef}>
|
||||
{player}
|
||||
</div>
|
||||
|
||||
@ -331,7 +331,10 @@ export default function LivePlayer({
|
||||
>
|
||||
{cameraEnabled &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<ImageShadowOverlay />
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
{player}
|
||||
{cameraEnabled &&
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
@ -71,6 +72,8 @@ function MSEPlayer({
|
||||
const [errorCount, setErrorCount] = useState<number>(0);
|
||||
const totalBytesLoaded = useRef(0);
|
||||
|
||||
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTIDRef = useRef<number | null>(null);
|
||||
@ -475,7 +478,10 @@ function MSEPlayer({
|
||||
setBufferTimeout(undefined);
|
||||
}
|
||||
|
||||
const timeoutDuration = bufferTime == 0 ? 5000 : 3000;
|
||||
const timeoutDuration =
|
||||
bufferTime == 0
|
||||
? (fallbackTimeout ?? 3) * 2 * 1000
|
||||
: (fallbackTimeout ?? 3) * 1000;
|
||||
setBufferTimeout(
|
||||
setTimeout(() => {
|
||||
if (
|
||||
@ -500,6 +506,7 @@ function MSEPlayer({
|
||||
onError,
|
||||
onPlaying,
|
||||
playbackEnabled,
|
||||
fallbackTimeout,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -12,15 +12,15 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
||||
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
|
||||
import Step3Validation from "@/components/settings/wizard/Step3Validation";
|
||||
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
|
||||
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
|
||||
import Step4Validation from "@/components/settings/wizard/Step4Validation";
|
||||
import type {
|
||||
WizardFormData,
|
||||
CameraConfigData,
|
||||
ConfigSetBody,
|
||||
} from "@/types/cameraWizard";
|
||||
import { processCameraName } from "@/utils/cameraUtil";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WizardState = {
|
||||
@ -57,6 +57,7 @@ const wizardReducer = (
|
||||
|
||||
const STEPS = [
|
||||
"cameraWizard.steps.nameAndConnection",
|
||||
"cameraWizard.steps.probeOrSnapshot",
|
||||
"cameraWizard.steps.streamConfiguration",
|
||||
"cameraWizard.steps.validationAndTesting",
|
||||
];
|
||||
@ -100,20 +101,20 @@ export default function CameraWizardDialog({
|
||||
const canProceedToNext = useCallback((): boolean => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
// Can proceed if camera name is set and at least one stream exists
|
||||
return !!(
|
||||
state.wizardData.cameraName &&
|
||||
(state.wizardData.streams?.length ?? 0) > 0
|
||||
);
|
||||
// Step 1: Can proceed if camera name is set
|
||||
return !!state.wizardData.cameraName;
|
||||
case 1:
|
||||
// Can proceed if at least one stream has 'detect' role
|
||||
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
||||
return (state.wizardData.streams?.length ?? 0) > 0;
|
||||
case 2:
|
||||
// Step 3: Can proceed if at least one stream has 'detect' role
|
||||
return !!(
|
||||
state.wizardData.streams?.some((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
) ?? false
|
||||
);
|
||||
case 2:
|
||||
// Always can proceed from final step (save will be handled there)
|
||||
case 3:
|
||||
// Step 4: Always can proceed from final step (save will be handled there)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@ -340,13 +341,7 @@ export default function CameraWizardDialog({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90dvh] max-w-xl overflow-y-auto",
|
||||
isDesktop &&
|
||||
currentStep == 0 &&
|
||||
state.wizardData?.streams?.[0]?.testResult?.snapshot &&
|
||||
"max-w-4xl",
|
||||
isDesktop && currentStep == 1 && "max-w-2xl",
|
||||
isDesktop && currentStep > 1 && "max-w-4xl",
|
||||
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
@ -385,7 +380,16 @@ export default function CameraWizardDialog({
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Step2StreamConfig
|
||||
<Step2ProbeOrSnapshot
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
probeMode={state.wizardData.probeMode ?? true}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3StreamConfig
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onBack={handleBack}
|
||||
@ -393,8 +397,8 @@ export default function CameraWizardDialog({
|
||||
canProceed={canProceedToNext()}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3Validation
|
||||
{currentStep === 3 && (
|
||||
<Step4Validation
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onSave={handleSave}
|
||||
|
||||
@ -262,13 +262,17 @@ export function PolygonCanvas({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activePolygonIndex === undefined || !polygons) {
|
||||
if (activePolygonIndex === undefined || !polygons?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPolygons = [...polygons];
|
||||
const activePolygon = updatedPolygons[activePolygonIndex];
|
||||
|
||||
if (!activePolygon) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add default points order for already completed polygons
|
||||
if (!activePolygon.pointsOrder && activePolygon.isFinished) {
|
||||
updatedPolygons[activePolygonIndex] = {
|
||||
|
||||
@ -179,7 +179,7 @@ export default function PolygonItem({
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.name,
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
@ -261,7 +261,9 @@ export default function PolygonItem({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="cursor-default">{polygon.name}</p>
|
||||
<p className="cursor-default">
|
||||
{polygon.friendly_name ?? polygon.name}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
@ -278,7 +280,7 @@ export default function PolygonItem({
|
||||
ns="views/settings"
|
||||
values={{
|
||||
type: polygon.type.replace("_", " "),
|
||||
name: polygon.name,
|
||||
name: polygon.friendly_name ?? polygon.name,
|
||||
}}
|
||||
>
|
||||
masksAndZones.form.polygonDrawing.delete.desc
|
||||
|
||||
@ -34,6 +34,7 @@ import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import NameAndIdFields from "../input/NameAndIdFields";
|
||||
|
||||
type ZoneEditPaneProps = {
|
||||
polygons?: Polygon[];
|
||||
@ -146,15 +147,37 @@ export default function ZoneEditPane({
|
||||
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
|
||||
),
|
||||
},
|
||||
)
|
||||
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
|
||||
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
|
||||
})
|
||||
.refine((value: string) => /[a-zA-Z]/.test(value), {
|
||||
),
|
||||
friendly_name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t(
|
||||
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
|
||||
"masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
|
||||
),
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(value: string) => {
|
||||
return !cameras.map((cam) => cam.name).includes(value);
|
||||
},
|
||||
{
|
||||
message: t(
|
||||
"masksAndZones.form.zoneName.error.mustNotBeSameWithCamera",
|
||||
),
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(value: string) => {
|
||||
const otherPolygonNames =
|
||||
polygons
|
||||
?.filter((_, index) => index !== activePolygonIndex)
|
||||
.map((polygon) => polygon.name) || [];
|
||||
|
||||
return !otherPolygonNames.includes(value);
|
||||
},
|
||||
{
|
||||
message: t("masksAndZones.form.zoneName.error.alreadyExists"),
|
||||
},
|
||||
),
|
||||
inertia: z.coerce
|
||||
.number()
|
||||
.min(1, {
|
||||
@ -247,6 +270,7 @@ export default function ZoneEditPane({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
name: polygon?.name ?? "",
|
||||
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
|
||||
inertia:
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
@ -286,6 +310,7 @@ export default function ZoneEditPane({
|
||||
async (
|
||||
{
|
||||
name: zoneName,
|
||||
friendly_name,
|
||||
inertia,
|
||||
loitering_time,
|
||||
objects: form_objects,
|
||||
@ -415,9 +440,14 @@ export default function ZoneEditPane({
|
||||
}
|
||||
}
|
||||
|
||||
let friendlyNameQuery = "";
|
||||
if (friendly_name) {
|
||||
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
|
||||
}
|
||||
|
||||
axios
|
||||
.put(
|
||||
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
|
||||
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
@ -427,7 +457,7 @@ export default function ZoneEditPane({
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName,
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
@ -541,26 +571,17 @@ export default function ZoneEditPane({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
|
||||
<FormField
|
||||
<NameAndIdFields
|
||||
type="zone"
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("masksAndZones.zones.name.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("masksAndZones.zones.name.tips")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
nameField="friendly_name"
|
||||
idField="name"
|
||||
idVisible={(polygon && polygon.name.length > 0) ?? false}
|
||||
nameLabel={t("masksAndZones.zones.name.title")}
|
||||
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
/>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
363
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
363
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
@ -0,0 +1,363 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FaCopy, FaCheck } from "react-icons/fa";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { CiCircleAlert } from "react-icons/ci";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
OnvifProbeResponse,
|
||||
OnvifRtspCandidate,
|
||||
TestResult,
|
||||
CandidateTestMap,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { maskUri } from "@/utils/cameraUtil";
|
||||
|
||||
type OnvifProbeResultsProps = {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: string;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
onRetry: () => void;
|
||||
selectedUris?: string[];
|
||||
testCandidate?: (uri: string) => void;
|
||||
candidateTests?: CandidateTestMap;
|
||||
testingCandidates?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export default function OnvifProbeResults({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
probeResult,
|
||||
onRetry,
|
||||
selectedUris,
|
||||
testCandidate,
|
||||
candidateTests,
|
||||
testingCandidates,
|
||||
}: OnvifProbeResultsProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [copiedUri, setCopiedUri] = useState<string | null>(null);
|
||||
|
||||
const handleCopyUri = (uri: string) => {
|
||||
navigator.clipboard.writeText(uri);
|
||||
setCopiedUri(uri);
|
||||
toast.success(t("cameraWizard.step2.uriCopied"));
|
||||
setTimeout(() => setCopiedUri(null), 2000);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<ActivityIndicator className="size-6" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step2.probingDevice")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.probeError")}</AlertTitle>
|
||||
{error && <AlertDescription>{error}</AlertDescription>}
|
||||
</Alert>
|
||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||
{t("button.retry", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!probeResult?.success) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.probeNoSuccess")}</AlertTitle>
|
||||
{probeResult?.message && (
|
||||
<AlertDescription>{probeResult.message}</AlertDescription>
|
||||
)}
|
||||
</Alert>
|
||||
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||
{t("button.retry", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rtspCandidates = (probeResult.rtsp_candidates || []).filter(
|
||||
(c) => c.source === "GetStreamUri",
|
||||
);
|
||||
|
||||
if (probeResult?.success && rtspCandidates.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>{t("cameraWizard.step2.noRtspCandidates")}</AlertTitle>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{probeResult?.success && (
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">{t("cameraWizard.step2.deviceInfo")}</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-2 p-4 text-sm">
|
||||
{probeResult.manufacturer && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.manufacturer")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.manufacturer}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.model && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.model")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.model}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.firmware_version && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.firmware")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.firmware_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.profiles_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.profiles")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.profiles_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.ptzSupport")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.ptz_supported
|
||||
? t("yes", { ns: "common" })
|
||||
: t("no", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported && probeResult.autotrack_supported && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.autotrackingSupport")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{t("yes", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{probeResult.ptz_supported &&
|
||||
probeResult.presets_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.presets")}:
|
||||
</span>{" "}
|
||||
<span className="text-primary-variant">
|
||||
{probeResult.presets_count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rtspCandidates.length > 0 && (
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="text-sm">
|
||||
{t("cameraWizard.step2.rtspCandidates")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step2.rtspCandidatesDescription")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rtspCandidates.map((candidate, idx) => {
|
||||
const isSelected = !!selectedUris?.includes(candidate.uri);
|
||||
const candidateTest = candidateTests?.[candidate.uri];
|
||||
const isTesting = testingCandidates?.[candidate.uri];
|
||||
|
||||
return (
|
||||
<CandidateItem
|
||||
key={idx}
|
||||
index={idx}
|
||||
candidate={candidate}
|
||||
copiedUri={copiedUri}
|
||||
onCopy={() => handleCopyUri(candidate.uri)}
|
||||
isSelected={isSelected}
|
||||
testCandidate={testCandidate}
|
||||
candidateTest={candidateTest}
|
||||
isTesting={isTesting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type CandidateItemProps = {
|
||||
candidate: OnvifRtspCandidate;
|
||||
index?: number;
|
||||
copiedUri: string | null;
|
||||
onCopy: () => void;
|
||||
isSelected?: boolean;
|
||||
testCandidate?: (uri: string) => void;
|
||||
candidateTest?: TestResult | { success: false; error: string };
|
||||
isTesting?: boolean;
|
||||
};
|
||||
|
||||
function CandidateItem({
|
||||
index,
|
||||
candidate,
|
||||
copiedUri,
|
||||
onCopy,
|
||||
isSelected,
|
||||
testCandidate,
|
||||
candidateTest,
|
||||
isTesting,
|
||||
}: CandidateItemProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [showFull, setShowFull] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
isSelected &&
|
||||
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step2.candidateStreamTitle", {
|
||||
number: (index ?? 0) + 1,
|
||||
})}
|
||||
</h4>
|
||||
{candidateTest?.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
candidateTest.resolution,
|
||||
candidateTest.fps
|
||||
? `${candidateTest.fps} ${t(
|
||||
"cameraWizard.testResultLabels.fps",
|
||||
)}`
|
||||
: null,
|
||||
candidateTest.videoCodec,
|
||||
candidateTest.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{candidateTest?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step2.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidateTest && !candidateTest.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step2.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<p
|
||||
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
|
||||
onClick={() => setShowFull((s) => !s)}
|
||||
title={t("cameraWizard.step2.toggleUriView")}
|
||||
>
|
||||
{showFull ? candidate.uri : maskUri(candidate.uri)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCopy}
|
||||
className="mr-4 size-8 p-0"
|
||||
title={t("cameraWizard.step2.uriCopy")}
|
||||
>
|
||||
{copiedUri === candidate.uri ? (
|
||||
<FaCheck className="size-3" />
|
||||
) : (
|
||||
<FaCopy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting}
|
||||
onClick={() => testCandidate?.(candidate.uri)}
|
||||
className="h-8 px-3 text-sm"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<ActivityIndicator className="mr-2 size-4" />{" "}
|
||||
{t("cameraWizard.step2.testConnection")}
|
||||
</>
|
||||
) : (
|
||||
t("cameraWizard.step2.testConnection")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -2,11 +2,13 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@ -15,15 +17,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
@ -31,20 +31,13 @@ import {
|
||||
CameraBrand,
|
||||
CAMERA_BRANDS,
|
||||
CAMERA_BRAND_VALUES,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
StreamRole,
|
||||
StreamConfig,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { Card, CardContent, CardTitle } from "../../ui/card";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type Step1NameCameraProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
@ -63,9 +56,9 @@ export default function Step1NameCamera({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [probeMode, setProbeMode] = useState<boolean>(
|
||||
wizardData.probeMode ?? true,
|
||||
);
|
||||
|
||||
const existingCameraNames = useMemo(() => {
|
||||
if (!config?.cameras) {
|
||||
@ -88,6 +81,8 @@ export default function Step1NameCamera({
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
useDigestAuth: z.boolean().optional(),
|
||||
customUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
@ -124,6 +119,8 @@ export default function Step1NameCamera({
|
||||
? (wizardData.brandTemplate as CameraBrand)
|
||||
: "dahua",
|
||||
customUrl: wizardData.customUrl || "",
|
||||
onvifPort: wizardData.onvifPort ?? 80,
|
||||
useDigestAuth: wizardData.useDigestAuth ?? false,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
@ -132,271 +129,238 @@ export default function Step1NameCamera({
|
||||
const watchedHost = form.watch("host");
|
||||
const watchedCustomUrl = form.watch("customUrl");
|
||||
|
||||
const isTestButtonEnabled =
|
||||
watchedBrand === "other"
|
||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||
: !!(watchedHost && watchedHost.trim());
|
||||
const hostPresent = !!(watchedHost && watchedHost.trim());
|
||||
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
|
||||
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
|
||||
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return null;
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
data.host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Use detected protocol or fallback to rtsp
|
||||
const protocolKey = protocol || "rtsp";
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
if (Object.keys(templates).includes(protocolKey)) {
|
||||
const template =
|
||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||
return template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string> => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||
|
||||
if (dynamicUrl) {
|
||||
return dynamicUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[generateDynamicStreamUrl],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(async () => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestStatus("");
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// First get probe data for metadata
|
||||
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
||||
const probeResponse = await axios.get("ffprobe", {
|
||||
params: { paths: streamUrl, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let probeData = null;
|
||||
if (
|
||||
probeResponse.data &&
|
||||
probeResponse.data.length > 0 &&
|
||||
probeResponse.data[0].return_code === 0
|
||||
) {
|
||||
probeData = probeResponse.data[0];
|
||||
}
|
||||
|
||||
// Then get snapshot for preview (only if probe succeeded)
|
||||
let snapshotBlob = null;
|
||||
if (probeData) {
|
||||
setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
|
||||
try {
|
||||
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||
params: { url: streamUrl },
|
||||
responseType: "blob",
|
||||
timeout: 10000,
|
||||
});
|
||||
snapshotBlob = snapshotResponse.data;
|
||||
} catch (snapshotError) {
|
||||
// Snapshot is optional, don't fail if it doesn't work
|
||||
toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
|
||||
}
|
||||
}
|
||||
|
||||
if (probeData) {
|
||||
const ffprobeData = probeData.stdout;
|
||||
const streams = ffprobeData.streams || [];
|
||||
|
||||
const videoStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
? `${videoStream.width}x${videoStream.height}`
|
||||
: undefined;
|
||||
|
||||
// Extract FPS from rational (e.g., "15/1" -> 15)
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
// Convert snapshot blob to base64 if available
|
||||
let snapshotBase64 = undefined;
|
||||
if (snapshotBlob) {
|
||||
snapshotBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(snapshotBlob);
|
||||
});
|
||||
}
|
||||
|
||||
const testResult: TestResult = {
|
||||
success: true,
|
||||
snapshot: snapshotBase64,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
setTestResult(testResult);
|
||||
onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] });
|
||||
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||
} else {
|
||||
const error =
|
||||
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||
probeResponse.data[0].stderr.length > 0
|
||||
? probeResponse.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: error,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }), {
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
{
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setTestStatus("");
|
||||
}
|
||||
}, [form, generateStreamUrl, t, onUpdate]);
|
||||
const isContinueButtonEnabled =
|
||||
cameraNamePresent &&
|
||||
(probeMode
|
||||
? hostPresent
|
||||
: watchedBrand === "other"
|
||||
? customPresent
|
||||
: hostPresent);
|
||||
|
||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||
onUpdate(data);
|
||||
onUpdate({ ...data, probeMode });
|
||||
};
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
const streamId = `stream_${Date.now()}`;
|
||||
|
||||
const streamConfig: StreamConfig = {
|
||||
id: streamId,
|
||||
url: streamUrl,
|
||||
roles: ["detect" as StreamRole],
|
||||
resolution: testResult?.resolution,
|
||||
testResult: testResult || undefined,
|
||||
userTested: false,
|
||||
};
|
||||
|
||||
const updatedData = {
|
||||
...data,
|
||||
streams: [streamConfig],
|
||||
};
|
||||
|
||||
onNext(updatedData);
|
||||
}, [form, generateStreamUrl, testResult, onNext]);
|
||||
const isValid = await form.trigger();
|
||||
if (isValid) {
|
||||
const data = form.getValues();
|
||||
onNext({ ...data, probeMode });
|
||||
}
|
||||
}, [form, probeMode, onNext]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!testResult?.success && (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.description")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.description")}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cameraName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.cameraName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cameraName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.cameraName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.cameraNamePlaceholder",
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4">
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.detectionMethod")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={probeMode ? "probe" : "manual"}
|
||||
onValueChange={(value) => {
|
||||
setProbeMode(value === "probe");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="probe"
|
||||
id="probe-mode"
|
||||
className={
|
||||
probeMode
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor="probe-mode" className="cursor-pointer text-sm">
|
||||
{t("cameraWizard.step1.probeMode")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="manual"
|
||||
id="manual-mode"
|
||||
className={
|
||||
!probeMode
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor="manual-mode" className="cursor-pointer text-sm">
|
||||
{t("cameraWizard.step1.manualMode")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<FormDescription>
|
||||
{t("cameraWizard.step1.detectionMethodDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
{probeMode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="onvifPort"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.onvifPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder="80"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("cameraWizard.step1.onvifPortDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage>
|
||||
{fieldState.error ? fieldState.error.message : null}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{probeMode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useDigestAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-start space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(val) => field.onChange(!!val)}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-1 flex-col space-y-1">
|
||||
<FormLabel className="mb-0 text-primary-variant">
|
||||
{t("cameraWizard.step1.useDigestAuth")}
|
||||
</FormLabel>
|
||||
<FormDescription className="mt-0">
|
||||
{t("cameraWizard.step1.useDigestAuthDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!probeMode && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandTemplate"
|
||||
@ -463,90 +427,6 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedBrand !== "other" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.usernamePlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBrand == "other" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -568,124 +448,25 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{testResult?.success && (
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
{t("cameraWizard.step1.testSuccess")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{testResult.snapshot ? (
|
||||
<div className="relative flex justify-center">
|
||||
<img
|
||||
src={testResult.snapshot}
|
||||
alt="Camera snapshot"
|
||||
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
|
||||
<div className="space-y-1">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-4">
|
||||
<CardTitle className="mb-2 text-sm">
|
||||
{t("cameraWizard.step1.streamDetails")}
|
||||
</CardTitle>
|
||||
<CardContent className="p-0 text-sm">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTesting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{testStatus}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
||||
className="sm:flex-1"
|
||||
onClick={handleContinue}
|
||||
disabled={!isContinueButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{testResult?.success
|
||||
? t("button.back", { ns: "common" })
|
||||
: t("button.cancel", { ns: "common" })}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
{testResult?.success ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testConnection}
|
||||
disabled={isTesting || !isTestButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("cameraWizard.step1.testConnection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamDetails({ testResult }: { testResult: TestResult }) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{testResult.resolution && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.resolution")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.resolution}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.fps && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.fps")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.fps}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.videoCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.video")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.videoCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.audioCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.audio")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.audioCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
725
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
725
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
@ -0,0 +1,725 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
WizardFormData,
|
||||
TestResult,
|
||||
StreamConfig,
|
||||
StreamRole,
|
||||
OnvifProbeResponse,
|
||||
CandidateTestMap,
|
||||
FfprobeStream,
|
||||
FfprobeData,
|
||||
FfprobeResponse,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { Card, CardContent, CardTitle } from "../../ui/card";
|
||||
import OnvifProbeResults from "./OnvifProbeResults";
|
||||
import { CAMERA_BRANDS } from "@/types/cameraWizard";
|
||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type Step2ProbeOrSnapshotProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onNext: (data?: Partial<WizardFormData>) => void;
|
||||
onBack: () => void;
|
||||
probeMode: boolean;
|
||||
};
|
||||
|
||||
export default function Step2ProbeOrSnapshot({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
probeMode,
|
||||
}: Step2ProbeOrSnapshotProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const [probeError, setProbeError] = useState<string | null>(null);
|
||||
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [testingCandidates, setTestingCandidates] = useState<
|
||||
Record<string, boolean>
|
||||
>({} as Record<string, boolean>);
|
||||
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
|
||||
{} as CandidateTestMap,
|
||||
);
|
||||
|
||||
const probeUri = useCallback(
|
||||
async (
|
||||
uri: string,
|
||||
fetchSnapshot = false,
|
||||
setStatus?: (s: string) => void,
|
||||
): Promise<TestResult> => {
|
||||
try {
|
||||
const probeResponse = await axios.get("ffprobe", {
|
||||
params: { paths: uri, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let probeData: FfprobeResponse | null = null;
|
||||
if (
|
||||
probeResponse.data &&
|
||||
probeResponse.data.length > 0 &&
|
||||
probeResponse.data[0].return_code === 0
|
||||
) {
|
||||
probeData = probeResponse.data[0];
|
||||
}
|
||||
|
||||
if (!probeData) {
|
||||
const error =
|
||||
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||
probeResponse.data[0].stderr.length > 0
|
||||
? probeResponse.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
let ffprobeData: FfprobeData;
|
||||
if (typeof probeData.stdout === "string") {
|
||||
try {
|
||||
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
|
||||
} catch {
|
||||
ffprobeData = { streams: [] };
|
||||
}
|
||||
} else {
|
||||
ffprobeData = probeData.stdout as FfprobeData;
|
||||
}
|
||||
|
||||
const streams = ffprobeData.streams || [];
|
||||
|
||||
const videoStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
);
|
||||
|
||||
let resolution: string | undefined = undefined;
|
||||
if (videoStream) {
|
||||
const width = Number(videoStream.width || 0);
|
||||
const height = Number(videoStream.height || 0);
|
||||
if (width > 0 && height > 0) {
|
||||
resolution = `${width}x${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
let snapshotBase64: string | undefined = undefined;
|
||||
if (fetchSnapshot) {
|
||||
if (setStatus) {
|
||||
setStatus(t("cameraWizard.step2.testing.fetchingSnapshot"));
|
||||
}
|
||||
try {
|
||||
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||
params: { url: uri },
|
||||
responseType: "blob",
|
||||
timeout: 10000,
|
||||
});
|
||||
const snapshotBlob = snapshotResponse.data;
|
||||
snapshotBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(snapshotBlob);
|
||||
});
|
||||
} catch (snapshotError) {
|
||||
snapshotBase64 = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const streamTestResult: TestResult = {
|
||||
success: true,
|
||||
snapshot: snapshotBase64,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
return streamTestResult;
|
||||
} catch (err) {
|
||||
const axiosError = err as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const probeCamera = useCallback(async () => {
|
||||
if (!wizardData.host) {
|
||||
toast.error(t("cameraWizard.step2.errors.hostRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProbing(true);
|
||||
setProbeError(null);
|
||||
setProbeResult(null);
|
||||
|
||||
try {
|
||||
const response = await axios.get("/onvif/probe", {
|
||||
params: {
|
||||
host: wizardData.host,
|
||||
port: wizardData.onvifPort ?? 80,
|
||||
username: wizardData.username || "",
|
||||
password: wizardData.password || "",
|
||||
test: false,
|
||||
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setProbeResult(response.data);
|
||||
// Extract candidate URLs and pass to wizardData
|
||||
const candidateUris = (response.data.rtsp_candidates || [])
|
||||
.filter((c: { source: string }) => c.source === "GetStreamUri")
|
||||
.map((c: { uri: string }) => c.uri);
|
||||
onUpdate({
|
||||
probeMode: true,
|
||||
probeCandidates: candidateUris,
|
||||
candidateTests: {},
|
||||
});
|
||||
} else {
|
||||
setProbeError(response.data?.message || "Probe failed");
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Failed to probe camera";
|
||||
setProbeError(errorMessage);
|
||||
toast.error(t("cameraWizard.step2.probeFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsProbing(false);
|
||||
}
|
||||
}, [wizardData, onUpdate, t]);
|
||||
|
||||
const testAllSelectedCandidates = useCallback(async () => {
|
||||
const uris = (probeResult?.rtsp_candidates || [])
|
||||
.filter((c) => c.source === "GetStreamUri")
|
||||
.map((c) => c.uri);
|
||||
|
||||
if (!uris || uris.length === 0) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare an initial stream so the wizard can proceed to step 3.
|
||||
// Use the first candidate as the initial stream (no extra probing here).
|
||||
const streamsToCreate: StreamConfig[] = [];
|
||||
if (uris.length > 0) {
|
||||
const first = uris[0];
|
||||
streamsToCreate.push({
|
||||
id: `stream_${Date.now()}`,
|
||||
url: first,
|
||||
roles: ["detect" as const],
|
||||
testResult: candidateTests[first],
|
||||
});
|
||||
}
|
||||
|
||||
// Use existing candidateTests state (may contain entries from individual tests)
|
||||
onNext({
|
||||
probeMode: true,
|
||||
probeCandidates: uris,
|
||||
candidateTests: candidateTests,
|
||||
streams: streamsToCreate,
|
||||
});
|
||||
}, [probeResult, candidateTests, onNext, t]);
|
||||
|
||||
const testCandidate = useCallback(
|
||||
async (uri: string) => {
|
||||
if (!uri) return;
|
||||
setTestingCandidates((s) => ({ ...s, [uri]: true }));
|
||||
try {
|
||||
const result = await probeUri(uri, false);
|
||||
setCandidateTests((s) => ({ ...s, [uri]: result }));
|
||||
} finally {
|
||||
setTestingCandidates((s) => ({ ...s, [uri]: false }));
|
||||
}
|
||||
},
|
||||
[probeUri],
|
||||
);
|
||||
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string | null> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return null;
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
data.host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const protocolKey = protocol || "rtsp";
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
if (Object.keys(templates).includes(protocolKey)) {
|
||||
const template =
|
||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||
return template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string> => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||
|
||||
if (dynamicUrl) {
|
||||
return dynamicUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[generateDynamicStreamUrl],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(
|
||||
async (showToast = true) => {
|
||||
const streamUrl = await generateStreamUrl(wizardData);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestStatus("");
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||
const result = await probeUri(streamUrl, true, setTestStatus);
|
||||
|
||||
if (result && result.success) {
|
||||
setTestResult(result);
|
||||
const streamId = `stream_${Date.now()}`;
|
||||
onUpdate({
|
||||
streams: [
|
||||
{
|
||||
id: streamId,
|
||||
url: streamUrl,
|
||||
roles: ["detect"] as StreamRole[],
|
||||
testResult: result,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.success(t("cameraWizard.step2.testSuccess"));
|
||||
}
|
||||
} else {
|
||||
const errMsg = result?.error || "Unable to probe stream";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errMsg,
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errMsg }),
|
||||
{
|
||||
duration: 6000,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
{
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setTestStatus("");
|
||||
}
|
||||
},
|
||||
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
|
||||
);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
onNext();
|
||||
}, [onNext]);
|
||||
|
||||
// Auto-start probe or test when step loads
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
if (probeMode) {
|
||||
probeCamera();
|
||||
} else {
|
||||
// Auto-run the connection test but suppress toasts to avoid duplicates
|
||||
testConnection(false);
|
||||
}
|
||||
}
|
||||
}, [hasStarted, probeMode, probeCamera, testConnection]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{probeMode ? (
|
||||
// Probe mode: show probe results directly
|
||||
<>
|
||||
{probeResult && (
|
||||
<div className="space-y-4">
|
||||
<OnvifProbeResults
|
||||
isLoading={isProbing}
|
||||
isError={!!probeError}
|
||||
error={probeError || undefined}
|
||||
probeResult={probeResult}
|
||||
onRetry={probeCamera}
|
||||
testCandidate={testCandidate}
|
||||
candidateTests={candidateTests}
|
||||
testingCandidates={testingCandidates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProbeFooterButtons
|
||||
isProbing={isProbing}
|
||||
probeError={probeError}
|
||||
onBack={onBack}
|
||||
onTestAll={testAllSelectedCandidates}
|
||||
onRetry={probeCamera}
|
||||
// disable next if either the overall testConnection is running or any candidate test is running
|
||||
isTesting={
|
||||
isTesting || Object.values(testingCandidates).some((v) => v)
|
||||
}
|
||||
candidateCount={
|
||||
(probeResult?.rtsp_candidates || []).filter(
|
||||
(c) => c.source === "GetStreamUri",
|
||||
).length
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Manual mode: show snapshot and stream details
|
||||
<>
|
||||
{testResult?.success && (
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
{t("cameraWizard.step2.testSuccess")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{testResult.snapshot ? (
|
||||
<div className="relative flex justify-center">
|
||||
<img
|
||||
src={testResult.snapshot}
|
||||
alt="Camera snapshot"
|
||||
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
|
||||
<div className="space-y-1">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-4">
|
||||
<CardTitle className="mb-2 text-sm">
|
||||
{t("cameraWizard.step2.streamDetails")}
|
||||
</CardTitle>
|
||||
<CardContent className="p-0 text-sm">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTesting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{testStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && !testResult.success && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-destructive">{testResult.error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProbeFooterButtons
|
||||
mode="manual"
|
||||
isProbing={false}
|
||||
probeError={null}
|
||||
onBack={onBack}
|
||||
onTestAll={testAllSelectedCandidates}
|
||||
onRetry={probeCamera}
|
||||
isTesting={
|
||||
isTesting || Object.values(testingCandidates).some((v) => v)
|
||||
}
|
||||
candidateCount={
|
||||
(probeResult?.rtsp_candidates || []).filter(
|
||||
(c) => c.source === "GetStreamUri",
|
||||
).length
|
||||
}
|
||||
manualTestSuccess={!!testResult?.success}
|
||||
onContinue={handleContinue}
|
||||
onManualTest={testConnection}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamDetails({ testResult }: { testResult: TestResult }) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{testResult.resolution && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.resolution")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.resolution}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.fps && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.fps")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.fps}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.videoCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.video")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.videoCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.audioCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.audio")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.audioCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ProbeFooterProps = {
|
||||
isProbing: boolean;
|
||||
probeError: string | null;
|
||||
onBack: () => void;
|
||||
onTestAll: () => void;
|
||||
onRetry: () => void;
|
||||
isTesting: boolean;
|
||||
candidateCount?: number;
|
||||
mode?: "probe" | "manual";
|
||||
manualTestSuccess?: boolean;
|
||||
onContinue?: () => void;
|
||||
onManualTest?: () => void;
|
||||
};
|
||||
|
||||
function ProbeFooterButtons({
|
||||
isProbing,
|
||||
probeError,
|
||||
onBack,
|
||||
onTestAll,
|
||||
onRetry,
|
||||
isTesting,
|
||||
candidateCount = 0,
|
||||
mode = "probe",
|
||||
manualTestSuccess,
|
||||
onContinue,
|
||||
onManualTest,
|
||||
}: ProbeFooterProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
// Loading footer
|
||||
if (isProbing) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{t("cameraWizard.step2.probing")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
<ActivityIndicator className="size-4" />
|
||||
{t("cameraWizard.step2.probing")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error footer
|
||||
if (probeError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-destructive">{probeError}</div>
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("cameraWizard.step2.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default footer: show back + test (test disabled if none selected or testing)
|
||||
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
|
||||
if (mode === "manual") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
{manualTestSuccess ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onManualTest}
|
||||
disabled={isTesting}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<ActivityIndicator className="size-4" />{" "}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</>
|
||||
) : (
|
||||
t("cameraWizard.step2.retry")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default probe footer
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onTestAll}
|
||||
disabled={isTesting || (candidateCount ?? 0) === 0}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,481 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
WizardFormData,
|
||||
StreamConfig,
|
||||
StreamRole,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
} from "@/types/cameraWizard";
|
||||
import { Label } from "../../ui/label";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuInfo, LuExternalLink } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
|
||||
type Step2StreamConfigProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
canProceed?: boolean;
|
||||
};
|
||||
|
||||
export default function Step2StreamConfig({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onBack,
|
||||
onNext,
|
||||
canProceed,
|
||||
}: Step2StreamConfigProps) {
|
||||
const { t } = useTranslation(["views/settings", "components/dialog"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
|
||||
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||
|
||||
const addStream = useCallback(() => {
|
||||
const newStream: StreamConfig = {
|
||||
id: `stream_${Date.now()}`,
|
||||
url: "",
|
||||
roles: [],
|
||||
};
|
||||
onUpdate({
|
||||
streams: [...streams, newStream],
|
||||
});
|
||||
}, [streams, onUpdate]);
|
||||
|
||||
const removeStream = useCallback(
|
||||
(streamId: string) => {
|
||||
onUpdate({
|
||||
streams: streams.filter((s) => s.id !== streamId),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const updateStream = useCallback(
|
||||
(streamId: string, updates: Partial<StreamConfig>) => {
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === streamId ? { ...s, ...updates } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const getUsedRolesExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const roles = new Set<StreamRole>();
|
||||
streams.forEach((stream) => {
|
||||
if (stream.id !== excludeStreamId) {
|
||||
stream.roles.forEach((role) => roles.add(role));
|
||||
}
|
||||
});
|
||||
return roles;
|
||||
},
|
||||
[streams],
|
||||
);
|
||||
|
||||
const toggleRole = useCallback(
|
||||
(streamId: string, role: StreamRole) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
const hasRole = stream.roles.includes(role);
|
||||
if (hasRole) {
|
||||
// Allow removing the role
|
||||
const newRoles = stream.roles.filter((r) => r !== role);
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
} else {
|
||||
// Check if role is already used in another stream
|
||||
const usedRoles = getUsedRolesExcludingStream(streamId);
|
||||
if (!usedRoles.has(role)) {
|
||||
// Allow adding the role
|
||||
const newRoles = [...stream.roles, role];
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
}
|
||||
}
|
||||
},
|
||||
[streams, updateStream, getUsedRolesExcludingStream],
|
||||
);
|
||||
|
||||
const testStream = useCallback(
|
||||
(stream: StreamConfig) => {
|
||||
if (!stream.url.trim()) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingStreams((prev) => new Set(prev).add(stream.id));
|
||||
|
||||
axios
|
||||
.get("ffprobe", {
|
||||
params: { paths: stream.url, detailed: true },
|
||||
timeout: 10000,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data?.[0]?.return_code === 0) {
|
||||
const probeData = response.data[0];
|
||||
const streams = probeData.stdout.streams || [];
|
||||
|
||||
const videoStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("h265"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
? `${videoStream.width}x${videoStream.height}`
|
||||
: undefined;
|
||||
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
const testResult: TestResult = {
|
||||
success: true,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
updateStream(stream.id, { testResult, userTested: true });
|
||||
toast.success(t("cameraWizard.step2.testSuccess"));
|
||||
} else {
|
||||
const error = response.data?.[0]?.stderr || "Unknown error";
|
||||
updateStream(stream.id, {
|
||||
testResult: { success: false, error },
|
||||
userTested: true,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Connection failed";
|
||||
updateStream(stream.id, {
|
||||
testResult: { success: false, error: errorMessage },
|
||||
userTested: true,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setTestingStreams((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(stream.id);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
},
|
||||
[updateStream, t],
|
||||
);
|
||||
|
||||
const setRestream = useCallback(
|
||||
(streamId: string) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
updateStream(streamId, { restream: !stream.restream });
|
||||
},
|
||||
[streams, updateStream],
|
||||
);
|
||||
|
||||
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
{t("cameraWizard.step2.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream, index) => (
|
||||
<Card key={stream.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step2.streamTitle", { number: index + 1 })}
|
||||
</h4>
|
||||
{stream.testResult && stream.testResult.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
stream.testResult.resolution,
|
||||
stream.testResult.fps
|
||||
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||
: null,
|
||||
stream.testResult.videoCodec,
|
||||
stream.testResult.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stream.testResult?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step2.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stream.testResult && !stream.testResult.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step2.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{streams.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStream(stream.id)}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.url")}
|
||||
</label>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Input
|
||||
value={stream.url}
|
||||
onChange={(e) =>
|
||||
updateStream(stream.id, {
|
||||
url: e.target.value,
|
||||
testResult: undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 flex-1"
|
||||
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => testStream(stream)}
|
||||
disabled={
|
||||
testingStreams.has(stream.id) || !stream.url.trim()
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testingStreams.has(stream.id) && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("cameraWizard.step2.testStream")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stream.testResult &&
|
||||
!stream.testResult.success &&
|
||||
stream.userTested && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.testFailedTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{stream.testResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.roles")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.rolesPopover.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>
|
||||
<strong>detect</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.detect")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>record</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.record")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>audio</strong> -{" "}
|
||||
{t("cameraWizard.step2.rolesPopover.audio")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/cameras")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["detect", "record", "audio"] as const).map((role) => {
|
||||
const isUsedElsewhere = getUsedRolesExcludingStream(
|
||||
stream.id,
|
||||
).has(role);
|
||||
const isChecked = stream.roles.includes(role);
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-sm capitalize">{role}</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRole(stream.id, role)}
|
||||
disabled={!isChecked && isUsedElsewhere}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step2.featuresTitle")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step2.featuresPopover.title")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("cameraWizard.step2.featuresPopover.description")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/restream#reduce-connections-to-camera",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step2.go2rtc")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={stream.restream || false}
|
||||
onCheckedChange={() => setRestream(stream.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStream}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraWizard.step2.addAnotherStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasDetectRole && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step2.detectRoleWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onNext?.()}
|
||||
disabled={!canProceed}
|
||||
variant="select"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
757
web/src/components/settings/wizard/Step3StreamConfig.tsx
Normal file
757
web/src/components/settings/wizard/Step3StreamConfig.tsx
Normal file
@ -0,0 +1,757 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
WizardFormData,
|
||||
StreamConfig,
|
||||
StreamRole,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
FfprobeData,
|
||||
FfprobeResponse,
|
||||
CandidateTestMap,
|
||||
} from "@/types/cameraWizard";
|
||||
import { Label } from "../../ui/label";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import {
|
||||
LuInfo,
|
||||
LuExternalLink,
|
||||
LuCheck,
|
||||
LuChevronsUpDown,
|
||||
} from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
type Step3StreamConfigProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
canProceed?: boolean;
|
||||
};
|
||||
|
||||
export default function Step3StreamConfig({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onBack,
|
||||
onNext,
|
||||
canProceed,
|
||||
}: Step3StreamConfigProps) {
|
||||
const { t } = useTranslation(["views/settings", "components/dialog"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
const [openCombobox, setOpenCombobox] = useState<string | null>(null);
|
||||
|
||||
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||
|
||||
// Probe mode candidate tracking
|
||||
const probeCandidates = useMemo(
|
||||
() => (wizardData.probeCandidates || []) as string[],
|
||||
[wizardData.probeCandidates],
|
||||
);
|
||||
|
||||
const candidateTests = useMemo(
|
||||
() => (wizardData.candidateTests || {}) as CandidateTestMap,
|
||||
[wizardData.candidateTests],
|
||||
);
|
||||
|
||||
const isProbeMode = !!wizardData.probeMode;
|
||||
|
||||
const addStream = useCallback(() => {
|
||||
const newStreamId = `stream_${Date.now()}`;
|
||||
|
||||
let initialUrl = "";
|
||||
if (isProbeMode && probeCandidates.length > 0) {
|
||||
// pick first candidate not already used
|
||||
const used = new Set(streams.map((s) => s.url).filter(Boolean));
|
||||
const firstAvailable = probeCandidates.find((c) => !used.has(c));
|
||||
if (firstAvailable) {
|
||||
initialUrl = firstAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
const newStream: StreamConfig = {
|
||||
id: newStreamId,
|
||||
url: initialUrl,
|
||||
roles: [],
|
||||
testResult: initialUrl ? candidateTests[initialUrl] : undefined,
|
||||
userTested: initialUrl ? !!candidateTests[initialUrl] : false,
|
||||
};
|
||||
|
||||
onUpdate({
|
||||
streams: [...streams, newStream],
|
||||
});
|
||||
}, [streams, onUpdate, isProbeMode, probeCandidates, candidateTests]);
|
||||
|
||||
const removeStream = useCallback(
|
||||
(streamId: string) => {
|
||||
onUpdate({
|
||||
streams: streams.filter((s) => s.id !== streamId),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const updateStream = useCallback(
|
||||
(streamId: string, updates: Partial<StreamConfig>) => {
|
||||
onUpdate({
|
||||
streams: streams.map((s) =>
|
||||
s.id === streamId ? { ...s, ...updates } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[streams, onUpdate],
|
||||
);
|
||||
|
||||
const getUsedRolesExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const roles = new Set<StreamRole>();
|
||||
streams.forEach((stream) => {
|
||||
if (stream.id !== excludeStreamId) {
|
||||
stream.roles.forEach((role) => roles.add(role));
|
||||
}
|
||||
});
|
||||
return roles;
|
||||
},
|
||||
[streams],
|
||||
);
|
||||
|
||||
const getUsedUrlsExcludingStream = useCallback(
|
||||
(excludeStreamId: string) => {
|
||||
const used = new Set<string>();
|
||||
streams.forEach((s) => {
|
||||
if (s.id !== excludeStreamId && s.url) {
|
||||
used.add(s.url);
|
||||
}
|
||||
});
|
||||
return used;
|
||||
},
|
||||
[streams],
|
||||
);
|
||||
|
||||
const toggleRole = useCallback(
|
||||
(streamId: string, role: StreamRole) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
const hasRole = stream.roles.includes(role);
|
||||
if (hasRole) {
|
||||
// Allow removing the role
|
||||
const newRoles = stream.roles.filter((r) => r !== role);
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
} else {
|
||||
// Check if role is already used in another stream
|
||||
const usedRoles = getUsedRolesExcludingStream(streamId);
|
||||
if (!usedRoles.has(role)) {
|
||||
// Allow adding the role
|
||||
const newRoles = [...stream.roles, role];
|
||||
updateStream(streamId, { roles: newRoles });
|
||||
}
|
||||
}
|
||||
},
|
||||
[streams, updateStream, getUsedRolesExcludingStream],
|
||||
);
|
||||
|
||||
const testStream = useCallback(
|
||||
async (stream: StreamConfig) => {
|
||||
if (!stream.url.trim()) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingStreams((prev) => new Set(prev).add(stream.id));
|
||||
|
||||
try {
|
||||
const response = await axios.get("ffprobe", {
|
||||
params: { paths: stream.url, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let probeData: FfprobeResponse | null = null;
|
||||
if (
|
||||
response.data &&
|
||||
response.data.length > 0 &&
|
||||
response.data[0].return_code === 0
|
||||
) {
|
||||
probeData = response.data[0];
|
||||
}
|
||||
|
||||
if (!probeData) {
|
||||
const error =
|
||||
Array.isArray(response.data?.[0]?.stderr) &&
|
||||
response.data[0].stderr.length > 0
|
||||
? response.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
const failResult: TestResult = { success: false, error };
|
||||
updateStream(stream.id, { testResult: failResult, userTested: true });
|
||||
onUpdate({
|
||||
candidateTests: {
|
||||
...(wizardData.candidateTests || {}),
|
||||
[stream.url]: failResult,
|
||||
} as CandidateTestMap,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
|
||||
return;
|
||||
}
|
||||
|
||||
let ffprobeData: FfprobeData;
|
||||
if (typeof probeData.stdout === "string") {
|
||||
try {
|
||||
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
|
||||
} catch {
|
||||
ffprobeData = { streams: [] } as FfprobeData;
|
||||
}
|
||||
} else {
|
||||
ffprobeData = probeData.stdout as FfprobeData;
|
||||
}
|
||||
|
||||
const streamsArr = ffprobeData.streams || [];
|
||||
|
||||
const videoStream = streamsArr.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
);
|
||||
|
||||
const audioStream = streamsArr.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
);
|
||||
|
||||
let resolution: string | undefined = undefined;
|
||||
if (videoStream) {
|
||||
const width = Number(videoStream.width || 0);
|
||||
const height = Number(videoStream.height || 0);
|
||||
if (width > 0 && height > 0) {
|
||||
resolution = `${width}x${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
const testResult: TestResult = {
|
||||
success: true,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
updateStream(stream.id, { testResult, userTested: true });
|
||||
onUpdate({
|
||||
candidateTests: {
|
||||
...(wizardData.candidateTests || {}),
|
||||
[stream.url]: testResult,
|
||||
} as CandidateTestMap,
|
||||
});
|
||||
toast.success(t("cameraWizard.step3.testSuccess"));
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
const catchResult: TestResult = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
updateStream(stream.id, { testResult: catchResult, userTested: true });
|
||||
onUpdate({
|
||||
candidateTests: {
|
||||
...(wizardData.candidateTests || {}),
|
||||
[stream.url]: catchResult,
|
||||
} as CandidateTestMap,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
);
|
||||
} finally {
|
||||
setTestingStreams((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(stream.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateStream, t, onUpdate, wizardData.candidateTests],
|
||||
);
|
||||
|
||||
const setRestream = useCallback(
|
||||
(streamId: string) => {
|
||||
const stream = streams.find((s) => s.id === streamId);
|
||||
if (!stream) return;
|
||||
|
||||
updateStream(streamId, { restream: !stream.restream });
|
||||
},
|
||||
[streams, updateStream],
|
||||
);
|
||||
|
||||
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{streams.map((stream, index) => (
|
||||
<Card key={stream.id} className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", { number: index + 1 })}
|
||||
</h4>
|
||||
{stream.testResult && stream.testResult.success && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{[
|
||||
stream.testResult.resolution,
|
||||
stream.testResult.fps
|
||||
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||
: null,
|
||||
stream.testResult.videoCodec,
|
||||
stream.testResult.audioCodec,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stream.testResult?.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
{t("cameraWizard.step3.connected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stream.testResult && !stream.testResult.success && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<span className="text-danger">
|
||||
{t("cameraWizard.step3.notConnected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{streams.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStream(stream.id)}
|
||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
||||
>
|
||||
<LuTrash2 className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.url")}
|
||||
</label>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{isProbeMode && probeCandidates.length > 0 ? (
|
||||
// Responsive: Popover on desktop, Drawer on mobile
|
||||
!isMobile ? (
|
||||
<Popover
|
||||
open={openCombobox === stream.id}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpenCombobox(isOpen ? stream.id : null);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openCombobox === stream.id}
|
||||
className="h-8 w-full justify-between overflow-hidden text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{stream.url
|
||||
? stream.url
|
||||
: t("cameraWizard.step3.selectStream")}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-2"
|
||||
disablePortal
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.searchCandidates",
|
||||
)}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("cameraWizard.step3.noStreamFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{probeCandidates
|
||||
.filter((c) => {
|
||||
const used = getUsedUrlsExcludingStream(
|
||||
stream.id,
|
||||
);
|
||||
return !used.has(c);
|
||||
})
|
||||
.map((candidate) => (
|
||||
<CommandItem
|
||||
key={candidate}
|
||||
value={candidate}
|
||||
onSelect={() => {
|
||||
updateStream(stream.id, {
|
||||
url: candidate,
|
||||
testResult:
|
||||
candidateTests[candidate] ||
|
||||
undefined,
|
||||
userTested:
|
||||
!!candidateTests[candidate],
|
||||
});
|
||||
setOpenCombobox(null);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-3 size-5",
|
||||
stream.url === candidate
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{candidate}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Drawer
|
||||
open={openCombobox === stream.id}
|
||||
onOpenChange={(isOpen) =>
|
||||
setOpenCombobox(isOpen ? stream.id : null)
|
||||
}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openCombobox === stream.id}
|
||||
className="h-8 w-full justify-between overflow-hidden text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{stream.url
|
||||
? stream.url
|
||||
: t("cameraWizard.step3.selectStream")}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-2">
|
||||
<div className="mt-2">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.searchCandidates",
|
||||
)}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("cameraWizard.step3.noStreamFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{probeCandidates
|
||||
.filter((c) => {
|
||||
const used = getUsedUrlsExcludingStream(
|
||||
stream.id,
|
||||
);
|
||||
return !used.has(c);
|
||||
})
|
||||
.map((candidate) => (
|
||||
<CommandItem
|
||||
key={candidate}
|
||||
value={candidate}
|
||||
onSelect={() => {
|
||||
updateStream(stream.id, {
|
||||
url: candidate,
|
||||
testResult:
|
||||
candidateTests[candidate] ||
|
||||
undefined,
|
||||
userTested:
|
||||
!!candidateTests[candidate],
|
||||
});
|
||||
setOpenCombobox(null);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-3 size-5",
|
||||
stream.url === candidate
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{candidate}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
) : (
|
||||
<Input
|
||||
value={stream.url}
|
||||
onChange={(e) =>
|
||||
updateStream(stream.id, {
|
||||
url: e.target.value,
|
||||
testResult: undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 flex-1"
|
||||
placeholder={t(
|
||||
"cameraWizard.step3.streamUrlPlaceholder",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => testStream(stream)}
|
||||
disabled={
|
||||
testingStreams.has(stream.id) || !stream.url.trim()
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testingStreams.has(stream.id) && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("cameraWizard.step3.testStream")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stream.testResult &&
|
||||
!stream.testResult.success &&
|
||||
stream.userTested && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.testFailedTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{stream.testResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.roles")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.rolesPopover.title")}
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>
|
||||
<strong>detect</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.detect")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>record</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.record")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>audio</strong> -{" "}
|
||||
{t("cameraWizard.step3.rolesPopover.audio")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/cameras")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["detect", "record", "audio"] as const).map((role) => {
|
||||
const isUsedElsewhere = getUsedRolesExcludingStream(
|
||||
stream.id,
|
||||
).has(role);
|
||||
const isChecked = stream.roles.includes(role);
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-sm capitalize">{role}</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRole(stream.id, role)}
|
||||
disabled={!isChecked && isUsedElsewhere}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.featuresTitle")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.featuresPopover.title")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("cameraWizard.step3.featuresPopover.description")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/restream#reduce-connections-to-camera",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.go2rtc")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={stream.restream || false}
|
||||
onCheckedChange={() => setRestream(stream.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStream}
|
||||
variant="outline"
|
||||
className=""
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
{t("cameraWizard.step3.addAnotherStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasDetectRole && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step3.detectRoleWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onNext?.()}
|
||||
disabled={!canProceed || testingStreams.size > 0}
|
||||
variant="select"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -18,8 +18,9 @@ import { PlayerStatsType } from "@/types/live";
|
||||
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Card, CardContent } from "../../ui/card";
|
||||
import { maskUri } from "@/utils/cameraUtil";
|
||||
|
||||
type Step3ValidationProps = {
|
||||
type Step4ValidationProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onSave: (config: WizardFormData) => void;
|
||||
@ -27,13 +28,13 @@ type Step3ValidationProps = {
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function Step3Validation({
|
||||
export default function Step4Validation({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onSave,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}: Step3ValidationProps) {
|
||||
}: Step4ValidationProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
@ -143,13 +144,13 @@ export default function Step3Validation({
|
||||
|
||||
if (testResult.success) {
|
||||
toast.success(
|
||||
t("cameraWizard.step3.streamValidated", {
|
||||
t("cameraWizard.step4.streamValidated", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("cameraWizard.step3.streamValidationFailed", {
|
||||
t("cameraWizard.step4.streamValidationFailed", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
@ -200,16 +201,16 @@ export default function Step3Validation({
|
||||
(r) => r.success,
|
||||
).length;
|
||||
if (successfulTests === results.size) {
|
||||
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
||||
toast.success(t("cameraWizard.step4.reconnectionSuccess"));
|
||||
} else {
|
||||
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
||||
toast.warning(t("cameraWizard.step4.reconnectionPartial"));
|
||||
}
|
||||
}
|
||||
}, [streams, onUpdate, t, performStreamValidation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||
toast.error(t("cameraWizard.step3.saveError"));
|
||||
toast.error(t("cameraWizard.step4.saveError"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -239,13 +240,13 @@ export default function Step3Validation({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
{t("cameraWizard.step4.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("cameraWizard.step3.validationTitle")}
|
||||
{t("cameraWizard.step4.validationTitle")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={validateAllStreams}
|
||||
@ -254,8 +255,8 @@ export default function Step3Validation({
|
||||
>
|
||||
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isValidating
|
||||
? t("cameraWizard.step3.connecting")
|
||||
: t("cameraWizard.step3.connectAllStreams")}
|
||||
? t("cameraWizard.step4.connecting")
|
||||
: t("cameraWizard.step4.connectAllStreams")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -270,7 +271,7 @@ export default function Step3Validation({
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex flex-row items-center">
|
||||
<h4 className="mr-2 font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", {
|
||||
{t("cameraWizard.step4.streamTitle", {
|
||||
number: index + 1,
|
||||
})}
|
||||
</h4>
|
||||
@ -331,7 +332,7 @@ export default function Step3Validation({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@ -346,11 +347,11 @@ export default function Step3Validation({
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t(
|
||||
"cameraWizard.step3.ffmpegModuleDescription",
|
||||
"cameraWizard.step4.ffmpegModuleDescription",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -374,7 +375,7 @@ export default function Step3Validation({
|
||||
|
||||
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{stream.url}
|
||||
{maskUri(stream.url)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@ -402,17 +403,17 @@ export default function Step3Validation({
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{result?.success
|
||||
? t("cameraWizard.step3.disconnectStream")
|
||||
? t("cameraWizard.step4.disconnectStream")
|
||||
: testingStreams.has(stream.id)
|
||||
? t("cameraWizard.step3.connectingStream")
|
||||
: t("cameraWizard.step3.connectStream")}
|
||||
? t("cameraWizard.step4.connectingStream")
|
||||
: t("cameraWizard.step4.connectStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs">
|
||||
{t("cameraWizard.step3.issues.title")}
|
||||
{t("cameraWizard.step4.issues.title")}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<StreamIssues
|
||||
@ -455,7 +456,7 @@ export default function Step3Validation({
|
||||
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isLoading
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("cameraWizard.step3.saveAndApply")}
|
||||
: t("cameraWizard.step4.saveAndApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -486,7 +487,7 @@ function StreamIssues({
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -497,7 +498,7 @@ function StreamIssues({
|
||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||
codec: stream.testResult.videoCodec,
|
||||
}),
|
||||
});
|
||||
@ -511,20 +512,20 @@ function StreamIssues({
|
||||
if (audioCodec === "aac") {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||
codec: stream.testResult.audioCodec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
||||
message: t("cameraWizard.step4.issues.noAudioWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -534,7 +535,7 @@ function StreamIssues({
|
||||
if (!stream.testResult?.audioCodec) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRequired"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -544,7 +545,7 @@ function StreamIssues({
|
||||
if (stream.restream) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
||||
message: t("cameraWizard.step4.issues.restreamingWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -557,14 +558,14 @@ function StreamIssues({
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.resolutionHigh", {
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.resolutionLow", {
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
@ -580,7 +581,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@ -590,7 +591,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
|
||||
});
|
||||
}
|
||||
|
||||
@ -662,7 +663,7 @@ function BandwidthDisplay({
|
||||
return (
|
||||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("cameraWizard.step3.estimatedBandwidth")}:
|
||||
{t("cameraWizard.step4.estimatedBandwidth")}:
|
||||
</span>{" "}
|
||||
<span className="text-secondary-foreground">
|
||||
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||
@ -748,7 +749,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<span className="text-sm text-danger">
|
||||
{t("cameraWizard.step3.streamUnavailable")}
|
||||
{t("cameraWizard.step4.streamUnavailable")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -757,7 +758,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuRotateCcw className="size-4" />
|
||||
{t("cameraWizard.step3.reload")}
|
||||
{t("cameraWizard.step4.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -771,7 +772,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span className="ml-2 text-sm">
|
||||
{t("cameraWizard.step3.connecting")}
|
||||
{t("cameraWizard.step4.connecting")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -15,7 +15,7 @@ import useSWR from "swr";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Event } from "@/types/event";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import EventMenu from "@/components/timeline/EventMenu";
|
||||
@ -26,6 +26,7 @@ import { Link } from "react-router-dom";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
import { MdAutoAwesome } from "react-icons/md";
|
||||
|
||||
@ -192,7 +193,7 @@ export default function DetailStream({
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container flex-1 overflow-y-auto pb-14"
|
||||
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
|
||||
>
|
||||
<div className="space-y-4 py-2">
|
||||
{reviewItems?.length === 0 ? (
|
||||
@ -348,7 +349,7 @@ function ReviewGroup({
|
||||
? fetchedEvents.length
|
||||
: (review.data.objects ?? []).length;
|
||||
|
||||
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
||||
return `${t("detail.trackedObject", { count: objectCount })}`;
|
||||
}, [review, t, fetchedEvents]);
|
||||
|
||||
const reviewDuration = useMemo(
|
||||
@ -390,8 +391,8 @@ function ReviewGroup({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-3 flex w-full justify-between">
|
||||
<div className="ml-1 flex flex-col items-start gap-1.5">
|
||||
<div className="mr-3 grid w-full grid-cols-[1fr_auto] gap-2">
|
||||
<div className="ml-1 flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="text-sm font-medium">{displayTime}</div>
|
||||
<div className="relative flex items-center gap-2 text-white">
|
||||
@ -407,7 +408,7 @@ function ReviewGroup({
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{review.data.metadata?.title && (
|
||||
<div className="mb-1 flex items-center gap-1 text-sm text-primary-variant">
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1 text-sm text-primary-variant">
|
||||
<MdAutoAwesome className="size-3 shrink-0" />
|
||||
<span className="truncate">{review.data.metadata.title}</span>
|
||||
</div>
|
||||
@ -431,7 +432,7 @@ function ReviewGroup({
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="ml-2 inline-flex items-center justify-center rounded p-1 hover:bg-secondary/10"
|
||||
className="inline-flex items-center justify-center self-center rounded p-1 hover:bg-secondary/10"
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronDown className="size-4 text-primary-variant" />
|
||||
@ -477,7 +478,7 @@ function ReviewGroup({
|
||||
<div className="rounded-full bg-muted-foreground p-1">
|
||||
{getIconForLabel(audioLabel, "size-3 text-white")}
|
||||
</div>
|
||||
<span>{getTranslatedLabel(audioLabel)}</span>
|
||||
<span>{getTranslatedLabel(audioLabel, "audio")}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -512,7 +513,8 @@ function EventList({
|
||||
|
||||
const isSelected = selectedObjectIds.includes(event.id);
|
||||
|
||||
const label = event.sub_label || getTranslatedLabel(event.label);
|
||||
const label =
|
||||
event.sub_label || getTranslatedLabel(event.label, event.data.type);
|
||||
|
||||
const handleObjectSelect = (event: Event | undefined) => {
|
||||
if (event) {
|
||||
@ -793,17 +795,29 @@ function ObjectTimeline({
|
||||
},
|
||||
]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timeline = useMemo(() => {
|
||||
if (!fullTimeline) {
|
||||
return fullTimeline;
|
||||
}
|
||||
|
||||
return fullTimeline.filter(
|
||||
(t) =>
|
||||
t.timestamp >= review.start_time &&
|
||||
(review.end_time == undefined || t.timestamp <= review.end_time),
|
||||
);
|
||||
}, [fullTimeline, review]);
|
||||
return fullTimeline
|
||||
.filter(
|
||||
(t) =>
|
||||
t.timestamp >= review.start_time - REVIEW_PADDING &&
|
||||
(review.end_time == undefined ||
|
||||
t.timestamp <= review.end_time + REVIEW_PADDING),
|
||||
)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
data: {
|
||||
...event.data,
|
||||
zones_friendly_names: event.data?.zones?.map((zone) =>
|
||||
resolveZoneName(config, zone),
|
||||
),
|
||||
},
|
||||
}));
|
||||
}, [config, fullTimeline, review]);
|
||||
|
||||
if (isValidating && (!timeline || timeline.length === 0)) {
|
||||
return <ActivityIndicator className="ml-2 size-3" />;
|
||||
@ -811,7 +825,7 @@ function ObjectTimeline({
|
||||
|
||||
if (!timeline || timeline.length === 0) {
|
||||
return (
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
<div className="ml-8 text-sm text-muted-foreground">
|
||||
{t("detail.noObjectDetailData")}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -55,20 +55,24 @@ export default function EventMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={handleObjectSelect}>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={handleObjectSelect}
|
||||
>
|
||||
{isSelected
|
||||
? t("itemMenu.hideObjectDetails.label")
|
||||
: t("itemMenu.showObjectDetails.label")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-0.5" />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => {
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
}}
|
||||
>
|
||||
{t("details.item.button.viewInExplore")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<DropdownMenuItem className="cursor-pointer" asChild>
|
||||
<a
|
||||
download
|
||||
href={
|
||||
@ -86,6 +90,7 @@ export default function EventMenu({
|
||||
event.data.type == "object" &&
|
||||
config?.plus?.enabled && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => {
|
||||
setIsOpen(false);
|
||||
onOpenUpload?.(event);
|
||||
@ -97,6 +102,7 @@ export default function EventMenu({
|
||||
|
||||
{event.has_snapshot && config?.semantic_search?.enabled && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => {
|
||||
if (onOpenSimilarity) onOpenSimilarity(event);
|
||||
else
|
||||
|
||||
@ -515,7 +515,7 @@ export function ReviewTimeline({
|
||||
<div
|
||||
className={`absolute z-30 flex gap-2 ${
|
||||
isMobile
|
||||
? "bottom-4 right-1 flex-col gap-3"
|
||||
? "bottom-4 right-1 flex-col-reverse gap-3"
|
||||
: "bottom-2 left-1/2 -translate-x-1/2"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
|
||||
@ -36,6 +42,23 @@ export function DetailStreamProvider({
|
||||
() => initialSelectedObjectIds ?? [],
|
||||
);
|
||||
|
||||
// When the parent provides a new initialSelectedObjectIds (for example
|
||||
// when navigating between search results) update the selection so children
|
||||
// like `ObjectTrackOverlay` receive the new ids immediately. We only
|
||||
// perform this update when the incoming value actually changes.
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialSelectedObjectIds &&
|
||||
(initialSelectedObjectIds.length !== selectedObjectIds.length ||
|
||||
initialSelectedObjectIds.some((v, i) => selectedObjectIds[i] !== v))
|
||||
) {
|
||||
setSelectedObjectIds(initialSelectedObjectIds);
|
||||
}
|
||||
// Intentionally include selectedObjectIds to compare previous value and
|
||||
// avoid overwriting user interactions unless the incoming prop changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialSelectedObjectIds]);
|
||||
|
||||
const toggleObjectSelection = (id: string | undefined) => {
|
||||
if (id === undefined) {
|
||||
setSelectedObjectIds([]);
|
||||
@ -63,10 +86,33 @@ export function DetailStreamProvider({
|
||||
setAnnotationOffset(cfgOffset);
|
||||
}, [config, camera]);
|
||||
|
||||
// Clear selected objects when exiting detail mode or changing cameras
|
||||
// Clear selected objects when exiting detail mode or when the camera
|
||||
// changes for providers that are not initialized with an explicit
|
||||
// `initialSelectedObjectIds` (e.g., the RecordingView). For providers
|
||||
// that receive `initialSelectedObjectIds` (like SearchDetailDialog) we
|
||||
// avoid clearing on camera change to prevent a race with children that
|
||||
// immediately set selection when mounting.
|
||||
const prevCameraRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
setSelectedObjectIds([]);
|
||||
}, [isDetailMode, camera]);
|
||||
// Always clear when leaving detail mode
|
||||
if (!isDetailMode) {
|
||||
setSelectedObjectIds([]);
|
||||
prevCameraRef.current = camera;
|
||||
return;
|
||||
}
|
||||
|
||||
// If camera changed and the parent did not provide initialSelectedObjectIds,
|
||||
// clear selection to preserve previous behavior.
|
||||
if (
|
||||
prevCameraRef.current !== undefined &&
|
||||
prevCameraRef.current !== camera &&
|
||||
initialSelectedObjectIds === undefined
|
||||
) {
|
||||
setSelectedObjectIds([]);
|
||||
}
|
||||
|
||||
prevCameraRef.current = camera;
|
||||
}, [isDetailMode, camera, initialSelectedObjectIds]);
|
||||
|
||||
const value: DetailStreamContextType = {
|
||||
selectedObjectIds,
|
||||
|
||||
@ -6,6 +6,7 @@ import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
|
||||
export default function useCameraLiveMode(
|
||||
cameras: CameraConfig[],
|
||||
windowVisible: boolean,
|
||||
activeStreams?: { [cameraName: string]: string },
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -20,16 +21,20 @@ export default function useCameraLiveMode(
|
||||
);
|
||||
|
||||
if (isRestreamed) {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
streamNames.add(streamName);
|
||||
});
|
||||
if (activeStreams && activeStreams[camera.name]) {
|
||||
streamNames.add(activeStreams[camera.name]);
|
||||
} else {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
streamNames.add(streamName);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return streamNames.size > 0
|
||||
? Array.from(streamNames).sort().join(",")
|
||||
: null;
|
||||
}, [cameras, config]);
|
||||
}, [cameras, config, activeStreams]);
|
||||
|
||||
const streamsFetcher = useCallback(async (key: string) => {
|
||||
const streamNames = key.split(",");
|
||||
@ -68,7 +73,9 @@ export default function useCameraLiveMode(
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(restreamedStreamsKey, streamsFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
});
|
||||
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
|
||||
41
web/src/hooks/use-zone-friendly-name.ts
Normal file
41
web/src/hooks/use-zone-friendly-name.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
export function resolveZoneName(
|
||||
config: FrigateConfig | undefined,
|
||||
zoneId: string,
|
||||
cameraId?: string,
|
||||
) {
|
||||
if (!config) return String(zoneId).replace(/_/g, " ");
|
||||
|
||||
if (cameraId) {
|
||||
const camera = config.cameras?.[String(cameraId)];
|
||||
const zone = camera?.zones?.[zoneId];
|
||||
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
for (const camKey in config.cameras) {
|
||||
if (!Object.prototype.hasOwnProperty.call(config.cameras, camKey)) continue;
|
||||
const cam = config.cameras[camKey];
|
||||
if (!cam?.zones) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) {
|
||||
const zone = cam.zones[zoneId];
|
||||
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return a cleaned-up zoneId string
|
||||
return String(zoneId).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
export function useZoneFriendlyName(zoneId: string, cameraId?: string): string {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const name = useMemo(
|
||||
() => resolveZoneName(config, zoneId, cameraId),
|
||||
[config, cameraId, zoneId],
|
||||
);
|
||||
|
||||
return name;
|
||||
}
|
||||
@ -622,7 +622,15 @@ type TrainingGridProps = {
|
||||
faceNames: string[];
|
||||
selectedFaces: string[];
|
||||
onClickFaces: (images: string[], ctrl: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
onRefresh: (
|
||||
data?:
|
||||
| FaceLibraryData
|
||||
| Promise<FaceLibraryData>
|
||||
| ((
|
||||
currentData: FaceLibraryData | undefined,
|
||||
) => FaceLibraryData | undefined),
|
||||
opts?: boolean | { revalidate?: boolean },
|
||||
) => Promise<FaceLibraryData | undefined>;
|
||||
};
|
||||
function TrainingGrid({
|
||||
config,
|
||||
@ -726,7 +734,15 @@ type FaceAttemptGroupProps = {
|
||||
faceNames: string[];
|
||||
selectedFaces: string[];
|
||||
onClickFaces: (image: string[], ctrl: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
onRefresh: (
|
||||
data?:
|
||||
| FaceLibraryData
|
||||
| Promise<FaceLibraryData>
|
||||
| ((
|
||||
currentData: FaceLibraryData | undefined,
|
||||
) => FaceLibraryData | undefined),
|
||||
opts?: boolean | { revalidate?: boolean },
|
||||
) => Promise<FaceLibraryData | undefined>;
|
||||
};
|
||||
function FaceAttemptGroup({
|
||||
config,
|
||||
@ -814,11 +830,44 @@ function FaceAttemptGroup({
|
||||
axios
|
||||
.post(`/faces/reprocess`, { training_file: data.filename })
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(t("toast.success.updatedFaceScore"), {
|
||||
position: "top-center",
|
||||
});
|
||||
onRefresh();
|
||||
if (resp.status == 200 && resp.data?.success) {
|
||||
const { face_name, score } = resp.data;
|
||||
const oldFilename = data.filename;
|
||||
const parts = oldFilename.split("-");
|
||||
const newFilename = `${parts[0]}-${parts[1]}-${parts[2]}-${face_name}-${score}.webp`;
|
||||
|
||||
onRefresh(
|
||||
(currentData: FaceLibraryData | undefined) => {
|
||||
if (!currentData?.train) return currentData;
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
train: currentData.train.map((filename: string) =>
|
||||
filename === oldFilename ? newFilename : filename,
|
||||
),
|
||||
};
|
||||
},
|
||||
{ revalidate: true },
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("toast.success.updatedFaceScore", {
|
||||
name: face_name,
|
||||
score: score.toFixed(2),
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
} else if (resp.data?.success === false) {
|
||||
// Handle case where API returns success: false
|
||||
const errorMessage = resp.data?.message || "Unknown error";
|
||||
toast.error(
|
||||
t("toast.error.updateFaceScoreFailed", { errorMessage }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@ -42,7 +42,7 @@ import { useInitialCameraState } from "@/api/ws";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TriggerView from "@/views/settings/TriggerView";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@ -650,7 +650,7 @@ function CameraSelectButton({
|
||||
key={item.name}
|
||||
isChecked={item.name === selectedCamera}
|
||||
label={item.name}
|
||||
isCameraName={true}
|
||||
type={"camera"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
||||
setSelectedCamera(item.name);
|
||||
|
||||
@ -99,6 +99,11 @@ export type TestResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CandidateTestMap = Record<
|
||||
string,
|
||||
TestResult | { success: false; error: string }
|
||||
>;
|
||||
|
||||
export type WizardFormData = {
|
||||
cameraName?: string;
|
||||
host?: string;
|
||||
@ -107,12 +112,18 @@ export type WizardFormData = {
|
||||
brandTemplate?: CameraBrand;
|
||||
customUrl?: string;
|
||||
streams?: StreamConfig[];
|
||||
probeMode?: boolean; // true for probe, false for manual
|
||||
onvifPort?: number;
|
||||
useDigestAuth?: boolean;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
probeCandidates?: string[]; // candidate URLs from probe
|
||||
candidateTests?: CandidateTestMap; // test results for candidates
|
||||
};
|
||||
|
||||
// API Response Types
|
||||
export type FfprobeResponse = {
|
||||
return_code: number;
|
||||
stderr: string;
|
||||
stderr: string | string[];
|
||||
stdout: FfprobeData | string;
|
||||
};
|
||||
|
||||
@ -167,3 +178,26 @@ export type ConfigSetBody = {
|
||||
config_data: CameraConfigData;
|
||||
update_topic?: string;
|
||||
};
|
||||
|
||||
export type OnvifRtspCandidate = {
|
||||
source: "GetStreamUri" | "pattern";
|
||||
profile_token?: string;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export type OnvifProbeResponse = {
|
||||
success: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
firmware_version?: string;
|
||||
profiles_count?: number;
|
||||
ptz_supported?: boolean;
|
||||
presets_count?: number;
|
||||
autotrack_supported?: boolean;
|
||||
move_status_supported?: boolean;
|
||||
rtsp_candidates?: OnvifRtspCandidate[];
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
@ -11,10 +11,12 @@ export type Polygon = {
|
||||
distances: number[];
|
||||
isFinished: boolean;
|
||||
color: number[];
|
||||
friendly_name?: string;
|
||||
};
|
||||
|
||||
export type ZoneFormValuesType = {
|
||||
name: string;
|
||||
friendly_name: string;
|
||||
inertia: number;
|
||||
loitering_time: number;
|
||||
isFinished: boolean;
|
||||
|
||||
@ -20,3 +20,17 @@ export type ClassificationThreshold = {
|
||||
recognition: number;
|
||||
unknown: number;
|
||||
};
|
||||
|
||||
export type ClassificationDatasetResponse = {
|
||||
categories: {
|
||||
[id: string]: string[];
|
||||
};
|
||||
training_metadata: {
|
||||
has_trained: boolean;
|
||||
last_training_date: string | null;
|
||||
last_training_image_count: number;
|
||||
current_image_count: number;
|
||||
new_images_count: number;
|
||||
dataset_changed: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@ -280,6 +280,7 @@ export interface CameraConfig {
|
||||
speed_threshold: number;
|
||||
objects: string[];
|
||||
color: number[];
|
||||
friendly_name?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ export type TrackingDetailsSequence = {
|
||||
attribute: string;
|
||||
attribute_box?: [number, number, number, number];
|
||||
zones: string[];
|
||||
zones_friendly_names?: string[];
|
||||
};
|
||||
class_type: LifecycleClassType;
|
||||
source_id: string;
|
||||
|
||||
@ -87,7 +87,8 @@ export type ModelState =
|
||||
| "downloaded"
|
||||
| "error"
|
||||
| "training"
|
||||
| "complete";
|
||||
| "complete"
|
||||
| "failed";
|
||||
|
||||
export type EmbeddingsReindexProgressType = {
|
||||
thumbnails: number;
|
||||
|
||||
@ -71,3 +71,26 @@ export async function detectReolinkCamera(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask credentials in RTSP URIs for display
|
||||
*/
|
||||
export function maskUri(uri: string): string {
|
||||
try {
|
||||
// Handle RTSP URLs with user:pass@host format
|
||||
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
|
||||
if (rtspMatch) {
|
||||
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
|
||||
}
|
||||
|
||||
// Handle HTTP/HTTPS URLs with password query parameter
|
||||
const urlObj = new URL(uri);
|
||||
if (urlObj.searchParams.has("password")) {
|
||||
urlObj.searchParams.set("password", "*".repeat(4));
|
||||
return urlObj.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
@ -244,12 +244,12 @@ export const getDurationFromTimestamps = (
|
||||
abbreviated: boolean = false,
|
||||
): string => {
|
||||
if (isNaN(start_time)) {
|
||||
return "Invalid start time";
|
||||
return i18n.t("time.invalidStartTime", { ns: "common" });
|
||||
}
|
||||
let duration = "In Progress";
|
||||
let duration = i18n.t("time.inProgress", { ns: "common" });
|
||||
if (end_time !== null) {
|
||||
if (isNaN(end_time)) {
|
||||
return "Invalid end time";
|
||||
return i18n.t("time.invalidEndTime", { ns: "common" });
|
||||
}
|
||||
const start = fromUnixTime(start_time);
|
||||
const end = fromUnixTime(end_time);
|
||||
|
||||
@ -1,25 +1,7 @@
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
import { t } from "i18next";
|
||||
import { getTranslatedLabel } from "./i18n";
|
||||
import { capitalizeFirstLetter } from "./stringUtil";
|
||||
|
||||
function formatZonesList(zones: string[]): string {
|
||||
if (zones.length === 0) return "";
|
||||
if (zones.length === 1) return zones[0];
|
||||
if (zones.length === 2) {
|
||||
return t("list.two", {
|
||||
0: zones[0],
|
||||
1: zones[1],
|
||||
});
|
||||
}
|
||||
|
||||
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
|
||||
const allButLast = zones.slice(0, -1).join(separatorWithSpace);
|
||||
return t("list.many", {
|
||||
items: allButLast,
|
||||
last: zones[zones.length - 1],
|
||||
});
|
||||
}
|
||||
import { capitalizeFirstLetter, formatList } from "./stringUtil";
|
||||
|
||||
export function getLifecycleItemDescription(
|
||||
lifecycleItem: TrackingDetailsSequence,
|
||||
@ -42,7 +24,9 @@ export function getLifecycleItemDescription(
|
||||
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
zones: formatZonesList(lifecycleItem.data.zones),
|
||||
zones: formatList(
|
||||
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
|
||||
),
|
||||
});
|
||||
case "active":
|
||||
return t("trackingDetails.lifecycleItemDesc.active", {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { t } from "i18next";
|
||||
|
||||
export const capitalizeFirstLetter = (text: string): string => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
@ -19,20 +21,30 @@ export const capitalizeAll = (text: string): string => {
|
||||
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
||||
*/
|
||||
export function generateFixedHash(name: string, prefix: string = "id"): string {
|
||||
// Safely encode Unicode as UTF-8 bytes
|
||||
// Use the full UTF-8 bytes of the name and compute an FNV-1a 32-bit hash.
|
||||
// This is deterministic, fast, works with Unicode and avoids collisions from
|
||||
// simple truncation of base64 output.
|
||||
const utf8Bytes = new TextEncoder().encode(name);
|
||||
|
||||
// Convert to base64 manually
|
||||
let binary = "";
|
||||
for (const byte of utf8Bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
// FNV-1a 32-bit hash algorithm
|
||||
let hash = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < utf8Bytes.length; i++) {
|
||||
hash ^= utf8Bytes[i];
|
||||
// Multiply by FNV prime (0x01000193) with 32-bit overflow
|
||||
hash = (hash >>> 0) * 0x01000193;
|
||||
// Ensure 32-bit unsigned integer
|
||||
hash >>>= 0;
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
|
||||
// Strip out non-alphanumeric characters and truncate
|
||||
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
||||
// Convert to an 8-character lowercase hex string
|
||||
const hashHex = (hash >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
||||
|
||||
return `${prefix}_${cleanHash.toLowerCase()}`;
|
||||
// Ensure the first character is a letter to avoid an identifier that's purely
|
||||
// numeric (isValidId forbids all-digit IDs). If it starts with a digit,
|
||||
// replace with 'a'. This is extremely unlikely but a simple safeguard.
|
||||
const safeHash = /^[0-9]/.test(hashHex[0]) ? `a${hashHex.slice(1)}` : hashHex;
|
||||
|
||||
return `${prefix}_${safeHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,3 +57,29 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
|
||||
export function isValidId(name: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a list of strings into a human-readable format with proper localization.
|
||||
* Handles different cases for empty, single-item, two-item, and multi-item lists.
|
||||
*
|
||||
* @param item - The array of strings to format
|
||||
* @returns A formatted string representation of the list
|
||||
*/
|
||||
export function formatList(item: string[]): string {
|
||||
if (item.length === 0) return "";
|
||||
if (item.length === 1) return item[0];
|
||||
if (item.length === 2) {
|
||||
return t("list.two", {
|
||||
0: item[0],
|
||||
1: item[1],
|
||||
ns: "common",
|
||||
});
|
||||
}
|
||||
|
||||
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
|
||||
const allButLast = item.slice(0, -1).join(separatorWithSpace);
|
||||
return t("list.many", {
|
||||
items: allButLast,
|
||||
last: item[item.length - 1],
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
CustomClassificationModelConfig,
|
||||
FrigateConfig,
|
||||
} from "@/types/frigateConfig";
|
||||
import { ClassificationDatasetResponse } from "@/types/classification";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolderPlus } from "react-icons/fa";
|
||||
@ -209,9 +210,10 @@ type ModelCardProps = {
|
||||
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
const { t } = useTranslation(["views/classificationModel"]);
|
||||
|
||||
const { data: dataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
||||
const { data: dataset } = useSWR<ClassificationDatasetResponse>(
|
||||
`classification/${config.name}/dataset`,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
@ -260,20 +262,25 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
}, []);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
if (!dataset) {
|
||||
if (!dataset || !dataset.categories) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys = Object.keys(dataset).filter((key) => key != "none");
|
||||
const selectedKey = keys[0];
|
||||
const keys = Object.keys(dataset.categories).filter((key) => key != "none");
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!dataset[selectedKey]) {
|
||||
const selectedKey = keys[0];
|
||||
const images = dataset.categories[selectedKey];
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name: selectedKey,
|
||||
img: dataset[selectedKey][0],
|
||||
img: images[0],
|
||||
};
|
||||
}, [dataset]);
|
||||
|
||||
@ -317,11 +324,19 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
className="size-full"
|
||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
|
||||
/>
|
||||
<ImageShadowOverlay lowerClassName="h-[30%] z-0" />
|
||||
{coverImage ? (
|
||||
<>
|
||||
<img
|
||||
className="size-full"
|
||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage.name}/${coverImage.img}`}
|
||||
/>
|
||||
<ImageShadowOverlay lowerClassName="h-[30%] z-0" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center bg-background_alt">
|
||||
<MdModelTraining className="size-16 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
|
||||
{config.name}
|
||||
</div>
|
||||
|
||||
@ -59,7 +59,11 @@ import { useNavigate } from "react-router-dom";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import TrainFilterDialog from "@/components/overlay/dialog/TrainFilterDialog";
|
||||
import useApiFilter from "@/hooks/use-api-filter";
|
||||
import { ClassificationItemData, TrainFilter } from "@/types/classification";
|
||||
import {
|
||||
ClassificationDatasetResponse,
|
||||
ClassificationItemData,
|
||||
TrainFilter,
|
||||
} from "@/types/classification";
|
||||
import {
|
||||
ClassificationCard,
|
||||
GroupedClassificationCard,
|
||||
@ -102,6 +106,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
position: "top-center",
|
||||
});
|
||||
setWasTraining(false);
|
||||
refreshDataset();
|
||||
} else if (modelState == "failed") {
|
||||
toast.error(t("toast.error.trainingFailed"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setWasTraining(false);
|
||||
}
|
||||
// only refresh when modelState changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -112,12 +122,21 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>(
|
||||
`classification/${model.name}/train`,
|
||||
);
|
||||
const { data: dataset, mutate: refreshDataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
}>(`classification/${model.name}/dataset`);
|
||||
const { data: datasetResponse, mutate: refreshDataset } =
|
||||
useSWR<ClassificationDatasetResponse>(
|
||||
`classification/${model.name}/dataset`,
|
||||
);
|
||||
|
||||
const dataset = datasetResponse?.categories || {};
|
||||
const trainingMetadata = datasetResponse?.training_metadata;
|
||||
|
||||
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
refreshTrain();
|
||||
refreshDataset();
|
||||
}, [refreshTrain, refreshDataset]);
|
||||
|
||||
// image multiselect
|
||||
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
||||
@ -172,7 +191,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
toast.error(t("toast.error.trainingFailed", { errorMessage }), {
|
||||
toast.error(t("toast.error.trainingFailedToStart", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
@ -182,12 +201,44 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
null,
|
||||
);
|
||||
|
||||
const onRename = useCallback(
|
||||
(old_name: string, new_name: string) => {
|
||||
axios
|
||||
.put(`/classification/${model.name}/dataset/${old_name}/rename`, {
|
||||
new_category: new_name,
|
||||
})
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(
|
||||
t("toast.success.renamedCategory", { name: new_name }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
setPageToggle(new_name);
|
||||
refreshDataset();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("toast.error.renameCategoryFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[model, setPageToggle, refreshDataset, t],
|
||||
);
|
||||
|
||||
const onDelete = useCallback(
|
||||
(ids: string[], isName: boolean = false) => {
|
||||
(ids: string[], isName: boolean = false, category?: string) => {
|
||||
const targetCategory = category || pageToggle;
|
||||
const api =
|
||||
pageToggle == "train"
|
||||
targetCategory == "train"
|
||||
? `/classification/${model.name}/train/delete`
|
||||
: `/classification/${model.name}/dataset/${pageToggle}/delete`;
|
||||
: `/classification/${model.name}/dataset/${targetCategory}/delete`;
|
||||
|
||||
axios
|
||||
.post(api, { ids })
|
||||
@ -211,10 +262,11 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Always refresh dataset to update the categories list
|
||||
refreshDataset();
|
||||
|
||||
if (pageToggle == "train") {
|
||||
refreshTrain();
|
||||
} else {
|
||||
refreshDataset();
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -348,7 +400,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
trainImages={trainImages || []}
|
||||
setPageToggle={setPageToggle}
|
||||
onDelete={onDelete}
|
||||
onRename={() => {}}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -384,19 +436,48 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
filterValues={{ classes: Object.keys(dataset || {}) }}
|
||||
onUpdateFilter={setTrainFilter}
|
||||
/>
|
||||
<Button
|
||||
className="flex justify-center gap-2"
|
||||
onClick={trainModel}
|
||||
variant="select"
|
||||
disabled={modelState != "complete"}
|
||||
>
|
||||
{modelState == "training" ? (
|
||||
<ActivityIndicator size={20} />
|
||||
) : (
|
||||
<HiSparkles className="text-white" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="flex justify-center gap-2"
|
||||
onClick={trainModel}
|
||||
variant={modelState == "failed" ? "destructive" : "select"}
|
||||
disabled={
|
||||
(modelState != "complete" && modelState != "failed") ||
|
||||
!trainingMetadata?.dataset_changed
|
||||
}
|
||||
>
|
||||
{modelState == "training" ? (
|
||||
<ActivityIndicator size={20} />
|
||||
) : (
|
||||
<HiSparkles className="text-white" />
|
||||
)}
|
||||
{isDesktop && (
|
||||
<>
|
||||
{t("button.trainModel")}
|
||||
{trainingMetadata?.new_images_count !== undefined &&
|
||||
trainingMetadata.new_images_count > 0 && (
|
||||
<span className="text-sm text-selected-foreground">
|
||||
({trainingMetadata.new_images_count})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{(!trainingMetadata?.dataset_changed ||
|
||||
(modelState != "complete" && modelState != "failed")) && (
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{modelState == "training"
|
||||
? t("tooltip.trainingInProgress")
|
||||
: !trainingMetadata?.dataset_changed
|
||||
? t("tooltip.noChanges")
|
||||
: t("tooltip.modelNotReady")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
)}
|
||||
{isDesktop && t("button.trainModel")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -408,7 +489,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
trainImages={trainImages || []}
|
||||
trainFilter={trainFilter}
|
||||
selectedImages={selectedImages}
|
||||
onRefresh={refreshTrain}
|
||||
onRefresh={refreshAll}
|
||||
onClickImages={onClickImages}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
@ -432,7 +513,7 @@ type LibrarySelectorProps = {
|
||||
dataset: { [id: string]: string[] };
|
||||
trainImages: string[];
|
||||
setPageToggle: (toggle: string) => void;
|
||||
onDelete: (ids: string[], isName: boolean) => void;
|
||||
onDelete: (ids: string[], isName: boolean, category?: string) => void;
|
||||
onRename: (old_name: string, new_name: string) => void;
|
||||
};
|
||||
function LibrarySelector({
|
||||
@ -448,7 +529,7 @@ function LibrarySelector({
|
||||
// data
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [renameClass, setRenameFace] = useState<string | null>(null);
|
||||
const [renameClass, setRenameClass] = useState<string | null>(null);
|
||||
const pageTitle = useMemo(() => {
|
||||
if (pageToggle != "train") {
|
||||
return pageToggle;
|
||||
@ -463,12 +544,12 @@ function LibrarySelector({
|
||||
|
||||
// interaction
|
||||
|
||||
const handleDeleteFace = useCallback(
|
||||
const handleDeleteCategory = useCallback(
|
||||
(name: string) => {
|
||||
// Get all image IDs for this face
|
||||
// Get all image IDs for this category
|
||||
const imageIds = dataset?.[name] || [];
|
||||
|
||||
onDelete(imageIds, true);
|
||||
onDelete(imageIds, true, name);
|
||||
setPageToggle("train");
|
||||
},
|
||||
[dataset, onDelete, setPageToggle],
|
||||
@ -476,7 +557,7 @@ function LibrarySelector({
|
||||
|
||||
const handleSetOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
setRenameFace(open ? renameClass : null);
|
||||
setRenameClass(open ? renameClass : null);
|
||||
},
|
||||
[renameClass],
|
||||
);
|
||||
@ -489,27 +570,44 @@ function LibrarySelector({
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("deleteCategory.title")}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{Object.keys(dataset).length <= 2
|
||||
? t("deleteCategory.minClassesTitle")
|
||||
: t("deleteCategory.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deleteCategory.desc", { name: confirmDelete })}
|
||||
{Object.keys(dataset).length <= 2
|
||||
? t("deleteCategory.minClassesDesc")
|
||||
: t("deleteCategory.desc", { name: confirmDelete })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteFace(confirmDelete);
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
{Object.keys(dataset).length <= 2 ? (
|
||||
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
|
||||
{t("button.ok", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteCategory(confirmDelete);
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -521,14 +619,14 @@ function LibrarySelector({
|
||||
description={t("renameCategory.desc", { name: renameClass })}
|
||||
onSave={(newName) => {
|
||||
onRename(renameClass!, newName);
|
||||
setRenameFace(null);
|
||||
setRenameClass(null);
|
||||
}}
|
||||
defaultValue={renameClass || ""}
|
||||
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
|
||||
regexErrorMessage={t("description.invalidName")}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="flex justify-between smart-capitalize">
|
||||
{pageTitle}
|
||||
@ -579,48 +677,50 @@ function LibrarySelector({
|
||||
({dataset?.[id].length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameFace(id);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-4 text-primary" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.renameCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(id);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.deleteCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{id != "none" && (
|
||||
<div className="flex gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameClass(id);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-4 text-primary" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.renameCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(id);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.deleteCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@ -739,17 +839,11 @@ function TrainGrid({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
trainFilter.min_score &&
|
||||
trainFilter.min_score > data.score / 100.0
|
||||
) {
|
||||
if (trainFilter.min_score && trainFilter.min_score > data.score) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
trainFilter.max_score &&
|
||||
trainFilter.max_score < data.score / 100.0
|
||||
) {
|
||||
if (trainFilter.max_score && trainFilter.max_score < data.score) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -236,10 +236,6 @@ function ExploreThumbnailImage({
|
||||
onSelectSearch(event, false, "tracking_details");
|
||||
};
|
||||
|
||||
const handleShowSnapshot = () => {
|
||||
onSelectSearch(event, false, "snapshot");
|
||||
};
|
||||
|
||||
const handleAddTrigger = () => {
|
||||
navigate(
|
||||
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
|
||||
@ -252,7 +248,6 @@ function ExploreThumbnailImage({
|
||||
findSimilar={handleFindSimilar}
|
||||
refreshResults={mutate}
|
||||
showTrackingDetails={handleShowTrackingDetails}
|
||||
showSnapshot={handleShowSnapshot}
|
||||
addTrigger={handleAddTrigger}
|
||||
isContextMenu={true}
|
||||
>
|
||||
|
||||
@ -86,14 +86,6 @@ export default function DraggableGridLayout({
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
|
||||
@ -106,6 +98,33 @@ export default function DraggableGridLayout({
|
||||
}
|
||||
}, [allGroupsStreamingSettings, cameraGroup]);
|
||||
|
||||
const activeStreams = useMemo(() => {
|
||||
const streams: { [cameraName: string]: string } = {};
|
||||
cameras.forEach((camera) => {
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const streamNameFromSettings =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
|
||||
const streamExists =
|
||||
streamNameFromSettings &&
|
||||
Object.values(availableStreams).includes(streamNameFromSettings);
|
||||
|
||||
const streamName = streamExists
|
||||
? streamNameFromSettings
|
||||
: Object.values(availableStreams)[0] || "";
|
||||
|
||||
streams[camera.name] = streamName;
|
||||
});
|
||||
return streams;
|
||||
}, [cameras, currentGroupStreamingSettings]);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
// grid layout
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
@ -162,6 +162,9 @@ export default function LiveCameraView({
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
},
|
||||
);
|
||||
|
||||
@ -1027,294 +1030,298 @@ function FrigateCameraFeatures({
|
||||
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||
loading={isSnapshotLoading}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0",
|
||||
)}
|
||||
>
|
||||
<FaCog
|
||||
className={`text-secondary-foreground" size-5 md:m-[6px]`}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-96">
|
||||
<div className="flex flex-col gap-5 p-4">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>
|
||||
{t("streaming.label", { ns: "components/dialog" })}
|
||||
</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
{t("streaming.restreaming.disabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("streaming.restreaming.desc.title", {
|
||||
{!fullscreen && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0",
|
||||
)}
|
||||
>
|
||||
<FaCog
|
||||
className={`text-secondary-foreground" size-5 md:m-[6px]`}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-96">
|
||||
<div className="flex flex-col gap-5 p-4">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>
|
||||
{t("streaming.label", { ns: "components/dialog" })}
|
||||
</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
{t("streaming.restreaming.disabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="streaming-method">
|
||||
{t("stream.title")}
|
||||
</Label>
|
||||
<Select
|
||||
value={streamName}
|
||||
disabled={debug}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<>
|
||||
<LuX className="size-8 text-danger" />
|
||||
<div>{t("stream.debug.picker")}</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("stream.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.twoWayTalk.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live/#webrtc-extra-configuration",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode == "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
|
||||
<p className="text-sm">
|
||||
{t("stream.lowBandwidth.tips")}
|
||||
</p>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("streaming.restreaming.desc.title", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="streaming-method">
|
||||
{t("stream.title")}
|
||||
</Label>
|
||||
<Select
|
||||
value={streamName}
|
||||
disabled={debug}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<>
|
||||
<LuX className="size-8 text-danger" />
|
||||
<div>{t("stream.debug.picker")}</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("stream.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.twoWayTalk.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live/#webrtc-extra-configuration",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode == "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
|
||||
<p className="text-sm">
|
||||
{t("stream.lowBandwidth.tips")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="backgroundplay"
|
||||
>
|
||||
{t("stream.playInBackground.label")}
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="backgroundplay"
|
||||
disabled={debug}
|
||||
checked={playInBackground}
|
||||
onCheckedChange={(checked) =>
|
||||
setPlayInBackground(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("stream.playInBackground.tips")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="backgroundplay"
|
||||
htmlFor="showstats"
|
||||
>
|
||||
{t("stream.playInBackground.label")}
|
||||
{t("streaming.showStats.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="backgroundplay"
|
||||
id="showstats"
|
||||
disabled={debug}
|
||||
checked={playInBackground}
|
||||
onCheckedChange={(checked) =>
|
||||
setPlayInBackground(checked)
|
||||
}
|
||||
checked={showStats}
|
||||
onCheckedChange={(checked) => setShowStats(checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("stream.playInBackground.tips")}
|
||||
{t("streaming.showStats.desc", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="showstats"
|
||||
>
|
||||
{t("streaming.showStats.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="showstats"
|
||||
disabled={debug}
|
||||
checked={showStats}
|
||||
onCheckedChange={(checked) => setShowStats(checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("streaming.showStats.desc", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="debug"
|
||||
>
|
||||
{t("streaming.debugView", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="debug"
|
||||
checked={debug}
|
||||
onCheckedChange={(checked) => setDebug(checked)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="debug"
|
||||
>
|
||||
{t("streaming.debugView", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="debug"
|
||||
checked={debug}
|
||||
onCheckedChange={(checked) => setDebug(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -202,14 +202,6 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
|
||||
@ -239,6 +231,33 @@ export default function LiveDashboardView({
|
||||
[visibleCameraObserver.current],
|
||||
);
|
||||
|
||||
const activeStreams = useMemo(() => {
|
||||
const streams: { [cameraName: string]: string } = {};
|
||||
cameras.forEach((camera) => {
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const streamNameFromSettings =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
|
||||
const streamExists =
|
||||
streamNameFromSettings &&
|
||||
Object.values(availableStreams).includes(streamNameFromSettings);
|
||||
|
||||
const streamName = streamExists
|
||||
? streamNameFromSettings
|
||||
: Object.values(availableStreams)[0] || "";
|
||||
|
||||
streams[camera.name] = streamName;
|
||||
});
|
||||
return streams;
|
||||
}, [cameras, currentGroupStreamingSettings]);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
|
||||
const handleError = useCallback(
|
||||
|
||||
@ -63,7 +63,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { DetailStreamProvider } from "@/context/detail-stream-context";
|
||||
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
|
||||
@ -649,7 +649,7 @@ export function RecordingView({
|
||||
value="detail"
|
||||
aria-label="Detail Stream"
|
||||
>
|
||||
<div className="">Detail</div>
|
||||
<div className="">{t("detail.label")}</div>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : (
|
||||
@ -985,7 +985,7 @@ function Timeline({
|
||||
),
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
{isMobile && timelineType == "timeline" && (
|
||||
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
||||
)}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user