mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Remove gifs and use existing views (#11027)
* Use existing components for preview filmstrip instead of gif * Allow setting format
This commit is contained in:
parent
0bad001ac9
commit
a1905f5604
@ -14,14 +14,7 @@ from urllib.parse import unquote
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytz
|
import pytz
|
||||||
from flask import (
|
from flask import Blueprint, Response, current_app, jsonify, make_response, request
|
||||||
Blueprint,
|
|
||||||
Response,
|
|
||||||
current_app,
|
|
||||||
jsonify,
|
|
||||||
make_response,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
from peewee import DoesNotExist, fn
|
from peewee import DoesNotExist, fn
|
||||||
from tzlocal import get_localzone_name
|
from tzlocal import get_localzone_name
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@ -36,9 +29,7 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||||
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
|
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import get_tz_modifiers
|
||||||
get_tz_modifiers,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -1322,8 +1313,151 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@MediaBp.route("/review/<id>/preview.gif")
|
@MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4")
|
||||||
|
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4")
|
||||||
|
def preview_mp4(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",
|
||||||
|
"copy",
|
||||||
|
"-f",
|
||||||
|
"mp4",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
|
||||||
|
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}.{PREVIEW_FRAME_TYPE}"
|
||||||
|
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
|
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",
|
||||||
|
"libx264",
|
||||||
|
"-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
|
||||||
|
|
||||||
|
|
||||||
|
@MediaBp.route("/review/<id>/preview")
|
||||||
def review_preview(id: str):
|
def review_preview(id: str):
|
||||||
|
format = request.args.get("format", default="gif")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
|
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
@ -1336,7 +1470,11 @@ def review_preview(id: str):
|
|||||||
end_ts = (
|
end_ts = (
|
||||||
review.end_time + padding if review.end_time else datetime.now().timestamp()
|
review.end_time + padding if review.end_time else datetime.now().timestamp()
|
||||||
)
|
)
|
||||||
return preview_gif(review.camera, start_ts, end_ts)
|
|
||||||
|
if format == "gif":
|
||||||
|
return preview_gif(review.camera, start_ts, end_ts)
|
||||||
|
else:
|
||||||
|
return preview_mp4(review.camera, start_ts, end_ts)
|
||||||
|
|
||||||
|
|
||||||
@MediaBp.route("/preview/<file_name>/thumbnail.jpg")
|
@MediaBp.route("/preview/<file_name>/thumbnail.jpg")
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
|
||||||
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 { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
|
||||||
import { RecordingStartingPoint } from "@/types/record";
|
import { RecordingStartingPoint } from "@/types/record";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { Preview } from "@/types/preview";
|
||||||
|
import {
|
||||||
|
InProgressPreview,
|
||||||
|
VideoPreview,
|
||||||
|
} from "../player/PreviewThumbnailPlayer";
|
||||||
|
|
||||||
type AnimatedEventCardProps = {
|
type AnimatedEventCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
@ -16,6 +19,12 @@ type AnimatedEventCardProps = {
|
|||||||
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
// preview
|
||||||
|
|
||||||
|
const { data: previews } = useSWR<Preview[]>(
|
||||||
|
`/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`,
|
||||||
|
);
|
||||||
|
|
||||||
// interaction
|
// interaction
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -35,16 +44,6 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
|||||||
|
|
||||||
// image behavior
|
// image behavior
|
||||||
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [error, setError] = useState(0);
|
|
||||||
const imageUrl = useMemo(() => {
|
|
||||||
if (error > 0) {
|
|
||||||
return `${baseUrl}api/review/${event.id}/preview.gif?key=${error}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseUrl}api/review/${event.id}/preview.gif`;
|
|
||||||
}, [error, event]);
|
|
||||||
|
|
||||||
const aspectRatio = useMemo(() => {
|
const aspectRatio = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return 1;
|
return 1;
|
||||||
@ -63,18 +62,36 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
|||||||
aspectRatio: aspectRatio,
|
aspectRatio: aspectRatio,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
className="size-full rounded object-cover object-center cursor-pointer"
|
className="size-full rounded cursor-pointer"
|
||||||
src={imageUrl}
|
|
||||||
onClick={onOpenReview}
|
onClick={onOpenReview}
|
||||||
onLoad={() => setLoaded(true)}
|
>
|
||||||
onError={() => {
|
{previews ? (
|
||||||
if (error < 2) {
|
<VideoPreview
|
||||||
setError(error + 1);
|
relevantPreview={previews[previews.length - 1]}
|
||||||
}
|
startTime={event.start_time}
|
||||||
}}
|
endTime={event.end_time}
|
||||||
/>
|
loop
|
||||||
{!loaded && <Skeleton className="absolute inset-0" />}
|
showProgress={false}
|
||||||
|
setReviewed={() => {}}
|
||||||
|
setIgnoreClick={() => {}}
|
||||||
|
isPlayingBack={() => {}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InProgressPreview
|
||||||
|
review={event}
|
||||||
|
timeRange={{
|
||||||
|
after: event.start_time,
|
||||||
|
before: event.end_time ?? event.start_time + 20,
|
||||||
|
}}
|
||||||
|
loop
|
||||||
|
showProgress={false}
|
||||||
|
setReviewed={() => {}}
|
||||||
|
setIgnoreClick={() => {}}
|
||||||
|
isPlayingBack={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<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">
|
||||||
<TimeAgo time={event.start_time * 1000} dense />
|
<TimeAgo time={event.start_time * 1000} dense />
|
||||||
|
@ -342,15 +342,19 @@ type VideoPreviewProps = {
|
|||||||
relevantPreview: Preview;
|
relevantPreview: Preview;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime?: number;
|
endTime?: number;
|
||||||
|
showProgress?: boolean;
|
||||||
|
loop?: boolean;
|
||||||
setReviewed: () => void;
|
setReviewed: () => void;
|
||||||
setIgnoreClick: (ignore: boolean) => void;
|
setIgnoreClick: (ignore: boolean) => void;
|
||||||
isPlayingBack: (ended: boolean) => void;
|
isPlayingBack: (ended: boolean) => void;
|
||||||
onTimeUpdate?: (time: number | undefined) => void;
|
onTimeUpdate?: (time: number | undefined) => void;
|
||||||
};
|
};
|
||||||
function VideoPreview({
|
export function VideoPreview({
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
showProgress = true,
|
||||||
|
loop = false,
|
||||||
setReviewed,
|
setReviewed,
|
||||||
setIgnoreClick,
|
setIgnoreClick,
|
||||||
isPlayingBack,
|
isPlayingBack,
|
||||||
@ -425,6 +429,11 @@ function VideoPreview({
|
|||||||
if (playerPercent > 100) {
|
if (playerPercent > 100) {
|
||||||
setReviewed();
|
setReviewed();
|
||||||
|
|
||||||
|
if (loop && playerRef.current) {
|
||||||
|
playerRef.current.currentTime = playerStartTime;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
isPlayingBack(false);
|
isPlayingBack(false);
|
||||||
|
|
||||||
@ -553,17 +562,19 @@ function VideoPreview({
|
|||||||
>
|
>
|
||||||
<source src={relevantPreview.src} type={relevantPreview.type} />
|
<source src={relevantPreview.src} type={relevantPreview.type} />
|
||||||
</video>
|
</video>
|
||||||
<NoThumbSlider
|
{showProgress && (
|
||||||
ref={sliderRef}
|
<NoThumbSlider
|
||||||
className="absolute inset-x-0 bottom-0 z-30"
|
ref={sliderRef}
|
||||||
value={[progress]}
|
className="absolute inset-x-0 bottom-0 z-30"
|
||||||
onValueChange={onManualSeek}
|
value={[progress]}
|
||||||
onValueCommit={onStopManualSeek}
|
onValueChange={onManualSeek}
|
||||||
min={0}
|
onValueCommit={onStopManualSeek}
|
||||||
step={1}
|
min={0}
|
||||||
max={100}
|
step={1}
|
||||||
onMouseMove={isMobile ? undefined : onProgressHover}
|
max={100}
|
||||||
/>
|
onMouseMove={isMobile ? undefined : onProgressHover}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -572,14 +583,18 @@ const MIN_LOAD_TIMEOUT_MS = 200;
|
|||||||
type InProgressPreviewProps = {
|
type InProgressPreviewProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
|
showProgress?: boolean;
|
||||||
|
loop?: boolean;
|
||||||
setReviewed: (reviewId: string) => void;
|
setReviewed: (reviewId: string) => void;
|
||||||
setIgnoreClick: (ignore: boolean) => void;
|
setIgnoreClick: (ignore: boolean) => void;
|
||||||
isPlayingBack: (ended: boolean) => void;
|
isPlayingBack: (ended: boolean) => void;
|
||||||
onTimeUpdate?: (time: number | undefined) => void;
|
onTimeUpdate?: (time: number | undefined) => void;
|
||||||
};
|
};
|
||||||
function InProgressPreview({
|
export function InProgressPreview({
|
||||||
review,
|
review,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
showProgress = true,
|
||||||
|
loop = false,
|
||||||
setReviewed,
|
setReviewed,
|
||||||
setIgnoreClick,
|
setIgnoreClick,
|
||||||
isPlayingBack,
|
isPlayingBack,
|
||||||
@ -615,6 +630,11 @@ function InProgressPreview({
|
|||||||
setReviewed(review.id);
|
setReviewed(review.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loop) {
|
||||||
|
setKey(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
isPlayingBack(false);
|
isPlayingBack(false);
|
||||||
|
|
||||||
@ -717,17 +737,19 @@ function InProgressPreview({
|
|||||||
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`}
|
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
/>
|
/>
|
||||||
<NoThumbSlider
|
{showProgress && (
|
||||||
ref={sliderRef}
|
<NoThumbSlider
|
||||||
className="absolute inset-x-0 bottom-0 z-30"
|
ref={sliderRef}
|
||||||
value={[key]}
|
className="absolute inset-x-0 bottom-0 z-30"
|
||||||
onValueChange={onManualSeek}
|
value={[key]}
|
||||||
onValueCommit={onStopManualSeek}
|
onValueChange={onManualSeek}
|
||||||
min={0}
|
onValueCommit={onStopManualSeek}
|
||||||
step={1}
|
min={0}
|
||||||
max={previewFrames.length - 1}
|
step={1}
|
||||||
onMouseMove={isMobile ? undefined : onProgressHover}
|
max={previewFrames.length - 1}
|
||||||
/>
|
onMouseMove={isMobile ? undefined : onProgressHover}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user