mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
Use alerts for live page gifs and allow opening recordings directly from them (#10135)
* Use alerts for gifs at top instead of events * Add ability to open to recordings from event gif * cleanup * Formatting * Add padding
This commit is contained in:
parent
dce4e3fb0b
commit
b5becad63f
298
frigate/http.py
298
frigate/http.py
@ -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")
|
||||||
@ -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,21 @@ 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
@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"]
|
||||||
)
|
)
|
||||||
|
@ -1,25 +1,35 @@
|
|||||||
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 { useCallback, 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";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type AnimatedEventThumbnailProps = {
|
type AnimatedEventThumbnailProps = {
|
||||||
event: FrigateEvent;
|
event: ReviewSegment;
|
||||||
};
|
};
|
||||||
export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
// interaction
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const onOpenReview = useCallback(() => {
|
||||||
|
navigate("events", { state: { review: event.id } });
|
||||||
|
}, [navigate, event]);
|
||||||
|
|
||||||
|
// image behavior
|
||||||
|
|
||||||
const imageUrl = useMemo(() => {
|
const imageUrl = useMemo(() => {
|
||||||
if (Date.now() / 1000 < event.start_time + 20) {
|
if (Date.now() / 1000 < event.start_time + 20) {
|
||||||
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(() => {
|
||||||
@ -35,11 +45,12 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="h-24 relative rounded bg-cover bg-no-repeat bg-center mr-4"
|
className="h-24 relative rounded bg-cover bg-no-repeat bg-center mr-4 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${imageUrl})`,
|
backgroundImage: `url(${imageUrl})`,
|
||||||
aspectRatio: aspectRatio,
|
aspectRatio: aspectRatio,
|
||||||
}}
|
}}
|
||||||
|
onClick={onOpenReview}
|
||||||
>
|
>
|
||||||
<div className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-slate-900/50 to-transparent rounded">
|
<div className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-slate-900/50 to-transparent rounded">
|
||||||
<div className="w-full absolute left-1 bottom-0 text-xs text-white">
|
<div className="w-full absolute left-1 bottom-0 text-xs text-white">
|
||||||
@ -49,13 +60,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>
|
||||||
);
|
);
|
||||||
|
@ -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,10 @@ 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" &&
|
|
||||||
(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(() => {
|
||||||
|
Loading…
Reference in New Issue
Block a user