mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Create API to create gif from previews and show instead of still thumbnails (#9786)
* Start working on animation * Change output file * Create preview gif * Show animated gif for event thumb * Remove favorite * Cleanup
This commit is contained in:
parent
9de09f6cdf
commit
bcbea8da7d
183
frigate/http.py
183
frigate/http.py
@ -593,6 +593,149 @@ def event_thumbnail(id, max_cache_age=2592000):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/events/<id>/preview.gif")
|
||||||
|
def event_preview(id: str, max_cache_age=2592000):
|
||||||
|
try:
|
||||||
|
event: Event = Event.get(Event.id == id)
|
||||||
|
except DoesNotExist:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Event not found"}), 404
|
||||||
|
)
|
||||||
|
|
||||||
|
start_ts = event.start_time
|
||||||
|
end_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,
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
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")
|
||||||
def timeline():
|
def timeline():
|
||||||
camera = request.args.get("camera", "all")
|
camera = request.args.get("camera", "all")
|
||||||
@ -2265,9 +2408,11 @@ def export_recording(camera_name: str, start_time, end_time):
|
|||||||
camera_name,
|
camera_name,
|
||||||
int(start_time),
|
int(start_time),
|
||||||
int(end_time),
|
int(end_time),
|
||||||
PlaybackFactorEnum[playback_factor]
|
(
|
||||||
if playback_factor in PlaybackFactorEnum.__members__.values()
|
PlaybackFactorEnum[playback_factor]
|
||||||
else PlaybackFactorEnum.realtime,
|
if playback_factor in PlaybackFactorEnum.__members__.values()
|
||||||
|
else PlaybackFactorEnum.realtime
|
||||||
|
),
|
||||||
)
|
)
|
||||||
exporter.start()
|
exporter.start()
|
||||||
return make_response(
|
return make_response(
|
||||||
@ -2423,12 +2568,16 @@ def ffprobe():
|
|||||||
output.append(
|
output.append(
|
||||||
{
|
{
|
||||||
"return_code": ffprobe.returncode,
|
"return_code": ffprobe.returncode,
|
||||||
"stderr": ffprobe.stderr.decode("unicode_escape").strip()
|
"stderr": (
|
||||||
if ffprobe.returncode != 0
|
ffprobe.stderr.decode("unicode_escape").strip()
|
||||||
else "",
|
if ffprobe.returncode != 0
|
||||||
"stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
else ""
|
||||||
if ffprobe.returncode == 0
|
),
|
||||||
else "",
|
"stdout": (
|
||||||
|
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
||||||
|
if ffprobe.returncode == 0
|
||||||
|
else ""
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2441,12 +2590,16 @@ def vainfo():
|
|||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"return_code": vainfo.returncode,
|
"return_code": vainfo.returncode,
|
||||||
"stderr": vainfo.stderr.decode("unicode_escape").strip()
|
"stderr": (
|
||||||
if vainfo.returncode != 0
|
vainfo.stderr.decode("unicode_escape").strip()
|
||||||
else "",
|
if vainfo.returncode != 0
|
||||||
"stdout": vainfo.stdout.decode("unicode_escape").strip()
|
else ""
|
||||||
if vainfo.returncode == 0
|
),
|
||||||
else "",
|
"stdout": (
|
||||||
|
vainfo.stdout.decode("unicode_escape").strip()
|
||||||
|
if vainfo.returncode == 0
|
||||||
|
else ""
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
37
web/src/components/image/AnimatedEventThumbnail.tsx
Normal file
37
web/src/components/image/AnimatedEventThumbnail.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import { Event as FrigateEvent } from "@/types/event";
|
||||||
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
|
type AnimatedEventThumbnailProps = {
|
||||||
|
event: FrigateEvent;
|
||||||
|
};
|
||||||
|
export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className="relative rounded bg-cover aspect-video h-24 bg-no-repeat bg-center mr-4"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${baseUrl}api/events/${event.id}/preview.gif)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute bottom-0 w-full h-6 bg-gradient-to-t from-slate-900/50 to-transparent rounded">
|
||||||
|
<div className="absolute left-1 bottom-0 text-xs text-white w-full">
|
||||||
|
<TimeAgo time={event.start_time * 1000} dense />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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}%)`
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
@ -40,22 +40,23 @@ export default function LivePlayer({
|
|||||||
const { activeMotion, activeAudio, activeTracking } =
|
const { activeMotion, activeAudio, activeTracking } =
|
||||||
useCameraActivity(cameraConfig);
|
useCameraActivity(cameraConfig);
|
||||||
|
|
||||||
|
const cameraActive = useMemo(() => activeMotion || activeTracking, [activeMotion, activeTracking])
|
||||||
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
|
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
|
||||||
|
|
||||||
const [liveReady, setLiveReady] = useState(false);
|
const [liveReady, setLiveReady] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!liveReady) {
|
if (!liveReady) {
|
||||||
if (activeMotion && liveMode == "jsmpeg") {
|
if (cameraActive && liveMode == "jsmpeg") {
|
||||||
setLiveReady(true);
|
setLiveReady(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeMotion && !activeTracking) {
|
if (!cameraActive) {
|
||||||
setLiveReady(false);
|
setLiveReady(false);
|
||||||
}
|
}
|
||||||
}, [activeMotion, activeTracking, liveReady]);
|
}, [cameraActive, liveReady]);
|
||||||
|
|
||||||
const { payload: recording } = useRecordingsState(cameraConfig.name);
|
const { payload: recording } = useRecordingsState(cameraConfig.name);
|
||||||
|
|
||||||
@ -167,7 +168,7 @@ export default function LivePlayer({
|
|||||||
: "outline-0"
|
: "outline-0"
|
||||||
} transition-all duration-500 ${className}`}
|
} transition-all duration-500 ${className}`}
|
||||||
>
|
>
|
||||||
{(showStillWithoutActivity == false || activeMotion || activeTracking) &&
|
{(showStillWithoutActivity == false || cameraActive) &&
|
||||||
player}
|
player}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -179,7 +180,7 @@ export default function LivePlayer({
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
showFps={false}
|
showFps={false}
|
||||||
reloadInterval={30000}
|
reloadInterval={(cameraActive && !liveReady) ? 200 : 30000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import { EventThumbnail } from "@/components/image/EventThumbnail";
|
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
|
||||||
import LivePlayer from "@/components/player/LivePlayer";
|
import LivePlayer from "@/components/player/LivePlayer";
|
||||||
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 { Event as FrigateEvent } from "@/types/event";
|
import { Event as FrigateEvent } from "@/types/event";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import axios from "axios";
|
import { useMemo } from "react";
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
function Live() {
|
function Live() {
|
||||||
@ -13,7 +12,7 @@ function Live() {
|
|||||||
|
|
||||||
// recent events
|
// recent events
|
||||||
|
|
||||||
const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>(
|
const { data: allEvents } = useSWR<FrigateEvent[]>(
|
||||||
["events", { limit: 10 }],
|
["events", { limit: 10 }],
|
||||||
{ refreshInterval: 60000 }
|
{ refreshInterval: 60000 }
|
||||||
);
|
);
|
||||||
@ -29,19 +28,6 @@ function Live() {
|
|||||||
return allEvents.filter((event) => event.start_time > cutoff);
|
return allEvents.filter((event) => event.start_time > cutoff);
|
||||||
}, [allEvents]);
|
}, [allEvents]);
|
||||||
|
|
||||||
const onFavorite = useCallback(async (e: Event, event: FrigateEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
let response;
|
|
||||||
if (!event.retain_indefinitely) {
|
|
||||||
response = await axios.post(`events/${event.id}/retain`);
|
|
||||||
} else {
|
|
||||||
response = await axios.delete(`events/${event.id}/retain`);
|
|
||||||
}
|
|
||||||
if (response.status === 200) {
|
|
||||||
updateEvents();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// camera live views
|
// camera live views
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
@ -61,13 +47,7 @@ function Live() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
return (
|
return <AnimatedEventThumbnail key={event.id} event={event} />;
|
||||||
<EventThumbnail
|
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
onFavorite={onFavorite}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
Loading…
Reference in New Issue
Block a user