mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-19 19:46:43 +03:00
Compare commits
26 Commits
98918b1583
...
64f9e7076b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64f9e7076b | ||
|
|
84cf7748c7 | ||
|
|
a151df29d4 | ||
|
|
fcd8823c6f | ||
|
|
4b0e37f800 | ||
|
|
f48be307dd | ||
|
|
7329563d62 | ||
|
|
2fba55c72f | ||
|
|
31b492dbc4 | ||
|
|
09aa713b76 | ||
|
|
95f151c693 | ||
|
|
f50941cbb6 | ||
|
|
4d6e3a80d9 | ||
|
|
3a39872eff | ||
|
|
9c481b41cf | ||
|
|
edaa23c1b0 | ||
|
|
2664f289b9 | ||
|
|
3e851c6eef | ||
|
|
d3b1f5da43 | ||
|
|
11405710d6 | ||
|
|
1681ba5984 | ||
|
|
4be578dc27 | ||
|
|
cd944ae948 | ||
|
|
03898c91fe | ||
|
|
2a9c028f55 | ||
|
|
aa8b423b68 |
@ -15,7 +15,7 @@ ARG AMDGPU
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.0.2/ubuntu/jammy/amdgpu-install_7.0.2.70002-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1/ubuntu/jammy/amdgpu-install_7.1.70100-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
|
||||
@ -1 +1 @@
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.0.2/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
|
||||
@ -2,7 +2,7 @@ variable "AMDGPU" {
|
||||
default = "gfx900"
|
||||
}
|
||||
variable "ROCM" {
|
||||
default = "7.0.2"
|
||||
default = "7.1"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@ -144,4 +144,10 @@ In order to use transcription and translation for past events, you must enable a
|
||||
|
||||
The transcribed/translated speech will appear in the description box in the Tracked Object Details pane. If Semantic Search is enabled, embeddings are generated for the transcription text and are fully searchable using the description search type.
|
||||
|
||||
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
|
||||
:::note
|
||||
|
||||
Only one `speech` event may be transcribed at a time. Frigate does not automatically transcribe `speech` events or implement a queue for long-running transcription model inference.
|
||||
|
||||
:::
|
||||
|
||||
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a supported Nvidia GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
|
||||
|
||||
@ -700,11 +700,11 @@ genai:
|
||||
# Optional: Configuration for audio transcription
|
||||
# NOTE: only the enabled option can be overridden at the camera level
|
||||
audio_transcription:
|
||||
# Optional: Enable license plate recognition (default: shown below)
|
||||
# Optional: Enable live and speech event audio transcription (default: shown below)
|
||||
enabled: False
|
||||
# Optional: The device to run the models on (default: shown below)
|
||||
# Optional: The device to run the models on for live transcription. (default: shown below)
|
||||
device: CPU
|
||||
# Optional: Set the model size used for transcription. (default: shown below)
|
||||
# Optional: Set the model size used for live transcription. (default: shown below)
|
||||
model_size: small
|
||||
# Optional: Set the language used for transcription translation. (default: shown below)
|
||||
# List of language codes: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10
|
||||
|
||||
@ -542,6 +542,7 @@ def transcribe_audio(request: Request, body: AudioTranscriptionBody):
|
||||
status_code=409, # 409 Conflict
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Failed to transcribe audio, response: {response}")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
|
||||
@ -23,6 +23,7 @@ from frigate.const import (
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
@ -61,6 +62,7 @@ class Dispatcher:
|
||||
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
||||
self.embeddings_reindex: dict[str, Any] = {}
|
||||
self.birdseye_layout: dict[str, Any] = {}
|
||||
self.audio_transcription_state: str = "idle"
|
||||
self._camera_settings_handlers: dict[str, Callable] = {
|
||||
"audio": self._on_audio_command,
|
||||
"audio_transcription": self._on_audio_transcription_command,
|
||||
@ -178,6 +180,19 @@ class Dispatcher:
|
||||
def handle_model_state() -> None:
|
||||
self.publish("model_state", json.dumps(self.model_state.copy()))
|
||||
|
||||
def handle_update_audio_transcription_state() -> None:
|
||||
if payload:
|
||||
self.audio_transcription_state = payload
|
||||
self.publish(
|
||||
"audio_transcription_state",
|
||||
json.dumps(self.audio_transcription_state),
|
||||
)
|
||||
|
||||
def handle_audio_transcription_state() -> None:
|
||||
self.publish(
|
||||
"audio_transcription_state", json.dumps(self.audio_transcription_state)
|
||||
)
|
||||
|
||||
def handle_update_embeddings_reindex_progress() -> None:
|
||||
self.embeddings_reindex = payload
|
||||
self.publish(
|
||||
@ -264,10 +279,12 @@ class Dispatcher:
|
||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
||||
NOTIFICATION_TEST: handle_notification_test,
|
||||
"restart": handle_restart,
|
||||
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
|
||||
"modelState": handle_model_state,
|
||||
"audioTranscriptionState": handle_audio_transcription_state,
|
||||
"birdseyeLayout": handle_birdseye_layout,
|
||||
"onConnect": handle_on_connect,
|
||||
}
|
||||
|
||||
@ -113,6 +113,7 @@ CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
|
||||
UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
|
||||
UPDATE_AUDIO_ACTIVITY = "update_audio_activity"
|
||||
EXPIRE_AUDIO_ACTIVITY = "expire_audio_activity"
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE = "update_audio_transcription_state"
|
||||
UPDATE_EVENT_DESCRIPTION = "update_event_description"
|
||||
UPDATE_REVIEW_DESCRIPTION = "update_review_description"
|
||||
UPDATE_MODEL_STATE = "update_model_state"
|
||||
|
||||
@ -13,6 +13,7 @@ from frigate.config import FrigateConfig
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
MODEL_CACHE_DIR,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
)
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
@ -190,6 +191,8 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self.transcription_running = False
|
||||
self.transcription_thread = None
|
||||
|
||||
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "idle")
|
||||
|
||||
def handle_request(self, topic: str, request_data: dict[str, any]) -> str | None:
|
||||
if topic == "transcribe_audio":
|
||||
event = request_data["event"]
|
||||
@ -203,6 +206,8 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
|
||||
# Mark as running and start the thread
|
||||
self.transcription_running = True
|
||||
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "processing")
|
||||
|
||||
self.transcription_thread = threading.Thread(
|
||||
target=self._transcription_wrapper, args=(event,), daemon=True
|
||||
)
|
||||
|
||||
@ -109,6 +109,7 @@ class TimelineProcessor(threading.Thread):
|
||||
event_data["region"],
|
||||
),
|
||||
"attribute": "",
|
||||
"score": event_data["score"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
"updatedSublabel": "Subetiqueta actualitzada amb èxit.",
|
||||
"updatedLPR": "Matrícula actualitzada amb èxit.",
|
||||
"regenerate": "El {{provider}} ha sol·licitat una nova descripció. En funció de la velocitat del vostre proveïdor, la nova descripció pot trigar un temps a regenerar-se.",
|
||||
"audioTranscription": "Transcripció d'àudio sol·licitada amb èxit."
|
||||
"audioTranscription": "S'ha sol·licitat correctament la transcripció d'àudio. Depenent de la velocitat del vostre servidor Frigate, la transcripció pot trigar una estona a completar-se."
|
||||
},
|
||||
"error": {
|
||||
"regenerate": "No s'ha pogut contactar amb {{provider}} per obtenir una nova descripció: {{errorMessage}}",
|
||||
|
||||
@ -28,7 +28,11 @@
|
||||
"categorizedImage": "Bild erfolgreich klassifiziert",
|
||||
"trainedModel": "Modell erfolgreich trainiert.",
|
||||
"trainingModel": "Modelltraining erfolgreich gestartet.",
|
||||
"updatedModel": "Modellkonfiguration erfolgreich aktualisiert"
|
||||
"updatedModel": "Modellkonfiguration erfolgreich aktualisiert",
|
||||
"renamedCategory": "Klasse erfolgreich in {{name}} umbenannt"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,8 @@
|
||||
"aria": "Detailansicht umschalten",
|
||||
"trackedObject_one": "{{count}} Objekt",
|
||||
"trackedObject_other": "{{count}} Objekte",
|
||||
"noObjectDetailData": "Keine detaillierten Daten des Objekt verfügbar."
|
||||
"noObjectDetailData": "Keine detaillierten Daten des Objekt verfügbar.",
|
||||
"noDataFound": "Keine Detaildaten zur Überprüfung",
|
||||
"settings": "Detailansicht Einstellungen"
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,9 +40,9 @@
|
||||
"deleteModel": {
|
||||
"title": "Borrar Modelo de Clasificación",
|
||||
"single": "¿Está seguro de que quiere eliminar {{name}}? Esto borrar permanentemente todos los datos asociados incluidas las imágenes y los datos de entrenamiento. Esta acción no se puede deshacer.",
|
||||
"desc_one": "¿Estas seguro de que quiere borrar {{count}} modelo/s? Esto borrara permanentemente todos los datos asociados, incluyendo imágenes y datos de entrenamiento. Esta acción no puede ser desehecha.",
|
||||
"desc_many": "",
|
||||
"desc_other": ""
|
||||
"desc_one": "¿Estas seguro de que quiere borrar {{count}} modelo? Esto borrara permanentemente todos los datos asociados, incluyendo imágenes y datos de entrenamiento. Esta acción no puede ser desehecha.",
|
||||
"desc_many": "¿Estas seguro de que quiere borrar {{count}} modelos? Esto borrara permanentemente todos los datos asociados, incluyendo imágenes y datos de entrenamiento. Esta acción no puede ser desehecha.",
|
||||
"desc_other": "¿Estas seguro de que quiere borrar {{count}} modelos? Esto borrara permanentemente todos los datos asociados, incluyendo imágenes y datos de entrenamiento. Esta acción no puede ser desehecha."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar modelo de clasificación"
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
"disable": "Ocultar estadísticas de transmisión"
|
||||
},
|
||||
"manualRecording": {
|
||||
"title": "Grabación bajo demanda",
|
||||
"title": "Bajo demanda",
|
||||
"tips": "Iniciar un evento manual basado en la configuración de retención de grabaciones de esta cámara.",
|
||||
"playInBackground": {
|
||||
"label": "Reproducir en segundo plano",
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
"regenerate": "Une nouvelle description a été demandée à {{provider}}. Selon la vitesse de votre fournisseur, la régénération de la nouvelle description peut prendre un certain temps.",
|
||||
"updatedSublabel": "Sous-étiquette mise à jour avec succès",
|
||||
"updatedLPR": "Plaque d'immatriculation mise à jour avec succès",
|
||||
"audioTranscription": "Transcription audio demandée avec succès"
|
||||
"audioTranscription": "Transcription audio demandée avec succès. Selon la vitesse de votre serveur Frigate, la transcription peut prendre un certain temps."
|
||||
},
|
||||
"error": {
|
||||
"regenerate": "Échec de l'appel de {{provider}} pour une nouvelle description : {{errorMessage}}",
|
||||
|
||||
@ -105,7 +105,7 @@
|
||||
"regenerate": "Er is een nieuwe beschrijving aangevraagd bij {{provider}}. Afhankelijk van de snelheid van je provider kan het regenereren van de nieuwe beschrijving enige tijd duren.",
|
||||
"updatedSublabel": "Sublabel succesvol bijgewerkt.",
|
||||
"updatedLPR": "Kenteken succesvol bijgewerkt.",
|
||||
"audioTranscription": "Audiotranscriptie succesvol aangevraagd."
|
||||
"audioTranscription": "Audio-transcriptie succesvol aangevraagd. Afhankelijk van de snelheid van uw Frigate-server kan het even duren voordat de transcriptie voltooid is."
|
||||
},
|
||||
"error": {
|
||||
"updatedSublabelFailed": "Het is niet gelukt om het sublabel bij te werken: {{errorMessage}}",
|
||||
|
||||
@ -104,7 +104,7 @@
|
||||
"updatedLPR": "Номерний знак успішно оновлено.",
|
||||
"updatedSublabel": "Підмітку успішно оновлено.",
|
||||
"regenerate": "Новий опис було запрошено від {{provider}}. Залежно від швидкості вашого провайдера, його перегенерація може зайняти деякий час.",
|
||||
"audioTranscription": "Запит на аудіотранскрипцію успішно надіслано."
|
||||
"audioTranscription": "Запит на аудіотранскрипцію успішно надіслано. Залежно від швидкості вашого сервера Frigate, транскрипція може тривати деякий час."
|
||||
},
|
||||
"error": {
|
||||
"regenerate": "Не вдалося звернутися до {{provider}} для отримання нового опису: {{errorMessage}}",
|
||||
|
||||
@ -461,6 +461,40 @@ export function useEmbeddingsReindexProgress(
|
||||
return { payload: data };
|
||||
}
|
||||
|
||||
export function useAudioTranscriptionProcessState(
|
||||
revalidateOnFocus: boolean = true,
|
||||
): { payload: string } {
|
||||
const {
|
||||
value: { payload },
|
||||
send: sendCommand,
|
||||
} = useWs("audio_transcription_state", "audioTranscriptionState");
|
||||
|
||||
const data = useDeepMemo(
|
||||
payload ? (JSON.parse(payload as string) as string) : "idle",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let listener = undefined;
|
||||
if (revalidateOnFocus) {
|
||||
sendCommand("audioTranscriptionState");
|
||||
listener = () => {
|
||||
if (document.visibilityState == "visible") {
|
||||
sendCommand("audioTranscriptionState");
|
||||
}
|
||||
};
|
||||
addEventListener("visibilitychange", listener);
|
||||
}
|
||||
return () => {
|
||||
if (listener) {
|
||||
removeEventListener("visibilitychange", listener);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [revalidateOnFocus]);
|
||||
|
||||
return { payload: data || "idle" };
|
||||
}
|
||||
|
||||
export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
|
||||
payload: string;
|
||||
} {
|
||||
|
||||
@ -42,6 +42,7 @@ type ObjectData = {
|
||||
pathPoints: PathPoint[];
|
||||
currentZones: string[];
|
||||
currentBox?: number[];
|
||||
currentAttributeBox?: number[];
|
||||
};
|
||||
|
||||
export default function ObjectTrackOverlay({
|
||||
@ -105,6 +106,12 @@ export default function ObjectTrackOverlay({
|
||||
selectedObjectIds.length > 0
|
||||
? ["event_ids", { ids: selectedObjectIds.join(",") }]
|
||||
: null,
|
||||
null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch timeline data for each object ID using fixed number of hooks
|
||||
@ -112,7 +119,12 @@ export default function ObjectTrackOverlay({
|
||||
selectedObjectIds.length > 0
|
||||
? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000`
|
||||
: null,
|
||||
{ revalidateOnFocus: false },
|
||||
null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
|
||||
@ -270,6 +282,7 @@ export default function ObjectTrackOverlay({
|
||||
);
|
||||
|
||||
const currentBox = nearbyTimelineEvent?.data?.box;
|
||||
const currentAttributeBox = nearbyTimelineEvent?.data?.attribute_box;
|
||||
|
||||
return {
|
||||
objectId,
|
||||
@ -278,6 +291,7 @@ export default function ObjectTrackOverlay({
|
||||
pathPoints: combinedPoints,
|
||||
currentZones,
|
||||
currentBox,
|
||||
currentAttributeBox,
|
||||
};
|
||||
})
|
||||
.filter((obj: ObjectData) => obj.pathPoints.length > 0); // Only include objects with path data
|
||||
@ -482,6 +496,20 @@ export default function ObjectTrackOverlay({
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
{objData.currentAttributeBox && showBoundingBoxes && (
|
||||
<g>
|
||||
<rect
|
||||
x={objData.currentAttributeBox[0] * videoWidth}
|
||||
y={objData.currentAttributeBox[1] * videoHeight}
|
||||
width={objData.currentAttributeBox[2] * videoWidth}
|
||||
height={objData.currentAttributeBox[3] * videoHeight}
|
||||
fill="none"
|
||||
stroke={objData.color}
|
||||
strokeWidth={boxStroke}
|
||||
opacity="0.9"
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -92,6 +92,7 @@ import { DialogPortal } from "@radix-ui/react-dialog";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
import { HiSparkles } from "react-icons/hi";
|
||||
import { useAudioTranscriptionProcessState } from "@/api/ws";
|
||||
|
||||
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
@ -1076,6 +1077,11 @@ function ObjectDetailsTab({
|
||||
});
|
||||
}, [search, t]);
|
||||
|
||||
// audio transcription processing state
|
||||
|
||||
const { payload: audioTranscriptionProcessState } =
|
||||
useAudioTranscriptionProcessState();
|
||||
|
||||
// frigate+ submission
|
||||
|
||||
type SubmissionState = "reviewing" | "uploading" | "submitted";
|
||||
@ -1431,10 +1437,20 @@ function ObjectDetailsTab({
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("itemMenu.audioTranscription.label")}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
className={cn(
|
||||
"text-primary/40",
|
||||
audioTranscriptionProcessState === "processing"
|
||||
? "cursor-not-allowed"
|
||||
: "hover:text-primary/80",
|
||||
)}
|
||||
onClick={onTranscribe}
|
||||
disabled={audioTranscriptionProcessState === "processing"}
|
||||
>
|
||||
<FaMicrophone className="size-4" />
|
||||
{audioTranscriptionProcessState === "processing" ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
<FaMicrophone className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@ -75,12 +75,15 @@ export function TrackingDetails({
|
||||
setIsVideoLoading(true);
|
||||
}, [event.id]);
|
||||
|
||||
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
|
||||
"timeline",
|
||||
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>(
|
||||
["timeline", { source_id: event.id }],
|
||||
null,
|
||||
{
|
||||
source_id: event.id,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
]);
|
||||
);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -104,6 +107,12 @@ export function TrackingDetails({
|
||||
},
|
||||
]
|
||||
: null,
|
||||
null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
// Convert a timeline timestamp to actual video player time, accounting for
|
||||
@ -714,53 +723,6 @@ export function TrackingDetails({
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{eventSequence.map((item, idx) => {
|
||||
const isActive =
|
||||
Math.abs(
|
||||
(effectiveTime ?? 0) - (item.timestamp ?? 0),
|
||||
) <= 0.5;
|
||||
const formattedEventTimestamp = config
|
||||
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t(
|
||||
"time.formattedTimestampHourMinuteSecond.24hour",
|
||||
{ ns: "common" },
|
||||
)
|
||||
: t(
|
||||
"time.formattedTimestampHourMinuteSecond.12hour",
|
||||
{ ns: "common" },
|
||||
),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "";
|
||||
|
||||
const ratio =
|
||||
Array.isArray(item.data.box) &&
|
||||
item.data.box.length >= 4
|
||||
? (
|
||||
aspectRatio *
|
||||
(item.data.box[2] / item.data.box[3])
|
||||
).toFixed(2)
|
||||
: "N/A";
|
||||
const areaPx =
|
||||
Array.isArray(item.data.box) &&
|
||||
item.data.box.length >= 4
|
||||
? Math.round(
|
||||
(config.cameras[event.camera]?.detect?.width ??
|
||||
0) *
|
||||
(config.cameras[event.camera]?.detect
|
||||
?.height ?? 0) *
|
||||
(item.data.box[2] * item.data.box[3]),
|
||||
)
|
||||
: undefined;
|
||||
const areaPct =
|
||||
Array.isArray(item.data.box) &&
|
||||
item.data.box.length >= 4
|
||||
? (item.data.box[2] * item.data.box[3]).toFixed(4)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||
@ -770,11 +732,7 @@ export function TrackingDetails({
|
||||
>
|
||||
<LifecycleIconRow
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
formattedEventTimestamp={formattedEventTimestamp}
|
||||
ratio={ratio}
|
||||
areaPx={areaPx}
|
||||
areaPct={areaPct}
|
||||
event={event}
|
||||
onClick={() => handleLifecycleClick(item)}
|
||||
setSelectedZone={setSelectedZone}
|
||||
getZoneColor={getZoneColor}
|
||||
@ -798,11 +756,7 @@ export function TrackingDetails({
|
||||
|
||||
type LifecycleIconRowProps = {
|
||||
item: TrackingDetailsSequence;
|
||||
isActive?: boolean;
|
||||
formattedEventTimestamp: string;
|
||||
ratio: string;
|
||||
areaPx?: number;
|
||||
areaPct?: string;
|
||||
event: Event;
|
||||
onClick: () => void;
|
||||
setSelectedZone: (z: string) => void;
|
||||
getZoneColor: (zoneName: string) => number[] | undefined;
|
||||
@ -812,11 +766,7 @@ type LifecycleIconRowProps = {
|
||||
|
||||
function LifecycleIconRow({
|
||||
item,
|
||||
isActive,
|
||||
formattedEventTimestamp,
|
||||
ratio,
|
||||
areaPx,
|
||||
areaPct,
|
||||
event,
|
||||
onClick,
|
||||
setSelectedZone,
|
||||
getZoneColor,
|
||||
@ -826,9 +776,101 @@ function LifecycleIconRow({
|
||||
const { t } = useTranslation(["views/explore", "components/player"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!config) {
|
||||
return 16 / 9;
|
||||
}
|
||||
|
||||
return (
|
||||
config.cameras[event.camera].detect.width /
|
||||
config.cameras[event.camera].detect.height
|
||||
);
|
||||
}, [config, event]);
|
||||
|
||||
const isActive = useMemo(
|
||||
() => Math.abs((effectiveTime ?? 0) - (item.timestamp ?? 0)) <= 0.5,
|
||||
[effectiveTime, item.timestamp],
|
||||
);
|
||||
|
||||
const formattedEventTimestamp = useMemo(
|
||||
() =>
|
||||
config
|
||||
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "",
|
||||
[config, item.timestamp, t],
|
||||
);
|
||||
|
||||
const ratio = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||
? (aspectRatio * (item.data.box[2] / item.data.box[3])).toFixed(2)
|
||||
: "N/A",
|
||||
[aspectRatio, item.data.box],
|
||||
);
|
||||
|
||||
const areaPx = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||
? Math.round(
|
||||
(config?.cameras[event.camera]?.detect?.width ?? 0) *
|
||||
(config?.cameras[event.camera]?.detect?.height ?? 0) *
|
||||
(item.data.box[2] * item.data.box[3]),
|
||||
)
|
||||
: undefined,
|
||||
[config, event.camera, item.data.box],
|
||||
);
|
||||
|
||||
const attributeAreaPx = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.attribute_box) &&
|
||||
item.data.attribute_box.length >= 4
|
||||
? Math.round(
|
||||
(config?.cameras[event.camera]?.detect?.width ?? 0) *
|
||||
(config?.cameras[event.camera]?.detect?.height ?? 0) *
|
||||
(item.data.attribute_box[2] * item.data.attribute_box[3]),
|
||||
)
|
||||
: undefined,
|
||||
[config, event.camera, item.data.attribute_box],
|
||||
);
|
||||
|
||||
const attributeAreaPct = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.attribute_box) &&
|
||||
item.data.attribute_box.length >= 4
|
||||
? (item.data.attribute_box[2] * item.data.attribute_box[3]).toFixed(4)
|
||||
: undefined,
|
||||
[item.data.attribute_box],
|
||||
);
|
||||
|
||||
const areaPct = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||
? (item.data.box[2] * item.data.box[3]).toFixed(4)
|
||||
: undefined,
|
||||
[item.data.box],
|
||||
);
|
||||
|
||||
const score = useMemo(() => {
|
||||
if (item.data.score !== undefined) {
|
||||
return (item.data.score * 100).toFixed(0) + "%";
|
||||
}
|
||||
return "N/A";
|
||||
}, [item.data.score]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@ -856,16 +898,28 @@ function LifecycleIconRow({
|
||||
<div className="text-md flex items-start break-words text-left">
|
||||
{getLifecycleItemDescription(item)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-secondary-foreground md:gap-5">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="my-2 ml-2 flex flex-col flex-wrap items-start gap-1.5 text-xs text-secondary-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.score")}
|
||||
</span>
|
||||
<span className="font-medium text-primary">{score}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||
</span>
|
||||
<span className="font-medium text-primary">{ratio}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")}{" "}
|
||||
{attributeAreaPx !== undefined &&
|
||||
attributeAreaPct !== undefined && (
|
||||
<span className="text-primary-variant">
|
||||
({getTranslatedLabel(item.data.label)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{areaPx !== undefined && areaPct !== undefined ? (
|
||||
<span className="font-medium text-primary">
|
||||
@ -876,9 +930,25 @@ function LifecycleIconRow({
|
||||
<span>N/A</span>
|
||||
)}
|
||||
</div>
|
||||
{attributeAreaPx !== undefined &&
|
||||
attributeAreaPct !== undefined && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")} (
|
||||
{getTranslatedLabel(item.data.attribute)})
|
||||
</span>
|
||||
<span className="font-medium text-primary">
|
||||
{t("information.pixels", {
|
||||
ns: "common",
|
||||
area: attributeAreaPx,
|
||||
})}{" "}
|
||||
· {attributeAreaPct}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.data?.zones && item.data.zones.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{item.data.zones.map((zone, zidx) => {
|
||||
const color = getZoneColor(zone)?.join(",") ?? "0,0,0";
|
||||
return (
|
||||
|
||||
@ -82,6 +82,7 @@ function MSEPlayer({
|
||||
[key: string]: (msg: { value: string; type: string }) => void;
|
||||
}>({});
|
||||
const msRef = useRef<MediaSource | null>(null);
|
||||
const mseCodecRef = useRef<string | null>(null);
|
||||
|
||||
const wsURL = useMemo(() => {
|
||||
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
|
||||
@ -93,6 +94,10 @@ function MSEPlayer({
|
||||
console.error(
|
||||
`${camera} - MSE error '${error}': ${description} See the documentation: https://docs.frigate.video/configuration/live/#live-player-error-messages`,
|
||||
);
|
||||
if (mseCodecRef.current) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`${camera} - MSE codec in use: ${mseCodecRef.current}`);
|
||||
}
|
||||
onError?.(error);
|
||||
},
|
||||
[camera, onError],
|
||||
@ -299,6 +304,9 @@ function MSEPlayer({
|
||||
onmessageRef.current["mse"] = (msg) => {
|
||||
if (msg.type !== "mse") return;
|
||||
|
||||
// Store the codec value for error logging
|
||||
mseCodecRef.current = msg.value;
|
||||
|
||||
let sb: SourceBuffer | undefined;
|
||||
try {
|
||||
sb = msRef.current?.addSourceBuffer(msg.value);
|
||||
|
||||
@ -16,6 +16,7 @@ export type TrackingDetailsSequence = {
|
||||
data: {
|
||||
camera: string;
|
||||
label: string;
|
||||
score: number;
|
||||
sub_label: string;
|
||||
box?: [number, number, number, number];
|
||||
region: [number, number, number, number];
|
||||
|
||||
@ -1376,329 +1376,343 @@ function FrigateCameraFeatures({
|
||||
title={t("cameraSettings.title", { camera })}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="rounded-2xl px-2 py-4">
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.cameraEnabled")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.objectDetection")}
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{recordingEnabled && (
|
||||
<DrawerContent className="max-h-[75dvh] overflow-hidden rounded-2xl">
|
||||
<div className="scrollbar-container mt-2 flex h-auto flex-col gap-2 overflow-y-auto px-2 py-4">
|
||||
<>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.recording")}
|
||||
isChecked={recordState == "ON"}
|
||||
label={t("cameraSettings.cameraEnabled")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.snapshots")}
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.audioDetection")}
|
||||
isChecked={audioState == "ON"}
|
||||
label={t("cameraSettings.objectDetection")}
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{audioDetectEnabled && transcriptionEnabled && (
|
||||
{recordingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.recording")}
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.transcription")}
|
||||
disabled={audioState == "OFF"}
|
||||
isChecked={transcriptionState == "ON"}
|
||||
label={t("cameraSettings.snapshots")}
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendTranscription(transcriptionState == "ON" ? "OFF" : "ON")
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.autotracking")}
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.audioDetection")}
|
||||
isChecked={audioState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{audioDetectEnabled && transcriptionEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.transcription")}
|
||||
disabled={audioState == "OFF"}
|
||||
isChecked={transcriptionState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendTranscription(
|
||||
transcriptionState == "ON" ? "OFF" : "ON",
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.autotracking")}
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Label>{t("stream.title")}</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
{t("streaming.restreaming.disabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("streaming.restreaming.desc.title", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed && Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="mt-1 p-2">
|
||||
<div className="mb-1 text-sm">{t("stream.title")}</div>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
disabled={debug}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<>
|
||||
<LuX className="size-8 text-danger" />
|
||||
<div>{t("stream.debug.picker")}</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode != "jsmpeg" && !debug && isRestreamed && (
|
||||
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Label>{t("stream.title")}</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.twoWayTalk.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live/#webrtc-extra-configuration",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
{t("streaming.restreaming.disabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("streaming.restreaming.desc.title", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="mt-1 p-2">
|
||||
<div className="mb-1 text-sm">{t("stream.title")}</div>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
disabled={debug}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<>
|
||||
<LuX className="size-8 text-danger" />
|
||||
<div>{t("stream.debug.picker")}</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.twoWayTalk.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live/#webrtc-extra-configuration",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="mt-2 flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
<p className="text-sm">
|
||||
{t("stream.lowBandwidth.tips")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={debug}
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="mt-2 flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
<p className="text-sm">{t("stream.lowBandwidth.tips")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<div className="mb-1 text-sm font-medium leading-none">
|
||||
{t("manualRecording.title")}
|
||||
</div>
|
||||
<div className="flex flex-row items-stretch gap-2">
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={debug}
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
onClick={handleSnapshotClick}
|
||||
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||
className="h-auto w-full whitespace-normal"
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
{isSnapshotLoading && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("snapshot.takeSnapshot")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEventButtonClick}
|
||||
className={cn(
|
||||
"h-auto w-full whitespace-normal",
|
||||
isRecording &&
|
||||
"animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
disabled={debug}
|
||||
>
|
||||
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("manualRecording.tips")}
|
||||
</p>
|
||||
</div>
|
||||
{isRestreamed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.playInBackground.label")}
|
||||
isChecked={playInBackground}
|
||||
onCheckedChange={(checked) => {
|
||||
setPlayInBackground(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.playInBackground.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.showStats.label")}
|
||||
isChecked={showStats}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowStats(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.showStats.desc")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<div className="mb-1 text-sm font-medium leading-none">
|
||||
{t("manualRecording.title")}
|
||||
</div>
|
||||
<div className="flex flex-row items-stretch gap-2">
|
||||
<Button
|
||||
onClick={handleSnapshotClick}
|
||||
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||
className="h-auto w-full whitespace-normal"
|
||||
>
|
||||
{isSnapshotLoading && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("snapshot.takeSnapshot")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEventButtonClick}
|
||||
className={cn(
|
||||
"h-auto w-full whitespace-normal",
|
||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
disabled={debug}
|
||||
>
|
||||
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("manualRecording.tips")}
|
||||
</p>
|
||||
</div>
|
||||
{isRestreamed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-3 flex flex-col">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.playInBackground.label")}
|
||||
isChecked={playInBackground}
|
||||
onCheckedChange={(checked) => {
|
||||
setPlayInBackground(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
label={t("streaming.debugView", { ns: "components/dialog" })}
|
||||
isChecked={debug}
|
||||
onCheckedChange={(checked) => setDebug(checked)}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.playInBackground.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.showStats.label")}
|
||||
isChecked={showStats}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowStats(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.showStats.desc")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mb-3 flex flex-col">
|
||||
<FilterSwitch
|
||||
label={t("streaming.debugView", { ns: "components/dialog" })}
|
||||
isChecked={debug}
|
||||
onCheckedChange={(checked) => setDebug(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@ -784,7 +784,7 @@ export default function AuthenticationView({
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
{section === "users" && UsersSection}
|
||||
{section === "roles" && RolesSection}
|
||||
{!section && (
|
||||
|
||||
@ -65,7 +65,7 @@ export default function CameraManagementView({
|
||||
closeButton
|
||||
/>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
{viewMode === "settings" ? (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
|
||||
@ -298,7 +298,7 @@ export default function CameraReviewSettingsView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("cameraReview.title")}
|
||||
</Heading>
|
||||
|
||||
@ -244,7 +244,7 @@ export default function EnrichmentsSettingsView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("enrichments.title")}
|
||||
</Heading>
|
||||
|
||||
@ -211,7 +211,7 @@ export default function FrigatePlusSettingsView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("frigatePlus.title")}
|
||||
</Heading>
|
||||
|
||||
@ -434,7 +434,7 @@ export default function MasksAndZonesView({
|
||||
{cameraConfig && editingPolygons && (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
{editPane == "zone" && (
|
||||
<ZoneEditPane
|
||||
polygons={editingPolygons}
|
||||
|
||||
@ -191,7 +191,7 @@ export default function MotionTunerView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("motionDetectionTuner.title")}
|
||||
</Heading>
|
||||
|
||||
@ -331,7 +331,7 @@ export default function NotificationView({
|
||||
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
return (
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<Heading as="h4" className="mb-2">
|
||||
@ -385,7 +385,7 @@ export default function NotificationView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div
|
||||
className={cn(
|
||||
isAdmin && "grid w-full grid-cols-1 gap-4 md:grid-cols-2",
|
||||
|
||||
@ -164,7 +164,7 @@ export default function ObjectSettingsView({
|
||||
return (
|
||||
<div className="mt-1 flex size-full flex-col pb-2 md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("debug.title")}
|
||||
</Heading>
|
||||
@ -434,7 +434,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
{t("debug.objectShapeFilterDrawing.area")}
|
||||
</p>
|
||||
{obj.area ? (
|
||||
<>
|
||||
<div className="text-end">
|
||||
<div className="text-xs">
|
||||
px: {obj.area.toString()}
|
||||
</div>
|
||||
@ -448,7 +448,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
.toFixed(4)
|
||||
.toString()}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
|
||||
@ -440,7 +440,7 @@ export default function TriggerView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
{!isSemanticSearchEnabled ? (
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
|
||||
@ -108,7 +108,7 @@ export default function UiSettingsView() {
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("general.title")}
|
||||
</Heading>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user