Use alerts for gifs at top instead of events

This commit is contained in:
Nicolas Mowen 2024-02-28 08:31:28 -07:00
parent a978adc5a9
commit 1825869a64
3 changed files with 181 additions and 180 deletions

View File

@ -607,7 +607,7 @@ def event_thumbnail(id, max_cache_age=2592000):
@bp.route("/events/<id>/preview.gif") @bp.route("/events/<id>/preview.gif")
def event_preview(id: str, max_cache_age=2592000): def event_preview(id: str):
try: try:
event: Event = Event.get(Event.id == id) event: Event = Event.get(Event.id == id)
except DoesNotExist: except DoesNotExist:
@ -619,145 +619,7 @@ def event_preview(id: str, max_cache_age=2592000):
end_ts = start_ts + ( end_ts = start_ts + (
min(event.end_time - event.start_time, 20) if event.end_time else 20 min(event.end_time - event.start_time, 20) if event.end_time else 20
) )
return preview_gif(event.camera, start_ts, end_ts)
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
@bp.route("/timeline") @bp.route("/timeline")
@ -1055,9 +917,9 @@ def event_snapshot(id):
else: else:
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
if download: if download:
response.headers[ response.headers["Content-Disposition"] = (
"Content-Disposition" f"attachment; filename=snapshot-{id}.jpg"
] = f"attachment; filename=snapshot-{id}.jpg" )
return response return response
@ -1244,9 +1106,9 @@ def event_clip(id):
if download: if download:
response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name
response.headers["Content-Length"] = os.path.getsize(clip_path) response.headers["Content-Length"] = os.path.getsize(clip_path)
response.headers[ response.headers["X-Accel-Redirect"] = (
"X-Accel-Redirect" f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
] = f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers )
return response return response
@ -1949,9 +1811,9 @@ def get_recordings_storage_usage():
total_mb = recording_stats["total"] total_mb = recording_stats["total"]
camera_usages: dict[ camera_usages: dict[str, dict] = (
str, dict current_app.storage_maintainer.calculate_camera_usages()
] = current_app.storage_maintainer.calculate_camera_usages() )
for camera_name in camera_usages.keys(): for camera_name in camera_usages.keys():
if camera_usages.get(camera_name, {}).get("usage"): if camera_usages.get(camera_name, {}).get("usage"):
@ -2139,9 +2001,9 @@ def recording_clip(camera_name, start_ts, end_ts):
if download: if download:
response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name
response.headers["Content-Length"] = os.path.getsize(path) response.headers["Content-Length"] = os.path.getsize(path)
response.headers[ response.headers["X-Accel-Redirect"] = (
"X-Accel-Redirect" f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
] = f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers )
return response return response
@ -2337,6 +2199,147 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
return jsonify(selected_previews) return jsonify(selected_previews)
@bp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.gif")
@bp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/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/<id>") @bp.route("/vod/event/<id>")
def vod_event(id): def vod_event(id):
try: try:
@ -2478,6 +2481,20 @@ def set_not_reviewed(id):
) )
@bp.route("/review/<id>/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
)
start_ts = review.start_time
end_ts = review.end_time
return preview_gif(review.camera, start_ts, end_ts)
@bp.route( @bp.route(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"] "/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
) )

View File

@ -1,14 +1,14 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { Event as FrigateEvent } from "@/types/event";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useMemo } from "react"; import { useMemo } from "react";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
type AnimatedEventThumbnailProps = { type AnimatedEventThumbnailProps = {
event: FrigateEvent; event: ReviewSegment;
}; };
export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -19,7 +19,7 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
return `${apiHost}api/preview/${event.camera}/${event.start_time}/thumbnail.jpg`; 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]); }, [event]);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
@ -49,13 +49,7 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{`${event.label} ${ {`${[...event.data.objects, ...event.data.audio, ...(event.data.sub_labels || [])].join(", ")} detected`}
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}%)`
: ""
}`}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View File

@ -1,4 +1,4 @@
import { useFrigateEvents } from "@/api/ws"; import { useFrigateReviews } from "@/api/ws";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
import LivePlayer from "@/components/player/LivePlayer"; 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 { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { Event as FrigateEvent } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isDesktop, isMobile, isSafari } from "react-device-detect"; import { isDesktop, isMobile, isSafari } from "react-device-detect";
import { CiGrid2H, CiGrid31 } from "react-icons/ci"; import { CiGrid2H, CiGrid31 } from "react-icons/ci";
@ -24,10 +24,10 @@ function Live() {
); );
// recent events // recent events
const { payload: eventUpdate } = useFrigateEvents(); const { payload: eventUpdate } = useFrigateReviews();
const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>([ const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
"events", "review",
{ limit: 10 }, { limit: 10, severity: "alert" },
]); ]);
useEffect(() => { useEffect(() => {
@ -36,21 +36,11 @@ function Live() {
} }
// if event is ended and was saved, update events list // if event is ended and was saved, update events list
if ( if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") {
eventUpdate.type == "end" && console.log("new alert " + eventUpdate.review.id);
(eventUpdate.after.has_clip || eventUpdate.after.has_snapshot)
) {
updateEvents(); updateEvents();
return; 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]); }, [eventUpdate]);
const events = useMemo(() => { const events = useMemo(() => {