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:
Nicolas Mowen 2024-02-11 06:23:45 -07:00 committed by GitHub
parent 9de09f6cdf
commit bcbea8da7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 215 additions and 44 deletions

View File

@ -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 ""
),
} }
) )

View 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>
);
}

View File

@ -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>

View File

@ -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>