diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 81d448f25..482f9939f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -26,7 +26,7 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr - This PR fixes or closes issue: fixes # - This PR is related to issue: -- Link to discussion with maintainers (**required** for large/pinned features): +- Link to discussion with maintainers (**required** for any large or "planned" features): ## For new features diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 011f70afd..34bc2628b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,8 +19,8 @@ jobs: days-before-stale: 30 days-before-close: 3 exempt-draft-pr: true - exempt-issue-labels: "pinned,security" - exempt-pr-labels: "pinned,security,dependencies" + exempt-issue-labels: "planned,security" + exempt-pr-labels: "planned,security,dependencies" operations-per-run: 120 - name: Print outputs env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cb575d37..74f59d011 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature: -1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one. +1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one. 2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it. 3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider. diff --git a/frigate/api/media.py b/frigate/api/media.py index 489c008b4..69f0b8372 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -1368,12 +1368,17 @@ def preview_gif( file_start = f"preview_{camera_name}-" start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue @@ -1550,12 +1555,17 @@ def preview_mp4( file_start = f"preview_{camera_name}-" start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue diff --git a/frigate/api/preview.py b/frigate/api/preview.py index a5e30764d..a307b5abc 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -148,12 +148,17 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo file_start = f"preview_{camera_name}-" start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue diff --git a/frigate/const.py b/frigate/const.py index 51e06e4ad..07537ea5f 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -15,7 +15,7 @@ TRIGGER_DIR = f"{CLIPS_DIR}/triggers" BIRDSEYE_PIPE = "/tmp/cache/birdseye" CACHE_DIR = "/tmp/cache" REPLAY_CAMERA_PREFIX = "_replay_" -REPLAY_DIR = os.path.join(CACHE_DIR, "replay") +REPLAY_DIR = os.path.join(CLIPS_DIR, "replay") PLUS_ENV_VAR = "PLUS_API_KEY" PLUS_API_HOST = "https://api.frigate.video" diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index c5e51d612..3740ac25f 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -366,12 +366,17 @@ class ReviewDescriptionProcessor(PostProcessorApi): file_start = f"preview_{camera}-" start_file = f"{file_start}{start_time}.webp" end_file = f"{file_start}{end_time}.webp" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + all_frames: list[str] = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: if len(all_frames): all_frames[0] = os.path.join(preview_dir, file) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index e26b50757..3bc98100c 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -153,9 +153,6 @@ Each line represents a detection state, not necessarily unique individuals. The if "other_concerns" in schema.get("required", []): schema["required"].remove("other_concerns") - # OpenAI strict mode requires additionalProperties: false on all objects - schema["additionalProperties"] = False - response_format = { "type": "json_schema", "json_schema": { diff --git a/frigate/genai/gemini.py b/frigate/genai/gemini.py index c4befbe90..eec22a991 100644 --- a/frigate/genai/gemini.py +++ b/frigate/genai/gemini.py @@ -136,11 +136,29 @@ class GeminiClient(GenAIClient): ) ) elif role == "assistant": - gemini_messages.append( - types.Content( - role="model", parts=[types.Part.from_text(text=content)] - ) - ) + parts: list[types.Part] = [] + if content: + parts.append(types.Part.from_text(text=content)) + for tc in msg.get("tool_calls") or []: + func = tc.get("function") or {} + tc_name = func.get("name") or "" + tc_args: Any = func.get("arguments") + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except (json.JSONDecodeError, TypeError): + tc_args = {} + if not isinstance(tc_args, dict): + tc_args = {} + if tc_name: + parts.append( + types.Part.from_function_call( + name=tc_name, args=tc_args + ) + ) + if not parts: + parts.append(types.Part.from_text(text=" ")) + gemini_messages.append(types.Content(role="model", parts=parts)) elif role == "tool": # Handle tool response response_payload = ( @@ -151,7 +169,9 @@ class GeminiClient(GenAIClient): role="function", parts=[ types.Part.from_function_response( - name=msg.get("name", ""), + name=msg.get("name") + or msg.get("tool_call_id") + or "", response=response_payload, ) ], @@ -345,11 +365,29 @@ class GeminiClient(GenAIClient): ) ) elif role == "assistant": - gemini_messages.append( - types.Content( - role="model", parts=[types.Part.from_text(text=content)] - ) - ) + parts: list[types.Part] = [] + if content: + parts.append(types.Part.from_text(text=content)) + for tc in msg.get("tool_calls") or []: + func = tc.get("function") or {} + tc_name = func.get("name") or "" + tc_args: Any = func.get("arguments") + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except (json.JSONDecodeError, TypeError): + tc_args = {} + if not isinstance(tc_args, dict): + tc_args = {} + if tc_name: + parts.append( + types.Part.from_function_call( + name=tc_name, args=tc_args + ) + ) + if not parts: + parts.append(types.Part.from_text(text=" ")) + gemini_messages.append(types.Content(role="model", parts=parts)) elif role == "tool": # Handle tool response response_payload = ( @@ -360,7 +398,9 @@ class GeminiClient(GenAIClient): role="function", parts=[ types.Part.from_function_response( - name=msg.get("name", ""), + name=msg.get("name") + or msg.get("tool_call_id") + or "", response=response_payload, ) ], diff --git a/frigate/genai/openai.py b/frigate/genai/openai.py index af94859de..432641332 100644 --- a/frigate/genai/openai.py +++ b/frigate/genai/openai.py @@ -73,8 +73,17 @@ class OpenAIClient(GenAIClient): **self.genai_config.runtime_options, } if response_format: + # OpenAI strict mode requires additionalProperties: false on the schema + if response_format.get("type") == "json_schema" and response_format.get( + "json_schema", {} + ).get("strict"): + schema = response_format.get("json_schema", {}).get("schema") + if isinstance(schema, dict): + schema["additionalProperties"] = False request_params["response_format"] = response_format + result = self.provider.chat.completions.create(**request_params) + if ( result is not None and hasattr(result, "choices") diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 63d391162..c409e5cfa 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -391,10 +391,8 @@ export default function MobileReviewSettingsDrawer({ className="flex w-full items-center justify-center gap-2" aria-label={t("title", { ns: "views/replay" })} onClick={() => { - const now = new Date(latestTime * 1000); - now.setHours(now.getHours() - 1); setDebugReplayRange({ - after: now.getTime() / 1000, + after: latestTime - 60, before: latestTime, }); setSelectedReplayOption("1"); @@ -541,11 +539,9 @@ export default function MobileReviewSettingsDrawer({ return; } - const hours = parseInt(option); + const minutes = parseInt(option, 10); const end = latestTime; - const now = new Date(end * 1000); - now.setHours(now.getHours() - hours); - setDebugReplayRange({ after: now.getTime() / 1000, before: end }); + setDebugReplayRange({ after: end - minutes * 60, before: end }); }; content = ( diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 1c8b099e2..9433e3975 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -396,7 +396,6 @@ export default function HlsVideoPlayer({ }} > { - const now = new Date(timeRange.before * 1000); - now.setHours(now.getHours() - 1); setDebugReplayRange({ - after: now.getTime() / 1000, + after: timeRange.before - 60, before: timeRange.before, }); setDebugReplayMode("select");