Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create API to create gif from previews and show instead of still thumbnails #9786

Merged
merged 6 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 168 additions & 15 deletions frigate/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,149 @@ def event_thumbnail(id, max_cache_age=2592000):
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")
def timeline():
camera = request.args.get("camera", "all")
Expand Down Expand Up @@ -2265,9 +2408,11 @@ def export_recording(camera_name: str, start_time, end_time):
camera_name,
int(start_time),
int(end_time),
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime,
(
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime
),
)
exporter.start()
return make_response(
Expand Down Expand Up @@ -2423,12 +2568,16 @@ def ffprobe():
output.append(
{
"return_code": ffprobe.returncode,
"stderr": ffprobe.stderr.decode("unicode_escape").strip()
if ffprobe.returncode != 0
else "",
"stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip())
if ffprobe.returncode == 0
else "",
"stderr": (
ffprobe.stderr.decode("unicode_escape").strip()
if ffprobe.returncode != 0
else ""
),
"stdout": (
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
if ffprobe.returncode == 0
else ""
),
}
)

Expand All @@ -2441,12 +2590,16 @@ def vainfo():
return jsonify(
{
"return_code": vainfo.returncode,
"stderr": vainfo.stderr.decode("unicode_escape").strip()
if vainfo.returncode != 0
else "",
"stdout": vainfo.stdout.decode("unicode_escape").strip()
if vainfo.returncode == 0
else "",
"stderr": (
vainfo.stderr.decode("unicode_escape").strip()
if vainfo.returncode != 0
else ""
),
"stdout": (
vainfo.stdout.decode("unicode_escape").strip()
if vainfo.returncode == 0
else ""
),
}
)

Expand Down
37 changes: 37 additions & 0 deletions web/src/components/image/AnimatedEventThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
11 changes: 6 additions & 5 deletions web/src/components/player/LivePlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,23 @@ export default function LivePlayer({
const { activeMotion, activeAudio, activeTracking } =
useCameraActivity(cameraConfig);

const cameraActive = useMemo(() => activeMotion || activeTracking, [activeMotion, activeTracking])
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);

const [liveReady, setLiveReady] = useState(false);
useEffect(() => {
if (!liveReady) {
if (activeMotion && liveMode == "jsmpeg") {
if (cameraActive && liveMode == "jsmpeg") {
setLiveReady(true);
}

return;
}

if (!activeMotion && !activeTracking) {
if (!cameraActive) {
setLiveReady(false);
}
}, [activeMotion, activeTracking, liveReady]);
}, [cameraActive, liveReady]);

const { payload: recording } = useRecordingsState(cameraConfig.name);

Expand Down Expand Up @@ -167,7 +168,7 @@ export default function LivePlayer({
: "outline-0"
} transition-all duration-500 ${className}`}
>
{(showStillWithoutActivity == false || activeMotion || activeTracking) &&
{(showStillWithoutActivity == false || cameraActive) &&
player}

<div
Expand All @@ -179,7 +180,7 @@ export default function LivePlayer({
className="w-full h-full"
camera={cameraConfig.name}
showFps={false}
reloadInterval={30000}
reloadInterval={(cameraActive && !liveReady) ? 200 : 30000}
/>
</div>

Expand Down
28 changes: 4 additions & 24 deletions web/src/pages/Live.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { EventThumbnail } from "@/components/image/EventThumbnail";
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
import LivePlayer from "@/components/player/LivePlayer";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Event as FrigateEvent } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import useSWR from "swr";

function Live() {
const { data: config } = useSWR<FrigateConfig>("config");

// recent events

const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>(
const { data: allEvents } = useSWR<FrigateEvent[]>(
["events", { limit: 10 }],
{ refreshInterval: 60000 }
);
Expand All @@ -29,19 +28,6 @@ function Live() {
return allEvents.filter((event) => event.start_time > cutoff);
}, [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

const cameras = useMemo(() => {
Expand All @@ -61,13 +47,7 @@ function Live() {
<TooltipProvider>
<div className="flex">
{events.map((event) => {
return (
<EventThumbnail
key={event.id}
event={event}
onFavorite={onFavorite}
/>
);
return <AnimatedEventThumbnail key={event.id} event={event} />;
})}
</div>
</TooltipProvider>
Expand Down
Loading