diff --git a/frigate/http.py b/frigate/http.py index b2ff9fa79..14f8a952f 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -607,7 +607,7 @@ def event_thumbnail(id, max_cache_age=2592000): @bp.route("/events//preview.gif") -def event_preview(id: str, max_cache_age=2592000): +def event_preview(id: str): try: event: Event = Event.get(Event.id == id) except DoesNotExist: @@ -619,145 +619,7 @@ def event_preview(id: str, max_cache_age=2592000): end_ts = start_ts + ( min(event.end_time - event.start_time, 20) if event.end_time else 20 ) - - if datetime.fromtimestamp(event.start_time) < datetime.now().replace( - minute=0, second=0 - ): - # has preview mp4 - preview: Previews = ( - Previews.select( - Previews.camera, - Previews.path, - Previews.duration, - Previews.start_time, - Previews.end_time, - ) - .where( - Previews.start_time.between(start_ts, end_ts) - | Previews.end_time.between(start_ts, end_ts) - | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) - ) - .where(Previews.camera == event.camera) - .limit(1) - .get() - ) - - if not preview: - return make_response( - jsonify({"success": False, "message": "Preview not found"}), 404 - ) - - diff = event.start_time - preview.start_time - minutes = int(diff / 60) - seconds = int(diff % 60) - ffmpeg_cmd = [ - "ffmpeg", - "-hide_banner", - "-loglevel", - "warning", - "-ss", - f"00:{minutes}:{seconds}", - "-t", - f"{end_ts - start_ts}", - "-i", - preview.path, - "-r", - "8", - "-vf", - "setpts=0.12*PTS", - "-loop", - "0", - "-c:v", - "gif", - "-f", - "gif", - "-", - ] - - process = sp.run( - ffmpeg_cmd, - capture_output=True, - ) - - if process.returncode != 0: - logger.error(process.stderr) - return make_response( - jsonify({"success": False, "message": "Unable to create preview gif"}), - 500, - ) - - gif_bytes = process.stdout - else: - # need to generate from existing images - preview_dir = os.path.join(CACHE_DIR, "preview_frames") - file_start = f"preview_{event.camera}" - start_file = f"{file_start}-{start_ts}.jpg" - end_file = f"{file_start}-{end_ts}.jpg" - selected_previews = [] - - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - - if file < start_file: - continue - - if file > end_file: - break - - selected_previews.append(f"file '/tmp/cache/preview_frames/{file}'") - selected_previews.append("duration 0.12") - - if not selected_previews: - return make_response( - jsonify({"success": False, "message": "Preview not found"}), 404 - ) - - last_file = selected_previews[-2] - selected_previews.append(last_file) - - ffmpeg_cmd = [ - "ffmpeg", - "-hide_banner", - "-loglevel", - "warning", - "-f", - "concat", - "-y", - "-protocol_whitelist", - "pipe,file", - "-safe", - "0", - "-i", - "/dev/stdin", - "-loop", - "0", - "-c:v", - "gif", - "-f", - "gif", - "-", - ] - - process = sp.run( - ffmpeg_cmd, - input=str.encode("\n".join(selected_previews)), - capture_output=True, - ) - - if process.returncode != 0: - logger.error(process.stderr) - return make_response( - jsonify({"success": False, "message": "Unable to create preview gif"}), - 500, - ) - - gif_bytes = process.stdout - - response = make_response(gif_bytes) - response.headers["Content-Type"] = "image/gif" - response.headers["Cache-Control"] = f"private, max-age={max_cache_age}" - return response + return preview_gif(event.camera, start_ts, end_ts) @bp.route("/timeline") @@ -2337,6 +2199,147 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts): return jsonify(selected_previews) +@bp.route("//start//end//preview.gif") +@bp.route("//start//end//preview.gif") +def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000): + if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0): + # has preview mp4 + preview: Previews = ( + Previews.select( + Previews.camera, + Previews.path, + Previews.duration, + Previews.start_time, + Previews.end_time, + ) + .where( + Previews.start_time.between(start_ts, end_ts) + | Previews.end_time.between(start_ts, end_ts) + | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) + ) + .where(Previews.camera == camera_name) + .limit(1) + .get() + ) + + if not preview: + return make_response( + jsonify({"success": False, "message": "Preview not found"}), 404 + ) + + diff = start_ts - preview.start_time + minutes = int(diff / 60) + seconds = int(diff % 60) + ffmpeg_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "warning", + "-ss", + f"00:{minutes}:{seconds}", + "-t", + f"{end_ts - start_ts}", + "-i", + preview.path, + "-r", + "8", + "-vf", + "setpts=0.12*PTS", + "-loop", + "0", + "-c:v", + "gif", + "-f", + "gif", + "-", + ] + + process = sp.run( + ffmpeg_cmd, + capture_output=True, + ) + + if process.returncode != 0: + logger.error(process.stderr) + return make_response( + jsonify({"success": False, "message": "Unable to create preview gif"}), + 500, + ) + + gif_bytes = process.stdout + else: + # need to generate from existing images + preview_dir = os.path.join(CACHE_DIR, "preview_frames") + file_start = f"preview_{camera_name}" + start_file = f"{file_start}-{start_ts}.jpg" + end_file = f"{file_start}-{end_ts}.jpg" + selected_previews = [] + + for file in sorted(os.listdir(preview_dir)): + if not file.startswith(file_start): + continue + + if file < start_file: + continue + + if file > end_file: + break + + selected_previews.append(f"file '{os.path.join(preview_dir, file)}'") + selected_previews.append("duration 0.12") + + if not selected_previews: + return make_response( + jsonify({"success": False, "message": "Preview not found"}), 404 + ) + + last_file = selected_previews[-2] + selected_previews.append(last_file) + + ffmpeg_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "warning", + "-f", + "concat", + "-y", + "-protocol_whitelist", + "pipe,file", + "-safe", + "0", + "-i", + "/dev/stdin", + "-loop", + "0", + "-c:v", + "gif", + "-f", + "gif", + "-", + ] + + process = sp.run( + ffmpeg_cmd, + input=str.encode("\n".join(selected_previews)), + capture_output=True, + ) + + if process.returncode != 0: + logger.error(process.stderr) + return make_response( + jsonify({"success": False, "message": "Unable to create preview gif"}), + 500, + ) + + gif_bytes = process.stdout + + response = make_response(gif_bytes) + response.headers["Content-Type"] = "image/gif" + response.headers["Cache-Control"] = f"private, max-age={max_cache_age}" + return response + + @bp.route("/vod/event/") def vod_event(id): try: @@ -2478,6 +2481,21 @@ def set_not_reviewed(id): ) +@bp.route("/review//preview.gif") +def review_preview(id: str): + try: + review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Review segment not found"}), 404 + ) + + padding = 8 + start_ts = review.start_time - padding + end_ts = review.end_time + padding + return preview_gif(review.camera, start_ts, end_ts) + + @bp.route( "/export//start//end/", methods=["POST"] ) diff --git a/web/src/components/image/AnimatedEventThumbnail.tsx b/web/src/components/image/AnimatedEventThumbnail.tsx index 9d12f7e15..a504c8956 100644 --- a/web/src/components/image/AnimatedEventThumbnail.tsx +++ b/web/src/components/image/AnimatedEventThumbnail.tsx @@ -1,25 +1,35 @@ import { baseUrl } from "@/api/baseUrl"; -import { Event as FrigateEvent } from "@/types/event"; import TimeAgo from "../dynamic/TimeAgo"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useApiHost } from "@/api"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; +import { ReviewSegment } from "@/types/review"; +import { useNavigate } from "react-router-dom"; type AnimatedEventThumbnailProps = { - event: FrigateEvent; + event: ReviewSegment; }; export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); + // interaction + + const navigate = useNavigate(); + const onOpenReview = useCallback(() => { + navigate("events", { state: { review: event.id } }); + }, [navigate, event]); + + // image behavior + const imageUrl = useMemo(() => { if (Date.now() / 1000 < event.start_time + 20) { return `${apiHost}api/preview/${event.camera}/${event.start_time}/thumbnail.jpg`; } - return `${baseUrl}api/events/${event.id}/preview.gif`; + return `${baseUrl}api/review/${event.id}/preview.gif`; }, [event]); const aspectRatio = useMemo(() => { @@ -35,11 +45,12 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
@@ -49,13 +60,7 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
- {`${event.label} ${ - event.sub_label ? `(${event.sub_label})` : "" - } detected with score of ${(event.data.score * 100).toFixed(0)}% ${ - event.data.sub_label_score - ? `(${event.data.sub_label_score * 100}%)` - : "" - }`} + {`${[...event.data.objects, ...event.data.audio, ...(event.data.sub_labels || [])].join(", ")} detected`} ); diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index e4c9f71a2..951edd633 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -1,4 +1,4 @@ -import { useFrigateEvents } from "@/api/ws"; +import { useFrigateReviews } from "@/api/ws"; import Logo from "@/components/Logo"; import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; import LivePlayer from "@/components/player/LivePlayer"; @@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { TooltipProvider } from "@/components/ui/tooltip"; import { usePersistence } from "@/hooks/use-persistence"; -import { Event as FrigateEvent } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; +import { ReviewSegment } from "@/types/review"; import { useCallback, useEffect, useMemo, useState } from "react"; import { isDesktop, isMobile, isSafari } from "react-device-detect"; import { CiGrid2H, CiGrid31 } from "react-icons/ci"; @@ -24,10 +24,10 @@ function Live() { ); // recent events - const { payload: eventUpdate } = useFrigateEvents(); - const { data: allEvents, mutate: updateEvents } = useSWR([ - "events", - { limit: 10 }, + const { payload: eventUpdate } = useFrigateReviews(); + const { data: allEvents, mutate: updateEvents } = useSWR([ + "review", + { limit: 10, severity: "alert" }, ]); useEffect(() => { @@ -36,21 +36,10 @@ function Live() { } // if event is ended and was saved, update events list - if ( - eventUpdate.type == "end" && - (eventUpdate.after.has_clip || eventUpdate.after.has_snapshot) - ) { + if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") { updateEvents(); return; } - - // if event is updated and has become a saved event, update events list - if ( - !(eventUpdate.before.has_clip || eventUpdate.before.has_snapshot) && - (eventUpdate.after.has_clip || eventUpdate.after.has_snapshot) - ) { - updateEvents(); - } }, [eventUpdate]); const events = useMemo(() => {