Compare commits

..

No commits in common. "55294328564e6eda2b667dc4751a211e6feb2b74" and "d44340eca611e1fbc27457e39e9ce69b6554125e" have entirely different histories.

22 changed files with 230 additions and 439 deletions

View File

@ -1,2 +1 @@
cuda-python == 12.6.*; platform_machine == 'aarch64' cuda-python == 12.6.*; platform_machine == 'aarch64'
numpy == 1.26.*; platform_machine == 'aarch64'

View File

@ -37,6 +37,7 @@ from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.util.builtin import ( from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
flatten_config_data, flatten_config_data,
get_tz_modifiers,
process_config_query_string, process_config_query_string,
update_yaml_file_bulk, update_yaml_file_bulk,
) )
@ -47,7 +48,6 @@ from frigate.util.services import (
restart_frigate, restart_frigate,
vainfo_hwaccel, vainfo_hwaccel,
) )
from frigate.util.time import get_tz_modifiers
from frigate.version import VERSION from frigate.version import VERSION
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -403,10 +403,9 @@ def config_set(request: Request, body: AppConfigSetBody):
settings, settings,
) )
else: else:
# Generic handling for global config updates # Handle nested config updates (e.g., config/classification/custom/{name})
settings = config.get_nested_object(body.update_topic) settings = config.get_nested_object(body.update_topic)
if settings:
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish( request.app.config_publisher.publisher.publish(
body.update_topic, settings body.update_topic, settings
) )

View File

@ -31,7 +31,7 @@ from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera import DetectConfig from frigate.config.camera import DetectConfig
from frigate.const import CLIPS_DIR, FACE_DIR, MODEL_CACHE_DIR from frigate.const import CLIPS_DIR, FACE_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Event from frigate.models import Event
from frigate.util.classification import ( from frigate.util.classification import (
@ -828,13 +828,9 @@ def delete_classification_model(request: Request, name: str):
status_code=404, status_code=404,
) )
# Delete the classification model's data directory in clips # Delete the classification model's data directory
data_dir = os.path.join(CLIPS_DIR, sanitize_filename(name)) model_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
if os.path.exists(data_dir):
shutil.rmtree(data_dir)
# Delete the classification model's files in model_cache
model_dir = os.path.join(MODEL_CACHE_DIR, sanitize_filename(name))
if os.path.exists(model_dir): if os.path.exists(model_dir):
shutil.rmtree(model_dir) shutil.rmtree(model_dir)

View File

@ -57,8 +57,8 @@ from frigate.const import CLIPS_DIR, TRIGGER_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Event, ReviewSegment, Timeline, Trigger from frigate.models import Event, ReviewSegment, Timeline, Trigger
from frigate.track.object_processing import TrackedObject from frigate.track.object_processing import TrackedObject
from frigate.util.builtin import get_tz_modifiers
from frigate.util.path import get_event_thumbnail_bytes from frigate.util.path import get_event_thumbnail_bytes
from frigate.util.time import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -34,7 +34,7 @@ from frigate.record.export import (
PlaybackSourceEnum, PlaybackSourceEnum,
RecordingExporter, RecordingExporter,
) )
from frigate.util.time import is_current_hour from frigate.util.builtin import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -44,9 +44,9 @@ from frigate.const import (
) )
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.track.object_processing import TrackedObjectProcessor from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.builtin import get_tz_modifiers
from frigate.util.image import get_image_from_recording from frigate.util.image import get_image_from_recording
from frigate.util.path import get_event_thumbnail_bytes from frigate.util.path import get_event_thumbnail_bytes
from frigate.util.time import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -36,7 +36,7 @@ from frigate.config import FrigateConfig
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.util.time import get_dst_transitions, get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -329,57 +329,16 @@ async def review_summary(
) )
clauses.append(reduce(operator.or_, label_clauses)) clauses.append(reduce(operator.or_, label_clauses))
# Find the time range of available data
time_range_query = (
ReviewSegment.select(
fn.MIN(ReviewSegment.start_time).alias("min_time"),
fn.MAX(ReviewSegment.start_time).alias("max_time"),
)
.where(reduce(operator.and_, clauses) if clauses else True)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
data = {
"last24Hours": last_24_query,
}
# If no data, return early
if min_time is None or max_time is None:
return JSONResponse(content=data)
# Get DST transition periods
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
day_in_seconds = 60 * 60 * 24 day_in_seconds = 60 * 60 * 24
last_month_query = (
# Query each DST period separately with the correct offset
for period_start, period_end, period_offset in dst_periods:
# Calculate hour/minute modifiers for this period
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
# Build clauses including time range for this period
period_clauses = clauses.copy()
period_clauses.append(
(ReviewSegment.start_time >= period_start)
& (ReviewSegment.start_time <= period_end)
)
period_query = (
ReviewSegment.select( ReviewSegment.select(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
ReviewSegment.start_time, ReviewSegment.start_time,
"unixepoch", "unixepoch",
period_hour_modifier, hour_modifier,
period_minute_modifier, minute_modifier,
), ),
).alias("day"), ).alias("day"),
fn.SUM( fn.SUM(
@ -440,24 +399,19 @@ async def review_summary(
& (UserReviewStatus.user_id == user_id) & (UserReviewStatus.user_id == user_id)
), ),
) )
.where(reduce(operator.and_, period_clauses)) .where(reduce(operator.and_, clauses) if clauses else True)
.group_by( .group_by(
(ReviewSegment.start_time + period_offset).cast("int") / day_in_seconds (ReviewSegment.start_time + seconds_offset).cast("int") / day_in_seconds
) )
.order_by(ReviewSegment.start_time.desc()) .order_by(ReviewSegment.start_time.desc())
) )
# Merge results from this period data = {
for e in period_query.dicts().iterator(): "last24Hours": last_24_query,
day_key = e["day"] }
if day_key in data:
# Merge counts if day already exists (edge case at DST boundary) for e in last_month_query.dicts().iterator():
data[day_key]["reviewed_alert"] += e["reviewed_alert"] or 0 data[e["day"]] = e
data[day_key]["reviewed_detection"] += e["reviewed_detection"] or 0
data[day_key]["total_alert"] += e["total_alert"] or 0
data[day_key]["total_detection"] += e["total_detection"] or 0
else:
data[day_key] = e
return JSONResponse(content=data) return JSONResponse(content=data)

View File

@ -166,7 +166,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
camera = obj_data["camera"] camera = obj_data["camera"]
if not self.config.cameras[camera].face_recognition.enabled: if not self.config.cameras[camera].face_recognition.enabled:
logger.debug(f"Face recognition disabled for camera {camera}, skipping")
return return
start = datetime.datetime.now().timestamp() start = datetime.datetime.now().timestamp()
@ -209,7 +208,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
person_box = obj_data.get("box") person_box = obj_data.get("box")
if not person_box: if not person_box:
logger.debug(f"No person box available for {id}")
return return
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
@ -235,8 +233,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
try: try:
face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR) face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR)
except Exception as e: except Exception:
logger.debug(f"Failed to convert face frame color for {id}: {e}")
return return
else: else:
# don't run for object without attributes # don't run for object without attributes
@ -254,7 +251,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
# no faces detected in this frame # no faces detected in this frame
if not face: if not face:
logger.debug(f"No face attributes found for {id}")
return return
face_box = face.get("box") face_box = face.get("box")
@ -278,7 +274,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
res = self.recognizer.classify(face_frame) res = self.recognizer.classify(face_frame)
if not res: if not res:
logger.debug(f"Face recognizer returned no result for {id}")
self.__update_metrics(datetime.datetime.now().timestamp() - start) self.__update_metrics(datetime.datetime.now().timestamp() - start)
return return
@ -335,7 +330,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
def handle_request(self, topic, request_data) -> dict[str, Any] | None: def handle_request(self, topic, request_data) -> dict[str, Any] | None:
if topic == EmbeddingsRequestEnum.clear_face_classifier.value: if topic == EmbeddingsRequestEnum.clear_face_classifier.value:
self.recognizer.clear() self.recognizer.clear()
return {"success": True, "message": "Face classifier cleared."}
elif topic == EmbeddingsRequestEnum.recognize_face.value: elif topic == EmbeddingsRequestEnum.recognize_face.value:
img = cv2.imdecode( img = cv2.imdecode(
np.frombuffer(base64.b64decode(request_data["image"]), dtype=np.uint8), np.frombuffer(base64.b64decode(request_data["image"]), dtype=np.uint8),

View File

@ -158,13 +158,11 @@ class EmbeddingMaintainer(threading.Thread):
self.realtime_processors: list[RealTimeProcessorApi] = [] self.realtime_processors: list[RealTimeProcessorApi] = []
if self.config.face_recognition.enabled: if self.config.face_recognition.enabled:
logger.debug("Face recognition enabled, initializing FaceRealTimeProcessor")
self.realtime_processors.append( self.realtime_processors.append(
FaceRealTimeProcessor( FaceRealTimeProcessor(
self.config, self.requestor, self.event_metadata_publisher, metrics self.config, self.requestor, self.event_metadata_publisher, metrics
) )
) )
logger.debug("FaceRealTimeProcessor initialized successfully")
if self.config.classification.bird.enabled: if self.config.classification.bird.enabled:
self.realtime_processors.append( self.realtime_processors.append(
@ -285,32 +283,11 @@ class EmbeddingMaintainer(threading.Thread):
logger.info("Exiting embeddings maintenance...") logger.info("Exiting embeddings maintenance...")
def _check_classification_config_updates(self) -> None: def _check_classification_config_updates(self) -> None:
"""Check for classification config updates and add/remove processors.""" """Check for classification config updates and add new processors."""
topic, model_config = self.classification_config_subscriber.check_for_update() topic, model_config = self.classification_config_subscriber.check_for_update()
if topic: if topic and model_config:
model_name = topic.split("/")[-1] model_name = topic.split("/")[-1]
if model_config is None:
self.realtime_processors = [
processor
for processor in self.realtime_processors
if not (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
)
and processor.model_config.name == model_name
)
]
logger.info(
f"Successfully removed classification processor for model: {model_name}"
)
else:
self.config.classification.custom[model_name] = model_config self.config.classification.custom[model_name] = model_config
# Check if processor already exists # Check if processor already exists

View File

@ -14,8 +14,7 @@ from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.record.util import remove_empty_directories, sync_recordings from frigate.record.util import remove_empty_directories, sync_recordings
from frigate.util.builtin import clear_and_unlink from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
from frigate.util.time import get_tomorrow_at_time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -28,7 +28,7 @@ from frigate.ffmpeg_presets import (
parse_preset_hardware_acceleration_encode, parse_preset_hardware_acceleration_encode,
) )
from frigate.models import Export, Previews, Recordings from frigate.models import Export, Previews, Recordings
from frigate.util.time import is_current_hour from frigate.util.builtin import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -15,9 +15,12 @@ from collections.abc import Mapping
from multiprocessing.sharedctypes import Synchronized from multiprocessing.sharedctypes import Synchronized
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple, Union from typing import Any, Dict, Optional, Tuple, Union
from zoneinfo import ZoneInfoNotFoundError
import numpy as np import numpy as np
import pytz
from ruamel.yaml import YAML from ruamel.yaml import YAML
from tzlocal import get_localzone
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
@ -154,6 +157,17 @@ def load_labels(path: Optional[str], encoding="utf-8", prefill=91):
return labels return labels
def get_tz_modifiers(tz_name: str) -> Tuple[str, str, float]:
seconds_offset = (
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
)
hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
hour_modifier = f"{hours_offset} hour"
minute_modifier = f"{minutes_offset} minute"
return hour_modifier, minute_modifier, seconds_offset
def to_relative_box( def to_relative_box(
width: int, height: int, box: Tuple[int, int, int, int] width: int, height: int, box: Tuple[int, int, int, int]
) -> Tuple[int | float, int | float, int | float, int | float]: ) -> Tuple[int | float, int | float, int | float, int | float]:
@ -284,6 +298,34 @@ def find_by_key(dictionary, target_key):
return None return None
def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am."""
try:
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
except ZoneInfoNotFoundError:
tomorrow = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
days=1
)
logger.warning(
"Using utc for maintenance due to missing or incorrect timezone set"
)
return tomorrow.replace(hour=hour, minute=0, second=0).astimezone(
datetime.timezone.utc
)
def is_current_hour(timestamp: int) -> bool:
"""Returns if timestamp is in the current UTC hour."""
start_of_next_hour = (
datetime.datetime.now(datetime.timezone.utc).replace(
minute=0, second=0, microsecond=0
)
+ datetime.timedelta(hours=1)
).timestamp()
return timestamp < start_of_next_hour
def clear_and_unlink(file: Path, missing_ok: bool = True) -> None: def clear_and_unlink(file: Path, missing_ok: bool = True) -> None:
"""clear file then unlink to avoid space retained by file descriptors.""" """clear file then unlink to avoid space retained by file descriptors."""
if not missing_ok and not file.exists(): if not missing_ok and not file.exists():

View File

@ -1,100 +0,0 @@
"""Time utilities."""
import datetime
import logging
from typing import Tuple
from zoneinfo import ZoneInfoNotFoundError
import pytz
from tzlocal import get_localzone
logger = logging.getLogger(__name__)
def get_tz_modifiers(tz_name: str) -> Tuple[str, str, float]:
seconds_offset = (
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
)
hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
hour_modifier = f"{hours_offset} hour"
minute_modifier = f"{minutes_offset} minute"
return hour_modifier, minute_modifier, seconds_offset
def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am."""
try:
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
except ZoneInfoNotFoundError:
tomorrow = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
days=1
)
logger.warning(
"Using utc for maintenance due to missing or incorrect timezone set"
)
return tomorrow.replace(hour=hour, minute=0, second=0).astimezone(
datetime.timezone.utc
)
def is_current_hour(timestamp: int) -> bool:
"""Returns if timestamp is in the current UTC hour."""
start_of_next_hour = (
datetime.datetime.now(datetime.timezone.utc).replace(
minute=0, second=0, microsecond=0
)
+ datetime.timedelta(hours=1)
).timestamp()
return timestamp < start_of_next_hour
def get_dst_transitions(
tz_name: str, start_time: float, end_time: float
) -> list[tuple[float, float]]:
"""
Find DST transition points and return time periods with consistent offsets.
Args:
tz_name: Timezone name (e.g., 'America/New_York')
start_time: Start timestamp (UTC)
end_time: End timestamp (UTC)
Returns:
List of (period_start, period_end, seconds_offset) tuples representing
continuous periods with the same UTC offset
"""
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
# If timezone is invalid, return single period with no offset
return [(start_time, end_time, 0)]
periods = []
current = start_time
# Get initial offset
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz)
prev_offset = local_dt.utcoffset().total_seconds()
period_start = start_time
# Check each day for offset changes
while current <= end_time:
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz)
current_offset = local_dt.utcoffset().total_seconds()
if current_offset != prev_offset:
# Found a transition - close previous period
periods.append((period_start, current, prev_offset))
period_start = current
prev_offset = current_offset
current += 86400 # Check daily
# Add final period
periods.append((period_start, end_time, prev_offset))
return periods

View File

@ -34,7 +34,7 @@ from frigate.ptz.autotrack import ptz_moving_at_frame_time
from frigate.track import ObjectTracker from frigate.track import ObjectTracker
from frigate.track.norfair_tracker import NorfairTracker from frigate.track.norfair_tracker import NorfairTracker
from frigate.track.tracked_object import TrackedObjectAttribute from frigate.track.tracked_object import TrackedObjectAttribute
from frigate.util.builtin import EventsPerSecond from frigate.util.builtin import EventsPerSecond, get_tomorrow_at_time
from frigate.util.image import ( from frigate.util.image import (
FrameManager, FrameManager,
SharedMemoryFrameManager, SharedMemoryFrameManager,
@ -53,7 +53,6 @@ from frigate.util.object import (
reduce_detections, reduce_detections,
) )
from frigate.util.process import FrigateProcess from frigate.util.process import FrigateProcess
from frigate.util.time import get_tomorrow_at_time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -271,8 +271,6 @@
"disconnectStream": "Disconnect", "disconnectStream": "Disconnect",
"estimatedBandwidth": "Estimated Bandwidth", "estimatedBandwidth": "Estimated Bandwidth",
"roles": "Roles", "roles": "Roles",
"ffmpegModule": "Use stream compatibility mode",
"ffmpegModuleDescription": "If the stream does not load after several attempts, try enabling this. When enabled, Frigate will use the ffmpeg module with go2rtc. This may provide better compatibility with some camera streams.",
"none": "None", "none": "None",
"error": "Error", "error": "Error",
"streamValidated": "Stream {{number}} validated successfully", "streamValidated": "Stream {{number}} validated successfully",

View File

@ -317,21 +317,6 @@ export default function Step3ChooseExamples({
return unclassifiedImages.length === 0; return unclassifiedImages.length === 0;
}, [unclassifiedImages]); }, [unclassifiedImages]);
const handleBack = useCallback(() => {
if (currentClassIndex > 0) {
const previousClass = allClasses[currentClassIndex - 1];
setCurrentClassIndex((prev) => prev - 1);
// Restore selections for the previous class
const previousSelections = Object.entries(imageClassifications)
.filter(([_, className]) => className === previousClass)
.map(([imageName, _]) => imageName);
setSelectedImages(new Set(previousSelections));
} else {
onBack();
}
}, [currentClassIndex, allClasses, imageClassifications, onBack]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{isTraining ? ( {isTraining ? (
@ -435,7 +420,7 @@ export default function Step3ChooseExamples({
{!isTraining && ( {!isTraining && (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={handleBack} className="sm:flex-1"> <Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })} {t("button.back", { ns: "common" })}
</Button> </Button>
<Button <Button

View File

@ -174,7 +174,9 @@ export default function CameraWizardDialog({
...(friendlyName && { friendly_name: friendlyName }), ...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: { ffmpeg: {
inputs: wizardData.streams.map((stream, index) => { inputs: wizardData.streams.map((stream, index) => {
if (stream.restream) { const isRestreamed =
wizardData.restreamIds?.includes(stream.id) ?? false;
if (isRestreamed) {
const go2rtcStreamName = const go2rtcStreamName =
wizardData.streams!.length === 1 wizardData.streams!.length === 1
? finalCameraName ? finalCameraName
@ -232,11 +234,7 @@ export default function CameraWizardDialog({
wizardData.streams!.length === 1 wizardData.streams!.length === 1
? finalCameraName ? finalCameraName
: `${finalCameraName}_${index + 1}`; : `${finalCameraName}_${index + 1}`;
go2rtcStreams[streamName] = [stream.url];
const streamUrl = stream.useFfmpeg
? `ffmpeg:${stream.url}`
: stream.url;
go2rtcStreams[streamName] = [streamUrl];
}); });
if (Object.keys(go2rtcStreams).length > 0) { if (Object.keys(go2rtcStreams).length > 0) {

View File

@ -608,12 +608,6 @@ export default function Step1NameCamera({
</div> </div>
)} )}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button <Button
type="button" type="button"
@ -641,7 +635,10 @@ export default function Step1NameCamera({
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
> >
{t("cameraWizard.step1.testConnection")} {isTesting && <ActivityIndicator className="size-4" />}
{isTesting && testStatus
? testStatus
: t("cameraWizard.step1.testConnection")}
</Button> </Button>
)} )}
</div> </div>

View File

@ -201,12 +201,16 @@ export default function Step2StreamConfig({
const setRestream = useCallback( const setRestream = useCallback(
(streamId: string) => { (streamId: string) => {
const stream = streams.find((s) => s.id === streamId); const currentIds = wizardData.restreamIds || [];
if (!stream) return; const isSelected = currentIds.includes(streamId);
const newIds = isSelected
updateStream(streamId, { restream: !stream.restream }); ? currentIds.filter((id) => id !== streamId)
: [...currentIds, streamId];
onUpdate({
restreamIds: newIds,
});
}, },
[streams, updateStream], [wizardData.restreamIds, onUpdate],
); );
const hasDetectRole = streams.some((s) => s.roles.includes("detect")); const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
@ -431,7 +435,9 @@ export default function Step2StreamConfig({
{t("cameraWizard.step2.go2rtc")} {t("cameraWizard.step2.go2rtc")}
</span> </span>
<Switch <Switch
checked={stream.restream || false} checked={(wizardData.restreamIds || []).includes(
stream.id,
)}
onCheckedChange={() => setRestream(stream.id)} onCheckedChange={() => setRestream(stream.id)}
/> />
</div> </div>

View File

@ -1,13 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuRotateCcw, LuInfo } from "react-icons/lu"; import { LuRotateCcw } from "react-icons/lu";
import { useState, useCallback, useMemo, useEffect } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios"; import axios from "axios";
@ -222,6 +216,7 @@ export default function Step3Validation({
brandTemplate: wizardData.brandTemplate, brandTemplate: wizardData.brandTemplate,
customUrl: wizardData.customUrl, customUrl: wizardData.customUrl,
streams: wizardData.streams, streams: wizardData.streams,
restreamIds: wizardData.restreamIds,
}; };
onSave(configData); onSave(configData);
@ -327,51 +322,6 @@ export default function Step3Validation({
</div> </div>
)} )}
{result?.success && (
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">
{t("cameraWizard.step3.ffmpegModule")}
</span>
<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.ffmpegModule")}
</div>
<div className="text-muted-foreground">
{t(
"cameraWizard.step3.ffmpegModuleDescription",
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
<Switch
checked={stream.useFfmpeg || false}
onCheckedChange={(checked) => {
onUpdate({
streams: streams.map((s) =>
s.id === stream.id
? { ...s, useFfmpeg: checked }
: s,
),
});
}}
/>
</div>
)}
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center"> <div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
<span className="break-all text-sm text-muted-foreground"> <span className="break-all text-sm text-muted-foreground">
{stream.url} {stream.url}
@ -541,7 +491,8 @@ function StreamIssues({
// Restreaming check // Restreaming check
if (stream.roles.includes("record")) { if (stream.roles.includes("record")) {
if (stream.restream) { const restreamIds = wizardData.restreamIds || [];
if (restreamIds.includes(stream.id)) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.restreamingWarning"), message: t("cameraWizard.step3.issues.restreamingWarning"),
@ -709,10 +660,9 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
useEffect(() => { useEffect(() => {
// Register stream with go2rtc // Register stream with go2rtc
const streamUrl = stream.useFfmpeg ? `ffmpeg:${stream.url}` : stream.url;
axios axios
.put(`go2rtc/streams/${streamId}`, null, { .put(`go2rtc/streams/${streamId}`, null, {
params: { src: streamUrl }, params: { src: stream.url },
}) })
.then(() => { .then(() => {
// Add small delay to allow go2rtc api to run and initialize the stream // Add small delay to allow go2rtc api to run and initialize the stream
@ -730,7 +680,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
// do nothing on cleanup errors - go2rtc won't consume the streams // do nothing on cleanup errors - go2rtc won't consume the streams
}); });
}; };
}, [stream.url, stream.useFfmpeg, streamId]); }, [stream.url, streamId]);
const resolution = stream.testResult?.resolution; const resolution = stream.testResult?.resolution;
let aspectRatio = "16/9"; let aspectRatio = "16/9";

View File

@ -85,8 +85,6 @@ export type StreamConfig = {
quality?: string; quality?: string;
testResult?: TestResult; testResult?: TestResult;
userTested?: boolean; userTested?: boolean;
useFfmpeg?: boolean;
restream?: boolean;
}; };
export type TestResult = { export type TestResult = {
@ -107,6 +105,7 @@ export type WizardFormData = {
brandTemplate?: CameraBrand; brandTemplate?: CameraBrand;
customUrl?: string; customUrl?: string;
streams?: StreamConfig[]; streams?: StreamConfig[];
restreamIds?: string[];
}; };
// API Response Types // API Response Types
@ -147,7 +146,6 @@ export type CameraConfigData = {
inputs: { inputs: {
path: string; path: string;
roles: string[]; roles: string[];
input_args?: string;
}[]; }[];
}; };
live?: { live?: {

View File

@ -10,7 +10,7 @@ import {
CustomClassificationModelConfig, CustomClassificationModelConfig,
FrigateConfig, FrigateConfig,
} from "@/types/frigateConfig"; } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaFolderPlus } from "react-icons/fa"; import { FaFolderPlus } from "react-icons/fa";
import { MdModelTraining } from "react-icons/md"; import { MdModelTraining } from "react-icons/md";
@ -21,6 +21,7 @@ import Heading from "@/components/ui/heading";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -211,30 +212,25 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false }); }>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const bypassDialogRef = useRef(false);
const handleDelete = useCallback(async () => { useKeyboardListener(["Shift"], (_, modifiers) => {
try { bypassDialogRef.current = modifiers.shift;
await axios.delete(`classification/${config.name}`); return false;
await axios.put("/config/set", {
requires_restart: 0,
update_topic: `config/classification/custom/${config.name}`,
config_data: {
classification: {
custom: {
[config.name]: "",
},
},
},
}); });
const handleDelete = useCallback(async () => {
await axios
.delete(`classification/${config.name}`)
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.deletedModel", { count: 1 }), { toast.success(t("toast.success.deletedModel", { count: 1 }), {
position: "top-center", position: "top-center",
}); });
onDelete(); onDelete();
} catch (err) { }
const error = err as { })
response?: { data?: { message?: string; detail?: string } }; .catch((error) => {
};
const errorMessage = const errorMessage =
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
@ -242,13 +238,16 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
toast.error(t("toast.error.deleteModelFailed", { errorMessage }), { toast.error(t("toast.error.deleteModelFailed", { errorMessage }), {
position: "top-center", position: "top-center",
}); });
} });
}, [config, onDelete, t]); }, [config, onDelete, t]);
const handleDeleteClick = useCallback((e: React.MouseEvent) => { const handleDeleteClick = useCallback(() => {
e.stopPropagation(); if (bypassDialogRef.current) {
handleDelete();
} else {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}, []); }
}, [handleDelete]);
const coverImage = useMemo(() => { const coverImage = useMemo(() => {
if (!dataset) { if (!dataset) {
@ -305,7 +304,7 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
className="size-full" 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" /> <ImageShadowOverlay />
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize"> <div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
{config.name} {config.name}
</div> </div>
@ -316,13 +315,14 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
<FiMoreVertical className="size-5 text-white" /> <FiMoreVertical className="size-5 text-white" />
</BlurredIconButton> </BlurredIconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent align="end">
align="end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={handleDeleteClick}> <DropdownMenuItem onClick={handleDeleteClick}>
<LuTrash2 className="mr-2 size-4" /> <LuTrash2 className="mr-2 size-4" />
<span>{t("button.delete", { ns: "common" })}</span> <span>
{bypassDialogRef.current
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>