From c4b74c9148267f8c70c79f12edfb1ad5c919d6ec Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 10 May 2026 11:09:28 -0600 Subject: [PATCH] Miscellaneous fixes (#23155) * Change order * Improve title * add loading spinner to exports * Simplify JSON since not all providers see or use this the same * Add fields to primary prompt * Adjust centering for no overrides * Use GenAI title for exports when available * detect form-root objects by field path instead of schema identity * add bosnian * Strip v1 if included in url * prevent fast clicks in video controls from selecting text * Use title for metadata chapters --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- frigate/data_processing/post/types.py | 29 ++++++------------- frigate/genai/__init__.py | 5 ++-- frigate/genai/llama_cpp.py | 2 ++ frigate/record/export.py | 11 +++++-- web/public/locales/en/common.json | 1 + web/src/components/card/ReviewCard.tsx | 7 ++++- .../theme/templates/ObjectFieldTemplate.tsx | 3 +- .../components/overlay/MultiExportDialog.tsx | 1 + web/src/components/player/VideoControls.tsx | 2 +- .../settings/ProfileSectionDropdown.tsx | 2 +- web/src/hooks/use-date-locale.ts | 1 + web/src/lib/const.ts | 1 + web/src/pages/Exports.tsx | 13 +++++++++ 13 files changed, 48 insertions(+), 30 deletions(-) diff --git a/frigate/data_processing/post/types.py b/frigate/data_processing/post/types.py index 0344c7616..3698e99a8 100644 --- a/frigate/data_processing/post/types.py +++ b/frigate/data_processing/post/types.py @@ -2,7 +2,7 @@ from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, StringConstraints -ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=160)] +ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=200)] class ReviewMetadata(BaseModel): @@ -11,33 +11,22 @@ class ReviewMetadata(BaseModel): observations: list[ObservationItem] = Field( ..., min_length=3, - max_length=15, - description=( - "Enumerate the significant observations across all frames, in " - "chronological order, BEFORE composing the scene narrative. " - "Include the very start of the activity — for example, a vehicle " - "entering the frame or pulling into the driveway — even if it " - "lasts only a few frames and the rest of the clip is dominated " - "by a longer activity. Include each arrival, departure, motion " - "event, object handled, and notable change in position or state. " - "Each item is a single concrete fact written as a complete " - "sentence. Do not summarize, interpret, or assign meaning here — " - "that belongs in the scene field." - ), - ) - title: str = Field( - max_length=80, - description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.", + max_length=8, + description="Enumerate the significant observations across all frames, in chronological order.", ) scene: str = Field( min_length=150, max_length=600, description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.", ) + title: str = Field( + max_length=80, + description="Title for the activity.", + ) shortSummary: str = Field( min_length=70, - max_length=120, - description="A brief 2-sentence summary of the scene, suitable for notifications.", + max_length=140, + description="A brief summary for the activity.", ) confidence: float = Field( ge=0.0, diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 3bc98100c..ce0034670 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -108,10 +108,11 @@ When forming your description: ## Response Field Guidelines Respond with a JSON object matching the provided schema. Field-specific guidance: +- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence. - `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign. -- `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious." +- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. +- `shortSummary`: Briefly summarize the primary activity across the observations. - `potential_threat_level`: Must be consistent with your scene description and the activity patterns above. -{get_concern_prompt()} ## Sequence Details diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index 11060e537..de45deadb 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -67,6 +67,8 @@ class LlamaCppClient(GenAIClient): if base_url is None: return None + else: + base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url configured_model = self.genai_config.model diff --git a/frigate/record/export.py b/frigate/record/export.py index ef2fdc810..9f571a5a5 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -380,9 +380,14 @@ class RecordingExporter(threading.Thread): if label and label not in labels: labels.append(label) - title = str(review.severity).capitalize() - if labels: - title = f"{title}: {', '.join(labels)}" + metadata = data.get("metadata") or {} + title = metadata.get("title") + + if not title: + title = str(review.severity).capitalize() + + if labels: + title = f"{title}: {', '.join(labels)}" chapter_blocks.append( "[CHAPTER]\n" diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 8b86b69d4..a05126c68 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -207,6 +207,7 @@ "th": "ไทย (Thai)", "ca": "Català (Catalan)", "hr": "Hrvatski (Croatian)", + "bs": "Bosanski (Bosnian)", "sr": "Српски (Serbian)", "sl": "Slovenščina (Slovenian)", "lt": "Lietuvių (Lithuanian)", diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index a57c1e9ed..33d651ed8 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -79,10 +79,15 @@ export default function ReviewCard({ ? event.end_time + REVIEW_PADDING : Date.now() / 1000; + const genAiTitle = event.data.metadata?.title?.trim(); + axios .post( `export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`, - { playback: "realtime" }, + { + playback: "realtime", + ...(genAiTitle ? { name: genAiTitle } : {}), + }, ) .then((response) => { if (response.status < 300) { diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index f1d8ae14a..78f08098c 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -52,12 +52,11 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { } = props; const formContext = registry?.formContext as ConfigFormContext | undefined; - // Check if this is a root-level object - const isRoot = registry?.rootSchema === schema; const overrides = formContext?.overrides; const baselineFormData = formContext?.baselineFormData; const hiddenFields = formContext?.hiddenFields; const fieldPath = props.fieldPathId.path; + const isRoot = fieldPath.length === 0; const restartRequired = formContext?.restartRequired; const defaultRequiresRestart = formContext?.requiresRestart ?? true; diff --git a/web/src/components/overlay/MultiExportDialog.tsx b/web/src/components/overlay/MultiExportDialog.tsx index c914f3edd..f37b99b84 100644 --- a/web/src/components/overlay/MultiExportDialog.tsx +++ b/web/src/components/overlay/MultiExportDialog.tsx @@ -178,6 +178,7 @@ export default function MultiExportDialog({ start_time: review.start_time - REVIEW_PADDING, end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING, image_path: review.thumb_path || undefined, + friendly_name: review.data.metadata?.title?.trim() || undefined, client_item_id: review.id, })); diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index 5a45e83ac..ab4340e0e 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -198,7 +198,7 @@ export default function VideoControls({ return (
feat).length > diff --git a/web/src/components/settings/ProfileSectionDropdown.tsx b/web/src/components/settings/ProfileSectionDropdown.tsx index 923a37ebf..02781e3a8 100644 --- a/web/src/components/settings/ProfileSectionDropdown.tsx +++ b/web/src/components/settings/ProfileSectionDropdown.tsx @@ -91,7 +91,7 @@ export function ProfileSectionDropdown({ className="group flex items-start justify-between gap-2" onClick={() => onSelectProfile(profile)} > -
+
{isActive && } Promise> = { th: () => import("date-fns/locale/th").then((module) => module.th), ca: () => import("date-fns/locale/ca").then((module) => module.ca), hr: () => import("date-fns/locale/hr").then((module) => module.hr), + bs: () => import("date-fns/locale/bs").then((module) => module.bs), sl: () => import("date-fns/locale/sl").then((module) => module.sl), }; diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index 78774bb66..96aa1f4b1 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -29,6 +29,7 @@ export const supportedLanguageKeys = [ "tr", "pl", "hr", + "bs", "sk", "sl", "lt", diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 7b336d7ad..244fcf763 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -5,6 +5,7 @@ import { CaseCard, ExportCard, } from "@/components/card/ExportCard"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; import { AlertDialog, AlertDialogCancel, @@ -864,6 +865,7 @@ function Exports() { search={search} selectedExports={selectedExports} selectionMode={selectionMode} + isLoading={cases === undefined || rawExports === undefined} onSelectExport={onSelectExport} setSelected={setSelected} renameClip={onHandleRename} @@ -882,6 +884,7 @@ function Exports() { activeJobs={activeJobsByCase["none"] || []} selectedExports={selectedExports} selectionMode={selectionMode} + isLoading={cases === undefined || rawExports === undefined} onSelectExport={onSelectExport} setSelectedCaseId={setSelectedCaseId} setSelected={setSelected} @@ -903,6 +906,7 @@ type AllExportsViewProps = { activeJobs: ExportJob[]; selectedExports: Export[]; selectionMode: boolean; + isLoading: boolean; onSelectExport: (e: Export) => void; setSelectedCaseId: (id: string) => void; setSelected: (e: Export) => void; @@ -919,6 +923,7 @@ function AllExportsView({ activeJobs, selectedExports, selectionMode, + isLoading, onSelectExport, setSelectedCaseId, setSelected, @@ -1027,6 +1032,8 @@ function AllExportsView({
)}
+ ) : isLoading ? ( + ) : (
@@ -1046,6 +1053,7 @@ type CaseViewProps = { search: string; selectedExports: Export[]; selectionMode: boolean; + isLoading: boolean; onSelectExport: (e: Export) => void; setSelected: (e: Export) => void; renameClip: (id: string, update: string) => void; @@ -1063,6 +1071,7 @@ function CaseView({ search, selectedExports, selectionMode, + isLoading, onSelectExport, setSelected, renameClip, @@ -1201,6 +1210,10 @@ function CaseView({ /> ))}
+ ) : isLoading ? ( +
+ +
) : (