mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 05:24:11 +03:00
Miscellaneous fixes (#21141)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Remove source_type from API * Don't require state classification models to select all classes * Specifically validate provided end_time for manual events * Remove yolov9 specification for warning * Remove warning for coral * clarify zone name tip * clarify replace rules in lpr docs * remove periods * Add explanation for review report * adjust HLS gap controller params defaults to false, should help to recover from hangs and stalling in tracking details videos on chrome * only redirect to login page once on 401 attempt to fix ios pwa safari redirect storm * Use contextual information from other cameras to inform report summary * Formatting and prompt improvements for review summary report * More improvements to prompt * Remove examples * Don't show admin action buttons on export card * fix redirect race condition Coordinate 401 redirect logic between ApiProvider and ProtectedRoute using a shared flag to prevent multiple simultaneous redirects that caused UI flashing. Ensure both auth error paths check and set the redirect flag before navigating to login, eliminating race conditions where both mechanisms could trigger at once --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
parent
9ab78f496c
commit
c136e5e8bd
@ -111,3 +111,9 @@ review:
|
||||
## Review Reports
|
||||
|
||||
Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review.
|
||||
|
||||
### Requesting Reports Programmatically
|
||||
|
||||
Review reports can be requested via the [API](/integrations/api#review-summarization) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps.
|
||||
|
||||
For Home Assistant users, there is a built-in service (`frigate.generate_review_summary`) that makes it easy to request review reports as part of automations or scripts. This allows you to automatically generate daily summaries, vacation reports, or custom time period reports based on your specific needs.
|
||||
|
||||
@ -107,7 +107,7 @@ Fine-tune the LPR feature using these optional parameters at the global level of
|
||||
|
||||
### Normalization Rules
|
||||
|
||||
- **`replace_rules`**: List of regex replacement rules to normalize detected plates. These rules are applied sequentially. Each rule must have a `pattern` (which can be a string or a regex, prepended by `r`) and `replacement` (a string, which also supports [backrefs](https://docs.python.org/3/library/re.html#re.sub) like `\1`). These rules are useful for dealing with common OCR issues like noise characters, separators, or confusions (e.g., 'O'→'0').
|
||||
- **`replace_rules`**: List of regex replacement rules to normalize detected plates. These rules are applied sequentially and are applied _before_ the `format` regex, if specified. Each rule must have a `pattern` (which can be a string or a regex, prepended by `r`) and `replacement` (a string, which also supports [backrefs](https://docs.python.org/3/library/re.html#re.sub) like `\1`). These rules are useful for dealing with common OCR issues like noise characters, separators, or confusions (e.g., 'O'→'0').
|
||||
|
||||
These rules must be defined at the global level of your `lpr` config.
|
||||
|
||||
|
||||
@ -164,12 +164,6 @@ A Tensorflow Lite is provided in the container at `/openvino-model/ssdlite_mobil
|
||||
<details>
|
||||
<summary>YOLOv9 Setup & Config</summary>
|
||||
|
||||
:::warning
|
||||
|
||||
If you are using a Frigate+ YOLOv9 model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
|
||||
|
||||
:::
|
||||
|
||||
After placing the downloaded files for the tflite model and labels in your config folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
@ -408,7 +402,7 @@ The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv
|
||||
|
||||
:::warning
|
||||
|
||||
If you are using a Frigate+ YOLOv9 model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
|
||||
If you are using a Frigate+ model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
|
||||
|
||||
:::
|
||||
|
||||
@ -748,7 +742,7 @@ The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv
|
||||
|
||||
:::warning
|
||||
|
||||
If you are using a Frigate+ YOLOv9 model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
|
||||
If you are using a Frigate+ model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
1135
docs/static/frigate-api.yaml
vendored
1135
docs/static/frigate-api.yaml
vendored
File diff suppressed because it is too large
Load Diff
@ -29,7 +29,6 @@ class EventsDescriptionBody(BaseModel):
|
||||
|
||||
|
||||
class EventsCreateBody(BaseModel):
|
||||
source_type: Optional[str] = "api"
|
||||
sub_label: Optional[str] = None
|
||||
score: Optional[float] = 0
|
||||
duration: Optional[int] = 30
|
||||
|
||||
@ -346,7 +346,7 @@ def events(
|
||||
"/events/explore",
|
||||
response_model=list[EventResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get summary of objects.",
|
||||
summary="Get summary of objects",
|
||||
description="""Gets a summary of objects from the database.
|
||||
Returns a list of objects with a max of `limit` objects for each label.
|
||||
""",
|
||||
@ -439,7 +439,7 @@ def events_explore(
|
||||
"/event_ids",
|
||||
response_model=list[EventResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get events by ids.",
|
||||
summary="Get events by ids",
|
||||
description="""Gets events by a list of ids.
|
||||
Returns a list of events.
|
||||
""",
|
||||
@ -473,7 +473,7 @@ async def event_ids(ids: str, request: Request):
|
||||
@router.get(
|
||||
"/events/search",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Search events.",
|
||||
summary="Search events",
|
||||
description="""Searches for events in the database.
|
||||
Returns a list of events.
|
||||
""",
|
||||
@ -924,7 +924,7 @@ def events_summary(
|
||||
"/events/{event_id}",
|
||||
response_model=EventResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get event by id.",
|
||||
summary="Get event by id",
|
||||
description="Gets an event by its id.",
|
||||
)
|
||||
async def event(event_id: str, request: Request):
|
||||
@ -968,7 +968,7 @@ def set_retain(event_id: str):
|
||||
"/events/{event_id}/plus",
|
||||
response_model=EventUploadPlusResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Send event to Frigate+.",
|
||||
summary="Send event to Frigate+",
|
||||
description="""Sends an event to Frigate+.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
@ -1207,7 +1207,7 @@ async def false_positive(request: Request, event_id: str):
|
||||
"/events/{event_id}/retain",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Stop event from being retained indefinitely.",
|
||||
summary="Stop event from being retained indefinitely",
|
||||
description="""Stops an event from being retained indefinitely.
|
||||
Returns a success message or an error if the event is not found.
|
||||
NOTE: This is a legacy endpoint and is not supported in the frontend.
|
||||
@ -1236,7 +1236,7 @@ async def delete_retain(event_id: str, request: Request):
|
||||
"/events/{event_id}/sub_label",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Set event sub label.",
|
||||
summary="Set event sub label",
|
||||
description="""Sets an event's sub label.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
@ -1295,7 +1295,7 @@ async def set_sub_label(
|
||||
"/events/{event_id}/recognized_license_plate",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Set event license plate.",
|
||||
summary="Set event license plate",
|
||||
description="""Sets an event's license plate.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
@ -1355,7 +1355,7 @@ async def set_plate(
|
||||
"/events/{event_id}/description",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Set event description.",
|
||||
summary="Set event description",
|
||||
description="""Sets an event's description.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
@ -1411,7 +1411,7 @@ async def set_description(
|
||||
"/events/{event_id}/description/regenerate",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Regenerate event description.",
|
||||
summary="Regenerate event description",
|
||||
description="""Regenerates an event's description.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
@ -1463,8 +1463,8 @@ async def regenerate_description(
|
||||
@router.post(
|
||||
"/description/generate",
|
||||
response_model=GenericResponse,
|
||||
# dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Generate description embedding.",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Generate description embedding",
|
||||
description="""Generates an embedding for an event's description.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
@ -1529,7 +1529,7 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
"/events/{event_id}",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete event.",
|
||||
summary="Delete event",
|
||||
description="""Deletes an event from the database.
|
||||
Returns a success message or an error if the event is not found.
|
||||
""",
|
||||
@ -1544,7 +1544,7 @@ async def delete_event(request: Request, event_id: str):
|
||||
"/events/",
|
||||
response_model=EventMultiDeleteResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete events.",
|
||||
summary="Delete events",
|
||||
description="""Deletes a list of events from the database.
|
||||
Returns a success message or an error if the events are not found.
|
||||
""",
|
||||
@ -1578,7 +1578,7 @@ async def delete_events(request: Request, body: EventsDeleteBody):
|
||||
"/events/{camera_name}/{label}/create",
|
||||
response_model=EventCreateResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create manual event.",
|
||||
summary="Create manual event",
|
||||
description="""Creates a manual event in the database.
|
||||
Returns a success message or an error if the event is not found.
|
||||
NOTES:
|
||||
@ -1620,7 +1620,7 @@ def create_event(
|
||||
body.score,
|
||||
body.sub_label,
|
||||
body.duration,
|
||||
body.source_type,
|
||||
"api",
|
||||
body.draw,
|
||||
),
|
||||
EventMetadataTypeEnum.manual_event_create.value,
|
||||
@ -1642,7 +1642,7 @@ def create_event(
|
||||
"/events/{event_id}/end",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="End manual event.",
|
||||
summary="End manual event",
|
||||
description="""Ends a manual event.
|
||||
Returns a success message or an error if the event is not found.
|
||||
NOTE: This should only be used for manual events.
|
||||
@ -1652,10 +1652,27 @@ async def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
|
||||
if body.end_time is not None and body.end_time < event.start_time:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"end_time ({body.end_time}) cannot be before start_time ({event.start_time}).",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
end_time = body.end_time or datetime.datetime.now().timestamp()
|
||||
request.app.event_metadata_updater.publish(
|
||||
(event_id, end_time), EventMetadataTypeEnum.manual_event_end.value
|
||||
)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": f"Event {event_id} not found."}),
|
||||
status_code=404,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
@ -1674,7 +1691,7 @@ async def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
"/trigger/embedding",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create trigger embedding.",
|
||||
summary="Create trigger embedding",
|
||||
description="""Creates a trigger embedding for a specific trigger.
|
||||
Returns a success message or an error if the trigger is not found.
|
||||
""",
|
||||
@ -1832,7 +1849,7 @@ def create_trigger_embedding(
|
||||
"/trigger/embedding/{camera_name}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Update trigger embedding.",
|
||||
summary="Update trigger embedding",
|
||||
description="""Updates a trigger embedding for a specific trigger.
|
||||
Returns a success message or an error if the trigger is not found.
|
||||
""",
|
||||
@ -1997,7 +2014,7 @@ def update_trigger_embedding(
|
||||
"/trigger/embedding/{camera_name}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete trigger embedding.",
|
||||
summary="Delete trigger embedding",
|
||||
description="""Deletes a trigger embedding for a specific trigger.
|
||||
Returns a success message or an error if the trigger is not found.
|
||||
""",
|
||||
@ -2071,7 +2088,7 @@ def delete_trigger_embedding(
|
||||
"/triggers/status/{camera_name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get triggers status.",
|
||||
summary="Get triggers status",
|
||||
description="""Gets the status of all triggers for a specific camera.
|
||||
Returns a success message or an error if the camera is not found.
|
||||
""",
|
||||
|
||||
@ -209,10 +209,22 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
logger.debug(
|
||||
f"Found GenAI Review Summary request for {start_ts} to {end_ts}"
|
||||
)
|
||||
items: list[dict[str, Any]] = [
|
||||
r["data"]["metadata"]
|
||||
|
||||
# Query all review segments with camera and time information
|
||||
segments: list[dict[str, Any]] = [
|
||||
{
|
||||
"camera": r["camera"].replace("_", " ").title(),
|
||||
"start_time": r["start_time"],
|
||||
"end_time": r["end_time"],
|
||||
"metadata": r["data"]["metadata"],
|
||||
}
|
||||
for r in (
|
||||
ReviewSegment.select(ReviewSegment.data)
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.camera,
|
||||
ReviewSegment.start_time,
|
||||
ReviewSegment.end_time,
|
||||
ReviewSegment.data,
|
||||
)
|
||||
.where(
|
||||
(ReviewSegment.data["metadata"].is_null(False))
|
||||
& (ReviewSegment.start_time < end_ts)
|
||||
@ -224,21 +236,66 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
]
|
||||
|
||||
if len(items) == 0:
|
||||
if len(segments) == 0:
|
||||
logger.debug("No review items with metadata found during time period")
|
||||
return "No activity was found during this time."
|
||||
return "No activity was found during this time period."
|
||||
|
||||
important_items = list(
|
||||
filter(
|
||||
lambda item: item.get("potential_threat_level", 0) > 0
|
||||
or item.get("other_concerns"),
|
||||
items,
|
||||
)
|
||||
)
|
||||
# Identify primary items (important items that need review)
|
||||
primary_segments = [
|
||||
seg
|
||||
for seg in segments
|
||||
if seg["metadata"].get("potential_threat_level", 0) > 0
|
||||
or seg["metadata"].get("other_concerns")
|
||||
]
|
||||
|
||||
if not important_items:
|
||||
if not primary_segments:
|
||||
return "No concerns were found during this time period."
|
||||
|
||||
# For each primary segment, find overlapping contextual items from other cameras
|
||||
all_items_for_summary = []
|
||||
|
||||
for primary_seg in primary_segments:
|
||||
# Add the primary item with marker
|
||||
primary_item = copy.deepcopy(primary_seg["metadata"])
|
||||
primary_item["_is_primary"] = True
|
||||
primary_item["_camera"] = primary_seg["camera"]
|
||||
all_items_for_summary.append(primary_item)
|
||||
|
||||
# Find overlapping contextual items from other cameras
|
||||
primary_start = primary_seg["start_time"]
|
||||
primary_end = primary_seg["end_time"]
|
||||
primary_camera = primary_seg["camera"]
|
||||
|
||||
for seg in segments:
|
||||
seg_camera = seg["camera"]
|
||||
|
||||
if seg_camera == primary_camera:
|
||||
continue
|
||||
|
||||
if seg in primary_segments:
|
||||
continue
|
||||
|
||||
seg_start = seg["start_time"]
|
||||
seg_end = seg["end_time"]
|
||||
|
||||
if seg_start < primary_end and primary_start < seg_end:
|
||||
contextual_item = copy.deepcopy(seg["metadata"])
|
||||
contextual_item["_is_primary"] = False
|
||||
contextual_item["_camera"] = seg_camera
|
||||
contextual_item["_related_to_camera"] = primary_camera
|
||||
|
||||
if not any(
|
||||
item.get("_camera") == seg_camera
|
||||
and item.get("time") == contextual_item.get("time")
|
||||
for item in all_items_for_summary
|
||||
):
|
||||
all_items_for_summary.append(contextual_item)
|
||||
|
||||
logger.debug(
|
||||
f"Summary includes {len(primary_segments)} primary items and "
|
||||
f"{len(all_items_for_summary) - len(primary_segments)} contextual items"
|
||||
)
|
||||
|
||||
if self.config.review.genai.debug_save_thumbnails:
|
||||
Path(
|
||||
os.path.join(CLIPS_DIR, "genai-requests", f"{start_ts}-{end_ts}")
|
||||
@ -247,7 +304,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
return self.genai_client.generate_review_summary(
|
||||
start_ts,
|
||||
end_ts,
|
||||
important_items,
|
||||
all_items_for_summary,
|
||||
self.config.review.genai.debug_save_thumbnails,
|
||||
)
|
||||
else:
|
||||
|
||||
@ -185,44 +185,66 @@ Each line represents a detection state, not necessarily unique individuals. Pare
|
||||
timeline_summary_prompt = f"""
|
||||
You are a security officer.
|
||||
Time range: {time_range}.
|
||||
Input: JSON list with "title", "scene", "confidence", "potential_threat_level" (1-2), "other_concerns".
|
||||
Input: JSON list with "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "_is_primary", "_camera".
|
||||
|
||||
Task: Write a concise, human-presentable security report in markdown format.
|
||||
|
||||
CRITICAL - Understanding Primary vs Contextual Items:
|
||||
- Items with "_is_primary": true are events that REQUIRE REVIEW and MUST be included in the report
|
||||
- Items with "_is_primary": false are additional context from other camera perspectives that overlap in time
|
||||
- **DO NOT create separate bullet points or sections for contextual items**
|
||||
- **ONLY use contextual items to enrich and inform the description of primary items**
|
||||
- The "_camera" field indicates which camera captured each event
|
||||
- **When a contextual item provides relevant background, you MUST incorporate it directly into the primary event's bullet point**
|
||||
- Contextual information often explains or de-escalates seemingly suspicious primary events
|
||||
|
||||
Rules for the report:
|
||||
|
||||
- Title & overview
|
||||
- Start with:
|
||||
# Security Summary - {time_range}
|
||||
- Write a 1-2 sentence situational overview capturing the general pattern of the period.
|
||||
- Keep the overview high-level; specific details will be in the event bullets below.
|
||||
|
||||
- Event details
|
||||
- Present events in chronological order as a bullet list.
|
||||
- **If multiple events occur within the same minute or overlapping time range, COMBINE them into a single bullet.**
|
||||
- Summarize the distinct activities as sub-points under the shared timestamp.
|
||||
- If no timestamp is given, preserve order but label as “Time not specified.”
|
||||
- **ONLY create bullet points for PRIMARY items (_is_primary: true)**
|
||||
- **Do NOT create sections or bullets for events that don't exist**
|
||||
- Do NOT create separate bullets for contextual items
|
||||
- Present primary events in chronological order as a bullet list.
|
||||
- **CRITICAL: When contextual items overlap with a primary event, you MUST weave that information directly into the same bullet point**
|
||||
- Format: **[Timestamp]** - [Description incorporating any contextual information]. [Camera info]. (threat level: X)
|
||||
- If contextual information provides an explanation (e.g., delivery truck → person is likely delivery driver), reflect this understanding in your description and potentially adjust the perceived threat level
|
||||
- If multiple PRIMARY events occur within the same minute, combine them into a single bullet with sub-points.
|
||||
- Use bold timestamps for clarity.
|
||||
- Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior).
|
||||
- Camera format: "Camera: [camera name]" or mention contextual cameras inline when relevant
|
||||
- Group bullets under subheadings ONLY when you have actual PRIMARY events to list (e.g., Porch Activity, Unusual Behavior).
|
||||
|
||||
- Threat levels
|
||||
- Always show the threat level for each event using these labels:
|
||||
- Show the threat level for PRIMARY events using these labels:
|
||||
- Threat level 0: "Normal"
|
||||
- Threat level 1: "Needs review"
|
||||
- Threat level 2: "Security concern"
|
||||
- Format as (threat level: Normal), (threat level: Needs review), or (threat level: Security concern).
|
||||
- If multiple events at the same time share the same threat level, only state it once.
|
||||
- **When contextual items clearly explain a primary event (e.g., delivery truck explains person at door), you should describe it as normal activity and note the explanation**
|
||||
- **Your description and tone should reflect the fuller understanding provided by contextual information**
|
||||
- Example: Primary event says "unidentified person with face covering" but context shows delivery truck → describe as "delivery person (truck visible on Front Driveway Cam)" rather than emphasizing suspicious elements
|
||||
- The stored threat level remains as originally classified, but your narrative should reflect the contextual understanding
|
||||
- If multiple PRIMARY events at the same time share the same threat level, only state it once.
|
||||
|
||||
- Final assessment
|
||||
- End with a Final Assessment section.
|
||||
- If all events are threat level 0:
|
||||
- If all primary events are threat level 0 or explained by contextual items:
|
||||
Final assessment: Only normal residential activity observed during this period.
|
||||
- If threat level 1 events are present:
|
||||
Final assessment: Some activity requires review but no security concerns identified.
|
||||
- If threat level 2 events are present, clearly summarize them as Security concerns requiring immediate attention.
|
||||
- Keep this section brief - do not repeat details from the event descriptions above.
|
||||
|
||||
- Conciseness
|
||||
- Do not repeat benign clothing/appearance details unless they distinguish individuals.
|
||||
- Summarize similar routine events instead of restating full scene descriptions.
|
||||
- When incorporating contextual information, do so briefly and naturally within the primary event description.
|
||||
- Avoid lengthy explanatory notes - integrate context seamlessly into the narrative.
|
||||
"""
|
||||
|
||||
for item in segments:
|
||||
|
||||
@ -177,7 +177,7 @@
|
||||
"generateSuccess": "Successfully generated sample images",
|
||||
"missingStatesWarning": {
|
||||
"title": "Missing State Examples",
|
||||
"description": "You haven't selected examples for all states. The model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model."
|
||||
"description": "It's recommended to select examples for all states for best results. You can continue without selecting all states, but the model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -500,7 +500,7 @@
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"inputPlaceHolder": "Enter a name…",
|
||||
"tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone."
|
||||
"tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone on this camera."
|
||||
},
|
||||
"inertia": {
|
||||
"title": "Inertia",
|
||||
|
||||
12
web/src/api/auth-redirect.ts
Normal file
12
web/src/api/auth-redirect.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// Module-level flag to prevent multiple simultaneous redirects
|
||||
// (eg, when multiple SWR queries fail with 401 at once, or when
|
||||
// both ApiProvider and ProtectedRoute try to redirect)
|
||||
let _isRedirectingToLogin = false;
|
||||
|
||||
export function isRedirectingToLogin(): boolean {
|
||||
return _isRedirectingToLogin;
|
||||
}
|
||||
|
||||
export function setRedirectingToLogin(value: boolean): void {
|
||||
_isRedirectingToLogin = value;
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { SWRConfig } from "swr";
|
||||
import { WsProvider } from "./ws";
|
||||
import axios from "axios";
|
||||
import { ReactNode } from "react";
|
||||
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
|
||||
|
||||
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||
|
||||
@ -31,7 +32,8 @@ export function ApiProvider({ children, options }: ApiProviderType) {
|
||||
) {
|
||||
// redirect to the login page if not already there
|
||||
const loginPage = error.response.headers.get("location") ?? "login";
|
||||
if (window.location.href !== loginPage) {
|
||||
if (window.location.href !== loginPage && !isRedirectingToLogin()) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = loginPage;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import {
|
||||
isRedirectingToLogin,
|
||||
setRedirectingToLogin,
|
||||
} from "@/api/auth-redirect";
|
||||
|
||||
export default function ProtectedRoute({
|
||||
requiredRoles,
|
||||
@ -10,6 +14,20 @@ export default function ProtectedRoute({
|
||||
}) {
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
// Redirect to login page when not authenticated
|
||||
// don't use <Navigate> because we need a full page load to reset state
|
||||
useEffect(() => {
|
||||
if (
|
||||
!auth.isLoading &&
|
||||
auth.isAuthenticated &&
|
||||
!auth.user &&
|
||||
!isRedirectingToLogin()
|
||||
) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
@ -23,7 +41,9 @@ export default function ProtectedRoute({
|
||||
|
||||
// Authenticated mode (8971): require login
|
||||
if (!auth.user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
);
|
||||
}
|
||||
|
||||
// If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback
|
||||
|
||||
@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
import BlurredIconButton from "../button/BlurredIconButton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type ExportProps = {
|
||||
className: string;
|
||||
@ -40,6 +41,7 @@ export default function ExportCard({
|
||||
onDelete,
|
||||
}: ExportProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [loading, setLoading] = useState(
|
||||
exportedRecording.thumb_path.length > 0,
|
||||
@ -195,7 +197,7 @@ export default function ExportCard({
|
||||
</Tooltip>
|
||||
</a>
|
||||
)}
|
||||
{!exportedRecording.in_progress && (
|
||||
{isAdmin && !exportedRecording.in_progress && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
@ -212,21 +214,23 @@ export default function ExportCard({
|
||||
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={() =>
|
||||
onDelete({
|
||||
file: exportedRecording.id,
|
||||
exportName: exportedRecording.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isAdmin && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={() =>
|
||||
onDelete({
|
||||
file: exportedRecording.id,
|
||||
exportName: exportedRecording.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -407,30 +407,6 @@ export default function Step3ChooseExamples({
|
||||
return allClasses.every((className) => statesWithExamples.has(className));
|
||||
}, [step1Data.modelType, allClasses, statesWithExamples]);
|
||||
|
||||
// For state models on the last class, require all images to be classified
|
||||
// But allow proceeding even if not all states have examples (with warning)
|
||||
const canProceed = useMemo(() => {
|
||||
if (step1Data.modelType === "state" && isLastClass) {
|
||||
// Check if all 24 images will be classified after current selections are applied
|
||||
const totalImages = unknownImages.slice(0, 24).length;
|
||||
|
||||
// Count images that will be classified (either already classified or currently selected)
|
||||
const allImages = unknownImages.slice(0, 24);
|
||||
const willBeClassified = allImages.filter((img) => {
|
||||
return imageClassifications[img] || selectedImages.has(img);
|
||||
}).length;
|
||||
|
||||
return willBeClassified >= totalImages;
|
||||
}
|
||||
return true;
|
||||
}, [
|
||||
step1Data.modelType,
|
||||
isLastClass,
|
||||
unknownImages,
|
||||
imageClassifications,
|
||||
selectedImages,
|
||||
]);
|
||||
|
||||
const hasUnclassifiedImages = useMemo(() => {
|
||||
if (!unknownImages) return false;
|
||||
const allImages = unknownImages.slice(0, 24);
|
||||
@ -594,9 +570,7 @@ export default function Step3ChooseExamples({
|
||||
}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={
|
||||
!hasGenerated || isGenerating || isProcessing || !canProceed
|
||||
}
|
||||
disabled={!hasGenerated || isGenerating || isProcessing}
|
||||
>
|
||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
|
||||
@ -559,6 +559,7 @@ export function TrackingDetails({
|
||||
isDetailMode={true}
|
||||
camera={event.camera}
|
||||
currentTimeOverride={currentTime}
|
||||
enableGapControllerRecovery={true}
|
||||
/>
|
||||
{isVideoLoading && (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Hls from "hls.js";
|
||||
import Hls, { HlsConfig } from "hls.js";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import VideoControls from "./VideoControls";
|
||||
@ -57,6 +57,7 @@ type HlsVideoPlayerProps = {
|
||||
isDetailMode?: boolean;
|
||||
camera?: string;
|
||||
currentTimeOverride?: number;
|
||||
enableGapControllerRecovery?: boolean;
|
||||
};
|
||||
|
||||
export default function HlsVideoPlayer({
|
||||
@ -81,6 +82,7 @@ export default function HlsVideoPlayer({
|
||||
isDetailMode = false,
|
||||
camera,
|
||||
currentTimeOverride,
|
||||
enableGapControllerRecovery = false,
|
||||
}: HlsVideoPlayerProps) {
|
||||
const { t } = useTranslation("components/player");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -170,11 +172,23 @@ export default function HlsVideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
hlsRef.current = new Hls({
|
||||
// Base HLS configuration
|
||||
const baseConfig: Partial<HlsConfig> = {
|
||||
maxBufferLength: 10,
|
||||
maxBufferSize: 20 * 1000 * 1000,
|
||||
startPosition: currentSource.startPosition,
|
||||
});
|
||||
};
|
||||
|
||||
const hlsConfig = { ...baseConfig };
|
||||
|
||||
if (enableGapControllerRecovery) {
|
||||
hlsConfig.highBufferWatchdogPeriod = 1; // Check for stalls every 1 second (default: 3)
|
||||
hlsConfig.nudgeOffset = 0.2; // Nudge playhead forward 0.2s when stalled (default: 0.1)
|
||||
hlsConfig.nudgeMaxRetry = 5; // Try up to 5 nudges before giving up (default: 3)
|
||||
hlsConfig.maxBufferHole = 0.5; // Tolerate up to 0.5s gaps between fragments (default: 0.1)
|
||||
}
|
||||
|
||||
hlsRef.current = new Hls(hlsConfig);
|
||||
hlsRef.current.attachMedia(videoRef.current);
|
||||
hlsRef.current.loadSource(currentSource.playlist);
|
||||
videoRef.current.playbackRate = currentPlaybackRate;
|
||||
@ -187,7 +201,13 @@ export default function HlsVideoPlayer({
|
||||
hlsRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
|
||||
}, [
|
||||
videoRef,
|
||||
hlsRef,
|
||||
useHlsCompat,
|
||||
currentSource,
|
||||
enableGapControllerRecovery,
|
||||
]);
|
||||
|
||||
// state handling
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user