mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-20 12:06:43 +03:00
Compare commits
9 Commits
627d82e6f8
...
0ee480b4e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ee480b4e4 | ||
|
|
de066d0062 | ||
|
|
f1a05d0f9b | ||
|
|
a623150811 | ||
|
|
e4eac4ac81 | ||
|
|
c371fc0c87 | ||
|
|
99a363c047 | ||
|
|
a374a60756 | ||
|
|
05a357c71d |
@ -14,5 +14,5 @@ nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
|
|||||||
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
|
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
|
||||||
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
|
||||||
onnx==1.16.*; platform_machine == 'x86_64'
|
onnx==1.16.*; platform_machine == 'x86_64'
|
||||||
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
|
onnxruntime-gpu==1.23.*; platform_machine == 'x86_64'
|
||||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||||
|
|||||||
@ -3,11 +3,17 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from importlib.util import find_spec
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
import httpx
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
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.auth import require_role
|
||||||
from frigate.api.defs.tags import Tags
|
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)
|
return round(float(num) / float(den), 2)
|
||||||
except (ValueError, ZeroDivisionError):
|
except (ValueError, ZeroDivisionError):
|
||||||
return None
|
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 (
|
from frigate.util.classification import (
|
||||||
collect_object_classification_examples,
|
collect_object_classification_examples,
|
||||||
collect_state_classification_examples,
|
collect_state_classification_examples,
|
||||||
|
get_dataset_image_count,
|
||||||
|
read_training_metadata,
|
||||||
)
|
)
|
||||||
from frigate.util.file import get_event_snapshot
|
from frigate.util.file import get_event_snapshot
|
||||||
|
|
||||||
@ -564,23 +566,59 @@ def get_classification_dataset(name: str):
|
|||||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset")
|
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset")
|
||||||
|
|
||||||
if not os.path.exists(dataset_dir):
|
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):
|
for category_name in os.listdir(dataset_dir):
|
||||||
category_dir = os.path.join(dataset_dir, name)
|
category_dir = os.path.join(dataset_dir, category_name)
|
||||||
|
|
||||||
if not os.path.isdir(category_dir):
|
if not os.path.isdir(category_dir):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dataset_dict[name] = []
|
dataset_dict[category_name] = []
|
||||||
|
|
||||||
for file in filter(
|
for file in filter(
|
||||||
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
|
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
|
||||||
os.listdir(category_dir),
|
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(
|
@router.get(
|
||||||
@ -915,31 +953,29 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
|||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
summary="Delete a classification model",
|
summary="Delete a classification model",
|
||||||
description="""Deletes a specific classification model and all its associated data.
|
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):
|
def delete_classification_model(request: Request, name: str):
|
||||||
config: FrigateConfig = request.app.frigate_config
|
sanitized_name = sanitize_filename(name)
|
||||||
|
|
||||||
if name not in config.classification.custom:
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"message": f"{name} is not a known classification model.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete the classification model's data directory in clips
|
# 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):
|
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
|
# 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):
|
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(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
|
|||||||
@ -177,6 +177,12 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
def ffmpeg_cmds(self) -> list[dict[str, list[str]]]:
|
def ffmpeg_cmds(self) -> list[dict[str, list[str]]]:
|
||||||
return self._ffmpeg_cmds
|
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):
|
def create_ffmpeg_cmds(self):
|
||||||
if "_ffmpeg_cmds" in self:
|
if "_ffmpeg_cmds" in self:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -56,6 +56,12 @@ class ZoneConfig(BaseModel):
|
|||||||
def contour(self) -> np.ndarray:
|
def contour(self) -> np.ndarray:
|
||||||
return self._contour
|
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")
|
@field_validator("objects", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_objects(cls, v):
|
def validate_objects(cls, v):
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import sherpa_onnx
|
import sherpa_onnx
|
||||||
from faster_whisper.utils import download_model
|
|
||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.const import MODEL_CACHE_DIR
|
from frigate.const import MODEL_CACHE_DIR
|
||||||
@ -25,6 +24,9 @@ class AudioTranscriptionModelRunner:
|
|||||||
|
|
||||||
if model_size == "large":
|
if model_size == "large":
|
||||||
# use the Whisper download function instead of our own
|
# 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")
|
logger.debug("Downloading Whisper audio transcription model")
|
||||||
download_model(
|
download_model(
|
||||||
size_or_id="small" if device == "cuda" else "tiny",
|
size_or_id="small" if device == "cuda" else "tiny",
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from faster_whisper import WhisperModel
|
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
@ -51,6 +50,9 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
|||||||
|
|
||||||
def __build_recognizer(self) -> None:
|
def __build_recognizer(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
# Import dynamically to avoid crashes on systems without AVX support
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
self.recognizer = WhisperModel(
|
self.recognizer = WhisperModel(
|
||||||
model_size_or_path="small",
|
model_size_or_path="small",
|
||||||
device="cuda"
|
device="cuda"
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from peewee import DoesNotExist
|
|||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera import CameraConfig
|
||||||
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
|
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
|
||||||
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
|
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
|
||||||
from frigate.data_processing.types import PostProcessDataEnum
|
from frigate.data_processing.types import PostProcessDataEnum
|
||||||
@ -30,6 +31,7 @@ from ..types import DataProcessorMetrics
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
||||||
|
MIN_RECORDING_DURATION = 10
|
||||||
|
|
||||||
|
|
||||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||||
@ -130,7 +132,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
|
|
||||||
if image_source == ImageSourceEnum.recordings:
|
if image_source == ImageSourceEnum.recordings:
|
||||||
duration = final_data["end_time"] - final_data["start_time"]
|
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(
|
thumbs = self.get_recording_frames(
|
||||||
camera,
|
camera,
|
||||||
@ -182,7 +194,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
self.requestor,
|
self.requestor,
|
||||||
self.genai_client,
|
self.genai_client,
|
||||||
self.review_desc_speed,
|
self.review_desc_speed,
|
||||||
camera,
|
camera_config,
|
||||||
final_data,
|
final_data,
|
||||||
thumbs,
|
thumbs,
|
||||||
camera_config.review.genai,
|
camera_config.review.genai,
|
||||||
@ -411,7 +423,7 @@ def run_analysis(
|
|||||||
requestor: InterProcessRequestor,
|
requestor: InterProcessRequestor,
|
||||||
genai_client: GenAIClient,
|
genai_client: GenAIClient,
|
||||||
review_inference_speed: InferenceSpeed,
|
review_inference_speed: InferenceSpeed,
|
||||||
camera: str,
|
camera_config: CameraConfig,
|
||||||
final_data: dict[str, str],
|
final_data: dict[str, str],
|
||||||
thumbs: list[bytes],
|
thumbs: list[bytes],
|
||||||
genai_config: GenAIReviewConfig,
|
genai_config: GenAIReviewConfig,
|
||||||
@ -419,10 +431,19 @@ def run_analysis(
|
|||||||
attribute_labels: list[str],
|
attribute_labels: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
start = datetime.datetime.now().timestamp()
|
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 = {
|
analytics_data = {
|
||||||
"id": final_data["id"],
|
"id": final_data["id"],
|
||||||
"camera": camera,
|
"camera": camera_config.get_formatted_name(),
|
||||||
"zones": final_data["data"]["zones"],
|
"zones": formatted_zones,
|
||||||
"start": datetime.datetime.fromtimestamp(final_data["start_time"]).strftime(
|
"start": datetime.datetime.fromtimestamp(final_data["start_time"]).strftime(
|
||||||
"%A, %I:%M %p"
|
"%A, %I:%M %p"
|
||||||
),
|
),
|
||||||
|
|||||||
@ -227,6 +227,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
self.tensor_output_details[0]["index"]
|
self.tensor_output_details[0]["index"]
|
||||||
)[0]
|
)[0]
|
||||||
probs = res / res.sum(axis=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)
|
best_id = np.argmax(probs)
|
||||||
score = round(probs[best_id], 2)
|
score = round(probs[best_id], 2)
|
||||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||||
@ -455,6 +458,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
self.tensor_output_details[0]["index"]
|
self.tensor_output_details[0]["index"]
|
||||||
)[0]
|
)[0]
|
||||||
probs = res / res.sum(axis=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)
|
best_id = np.argmax(probs)
|
||||||
score = round(probs[best_id], 2)
|
score = round(probs[best_id], 2)
|
||||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||||
|
|||||||
@ -255,6 +255,7 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||||
self.model_path = model_path
|
self.model_path = model_path
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.model_type = model_type
|
||||||
|
|
||||||
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
|
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
|
||||||
model_type
|
model_type
|
||||||
@ -341,6 +342,13 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
# Lock prevents concurrent access to infer_request
|
# Lock prevents concurrent access to infer_request
|
||||||
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
|
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
|
||||||
with self._inference_lock:
|
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
|
# Handle single input case for backward compatibility
|
||||||
if (
|
if (
|
||||||
len(inputs) == 1
|
len(inputs) == 1
|
||||||
@ -394,7 +402,11 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||||
|
|
||||||
# Run inference
|
# Run inference
|
||||||
self.infer_request.infer()
|
try:
|
||||||
|
self.infer_request.infer()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during OpenVINO inference: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
# Get all output tensors
|
# Get all output tensors
|
||||||
outputs = []
|
outputs = []
|
||||||
@ -529,7 +541,7 @@ def get_optimized_runner(
|
|||||||
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not CudaGraphRunner.is_model_supported(model_type)
|
CudaGraphRunner.is_model_supported(model_type)
|
||||||
and providers[0] == "CUDAExecutionProvider"
|
and providers[0] == "CUDAExecutionProvider"
|
||||||
):
|
):
|
||||||
options[0] = {
|
options[0] = {
|
||||||
|
|||||||
@ -472,7 +472,7 @@ class Embeddings:
|
|||||||
)
|
)
|
||||||
thumbnail_missing = True
|
thumbnail_missing = True
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
logger.warning(
|
logger.debug(
|
||||||
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
|
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -51,8 +51,7 @@ class GenAIClient:
|
|||||||
def get_concern_prompt() -> str:
|
def get_concern_prompt() -> str:
|
||||||
if concerns:
|
if concerns:
|
||||||
concern_list = "\n - ".join(concerns)
|
concern_list = "\n - ".join(concerns)
|
||||||
return f"""
|
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
||||||
- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
|
||||||
- {concern_list}"""
|
- {concern_list}"""
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
@ -70,7 +69,7 @@ class GenAIClient:
|
|||||||
return "\n- (No objects detected)"
|
return "\n- (No objects detected)"
|
||||||
|
|
||||||
context_prompt = f"""
|
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
|
## Normal Activity Patterns for This Property
|
||||||
|
|
||||||
@ -110,7 +109,7 @@ Your response MUST be a flat JSON object with:
|
|||||||
|
|
||||||
- Frame 1 = earliest, Frame {len(thumbnails)} = latest
|
- Frame 1 = earliest, Frame {len(thumbnails)} = latest
|
||||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
- 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
|
## Objects in Scene
|
||||||
|
|
||||||
|
|||||||
@ -407,6 +407,19 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
segment.last_detection_time = frame_time
|
segment.last_detection_time = frame_time
|
||||||
|
|
||||||
for object in activity.get_all_objects():
|
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"]:
|
if not object["sub_label"]:
|
||||||
segment.detections[object["id"]] = object["label"]
|
segment.detections[object["id"]] = object["label"]
|
||||||
elif object["sub_label"][0] in self.config.model.all_attributes:
|
elif object["sub_label"][0] in self.config.model.all_attributes:
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class ModelStatusTypesEnum(str, Enum):
|
|||||||
error = "error"
|
error = "error"
|
||||||
training = "training"
|
training = "training"
|
||||||
complete = "complete"
|
complete = "complete"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
class TrackedObjectUpdateTypesEnum(str, Enum):
|
class TrackedObjectUpdateTypesEnum(str, Enum):
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Util for classification models."""
|
"""Util for classification models."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@ -27,10 +29,96 @@ from frigate.util.process import FrigateProcess
|
|||||||
BATCH_SIZE = 16
|
BATCH_SIZE = 16
|
||||||
EPOCHS = 50
|
EPOCHS = 50
|
||||||
LEARNING_RATE = 0.001
|
LEARNING_RATE = 0.001
|
||||||
|
TRAINING_METADATA_FILE = ".training_metadata.json"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class ClassificationTrainingProcess(FrigateProcess):
|
||||||
def __init__(self, model_name: str) -> None:
|
def __init__(self, model_name: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@ -42,7 +130,8 @@ class ClassificationTrainingProcess(FrigateProcess):
|
|||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.pre_run_setup()
|
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_factory(self, dataset_dir: str):
|
||||||
def generate_representative_dataset():
|
def generate_representative_dataset():
|
||||||
@ -65,85 +154,117 @@ class ClassificationTrainingProcess(FrigateProcess):
|
|||||||
@redirect_output_to_logger(logger, logging.DEBUG)
|
@redirect_output_to_logger(logger, logging.DEBUG)
|
||||||
def __train_classification_model(self) -> bool:
|
def __train_classification_model(self) -> bool:
|
||||||
"""Train a classification model."""
|
"""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
|
dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset")
|
||||||
import tensorflow as tf
|
model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
||||||
from tensorflow.keras import layers, models, optimizers
|
os.makedirs(model_dir, exist_ok=True)
|
||||||
from tensorflow.keras.applications import MobileNetV2
|
|
||||||
from tensorflow.keras.preprocessing.image import ImageDataGenerator
|
|
||||||
|
|
||||||
logger.info(f"Kicking off classification training for {self.model_name}.")
|
num_classes = len(
|
||||||
dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset")
|
[
|
||||||
model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
d
|
||||||
os.makedirs(model_dir, exist_ok=True)
|
for d in os.listdir(dataset_dir)
|
||||||
num_classes = len(
|
if os.path.isdir(os.path.join(dataset_dir, d))
|
||||||
[
|
]
|
||||||
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
|
if num_classes < 2:
|
||||||
base_model = MobileNetV2(
|
logger.error(
|
||||||
input_shape=(224, 224, 3),
|
f"Training failed for {self.model_name}: Need at least 2 classes, found {num_classes}"
|
||||||
include_top=False,
|
)
|
||||||
weights="imagenet",
|
return False
|
||||||
alpha=0.35,
|
|
||||||
)
|
|
||||||
base_model.trainable = False # Freeze pre-trained layers
|
|
||||||
|
|
||||||
model = models.Sequential(
|
# Start with imagenet base model with 35% of channels in each layer
|
||||||
[
|
base_model = MobileNetV2(
|
||||||
base_model,
|
input_shape=(224, 224, 3),
|
||||||
layers.GlobalAveragePooling2D(),
|
include_top=False,
|
||||||
layers.Dense(128, activation="relu"),
|
weights="imagenet",
|
||||||
layers.Dropout(0.3),
|
alpha=0.35,
|
||||||
layers.Dense(num_classes, activation="softmax"),
|
)
|
||||||
]
|
base_model.trainable = False # Freeze pre-trained layers
|
||||||
)
|
|
||||||
|
|
||||||
model.compile(
|
model = models.Sequential(
|
||||||
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE),
|
[
|
||||||
loss="categorical_crossentropy",
|
base_model,
|
||||||
metrics=["accuracy"],
|
layers.GlobalAveragePooling2D(),
|
||||||
)
|
layers.Dense(128, activation="relu"),
|
||||||
|
layers.Dropout(0.3),
|
||||||
|
layers.Dense(num_classes, activation="softmax"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# create training set
|
model.compile(
|
||||||
datagen = ImageDataGenerator(rescale=1.0 / 255, validation_split=0.2)
|
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE),
|
||||||
train_gen = datagen.flow_from_directory(
|
loss="categorical_crossentropy",
|
||||||
dataset_dir,
|
metrics=["accuracy"],
|
||||||
target_size=(224, 224),
|
)
|
||||||
batch_size=BATCH_SIZE,
|
|
||||||
class_mode="categorical",
|
|
||||||
subset="training",
|
|
||||||
)
|
|
||||||
|
|
||||||
# write labelmap
|
# create training set
|
||||||
class_indices = train_gen.class_indices
|
datagen = ImageDataGenerator(rescale=1.0 / 255, validation_split=0.2)
|
||||||
index_to_class = {v: k for k, v in class_indices.items()}
|
train_gen = datagen.flow_from_directory(
|
||||||
sorted_classes = [index_to_class[i] for i in range(len(index_to_class))]
|
dataset_dir,
|
||||||
with open(os.path.join(model_dir, "labelmap.txt"), "w") as f:
|
target_size=(224, 224),
|
||||||
for class_name in sorted_classes:
|
batch_size=BATCH_SIZE,
|
||||||
f.write(f"{class_name}\n")
|
class_mode="categorical",
|
||||||
|
subset="training",
|
||||||
|
)
|
||||||
|
|
||||||
# train the model
|
total_images = train_gen.samples
|
||||||
model.fit(train_gen, epochs=EPOCHS, verbose=0)
|
logger.debug(
|
||||||
|
f"Training {self.model_name}: {total_images} images across {num_classes} classes"
|
||||||
|
)
|
||||||
|
|
||||||
# convert model to tflite
|
# write labelmap
|
||||||
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
class_indices = train_gen.class_indices
|
||||||
converter.optimizations = [tf.lite.Optimize.DEFAULT]
|
index_to_class = {v: k for k, v in class_indices.items()}
|
||||||
converter.representative_dataset = (
|
sorted_classes = [index_to_class[i] for i in range(len(index_to_class))]
|
||||||
self.__generate_representative_dataset_factory(dataset_dir)
|
with open(os.path.join(model_dir, "labelmap.txt"), "w") as f:
|
||||||
)
|
for class_name in sorted_classes:
|
||||||
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
|
f.write(f"{class_name}\n")
|
||||||
converter.inference_input_type = tf.uint8
|
|
||||||
converter.inference_output_type = tf.uint8
|
|
||||||
tflite_model = converter.convert()
|
|
||||||
|
|
||||||
# write model
|
# train the model
|
||||||
with open(os.path.join(model_dir, "model.tflite"), "wb") as f:
|
logger.debug(f"Training {self.model_name} for {EPOCHS} epochs...")
|
||||||
f.write(tflite_model)
|
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(
|
def kickoff_model_training(
|
||||||
@ -165,18 +286,36 @@ def kickoff_model_training(
|
|||||||
training_process.start()
|
training_process.start()
|
||||||
training_process.join()
|
training_process.join()
|
||||||
|
|
||||||
# reload model and mark training as complete
|
# check if training succeeded by examining the exit code
|
||||||
embeddingRequestor.send_data(
|
training_success = training_process.exitcode == 0
|
||||||
EmbeddingsRequestEnum.reload_classification_model.value,
|
|
||||||
{"model_name": model_name},
|
if training_success:
|
||||||
)
|
# reload model and mark training as complete
|
||||||
requestor.send_data(
|
embeddingRequestor.send_data(
|
||||||
UPDATE_MODEL_STATE,
|
EmbeddingsRequestEnum.reload_classification_model.value,
|
||||||
{
|
{"model_name": model_name},
|
||||||
"model": model_name,
|
)
|
||||||
"state": ModelStatusTypesEnum.complete,
|
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()
|
requestor.stop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,10 @@
|
|||||||
"formattedTimestampFilename": {
|
"formattedTimestampFilename": {
|
||||||
"12hour": "MM-dd-yy-h-mm-ss-a",
|
"12hour": "MM-dd-yy-h-mm-ss-a",
|
||||||
"24hour": "MM-dd-yy-HH-mm-ss"
|
"24hour": "MM-dd-yy-HH-mm-ss"
|
||||||
}
|
},
|
||||||
|
"inProgress": "In progress",
|
||||||
|
"invalidStartTime": "Invalid start time",
|
||||||
|
"invalidEndTime": "Invalid end time"
|
||||||
},
|
},
|
||||||
"unit": {
|
"unit": {
|
||||||
"speed": {
|
"speed": {
|
||||||
@ -144,7 +147,8 @@
|
|||||||
"unselect": "Unselect",
|
"unselect": "Unselect",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"deleteNow": "Delete Now",
|
"deleteNow": "Delete Now",
|
||||||
"next": "Next"
|
"next": "Next",
|
||||||
|
"continue": "Continue"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"system": "System",
|
"system": "System",
|
||||||
@ -237,6 +241,7 @@
|
|||||||
"export": "Export",
|
"export": "Export",
|
||||||
"uiPlayground": "UI Playground",
|
"uiPlayground": "UI Playground",
|
||||||
"faceLibrary": "Face Library",
|
"faceLibrary": "Face Library",
|
||||||
|
"classification": "Classification",
|
||||||
"user": {
|
"user": {
|
||||||
"title": "User",
|
"title": "User",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
|
|||||||
@ -13,6 +13,12 @@
|
|||||||
"deleteModels": "Delete Models",
|
"deleteModels": "Delete Models",
|
||||||
"editModel": "Edit Model"
|
"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": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"deletedCategory": "Deleted Class",
|
"deletedCategory": "Deleted Class",
|
||||||
@ -30,14 +36,17 @@
|
|||||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||||
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||||
"trainingFailed": "Failed to start model training: {{errorMessage}}",
|
"trainingFailed": "Model training failed. Check Frigate logs for details.",
|
||||||
|
"trainingFailedToStart": "Failed to start model training: {{errorMessage}}",
|
||||||
"updateModelFailed": "Failed to update model: {{errorMessage}}",
|
"updateModelFailed": "Failed to update model: {{errorMessage}}",
|
||||||
"renameCategoryFailed": "Failed to rename class: {{errorMessage}}"
|
"renameCategoryFailed": "Failed to rename class: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteCategory": {
|
"deleteCategory": {
|
||||||
"title": "Delete Class",
|
"title": "Delete Class",
|
||||||
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
|
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model.",
|
||||||
|
"minClassesTitle": "Cannot Delete Class",
|
||||||
|
"minClassesDesc": "A classification model must have at least 2 classes. Add another class before deleting this one."
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
"title": "Delete Classification Model",
|
"title": "Delete Classification Model",
|
||||||
@ -143,6 +152,8 @@
|
|||||||
"step3": {
|
"step3": {
|
||||||
"selectImagesPrompt": "Select all images with: {{className}}",
|
"selectImagesPrompt": "Select all images with: {{className}}",
|
||||||
"selectImagesDescription": "Click on images to select them. Click Continue when you're done with this class.",
|
"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": {
|
"generating": {
|
||||||
"title": "Generating Sample Images",
|
"title": "Generating Sample Images",
|
||||||
"description": "Frigate is pulling representative images from your recordings. This may take a moment..."
|
"description": "Frigate is pulling representative images from your recordings. This may take a moment..."
|
||||||
|
|||||||
@ -24,8 +24,8 @@
|
|||||||
"label": "Detail",
|
"label": "Detail",
|
||||||
"noDataFound": "No detail data to review",
|
"noDataFound": "No detail data to review",
|
||||||
"aria": "Toggle detail view",
|
"aria": "Toggle detail view",
|
||||||
"trackedObject_one": "object",
|
"trackedObject_one": "{{count}} object",
|
||||||
"trackedObject_other": "objects",
|
"trackedObject_other": "{{count}} objects",
|
||||||
"noObjectDetailData": "No object detail data available.",
|
"noObjectDetailData": "No object detail data available.",
|
||||||
"settings": "Detail View Settings",
|
"settings": "Detail View Settings",
|
||||||
"alwaysExpandActive": {
|
"alwaysExpandActive": {
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
"snapshot": "snapshot",
|
"snapshot": "snapshot",
|
||||||
"thumbnail": "thumbnail",
|
"thumbnail": "thumbnail",
|
||||||
"video": "video",
|
"video": "video",
|
||||||
"object_lifecycle": "object lifecycle"
|
"tracking_details": "tracking details"
|
||||||
},
|
},
|
||||||
"trackingDetails": {
|
"trackingDetails": {
|
||||||
"title": "Tracking Details",
|
"title": "Tracking Details",
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
"masksAndZones": "Mask and Zone Editor - Frigate",
|
"masksAndZones": "Mask and Zone Editor - Frigate",
|
||||||
"motionTuner": "Motion Tuner - Frigate",
|
"motionTuner": "Motion Tuner - Frigate",
|
||||||
"object": "Debug - Frigate",
|
"object": "Debug - Frigate",
|
||||||
"general": "General Settings - Frigate",
|
"general": "UI Settings - Frigate",
|
||||||
"frigatePlus": "Frigate+ Settings - Frigate",
|
"frigatePlus": "Frigate+ Settings - Frigate",
|
||||||
"notifications": "Notification Settings - Frigate"
|
"notifications": "Notification Settings - Frigate"
|
||||||
},
|
},
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"noCamera": "No Camera"
|
"noCamera": "No Camera"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "General Settings",
|
"title": "UI Settings",
|
||||||
"liveDashboard": {
|
"liveDashboard": {
|
||||||
"title": "Live Dashboard",
|
"title": "Live Dashboard",
|
||||||
"automaticLiveView": {
|
"automaticLiveView": {
|
||||||
@ -51,6 +51,10 @@
|
|||||||
"displayCameraNames": {
|
"displayCameraNames": {
|
||||||
"label": "Always Show Camera Names",
|
"label": "Always Show Camera Names",
|
||||||
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
"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": {
|
"storedLayouts": {
|
||||||
@ -154,6 +158,7 @@
|
|||||||
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
||||||
"steps": {
|
"steps": {
|
||||||
"nameAndConnection": "Name & Connection",
|
"nameAndConnection": "Name & Connection",
|
||||||
|
"probeOrSnapshot": "Probe or Snapshot",
|
||||||
"streamConfiguration": "Stream Configuration",
|
"streamConfiguration": "Stream Configuration",
|
||||||
"validationAndTesting": "Validation & Testing"
|
"validationAndTesting": "Validation & Testing"
|
||||||
},
|
},
|
||||||
@ -172,7 +177,7 @@
|
|||||||
"testFailed": "Stream test failed: {{error}}"
|
"testFailed": "Stream test failed: {{error}}"
|
||||||
},
|
},
|
||||||
"step1": {
|
"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",
|
"cameraName": "Camera Name",
|
||||||
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
|
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||||
"host": "Host/IP Address",
|
"host": "Host/IP Address",
|
||||||
@ -188,33 +193,65 @@
|
|||||||
"brandInformation": "Brand information",
|
"brandInformation": "Brand information",
|
||||||
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
|
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
|
||||||
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
|
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||||
"testConnection": "Test Connection",
|
"connectionSettings": "Connection Settings",
|
||||||
"testSuccess": "Connection test successful!",
|
"detectionMethod": "Stream Detection Method",
|
||||||
"testFailed": "Connection test failed. Please check your input and try again.",
|
"onvifPort": "ONVIF Port",
|
||||||
"streamDetails": "Stream Details",
|
"probeMode": "Probe camera",
|
||||||
"testing": {
|
"manualMode": "Manual selection",
|
||||||
"probingMetadata": "Probing camera metadata...",
|
"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\".",
|
||||||
"fetchingSnapshot": "Fetching camera snapshot..."
|
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
|
||||||
},
|
"useDigestAuth": "Use digest authentication",
|
||||||
"warnings": {
|
"useDigestAuthDescription": "Use HTTP digest authentication for ONVIF. Some cameras may require a dedicated ONVIF username/password instead of the standard admin user.",
|
||||||
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
|
|
||||||
},
|
|
||||||
"errors": {
|
"errors": {
|
||||||
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
||||||
"nameRequired": "Camera name is required",
|
"nameRequired": "Camera name is required",
|
||||||
"nameLength": "Camera name must be 64 characters or less",
|
"nameLength": "Camera name must be 64 characters or less",
|
||||||
"invalidCharacters": "Camera name contains invalid characters",
|
"invalidCharacters": "Camera name contains invalid characters",
|
||||||
"nameExists": "Camera name already exists",
|
"nameExists": "Camera name already exists",
|
||||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.",
|
"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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step2": {
|
"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.",
|
"description": "Configure stream roles and add additional streams for your camera.",
|
||||||
"streamsTitle": "Camera Streams",
|
"streamsTitle": "Camera Streams",
|
||||||
"addStream": "Add Stream",
|
"addStream": "Add Stream",
|
||||||
@ -222,6 +259,9 @@
|
|||||||
"streamTitle": "Stream {{number}}",
|
"streamTitle": "Stream {{number}}",
|
||||||
"streamUrl": "Stream URL",
|
"streamUrl": "Stream URL",
|
||||||
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
|
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||||
|
"selectStream": "Select a stream",
|
||||||
|
"searchCandidates": "Search candidates...",
|
||||||
|
"noStreamFound": "No stream found",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"resolution": "Resolution",
|
"resolution": "Resolution",
|
||||||
"selectResolution": "Select resolution",
|
"selectResolution": "Select resolution",
|
||||||
@ -253,7 +293,7 @@
|
|||||||
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
"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.",
|
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
|
||||||
"validationTitle": "Stream Validation",
|
"validationTitle": "Stream Validation",
|
||||||
"connectAllStreams": "Connect All Streams",
|
"connectAllStreams": "Connect All Streams",
|
||||||
@ -289,6 +329,9 @@
|
|||||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
"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.",
|
"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": {
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
CustomClassificationModelConfig,
|
CustomClassificationModelConfig,
|
||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
} from "@/types/frigateConfig";
|
} from "@/types/frigateConfig";
|
||||||
|
import { ClassificationDatasetResponse } from "@/types/classification";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@ -140,16 +141,19 @@ export default function ClassificationModelEditDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch dataset to get current classes for state models
|
// Fetch dataset to get current classes for state models
|
||||||
const { data: dataset } = useSWR<{
|
const { data: dataset } = useSWR<ClassificationDatasetResponse>(
|
||||||
[id: string]: string[];
|
isStateModel ? `classification/${model.name}/dataset` : null,
|
||||||
}>(isStateModel ? `classification/${model.name}/dataset` : null, {
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update form with classes from dataset when loaded
|
// Update form with classes from dataset when loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStateModel && dataset) {
|
if (isStateModel && dataset?.categories) {
|
||||||
const classes = Object.keys(dataset).filter((key) => key !== "none");
|
const classes = Object.keys(dataset.categories).filter(
|
||||||
|
(key) => key !== "none",
|
||||||
|
);
|
||||||
if (classes.length > 0) {
|
if (classes.length > 0) {
|
||||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||||
"classes",
|
"classes",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import Step3ChooseExamples, {
|
|||||||
} from "./wizard/Step3ChooseExamples";
|
} from "./wizard/Step3ChooseExamples";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const OBJECT_STEPS = [
|
const OBJECT_STEPS = [
|
||||||
"wizard.steps.nameAndDefine",
|
"wizard.steps.nameAndDefine",
|
||||||
@ -120,7 +121,18 @@ export default function ClassificationModelWizardDialog({
|
|||||||
dispatch({ type: "PREVIOUS_STEP" });
|
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" });
|
dispatch({ type: "RESET" });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,12 @@ import useSWR from "swr";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
export type Step3FormData = {
|
export type Step3FormData = {
|
||||||
examplesGenerated: boolean;
|
examplesGenerated: boolean;
|
||||||
@ -159,18 +165,15 @@ export default function Step3ChooseExamples({
|
|||||||
const isLastClass = currentClassIndex === allClasses.length - 1;
|
const isLastClass = currentClassIndex === allClasses.length - 1;
|
||||||
|
|
||||||
if (isLastClass) {
|
if (isLastClass) {
|
||||||
// Assign remaining unclassified images
|
// For object models, assign remaining unclassified images to "none"
|
||||||
unknownImages.slice(0, 24).forEach((imageName) => {
|
// For state models, this should never happen since we require all images to be classified
|
||||||
if (!newClassifications[imageName]) {
|
if (step1Data.modelType !== "state") {
|
||||||
// For state models with 2 classes, assign to the last class
|
unknownImages.slice(0, 24).forEach((imageName) => {
|
||||||
// For object models, assign to "none"
|
if (!newClassifications[imageName]) {
|
||||||
if (step1Data.modelType === "state" && allClasses.length === 2) {
|
|
||||||
newClassifications[imageName] = allClasses[allClasses.length - 1];
|
|
||||||
} else {
|
|
||||||
newClassifications[imageName] = "none";
|
newClassifications[imageName] = "none";
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// All done, trigger training immediately
|
// All done, trigger training immediately
|
||||||
setImageClassifications(newClassifications);
|
setImageClassifications(newClassifications);
|
||||||
@ -310,13 +313,44 @@ export default function Step3ChooseExamples({
|
|||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
return images.filter((img) => !imageClassifications[img]);
|
// If we're viewing a previous class (going back), show images for that class
|
||||||
}, [unknownImages, imageClassifications]);
|
// 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(() => {
|
const allImagesClassified = useMemo(() => {
|
||||||
return unclassifiedImages.length === 0;
|
return unclassifiedImages.length === 0;
|
||||||
}, [unclassifiedImages]);
|
}, [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(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (currentClassIndex > 0) {
|
if (currentClassIndex > 0) {
|
||||||
const previousClass = allClasses[currentClassIndex - 1];
|
const previousClass = allClasses[currentClassIndex - 1];
|
||||||
@ -438,20 +472,35 @@ export default function Step3ChooseExamples({
|
|||||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||||
{t("button.back", { ns: "common" })}
|
{t("button.back", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
onClick={
|
<Button
|
||||||
allImagesClassified
|
type="button"
|
||||||
? handleContinue
|
onClick={
|
||||||
: handleContinueClassification
|
allImagesClassified
|
||||||
}
|
? handleContinue
|
||||||
variant="select"
|
: handleContinueClassification
|
||||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
}
|
||||||
disabled={!hasGenerated || isGenerating || isProcessing}
|
variant="select"
|
||||||
>
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
disabled={
|
||||||
{t("button.continue", { ns: "common" })}
|
!hasGenerated || isGenerating || isProcessing || !canProceed
|
||||||
</Button>
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -454,6 +454,24 @@ export function GeneralFilterContent({
|
|||||||
onClose,
|
onClose,
|
||||||
}: GeneralFilterContentProps) {
|
}: GeneralFilterContentProps) {
|
||||||
const { t } = useTranslation(["components/filter", "views/events"]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
<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) => (
|
{allLabels.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={item}
|
key={item}
|
||||||
label={getTranslatedLabel(item)}
|
label={getTranslatedLabel(
|
||||||
|
item,
|
||||||
|
allAudioListenLabels.includes(item) ? "audio" : "object",
|
||||||
|
)}
|
||||||
isChecked={filter.labels?.includes(item) ?? false}
|
isChecked={filter.labels?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
|
|||||||
@ -81,6 +81,43 @@ export default function InputWithTags({
|
|||||||
revalidateOnFocus: false,
|
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 [inputValue, setInputValue] = useState(search || "");
|
||||||
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
||||||
null,
|
null,
|
||||||
@ -421,7 +458,8 @@ export default function InputWithTags({
|
|||||||
? t("button.yes", { ns: "common" })
|
? t("button.yes", { ns: "common" })
|
||||||
: t("button.no", { ns: "common" });
|
: t("button.no", { ns: "common" });
|
||||||
} else if (filterType === "labels") {
|
} else if (filterType === "labels") {
|
||||||
return getTranslatedLabel(String(filterValues));
|
const value = String(filterValues);
|
||||||
|
return resolveLabel(value);
|
||||||
} else if (filterType === "search_type") {
|
} else if (filterType === "search_type") {
|
||||||
return t("filter.searchType." + String(filterValues));
|
return t("filter.searchType." + String(filterValues));
|
||||||
} else {
|
} else {
|
||||||
@ -828,7 +866,7 @@ export default function InputWithTags({
|
|||||||
>
|
>
|
||||||
{t("filter.label." + filterType)}:{" "}
|
{t("filter.label." + filterType)}:{" "}
|
||||||
{filterType === "labels" ? (
|
{filterType === "labels" ? (
|
||||||
getTranslatedLabel(value)
|
resolveLabel(value)
|
||||||
) : filterType === "cameras" ? (
|
) : filterType === "cameras" ? (
|
||||||
<CameraNameLabel camera={value} />
|
<CameraNameLabel camera={value} />
|
||||||
) : filterType === "zones" ? (
|
) : filterType === "zones" ? (
|
||||||
|
|||||||
@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
upperClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
lowerClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1155,7 +1155,7 @@ function ObjectDetailsTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
|
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
|
||||||
{getIconForLabel(search.label, "size-4 text-primary")}
|
{getIconForLabel(search.label, "size-4 text-primary")}
|
||||||
{getTranslatedLabel(search.label)}
|
{getTranslatedLabel(search.label, search.data.type)}
|
||||||
{search.sub_label && ` (${search.sub_label})`}
|
{search.sub_label && ` (${search.sub_label})`}
|
||||||
{isAdmin && search.end_time && (
|
{isAdmin && search.end_time && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -1394,7 +1394,9 @@ function ObjectDetailsTab({
|
|||||||
{state == "submitted" && (
|
{state == "submitted" && (
|
||||||
<div className="flex flex-row items-center justify-center gap-2">
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
<FaCheckCircle className="size-4 text-success" />
|
<FaCheckCircle className="size-4 text-success" />
|
||||||
{t("explore.plus.review.state.submitted")}
|
{t("explore.plus.review.state.submitted", {
|
||||||
|
ns: "components/dialog",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -343,6 +343,10 @@ export function TrackingDetails({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [displayedRecordTime]);
|
}, [displayedRecordTime]);
|
||||||
|
|
||||||
|
const onUploadFrameToPlus = useCallback(() => {
|
||||||
|
return axios.post(`/${event.camera}/plus/${currentTime}`);
|
||||||
|
}, [event.camera, currentTime]);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
@ -388,6 +392,7 @@ export function TrackingDetails({
|
|||||||
frigateControls={true}
|
frigateControls={true}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onSeekToTime={handleSeekToTime}
|
onSeekToTime={handleSeekToTime}
|
||||||
|
onUploadFrame={onUploadFrameToPlus}
|
||||||
isDetailMode={true}
|
isDetailMode={true}
|
||||||
camera={event.camera}
|
camera={event.camera}
|
||||||
currentTimeOverride={currentTime}
|
currentTimeOverride={currentTime}
|
||||||
|
|||||||
@ -77,7 +77,10 @@ export default function BirdseyeLivePlayer({
|
|||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<ImageShadowOverlay />
|
<ImageShadowOverlay
|
||||||
|
upperClassName="md:rounded-2xl"
|
||||||
|
lowerClassName="md:rounded-2xl"
|
||||||
|
/>
|
||||||
<div className="size-full" ref={playerRef}>
|
<div className="size-full" ref={playerRef}>
|
||||||
{player}
|
{player}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -331,7 +331,10 @@ export default function LivePlayer({
|
|||||||
>
|
>
|
||||||
{cameraEnabled &&
|
{cameraEnabled &&
|
||||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||||
<ImageShadowOverlay />
|
<ImageShadowOverlay
|
||||||
|
upperClassName="md:rounded-2xl"
|
||||||
|
lowerClassName="md:rounded-2xl"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{player}
|
{player}
|
||||||
{cameraEnabled &&
|
{cameraEnabled &&
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
import {
|
import {
|
||||||
LivePlayerError,
|
LivePlayerError,
|
||||||
PlayerStatsType,
|
PlayerStatsType,
|
||||||
@ -71,6 +72,8 @@ function MSEPlayer({
|
|||||||
const [errorCount, setErrorCount] = useState<number>(0);
|
const [errorCount, setErrorCount] = useState<number>(0);
|
||||||
const totalBytesLoaded = useRef(0);
|
const totalBytesLoaded = useRef(0);
|
||||||
|
|
||||||
|
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTIDRef = useRef<number | null>(null);
|
const reconnectTIDRef = useRef<number | null>(null);
|
||||||
@ -475,7 +478,10 @@ function MSEPlayer({
|
|||||||
setBufferTimeout(undefined);
|
setBufferTimeout(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutDuration = bufferTime == 0 ? 5000 : 3000;
|
const timeoutDuration =
|
||||||
|
bufferTime == 0
|
||||||
|
? (fallbackTimeout ?? 3) * 2 * 1000
|
||||||
|
: (fallbackTimeout ?? 3) * 1000;
|
||||||
setBufferTimeout(
|
setBufferTimeout(
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (
|
if (
|
||||||
@ -500,6 +506,7 @@ function MSEPlayer({
|
|||||||
onError,
|
onError,
|
||||||
onPlaying,
|
onPlaying,
|
||||||
playbackEnabled,
|
playbackEnabled,
|
||||||
|
fallbackTimeout,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -12,15 +12,15 @@ import { toast } from "sonner";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
||||||
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
|
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
|
||||||
import Step3Validation from "@/components/settings/wizard/Step3Validation";
|
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
|
||||||
|
import Step4Validation from "@/components/settings/wizard/Step4Validation";
|
||||||
import type {
|
import type {
|
||||||
WizardFormData,
|
WizardFormData,
|
||||||
CameraConfigData,
|
CameraConfigData,
|
||||||
ConfigSetBody,
|
ConfigSetBody,
|
||||||
} from "@/types/cameraWizard";
|
} from "@/types/cameraWizard";
|
||||||
import { processCameraName } from "@/utils/cameraUtil";
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
import { isDesktop } from "react-device-detect";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type WizardState = {
|
type WizardState = {
|
||||||
@ -57,6 +57,7 @@ const wizardReducer = (
|
|||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
"cameraWizard.steps.nameAndConnection",
|
"cameraWizard.steps.nameAndConnection",
|
||||||
|
"cameraWizard.steps.probeOrSnapshot",
|
||||||
"cameraWizard.steps.streamConfiguration",
|
"cameraWizard.steps.streamConfiguration",
|
||||||
"cameraWizard.steps.validationAndTesting",
|
"cameraWizard.steps.validationAndTesting",
|
||||||
];
|
];
|
||||||
@ -100,20 +101,20 @@ export default function CameraWizardDialog({
|
|||||||
const canProceedToNext = useCallback((): boolean => {
|
const canProceedToNext = useCallback((): boolean => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 0:
|
case 0:
|
||||||
// Can proceed if camera name is set and at least one stream exists
|
// Step 1: Can proceed if camera name is set
|
||||||
return !!(
|
return !!state.wizardData.cameraName;
|
||||||
state.wizardData.cameraName &&
|
|
||||||
(state.wizardData.streams?.length ?? 0) > 0
|
|
||||||
);
|
|
||||||
case 1:
|
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 !!(
|
return !!(
|
||||||
state.wizardData.streams?.some((stream) =>
|
state.wizardData.streams?.some((stream) =>
|
||||||
stream.roles.includes("detect"),
|
stream.roles.includes("detect"),
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
case 2:
|
case 3:
|
||||||
// Always can proceed from final step (save will be handled there)
|
// Step 4: Always can proceed from final step (save will be handled there)
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
@ -340,13 +341,7 @@ export default function CameraWizardDialog({
|
|||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[90dvh] max-w-xl overflow-y-auto",
|
"scrollbar-container max-h-[90dvh] max-w-3xl 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",
|
|
||||||
)}
|
)}
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -385,7 +380,16 @@ export default function CameraWizardDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<Step2StreamConfig
|
<Step2ProbeOrSnapshot
|
||||||
|
wizardData={state.wizardData}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
probeMode={state.wizardData.probeMode ?? true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<Step3StreamConfig
|
||||||
wizardData={state.wizardData}
|
wizardData={state.wizardData}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
@ -393,8 +397,8 @@ export default function CameraWizardDialog({
|
|||||||
canProceed={canProceedToNext()}
|
canProceed={canProceedToNext()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 3 && (
|
||||||
<Step3Validation
|
<Step4Validation
|
||||||
wizardData={state.wizardData}
|
wizardData={state.wizardData}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
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 {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -15,15 +17,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
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 useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import {
|
import {
|
||||||
@ -31,20 +31,13 @@ import {
|
|||||||
CameraBrand,
|
CameraBrand,
|
||||||
CAMERA_BRANDS,
|
CAMERA_BRANDS,
|
||||||
CAMERA_BRAND_VALUES,
|
CAMERA_BRAND_VALUES,
|
||||||
TestResult,
|
|
||||||
FfprobeStream,
|
|
||||||
StreamRole,
|
|
||||||
StreamConfig,
|
|
||||||
} from "@/types/cameraWizard";
|
} from "@/types/cameraWizard";
|
||||||
import { FaCircleCheck } from "react-icons/fa6";
|
|
||||||
import { Card, CardContent, CardTitle } from "../../ui/card";
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { LuInfo } from "react-icons/lu";
|
import { LuInfo } from "react-icons/lu";
|
||||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
|
||||||
|
|
||||||
type Step1NameCameraProps = {
|
type Step1NameCameraProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@ -63,9 +56,9 @@ export default function Step1NameCamera({
|
|||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [probeMode, setProbeMode] = useState<boolean>(
|
||||||
const [testStatus, setTestStatus] = useState<string>("");
|
wizardData.probeMode ?? true,
|
||||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
);
|
||||||
|
|
||||||
const existingCameraNames = useMemo(() => {
|
const existingCameraNames = useMemo(() => {
|
||||||
if (!config?.cameras) {
|
if (!config?.cameras) {
|
||||||
@ -88,6 +81,8 @@ export default function Step1NameCamera({
|
|||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||||
|
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||||
|
useDigestAuth: z.boolean().optional(),
|
||||||
customUrl: z
|
customUrl: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@ -124,6 +119,8 @@ export default function Step1NameCamera({
|
|||||||
? (wizardData.brandTemplate as CameraBrand)
|
? (wizardData.brandTemplate as CameraBrand)
|
||||||
: "dahua",
|
: "dahua",
|
||||||
customUrl: wizardData.customUrl || "",
|
customUrl: wizardData.customUrl || "",
|
||||||
|
onvifPort: wizardData.onvifPort ?? 80,
|
||||||
|
useDigestAuth: wizardData.useDigestAuth ?? false,
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
@ -132,271 +129,238 @@ export default function Step1NameCamera({
|
|||||||
const watchedHost = form.watch("host");
|
const watchedHost = form.watch("host");
|
||||||
const watchedCustomUrl = form.watch("customUrl");
|
const watchedCustomUrl = form.watch("customUrl");
|
||||||
|
|
||||||
const isTestButtonEnabled =
|
const hostPresent = !!(watchedHost && watchedHost.trim());
|
||||||
watchedBrand === "other"
|
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
|
||||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
|
||||||
: !!(watchedHost && watchedHost.trim());
|
|
||||||
|
|
||||||
const generateDynamicStreamUrl = useCallback(
|
const isContinueButtonEnabled =
|
||||||
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
cameraNamePresent &&
|
||||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
(probeMode
|
||||||
if (!brand || !data.host) return null;
|
? hostPresent
|
||||||
|
: watchedBrand === "other"
|
||||||
let protocol = undefined;
|
? customPresent
|
||||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
: hostPresent);
|
||||||
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 onSubmit = (data: z.infer<typeof step1FormData>) => {
|
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||||
onUpdate(data);
|
onUpdate({ ...data, probeMode });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContinue = useCallback(async () => {
|
const handleContinue = useCallback(async () => {
|
||||||
const data = form.getValues();
|
const isValid = await form.trigger();
|
||||||
const streamUrl = await generateStreamUrl(data);
|
if (isValid) {
|
||||||
const streamId = `stream_${Date.now()}`;
|
const data = form.getValues();
|
||||||
|
onNext({ ...data, probeMode });
|
||||||
const streamConfig: StreamConfig = {
|
}
|
||||||
id: streamId,
|
}, [form, probeMode, onNext]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{!testResult?.success && (
|
<div className="text-sm text-muted-foreground">
|
||||||
<>
|
{t("cameraWizard.step1.description")}
|
||||||
<div className="text-sm text-muted-foreground">
|
</div>
|
||||||
{t("cameraWizard.step1.description")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cameraName"
|
name="cameraName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-primary-variant">
|
<FormLabel className="text-primary-variant">
|
||||||
{t("cameraWizard.step1.cameraName")}
|
{t("cameraWizard.step1.cameraName")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<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
|
<Input
|
||||||
className="text-md h-8"
|
className="text-md h-8 pr-10"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.cameraNamePlaceholder",
|
"cameraWizard.step1.passwordPlaceholder",
|
||||||
)}
|
)}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
<Button
|
||||||
<FormMessage />
|
type="button"
|
||||||
</FormItem>
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="brandTemplate"
|
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" && (
|
{watchedBrand == "other" && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -568,124 +448,25 @@ export default function Step1NameCamera({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form>
|
</div>
|
||||||
</Form>
|
)}
|
||||||
</>
|
</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">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
onClick={handleContinue}
|
||||||
className="sm:flex-1"
|
disabled={!isContinueButtonEnabled}
|
||||||
|
variant="select"
|
||||||
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
>
|
>
|
||||||
{testResult?.success
|
{t("button.continue", { ns: "common" })}
|
||||||
? t("button.back", { ns: "common" })
|
|
||||||
: t("button.cancel", { ns: "common" })}
|
|
||||||
</Button>
|
</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>
|
||||||
</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 { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||||
import { LuX } from "react-icons/lu";
|
import { LuX } from "react-icons/lu";
|
||||||
import { Card, CardContent } from "../../ui/card";
|
import { Card, CardContent } from "../../ui/card";
|
||||||
|
import { maskUri } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
type Step3ValidationProps = {
|
type Step4ValidationProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||||
onSave: (config: WizardFormData) => void;
|
onSave: (config: WizardFormData) => void;
|
||||||
@ -27,13 +28,13 @@ type Step3ValidationProps = {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Step3Validation({
|
export default function Step4Validation({
|
||||||
wizardData,
|
wizardData,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onSave,
|
onSave,
|
||||||
onBack,
|
onBack,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: Step3ValidationProps) {
|
}: Step4ValidationProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||||
@ -143,13 +144,13 @@ export default function Step3Validation({
|
|||||||
|
|
||||||
if (testResult.success) {
|
if (testResult.success) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("cameraWizard.step3.streamValidated", {
|
t("cameraWizard.step4.streamValidated", {
|
||||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("cameraWizard.step3.streamValidationFailed", {
|
t("cameraWizard.step4.streamValidationFailed", {
|
||||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -200,16 +201,16 @@ export default function Step3Validation({
|
|||||||
(r) => r.success,
|
(r) => r.success,
|
||||||
).length;
|
).length;
|
||||||
if (successfulTests === results.size) {
|
if (successfulTests === results.size) {
|
||||||
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
toast.success(t("cameraWizard.step4.reconnectionSuccess"));
|
||||||
} else {
|
} else {
|
||||||
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
toast.warning(t("cameraWizard.step4.reconnectionPartial"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [streams, onUpdate, t, performStreamValidation]);
|
}, [streams, onUpdate, t, performStreamValidation]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||||
toast.error(t("cameraWizard.step3.saveError"));
|
toast.error(t("cameraWizard.step4.saveError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,13 +240,13 @@ export default function Step3Validation({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("cameraWizard.step3.description")}
|
{t("cameraWizard.step4.description")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-medium">
|
<h3 className="text-lg font-medium">
|
||||||
{t("cameraWizard.step3.validationTitle")}
|
{t("cameraWizard.step4.validationTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
onClick={validateAllStreams}
|
onClick={validateAllStreams}
|
||||||
@ -254,8 +255,8 @@ export default function Step3Validation({
|
|||||||
>
|
>
|
||||||
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
{isValidating
|
{isValidating
|
||||||
? t("cameraWizard.step3.connecting")
|
? t("cameraWizard.step4.connecting")
|
||||||
: t("cameraWizard.step3.connectAllStreams")}
|
: t("cameraWizard.step4.connectAllStreams")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -270,7 +271,7 @@ export default function Step3Validation({
|
|||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<h4 className="mr-2 font-medium">
|
<h4 className="mr-2 font-medium">
|
||||||
{t("cameraWizard.step3.streamTitle", {
|
{t("cameraWizard.step4.streamTitle", {
|
||||||
number: index + 1,
|
number: index + 1,
|
||||||
})}
|
})}
|
||||||
</h4>
|
</h4>
|
||||||
@ -331,7 +332,7 @@ export default function Step3Validation({
|
|||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{t("cameraWizard.step3.ffmpegModule")}
|
{t("cameraWizard.step4.ffmpegModule")}
|
||||||
</span>
|
</span>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@ -346,11 +347,11 @@ export default function Step3Validation({
|
|||||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{t("cameraWizard.step3.ffmpegModule")}
|
{t("cameraWizard.step4.ffmpegModule")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"cameraWizard.step3.ffmpegModuleDescription",
|
"cameraWizard.step4.ffmpegModuleDescription",
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<span className="break-all text-sm text-muted-foreground">
|
||||||
{stream.url}
|
{maskUri(stream.url)}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -402,17 +403,17 @@ export default function Step3Validation({
|
|||||||
<ActivityIndicator className="mr-2 size-4" />
|
<ActivityIndicator className="mr-2 size-4" />
|
||||||
)}
|
)}
|
||||||
{result?.success
|
{result?.success
|
||||||
? t("cameraWizard.step3.disconnectStream")
|
? t("cameraWizard.step4.disconnectStream")
|
||||||
: testingStreams.has(stream.id)
|
: testingStreams.has(stream.id)
|
||||||
? t("cameraWizard.step3.connectingStream")
|
? t("cameraWizard.step4.connectingStream")
|
||||||
: t("cameraWizard.step3.connectStream")}
|
: t("cameraWizard.step4.connectStream")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
{t("cameraWizard.step3.issues.title")}
|
{t("cameraWizard.step4.issues.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-background p-3">
|
<div className="rounded-lg bg-background p-3">
|
||||||
<StreamIssues
|
<StreamIssues
|
||||||
@ -455,7 +456,7 @@ export default function Step3Validation({
|
|||||||
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||||
{isLoading
|
{isLoading
|
||||||
? t("button.saving", { ns: "common" })
|
? t("button.saving", { ns: "common" })
|
||||||
: t("cameraWizard.step3.saveAndApply")}
|
: t("cameraWizard.step4.saveAndApply")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -486,7 +487,7 @@ function StreamIssues({
|
|||||||
if (streamUrl.startsWith("rtsp://")) {
|
if (streamUrl.startsWith("rtsp://")) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
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)) {
|
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "good",
|
type: "good",
|
||||||
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||||
codec: stream.testResult.videoCodec,
|
codec: stream.testResult.videoCodec,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -511,20 +512,20 @@ function StreamIssues({
|
|||||||
if (audioCodec === "aac") {
|
if (audioCodec === "aac") {
|
||||||
result.push({
|
result.push({
|
||||||
type: "good",
|
type: "good",
|
||||||
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||||
codec: stream.testResult.audioCodec,
|
codec: stream.testResult.audioCodec,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result.push({
|
result.push({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
message: t("cameraWizard.step4.issues.noAudioWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -534,7 +535,7 @@ function StreamIssues({
|
|||||||
if (!stream.testResult?.audioCodec) {
|
if (!stream.testResult?.audioCodec) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
message: t("cameraWizard.step4.issues.audioCodecRequired"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -544,7 +545,7 @@ function StreamIssues({
|
|||||||
if (stream.restream) {
|
if (stream.restream) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
message: t("cameraWizard.step4.issues.restreamingWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -557,14 +558,14 @@ function StreamIssues({
|
|||||||
if (minDimension > 1080) {
|
if (minDimension > 1080) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.resolutionHigh", {
|
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||||
resolution: stream.resolution,
|
resolution: stream.resolution,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else if (maxDimension < 640) {
|
} else if (maxDimension < 640) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: t("cameraWizard.step3.issues.resolutionLow", {
|
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||||
resolution: stream.resolution,
|
resolution: stream.resolution,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -580,7 +581,7 @@ function StreamIssues({
|
|||||||
) {
|
) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
|
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -590,7 +591,7 @@ function StreamIssues({
|
|||||||
) {
|
) {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
|
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -662,7 +663,7 @@ function BandwidthDisplay({
|
|||||||
return (
|
return (
|
||||||
<div className="mb-2 text-sm">
|
<div className="mb-2 text-sm">
|
||||||
<span className="font-medium text-muted-foreground">
|
<span className="font-medium text-muted-foreground">
|
||||||
{t("cameraWizard.step3.estimatedBandwidth")}:
|
{t("cameraWizard.step4.estimatedBandwidth")}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-secondary-foreground">
|
<span className="text-secondary-foreground">
|
||||||
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||||
@ -748,7 +749,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
style={{ aspectRatio }}
|
style={{ aspectRatio }}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-danger">
|
<span className="text-sm text-danger">
|
||||||
{t("cameraWizard.step3.streamUnavailable")}
|
{t("cameraWizard.step4.streamUnavailable")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -757,7 +758,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<LuRotateCcw className="size-4" />
|
<LuRotateCcw className="size-4" />
|
||||||
{t("cameraWizard.step3.reload")}
|
{t("cameraWizard.step4.reload")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -771,7 +772,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
>
|
>
|
||||||
<ActivityIndicator className="size-4" />
|
<ActivityIndicator className="size-4" />
|
||||||
<span className="ml-2 text-sm">
|
<span className="ml-2 text-sm">
|
||||||
{t("cameraWizard.step3.connecting")}
|
{t("cameraWizard.step4.connecting")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -349,7 +349,7 @@ function ReviewGroup({
|
|||||||
? fetchedEvents.length
|
? fetchedEvents.length
|
||||||
: (review.data.objects ?? []).length;
|
: (review.data.objects ?? []).length;
|
||||||
|
|
||||||
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
return `${t("detail.trackedObject", { count: objectCount })}`;
|
||||||
}, [review, t, fetchedEvents]);
|
}, [review, t, fetchedEvents]);
|
||||||
|
|
||||||
const reviewDuration = useMemo(
|
const reviewDuration = useMemo(
|
||||||
@ -478,7 +478,7 @@ function ReviewGroup({
|
|||||||
<div className="rounded-full bg-muted-foreground p-1">
|
<div className="rounded-full bg-muted-foreground p-1">
|
||||||
{getIconForLabel(audioLabel, "size-3 text-white")}
|
{getIconForLabel(audioLabel, "size-3 text-white")}
|
||||||
</div>
|
</div>
|
||||||
<span>{getTranslatedLabel(audioLabel)}</span>
|
<span>{getTranslatedLabel(audioLabel, "audio")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -513,7 +513,8 @@ function EventList({
|
|||||||
|
|
||||||
const isSelected = selectedObjectIds.includes(event.id);
|
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) => {
|
const handleObjectSelect = (event: Event | undefined) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
|
|||||||
@ -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 { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@ -36,6 +42,23 @@ export function DetailStreamProvider({
|
|||||||
() => initialSelectedObjectIds ?? [],
|
() => 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) => {
|
const toggleObjectSelection = (id: string | undefined) => {
|
||||||
if (id === undefined) {
|
if (id === undefined) {
|
||||||
setSelectedObjectIds([]);
|
setSelectedObjectIds([]);
|
||||||
@ -63,10 +86,33 @@ export function DetailStreamProvider({
|
|||||||
setAnnotationOffset(cfgOffset);
|
setAnnotationOffset(cfgOffset);
|
||||||
}, [config, camera]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setSelectedObjectIds([]);
|
// Always clear when leaving detail mode
|
||||||
}, [isDetailMode, camera]);
|
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 = {
|
const value: DetailStreamContextType = {
|
||||||
selectedObjectIds,
|
selectedObjectIds,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
|
|||||||
export default function useCameraLiveMode(
|
export default function useCameraLiveMode(
|
||||||
cameras: CameraConfig[],
|
cameras: CameraConfig[],
|
||||||
windowVisible: boolean,
|
windowVisible: boolean,
|
||||||
|
activeStreams?: { [cameraName: string]: string },
|
||||||
) {
|
) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
@ -20,16 +21,20 @@ export default function useCameraLiveMode(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isRestreamed) {
|
if (isRestreamed) {
|
||||||
Object.values(camera.live.streams).forEach((streamName) => {
|
if (activeStreams && activeStreams[camera.name]) {
|
||||||
streamNames.add(streamName);
|
streamNames.add(activeStreams[camera.name]);
|
||||||
});
|
} else {
|
||||||
|
Object.values(camera.live.streams).forEach((streamName) => {
|
||||||
|
streamNames.add(streamName);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return streamNames.size > 0
|
return streamNames.size > 0
|
||||||
? Array.from(streamNames).sort().join(",")
|
? Array.from(streamNames).sort().join(",")
|
||||||
: null;
|
: null;
|
||||||
}, [cameras, config]);
|
}, [cameras, config, activeStreams]);
|
||||||
|
|
||||||
const streamsFetcher = useCallback(async (key: string) => {
|
const streamsFetcher = useCallback(async (key: string) => {
|
||||||
const streamNames = key.split(",");
|
const streamNames = key.split(",");
|
||||||
@ -68,7 +73,9 @@ export default function useCameraLiveMode(
|
|||||||
[key: string]: LiveStreamMetadata;
|
[key: string]: LiveStreamMetadata;
|
||||||
}>(restreamedStreamsKey, streamsFetcher, {
|
}>(restreamedStreamsKey, streamsFetcher, {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
dedupingInterval: 10000,
|
revalidateOnReconnect: false,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
dedupingInterval: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||||
|
|||||||
@ -99,6 +99,11 @@ export type TestResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CandidateTestMap = Record<
|
||||||
|
string,
|
||||||
|
TestResult | { success: false; error: string }
|
||||||
|
>;
|
||||||
|
|
||||||
export type WizardFormData = {
|
export type WizardFormData = {
|
||||||
cameraName?: string;
|
cameraName?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
@ -107,12 +112,18 @@ export type WizardFormData = {
|
|||||||
brandTemplate?: CameraBrand;
|
brandTemplate?: CameraBrand;
|
||||||
customUrl?: string;
|
customUrl?: string;
|
||||||
streams?: StreamConfig[];
|
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
|
// API Response Types
|
||||||
export type FfprobeResponse = {
|
export type FfprobeResponse = {
|
||||||
return_code: number;
|
return_code: number;
|
||||||
stderr: string;
|
stderr: string | string[];
|
||||||
stdout: FfprobeData | string;
|
stdout: FfprobeData | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -167,3 +178,26 @@ export type ConfigSetBody = {
|
|||||||
config_data: CameraConfigData;
|
config_data: CameraConfigData;
|
||||||
update_topic?: string;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@ -20,3 +20,17 @@ export type ClassificationThreshold = {
|
|||||||
recognition: number;
|
recognition: number;
|
||||||
unknown: 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;
|
||||||
|
};
|
||||||
|
|||||||
@ -87,7 +87,8 @@ export type ModelState =
|
|||||||
| "downloaded"
|
| "downloaded"
|
||||||
| "error"
|
| "error"
|
||||||
| "training"
|
| "training"
|
||||||
| "complete";
|
| "complete"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
export type EmbeddingsReindexProgressType = {
|
export type EmbeddingsReindexProgressType = {
|
||||||
thumbnails: number;
|
thumbnails: number;
|
||||||
|
|||||||
@ -71,3 +71,26 @@ export async function detectReolinkCamera(
|
|||||||
return null;
|
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,
|
abbreviated: boolean = false,
|
||||||
): string => {
|
): string => {
|
||||||
if (isNaN(start_time)) {
|
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 (end_time !== null) {
|
||||||
if (isNaN(end_time)) {
|
if (isNaN(end_time)) {
|
||||||
return "Invalid end time";
|
return i18n.t("time.invalidEndTime", { ns: "common" });
|
||||||
}
|
}
|
||||||
const start = fromUnixTime(start_time);
|
const start = fromUnixTime(start_time);
|
||||||
const end = fromUnixTime(end_time);
|
const end = fromUnixTime(end_time);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
CustomClassificationModelConfig,
|
CustomClassificationModelConfig,
|
||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
} from "@/types/frigateConfig";
|
} from "@/types/frigateConfig";
|
||||||
|
import { ClassificationDatasetResponse } from "@/types/classification";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaFolderPlus } from "react-icons/fa";
|
import { FaFolderPlus } from "react-icons/fa";
|
||||||
@ -209,9 +210,10 @@ type ModelCardProps = {
|
|||||||
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||||
const { t } = useTranslation(["views/classificationModel"]);
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
|
||||||
const { data: dataset } = useSWR<{
|
const { data: dataset } = useSWR<ClassificationDatasetResponse>(
|
||||||
[id: string]: string[];
|
`classification/${config.name}/dataset`,
|
||||||
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
{ revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
@ -260,20 +262,25 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const coverImage = useMemo(() => {
|
const coverImage = useMemo(() => {
|
||||||
if (!dataset) {
|
if (!dataset || !dataset.categories) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(dataset).filter((key) => key != "none");
|
const keys = Object.keys(dataset.categories).filter((key) => key != "none");
|
||||||
const selectedKey = keys[0];
|
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 undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: selectedKey,
|
name: selectedKey,
|
||||||
img: dataset[selectedKey][0],
|
img: images[0],
|
||||||
};
|
};
|
||||||
}, [dataset]);
|
}, [dataset]);
|
||||||
|
|
||||||
@ -317,11 +324,19 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<img
|
{coverImage ? (
|
||||||
className="size-full"
|
<>
|
||||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
|
<img
|
||||||
/>
|
className="size-full"
|
||||||
<ImageShadowOverlay lowerClassName="h-[30%] z-0" />
|
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">
|
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
|
||||||
{config.name}
|
{config.name}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,7 +59,11 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import TrainFilterDialog from "@/components/overlay/dialog/TrainFilterDialog";
|
import TrainFilterDialog from "@/components/overlay/dialog/TrainFilterDialog";
|
||||||
import useApiFilter from "@/hooks/use-api-filter";
|
import useApiFilter from "@/hooks/use-api-filter";
|
||||||
import { ClassificationItemData, TrainFilter } from "@/types/classification";
|
import {
|
||||||
|
ClassificationDatasetResponse,
|
||||||
|
ClassificationItemData,
|
||||||
|
TrainFilter,
|
||||||
|
} from "@/types/classification";
|
||||||
import {
|
import {
|
||||||
ClassificationCard,
|
ClassificationCard,
|
||||||
GroupedClassificationCard,
|
GroupedClassificationCard,
|
||||||
@ -102,6 +106,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
setWasTraining(false);
|
setWasTraining(false);
|
||||||
|
refreshDataset();
|
||||||
|
} else if (modelState == "failed") {
|
||||||
|
toast.error(t("toast.error.trainingFailed"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
setWasTraining(false);
|
||||||
}
|
}
|
||||||
// only refresh when modelState changes
|
// only refresh when modelState changes
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -112,9 +122,13 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>(
|
const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>(
|
||||||
`classification/${model.name}/train`,
|
`classification/${model.name}/train`,
|
||||||
);
|
);
|
||||||
const { data: dataset, mutate: refreshDataset } = useSWR<{
|
const { data: datasetResponse, mutate: refreshDataset } =
|
||||||
[id: string]: string[];
|
useSWR<ClassificationDatasetResponse>(
|
||||||
}>(`classification/${model.name}/dataset`);
|
`classification/${model.name}/dataset`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataset = datasetResponse?.categories || {};
|
||||||
|
const trainingMetadata = datasetResponse?.training_metadata;
|
||||||
|
|
||||||
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
|
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
|
||||||
|
|
||||||
@ -177,7 +191,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
error.response?.data?.detail ||
|
error.response?.data?.detail ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
|
|
||||||
toast.error(t("toast.error.trainingFailed", { errorMessage }), {
|
toast.error(t("toast.error.trainingFailedToStart", { errorMessage }), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -248,10 +262,11 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always refresh dataset to update the categories list
|
||||||
|
refreshDataset();
|
||||||
|
|
||||||
if (pageToggle == "train") {
|
if (pageToggle == "train") {
|
||||||
refreshTrain();
|
refreshTrain();
|
||||||
} else {
|
|
||||||
refreshDataset();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -421,19 +436,48 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
filterValues={{ classes: Object.keys(dataset || {}) }}
|
filterValues={{ classes: Object.keys(dataset || {}) }}
|
||||||
onUpdateFilter={setTrainFilter}
|
onUpdateFilter={setTrainFilter}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Tooltip>
|
||||||
className="flex justify-center gap-2"
|
<TooltipTrigger asChild>
|
||||||
onClick={trainModel}
|
<Button
|
||||||
variant="select"
|
className="flex justify-center gap-2"
|
||||||
disabled={modelState != "complete"}
|
onClick={trainModel}
|
||||||
>
|
variant={modelState == "failed" ? "destructive" : "select"}
|
||||||
{modelState == "training" ? (
|
disabled={
|
||||||
<ActivityIndicator size={20} />
|
(modelState != "complete" && modelState != "failed") ||
|
||||||
) : (
|
!trainingMetadata?.dataset_changed
|
||||||
<HiSparkles className="text-white" />
|
}
|
||||||
|
>
|
||||||
|
{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")}
|
</Tooltip>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -526,27 +570,44 @@ function LibrarySelector({
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("deleteCategory.title")}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{Object.keys(dataset).length <= 2
|
||||||
|
? t("deleteCategory.minClassesTitle")
|
||||||
|
: t("deleteCategory.title")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("deleteCategory.desc", { name: confirmDelete })}
|
{Object.keys(dataset).length <= 2
|
||||||
|
? t("deleteCategory.minClassesDesc")
|
||||||
|
: t("deleteCategory.desc", { name: confirmDelete })}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
|
{Object.keys(dataset).length <= 2 ? (
|
||||||
{t("button.cancel", { ns: "common" })}
|
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
|
||||||
</Button>
|
{t("button.ok", { ns: "common" })}
|
||||||
<Button
|
</Button>
|
||||||
variant="destructive"
|
) : (
|
||||||
className="text-white"
|
<>
|
||||||
onClick={() => {
|
<Button
|
||||||
if (confirmDelete) {
|
variant="outline"
|
||||||
handleDeleteCategory(confirmDelete);
|
onClick={() => setConfirmDelete(null)}
|
||||||
setConfirmDelete(null);
|
>
|
||||||
}
|
{t("button.cancel", { ns: "common" })}
|
||||||
}}
|
</Button>
|
||||||
>
|
<Button
|
||||||
{t("button.delete", { ns: "common" })}
|
variant="destructive"
|
||||||
</Button>
|
className="text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmDelete) {
|
||||||
|
handleDeleteCategory(confirmDelete);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -86,14 +86,6 @@ export default function DraggableGridLayout({
|
|||||||
|
|
||||||
// preferred live modes per camera
|
// preferred live modes per camera
|
||||||
|
|
||||||
const {
|
|
||||||
preferredLiveModes,
|
|
||||||
setPreferredLiveModes,
|
|
||||||
resetPreferredLiveMode,
|
|
||||||
isRestreamedStates,
|
|
||||||
supportsAudioOutputStates,
|
|
||||||
} = useCameraLiveMode(cameras, windowVisible);
|
|
||||||
|
|
||||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||||
|
|
||||||
@ -106,6 +98,33 @@ export default function DraggableGridLayout({
|
|||||||
}
|
}
|
||||||
}, [allGroupsStreamingSettings, cameraGroup]);
|
}, [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
|
// grid layout
|
||||||
|
|
||||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||||
|
|||||||
@ -162,6 +162,9 @@ export default function LiveCameraView({
|
|||||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||||
{
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
dedupingInterval: 60000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1027,294 +1030,298 @@ function FrigateCameraFeatures({
|
|||||||
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||||
loading={isSnapshotLoading}
|
loading={isSnapshotLoading}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu modal={false}>
|
{!fullscreen && (
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenu modal={false}>
|
||||||
<div
|
<DropdownMenuTrigger>
|
||||||
className={cn(
|
<div
|
||||||
"flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0",
|
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]`}
|
<FaCog
|
||||||
/>
|
className={`text-secondary-foreground" size-5 md:m-[6px]`}
|
||||||
</div>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</div>
|
||||||
<DropdownMenuContent className="max-w-96">
|
</DropdownMenuTrigger>
|
||||||
<div className="flex flex-col gap-5 p-4">
|
<DropdownMenuContent className="max-w-96">
|
||||||
{!isRestreamed && (
|
<div className="flex flex-col gap-5 p-4">
|
||||||
<div className="flex flex-col gap-2">
|
{!isRestreamed && (
|
||||||
<Label>
|
<div className="flex flex-col gap-2">
|
||||||
{t("streaming.label", { ns: "components/dialog" })}
|
<Label>
|
||||||
</Label>
|
{t("streaming.label", { ns: "components/dialog" })}
|
||||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
</Label>
|
||||||
<LuX className="size-4 text-danger" />
|
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||||
<div>
|
<LuX className="size-4 text-danger" />
|
||||||
{t("streaming.restreaming.disabled", {
|
<div>
|
||||||
ns: "components/dialog",
|
{t("streaming.restreaming.disabled", {
|
||||||
})}
|
|
||||||
</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", {
|
|
||||||
ns: "components/dialog",
|
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>
|
</div>
|
||||||
)}
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
{preferredLiveMode != "jsmpeg" &&
|
<div className="cursor-pointer p-0">
|
||||||
!debug &&
|
<LuInfo className="size-4" />
|
||||||
isRestreamed && (
|
<span className="sr-only">
|
||||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
{t("button.info", { ns: "common" })}
|
||||||
{supportsAudioOutput ? (
|
</span>
|
||||||
<>
|
|
||||||
<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>
|
</div>
|
||||||
<Button
|
</PopoverTrigger>
|
||||||
className={`flex items-center gap-2.5 rounded-lg`}
|
<PopoverContent className="w-80 text-xs">
|
||||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
{t("streaming.restreaming.desc.title", {
|
||||||
variant="outline"
|
ns: "components/dialog",
|
||||||
size="sm"
|
})}
|
||||||
onClick={() => setLowBandwidth(false)}
|
<div className="mt-2 flex items-center text-primary">
|
||||||
>
|
<Link
|
||||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
to={getLocaleDocUrl("configuration/live")}
|
||||||
<div className="text-primary-variant">
|
target="_blank"
|
||||||
{t("stream.lowBandwidth.resetStream")}
|
rel="noopener noreferrer"
|
||||||
</div>
|
className="inline"
|
||||||
</Button>
|
>
|
||||||
</div>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isRestreamed && (
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label
|
<Label
|
||||||
className="mx-0 cursor-pointer text-primary"
|
className="mx-0 cursor-pointer text-primary"
|
||||||
htmlFor="backgroundplay"
|
htmlFor="showstats"
|
||||||
>
|
>
|
||||||
{t("stream.playInBackground.label")}
|
{t("streaming.showStats.label", {
|
||||||
|
ns: "components/dialog",
|
||||||
|
})}
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
className="ml-1"
|
className="ml-1"
|
||||||
id="backgroundplay"
|
id="showstats"
|
||||||
disabled={debug}
|
disabled={debug}
|
||||||
checked={playInBackground}
|
checked={showStats}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => setShowStats(checked)}
|
||||||
setPlayInBackground(checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("stream.playInBackground.tips")}
|
{t("streaming.showStats.desc", {
|
||||||
|
ns: "components/dialog",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<Label
|
||||||
<Label
|
className="mx-0 cursor-pointer text-primary"
|
||||||
className="mx-0 cursor-pointer text-primary"
|
htmlFor="debug"
|
||||||
htmlFor="showstats"
|
>
|
||||||
>
|
{t("streaming.debugView", {
|
||||||
{t("streaming.showStats.label", {
|
ns: "components/dialog",
|
||||||
ns: "components/dialog",
|
})}
|
||||||
})}
|
</Label>
|
||||||
</Label>
|
<Switch
|
||||||
<Switch
|
className="ml-1"
|
||||||
className="ml-1"
|
id="debug"
|
||||||
id="showstats"
|
checked={debug}
|
||||||
disabled={debug}
|
onCheckedChange={(checked) => setDebug(checked)}
|
||||||
checked={showStats}
|
/>
|
||||||
onCheckedChange={(checked) => setShowStats(checked)}
|
</div>
|
||||||
/>
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuContent>
|
</DropdownMenu>
|
||||||
</DropdownMenu>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -202,14 +202,6 @@ export default function LiveDashboardView({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
|
||||||
preferredLiveModes,
|
|
||||||
setPreferredLiveModes,
|
|
||||||
resetPreferredLiveMode,
|
|
||||||
isRestreamedStates,
|
|
||||||
supportsAudioOutputStates,
|
|
||||||
} = useCameraLiveMode(cameras, windowVisible);
|
|
||||||
|
|
||||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||||
|
|
||||||
@ -239,6 +231,33 @@ export default function LiveDashboardView({
|
|||||||
[visibleCameraObserver.current],
|
[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 birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||||
|
|
||||||
const handleError = useCallback(
|
const handleError = useCallback(
|
||||||
|
|||||||
@ -649,7 +649,7 @@ export function RecordingView({
|
|||||||
value="detail"
|
value="detail"
|
||||||
aria-label="Detail Stream"
|
aria-label="Detail Stream"
|
||||||
>
|
>
|
||||||
<div className="">Detail</div>
|
<div className="">{t("detail.label")}</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -99,6 +99,10 @@ export default function UiSettingsView() {
|
|||||||
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
||||||
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||||
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
||||||
|
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
|
||||||
|
"liveFallbackTimeout",
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -161,6 +165,48 @@ export default function UiSettingsView() {
|
|||||||
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
|
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-row items-center justify-start gap-2">
|
||||||
|
<Label
|
||||||
|
className="cursor-pointer"
|
||||||
|
htmlFor="live-fallback-timeout"
|
||||||
|
>
|
||||||
|
{t("general.liveDashboard.liveFallbackTimeout.label")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||||
|
<p>{t("general.liveDashboard.liveFallbackTimeout.desc")}</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={fallbackTimeout?.toString()}
|
||||||
|
onValueChange={(value) => setFallbackTimeout(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
{t("time.second", {
|
||||||
|
ns: "common",
|
||||||
|
time: fallbackTimeout,
|
||||||
|
count: fallbackTimeout,
|
||||||
|
})}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((timeout) => (
|
||||||
|
<SelectItem
|
||||||
|
key={timeout}
|
||||||
|
className="cursor-pointer"
|
||||||
|
value={timeout.toString()}
|
||||||
|
>
|
||||||
|
{t("time.second", {
|
||||||
|
ns: "common",
|
||||||
|
time: timeout,
|
||||||
|
count: timeout,
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-3 flex w-full flex-col space-y-6">
|
<div className="my-3 flex w-full flex-col space-y-6">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user