mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Preview improvements (#10384)
* Write preview frames as webp instead of jpg and ensure webp are cached in nginx * Support preview player that shows current hour images * Update to get preview player working * Use timestamp based recordings check instead of calendar * Start motion review from current time * Adjust layout * Use preview players for previews * remove vite * Cleanup * Fix up the layout
This commit is contained in:
parent
fa22f01f39
commit
1c5d6765a1
@ -210,7 +210,7 @@ http {
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location ~* /api/.*\.(jpg|jpeg|png)$ {
|
||||
location ~* /api/.*\.(jpg|jpeg|png|webp)$ {
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
|
@ -30,6 +30,7 @@ from frigate.const import (
|
||||
CLIPS_DIR,
|
||||
EXPORT_DIR,
|
||||
MAX_SEGMENT_DURATION,
|
||||
PREVIEW_FRAME_TYPE,
|
||||
RECORD_DIR,
|
||||
)
|
||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||
@ -1173,8 +1174,8 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
|
||||
# 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}.jpg"
|
||||
end_file = f"{file_start}-{end_ts}.jpg"
|
||||
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)):
|
||||
@ -1258,8 +1259,9 @@ def review_preview(id: str):
|
||||
|
||||
|
||||
@MediaBp.route("/preview/<file_name>/thumbnail.jpg")
|
||||
@MediaBp.route("/preview/<file_name>/thumbnail.webp")
|
||||
def preview_thumbnail(file_name: str):
|
||||
"""Get a thumbnail from the cached preview jpgs."""
|
||||
"""Get a thumbnail from the cached preview frames."""
|
||||
safe_file_name_current = secure_filename(file_name)
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
|
@ -11,7 +11,7 @@ from flask import (
|
||||
make_response,
|
||||
)
|
||||
|
||||
from frigate.const import CACHE_DIR
|
||||
from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||
from frigate.models import Previews
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -97,8 +97,8 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
|
||||
"""Get list of cached preview frames"""
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
file_start = f"preview_{camera_name}"
|
||||
start_file = f"{file_start}-{start_ts}.jpg"
|
||||
end_file = f"{file_start}-{end_ts}.jpg"
|
||||
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)):
|
||||
|
@ -57,6 +57,10 @@ DRIVER_AMD = "radeonsi"
|
||||
DRIVER_INTEL_i965 = "i965"
|
||||
DRIVER_INTEL_iHD = "iHD"
|
||||
|
||||
# Preview Values
|
||||
|
||||
PREVIEW_FRAME_TYPE = "webp"
|
||||
|
||||
# Record Values
|
||||
|
||||
CACHE_SEGMENT_FORMAT = "%Y%m%d%H%M%S%z"
|
||||
|
@ -12,7 +12,7 @@ import numpy as np
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import CameraConfig, RecordQualityEnum
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE
|
||||
from frigate.ffmpeg_presets import (
|
||||
FPS_VFR_PARAM,
|
||||
EncodeTypeEnum,
|
||||
@ -42,12 +42,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
|
||||
"""Get the image name in cache."""
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.jpg",
|
||||
f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.{PREVIEW_FRAME_TYPE}",
|
||||
)
|
||||
|
||||
|
||||
class FFMpegConverter(threading.Thread):
|
||||
"""Convert a list of jpg frames into a vfr mp4."""
|
||||
"""Convert a list of still frames into a vfr mp4."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -176,7 +176,7 @@ class PreviewRecorder:
|
||||
)
|
||||
|
||||
file_start = f"preview_{config.name}"
|
||||
start_file = f"{file_start}-{start_ts}.jpg"
|
||||
start_file = f"{file_start}-{start_ts}.webp"
|
||||
|
||||
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
||||
if not file.startswith(file_start):
|
||||
@ -186,7 +186,7 @@ class PreviewRecorder:
|
||||
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
|
||||
continue
|
||||
|
||||
ts = float(file.split("-")[1][:-4])
|
||||
ts = float(file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)])
|
||||
|
||||
if self.start_time == 0:
|
||||
self.start_time = ts
|
||||
@ -242,12 +242,11 @@ class PreviewRecorder:
|
||||
small_frame,
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
_, jpg = cv2.imencode(".jpg", small_frame)
|
||||
with open(
|
||||
cv2.imwrite(
|
||||
get_cache_image_name(self.config.name, frame_time),
|
||||
"wb",
|
||||
) as j:
|
||||
j.write(jpg.tobytes())
|
||||
small_frame,
|
||||
[int(cv2.IMWRITE_WEBP_QUALITY), 80],
|
||||
)
|
||||
|
||||
def write_data(
|
||||
self,
|
||||
|
@ -9,9 +9,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { Recording } from "@/types/record";
|
||||
import { Preview } from "@/types/preview";
|
||||
import { DynamicPlayback } from "@/types/playback";
|
||||
import PreviewVideoPlayer, {
|
||||
PreviewVideoController,
|
||||
} from "./PreviewVideoPlayer";
|
||||
import PreviewPlayer, { PreviewController } from "./PreviewPlayer";
|
||||
|
||||
type PlayerMode = "playback" | "scrubbing";
|
||||
|
||||
@ -40,11 +38,6 @@ export default function DynamicVideoPlayer({
|
||||
}: DynamicVideoPlayerProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timezone = useMemo(
|
||||
() =>
|
||||
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
[config],
|
||||
);
|
||||
|
||||
// playback behavior
|
||||
const wideVideo = useMemo(() => {
|
||||
@ -63,7 +56,7 @@ export default function DynamicVideoPlayer({
|
||||
|
||||
const [playerRef, setPlayerRef] = useState<Player | null>(null);
|
||||
const [previewController, setPreviewController] =
|
||||
useState<PreviewVideoController | null>(null);
|
||||
useState<PreviewController | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(previewOnly);
|
||||
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
||||
undefined,
|
||||
@ -154,14 +147,8 @@ export default function DynamicVideoPlayer({
|
||||
// initial state
|
||||
|
||||
const initialPlaybackSource = useMemo(() => {
|
||||
const date = new Date(timeRange.start * 1000);
|
||||
return {
|
||||
src: `${apiHost}vod/${date.getFullYear()}-${
|
||||
date.getMonth() + 1
|
||||
}/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll(
|
||||
"/",
|
||||
",",
|
||||
)}/master.m3u8`,
|
||||
src: `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
|
||||
type: "application/vnd.apple.mpegurl",
|
||||
};
|
||||
// we only want to calculate this once
|
||||
@ -224,13 +211,7 @@ export default function DynamicVideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(timeRange.start * 1000);
|
||||
const playbackUri = `${apiHost}vod/${date.getFullYear()}-${
|
||||
date.getMonth() + 1
|
||||
}/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll(
|
||||
"/",
|
||||
",",
|
||||
)}/master.m3u8`;
|
||||
const playbackUri = `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`;
|
||||
|
||||
controller.newPlayback({
|
||||
recordings: recordings ?? [],
|
||||
@ -280,7 +261,7 @@ export default function DynamicVideoPlayer({
|
||||
)}
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
<PreviewVideoPlayer
|
||||
<PreviewPlayer
|
||||
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
|
||||
camera={camera}
|
||||
timeRange={timeRange}
|
||||
@ -298,7 +279,7 @@ export class DynamicVideoController {
|
||||
// main state
|
||||
public camera = "";
|
||||
private playerController: Player;
|
||||
private previewController: PreviewVideoController;
|
||||
private previewController: PreviewController;
|
||||
private setScrubbing: (isScrubbing: boolean) => void;
|
||||
private setFocusedItem: (timeline: Timeline) => void;
|
||||
private playerMode: PlayerMode = "playback";
|
||||
@ -315,7 +296,7 @@ export class DynamicVideoController {
|
||||
constructor(
|
||||
camera: string,
|
||||
playerController: Player,
|
||||
previewController: PreviewVideoController,
|
||||
previewController: PreviewController,
|
||||
annotationOffset: number,
|
||||
defaultMode: PlayerMode,
|
||||
setScrubbing: (isScrubbing: boolean) => void,
|
||||
|
458
web/src/components/player/PreviewPlayer.tsx
Normal file
458
web/src/components/player/PreviewPlayer.tsx
Normal file
@ -0,0 +1,458 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Preview } from "@/types/preview";
|
||||
import { PreviewPlayback } from "@/types/playback";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
cameraPreviews: Preview[];
|
||||
startTime?: number;
|
||||
onControllerReady: (controller: PreviewController) => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export default function PreviewPlayer({
|
||||
className,
|
||||
camera,
|
||||
timeRange,
|
||||
cameraPreviews,
|
||||
startTime,
|
||||
onControllerReady,
|
||||
onClick,
|
||||
}: PreviewPlayerProps) {
|
||||
if (isCurrentHour(timeRange.end)) {
|
||||
return (
|
||||
<PreviewFramesPlayer
|
||||
className={className}
|
||||
camera={camera}
|
||||
timeRange={timeRange}
|
||||
startTime={startTime}
|
||||
onControllerReady={onControllerReady}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewVideoPlayer
|
||||
className={className}
|
||||
camera={camera}
|
||||
timeRange={timeRange}
|
||||
cameraPreviews={cameraPreviews}
|
||||
startTime={startTime}
|
||||
onControllerReady={onControllerReady}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export abstract class PreviewController {
|
||||
public camera = "";
|
||||
|
||||
constructor(camera: string) {
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
abstract scrubToTimestamp(time: number): boolean;
|
||||
|
||||
abstract finishedSeeking(): void;
|
||||
|
||||
abstract setNewPreviewStartTime(time: number): void;
|
||||
}
|
||||
|
||||
type PreviewVideoPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
cameraPreviews: Preview[];
|
||||
startTime?: number;
|
||||
onControllerReady: (controller: PreviewVideoController) => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
function PreviewVideoPlayer({
|
||||
className,
|
||||
camera,
|
||||
timeRange,
|
||||
cameraPreviews,
|
||||
startTime,
|
||||
onControllerReady,
|
||||
onClick,
|
||||
}: PreviewVideoPlayerProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// controlling playback
|
||||
|
||||
const previewRef = useRef<HTMLVideoElement | null>(null);
|
||||
const controller = useMemo(() => {
|
||||
if (!config || !previewRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new PreviewVideoController(camera, previewRef);
|
||||
// we only care when preview is ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [camera, config, previewRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller) {
|
||||
onControllerReady(controller);
|
||||
}
|
||||
// we only want to fire once when players are ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller]);
|
||||
|
||||
// initial state
|
||||
|
||||
const initialPreview = useMemo(() => {
|
||||
return cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.start &&
|
||||
Math.floor(preview.end) <= timeRange.end,
|
||||
);
|
||||
|
||||
// we only want to calculate this once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [currentPreview, setCurrentPreview] = useState(initialPreview);
|
||||
|
||||
const onPreviewSeeked = useCallback(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.finishedSeeking();
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.start &&
|
||||
Math.floor(preview.end) <= timeRange.end,
|
||||
);
|
||||
setCurrentPreview(preview);
|
||||
|
||||
controller.newPlayback({
|
||||
preview,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
// we only want this to change when recordings update
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPreview || !previewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewRef.current.load();
|
||||
}, [currentPreview, previewRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<video
|
||||
ref={previewRef}
|
||||
className={`size-full rounded-2xl bg-black`}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
onSeeked={onPreviewSeeked}
|
||||
onLoadedData={() => {
|
||||
if (controller) {
|
||||
controller.previewReady();
|
||||
} else {
|
||||
previewRef.current?.pause();
|
||||
}
|
||||
|
||||
if (previewRef.current && startTime && currentPreview) {
|
||||
previewRef.current.currentTime = startTime - currentPreview.start;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentPreview != undefined && (
|
||||
<source src={currentPreview.src} type={currentPreview.type} />
|
||||
)}
|
||||
</video>
|
||||
{cameraPreviews && !currentPreview && (
|
||||
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center">
|
||||
No Preview Found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class PreviewVideoController extends PreviewController {
|
||||
// main state
|
||||
private previewRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
private timeRange: { start: number; end: number } | undefined = undefined;
|
||||
|
||||
// preview
|
||||
private preview: Preview | undefined = undefined;
|
||||
private timeToSeek: number | undefined = undefined;
|
||||
private seeking = false;
|
||||
|
||||
constructor(
|
||||
camera: string,
|
||||
previewRef: MutableRefObject<HTMLVideoElement | null>,
|
||||
) {
|
||||
super(camera);
|
||||
this.previewRef = previewRef;
|
||||
}
|
||||
|
||||
newPlayback(newPlayback: PreviewPlayback) {
|
||||
this.preview = newPlayback.preview;
|
||||
this.seeking = false;
|
||||
|
||||
this.timeRange = newPlayback.timeRange;
|
||||
}
|
||||
|
||||
override scrubToTimestamp(time: number): boolean {
|
||||
if (!this.preview || !this.timeRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (time < this.preview.start || time > this.preview.end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.seeking) {
|
||||
this.timeToSeek = time;
|
||||
} else {
|
||||
if (this.previewRef.current) {
|
||||
this.previewRef.current.currentTime = Math.max(
|
||||
0,
|
||||
time - this.preview.start,
|
||||
);
|
||||
this.seeking = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
override finishedSeeking() {
|
||||
if (!this.previewRef.current || !this.preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.timeToSeek &&
|
||||
this.timeToSeek != this.previewRef.current?.currentTime
|
||||
) {
|
||||
this.previewRef.current.currentTime =
|
||||
this.timeToSeek - this.preview.start;
|
||||
} else {
|
||||
this.seeking = false;
|
||||
}
|
||||
}
|
||||
|
||||
override setNewPreviewStartTime(time: number) {
|
||||
this.timeToSeek = time;
|
||||
}
|
||||
|
||||
previewReady() {
|
||||
this.seeking = false;
|
||||
this.previewRef.current?.pause();
|
||||
|
||||
if (this.timeToSeek) {
|
||||
this.finishedSeeking();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PreviewFramesPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
startTime?: number;
|
||||
onControllerReady: (controller: PreviewController) => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
function PreviewFramesPlayer({
|
||||
className,
|
||||
camera,
|
||||
timeRange,
|
||||
startTime,
|
||||
onControllerReady,
|
||||
onClick,
|
||||
}: PreviewFramesPlayerProps) {
|
||||
// frames data
|
||||
|
||||
const { data: previewFrames } = useSWR<string[]>(
|
||||
`preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil(
|
||||
timeRange.end,
|
||||
)}/frames`,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const frameTimes = useMemo(() => {
|
||||
if (!previewFrames) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return previewFrames.map((frame) =>
|
||||
parseFloat(frame.split("-")[1].slice(undefined, -5)),
|
||||
);
|
||||
}, [previewFrames]);
|
||||
|
||||
// controlling frames
|
||||
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const controller = useMemo(() => {
|
||||
if (!frameTimes || !imgRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new PreviewFramesController(camera, imgRef, frameTimes);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [imgRef, frameTimes, imgRef.current]);
|
||||
|
||||
// initial state
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller) {
|
||||
onControllerReady(controller);
|
||||
}
|
||||
|
||||
// we only want to fire once when players are ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller]);
|
||||
|
||||
const onImageLoaded = useCallback(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.finishedSeeking();
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start);
|
||||
} else {
|
||||
controller.scrubToTimestamp(startTime);
|
||||
}
|
||||
// we only want to calculate this once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={`size-full object-contain rounded-2xl bg-black`}
|
||||
onLoad={onImageLoaded}
|
||||
/>
|
||||
{previewFrames?.length === 0 && (
|
||||
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center">
|
||||
No Preview Found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class PreviewFramesController extends PreviewController {
|
||||
imgController: MutableRefObject<HTMLImageElement | null>;
|
||||
frameTimes: number[];
|
||||
seeking: boolean = false;
|
||||
private timeToSeek: number | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
camera: string,
|
||||
imgController: MutableRefObject<HTMLImageElement | null>,
|
||||
frameTimes: number[],
|
||||
) {
|
||||
super(camera);
|
||||
this.imgController = imgController;
|
||||
this.frameTimes = frameTimes;
|
||||
}
|
||||
|
||||
override scrubToTimestamp(time: number): boolean {
|
||||
if (!this.imgController.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const frame = this.frameTimes.find((p) => {
|
||||
return time <= p;
|
||||
});
|
||||
|
||||
if (!frame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.seeking) {
|
||||
this.timeToSeek = frame;
|
||||
} else {
|
||||
const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${frame}.webp/thumbnail.webp`;
|
||||
|
||||
if (this.imgController.current.src != newSrc) {
|
||||
this.imgController.current.src = newSrc;
|
||||
this.seeking = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
override finishedSeeking() {
|
||||
if (!this.imgController.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.timeToSeek) {
|
||||
const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${this.timeToSeek}.webp/thumbnail.webp`;
|
||||
|
||||
if (this.imgController.current.src != newSrc) {
|
||||
this.imgController.current.src = newSrc;
|
||||
} else {
|
||||
this.timeToSeek = undefined;
|
||||
this.seeking = false;
|
||||
}
|
||||
} else {
|
||||
this.seeking = false;
|
||||
}
|
||||
}
|
||||
|
||||
override setNewPreviewStartTime(time: number) {
|
||||
this.timeToSeek = time;
|
||||
}
|
||||
}
|
@ -527,6 +527,7 @@ function InProgressPreview({
|
||||
`preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
|
||||
Math.ceil(review.end_time) + PREVIEW_PADDING
|
||||
}/frames`,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const [manualFrame, setManualFrame] = useState(false);
|
||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>();
|
||||
@ -642,7 +643,7 @@ function InProgressPreview({
|
||||
<div className="relative size-full flex items-center bg-black">
|
||||
<img
|
||||
className="size-full object-contain"
|
||||
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.jpg`}
|
||||
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
<Slider
|
||||
|
@ -1,230 +0,0 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Preview } from "@/types/preview";
|
||||
import { PreviewPlayback } from "@/types/playback";
|
||||
|
||||
type PreviewVideoPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
cameraPreviews: Preview[];
|
||||
startTime?: number;
|
||||
onControllerReady: (controller: PreviewVideoController) => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export default function PreviewVideoPlayer({
|
||||
className,
|
||||
camera,
|
||||
timeRange,
|
||||
cameraPreviews,
|
||||
startTime,
|
||||
onControllerReady,
|
||||
onClick,
|
||||
}: PreviewVideoPlayerProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// controlling playback
|
||||
|
||||
const previewRef = useRef<HTMLVideoElement | null>(null);
|
||||
const controller = useMemo(() => {
|
||||
if (!config || !previewRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new PreviewVideoController(camera, previewRef);
|
||||
// we only care when preview is ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [camera, config, previewRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller) {
|
||||
onControllerReady(controller);
|
||||
}
|
||||
// we only want to fire once when players are ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller]);
|
||||
|
||||
// initial state
|
||||
|
||||
const initialPreview = useMemo(() => {
|
||||
return cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.start &&
|
||||
Math.floor(preview.end) <= timeRange.end,
|
||||
);
|
||||
|
||||
// we only want to calculate this once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [currentPreview, setCurrentPreview] = useState(initialPreview);
|
||||
|
||||
const onPreviewSeeked = useCallback(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.finishedSeeking();
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.start &&
|
||||
Math.floor(preview.end) <= timeRange.end,
|
||||
);
|
||||
setCurrentPreview(preview);
|
||||
|
||||
controller.newPlayback({
|
||||
preview,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
// we only want this to change when recordings update
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPreview || !previewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewRef.current.load();
|
||||
}, [currentPreview, previewRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<video
|
||||
ref={previewRef}
|
||||
className={`size-full rounded-2xl bg-black`}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
onSeeked={onPreviewSeeked}
|
||||
onLoadedData={() => {
|
||||
if (controller) {
|
||||
controller.previewReady();
|
||||
} else {
|
||||
previewRef.current?.pause();
|
||||
}
|
||||
|
||||
if (previewRef.current && startTime && currentPreview) {
|
||||
previewRef.current.currentTime = startTime - currentPreview.start;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentPreview != undefined && (
|
||||
<source src={currentPreview.src} type={currentPreview.type} />
|
||||
)}
|
||||
</video>
|
||||
{cameraPreviews && !currentPreview && (
|
||||
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center">
|
||||
No Preview Found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export class PreviewVideoController {
|
||||
// main state
|
||||
public camera = "";
|
||||
private previewRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
private timeRange: { start: number; end: number } | undefined = undefined;
|
||||
|
||||
// preview
|
||||
private preview: Preview | undefined = undefined;
|
||||
private timeToSeek: number | undefined = undefined;
|
||||
private seeking = false;
|
||||
|
||||
constructor(
|
||||
camera: string,
|
||||
previewRef: MutableRefObject<HTMLVideoElement | null>,
|
||||
) {
|
||||
this.camera = camera;
|
||||
this.previewRef = previewRef;
|
||||
}
|
||||
|
||||
newPlayback(newPlayback: PreviewPlayback) {
|
||||
this.preview = newPlayback.preview;
|
||||
this.seeking = false;
|
||||
|
||||
this.timeRange = newPlayback.timeRange;
|
||||
}
|
||||
|
||||
scrubToTimestamp(time: number): boolean {
|
||||
if (!this.preview || !this.timeRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (time < this.preview.start || time > this.preview.end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.seeking) {
|
||||
this.timeToSeek = time;
|
||||
} else {
|
||||
if (this.previewRef.current) {
|
||||
this.previewRef.current.currentTime = Math.max(
|
||||
0,
|
||||
time - this.preview.start,
|
||||
);
|
||||
this.seeking = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setNewPreviewStartTime(time: number) {
|
||||
this.timeToSeek = time;
|
||||
}
|
||||
|
||||
finishedSeeking() {
|
||||
if (!this.previewRef.current || !this.preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.timeToSeek &&
|
||||
this.timeToSeek != this.previewRef.current?.currentTime
|
||||
) {
|
||||
this.previewRef.current.currentTime =
|
||||
this.timeToSeek - this.preview.start;
|
||||
} else {
|
||||
this.seeking = false;
|
||||
}
|
||||
}
|
||||
|
||||
previewReady() {
|
||||
this.seeking = false;
|
||||
this.previewRef.current?.pause();
|
||||
|
||||
if (this.timeToSeek) {
|
||||
this.finishedSeeking();
|
||||
}
|
||||
}
|
||||
}
|
@ -163,7 +163,7 @@ export function getChunkedTimeRange(
|
||||
while (end < endTimestamp) {
|
||||
startDay.setHours(startDay.getHours() + 1);
|
||||
|
||||
if (startDay > endOfThisHour || startDay.getTime() / 1000 > endTimestamp) {
|
||||
if (startDay > endOfThisHour) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -33,9 +33,9 @@ import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import PreviewVideoPlayer, {
|
||||
PreviewVideoController,
|
||||
} from "@/components/player/PreviewVideoPlayer";
|
||||
import PreviewPlayer, {
|
||||
PreviewController,
|
||||
} from "@/components/player/PreviewPlayer";
|
||||
|
||||
type EventViewProps = {
|
||||
reviews?: ReviewSegment[];
|
||||
@ -578,9 +578,7 @@ function MotionReview({
|
||||
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
||||
}, [config, filter]);
|
||||
|
||||
const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>(
|
||||
{},
|
||||
);
|
||||
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
|
||||
|
||||
// motion data
|
||||
|
||||
@ -596,14 +594,9 @@ function MotionReview({
|
||||
|
||||
// timeline time
|
||||
|
||||
const lastFullHour = useMemo(() => {
|
||||
const end = new Date(timeRange.before * 1000);
|
||||
end.setMinutes(0, 0, 0);
|
||||
return end.getTime() / 1000;
|
||||
}, [timeRange]);
|
||||
const timeRangeSegments = useMemo(
|
||||
() => getChunkedTimeRange(timeRange.after, lastFullHour),
|
||||
[lastFullHour, timeRange],
|
||||
() => getChunkedTimeRange(timeRange.after, timeRange.before),
|
||||
[timeRange],
|
||||
);
|
||||
|
||||
const initialIndex = useMemo(() => {
|
||||
@ -620,7 +613,7 @@ function MotionReview({
|
||||
|
||||
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
|
||||
const [currentTime, setCurrentTime] = useState<number>(
|
||||
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.start,
|
||||
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.end,
|
||||
);
|
||||
const currentTimeRange = useMemo(
|
||||
() => timeRangeSegments.ranges[selectedRangeIdx],
|
||||
@ -674,7 +667,7 @@ function MotionReview({
|
||||
grow = "aspect-video";
|
||||
}
|
||||
return (
|
||||
<PreviewVideoPlayer
|
||||
<PreviewPlayer
|
||||
key={camera.name}
|
||||
className={`${grow}`}
|
||||
camera={camera.name}
|
||||
|
@ -1,6 +1,9 @@
|
||||
import DynamicVideoPlayer, {
|
||||
DynamicVideoController,
|
||||
} from "@/components/player/DynamicVideoPlayer";
|
||||
import PreviewPlayer, {
|
||||
PreviewController,
|
||||
} from "@/components/player/PreviewPlayer";
|
||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -11,6 +14,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Preview } from "@/types/preview";
|
||||
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
||||
@ -37,15 +41,15 @@ export function DesktopRecordingView({
|
||||
allCameras,
|
||||
allPreviews,
|
||||
}: DesktopRecordingViewProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const navigate = useNavigate();
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// controller state
|
||||
|
||||
const [mainCamera, setMainCamera] = useState(startCamera);
|
||||
const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>(
|
||||
{},
|
||||
);
|
||||
const mainControllerRef = useRef<DynamicVideoController | null>(null);
|
||||
const previewRefs = useRef<{ [camera: string]: PreviewController }>({});
|
||||
|
||||
const [playbackStart, setPlaybackStart] = useState(startTime);
|
||||
|
||||
@ -64,27 +68,20 @@ export function DesktopRecordingView({
|
||||
|
||||
// move to next clip
|
||||
useEffect(() => {
|
||||
if (
|
||||
!videoPlayersRef.current &&
|
||||
Object.values(videoPlayersRef.current).length > 0
|
||||
) {
|
||||
if (!mainControllerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainController = videoPlayersRef.current[mainCamera];
|
||||
|
||||
if (mainController) {
|
||||
mainController.onClipChangedEvent((dir) => {
|
||||
if (dir == "forward") {
|
||||
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
||||
setSelectedRangeIdx(selectedRangeIdx + 1);
|
||||
}
|
||||
mainControllerRef.current.onClipChangedEvent((dir) => {
|
||||
if (dir == "forward") {
|
||||
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
||||
setSelectedRangeIdx(selectedRangeIdx + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// we only want to fire once when players are ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedRangeIdx, timeRange, videoPlayersRef.current, mainCamera]);
|
||||
}, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]);
|
||||
|
||||
// scrubbing and timeline state
|
||||
|
||||
@ -107,7 +104,9 @@ export function DesktopRecordingView({
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(videoPlayersRef.current).forEach((controller) => {
|
||||
mainControllerRef.current?.scrubToTimestamp(currentTime);
|
||||
|
||||
Object.values(previewRefs.current).forEach((controller) => {
|
||||
controller.scrubToTimestamp(currentTime);
|
||||
});
|
||||
}
|
||||
@ -115,7 +114,7 @@ export function DesktopRecordingView({
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrubbing) {
|
||||
videoPlayersRef.current[mainCamera]?.seekToTimestamp(currentTime, true);
|
||||
mainControllerRef.current?.seekToTimestamp(currentTime, true);
|
||||
}
|
||||
|
||||
// we only want to seek when user stops scrubbing
|
||||
@ -124,27 +123,10 @@ export function DesktopRecordingView({
|
||||
|
||||
const onSelectCamera = useCallback(
|
||||
(newCam: string) => {
|
||||
const lastController = videoPlayersRef.current[mainCamera];
|
||||
const newController = videoPlayersRef.current[newCam];
|
||||
lastController.onPlayerTimeUpdate(null);
|
||||
lastController.onClipChangedEvent(null);
|
||||
lastController.scrubToTimestamp(currentTime);
|
||||
newController.onPlayerTimeUpdate((timestamp: number) => {
|
||||
setCurrentTime(timestamp);
|
||||
|
||||
allCameras.forEach((cam) => {
|
||||
if (cam != newCam) {
|
||||
videoPlayersRef.current[cam]?.scrubToTimestamp(
|
||||
Math.floor(timestamp),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
newController.seekToTimestamp(currentTime, true);
|
||||
setPlaybackStart(currentTime);
|
||||
setMainCamera(newCam);
|
||||
setPlaybackStart(currentTime);
|
||||
},
|
||||
[allCameras, currentTime, mainCamera],
|
||||
[currentTime],
|
||||
);
|
||||
|
||||
// motion timeline data
|
||||
@ -162,6 +144,21 @@ export function DesktopRecordingView({
|
||||
: null,
|
||||
);
|
||||
|
||||
const grow = useMemo(() => {
|
||||
if (!config) {
|
||||
return "aspect-video";
|
||||
}
|
||||
|
||||
const aspectRatio =
|
||||
config.cameras[mainCamera].detect.width /
|
||||
config.cameras[mainCamera].detect.height;
|
||||
if (aspectRatio > 2) {
|
||||
return "aspect-wide";
|
||||
} else {
|
||||
return "aspect-video";
|
||||
}
|
||||
}, [config, mainCamera]);
|
||||
|
||||
return (
|
||||
<div ref={contentRef} className="relative size-full">
|
||||
<Button
|
||||
@ -174,47 +171,39 @@ export function DesktopRecordingView({
|
||||
|
||||
<div className="flex h-full justify-center overflow-hidden">
|
||||
<div className="flex flex-1 flex-wrap">
|
||||
<div className="flex flex-col h-full px-2 justify-end">
|
||||
<div key={mainCamera} className="flex justify-center mb-5">
|
||||
<div className="w-full flex flex-col h-full px-2 justify-center items-center">
|
||||
<div
|
||||
key={mainCamera}
|
||||
className="w-[82%] flex justify-center items mb-5"
|
||||
>
|
||||
<DynamicVideoPlayer
|
||||
className="w-[85%]"
|
||||
className={`w-full ${grow}`}
|
||||
camera={mainCamera}
|
||||
timeRange={currentTimeRange}
|
||||
cameraPreviews={allPreviews ?? []}
|
||||
startTime={playbackStart}
|
||||
onControllerReady={(controller) => {
|
||||
videoPlayersRef.current[mainCamera] = controller;
|
||||
mainControllerRef.current = controller;
|
||||
controller.onPlayerTimeUpdate((timestamp: number) => {
|
||||
setCurrentTime(timestamp);
|
||||
|
||||
allCameras.forEach((otherCam) => {
|
||||
if (mainCamera != otherCam) {
|
||||
videoPlayersRef.current[otherCam]?.scrubToTimestamp(
|
||||
Math.floor(timestamp),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 overflow-x-auto">
|
||||
<div className="w-full flex justify-center gap-2 overflow-x-auto">
|
||||
{allCameras.map((cam) => {
|
||||
if (cam !== mainCamera) {
|
||||
return (
|
||||
<div
|
||||
key={cam}
|
||||
className="aspect-video flex items-center we-[300px]"
|
||||
>
|
||||
<DynamicVideoPlayer
|
||||
<div key={cam}>
|
||||
<PreviewPlayer
|
||||
className="size-full"
|
||||
camera={cam}
|
||||
timeRange={currentTimeRange}
|
||||
cameraPreviews={allPreviews ?? []}
|
||||
previewOnly
|
||||
startTime={startTime}
|
||||
onControllerReady={(controller) => {
|
||||
videoPlayersRef.current[cam] = controller;
|
||||
controller.scrubToTimestamp(startTime, true);
|
||||
previewRefs.current[cam] = controller;
|
||||
controller.scrubToTimestamp(startTime);
|
||||
}}
|
||||
onClick={() => onSelectCamera(cam)}
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user