diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index 02749a92b0..22dce5338e 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -26,7 +26,7 @@ function loadMockJson(filename: string): unknown { } // 1x1 transparent PNG -const PLACEHOLDER_PNG = Buffer.from( +export const PLACEHOLDER_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64", ); diff --git a/web/e2e/specs/preview-rollover.spec.ts b/web/e2e/specs/preview-rollover.spec.ts new file mode 100644 index 0000000000..f4bf8602c4 --- /dev/null +++ b/web/e2e/specs/preview-rollover.spec.ts @@ -0,0 +1,194 @@ +/** + * Preview hour rollover -- MEDIUM tier. + * + * Tiles beside the main player must not collapse to "No Preview Found" when + * the clock crosses an hour boundary with the page open: frames before, the + * hour's mp4 after. The mp4 src resolves to the /clips/** mock, which serves + * a PNG, so these assert the is wired up, not that it decodes. + */ + +import { test, expect } from "../fixtures/frigate-test"; +import { PLACEHOLDER_PNG } from "../helpers/api-mocker"; + +const HOUR_START = Date.UTC(2026, 8, 1, 10, 0, 0) / 1000; +const HOUR_END = Date.UTC(2026, 8, 1, 11, 0, 0) / 1000; +const REVIEW_START = HOUR_START + 900; // 10:15, inside the slot under test + +// close to the boundary so fastForward skips few unrelated app timers +const JUST_BEFORE_ROLLOVER = new Date(Date.UTC(2026, 8, 1, 10, 59, 50)); + +// the main camera's scrub preview plus one tile per other camera +const CAMERAS = ["front_door", "backyard", "garage"]; + +const review = { + id: "review-rollover-001", + camera: "front_door", + start_time: REVIEW_START, + end_time: REVIEW_START + 30, + has_been_reviewed: false, + severity: "alert", + thumb_path: "/clips/front_door/review-rollover-001-thumb.jpg", + data: { + audio: [], + detections: ["person-abc123"], + objects: ["person"], + sub_labels: [], + significant_motion_areas: [], + zones: [], + }, +}; + +function previewFor(camera: string) { + return { + camera, + src: `/clips/previews/${camera}/${HOUR_START}-${HOUR_END}.mp4`, + type: "video/mp4", + start: HOUR_START, + end: HOUR_END + 0.4, + }; +} + +/** Opens RecordingView pre-boundary; returns a fn that rolls the hour over. */ +async function openAtRollover(frigateApp: { + page: import("@playwright/test").Page; + installDefaults: (o?: { reviews?: unknown[] }) => Promise; + goto: (p: string) => Promise; +}) { + await frigateApp.page.clock.install({ time: JUST_BEFORE_ROLLOVER }); + await frigateApp.installDefaults({ reviews: [review] }); + + let hourRolled = false; + + await frigateApp.page.route("**/api/review/review-rollover-001", (route) => + route.fulfill({ json: review }), + ); + + await frigateApp.page.route(/\/api\/preview\/.+\/start\//, (route) => { + if (route.request().url().includes("/frames")) { + return route.fulfill({ + json: hourRolled ? [] : [`preview_backyard-${REVIEW_START}.webp`], + }); + } + + return route.fulfill({ + json: hourRolled ? CAMERAS.map(previewFor) : [], + }); + }); + + await frigateApp.page.route("**/thumbnail.webp", (route) => + route.fulfill({ contentType: "image/png", body: PLACEHOLDER_PNG }), + ); + + await frigateApp.goto("/review?id=review-rollover-001"); + + return async () => { + hourRolled = true; + await frigateApp.page.clock.fastForward("00:40"); + }; +} + +test.describe("Preview hour rollover: desktop @medium", () => { + // one alternation regex, not two entries: Playwright's isFixtureTuple reads a + // two-element array as [value, options] + test.use({ + expectedErrors: [/no supported source was found|MEDIA_ELEMENT_ERROR/i], + }); + + test("tile keeps a preview source when the hour rolls over", async ({ + frigateApp, + }, testInfo) => { + test.skip( + testInfo.project.name !== "desktop", + "preview tile row only renders on desktop", + ); + + const rollOver = await openAtRollover(frigateApp); + + // both handles are URLs only PreviewPlayer renders: the main camera's + // scrub preview and one tile per other camera, all hit by the same bug + const frameTiles = frigateApp.page.locator('img[src*="/thumbnail.webp"]'); + const mp4Tiles = frigateApp.page.locator( + `video source[src*="${HOUR_START}-${HOUR_END}"]`, + ); + const barePanel = frigateApp.page.getByText("No Preview Found", { + exact: true, + }); + + await expect(frameTiles).toHaveCount(CAMERAS.length, { timeout: 15_000 }); + await expect(barePanel).toHaveCount(0); + + await rollOver(); + + await expect(mp4Tiles).toHaveCount(CAMERAS.length, { timeout: 15_000 }); + await expect(barePanel).toHaveCount(0); + }); +}); + +test.describe("Preview hour rollover: mobile @medium @mobile", () => { + test.use({ + expectedErrors: [/no supported source was found|MEDIA_ELEMENT_ERROR/i], + }); + + test("main camera scrub preview keeps its source when the hour rolls over", async ({ + frigateApp, + }, testInfo) => { + test.skip( + testInfo.project.name === "desktop", + "desktop case above covers the tile row as well", + ); + + const rollOver = await openAtRollover(frigateApp); + + // the tile row is desktop-only, but the main player's scrub preview is a + // PreviewPlayer too, so mobile has exactly one and it hits the same bug + const frameTiles = frigateApp.page.locator('img[src*="/thumbnail.webp"]'); + const mp4Tiles = frigateApp.page.locator( + `video source[src*="${HOUR_START}-${HOUR_END}"]`, + ); + const barePanel = frigateApp.page.getByText("No Preview Found", { + exact: true, + }); + + await expect(frameTiles).toHaveCount(1, { timeout: 15_000 }); + await expect(barePanel).toHaveCount(0); + + await rollOver(); + + await expect(mp4Tiles).toHaveCount(1, { timeout: 15_000 }); + await expect(barePanel).toHaveCount(0); + }); +}); + +/** + * A review with no mp4 at all (aged out, camera offline, generation failed) + * used to fade to black on hover, since playback hides the thumbnail. + */ +test.describe("Review card without a preview: desktop @medium", () => { + test("hovering keeps the thumbnail instead of blanking the card", async ({ + frigateApp, + }, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "hover is desktop only"); + + // three hours old, so neither the current nor the previous hour + const start = Math.floor(Date.now() / 1000) - 3 * 3600; + const noPreviewReview = { + ...review, + id: "review-noprev-001", + start_time: start, + end_time: start + 30, + thumb_path: "/clips/front_door/review-noprev-001-thumb.jpg", + }; + + await frigateApp.installDefaults({ reviews: [noPreviewReview] }); + await frigateApp.goto("/review"); + + const thumbnail = frigateApp.page.locator( + 'img[src*="review-noprev-001-thumb"]', + ); + await expect(thumbnail).toBeVisible({ timeout: 15_000 }); + + await thumbnail.hover(); + // the card waits 500ms before entering playback + await expect(thumbnail).toHaveCSS("opacity", "1", { timeout: 3_000 }); + }); +}); diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 2a831fc6b3..4050f6a2f5 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -10,7 +10,7 @@ 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 { isCurrentOrPreviousHour } from "@/utils/dateUtil"; import { baseUrl } from "@/api/baseUrl"; import { isAndroid, isChrome, isMobile } from "react-device-detect"; import { TimeRange } from "@/types/timeline"; @@ -76,7 +76,7 @@ export default function PreviewPlayer({ ); } - if (isCurrentHour(timeRange.before)) { + if (isCurrentOrPreviousHour(timeRange.before)) { return ( - {t("noPreviewFoundFor", { camera: cameraName })} + {t("noPreviewFoundFor", { cameraName: cameraName })} )} {firstLoad && } diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 21aa41a433..fca6e774fb 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useApiHost } from "@/api"; -import { isCurrentHour } from "@/utils/dateUtil"; +import { isCurrentOrPreviousHour } from "@/utils/dateUtil"; import { ReviewSegment, ThreatLevel, @@ -139,9 +139,19 @@ export default function PreviewThumbnailPlayer({ const [hoverTimeout, setHoverTimeout] = useState(); const [playback, setPlayback] = useState(false); const [tooltipHovering, setTooltipHovering] = useState(false); + + const thumbnailUrl = `${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`; + + // not memoized: depends on the wall clock, and a stale value blanks the card + // for a whole hour after a rollover + const hasPreviewContent = + relevantPreview != undefined || isCurrentOrPreviousHour(review.start_time); + + // playback hides the thumbnail below, and only a mounted player calls + // isPlayingBack(false), so entering it empty leaves the card black const playingBack = useMemo( - () => playback && !tooltipHovering, - [playback, tooltipHovering], + () => playback && !tooltipHovering && hasPreviewContent, + [playback, tooltipHovering, hasPreviewContent], ); const [isHovered, setIsHovered] = useState(false); @@ -208,6 +218,7 @@ export default function PreviewThumbnailPlayer({ review={review} relevantPreview={relevantPreview} timeRange={timeRange} + defaultImageUrl={thumbnailUrl} setReviewed={handleSetReviewed} setIgnoreClick={setIgnoreClick} isPlayingBack={setPlayback} @@ -234,7 +245,7 @@ export default function PreviewThumbnailPlayer({ : undefined } draggable={false} - src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`} + src={thumbnailUrl} loading={isSafari ? "eager" : "lazy"} onLoad={() => { onImgLoad(); @@ -394,6 +405,7 @@ type PreviewContentProps = { review: ReviewSegment; relevantPreview: Preview | undefined; timeRange: TimeRange; + defaultImageUrl: string; setReviewed: () => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; @@ -403,6 +415,7 @@ function PreviewContent({ review, relevantPreview, timeRange, + defaultImageUrl, setReviewed, setIgnoreClick, isPlayingBack, @@ -423,13 +436,14 @@ function PreviewContent({ windowVisible={true} /> ); - } else if (isCurrentHour(review.start_time)) { + } else if (isCurrentOrPreviousHour(review.start_time)) { return ( ); } + + // unreachable while the caller gates on hasPreviewContent + return ; } diff --git a/web/src/hooks/use-camera-previews.ts b/web/src/hooks/use-camera-previews.ts index 63f795d7c8..edd8c441dc 100644 --- a/web/src/hooks/use-camera-previews.ts +++ b/web/src/hooks/use-camera-previews.ts @@ -2,11 +2,13 @@ import { Preview } from "@/types/preview"; import { TimeRange } from "@/types/timeline"; import { useEffect, useMemo, useState } from "react"; import useSWR from "swr"; +import { useHourRollover } from "@/hooks/use-hour-rollover"; type OptionalCameraPreviewProps = { camera?: string; autoRefresh?: boolean; fetchPreviews?: boolean; + refreshOnHourRollover?: boolean; }; export function useCameraPreviews( initialTimeRange: TimeRange, @@ -14,6 +16,7 @@ export function useCameraPreviews( camera = "all", autoRefresh = true, fetchPreviews = true, + refreshOnHourRollover = false, }: OptionalCameraPreviewProps, ) { const [timeRange, setTimeRange] = useState(initialTimeRange); @@ -22,33 +25,52 @@ export function useCameraPreviews( setTimeRange(initialTimeRange); }, [initialTimeRange]); - const { data: allPreviews } = useSWR( + const { data: allPreviews, mutate: refreshPreviews } = useSWR( fetchPreviews ? `preview/${camera}/start/${Math.round(timeRange.after)}/end/${Math.round(timeRange.before)}` : null, { revalidateOnFocus: autoRefresh, revalidateOnReconnect: autoRefresh }, ); + // an hour's mp4 is written after that hour ends, so it is never in the + // response the page loaded with + useHourRollover(refreshPreviews, refreshOnHourRollover && fetchPreviews); + return fetchPreviews ? allPreviews : []; } -// we need to add a buffer of 5 seconds to the end preview times -// this ensures that if preview generation is running slowly -// and the previews are generated 1-5 seconds late -// it is not falsely thrown out. -const PREVIEW_END_BUFFER = 5; // seconds - export function getPreviewForTimeRange( allPreviews: Preview[], camera: string, timeRange: TimeRange, ) { - return allPreviews.find( - (preview) => - preview.camera == camera && - Math.ceil(preview.start) >= timeRange.after && - Math.floor(preview.end) <= timeRange.before + PREVIEW_END_BUFFER, - ); + let best: Preview | undefined = undefined; + let bestOverlap = 0; + + for (const preview of allPreviews) { + // a preview belongs to the hour it starts in. a camera whose feed drops + // across the boundary produces one ending minutes into the next hour, and + // without this it would claim that hour's slot as well. + if ( + preview.camera != camera || + Math.ceil(preview.start) < timeRange.after + ) { + continue; + } + + // the slot for the live hour is stamped at page load and never grows, so + // an hour-long preview never fits inside it. rank by overlap instead. + const overlap = + Math.min(preview.end, timeRange.before) - + Math.max(preview.start, timeRange.after); + + if (overlap > bestOverlap) { + bestOverlap = overlap; + best = preview; + } + } + + return best; } export function usePreviewForTimeRange( diff --git a/web/src/hooks/use-hour-rollover.ts b/web/src/hooks/use-hour-rollover.ts new file mode 100644 index 0000000000..f0c8db74a1 --- /dev/null +++ b/web/src/hooks/use-hour-rollover.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react"; + +// the mp4 lands some way past :00 (first frame after the boundary, then an +// encode), and background tabs clamp timers to ~1min, so ladder rather than +// fire once. +const RETRY_OFFSETS = [15, 45, 120, 300]; // seconds past the hour + +/** Runs `callback` several times past each hour boundary; must be idempotent. */ +export function useHourRollover(callback: () => void, enabled: boolean = true) { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!enabled) { + return; + } + + let timeouts: ReturnType[] = []; + + const scheduleNextHour = () => { + // every prior timeout has fired by the time this reschedules itself + timeouts = []; + + const now = Date.now(); + const nextHour = new Date(now); + nextHour.setUTCMinutes(0, 0, 0); + nextHour.setUTCHours(nextHour.getUTCHours() + 1); + const msUntilBoundary = nextHour.getTime() - now; + + RETRY_OFFSETS.forEach((offset) => { + timeouts.push( + setTimeout( + () => callbackRef.current(), + msUntilBoundary + offset * 1000, + ), + ); + }); + + // repeat the ladder next hour + timeouts.push( + setTimeout( + scheduleNextHour, + msUntilBoundary + + (RETRY_OFFSETS[RETRY_OFFSETS.length - 1] + 1) * 1000, + ), + ); + }; + + scheduleNextHour(); + + return () => timeouts.forEach(clearTimeout); + }, [enabled]); +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 36e72a3feb..c83413c152 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -518,6 +518,7 @@ export default function Events() { previewTimes ?? { after: 0, before: 0 }, { fetchPreviews: previewTimes != undefined, + refreshOnHourRollover: true, }, ); diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index 53f31fcf4e..c14f8eaef2 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -385,6 +385,16 @@ export function isCurrentHour(timestamp: number) { return timestamp > now.getTime() / 1000; } +// a just-ended hour has no mp4 yet but may still have cached frames, so it +// stays eligible for the frame-based players +export function isCurrentOrPreviousHour(timestamp: number) { + const previousHour = new Date(); + previousHour.setUTCMinutes(0, 0, 0); + previousHour.setUTCHours(previousHour.getUTCHours() - 1); + + return timestamp > previousHour.getTime() / 1000; +} + export const convertLocalDateToTimestamp = (dateString: string): number => { // Ensure the date string is in the correct format (8 digits) if (!/^\d{8}$/.test(dateString)) { diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx index 2a4a8dafd2..8d52f11aa4 100644 --- a/web/src/views/motion-search/MotionSearchView.tsx +++ b/web/src/views/motion-search/MotionSearchView.tsx @@ -153,6 +153,7 @@ export default function MotionSearchView({ const allPreviews = useCameraPreviews(timeRange, { camera: selectedCamera ?? undefined, fetchPreviews: !isSearchDialogOpen, + refreshOnHourRollover: true, }); // ROI state