mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-20 12:06:43 +03:00
Compare commits
3 Commits
33119c428d
...
ad2e5c92a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2e5c92a9 | ||
|
|
e4eac4ac81 | ||
|
|
c371fc0c87 |
@ -3,11 +3,15 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from onvif import ONVIFCamera, ONVIFError
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
@ -452,3 +456,471 @@ def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
return round(float(num) / float(den), 2)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onvif/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Probe ONVIF device",
|
||||
description=(
|
||||
"Probe an ONVIF device to determine capabilities and optionally test available stream URIs. "
|
||||
"Query params: host (required), port (default 80), username, password, test (boolean)."
|
||||
),
|
||||
)
|
||||
async def onvif_probe(
|
||||
request: Request,
|
||||
host: str = Query(None),
|
||||
port: int = Query(80),
|
||||
username: str = Query(""),
|
||||
password: str = Query(""),
|
||||
test: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
onvif_camera = None
|
||||
|
||||
try:
|
||||
logger.debug(f"Probing ONVIF device at {host}:{port}")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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()
|
||||
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:
|
||||
logger.debug("Failed to get device info")
|
||||
|
||||
# Get media profiles
|
||||
profiles = []
|
||||
profiles_count = 0
|
||||
first_profile_token = None
|
||||
ptz_config_token = None
|
||||
try:
|
||||
media_service = await onvif_camera.create_media_service()
|
||||
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:
|
||||
logger.debug("Failed to get media profiles")
|
||||
|
||||
# Check PTZ support and capabilities
|
||||
ptz_supported = False
|
||||
presets_count = 0
|
||||
autotrack_supported = False
|
||||
|
||||
try:
|
||||
ptz_service = await onvif_camera.create_ptz_service()
|
||||
|
||||
# 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()
|
||||
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}")
|
||||
|
||||
@ -595,9 +595,13 @@ def get_classification_dataset(name: str):
|
||||
"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,
|
||||
@ -605,6 +609,7 @@ def get_classification_dataset(name: str):
|
||||
"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(
|
||||
@ -948,31 +953,29 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete a classification model",
|
||||
description="""Deletes a specific classification model and all its associated data.
|
||||
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||
Works even if the model is not in the config (e.g., partially created during wizard).
|
||||
Returns a success message.""",
|
||||
)
|
||||
def delete_classification_model(request: Request, name: str):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if name not in config.classification.custom:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"{name} is not a known classification model.",
|
||||
}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
sanitized_name = sanitize_filename(name)
|
||||
|
||||
# Delete the classification model's data directory in clips
|
||||
data_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
|
||||
data_dir = os.path.join(CLIPS_DIR, sanitized_name)
|
||||
if os.path.exists(data_dir):
|
||||
try:
|
||||
shutil.rmtree(data_dir)
|
||||
logger.info(f"Deleted classification data directory for {name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to delete data directory for {name}: {e}")
|
||||
|
||||
# Delete the classification model's files in model_cache
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, sanitize_filename(name))
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, sanitized_name)
|
||||
if os.path.exists(model_dir):
|
||||
try:
|
||||
shutil.rmtree(model_dir)
|
||||
logger.info(f"Deleted classification model directory for {name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to delete model directory for {name}: {e}")
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
|
||||
@ -177,6 +177,12 @@ class CameraConfig(FrigateBaseModel):
|
||||
def ffmpeg_cmds(self) -> list[dict[str, list[str]]]:
|
||||
return self._ffmpeg_cmds
|
||||
|
||||
def get_formatted_name(self) -> str:
|
||||
"""Return the friendly name if set, otherwise return a formatted version of the camera name."""
|
||||
if self.friendly_name:
|
||||
return self.friendly_name
|
||||
return self.name.replace("_", " ").title() if self.name else ""
|
||||
|
||||
def create_ffmpeg_cmds(self):
|
||||
if "_ffmpeg_cmds" in self:
|
||||
return
|
||||
|
||||
@ -56,6 +56,12 @@ class ZoneConfig(BaseModel):
|
||||
def contour(self) -> np.ndarray:
|
||||
return self._contour
|
||||
|
||||
def get_formatted_name(self, zone_name: str) -> str:
|
||||
"""Return the friendly name if set, otherwise return a formatted version of the zone name."""
|
||||
if self.friendly_name:
|
||||
return self.friendly_name
|
||||
return zone_name.replace("_", " ").title()
|
||||
|
||||
@field_validator("objects", mode="before")
|
||||
@classmethod
|
||||
def validate_objects(cls, v):
|
||||
|
||||
@ -4,7 +4,6 @@ import logging
|
||||
import os
|
||||
|
||||
import sherpa_onnx
|
||||
from faster_whisper.utils import download_model
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
@ -25,6 +24,9 @@ class AudioTranscriptionModelRunner:
|
||||
|
||||
if model_size == "large":
|
||||
# use the Whisper download function instead of our own
|
||||
# Import dynamically to avoid crashes on systems without AVX support
|
||||
from faster_whisper.utils import download_model
|
||||
|
||||
logger.debug("Downloading Whisper audio transcription model")
|
||||
download_model(
|
||||
size_or_id="small" if device == "cuda" else "tiny",
|
||||
|
||||
@ -6,7 +6,6 @@ import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
@ -51,6 +50,9 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
|
||||
def __build_recognizer(self) -> None:
|
||||
try:
|
||||
# Import dynamically to avoid crashes on systems without AVX support
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
self.recognizer = WhisperModel(
|
||||
model_size_or_path="small",
|
||||
device="cuda"
|
||||
|
||||
@ -16,6 +16,7 @@ from peewee import DoesNotExist
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera import CameraConfig
|
||||
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
@ -30,6 +31,7 @@ from ..types import DataProcessorMetrics
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
||||
MIN_RECORDING_DURATION = 10
|
||||
|
||||
|
||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
@ -130,7 +132,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
if image_source == ImageSourceEnum.recordings:
|
||||
duration = final_data["end_time"] - final_data["start_time"]
|
||||
buffer_extension = duration * RECORDING_BUFFER_EXTENSION_PERCENT
|
||||
buffer_extension = min(
|
||||
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||
)
|
||||
|
||||
# Ensure minimum total duration for short review items
|
||||
# This provides better context for brief events
|
||||
total_duration = duration + (2 * buffer_extension)
|
||||
if total_duration < MIN_RECORDING_DURATION:
|
||||
# Expand buffer to reach minimum duration, still respecting max of 10s per side
|
||||
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
||||
buffer_extension = min(10, additional_buffer_per_side)
|
||||
|
||||
thumbs = self.get_recording_frames(
|
||||
camera,
|
||||
@ -182,7 +194,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
self.requestor,
|
||||
self.genai_client,
|
||||
self.review_desc_speed,
|
||||
camera,
|
||||
camera_config,
|
||||
final_data,
|
||||
thumbs,
|
||||
camera_config.review.genai,
|
||||
@ -411,7 +423,7 @@ def run_analysis(
|
||||
requestor: InterProcessRequestor,
|
||||
genai_client: GenAIClient,
|
||||
review_inference_speed: InferenceSpeed,
|
||||
camera: str,
|
||||
camera_config: CameraConfig,
|
||||
final_data: dict[str, str],
|
||||
thumbs: list[bytes],
|
||||
genai_config: GenAIReviewConfig,
|
||||
@ -419,10 +431,19 @@ def run_analysis(
|
||||
attribute_labels: list[str],
|
||||
) -> None:
|
||||
start = datetime.datetime.now().timestamp()
|
||||
|
||||
# Format zone names using zone config friendly names if available
|
||||
formatted_zones = []
|
||||
for zone_name in final_data["data"]["zones"]:
|
||||
if zone_name in camera_config.zones:
|
||||
formatted_zones.append(
|
||||
camera_config.zones[zone_name].get_formatted_name(zone_name)
|
||||
)
|
||||
|
||||
analytics_data = {
|
||||
"id": final_data["id"],
|
||||
"camera": camera,
|
||||
"zones": final_data["data"]["zones"],
|
||||
"camera": camera_config.get_formatted_name(),
|
||||
"zones": formatted_zones,
|
||||
"start": datetime.datetime.fromtimestamp(final_data["start_time"]).strftime(
|
||||
"%A, %I:%M %p"
|
||||
),
|
||||
|
||||
@ -394,7 +394,11 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||
|
||||
# Run inference
|
||||
try:
|
||||
self.infer_request.infer()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during OpenVINO inference: {e}")
|
||||
return []
|
||||
|
||||
# Get all output tensors
|
||||
outputs = []
|
||||
|
||||
@ -472,7 +472,7 @@ class Embeddings:
|
||||
)
|
||||
thumbnail_missing = True
|
||||
except DoesNotExist:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
|
||||
)
|
||||
continue
|
||||
|
||||
@ -51,8 +51,7 @@ class GenAIClient:
|
||||
def get_concern_prompt() -> str:
|
||||
if concerns:
|
||||
concern_list = "\n - ".join(concerns)
|
||||
return f"""
|
||||
- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
||||
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
||||
- {concern_list}"""
|
||||
else:
|
||||
return ""
|
||||
@ -70,7 +69,7 @@ class GenAIClient:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
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
|
||||
|
||||
@ -110,7 +109,7 @@ Your response MUST be a flat JSON object with:
|
||||
|
||||
- Frame 1 = earliest, Frame {len(thumbnails)} = latest
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(z.replace("_", " ").title() for z in review_data["zones"]) or "None"}
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"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": {
|
||||
@ -43,7 +44,9 @@
|
||||
},
|
||||
"deleteCategory": {
|
||||
"title": "Delete Class",
|
||||
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
|
||||
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model.",
|
||||
"minClassesTitle": "Cannot Delete Class",
|
||||
"minClassesDesc": "A classification model must have at least 2 classes. Add another class before deleting this one."
|
||||
},
|
||||
"deleteModel": {
|
||||
"title": "Delete Classification Model",
|
||||
|
||||
@ -154,6 +154,7 @@
|
||||
"description": "Follow the steps below to add a new camera to your Frigate installation.",
|
||||
"steps": {
|
||||
"nameAndConnection": "Name & Connection",
|
||||
"probeOrSnapshot": "Probe or Snapshot",
|
||||
"streamConfiguration": "Stream Configuration",
|
||||
"validationAndTesting": "Validation & Testing"
|
||||
},
|
||||
@ -172,7 +173,7 @@
|
||||
"testFailed": "Stream test failed: {{error}}"
|
||||
},
|
||||
"step1": {
|
||||
"description": "Enter your camera details and test the connection.",
|
||||
"description": "Enter your camera details and choose to probe the camera or manually select the brand.",
|
||||
"cameraName": "Camera Name",
|
||||
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||
"host": "Host/IP Address",
|
||||
@ -188,33 +189,63 @@
|
||||
"brandInformation": "Brand information",
|
||||
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
|
||||
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||
"testConnection": "Test Connection",
|
||||
"testSuccess": "Connection test successful!",
|
||||
"testFailed": "Connection test failed. Please check your input and try again.",
|
||||
"streamDetails": "Stream Details",
|
||||
"testing": {
|
||||
"probingMetadata": "Probing camera metadata...",
|
||||
"fetchingSnapshot": "Fetching camera snapshot..."
|
||||
},
|
||||
"warnings": {
|
||||
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
|
||||
},
|
||||
"connectionSettings": "Connection Settings",
|
||||
"detectionMethod": "Stream Detection Method",
|
||||
"onvifPort": "ONVIF Port",
|
||||
"probeMode": "Probe camera",
|
||||
"manualMode": "Manual selection",
|
||||
"detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".",
|
||||
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
|
||||
"errors": {
|
||||
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
||||
"nameRequired": "Camera name is required",
|
||||
"nameLength": "Camera name must be 64 characters or less",
|
||||
"invalidCharacters": "Camera name contains invalid characters",
|
||||
"nameExists": "Camera name already exists",
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams."
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"description": "Probe the camera for available streams or configure manual settings based on your selected detection method.",
|
||||
"testSuccess": "Connection test successful!",
|
||||
"testFailed": "Connection test failed. Please check your input and try again.",
|
||||
"testFailedTitle": "Test Failed",
|
||||
"streamDetails": "Stream Details",
|
||||
"probing": "Probing camera...",
|
||||
"retry": "Retry",
|
||||
"testing": {
|
||||
"probingMetadata": "Probing camera metadata...",
|
||||
"fetchingSnapshot": "Fetching camera snapshot..."
|
||||
},
|
||||
"probeFailed": "Failed to probe camera: {{error}}",
|
||||
"probingDevice": "Probing device...",
|
||||
"probeSuccessful": "Probe successful",
|
||||
"probeError": "Probe Error",
|
||||
"probeNoSuccess": "Probe unsuccessful",
|
||||
"deviceInfo": "Device Information",
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"firmware": "Firmware",
|
||||
"profiles": "Profiles",
|
||||
"ptzSupport": "PTZ Support",
|
||||
"autotrackingSupport": "Autotracking Support",
|
||||
"presets": "Presets",
|
||||
"rtspCandidates": "RTSP Candidates",
|
||||
"rtspCandidatesDescription": "The following RTSP URLs were found from the camera probe. Test the connection to view stream metadata.",
|
||||
"noRtspCandidates": "No RTSP URLs were found from the camera. Your credentials may be incorrect, or the camera may not support ONVIF or the method used to retrieve RTSP URLs. Go back and enter the RTSP URL manually.",
|
||||
"candidateStreamTitle": "Candidate {{number}}",
|
||||
"useCandidate": "Use",
|
||||
"uriCopy": "Copy",
|
||||
"uriCopied": "URI copied to clipboard",
|
||||
"testConnection": "Test Connection",
|
||||
"toggleUriView": "Click to toggle full URI view",
|
||||
"connected": "Connected",
|
||||
"notConnected": "Not Connected",
|
||||
"errors": {
|
||||
"hostRequired": "Host/IP address is required"
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"description": "Configure stream roles and add additional streams for your camera.",
|
||||
"streamsTitle": "Camera Streams",
|
||||
"addStream": "Add Stream",
|
||||
@ -222,6 +253,9 @@
|
||||
"streamTitle": "Stream {{number}}",
|
||||
"streamUrl": "Stream URL",
|
||||
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||
"selectStream": "Select a stream",
|
||||
"searchCandidates": "Search candidates...",
|
||||
"noStreamFound": "No stream found",
|
||||
"url": "URL",
|
||||
"resolution": "Resolution",
|
||||
"selectResolution": "Select resolution",
|
||||
@ -253,7 +287,7 @@
|
||||
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"step4": {
|
||||
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
|
||||
"validationTitle": "Stream Validation",
|
||||
"connectAllStreams": "Connect All Streams",
|
||||
@ -289,6 +323,9 @@
|
||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard."
|
||||
},
|
||||
"dahua": {
|
||||
"substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available."
|
||||
},
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
CustomClassificationModelConfig,
|
||||
FrigateConfig,
|
||||
} from "@/types/frigateConfig";
|
||||
import { ClassificationDatasetResponse } from "@/types/classification";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
@ -140,16 +141,19 @@ export default function ClassificationModelEditDialog({
|
||||
});
|
||||
|
||||
// Fetch dataset to get current classes for state models
|
||||
const { data: dataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
}>(isStateModel ? `classification/${model.name}/dataset` : null, {
|
||||
const { data: dataset } = useSWR<ClassificationDatasetResponse>(
|
||||
isStateModel ? `classification/${model.name}/dataset` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Update form with classes from dataset when loaded
|
||||
useEffect(() => {
|
||||
if (isStateModel && dataset) {
|
||||
const classes = Object.keys(dataset).filter((key) => key !== "none");
|
||||
if (isStateModel && dataset?.categories) {
|
||||
const classes = Object.keys(dataset.categories).filter(
|
||||
(key) => key !== "none",
|
||||
);
|
||||
if (classes.length > 0) {
|
||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||
"classes",
|
||||
|
||||
@ -15,6 +15,7 @@ import Step3ChooseExamples, {
|
||||
} from "./wizard/Step3ChooseExamples";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import axios from "axios";
|
||||
|
||||
const OBJECT_STEPS = [
|
||||
"wizard.steps.nameAndDefine",
|
||||
@ -120,7 +121,18 @@ export default function ClassificationModelWizardDialog({
|
||||
dispatch({ type: "PREVIOUS_STEP" });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const handleCancel = async () => {
|
||||
// Clean up any generated training images if we're cancelling from Step 3
|
||||
if (wizardState.step1Data && wizardState.step3Data?.examplesGenerated) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/classification/${wizardState.step1Data.modelName}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Silently fail - user is already cancelling
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: "RESET" });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@ -165,18 +165,15 @@ export default function Step3ChooseExamples({
|
||||
const isLastClass = currentClassIndex === allClasses.length - 1;
|
||||
|
||||
if (isLastClass) {
|
||||
// Assign remaining unclassified images
|
||||
// For object models, assign remaining unclassified images to "none"
|
||||
// For state models, this should never happen since we require all images to be classified
|
||||
if (step1Data.modelType !== "state") {
|
||||
unknownImages.slice(0, 24).forEach((imageName) => {
|
||||
if (!newClassifications[imageName]) {
|
||||
// For state models with 2 classes, assign to the last class
|
||||
// For object models, assign to "none"
|
||||
if (step1Data.modelType === "state" && allClasses.length === 2) {
|
||||
newClassifications[imageName] = allClasses[allClasses.length - 1];
|
||||
} else {
|
||||
newClassifications[imageName] = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// All done, trigger training immediately
|
||||
setImageClassifications(newClassifications);
|
||||
@ -316,8 +313,15 @@ export default function Step3ChooseExamples({
|
||||
return images;
|
||||
}
|
||||
|
||||
return images.filter((img) => !imageClassifications[img]);
|
||||
}, [unknownImages, imageClassifications]);
|
||||
// If we're viewing a previous class (going back), show images for that class
|
||||
// Otherwise show only unclassified images
|
||||
const currentClassInView = allClasses[currentClassIndex];
|
||||
return images.filter((img) => {
|
||||
const imgClass = imageClassifications[img];
|
||||
// Show if: unclassified OR classified with current class we're viewing
|
||||
return !imgClass || imgClass === currentClassInView;
|
||||
});
|
||||
}, [unknownImages, imageClassifications, allClasses, currentClassIndex]);
|
||||
|
||||
const allImagesClassified = useMemo(() => {
|
||||
return unclassifiedImages.length === 0;
|
||||
@ -326,15 +330,26 @@ export default function Step3ChooseExamples({
|
||||
// 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 &&
|
||||
!allImagesClassified
|
||||
) {
|
||||
return false;
|
||||
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, allImagesClassified]);
|
||||
}, [
|
||||
step1Data.modelType,
|
||||
isLastClass,
|
||||
unknownImages,
|
||||
imageClassifications,
|
||||
selectedImages,
|
||||
]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentClassIndex > 0) {
|
||||
|
||||
@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl",
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
|
||||
upperClassName,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent",
|
||||
lowerClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -77,7 +77,10 @@ export default function BirdseyeLivePlayer({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ImageShadowOverlay />
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div className="size-full" ref={playerRef}>
|
||||
{player}
|
||||
</div>
|
||||
|
||||
@ -331,7 +331,10 @@ export default function LivePlayer({
|
||||
>
|
||||
{cameraEnabled &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<ImageShadowOverlay />
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
{player}
|
||||
{cameraEnabled &&
|
||||
|
||||
@ -12,15 +12,15 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
||||
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
|
||||
import Step3Validation from "@/components/settings/wizard/Step3Validation";
|
||||
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
|
||||
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
|
||||
import Step4Validation from "@/components/settings/wizard/Step4Validation";
|
||||
import type {
|
||||
WizardFormData,
|
||||
CameraConfigData,
|
||||
ConfigSetBody,
|
||||
} from "@/types/cameraWizard";
|
||||
import { processCameraName } from "@/utils/cameraUtil";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WizardState = {
|
||||
@ -57,6 +57,7 @@ const wizardReducer = (
|
||||
|
||||
const STEPS = [
|
||||
"cameraWizard.steps.nameAndConnection",
|
||||
"cameraWizard.steps.probeOrSnapshot",
|
||||
"cameraWizard.steps.streamConfiguration",
|
||||
"cameraWizard.steps.validationAndTesting",
|
||||
];
|
||||
@ -100,20 +101,20 @@ export default function CameraWizardDialog({
|
||||
const canProceedToNext = useCallback((): boolean => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
// Can proceed if camera name is set and at least one stream exists
|
||||
return !!(
|
||||
state.wizardData.cameraName &&
|
||||
(state.wizardData.streams?.length ?? 0) > 0
|
||||
);
|
||||
// Step 1: Can proceed if camera name is set
|
||||
return !!state.wizardData.cameraName;
|
||||
case 1:
|
||||
// Can proceed if at least one stream has 'detect' role
|
||||
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
||||
return (state.wizardData.streams?.length ?? 0) > 0;
|
||||
case 2:
|
||||
// Step 3: Can proceed if at least one stream has 'detect' role
|
||||
return !!(
|
||||
state.wizardData.streams?.some((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
) ?? false
|
||||
);
|
||||
case 2:
|
||||
// Always can proceed from final step (save will be handled there)
|
||||
case 3:
|
||||
// Step 4: Always can proceed from final step (save will be handled there)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@ -340,13 +341,7 @@ export default function CameraWizardDialog({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90dvh] max-w-xl overflow-y-auto",
|
||||
isDesktop &&
|
||||
currentStep == 0 &&
|
||||
state.wizardData?.streams?.[0]?.testResult?.snapshot &&
|
||||
"max-w-4xl",
|
||||
isDesktop && currentStep == 1 && "max-w-2xl",
|
||||
isDesktop && currentStep > 1 && "max-w-4xl",
|
||||
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
@ -385,7 +380,16 @@ export default function CameraWizardDialog({
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Step2StreamConfig
|
||||
<Step2ProbeOrSnapshot
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
probeMode={state.wizardData.probeMode ?? true}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3StreamConfig
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onBack={handleBack}
|
||||
@ -393,8 +397,8 @@ export default function CameraWizardDialog({
|
||||
canProceed={canProceedToNext()}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Step3Validation
|
||||
{currentStep === 3 && (
|
||||
<Step4Validation
|
||||
wizardData={state.wizardData}
|
||||
onUpdate={onUpdate}
|
||||
onSave={handleSave}
|
||||
|
||||
368
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
368
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
const maskUri = (uri: string) => {
|
||||
const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
|
||||
if (match) return `rtsp://${match[1]}:••••@${match[3]}`;
|
||||
return uri;
|
||||
};
|
||||
|
||||
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,6 +2,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@ -15,15 +16,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
@ -31,20 +30,13 @@ import {
|
||||
CameraBrand,
|
||||
CAMERA_BRANDS,
|
||||
CAMERA_BRAND_VALUES,
|
||||
TestResult,
|
||||
FfprobeStream,
|
||||
StreamRole,
|
||||
StreamConfig,
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { Card, CardContent, CardTitle } from "../../ui/card";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type Step1NameCameraProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
@ -63,9 +55,9 @@ export default function Step1NameCamera({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [probeMode, setProbeMode] = useState<boolean>(
|
||||
wizardData.probeMode ?? true,
|
||||
);
|
||||
|
||||
const existingCameraNames = useMemo(() => {
|
||||
if (!config?.cameras) {
|
||||
@ -88,6 +80,7 @@ export default function Step1NameCamera({
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
customUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
@ -124,6 +117,7 @@ export default function Step1NameCamera({
|
||||
? (wizardData.brandTemplate as CameraBrand)
|
||||
: "dahua",
|
||||
customUrl: wizardData.customUrl || "",
|
||||
onvifPort: wizardData.onvifPort ?? 80,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
@ -132,243 +126,32 @@ export default function Step1NameCamera({
|
||||
const watchedHost = form.watch("host");
|
||||
const watchedCustomUrl = form.watch("customUrl");
|
||||
|
||||
const isTestButtonEnabled =
|
||||
watchedBrand === "other"
|
||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||
: !!(watchedHost && watchedHost.trim());
|
||||
const hostPresent = !!(watchedHost && watchedHost.trim());
|
||||
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
|
||||
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
|
||||
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return null;
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
data.host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Use detected protocol or fallback to rtsp
|
||||
const protocolKey = protocol || "rtsp";
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
if (Object.keys(templates).includes(protocolKey)) {
|
||||
const template =
|
||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||
return template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string> => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||
|
||||
if (dynamicUrl) {
|
||||
return dynamicUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[generateDynamicStreamUrl],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(async () => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestStatus("");
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// First get probe data for metadata
|
||||
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
||||
const probeResponse = await axios.get("ffprobe", {
|
||||
params: { paths: streamUrl, detailed: true },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let probeData = null;
|
||||
if (
|
||||
probeResponse.data &&
|
||||
probeResponse.data.length > 0 &&
|
||||
probeResponse.data[0].return_code === 0
|
||||
) {
|
||||
probeData = probeResponse.data[0];
|
||||
}
|
||||
|
||||
// Then get snapshot for preview (only if probe succeeded)
|
||||
let snapshotBlob = null;
|
||||
if (probeData) {
|
||||
setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
|
||||
try {
|
||||
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||
params: { url: streamUrl },
|
||||
responseType: "blob",
|
||||
timeout: 10000,
|
||||
});
|
||||
snapshotBlob = snapshotResponse.data;
|
||||
} catch (snapshotError) {
|
||||
// Snapshot is optional, don't fail if it doesn't work
|
||||
toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
|
||||
}
|
||||
}
|
||||
|
||||
if (probeData) {
|
||||
const ffprobeData = probeData.stdout;
|
||||
const streams = ffprobeData.streams || [];
|
||||
|
||||
const videoStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
? `${videoStream.width}x${videoStream.height}`
|
||||
: undefined;
|
||||
|
||||
// Extract FPS from rational (e.g., "15/1" -> 15)
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
// Convert snapshot blob to base64 if available
|
||||
let snapshotBase64 = undefined;
|
||||
if (snapshotBlob) {
|
||||
snapshotBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(snapshotBlob);
|
||||
});
|
||||
}
|
||||
|
||||
const testResult: TestResult = {
|
||||
success: true,
|
||||
snapshot: snapshotBase64,
|
||||
resolution,
|
||||
videoCodec: videoStream?.codec_name,
|
||||
audioCodec: audioStream?.codec_name,
|
||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||
};
|
||||
|
||||
setTestResult(testResult);
|
||||
onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] });
|
||||
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||
} else {
|
||||
const error =
|
||||
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||
probeResponse.data[0].stderr.length > 0
|
||||
? probeResponse.data[0].stderr.join("\n")
|
||||
: "Unable to probe stream";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: error,
|
||||
});
|
||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }), {
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.message ||
|
||||
"Connection failed";
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
toast.error(
|
||||
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||
{
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setTestStatus("");
|
||||
}
|
||||
}, [form, generateStreamUrl, t, onUpdate]);
|
||||
const isContinueButtonEnabled =
|
||||
cameraNamePresent &&
|
||||
(probeMode
|
||||
? hostPresent
|
||||
: watchedBrand === "other"
|
||||
? customPresent
|
||||
: hostPresent);
|
||||
|
||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||
onUpdate(data);
|
||||
onUpdate({ ...data, probeMode });
|
||||
};
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (isValid) {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
const streamId = `stream_${Date.now()}`;
|
||||
|
||||
const streamConfig: StreamConfig = {
|
||||
id: streamId,
|
||||
url: streamUrl,
|
||||
roles: ["detect" as StreamRole],
|
||||
resolution: testResult?.resolution,
|
||||
testResult: testResult || undefined,
|
||||
userTested: false,
|
||||
};
|
||||
|
||||
const updatedData = {
|
||||
...data,
|
||||
streams: [streamConfig],
|
||||
};
|
||||
|
||||
onNext(updatedData);
|
||||
}, [form, generateStreamUrl, testResult, onNext]);
|
||||
onNext({ ...data, probeMode });
|
||||
}
|
||||
}, [form, probeMode, onNext]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!testResult?.success && (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.description")}
|
||||
</div>
|
||||
@ -386,9 +169,7 @@ export default function Step1NameCamera({
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.cameraNamePlaceholder",
|
||||
)}
|
||||
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -397,6 +178,160 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8 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>
|
||||
)}
|
||||
/>
|
||||
</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 && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandTemplate"
|
||||
@ -463,90 +398,6 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedBrand !== "other" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.host")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.usernamePlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("cameraWizard.step1.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBrand == "other" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -568,124 +419,25 @@ export default function Step1NameCamera({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{testResult?.success && (
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
||||
<FaCircleCheck className="size-4" />
|
||||
{t("cameraWizard.step1.testSuccess")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{testResult.snapshot ? (
|
||||
<div className="relative flex justify-center">
|
||||
<img
|
||||
src={testResult.snapshot}
|
||||
alt="Camera snapshot"
|
||||
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
|
||||
<div className="space-y-1">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-4">
|
||||
<CardTitle className="mb-2 text-sm">
|
||||
{t("cameraWizard.step1.streamDetails")}
|
||||
</CardTitle>
|
||||
<CardContent className="p-0 text-sm">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTesting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
{testStatus}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{testResult?.success
|
||||
? t("button.back", { ns: "common" })
|
||||
: t("button.cancel", { ns: "common" })}
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{testResult?.success ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
disabled={!isContinueButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={testConnection}
|
||||
disabled={isTesting || !isTestButtonEnabled}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
>
|
||||
{t("cameraWizard.step1.testConnection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamDetails({ testResult }: { testResult: TestResult }) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{testResult.resolution && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.resolution")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.resolution}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.fps && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.fps")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.fps}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.videoCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.video")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.videoCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.audioCodec && (
|
||||
<div>
|
||||
<span className="text-white/70">
|
||||
{t("cameraWizard.testResultLabels.audio")}:
|
||||
</span>{" "}
|
||||
<span className="text-white">{testResult.audioCodec}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
724
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
724
web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
Normal file
@ -0,0 +1,724 @@
|
||||
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,
|
||||
},
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -19,7 +19,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Card, CardContent } from "../../ui/card";
|
||||
|
||||
type Step3ValidationProps = {
|
||||
type Step4ValidationProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
onUpdate: (data: Partial<WizardFormData>) => void;
|
||||
onSave: (config: WizardFormData) => void;
|
||||
@ -27,13 +27,13 @@ type Step3ValidationProps = {
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function Step3Validation({
|
||||
export default function Step4Validation({
|
||||
wizardData,
|
||||
onUpdate,
|
||||
onSave,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}: Step3ValidationProps) {
|
||||
}: Step4ValidationProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
@ -143,13 +143,13 @@ export default function Step3Validation({
|
||||
|
||||
if (testResult.success) {
|
||||
toast.success(
|
||||
t("cameraWizard.step3.streamValidated", {
|
||||
t("cameraWizard.step4.streamValidated", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("cameraWizard.step3.streamValidationFailed", {
|
||||
t("cameraWizard.step4.streamValidationFailed", {
|
||||
number: streams.findIndex((s) => s.id === stream.id) + 1,
|
||||
}),
|
||||
);
|
||||
@ -200,16 +200,16 @@ export default function Step3Validation({
|
||||
(r) => r.success,
|
||||
).length;
|
||||
if (successfulTests === results.size) {
|
||||
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
|
||||
toast.success(t("cameraWizard.step4.reconnectionSuccess"));
|
||||
} else {
|
||||
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
|
||||
toast.warning(t("cameraWizard.step4.reconnectionPartial"));
|
||||
}
|
||||
}
|
||||
}, [streams, onUpdate, t, performStreamValidation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!wizardData.cameraName || !wizardData.streams?.length) {
|
||||
toast.error(t("cameraWizard.step3.saveError"));
|
||||
toast.error(t("cameraWizard.step4.saveError"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -239,13 +239,13 @@ export default function Step3Validation({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step3.description")}
|
||||
{t("cameraWizard.step4.description")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("cameraWizard.step3.validationTitle")}
|
||||
{t("cameraWizard.step4.validationTitle")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={validateAllStreams}
|
||||
@ -254,8 +254,8 @@ export default function Step3Validation({
|
||||
>
|
||||
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isValidating
|
||||
? t("cameraWizard.step3.connecting")
|
||||
: t("cameraWizard.step3.connectAllStreams")}
|
||||
? t("cameraWizard.step4.connecting")
|
||||
: t("cameraWizard.step4.connectAllStreams")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -270,7 +270,7 @@ export default function Step3Validation({
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex flex-row items-center">
|
||||
<h4 className="mr-2 font-medium">
|
||||
{t("cameraWizard.step3.streamTitle", {
|
||||
{t("cameraWizard.step4.streamTitle", {
|
||||
number: index + 1,
|
||||
})}
|
||||
</h4>
|
||||
@ -331,7 +331,7 @@ export default function Step3Validation({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@ -346,11 +346,11 @@ export default function Step3Validation({
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
{t("cameraWizard.step3.ffmpegModule")}
|
||||
{t("cameraWizard.step4.ffmpegModule")}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t(
|
||||
"cameraWizard.step3.ffmpegModuleDescription",
|
||||
"cameraWizard.step4.ffmpegModuleDescription",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -402,17 +402,17 @@ export default function Step3Validation({
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{result?.success
|
||||
? t("cameraWizard.step3.disconnectStream")
|
||||
? t("cameraWizard.step4.disconnectStream")
|
||||
: testingStreams.has(stream.id)
|
||||
? t("cameraWizard.step3.connectingStream")
|
||||
: t("cameraWizard.step3.connectStream")}
|
||||
? t("cameraWizard.step4.connectingStream")
|
||||
: t("cameraWizard.step4.connectStream")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs">
|
||||
{t("cameraWizard.step3.issues.title")}
|
||||
{t("cameraWizard.step4.issues.title")}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<StreamIssues
|
||||
@ -455,7 +455,7 @@ export default function Step3Validation({
|
||||
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
|
||||
{isLoading
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("cameraWizard.step3.saveAndApply")}
|
||||
: t("cameraWizard.step4.saveAndApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -486,7 +486,7 @@ function StreamIssues({
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
|
||||
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -497,7 +497,7 @@ function StreamIssues({
|
||||
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.videoCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
||||
codec: stream.testResult.videoCodec,
|
||||
}),
|
||||
});
|
||||
@ -511,20 +511,20 @@ function StreamIssues({
|
||||
if (audioCodec === "aac") {
|
||||
result.push({
|
||||
type: "good",
|
||||
message: t("cameraWizard.step3.issues.audioCodecGood", {
|
||||
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
||||
codec: stream.testResult.audioCodec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.noAudioWarning"),
|
||||
message: t("cameraWizard.step4.issues.noAudioWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -534,7 +534,7 @@ function StreamIssues({
|
||||
if (!stream.testResult?.audioCodec) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.audioCodecRequired"),
|
||||
message: t("cameraWizard.step4.issues.audioCodecRequired"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -544,7 +544,7 @@ function StreamIssues({
|
||||
if (stream.restream) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.restreamingWarning"),
|
||||
message: t("cameraWizard.step4.issues.restreamingWarning"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -557,14 +557,14 @@ function StreamIssues({
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.resolutionHigh", {
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step3.issues.resolutionLow", {
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
resolution: stream.resolution,
|
||||
}),
|
||||
});
|
||||
@ -580,7 +580,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.dahua.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@ -590,7 +590,7 @@ function StreamIssues({
|
||||
) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"),
|
||||
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
|
||||
});
|
||||
}
|
||||
|
||||
@ -662,7 +662,7 @@ function BandwidthDisplay({
|
||||
return (
|
||||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("cameraWizard.step3.estimatedBandwidth")}:
|
||||
{t("cameraWizard.step4.estimatedBandwidth")}:
|
||||
</span>{" "}
|
||||
<span className="text-secondary-foreground">
|
||||
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
|
||||
@ -748,7 +748,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<span className="text-sm text-danger">
|
||||
{t("cameraWizard.step3.streamUnavailable")}
|
||||
{t("cameraWizard.step4.streamUnavailable")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -757,7 +757,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuRotateCcw className="size-4" />
|
||||
{t("cameraWizard.step3.reload")}
|
||||
{t("cameraWizard.step4.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -771,7 +771,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
||||
>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span className="ml-2 text-sm">
|
||||
{t("cameraWizard.step3.connecting")}
|
||||
{t("cameraWizard.step4.connecting")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -1,4 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
|
||||
@ -36,6 +42,23 @@ export function DetailStreamProvider({
|
||||
() => initialSelectedObjectIds ?? [],
|
||||
);
|
||||
|
||||
// When the parent provides a new initialSelectedObjectIds (for example
|
||||
// when navigating between search results) update the selection so children
|
||||
// like `ObjectTrackOverlay` receive the new ids immediately. We only
|
||||
// perform this update when the incoming value actually changes.
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialSelectedObjectIds &&
|
||||
(initialSelectedObjectIds.length !== selectedObjectIds.length ||
|
||||
initialSelectedObjectIds.some((v, i) => selectedObjectIds[i] !== v))
|
||||
) {
|
||||
setSelectedObjectIds(initialSelectedObjectIds);
|
||||
}
|
||||
// Intentionally include selectedObjectIds to compare previous value and
|
||||
// avoid overwriting user interactions unless the incoming prop changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialSelectedObjectIds]);
|
||||
|
||||
const toggleObjectSelection = (id: string | undefined) => {
|
||||
if (id === undefined) {
|
||||
setSelectedObjectIds([]);
|
||||
@ -63,10 +86,33 @@ export function DetailStreamProvider({
|
||||
setAnnotationOffset(cfgOffset);
|
||||
}, [config, camera]);
|
||||
|
||||
// Clear selected objects when exiting detail mode or changing cameras
|
||||
// Clear selected objects when exiting detail mode or when the camera
|
||||
// changes for providers that are not initialized with an explicit
|
||||
// `initialSelectedObjectIds` (e.g., the RecordingView). For providers
|
||||
// that receive `initialSelectedObjectIds` (like SearchDetailDialog) we
|
||||
// avoid clearing on camera change to prevent a race with children that
|
||||
// immediately set selection when mounting.
|
||||
const prevCameraRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
// Always clear when leaving detail mode
|
||||
if (!isDetailMode) {
|
||||
setSelectedObjectIds([]);
|
||||
}, [isDetailMode, camera]);
|
||||
prevCameraRef.current = camera;
|
||||
return;
|
||||
}
|
||||
|
||||
// If camera changed and the parent did not provide initialSelectedObjectIds,
|
||||
// clear selection to preserve previous behavior.
|
||||
if (
|
||||
prevCameraRef.current !== undefined &&
|
||||
prevCameraRef.current !== camera &&
|
||||
initialSelectedObjectIds === undefined
|
||||
) {
|
||||
setSelectedObjectIds([]);
|
||||
}
|
||||
|
||||
prevCameraRef.current = camera;
|
||||
}, [isDetailMode, camera, initialSelectedObjectIds]);
|
||||
|
||||
const value: DetailStreamContextType = {
|
||||
selectedObjectIds,
|
||||
|
||||
@ -99,6 +99,11 @@ export type TestResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CandidateTestMap = Record<
|
||||
string,
|
||||
TestResult | { success: false; error: string }
|
||||
>;
|
||||
|
||||
export type WizardFormData = {
|
||||
cameraName?: string;
|
||||
host?: string;
|
||||
@ -107,12 +112,17 @@ export type WizardFormData = {
|
||||
brandTemplate?: CameraBrand;
|
||||
customUrl?: string;
|
||||
streams?: StreamConfig[];
|
||||
probeMode?: boolean; // true for probe, false for manual
|
||||
onvifPort?: number;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
probeCandidates?: string[]; // candidate URLs from probe
|
||||
candidateTests?: CandidateTestMap; // test results for candidates
|
||||
};
|
||||
|
||||
// API Response Types
|
||||
export type FfprobeResponse = {
|
||||
return_code: number;
|
||||
stderr: string;
|
||||
stderr: string | string[];
|
||||
stdout: FfprobeData | string;
|
||||
};
|
||||
|
||||
@ -167,3 +177,26 @@ export type ConfigSetBody = {
|
||||
config_data: CameraConfigData;
|
||||
update_topic?: string;
|
||||
};
|
||||
|
||||
export type OnvifRtspCandidate = {
|
||||
source: "GetStreamUri" | "pattern";
|
||||
profile_token?: string;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export type OnvifProbeResponse = {
|
||||
success: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
firmware_version?: string;
|
||||
profiles_count?: number;
|
||||
ptz_supported?: boolean;
|
||||
presets_count?: number;
|
||||
autotrack_supported?: boolean;
|
||||
move_status_supported?: boolean;
|
||||
rtsp_candidates?: OnvifRtspCandidate[];
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
@ -20,3 +20,17 @@ export type ClassificationThreshold = {
|
||||
recognition: number;
|
||||
unknown: number;
|
||||
};
|
||||
|
||||
export type ClassificationDatasetResponse = {
|
||||
categories: {
|
||||
[id: string]: string[];
|
||||
};
|
||||
training_metadata: {
|
||||
has_trained: boolean;
|
||||
last_training_date: string | null;
|
||||
last_training_image_count: number;
|
||||
current_image_count: number;
|
||||
new_images_count: number;
|
||||
dataset_changed: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
CustomClassificationModelConfig,
|
||||
FrigateConfig,
|
||||
} from "@/types/frigateConfig";
|
||||
import { ClassificationDatasetResponse } from "@/types/classification";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolderPlus } from "react-icons/fa";
|
||||
@ -209,9 +210,10 @@ type ModelCardProps = {
|
||||
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
const { t } = useTranslation(["views/classificationModel"]);
|
||||
|
||||
const { data: dataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
||||
const { data: dataset } = useSWR<ClassificationDatasetResponse>(
|
||||
`classification/${config.name}/dataset`,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
@ -260,20 +262,25 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
}, []);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
if (!dataset) {
|
||||
if (!dataset || !dataset.categories) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys = Object.keys(dataset).filter((key) => key != "none");
|
||||
const selectedKey = keys[0];
|
||||
const keys = Object.keys(dataset.categories).filter((key) => key != "none");
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!dataset[selectedKey]) {
|
||||
const selectedKey = keys[0];
|
||||
const images = dataset.categories[selectedKey];
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name: selectedKey,
|
||||
img: dataset[selectedKey][0],
|
||||
img: images[0],
|
||||
};
|
||||
}, [dataset]);
|
||||
|
||||
@ -317,11 +324,19 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{coverImage ? (
|
||||
<>
|
||||
<img
|
||||
className="size-full"
|
||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
|
||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage.name}/${coverImage.img}`}
|
||||
/>
|
||||
<ImageShadowOverlay lowerClassName="h-[30%] z-0" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center bg-background_alt">
|
||||
<MdModelTraining className="size-16 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
|
||||
{config.name}
|
||||
</div>
|
||||
|
||||
@ -59,7 +59,11 @@ import { useNavigate } from "react-router-dom";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import TrainFilterDialog from "@/components/overlay/dialog/TrainFilterDialog";
|
||||
import useApiFilter from "@/hooks/use-api-filter";
|
||||
import { ClassificationItemData, TrainFilter } from "@/types/classification";
|
||||
import {
|
||||
ClassificationDatasetResponse,
|
||||
ClassificationItemData,
|
||||
TrainFilter,
|
||||
} from "@/types/classification";
|
||||
import {
|
||||
ClassificationCard,
|
||||
GroupedClassificationCard,
|
||||
@ -118,16 +122,10 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>(
|
||||
`classification/${model.name}/train`,
|
||||
);
|
||||
const { data: datasetResponse, mutate: refreshDataset } = useSWR<{
|
||||
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;
|
||||
} | null;
|
||||
}>(`classification/${model.name}/dataset`);
|
||||
const { data: datasetResponse, mutate: refreshDataset } =
|
||||
useSWR<ClassificationDatasetResponse>(
|
||||
`classification/${model.name}/dataset`,
|
||||
);
|
||||
|
||||
const dataset = datasetResponse?.categories || {};
|
||||
const trainingMetadata = datasetResponse?.training_metadata;
|
||||
@ -264,10 +262,11 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Always refresh dataset to update the categories list
|
||||
refreshDataset();
|
||||
|
||||
if (pageToggle == "train") {
|
||||
refreshTrain();
|
||||
} else {
|
||||
refreshDataset();
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -445,7 +444,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
variant={modelState == "failed" ? "destructive" : "select"}
|
||||
disabled={
|
||||
(modelState != "complete" && modelState != "failed") ||
|
||||
(trainingMetadata?.new_images_count ?? 0) === 0
|
||||
!trainingMetadata?.dataset_changed
|
||||
}
|
||||
>
|
||||
{modelState == "training" ? (
|
||||
@ -466,14 +465,14 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{((trainingMetadata?.new_images_count ?? 0) === 0 ||
|
||||
{(!trainingMetadata?.dataset_changed ||
|
||||
(modelState != "complete" && modelState != "failed")) && (
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{modelState == "training"
|
||||
? t("tooltip.trainingInProgress")
|
||||
: trainingMetadata?.new_images_count === 0
|
||||
? t("tooltip.noNewImages")
|
||||
: !trainingMetadata?.dataset_changed
|
||||
? t("tooltip.noChanges")
|
||||
: t("tooltip.modelNotReady")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
@ -571,13 +570,28 @@ function LibrarySelector({
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("deleteCategory.title")}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{Object.keys(dataset).length <= 2
|
||||
? t("deleteCategory.minClassesTitle")
|
||||
: t("deleteCategory.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deleteCategory.desc", { name: confirmDelete })}
|
||||
{Object.keys(dataset).length <= 2
|
||||
? t("deleteCategory.minClassesDesc")
|
||||
: t("deleteCategory.desc", { name: confirmDelete })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
{Object.keys(dataset).length <= 2 ? (
|
||||
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
|
||||
{t("button.ok", { ns: "common" })}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
@ -592,6 +606,8 @@ function LibrarySelector({
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user