Skip to content

Commit

Permalink
Use alerts for live page gifs and allow opening recordings directly f…
Browse files Browse the repository at this point in the history
…rom them (#10135)

* Use alerts for gifs at top instead of events

* Add ability to open to recordings from event gif

* cleanup

* Formatting

* Add padding
  • Loading branch information
NickM-27 committed Feb 28, 2024
1 parent dce4e3f commit b5becad
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 170 deletions.
298 changes: 158 additions & 140 deletions frigate/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ def event_thumbnail(id, max_cache_age=2592000):


@bp.route("/events/<id>/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:
Expand All @@ -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")
Expand Down Expand Up @@ -2337,6 +2199,147 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
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>")
def vod_event(id):
try:
Expand Down Expand Up @@ -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(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
)
Expand Down
29 changes: 17 additions & 12 deletions web/src/components/image/AnimatedEventThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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<FrigateConfig>("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(() => {
Expand All @@ -35,11 +45,12 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
<Tooltip>
<TooltipTrigger asChild>
<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={{
backgroundImage: `url(${imageUrl})`,
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="w-full absolute left-1 bottom-0 text-xs text-white">
Expand All @@ -49,13 +60,7 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
</div>
</TooltipTrigger>
<TooltipContent>
{`${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`}
</TooltipContent>
</Tooltip>
);
Expand Down
Loading

0 comments on commit b5becad

Please sign in to comment.