mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-07 11:45:24 +03:00
Add support for review grid
This commit is contained in:
parent
a5b504d3b2
commit
05ffd24760
@ -45,6 +45,12 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
|
||||
if prev_event["attributes"] != current_event["attributes"]:
|
||||
return True
|
||||
|
||||
if prev_event["sub_label"] != current_event["sub_label"]:
|
||||
return True
|
||||
|
||||
if len(prev_event["current_zones"]) < len(current_event["current_zones"]):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
107
frigate/http.py
107
frigate/http.py
@ -611,6 +611,57 @@ def timeline():
|
||||
return jsonify([t for t in timeline])
|
||||
|
||||
|
||||
@bp.route("/timeline/hourly")
|
||||
def hourly_timeline():
|
||||
"""Get hourly summary for timeline."""
|
||||
camera = request.args.get("camera", "all")
|
||||
limit = request.args.get("limit", 200)
|
||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
||||
_, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||
|
||||
clauses = []
|
||||
|
||||
if camera != "all":
|
||||
clauses.append((Timeline.camera == camera))
|
||||
|
||||
if len(clauses) == 0:
|
||||
clauses.append((True))
|
||||
|
||||
timeline = (
|
||||
Timeline.select(
|
||||
Timeline.camera,
|
||||
Timeline.timestamp,
|
||||
Timeline.data,
|
||||
Timeline.class_type,
|
||||
Timeline.source_id,
|
||||
Timeline.source,
|
||||
)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(Timeline.timestamp.desc())
|
||||
.limit(limit)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
hours: dict[str, list[dict[str, any]]] = {}
|
||||
|
||||
for t in timeline:
|
||||
hour = (
|
||||
datetime.fromtimestamp(t["timestamp"]).replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
)
|
||||
+ timedelta(
|
||||
minutes=int(minute_modifier.split(" ")[0]),
|
||||
)
|
||||
).timestamp()
|
||||
if hour not in hours:
|
||||
hours[hour] = [t]
|
||||
else:
|
||||
hours[hour].insert(0, t)
|
||||
|
||||
return jsonify(hours)
|
||||
|
||||
|
||||
@bp.route("/<camera_name>/<label>/best.jpg")
|
||||
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
|
||||
def label_thumbnail(camera_name, label):
|
||||
@ -1863,17 +1914,27 @@ def vod_hour(year_month, day, hour, camera_name, tz_name):
|
||||
@bp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>")
|
||||
def preview_ts(camera_name, start_ts, end_ts):
|
||||
"""Get all mp4 previews relevant for time period."""
|
||||
if camera_name != "all":
|
||||
camera_clause = Previews.camera == camera_name
|
||||
else:
|
||||
camera_clause = True
|
||||
|
||||
previews = (
|
||||
Previews.select(
|
||||
Previews.path, Previews.duration, Previews.start_time, Previews.end_time
|
||||
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)
|
||||
.where(camera_clause)
|
||||
.order_by(Previews.start_time.asc())
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
@ -1883,15 +1944,15 @@ def preview_ts(camera_name, start_ts, end_ts):
|
||||
for preview in previews:
|
||||
clips.append(
|
||||
{
|
||||
"src": preview.path.replace("/media/frigate", ""),
|
||||
"camera": preview["camera"],
|
||||
"src": preview["path"].replace("/media/frigate", ""),
|
||||
"type": "video/mp4",
|
||||
"start": preview.start_time,
|
||||
"end": preview.end_time,
|
||||
"start": preview["start_time"],
|
||||
"end": preview["end_time"],
|
||||
}
|
||||
)
|
||||
|
||||
if not clips:
|
||||
logger.error("No previews found for the requested time range")
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
@ -1919,6 +1980,40 @@ def preview_hour(year_month, day, hour, camera_name, tz_name):
|
||||
return preview_ts(camera_name, start_ts, end_ts)
|
||||
|
||||
|
||||
@bp.route("/preview/<camera_name>/<frame_time>/thumbnail.jpg")
|
||||
def preview_thumbnail(camera_name, frame_time):
|
||||
"""Get a thumbnail from the cached preview jpgs."""
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
file_start = f"preview_{camera_name}"
|
||||
file_check = f"{file_start}-{frame_time}.jpg"
|
||||
selected_preview = None
|
||||
|
||||
for file in os.listdir(preview_dir):
|
||||
if file.startswith(file_start):
|
||||
if file < file_check:
|
||||
selected_preview = file
|
||||
break
|
||||
|
||||
if selected_preview is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Could not find valid preview jpg.",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
with open(os.path.join(preview_dir, selected_preview), "rb") as image_file:
|
||||
jpg_bytes = image_file.read()
|
||||
|
||||
response = make_response(jpg_bytes)
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
response.headers["Cache-Control"] = "private, max-age=31536000"
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/vod/event/<id>")
|
||||
def vod_event(id):
|
||||
try:
|
||||
|
||||
@ -29,6 +29,7 @@ class TimelineProcessor(threading.Thread):
|
||||
self.config = config
|
||||
self.queue = queue
|
||||
self.stop_event = stop_event
|
||||
self.pre_event_cache: dict[str, list[dict[str, any]]] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@ -48,14 +49,39 @@ class TimelineProcessor(threading.Thread):
|
||||
camera, event_type, prev_event_data, event_data
|
||||
)
|
||||
|
||||
def insert_or_save(
|
||||
self,
|
||||
entry: dict[str, any],
|
||||
prev_event_data: dict[any, any],
|
||||
event_data: dict[any, any],
|
||||
) -> None:
|
||||
"""Insert into db or cache."""
|
||||
id = entry[Timeline.source_id]
|
||||
if not event_data["has_clip"] and not event_data["has_snapshot"]:
|
||||
# the related event has not been saved yet, should be added to cache
|
||||
if id in self.pre_event_cache.keys():
|
||||
self.pre_event_cache[id].append(entry)
|
||||
else:
|
||||
self.pre_event_cache[id] = [entry]
|
||||
else:
|
||||
# the event is saved, insert to db and insert cached into db
|
||||
if id in self.pre_event_cache.keys():
|
||||
for e in self.pre_event_cache[id]:
|
||||
Timeline.insert(e).execute()
|
||||
|
||||
self.pre_event_cache.pop(id)
|
||||
|
||||
Timeline.insert(entry).execute()
|
||||
|
||||
def handle_object_detection(
|
||||
self,
|
||||
camera: str,
|
||||
event_type: str,
|
||||
prev_event_data: dict[any, any],
|
||||
event_data: dict[any, any],
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Handle object detection."""
|
||||
save = False
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
timeline_entry = {
|
||||
@ -70,6 +96,7 @@ class TimelineProcessor(threading.Thread):
|
||||
event_data["box"],
|
||||
),
|
||||
"label": event_data["label"],
|
||||
"sub_label": event_data.get("sub_label"),
|
||||
"region": to_relative_box(
|
||||
camera_config.detect.width,
|
||||
camera_config.detect.height,
|
||||
@ -80,41 +107,36 @@ class TimelineProcessor(threading.Thread):
|
||||
}
|
||||
if event_type == "start":
|
||||
timeline_entry[Timeline.class_type] = "visible"
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif event_type == "update":
|
||||
# zones have been updated
|
||||
if (
|
||||
prev_event_data["current_zones"] != event_data["current_zones"]
|
||||
and len(event_data["current_zones"]) > 0
|
||||
len(prev_event_data["current_zones"]) < len(event_data["current_zones"])
|
||||
and not event_data["stationary"]
|
||||
):
|
||||
timeline_entry[Timeline.class_type] = "entered_zone"
|
||||
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif prev_event_data["stationary"] != event_data["stationary"]:
|
||||
timeline_entry[Timeline.class_type] = (
|
||||
"stationary" if event_data["stationary"] else "active"
|
||||
)
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
|
||||
timeline_entry[Timeline.class_type] = "attribute"
|
||||
timeline_entry[Timeline.data]["attribute"] = list(
|
||||
event_data["attributes"].keys()
|
||||
)[0]
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif not prev_event_data.get("sub_label") and event_data.get("sub_label"):
|
||||
sub_label = event_data["sub_label"][0]
|
||||
|
||||
if sub_label not in ALL_ATTRIBUTE_LABELS:
|
||||
timeline_entry[Timeline.class_type] = "sub_label"
|
||||
timeline_entry[Timeline.data]["sub_label"] = sub_label
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif event_type == "end":
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
else:
|
||||
# if event was not saved then the timeline entries should be deleted
|
||||
Timeline.delete().where(
|
||||
Timeline.source_id == event_data["id"]
|
||||
).execute()
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
save = True
|
||||
|
||||
if save:
|
||||
self.insert_or_save(timeline_entry, prev_event_data, event_data)
|
||||
|
||||
32
web-new/package-lock.json
generated
32
web-new/package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
@ -1442,6 +1443,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz",
|
||||
"integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/number": "1.0.1",
|
||||
"@radix-ui/primitive": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.1",
|
||||
"@radix-ui/react-context": "1.0.1",
|
||||
"@radix-ui/react-direction": "1.0.1",
|
||||
"@radix-ui/react-presence": "1.0.1",
|
||||
"@radix-ui/react-primitive": "1.0.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
|
||||
@ -7,6 +7,7 @@ import Header from "@/components/Header";
|
||||
import Dashboard from "@/pages/Dashboard";
|
||||
import Live from "@/pages/Live";
|
||||
import History from "@/pages/History";
|
||||
import Review from "@/pages/Review";
|
||||
import Export from "@/pages/Export";
|
||||
import Storage from "@/pages/Storage";
|
||||
import System from "@/pages/System";
|
||||
@ -34,6 +35,7 @@ function App() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/live" element={<Live />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/review" element={<Review />} />
|
||||
<Route path="/export" element={<Export />} />
|
||||
<Route path="/storage" element={<Storage />} />
|
||||
<Route path="/system" element={<System />} />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { IconType } from "react-icons";
|
||||
import { LuFileUp, LuFilm, LuLayoutDashboard, LuVideo } from "react-icons/lu";
|
||||
import { MdOutlinePreview } from "react-icons/md"
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import Logo from "./Logo";
|
||||
@ -25,6 +26,12 @@ const navbarLinks = [
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: MdOutlinePreview,
|
||||
title: "Review",
|
||||
url: "/review",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: LuFileUp,
|
||||
title: "Export",
|
||||
url: "/export",
|
||||
|
||||
115
web-new/src/components/card/ReviewCard.tsx
Normal file
115
web-new/src/components/card/ReviewCard.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import useSWR from "swr";
|
||||
import PreviewPlayer from "../player/PreviewPlayer";
|
||||
import { Card } from "../ui/card";
|
||||
import Heading from "../ui/heading";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { LuCircle, LuClock, LuPlay, LuPlayCircle, LuTruck } from "react-icons/lu";
|
||||
import { IoMdExit } from "react-icons/io"
|
||||
import { MdFaceUnlock, MdOutlineLocationOn, MdOutlinePictureInPictureAlt } from "react-icons/md";
|
||||
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
|
||||
type ReviewCardProps = {
|
||||
timeline: Card,
|
||||
allPreviews?: Preview[],
|
||||
}
|
||||
|
||||
export default function ReviewCard({ allPreviews, timeline }: ReviewCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="my-2 mr-2 bg-secondary">
|
||||
<PreviewPlayer
|
||||
camera={timeline.camera}
|
||||
allPreviews={allPreviews || []}
|
||||
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||
mode="thumbnail"
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="text-sm flex">
|
||||
<LuClock className="h-5 w-5 mr-2 inline" />
|
||||
{formatUnixTimestampToDateTime(timeline.time, { strftime_fmt: config.ui.time_format == '24hour' ? '%H:%M:%S' : '%I:%M:%S' })}
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1">
|
||||
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
|
||||
{timeline.camera.replaceAll('_', ' ')}
|
||||
</div>
|
||||
<Heading as="h4" className="my-2">
|
||||
Activity:
|
||||
</Heading>
|
||||
{Object.entries(timeline.entries).map(([_, entry]) => {
|
||||
return (
|
||||
<div key={entry.timestamp} className="flex text-xs my-1">
|
||||
{getTimelineIcon(entry)}
|
||||
{getTimelineItemDescription(entry)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimelineIcon(timelineItem: Timeline) {
|
||||
switch (timelineItem.class_type) {
|
||||
case 'visible':
|
||||
return <LuPlay className="w-4 mr-1" />;
|
||||
case 'gone':
|
||||
return <IoMdExit className="w-4 mr-1" />;
|
||||
case 'active':
|
||||
return <LuPlayCircle className="w-4 mr-1" />;
|
||||
case 'stationary':
|
||||
return <LuCircle className="w-4 mr-1" />;
|
||||
case 'entered_zone':
|
||||
return <MdOutlineLocationOn className="w-4 mr-1" />;
|
||||
case 'attribute':
|
||||
switch (timelineItem.data.attribute) {
|
||||
case 'face':
|
||||
return <MdFaceUnlock className="w-4 mr-1" />;
|
||||
case 'license_plate':
|
||||
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
||||
default:
|
||||
return <LuTruck className="w-4 mr-1" />;
|
||||
}
|
||||
case 'sub_label':
|
||||
switch (timelineItem.data.label) {
|
||||
case 'person':
|
||||
return <MdFaceUnlock className="w-4 mr-1" />;
|
||||
case 'car':
|
||||
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTimelineItemDescription(timelineItem: Timeline) {
|
||||
const label = ((Array.isArray(timelineItem.data.sub_label) ? timelineItem.data.sub_label[0] : timelineItem.data.sub_label) || timelineItem.data.label).replaceAll('_', ' ');
|
||||
|
||||
switch (timelineItem.class_type) {
|
||||
case 'visible':
|
||||
return `${label} detected`;
|
||||
case 'entered_zone':
|
||||
return `${label} entered ${timelineItem.data.zones.join(' and ').replaceAll('_', ' ')}`;
|
||||
case 'active':
|
||||
return `${label} became active`;
|
||||
case 'stationary':
|
||||
return `${label} became stationary`;
|
||||
case 'attribute': {
|
||||
let title = '';
|
||||
if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') {
|
||||
title = `${timelineItem.data.attribute.replaceAll('_', ' ')} detected for ${label}`;
|
||||
} else {
|
||||
title = `${timelineItem.data.sub_label} recognized as ${timelineItem.data.attribute.replaceAll('_', ' ')}`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
case 'sub_label':
|
||||
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
|
||||
case 'gone':
|
||||
return `${label} left`;
|
||||
}
|
||||
}
|
||||
100
web-new/src/components/player/PreviewPlayer.tsx
Normal file
100
web-new/src/components/player/PreviewPlayer.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import VideoPlayer from "./VideoPlayer";
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import Player from "video.js/dist/types/player";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
camera: string,
|
||||
allPreviews: Preview[],
|
||||
startTs: number,
|
||||
mode: string,
|
||||
}
|
||||
|
||||
type Preview = {
|
||||
camera: string,
|
||||
src: string,
|
||||
type: string,
|
||||
start: number,
|
||||
end: number,
|
||||
}
|
||||
|
||||
export default function PreviewPlayer({ camera, allPreviews, startTs, mode }: PreviewPlayerProps) {
|
||||
const { data: config } = useSWR('config');
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const relevantPreview = useMemo(() => {
|
||||
return Object.values(allPreviews || []).find(
|
||||
(preview) => preview.camera == camera && preview.start < startTs && preview.end > startTs
|
||||
);
|
||||
}, [allPreviews, camera, startTs]);
|
||||
|
||||
const onHover = useCallback((isHovered: Boolean) => {
|
||||
if (!relevantPreview || !playerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
playerRef.current.play();
|
||||
} else {
|
||||
playerRef.current.pause();
|
||||
playerRef.current.currentTime(startTs - relevantPreview.start);
|
||||
}
|
||||
},
|
||||
[relevantPreview, startTs]
|
||||
);
|
||||
|
||||
if (!relevantPreview) {
|
||||
return (
|
||||
<img className={getThumbWidth(camera, config)} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={mode == 'thumbnail' ? getThumbWidth(camera, config) : ''}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
>
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: 'auto',
|
||||
autoplay: false,
|
||||
controls: false,
|
||||
muted: true,
|
||||
loadingSpinner: false,
|
||||
sources: [
|
||||
{
|
||||
src: `${relevantPreview.src}`,
|
||||
type: 'video/mp4',
|
||||
},
|
||||
],
|
||||
}}
|
||||
seekOptions={{}}
|
||||
onReady={(player) => {
|
||||
playerRef.current = player;
|
||||
player.playbackRate(8);
|
||||
player.currentTime(startTs - relevantPreview.start);
|
||||
}}
|
||||
onDispose={() => {
|
||||
playerRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getThumbWidth(camera: string, config: FrigateConfig) {
|
||||
const detect = config.cameras[camera].detect;
|
||||
if (detect.width / detect.height > 2) {
|
||||
return 'w-[320px]';
|
||||
}
|
||||
|
||||
if (detect.width / detect.height < 1.4) {
|
||||
return 'w-[200px]';
|
||||
}
|
||||
|
||||
return 'w-[240px]';
|
||||
}
|
||||
74
web-new/src/components/player/VideoPlayer.tsx
Normal file
74
web-new/src/components/player/VideoPlayer.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, ReactNode } from "react";
|
||||
import videojs from 'video.js';
|
||||
import 'videojs-playlist';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import Player from "video.js/dist/types/player";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
children?: ReactNode[],
|
||||
options?: {
|
||||
[key: string]: any
|
||||
},
|
||||
seekOptions?: {
|
||||
forward?: number,
|
||||
backward?: number,
|
||||
},
|
||||
onReady?: (player: Player) => void,
|
||||
onDispose?: () => void,
|
||||
}
|
||||
|
||||
export default function VideoPlayer({ children, options, seekOptions = {forward:30, backward: 10}, onReady = (_) => {}, onDispose = () => {} }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultOptions = {
|
||||
controls: true,
|
||||
controlBar: {
|
||||
skipButtons: seekOptions,
|
||||
},
|
||||
playbackRates: [0.5, 1, 2, 4, 8],
|
||||
fluid: true,
|
||||
};
|
||||
|
||||
|
||||
if (!videojs.browser.IS_FIREFOX) {
|
||||
defaultOptions.playbackRates.push(16);
|
||||
}
|
||||
|
||||
// Make sure Video.js player is only initialized once
|
||||
if (!playerRef.current) {
|
||||
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
|
||||
const videoElement = document.createElement("video-js");
|
||||
|
||||
videoElement.classList.add('small-player');
|
||||
videoElement.classList.add('video-js');
|
||||
videoElement.classList.add('vjs-default-skin');
|
||||
videoRef.current.appendChild(videoElement);
|
||||
|
||||
const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => {
|
||||
onReady && onReady(player);
|
||||
});
|
||||
}
|
||||
}, [options, videoRef]);
|
||||
|
||||
// Dispose the Video.js player when the functional component unmounts
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
|
||||
return () => {
|
||||
if (player && !player.isDisposed()) {
|
||||
player.dispose();
|
||||
playerRef.current = null;
|
||||
onDispose();
|
||||
}
|
||||
};
|
||||
}, [playerRef]);
|
||||
|
||||
return (
|
||||
<div data-vjs-player>
|
||||
<div ref={videoRef} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
web-new/src/components/ui/scroll-area.tsx
Normal file
46
web-new/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
137
web-new/src/pages/Review.tsx
Normal file
137
web-new/src/pages/Review.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||
import ReviewCard from "@/components/card/ReviewCard";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
|
||||
export function Review() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
|
||||
const start = useMemo(() => new Date().getTime() / 1000, []);
|
||||
const { data: hourlyTimeline } = useSWR<HourlyTimeline>(['timeline/hourly', { timezone }]);
|
||||
const { data: allPreviews } = useSWR<Preview[]>(`preview/all/start/${Object.keys(hourlyTimeline || [0])[0]}/end/${start}`);
|
||||
|
||||
// detail levels can be normal, extra, full
|
||||
const [detailLevel, setDetailLevel] = useState('normal');
|
||||
|
||||
const timelineCards: CardsData | never[] = useMemo(() => {
|
||||
if (!hourlyTimeline) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cards: CardsData = {};
|
||||
Object.keys(hourlyTimeline)
|
||||
.reverse()
|
||||
.forEach((hour) => {
|
||||
const day = new Date(parseInt(hour) * 1000);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const dayKey = (day.getTime() / 1000).toString();
|
||||
const source_to_types: {[key: string]: string[]} = {};
|
||||
Object.values(hourlyTimeline[hour]).forEach((i) => {
|
||||
const time = new Date(i.timestamp * 1000);
|
||||
time.setSeconds(0);
|
||||
time.setMilliseconds(0);
|
||||
const key = `${i.source_id}-${time.getMinutes()}`;
|
||||
if (key in source_to_types) {
|
||||
source_to_types[key].push(i.class_type);
|
||||
} else {
|
||||
source_to_types[key] = [i.class_type];
|
||||
}
|
||||
});
|
||||
|
||||
if (!Object.keys(cards).includes(dayKey)) {
|
||||
cards[dayKey] = {};
|
||||
}
|
||||
cards[dayKey][hour] = {};
|
||||
Object.values(hourlyTimeline[hour]).forEach((i) => {
|
||||
const time = new Date(i.timestamp * 1000);
|
||||
time.setSeconds(0);
|
||||
time.setMilliseconds(0);
|
||||
const key = `${i.camera}-${time.getMinutes()}`;
|
||||
|
||||
// detail level for saving items
|
||||
// detail level determines which timeline items for each moment is returned
|
||||
// values can be normal, extra, or full
|
||||
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
|
||||
// extra: return all items except attribute / gone / visible unless that is the only item
|
||||
// full: return all items
|
||||
|
||||
let add = true;
|
||||
if (detailLevel == 'normal') {
|
||||
if (
|
||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length > 1 &&
|
||||
['active', 'attribute', 'gone', 'stationary', 'visible'].includes(i.class_type)
|
||||
) {
|
||||
add = false;
|
||||
}
|
||||
} else if (detailLevel == 'extra') {
|
||||
if (
|
||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length > 1 &&
|
||||
i.class_type in ['attribute', 'gone', 'visible']
|
||||
) {
|
||||
add = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (add) {
|
||||
if (key in cards[dayKey][hour]) {
|
||||
cards[dayKey][hour][key].entries.push(i);
|
||||
} else {
|
||||
cards[dayKey][hour][key] = {
|
||||
camera: i.camera,
|
||||
time: time.getTime() / 1000,
|
||||
entries: [i],
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cards;
|
||||
}, [detailLevel, hourlyTimeline]);
|
||||
|
||||
if (!config || !timelineCards) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Review</Heading>
|
||||
<div className="text-xs mb-4">Dates and times are based on the timezone {timezone}</div>
|
||||
|
||||
<div>
|
||||
{Object.keys(timelineCards).reverse().map((day) => {
|
||||
return (
|
||||
<div key={day}>
|
||||
<Heading as="h3">
|
||||
{formatUnixTimestampToDateTime(parseInt(day), { strftime_fmt: '%A %b %d' })}
|
||||
</Heading>
|
||||
{Object.entries(timelineCards[day]).map(([hour, timelineHour]) => {
|
||||
return (
|
||||
<div key={hour}>
|
||||
<Heading as="h4">
|
||||
{formatUnixTimestampToDateTime(parseInt(hour), { strftime_fmt: '%I:00' })}
|
||||
</Heading>
|
||||
<ScrollArea>
|
||||
<div className="flex">
|
||||
{Object.entries(timelineHour).map(([key, timeline]) => {
|
||||
return <ReviewCard key={key} timeline={timeline} allPreviews={allPreviews} />
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar className="m-2" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Review
|
||||
@ -1,3 +1,13 @@
|
||||
interface UiConfig {
|
||||
timezone: string;
|
||||
time_format: 'browser' | '12hour' | '24hour';
|
||||
date_style: 'full' | 'long' | 'medium' | 'short';
|
||||
time_style: 'full' | 'long' | 'medium' | 'short';
|
||||
strftime_fmt: string;
|
||||
live_mode: string;
|
||||
use_experimental: boolean;
|
||||
}
|
||||
|
||||
export interface FrigateConfig {
|
||||
audio: {
|
||||
enabled: boolean;
|
||||
@ -389,14 +399,6 @@ export interface FrigateConfig {
|
||||
thickness: number;
|
||||
};
|
||||
|
||||
ui: {
|
||||
date_style: string;
|
||||
live_mode: string;
|
||||
strftime_fmt: string | null;
|
||||
time_format: string;
|
||||
time_style: string;
|
||||
timezone: string | null;
|
||||
use_experimental: boolean;
|
||||
};
|
||||
ui: UiConfig;
|
||||
|
||||
}
|
||||
36
web-new/src/types/review.ts
Normal file
36
web-new/src/types/review.ts
Normal file
@ -0,0 +1,36 @@
|
||||
type CardsData = {
|
||||
[key: string]: {
|
||||
[key: string]: {
|
||||
[key: string]: Card
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Card = {
|
||||
camera: string,
|
||||
time: number,
|
||||
entries: Timeline[],
|
||||
}
|
||||
|
||||
type Preview = {
|
||||
camera: string,
|
||||
src: string,
|
||||
type: string,
|
||||
start: number,
|
||||
end: number,
|
||||
}
|
||||
|
||||
type Timeline = {
|
||||
camera: string,
|
||||
timestamp: number,
|
||||
data: {
|
||||
[key: string]: any
|
||||
},
|
||||
class_type: string,
|
||||
source_id: string,
|
||||
source: string,
|
||||
}
|
||||
|
||||
type HourlyTimeline = {
|
||||
[key: string]: Timeline[];
|
||||
}
|
||||
229
web-new/src/utils/dateUtil.ts
Normal file
229
web-new/src/utils/dateUtil.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import strftime from 'strftime';
|
||||
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
|
||||
import { FrigateConfig, UiConfig } from "@/types/frigateConfig";
|
||||
export const longToDate = (long: number): Date => new Date(long * 1000);
|
||||
export const epochToLong = (date: number): number => date / 1000;
|
||||
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
||||
|
||||
const getDateTimeYesterday = (dateTime: Date): Date => {
|
||||
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
||||
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
||||
};
|
||||
|
||||
const getNowYesterday = (): Date => {
|
||||
return getDateTimeYesterday(new Date());
|
||||
};
|
||||
|
||||
export const getNowYesterdayInLong = (): number => {
|
||||
return dateToLong(getNowYesterday());
|
||||
};
|
||||
|
||||
/**
|
||||
* This function takes in a Unix timestamp, configuration options for date/time display, and an optional strftime format string,
|
||||
* and returns a formatted date/time string.
|
||||
*
|
||||
* If the Unix timestamp is not provided, it returns "Invalid time".
|
||||
*
|
||||
* The configuration options determine how the date and time are formatted.
|
||||
* The `timezone` option allows you to specify a specific timezone for the output, otherwise the user's browser timezone will be used.
|
||||
* The `use12hour` option allows you to display time in a 12-hour format if true, and 24-hour format if false.
|
||||
* The `dateStyle` and `timeStyle` options allow you to specify pre-defined formats for displaying the date and time.
|
||||
* The `strftime_fmt` option allows you to specify a custom format using the strftime syntax.
|
||||
*
|
||||
* If both `strftime_fmt` and `dateStyle`/`timeStyle` are provided, `strftime_fmt` takes precedence.
|
||||
*
|
||||
* @param unixTimestamp The Unix timestamp to format
|
||||
* @param config An object containing the configuration options for date/time display
|
||||
* @returns The formatted date/time string, or "Invalid time" if the Unix timestamp is not provided or invalid.
|
||||
*/
|
||||
|
||||
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||
const formatMap: {
|
||||
[k: string]: {
|
||||
date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' };
|
||||
time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' };
|
||||
};
|
||||
} = {
|
||||
full: {
|
||||
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
||||
},
|
||||
long: {
|
||||
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
||||
},
|
||||
medium: {
|
||||
date: { year: 'numeric', month: 'short', day: 'numeric' },
|
||||
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
|
||||
},
|
||||
short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } },
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to get the system's time zone using Intl.DateTimeFormat. If that fails (for instance, in environments
|
||||
* where Intl is not fully supported), it calculates the UTC offset for the current system time and returns
|
||||
* it in a string format.
|
||||
*
|
||||
* Keeping the Intl.DateTimeFormat for now, as this is the recommended way to get the time zone.
|
||||
* https://stackoverflow.com/a/34602679
|
||||
*
|
||||
* Intl.DateTimeFormat function as of April 2023, works in 95.03% of the browsers used globally
|
||||
* https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_resolvedoptions_computed_timezone
|
||||
*
|
||||
* @returns {string} The resolved time zone or a calculated UTC offset.
|
||||
* The returned string will either be a named time zone (e.g., "America/Los_Angeles"), or it will follow
|
||||
* the format "UTC±HH:MM".
|
||||
*/
|
||||
const getResolvedTimeZone = () => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch (error) {
|
||||
const offsetMinutes = new Date().getTimezoneOffset();
|
||||
return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}:${Math.abs(offsetMinutes % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp into a human-readable date/time string.
|
||||
*
|
||||
* The format of the output string is determined by a configuration object passed as an argument, which
|
||||
* may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time.
|
||||
* If these options are not specified, the function will use system defaults or sensible fallbacks.
|
||||
*
|
||||
* The function is robust to environments where the Intl API is not fully supported, and includes a
|
||||
* fallback method to create a formatted date/time string in such cases.
|
||||
*
|
||||
* @param {number} unixTimestamp - The Unix timestamp to be formatted.
|
||||
* @param {DateTimeStyle} config - User configuration object.
|
||||
* @returns {string} A formatted date/time string.
|
||||
*
|
||||
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
|
||||
*/
|
||||
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => {
|
||||
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
|
||||
const locale = window.navigator?.language || 'en-us';
|
||||
if (isNaN(unixTimestamp)) {
|
||||
return 'Invalid time';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(unixTimestamp * 1000);
|
||||
const resolvedTimeZone = getResolvedTimeZone();
|
||||
|
||||
// use strftime_fmt if defined in config
|
||||
if (strftime_fmt) {
|
||||
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
|
||||
const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale);
|
||||
return strftime_locale(strftime_fmt, date);
|
||||
}
|
||||
|
||||
// DateTime format options
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
dateStyle: date_style,
|
||||
timeStyle: time_style,
|
||||
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
|
||||
};
|
||||
|
||||
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
|
||||
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
|
||||
if (timezone || !isUTCOffsetFormat) {
|
||||
options.timeZone = timezone || resolvedTimeZone;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locale, options);
|
||||
const formattedDateTime = formatter.format(date);
|
||||
|
||||
// Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported.
|
||||
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
|
||||
|
||||
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
||||
if (!containsTime) {
|
||||
const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 };
|
||||
const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 };
|
||||
|
||||
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
||||
}
|
||||
|
||||
return formattedDateTime;
|
||||
} catch (error) {
|
||||
return 'Invalid time';
|
||||
}
|
||||
};
|
||||
|
||||
interface DurationToken {
|
||||
xSeconds: string;
|
||||
xMinutes: string;
|
||||
xHours: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes in start and end time in unix timestamp,
|
||||
* and returns the duration between start and end time in hours, minutes and seconds.
|
||||
* If end time is not provided, it returns 'In Progress'
|
||||
* @param start_time: number - Unix timestamp for start time
|
||||
* @param end_time: number|null - Unix timestamp for end time
|
||||
* @returns string - duration or 'In Progress' if end time is not provided
|
||||
*/
|
||||
export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => {
|
||||
if (isNaN(start_time)) {
|
||||
return 'Invalid start time';
|
||||
}
|
||||
let duration = 'In Progress';
|
||||
if (end_time !== null) {
|
||||
if (isNaN(end_time)) {
|
||||
return 'Invalid end time';
|
||||
}
|
||||
const start = fromUnixTime(start_time);
|
||||
const end = fromUnixTime(end_time);
|
||||
const formatDistanceLocale: DurationToken = {
|
||||
xSeconds: '{{count}}s',
|
||||
xMinutes: '{{count}}m',
|
||||
xHours: '{{count}}h',
|
||||
};
|
||||
const shortEnLocale = {
|
||||
formatDistance: (token: keyof DurationToken, count: number) =>
|
||||
formatDistanceLocale[token].replace('{{count}}', count.toString()),
|
||||
};
|
||||
duration = formatDuration(intervalToDuration({ start, end }), {
|
||||
format: ['hours', 'minutes', 'seconds'],
|
||||
locale: shortEnLocale,
|
||||
});
|
||||
}
|
||||
return duration;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
|
||||
* returns the offset of that timezone from UTC in minutes.
|
||||
* @param timezone string representation of the timezone the user is requesting
|
||||
* @returns number of minutes offset from UTC
|
||||
*/
|
||||
const getUTCOffset = (date: Date, timezone: string): number => {
|
||||
// If timezone is in UTC±HH:MM format, parse it to get offset
|
||||
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
|
||||
if (utcOffsetMatch) {
|
||||
const hours = parseInt(utcOffsetMatch[2], 10);
|
||||
const minutes = parseInt(utcOffsetMatch[3], 10);
|
||||
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
// Otherwise, calculate offset using provided timezone
|
||||
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
|
||||
// locale of en-CA is required for proper locale format
|
||||
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
|
||||
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
|
||||
let target = new Date(`${iso}Z`);
|
||||
|
||||
// safari doesn't like the default format
|
||||
if (isNaN(target.getTime())) {
|
||||
iso = iso.replace("T", " ").split(".")[0];
|
||||
target = new Date(`${iso}+000`);
|
||||
}
|
||||
|
||||
return (target.getTime() - utcDate.getTime()) / 60 / 1000;
|
||||
};
|
||||
@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2021.String"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
|
||||
@ -12,21 +12,24 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
target: 'http://192.168.50.106:5000',
|
||||
ws: true,
|
||||
},
|
||||
'/vod': {
|
||||
target: 'http://localhost:5000'
|
||||
target: 'http://192.168.50.106:5000'
|
||||
},
|
||||
'/clips': {
|
||||
target: 'http://192.168.50.106:5000'
|
||||
},
|
||||
'/exports': {
|
||||
target: 'http://localhost:5000'
|
||||
target: 'http://192.168.50.106:5000'
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:5000',
|
||||
target: 'ws://192.168.50.106:5000',
|
||||
ws: true,
|
||||
},
|
||||
'/live': {
|
||||
target: 'ws://localhost:5000',
|
||||
target: 'ws://192.168.50.106:5000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
|
||||
83
web/src/components/PreviewPlayer.jsx
Normal file
83
web/src/components/PreviewPlayer.jsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { h } from 'preact';
|
||||
import useSWR from 'swr';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { useMemo } from 'react';
|
||||
import { useApiHost } from '../api';
|
||||
|
||||
export default function PreviewPlayer({ camera, allPreviews, startTs, mode }) {
|
||||
const { data: config } = useSWR('config');
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const relevantPreview = useMemo(() => {
|
||||
return Object.values(allPreviews || []).find(
|
||||
(preview) => preview.camera == camera && preview.start < startTs && preview.end > startTs
|
||||
);
|
||||
}, [allPreviews, camera, startTs]);
|
||||
|
||||
const onHover = useCallback(
|
||||
(isHovered) => {
|
||||
if (isHovered) {
|
||||
this.player.play();
|
||||
} else {
|
||||
this.player.pause();
|
||||
this.player.currentTime(startTs - relevantPreview.start);
|
||||
}
|
||||
},
|
||||
[relevantPreview, startTs]
|
||||
);
|
||||
|
||||
if (!relevantPreview) {
|
||||
return (
|
||||
<img className={getThumbWidth(camera, config)} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={mode == 'thumbnail' ? getThumbWidth(camera, config) : ''}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
>
|
||||
<VideoPlayer
|
||||
tag={startTs}
|
||||
options={{
|
||||
preload: 'auto',
|
||||
autoplay: false,
|
||||
controls: false,
|
||||
muted: true,
|
||||
loadingSpinner: false,
|
||||
sources: [
|
||||
{
|
||||
src: `${relevantPreview.src}`,
|
||||
type: 'video/mp4',
|
||||
},
|
||||
],
|
||||
}}
|
||||
seekOptions={{}}
|
||||
onReady={(player) => {
|
||||
this.player = player;
|
||||
this.player.playbackRate(8);
|
||||
this.player.currentTime(startTs - relevantPreview.start);
|
||||
}}
|
||||
onDispose={() => {
|
||||
this.player = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getThumbWidth(camera, config) {
|
||||
const detect = config.cameras[camera].detect;
|
||||
if (detect.width / detect.height > 2) {
|
||||
return 'w-[320px]';
|
||||
}
|
||||
|
||||
if (detect.width / detect.height < 1.4) {
|
||||
return 'w-[200px]';
|
||||
}
|
||||
|
||||
return 'w-[240px]';
|
||||
}
|
||||
222
web/src/routes/Review.jsx
Normal file
222
web/src/routes/Review.jsx
Normal file
@ -0,0 +1,222 @@
|
||||
import Heading from '../components/Heading';
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import useSWR from 'swr';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import PreviewPlayer from '../components/PreviewPlayer';
|
||||
import { formatUnixTimestampToDateTime } from '../utils/dateUtil';
|
||||
import { Clock } from '../icons/Clock';
|
||||
import { Camera } from '../icons/Camera';
|
||||
import ActiveObjectIcon from '../icons/ActiveObject';
|
||||
import PlayIcon from '../icons/Play';
|
||||
import ExitIcon from '../icons/Exit';
|
||||
import StationaryObjectIcon from '../icons/StationaryObject';
|
||||
import FaceIcon from '../icons/Face';
|
||||
import LicensePlateIcon from '../icons/LicensePlate';
|
||||
import DeliveryTruckIcon from '../icons/DeliveryTruck';
|
||||
import ZoneIcon from '../icons/Zone';
|
||||
|
||||
export default function Export() {
|
||||
const { data: config } = useSWR('config');
|
||||
const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
|
||||
const { data: hourlyTimeline } = useSWR(['timeline/hourly', { timezone }]);
|
||||
const { data: allPreviews } = useSWR([
|
||||
`preview/all/start/${Object.keys(hourlyTimeline || [0])[0]}/end/${Object.keys(hourlyTimeline || [0]).slice(-1)[0]}`,
|
||||
]);
|
||||
|
||||
// detail levels can be normal, extra, full
|
||||
const [detailLevel, setDetailLevel] = useState('normal');
|
||||
|
||||
const timelineCards = useMemo(() => {
|
||||
if (!hourlyTimeline) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cards = {};
|
||||
Object.keys(hourlyTimeline)
|
||||
.reverse()
|
||||
.forEach((hour) => {
|
||||
const source_to_types = {};
|
||||
Object.values(hourlyTimeline[hour]).forEach((i) => {
|
||||
const time = new Date(i.timestamp * 1000);
|
||||
time.setSeconds(0);
|
||||
time.setMilliseconds(0);
|
||||
const key = `${i.source_id}-${time.getMinutes()}`;
|
||||
if (key in source_to_types) {
|
||||
source_to_types[key].push(i.class_type);
|
||||
} else {
|
||||
source_to_types[key] = [i.class_type];
|
||||
}
|
||||
});
|
||||
|
||||
cards[hour] = {};
|
||||
Object.values(hourlyTimeline[hour]).forEach((i) => {
|
||||
const time = new Date(i.timestamp * 1000);
|
||||
time.setSeconds(0);
|
||||
time.setMilliseconds(0);
|
||||
const key = `${i.camera}-${time.getMinutes()}`;
|
||||
|
||||
// detail level for saving items
|
||||
// detail level determines which timeline items for each moment is returned
|
||||
// values can be normal, extra, or full
|
||||
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
|
||||
// extra: return all items except attribute / gone / visible unless that is the only item
|
||||
// full: return all items
|
||||
|
||||
let add = true;
|
||||
if (detailLevel == 'normal') {
|
||||
if (
|
||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length > 1 &&
|
||||
['active', 'attribute', 'gone', 'stationary', 'visible'].includes(i.class_type)
|
||||
) {
|
||||
add = false;
|
||||
}
|
||||
} else if (detailLevel == 'extra') {
|
||||
if (
|
||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length > 1 &&
|
||||
i.class_type in ['attribute', 'gone', 'visible']
|
||||
) {
|
||||
add = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (add) {
|
||||
if (key in cards[hour]) {
|
||||
cards[hour][key].entries.push(i);
|
||||
} else {
|
||||
cards[hour][key] = {
|
||||
camera: i.camera,
|
||||
time: time.getTime() / 1000,
|
||||
entries: [i],
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cards;
|
||||
}, [detailLevel, hourlyTimeline]);
|
||||
|
||||
if (!timelineCards) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-2 px-4">
|
||||
<Heading>Review</Heading>
|
||||
<div className="text-xs">Dates and times are based on the timezone {timezone}</div>
|
||||
|
||||
<div>
|
||||
{Object.entries(timelineCards).map(([hour, timelineHour]) => {
|
||||
return (
|
||||
<div key={hour}>
|
||||
<Heading size="md">
|
||||
{formatUnixTimestampToDateTime(hour, {
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
})}
|
||||
</Heading>
|
||||
<div className="flex overflow-auto">
|
||||
{Object.entries(timelineHour).map(([key, timeline]) => {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow my-2 mr-2"
|
||||
>
|
||||
<PreviewPlayer
|
||||
camera={timeline.camera}
|
||||
allPreviews={allPreviews}
|
||||
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||
mode="thumbnail"
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="text-sm flex">
|
||||
<Clock className="h-5 w-5 mr-2 inline" />
|
||||
{formatUnixTimestampToDateTime(timeline.time, { ...config.ui })}
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1">
|
||||
<Camera className="h-5 w-5 mr-2 inline" />
|
||||
{timeline.camera.replaceAll('_', ' ')}
|
||||
</div>
|
||||
<Heading size="xs" className="my-2">
|
||||
Activity:
|
||||
</Heading>
|
||||
{Object.entries(timeline.entries).map(([_, entry]) => {
|
||||
return (
|
||||
<div key={entry.timestamp} className="flex text-xs my-1">
|
||||
{getTimelineIcon(entry)}
|
||||
{getTimelineItemDescription(config, entry)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimelineIcon(timelineItem) {
|
||||
switch (timelineItem.class_type) {
|
||||
case 'visible':
|
||||
return <PlayIcon className="w-4 mr-1" />;
|
||||
case 'gone':
|
||||
return <ExitIcon className="w-4 mr-1" />;
|
||||
case 'active':
|
||||
return <ActiveObjectIcon className="w-4 mr-1" />;
|
||||
case 'stationary':
|
||||
return <StationaryObjectIcon className="w-4 mr-1" />;
|
||||
case 'entered_zone':
|
||||
return <ZoneIcon className="w-4 mr-1" />;
|
||||
case 'attribute':
|
||||
switch (timelineItem.data.attribute) {
|
||||
case 'face':
|
||||
return <FaceIcon className="w-4 mr-1" />;
|
||||
case 'license_plate':
|
||||
return <LicensePlateIcon className="w-4 mr-1" />;
|
||||
default:
|
||||
return <DeliveryTruckIcon className="w-4 mr-1" />;
|
||||
}
|
||||
case 'sub_label':
|
||||
switch (timelineItem.data.label) {
|
||||
case 'person':
|
||||
return <FaceIcon className="w-4 mr-1" />;
|
||||
case 'car':
|
||||
return <LicensePlateIcon className="w-4 mr-1" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTimelineItemDescription(config, timelineItem) {
|
||||
const label = (timelineItem.data.sub_label || timelineItem.data.label).replaceAll('_', ' ');
|
||||
|
||||
switch (timelineItem.class_type) {
|
||||
case 'visible':
|
||||
return `${label} detected`;
|
||||
case 'entered_zone':
|
||||
return `${label} entered ${timelineItem.data.zones.join(' and ').replaceAll('_', ' ')}`;
|
||||
case 'active':
|
||||
return `${label} became active`;
|
||||
case 'stationary':
|
||||
return `${label} became stationary`;
|
||||
case 'attribute': {
|
||||
let title = '';
|
||||
if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') {
|
||||
title = `${timelineItem.data.attribute.replaceAll('_', ' ')} detected for ${label}`;
|
||||
} else {
|
||||
title = `${timelineItem.data.sub_label} recognized as ${timelineItem.data.attribute.replaceAll('_', ' ')}`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
case 'sub_label':
|
||||
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
|
||||
case 'gone':
|
||||
return `${label} left`;
|
||||
}
|
||||
}
|
||||
@ -19,16 +19,7 @@ export default defineConfig({
|
||||
},
|
||||
'/exports': {
|
||||
target: 'http://localhost:5000'
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:5000',
|
||||
ws: true,
|
||||
},
|
||||
'/live': {
|
||||
target: 'ws://localhost:5000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user