mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
No commits in common. "5fdb56a10632b61dd94e087425753c0305369a9f" and "31ee62b760891bc8cbe4728b5d673304001aa027" have entirely different histories.
5fdb56a106
...
31ee62b760
@ -13,7 +13,7 @@ ARG ROCM
|
|||||||
|
|
||||||
RUN apt update -qq && \
|
RUN apt update -qq && \
|
||||||
apt install -y wget gpg && \
|
apt install -y wget gpg && \
|
||||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2/ubuntu/jammy/amdgpu-install_7.2.70200-1_all.deb && \
|
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
|
||||||
apt install -y ./rocm.deb && \
|
apt install -y ./rocm.deb && \
|
||||||
apt update && \
|
apt update && \
|
||||||
apt install -qq -y rocm
|
apt install -qq -y rocm
|
||||||
@ -56,8 +56,6 @@ FROM scratch AS rocm-dist
|
|||||||
|
|
||||||
ARG ROCM
|
ARG ROCM
|
||||||
|
|
||||||
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
|
|
||||||
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
|
|
||||||
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
|
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
|
||||||
# Copy MIOpen database files for gfx10xx and gfx11xx only (RDNA2/RDNA3)
|
# Copy MIOpen database files for gfx10xx and gfx11xx only (RDNA2/RDNA3)
|
||||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
|
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.1.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||||
@ -1,5 +1,5 @@
|
|||||||
variable "ROCM" {
|
variable "ROCM" {
|
||||||
default = "7.2.0"
|
default = "7.1.1"
|
||||||
}
|
}
|
||||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||||
default = ""
|
default = ""
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
"""Chat and LLM tool calling APIs."""
|
"""Chat and LLM tool calling APIs."""
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import cv2
|
|
||||||
from fastapi import APIRouter, Body, Depends, Request
|
from fastapi import APIRouter, Body, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@ -89,28 +87,6 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
|
|||||||
"required": [],
|
"required": [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "get_live_context",
|
|
||||||
"description": (
|
|
||||||
"Get the current detection information for a camera: objects being tracked, "
|
|
||||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
|
||||||
"Call this when the user has included a live image (via include_live_image) or "
|
|
||||||
"when answering questions about what is happening right now on a specific camera."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"camera": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Camera name to get live context for.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["camera"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -231,98 +207,6 @@ async def execute_tool(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _execute_get_live_context(
|
|
||||||
request: Request,
|
|
||||||
camera: str,
|
|
||||||
allowed_cameras: List[str],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
if camera not in allowed_cameras:
|
|
||||||
return {
|
|
||||||
"error": f"Camera '{camera}' not found or access denied",
|
|
||||||
}
|
|
||||||
|
|
||||||
if camera not in request.app.frigate_config.cameras:
|
|
||||||
return {
|
|
||||||
"error": f"Camera '{camera}' not found",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
frame_processor = request.app.detected_frames_processor
|
|
||||||
camera_state = frame_processor.camera_states.get(camera)
|
|
||||||
|
|
||||||
if camera_state is None:
|
|
||||||
return {
|
|
||||||
"error": f"Camera '{camera}' state not available",
|
|
||||||
}
|
|
||||||
|
|
||||||
tracked_objects_dict = {}
|
|
||||||
with camera_state.current_frame_lock:
|
|
||||||
tracked_objects = camera_state.tracked_objects.copy()
|
|
||||||
frame_time = camera_state.current_frame_time
|
|
||||||
|
|
||||||
for obj_id, tracked_obj in tracked_objects.items():
|
|
||||||
obj_dict = tracked_obj.to_dict()
|
|
||||||
if obj_dict.get("frame_time") == frame_time:
|
|
||||||
tracked_objects_dict[obj_id] = {
|
|
||||||
"label": obj_dict.get("label"),
|
|
||||||
"zones": obj_dict.get("current_zones", []),
|
|
||||||
"sub_label": obj_dict.get("sub_label"),
|
|
||||||
"stationary": obj_dict.get("stationary", False),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"camera": camera,
|
|
||||||
"timestamp": frame_time,
|
|
||||||
"detections": list(tracked_objects_dict.values()),
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error executing get_live_context: {e}", exc_info=True)
|
|
||||||
return {
|
|
||||||
"error": f"Error getting live context: {str(e)}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_live_frame_image_url(
|
|
||||||
request: Request,
|
|
||||||
camera: str,
|
|
||||||
allowed_cameras: List[str],
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Fetch the current live frame for a camera as a base64 data URL.
|
|
||||||
|
|
||||||
Returns None if the frame cannot be retrieved. Used when include_live_image
|
|
||||||
is set to attach the image to the first user message.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
camera not in allowed_cameras
|
|
||||||
or camera not in request.app.frigate_config.cameras
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
frame_processor = request.app.detected_frames_processor
|
|
||||||
if camera not in frame_processor.camera_states:
|
|
||||||
return None
|
|
||||||
frame = frame_processor.get_current_frame(camera, {})
|
|
||||||
if frame is None:
|
|
||||||
return None
|
|
||||||
height, width = frame.shape[:2]
|
|
||||||
max_dimension = 1024
|
|
||||||
if height > max_dimension or width > max_dimension:
|
|
||||||
scale = max_dimension / max(height, width)
|
|
||||||
frame = cv2.resize(
|
|
||||||
frame,
|
|
||||||
(int(width * scale), int(height * scale)),
|
|
||||||
interpolation=cv2.INTER_AREA,
|
|
||||||
)
|
|
||||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
||||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
|
||||||
return f"data:image/jpeg;base64,{b64}"
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Failed to get live frame for %s: %s", camera, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_tool_internal(
|
async def _execute_tool_internal(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: Dict[str, Any],
|
arguments: Dict[str, Any],
|
||||||
@ -347,11 +231,6 @@ async def _execute_tool_internal(
|
|||||||
except (json.JSONDecodeError, AttributeError) as e:
|
except (json.JSONDecodeError, AttributeError) as e:
|
||||||
logger.warning(f"Failed to extract tool result: {e}")
|
logger.warning(f"Failed to extract tool result: {e}")
|
||||||
return {"error": "Failed to parse tool result"}
|
return {"error": "Failed to parse tool result"}
|
||||||
elif tool_name == "get_live_context":
|
|
||||||
camera = arguments.get("camera")
|
|
||||||
if not camera:
|
|
||||||
return {"error": "Camera parameter is required"}
|
|
||||||
return await _execute_get_live_context(request, camera, allowed_cameras)
|
|
||||||
else:
|
else:
|
||||||
return {"error": f"Unknown tool: {tool_name}"}
|
return {"error": f"Unknown tool: {tool_name}"}
|
||||||
|
|
||||||
@ -398,43 +277,13 @@ async def chat_completion(
|
|||||||
current_datetime = datetime.now(timezone.utc)
|
current_datetime = datetime.now(timezone.utc)
|
||||||
current_date_str = current_datetime.strftime("%Y-%m-%d")
|
current_date_str = current_datetime.strftime("%Y-%m-%d")
|
||||||
current_time_str = current_datetime.strftime("%H:%M:%S %Z")
|
current_time_str = current_datetime.strftime("%H:%M:%S %Z")
|
||||||
|
|
||||||
cameras_info = []
|
|
||||||
config = request.app.frigate_config
|
|
||||||
for camera_id in allowed_cameras:
|
|
||||||
if camera_id not in config.cameras:
|
|
||||||
continue
|
|
||||||
camera_config = config.cameras[camera_id]
|
|
||||||
friendly_name = (
|
|
||||||
camera_config.friendly_name
|
|
||||||
if camera_config.friendly_name
|
|
||||||
else camera_id.replace("_", " ").title()
|
|
||||||
)
|
|
||||||
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
|
|
||||||
|
|
||||||
cameras_section = ""
|
|
||||||
if cameras_info:
|
|
||||||
cameras_section = (
|
|
||||||
"\n\nAvailable cameras:\n"
|
|
||||||
+ "\n".join(cameras_info)
|
|
||||||
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
|
||||||
)
|
|
||||||
|
|
||||||
live_image_note = ""
|
|
||||||
if body.include_live_image:
|
|
||||||
live_image_note = (
|
|
||||||
f"\n\nThe first user message includes a live image from camera "
|
|
||||||
f"'{body.include_live_image}'. Use get_live_context for that camera to get "
|
|
||||||
"current detection details (objects, zones) to aid in understanding the image."
|
|
||||||
)
|
|
||||||
|
|
||||||
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
||||||
|
|
||||||
Current date and time: {current_date_str} at {current_time_str} (UTC)
|
Current date and time: {current_date_str} at {current_time_str} (UTC)
|
||||||
|
|
||||||
When users ask questions about "today", "yesterday", "this week", etc., use the current date above as reference.
|
When users ask questions about "today", "yesterday", "this week", etc., use the current date above as reference.
|
||||||
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
||||||
Always be accurate with time calculations based on the current date provided.{cameras_section}{live_image_note}"""
|
Always be accurate with time calculations based on the current date provided."""
|
||||||
|
|
||||||
conversation.append(
|
conversation.append(
|
||||||
{
|
{
|
||||||
@ -443,7 +292,6 @@ Always be accurate with time calculations based on the current date provided.{ca
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
first_user_message_seen = False
|
|
||||||
for msg in body.messages:
|
for msg in body.messages:
|
||||||
msg_dict = {
|
msg_dict = {
|
||||||
"role": msg.role,
|
"role": msg.role,
|
||||||
@ -453,22 +301,6 @@ Always be accurate with time calculations based on the current date provided.{ca
|
|||||||
msg_dict["tool_call_id"] = msg.tool_call_id
|
msg_dict["tool_call_id"] = msg.tool_call_id
|
||||||
if msg.name:
|
if msg.name:
|
||||||
msg_dict["name"] = msg.name
|
msg_dict["name"] = msg.name
|
||||||
|
|
||||||
if (
|
|
||||||
msg.role == "user"
|
|
||||||
and not first_user_message_seen
|
|
||||||
and body.include_live_image
|
|
||||||
):
|
|
||||||
first_user_message_seen = True
|
|
||||||
image_url = await _get_live_frame_image_url(
|
|
||||||
request, body.include_live_image, allowed_cameras
|
|
||||||
)
|
|
||||||
if image_url:
|
|
||||||
msg_dict["content"] = [
|
|
||||||
{"type": "text", "text": msg.content},
|
|
||||||
{"type": "image_url", "image_url": {"url": image_url}},
|
|
||||||
]
|
|
||||||
|
|
||||||
conversation.append(msg_dict)
|
conversation.append(msg_dict)
|
||||||
|
|
||||||
tool_iterations = 0
|
tool_iterations = 0
|
||||||
|
|||||||
@ -32,10 +32,3 @@ class ChatCompletionRequest(BaseModel):
|
|||||||
le=10,
|
le=10,
|
||||||
description="Maximum number of tool call iterations (default: 5)",
|
description="Maximum number of tool call iterations (default: 5)",
|
||||||
)
|
)
|
||||||
include_live_image: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
description=(
|
|
||||||
"If set, the current live frame from this camera is attached to the first "
|
|
||||||
"user message as multimodal content. Use with get_live_context for detection info."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|||||||
@ -42,7 +42,6 @@ from frigate.const import (
|
|||||||
PREVIEW_FRAME_TYPE,
|
PREVIEW_FRAME_TYPE,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||||
from frigate.output.preview import get_most_recent_preview_frame
|
|
||||||
from frigate.track.object_processing import TrackedObjectProcessor
|
from frigate.track.object_processing import TrackedObjectProcessor
|
||||||
from frigate.util.file import get_event_thumbnail_bytes
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
from frigate.util.image import get_image_from_recording
|
from frigate.util.image import get_image_from_recording
|
||||||
@ -126,9 +125,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{camera_name}/latest.{extension}",
|
"/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)]
|
||||||
dependencies=[Depends(require_camera_access)],
|
|
||||||
description="Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline.",
|
|
||||||
)
|
)
|
||||||
async def latest_frame(
|
async def latest_frame(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -162,37 +159,20 @@ async def latest_frame(
|
|||||||
or 10
|
or 10
|
||||||
)
|
)
|
||||||
|
|
||||||
is_offline = False
|
|
||||||
if frame is None or datetime.now().timestamp() > (
|
if frame is None or datetime.now().timestamp() > (
|
||||||
frame_processor.get_current_frame_time(camera_name) + retry_interval
|
frame_processor.get_current_frame_time(camera_name) + retry_interval
|
||||||
):
|
):
|
||||||
last_frame_time = frame_processor.get_current_frame_time(camera_name)
|
if request.app.camera_error_image is None:
|
||||||
preview_path = get_most_recent_preview_frame(
|
error_image = glob.glob(
|
||||||
camera_name, before=last_frame_time
|
os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg")
|
||||||
)
|
|
||||||
|
|
||||||
if preview_path:
|
|
||||||
logger.debug(f"Using most recent preview frame for {camera_name}")
|
|
||||||
frame = cv2.imread(preview_path, cv2.IMREAD_UNCHANGED)
|
|
||||||
|
|
||||||
if frame is not None:
|
|
||||||
is_offline = True
|
|
||||||
|
|
||||||
if frame is None or not is_offline:
|
|
||||||
logger.debug(
|
|
||||||
f"No live or preview frame available for {camera_name}. Using error image."
|
|
||||||
)
|
)
|
||||||
if request.app.camera_error_image is None:
|
|
||||||
error_image = glob.glob(
|
if len(error_image) > 0:
|
||||||
os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg")
|
request.app.camera_error_image = cv2.imread(
|
||||||
|
error_image[0], cv2.IMREAD_UNCHANGED
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(error_image) > 0:
|
frame = request.app.camera_error_image
|
||||||
request.app.camera_error_image = cv2.imread(
|
|
||||||
error_image[0], cv2.IMREAD_UNCHANGED
|
|
||||||
)
|
|
||||||
|
|
||||||
frame = request.app.camera_error_image
|
|
||||||
|
|
||||||
height = int(params.height or str(frame.shape[0]))
|
height = int(params.height or str(frame.shape[0]))
|
||||||
width = int(height * frame.shape[1] / frame.shape[0])
|
width = int(height * frame.shape[1] / frame.shape[0])
|
||||||
@ -214,18 +194,14 @@ async def latest_frame(
|
|||||||
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
_, img = cv2.imencode(f".{extension.value}", frame, quality_params)
|
_, img = cv2.imencode(f".{extension.value}", frame, quality_params)
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Cache-Control": "no-store" if not params.store else "private, max-age=60",
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_offline:
|
|
||||||
headers["X-Frigate-Offline"] = "true"
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=img.tobytes(),
|
content=img.tobytes(),
|
||||||
media_type=extension.get_mime_type(),
|
media_type=extension.get_mime_type(),
|
||||||
headers=headers,
|
headers={
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
if not params.store
|
||||||
|
else "private, max-age=60",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
camera_name == "birdseye"
|
camera_name == "birdseye"
|
||||||
|
|||||||
@ -131,8 +131,9 @@ class ONNXModelRunner(BaseModelRunner):
|
|||||||
|
|
||||||
return model_type in [
|
return model_type in [
|
||||||
EnrichmentModelTypeEnum.paddleocr.value,
|
EnrichmentModelTypeEnum.paddleocr.value,
|
||||||
|
EnrichmentModelTypeEnum.jina_v1.value,
|
||||||
EnrichmentModelTypeEnum.jina_v2.value,
|
EnrichmentModelTypeEnum.jina_v2.value,
|
||||||
EnrichmentModelTypeEnum.arcface.value,
|
EnrichmentModelTypeEnum.facenet.value,
|
||||||
ModelTypeEnum.rfdetr.value,
|
ModelTypeEnum.rfdetr.value,
|
||||||
ModelTypeEnum.dfine.value,
|
ModelTypeEnum.dfine.value,
|
||||||
]
|
]
|
||||||
|
|||||||
@ -69,7 +69,7 @@ class GenAIClient:
|
|||||||
return "\n- (No objects detected)"
|
return "\n- (No objects detected)"
|
||||||
|
|
||||||
context_prompt = f"""
|
context_prompt = f"""
|
||||||
Your task is to analyze a sequence of images taken in chronological order from a security camera.
|
Your task is to analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"]} security camera.
|
||||||
|
|
||||||
## Normal Activity Patterns for This Property
|
## Normal Activity Patterns for This Property
|
||||||
|
|
||||||
@ -107,8 +107,7 @@ Your response MUST be a flat JSON object with:
|
|||||||
|
|
||||||
## Sequence Details
|
## Sequence Details
|
||||||
|
|
||||||
- Camera: {review_data["camera"]}
|
- Frame 1 = earliest, Frame {len(thumbnails)} = latest
|
||||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
|
|
||||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||||
|
|
||||||
|
|||||||
@ -216,14 +216,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
"finish_reason": "error",
|
"finish_reason": "error",
|
||||||
}
|
}
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
error_detail = str(e)
|
logger.warning("llama.cpp returned an error: %s", str(e))
|
||||||
if hasattr(e, "response") and e.response is not None:
|
|
||||||
try:
|
|
||||||
error_body = e.response.text
|
|
||||||
error_detail = f"{str(e)} - Response: {error_body[:500]}"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.warning("llama.cpp returned an error: %s", error_detail)
|
|
||||||
return {
|
return {
|
||||||
"content": None,
|
"content": None,
|
||||||
"tool_calls": None,
|
"tool_calls": None,
|
||||||
|
|||||||
@ -57,51 +57,6 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_most_recent_preview_frame(camera: str, before: float = None) -> str | None:
|
|
||||||
"""Get the most recent preview frame for a camera."""
|
|
||||||
if not os.path.exists(PREVIEW_CACHE_DIR):
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# files are named preview_{camera}-{timestamp}.webp
|
|
||||||
# we want the largest timestamp that is less than or equal to before
|
|
||||||
preview_files = [
|
|
||||||
f
|
|
||||||
for f in os.listdir(PREVIEW_CACHE_DIR)
|
|
||||||
if f.startswith(f"preview_{camera}-")
|
|
||||||
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
|
|
||||||
]
|
|
||||||
|
|
||||||
if not preview_files:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# sort by timestamp in descending order
|
|
||||||
# filenames are like preview_front-1712345678.901234.webp
|
|
||||||
preview_files.sort(reverse=True)
|
|
||||||
|
|
||||||
if before is None:
|
|
||||||
return os.path.join(PREVIEW_CACHE_DIR, preview_files[0])
|
|
||||||
|
|
||||||
for file_name in preview_files:
|
|
||||||
try:
|
|
||||||
# Extract timestamp: preview_front-1712345678.901234.webp
|
|
||||||
# Split by dash and extension
|
|
||||||
timestamp_part = file_name.split("-")[-1].split(
|
|
||||||
f".{PREVIEW_FRAME_TYPE}"
|
|
||||||
)[0]
|
|
||||||
timestamp = float(timestamp_part)
|
|
||||||
|
|
||||||
if timestamp <= before:
|
|
||||||
return os.path.join(PREVIEW_CACHE_DIR, file_name)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error searching for most recent preview frame: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class FFMpegConverter(threading.Thread):
|
class FFMpegConverter(threading.Thread):
|
||||||
"""Convert a list of still frames into a vfr mp4."""
|
"""Convert a list of still frames into a vfr mp4."""
|
||||||
|
|
||||||
|
|||||||
@ -1,107 +0,0 @@
|
|||||||
import os
|
|
||||||
import shutil
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE
|
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
|
||||||
|
|
||||||
|
|
||||||
class TestHttpLatestFrame(BaseTestHttp):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp([])
|
|
||||||
self.app = super().create_app()
|
|
||||||
self.app.detected_frames_processor = MagicMock()
|
|
||||||
|
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
|
||||||
os.makedirs(PREVIEW_CACHE_DIR)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
|
||||||
super().tearDown()
|
|
||||||
|
|
||||||
def test_latest_frame_fallback_to_preview(self):
|
|
||||||
camera = "front_door"
|
|
||||||
# 1. Mock frame processor to return None (simulating offline/missing frame)
|
|
||||||
self.app.detected_frames_processor.get_current_frame.return_value = None
|
|
||||||
# Return a timestamp that is after our dummy preview frame
|
|
||||||
self.app.detected_frames_processor.get_current_frame_time.return_value = (
|
|
||||||
1234567891.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Create a dummy preview file
|
|
||||||
dummy_frame = np.zeros((180, 320, 3), np.uint8)
|
|
||||||
cv2.putText(
|
|
||||||
dummy_frame,
|
|
||||||
"PREVIEW",
|
|
||||||
(50, 50),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
|
||||||
1,
|
|
||||||
(255, 255, 255),
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
preview_path = os.path.join(
|
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
|
|
||||||
)
|
|
||||||
cv2.imwrite(preview_path, dummy_frame)
|
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
response = client.get(f"/{camera}/latest.webp")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers.get("X-Frigate-Offline") == "true"
|
|
||||||
# Verify we got an image (webp)
|
|
||||||
assert response.headers.get("content-type") == "image/webp"
|
|
||||||
|
|
||||||
def test_latest_frame_no_fallback_when_live(self):
|
|
||||||
camera = "front_door"
|
|
||||||
# 1. Mock frame processor to return a live frame
|
|
||||||
dummy_frame = np.zeros((180, 320, 3), np.uint8)
|
|
||||||
self.app.detected_frames_processor.get_current_frame.return_value = dummy_frame
|
|
||||||
self.app.detected_frames_processor.get_current_frame_time.return_value = (
|
|
||||||
2000000000.0 # Way in the future
|
|
||||||
)
|
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
response = client.get(f"/{camera}/latest.webp")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "X-Frigate-Offline" not in response.headers
|
|
||||||
|
|
||||||
def test_latest_frame_stale_falls_back_to_preview(self):
|
|
||||||
camera = "front_door"
|
|
||||||
# 1. Mock frame processor to return a stale frame
|
|
||||||
dummy_frame = np.zeros((180, 320, 3), np.uint8)
|
|
||||||
self.app.detected_frames_processor.get_current_frame.return_value = dummy_frame
|
|
||||||
# Return a timestamp that is after our dummy preview frame, but way in the past
|
|
||||||
self.app.detected_frames_processor.get_current_frame_time.return_value = 1000.0
|
|
||||||
|
|
||||||
# 2. Create a dummy preview file
|
|
||||||
preview_path = os.path.join(
|
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
|
|
||||||
)
|
|
||||||
cv2.imwrite(preview_path, dummy_frame)
|
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
response = client.get(f"/{camera}/latest.webp")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers.get("X-Frigate-Offline") == "true"
|
|
||||||
|
|
||||||
def test_latest_frame_no_preview_found(self):
|
|
||||||
camera = "front_door"
|
|
||||||
# 1. Mock frame processor to return None
|
|
||||||
self.app.detected_frames_processor.get_current_frame.return_value = None
|
|
||||||
|
|
||||||
# 2. No preview file created
|
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
response = client.get(f"/{camera}/latest.webp")
|
|
||||||
# Should fall back to camera-error.jpg (which might not exist in test env, but let's see)
|
|
||||||
# If camera-error.jpg is not found, it returns 500 "Unable to get valid frame" in latest_frame
|
|
||||||
# OR it uses request.app.camera_error_image if already loaded.
|
|
||||||
|
|
||||||
# Since we didn't provide camera-error.jpg, it might 500 if glob fails or return 500 if frame is None.
|
|
||||||
assert response.status_code in [200, 500]
|
|
||||||
assert "X-Frigate-Offline" not in response.headers
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import os
|
|
||||||
import shutil
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from frigate.output.preview import (
|
|
||||||
PREVIEW_CACHE_DIR,
|
|
||||||
PREVIEW_FRAME_TYPE,
|
|
||||||
get_most_recent_preview_frame,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreviewLoader(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
|
||||||
os.makedirs(PREVIEW_CACHE_DIR)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
|
||||||
|
|
||||||
def test_get_most_recent_preview_frame_missing(self):
|
|
||||||
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
|
||||||
|
|
||||||
def test_get_most_recent_preview_frame_exists(self):
|
|
||||||
camera = "test_camera"
|
|
||||||
# create dummy preview files
|
|
||||||
for ts in ["1000.0", "2000.0", "1500.0"]:
|
|
||||||
with open(
|
|
||||||
os.path.join(
|
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
|
|
||||||
),
|
|
||||||
"w",
|
|
||||||
) as f:
|
|
||||||
f.write(f"test_{ts}")
|
|
||||||
|
|
||||||
expected_path = os.path.join(
|
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}"
|
|
||||||
)
|
|
||||||
self.assertEqual(get_most_recent_preview_frame(camera), expected_path)
|
|
||||||
|
|
||||||
def test_get_most_recent_preview_frame_before(self):
|
|
||||||
camera = "test_camera"
|
|
||||||
# create dummy preview files
|
|
||||||
for ts in ["1000.0", "2000.0"]:
|
|
||||||
with open(
|
|
||||||
os.path.join(
|
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
|
|
||||||
),
|
|
||||||
"w",
|
|
||||||
) as f:
|
|
||||||
f.write(f"test_{ts}")
|
|
||||||
|
|
||||||
# Test finding frame before or at 1500
|
|
||||||
expected_path = os.path.join(
|
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}"
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
get_most_recent_preview_frame(camera, before=1500.0), expected_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test finding frame before or at 999
|
|
||||||
self.assertIsNone(get_most_recent_preview_frame(camera, before=999.0))
|
|
||||||
|
|
||||||
def test_get_most_recent_preview_frame_other_camera(self):
|
|
||||||
camera = "test_camera"
|
|
||||||
other_camera = "other_camera"
|
|
||||||
with open(
|
|
||||||
os.path.join(
|
|
||||||
PREVIEW_CACHE_DIR, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}"
|
|
||||||
),
|
|
||||||
"w",
|
|
||||||
) as f:
|
|
||||||
f.write("test")
|
|
||||||
|
|
||||||
self.assertIsNone(get_most_recent_preview_frame(camera))
|
|
||||||
|
|
||||||
def test_get_most_recent_preview_frame_no_directory(self):
|
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
|
||||||
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
|
||||||
@ -81,11 +81,6 @@ export default function LivePlayer({
|
|||||||
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const cameraName = useCameraFriendlyName(cameraConfig);
|
const cameraName = useCameraFriendlyName(cameraConfig);
|
||||||
|
|
||||||
// player is showing on a dashboard if containerRef is not provided
|
|
||||||
|
|
||||||
const inDashboard = containerRef?.current == null;
|
|
||||||
|
|
||||||
// stats
|
// stats
|
||||||
|
|
||||||
const [stats, setStats] = useState<PlayerStatsType>({
|
const [stats, setStats] = useState<PlayerStatsType>({
|
||||||
@ -413,28 +408,6 @@ export default function LivePlayer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{offline && inDashboard && (
|
|
||||||
<>
|
|
||||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
|
||||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
|
||||||
<div className="text-md">{t("streamOffline.title")}</div>
|
|
||||||
<TbExclamationCircle className="size-6" />
|
|
||||||
<p className="text-center text-sm">
|
|
||||||
<Trans
|
|
||||||
ns="components/player"
|
|
||||||
values={{
|
|
||||||
cameraName: cameraName,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
streamOffline.desc
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{offline && !showStillWithoutActivity && cameraEnabled && (
|
{offline && !showStillWithoutActivity && cameraEnabled && (
|
||||||
<div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2">
|
<div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2">
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg bg-background/50 p-5">
|
<div className="flex flex-col items-center justify-center rounded-lg bg-background/50 p-5">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user