Merge pull request #14 from ibs0d/codex/add-camera-rotation-config-option

Add per-camera dashboard rotation and cover-fit support for live views
This commit is contained in:
ibs0d 2026-03-08 15:12:59 +11:00 committed by GitHub
commit 27245af7d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 250 additions and 84 deletions

View File

@ -16,3 +16,8 @@ class CameraUiConfig(FrigateBaseModel):
title="Show in UI", title="Show in UI",
description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.", description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.",
) )
rotate: bool = Field(
default=False,
title="Rotate in grid",
description="Rotate this camera 90 degrees clockwise in multi-camera dashboard/grid views only.",
)

View File

@ -9,6 +9,7 @@ type AutoUpdatingCameraImageProps = {
cameraClasses?: string; cameraClasses?: string;
reloadInterval?: number; reloadInterval?: number;
periodicCache?: boolean; periodicCache?: boolean;
fit?: "contain" | "cover";
}; };
const MIN_LOAD_TIMEOUT_MS = 200; const MIN_LOAD_TIMEOUT_MS = 200;
@ -21,6 +22,7 @@ export default function AutoUpdatingCameraImage({
cameraClasses, cameraClasses,
reloadInterval = MIN_LOAD_TIMEOUT_MS, reloadInterval = MIN_LOAD_TIMEOUT_MS,
periodicCache = false, periodicCache = false,
fit = "contain",
}: AutoUpdatingCameraImageProps) { }: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now()); const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState<string>("0"); const [fps, setFps] = useState<string>("0");
@ -96,6 +98,7 @@ export default function AutoUpdatingCameraImage({
onload={handleLoad} onload={handleLoad}
searchParams={cacheKey} searchParams={cacheKey}
className={cameraClasses} className={cameraClasses}
fit={fit}
/> />
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null} {showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
</div> </div>

View File

@ -12,6 +12,7 @@ type CameraImageProps = {
camera: string; camera: string;
onload?: () => void; onload?: () => void;
searchParams?: string; searchParams?: string;
fit?: "contain" | "cover";
}; };
export default function CameraImage({ export default function CameraImage({
@ -19,6 +20,7 @@ export default function CameraImage({
camera, camera,
onload, onload,
searchParams = "", searchParams = "",
fit = "contain",
}: CameraImageProps) { }: CameraImageProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -87,12 +89,16 @@ export default function CameraImage({
<img <img
ref={imgRef} ref={imgRef}
className={cn( className={cn(
"object-contain", fit == "cover" ? "size-full object-cover" : "object-contain",
imageLoaded fit == "cover"
? isPortraitImage ? imageLoaded
? "h-full w-auto" ? "visible"
: "h-auto w-full" : "invisible"
: "invisible", : imageLoaded
? isPortraitImage
? "h-full w-auto"
: "h-auto w-full"
: "invisible",
"rounded-lg md:rounded-2xl", "rounded-lg md:rounded-2xl",
)} )}
onLoad={handleImageLoad} onLoad={handleImageLoad}

View File

@ -16,6 +16,7 @@ type JSMpegPlayerProps = {
useWebGL: boolean; useWebGL: boolean;
setStats?: (stats: PlayerStatsType) => void; setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void; onPlaying?: () => void;
fit?: "contain" | "cover";
}; };
export default function JSMpegPlayer({ export default function JSMpegPlayer({
@ -28,6 +29,7 @@ export default function JSMpegPlayer({
useWebGL = false, useWebGL = false,
setStats, setStats,
onPlaying, onPlaying,
fit = "contain",
}: JSMpegPlayerProps) { }: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
const videoRef = useRef<HTMLDivElement>(null); const videoRef = useRef<HTMLDivElement>(null);
@ -60,8 +62,28 @@ export default function JSMpegPlayer({
[containerWidth, containerHeight], [containerWidth, containerHeight],
); );
const scaledHeight = useMemo(() => { const scaledDimensions = useMemo(() => {
if (selectedContainerRef?.current && width && height) { if (!width || !height || !containerWidth || !containerHeight) {
return { width: undefined, height: undefined };
}
if (fit == "cover") {
if (aspectRatio < fitAspect) {
const coverWidth = Math.ceil(containerWidth);
return {
width: coverWidth,
height: Math.ceil(coverWidth / aspectRatio),
};
}
const coverHeight = Math.ceil(containerHeight);
return {
width: Math.ceil(coverHeight * aspectRatio),
height: coverHeight,
};
}
if (selectedContainerRef?.current) {
const scaledHeight = const scaledHeight =
aspectRatio < (fitAspect ?? 0) aspectRatio < (fitAspect ?? 0)
? Math.floor( ? Math.floor(
@ -78,33 +100,31 @@ export default function JSMpegPlayer({
: Math.min(scaledHeight, height); : Math.min(scaledHeight, height);
if (finalHeight > 0) { if (finalHeight > 0) {
return finalHeight; return {
width: Math.ceil(finalHeight * aspectRatio),
height: finalHeight,
};
} }
} }
return undefined;
return { width: undefined, height: undefined };
}, [ }, [
aspectRatio, aspectRatio,
containerWidth, containerWidth,
containerHeight, containerHeight,
fitAspect, fitAspect,
fit,
height, height,
width, width,
stretch, stretch,
selectedContainerRef, selectedContainerRef,
]); ]);
const scaledWidth = useMemo(() => {
if (aspectRatio && scaledHeight) {
return Math.ceil(scaledHeight * aspectRatio);
}
return undefined;
}, [scaledHeight, aspectRatio]);
useEffect(() => { useEffect(() => {
if (scaledWidth && scaledHeight) { if (scaledDimensions.width && scaledDimensions.height) {
setDimensionsReady(true); setDimensionsReady(true);
} }
}, [scaledWidth, scaledHeight]); }, [scaledDimensions]);
useEffect(() => { useEffect(() => {
onPlayingRef.current = onPlaying; onPlayingRef.current = onPlaying;
@ -242,7 +262,7 @@ export default function JSMpegPlayer({
<div <div
ref={videoRef} ref={videoRef}
className={cn( className={cn(
"jsmpeg flex h-full w-auto items-center justify-center", "jsmpeg flex size-full items-center justify-center overflow-hidden",
!showCanvas && "hidden", !showCanvas && "hidden",
)} )}
> >
@ -250,8 +270,8 @@ export default function JSMpegPlayer({
ref={canvasRef} ref={canvasRef}
className="rounded-lg md:rounded-2xl" className="rounded-lg md:rounded-2xl"
style={{ style={{
width: scaledWidth, width: scaledDimensions.width,
height: scaledHeight, height: scaledDimensions.height,
}} }}
></canvas> ></canvas>
</div> </div>

View File

@ -0,0 +1,104 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import LivePlayer from "./LivePlayer";
import { CameraConfig } from "@/types/frigateConfig";
vi.mock("@/hooks/resize-observer", () => ({
useResizeObserver: () => [{ width: 300, height: 200 }],
}));
vi.mock("@/hooks/use-camera-activity", () => ({
useCameraActivity: () => ({
enabled: true,
activeMotion: true,
activeTracking: false,
objects: [],
offline: false,
}),
}));
vi.mock("@/hooks/use-camera-friendly-name", () => ({
useCameraFriendlyName: () => "Front Door",
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
Trans: ({ children }: { children: string }) => children,
initReactI18next: { type: "3rdParty", init: () => undefined },
}));
vi.mock("@/utils/i18n", () => ({
getTranslatedLabel: (value: string) => value,
}));
vi.mock("./WebRTCPlayer", () => ({
default: ({ className }: { className?: string }) => (
<video className={className}>webrtc</video>
),
}));
vi.mock("./MsePlayer", () => ({
default: ({ className }: { className?: string }) => (
<video className={className}>mse</video>
),
}));
vi.mock("./JSMpegPlayer", () => ({
default: ({ className }: { className?: string }) => (
<div className={className}>jsmpeg</div>
),
}));
vi.mock("../camera/AutoUpdatingCameraImage", () => ({
default: () => <div>still</div>,
}));
vi.mock("../overlay/ImageShadowOverlay", () => ({
ImageShadowOverlay: () => <div />,
}));
vi.mock("./PlayerStats", () => ({
PlayerStats: () => <div />,
}));
const cameraConfig = {
name: "front_door",
detect: { width: 1920, height: 1080 },
} as CameraConfig;
describe("LivePlayer dashboard transform gating", () => {
it("does not apply rotate transform when applyDashboardTransforms is false", () => {
const html = renderToStaticMarkup(
<LivePlayer
cameraConfig={cameraConfig}
streamName="front_door"
preferredLiveMode="webrtc"
useWebGL={false}
playInBackground={false}
rotateClockwise
fillContainer
applyDashboardTransforms={false}
/>,
);
expect(html).not.toContain("rotate(90deg)");
});
it("applies rotate transform when dashboard transforms are enabled", () => {
const html = renderToStaticMarkup(
<LivePlayer
cameraConfig={cameraConfig}
streamName="front_door"
preferredLiveMode="webrtc"
useWebGL={false}
playInBackground={false}
rotateClockwise
fillContainer
applyDashboardTransforms
/>,
);
expect(html).toContain("rotate(90deg)");
});
});

View File

@ -27,6 +27,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil"; import { formatList } from "@/utils/stringUtil";
import { useResizeObserver } from "@/hooks/resize-observer";
type LivePlayerProps = { type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void; cameraRef?: (ref: HTMLDivElement | null) => void;
@ -47,6 +48,9 @@ type LivePlayerProps = {
pip?: boolean; pip?: boolean;
autoLive?: boolean; autoLive?: boolean;
showStats?: boolean; showStats?: boolean;
rotateClockwise?: boolean;
fillContainer?: boolean;
applyDashboardTransforms?: boolean;
onClick?: () => void; onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
@ -72,6 +76,9 @@ export default function LivePlayer({
pip, pip,
autoLive = true, autoLive = true,
showStats = false, showStats = false,
rotateClockwise = false,
fillContainer = false,
applyDashboardTransforms = false,
onClick, onClick,
setFullResolution, setFullResolution,
onError, onError,
@ -83,10 +90,31 @@ export default function LivePlayer({
const cameraName = useCameraFriendlyName(cameraConfig); const cameraName = useCameraFriendlyName(cameraConfig);
// player is showing on a dashboard if containerRef is not provided const shouldFillContainer = applyDashboardTransforms && fillContainer;
const shouldRotateClockwise = applyDashboardTransforms && rotateClockwise;
const inDashboard = containerRef?.current == null; const mediaViewportRef = useRef<HTMLDivElement | null>(null);
const [{ width: viewportWidth, height: viewportHeight }] =
useResizeObserver(mediaViewportRef);
const mediaTransformStyle = useMemo(() => {
const transforms = ["translate(-50%, -50%)"];
if (shouldRotateClockwise) {
transforms.push("rotate(90deg)");
}
// For a 90° rotation, the media box must use swapped viewport dimensions
// before rotating, otherwise the rotated content can under-fill one axis.
const rotatedWidth = viewportHeight ? `${viewportHeight}px` : "100%";
const rotatedHeight = viewportWidth ? `${viewportWidth}px` : "100%";
return {
transform: transforms.join(" "),
width: shouldRotateClockwise ? rotatedWidth : "100%",
height: shouldRotateClockwise ? rotatedHeight : "100%",
};
}, [shouldRotateClockwise, viewportHeight, viewportWidth]);
// stats // stats
const [stats, setStats] = useState<PlayerStatsType>({ const [stats, setStats] = useState<PlayerStatsType>({
@ -279,7 +307,11 @@ export default function LivePlayer({
player = ( player = (
<WebRtcPlayer <WebRtcPlayer
key={"webrtc_" + key} key={"webrtc_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={cn(
"size-full rounded-lg md:rounded-2xl",
shouldFillContainer && "object-cover",
liveReady ? "" : "hidden",
)}
camera={streamName} camera={streamName}
playbackEnabled={cameraActive || liveReady} playbackEnabled={cameraActive || liveReady}
getStats={showStats} getStats={showStats}
@ -298,7 +330,11 @@ export default function LivePlayer({
player = ( player = (
<MSEPlayer <MSEPlayer
key={"mse_" + key} key={"mse_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={cn(
"size-full rounded-lg md:rounded-2xl",
shouldFillContainer && "object-cover",
liveReady ? "" : "hidden",
)}
camera={streamName} camera={streamName}
playbackEnabled={cameraActive || liveReady} playbackEnabled={cameraActive || liveReady}
audioEnabled={playAudio} audioEnabled={playAudio}
@ -324,7 +360,11 @@ export default function LivePlayer({
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
key={"jsmpeg_" + key} key={"jsmpeg_" + key}
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl" className={cn(
"flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl",
shouldFillContainer &&
"[&_.internal-jsmpeg-container]:size-full [&_.jsmpeg]:size-full",
)}
camera={cameraConfig.name} camera={cameraConfig.name}
width={cameraConfig.detect.width} width={cameraConfig.detect.width}
height={cameraConfig.detect.height} height={cameraConfig.detect.height}
@ -335,6 +375,7 @@ export default function LivePlayer({
setStats={setStats} setStats={setStats}
containerRef={containerRef ?? internalContainerRef} containerRef={containerRef ?? internalContainerRef}
onPlaying={playerIsPlaying} onPlaying={playerIsPlaying}
fit={shouldFillContainer ? "cover" : "contain"}
/> />
); );
} else { } else {
@ -371,7 +412,17 @@ export default function LivePlayer({
lowerClassName="md:rounded-2xl" lowerClassName="md:rounded-2xl"
/> />
)} )}
{player} <div
ref={mediaViewportRef}
className={cn(
"absolute inset-0",
shouldFillContainer && "overflow-hidden",
)}
>
<div className="absolute left-1/2 top-1/2" style={mediaTransformStyle}>
{player}
</div>
</div>
{cameraEnabled && {cameraEnabled &&
!offline && !offline &&
(!showStillWithoutActivity || isReEnabling) && (!showStillWithoutActivity || isReEnabling) &&
@ -441,8 +492,15 @@ export default function LivePlayer({
)} )}
> >
<AutoUpdatingCameraImage <AutoUpdatingCameraImage
className="pointer-events-none size-full" className={cn(
cameraClasses="relative size-full flex justify-center" "pointer-events-none size-full",
shouldFillContainer && "overflow-hidden",
)}
cameraClasses={cn(
"relative size-full",
shouldFillContainer && "overflow-hidden",
)}
fit={shouldFillContainer ? "cover" : "contain"}
camera={cameraConfig.name} camera={cameraConfig.name}
showFps={false} showFps={false}
reloadInterval={stillReloadInterval} reloadInterval={stillReloadInterval}
@ -450,7 +508,7 @@ export default function LivePlayer({
/> />
</div> </div>
{offline && inDashboard && ( {offline && applyDashboardTransforms && (
<> <>
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" /> <div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center"> <div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">

View File

@ -8,6 +8,7 @@ export interface UiConfig {
time_style?: "full" | "long" | "medium" | "short"; time_style?: "full" | "long" | "medium" | "short";
dashboard: boolean; dashboard: boolean;
order: number; order: number;
rotate: boolean;
unit_system?: "metric" | "imperial"; unit_system?: "metric" | "imperial";
} }

View File

@ -27,7 +27,6 @@ import {
StatsState, StatsState,
VolumeState, VolumeState,
} from "@/types/live"; } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
@ -194,38 +193,20 @@ export default function DraggableGridLayout({
return; return;
} }
let aspectRatio; // Keep birdseye aspect-aware sizing, while camera tiles use a stable size.
let col;
// Handle "birdseye" camera as a special case
if (cameraName === "birdseye") {
aspectRatio =
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
col = 0; // Set birdseye camera in the first column
} else {
const camera = cameras.find((cam) => cam.name === cameraName);
aspectRatio =
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
col = index % 3; // Regular cameras distributed across columns
}
// Calculate layout options based on aspect ratio
const columnsPerPlayer = 4; const columnsPerPlayer = 4;
let height; const col = index % 3;
let width; let width = columnsPerPlayer;
let height = columnsPerPlayer;
if (aspectRatio < 1) { if (cameraName === "birdseye") {
// Portrait const aspectRatio =
height = 2 * columnsPerPlayer; (birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
width = columnsPerPlayer; if (aspectRatio < 1) {
} else if (aspectRatio > 2) { height = 2 * columnsPerPlayer;
// Wide } else if (aspectRatio > 2) {
height = 1 * columnsPerPlayer; width = 2 * columnsPerPlayer;
width = 2 * columnsPerPlayer; }
} else {
// Landscape
height = 1 * columnsPerPlayer;
width = columnsPerPlayer;
} }
const options = { const options = {
@ -606,15 +587,7 @@ export default function DraggableGridLayout({
</BirdseyeLivePlayerGridItem> </BirdseyeLivePlayerGridItem>
)} )}
{cameras.map((camera) => { {cameras.map((camera) => {
let grow; const grow = "size-full";
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
grow = `aspect-wide w-full`;
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
grow = `aspect-tall h-full`;
} else {
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {}; const availableStreams = camera.live.streams || {};
const firstStreamEntry = Object.values(availableStreams)[0] || ""; const firstStreamEntry = Object.values(availableStreams)[0] || "";
@ -681,8 +654,7 @@ export default function DraggableGridLayout({
useWebGL={useWebGL} useWebGL={useWebGL}
cameraRef={cameraRef} cameraRef={cameraRef}
className={cn( className={cn(
"rounded-lg bg-black md:rounded-2xl", "size-full overflow-hidden rounded-lg bg-black md:rounded-2xl",
grow,
isEditMode && isEditMode &&
showCircles && showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", "outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
@ -709,6 +681,9 @@ export default function DraggableGridLayout({
onResetLiveMode={() => resetPreferredLiveMode(camera.name)} onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]} playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]} volume={volumeStates[camera.name]}
rotateClockwise={camera.ui.rotate}
fillContainer
applyDashboardTransforms
/> />
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </GridLiveContextMenu>

View File

@ -511,16 +511,7 @@ export default function LiveDashboardView({
</div> </div>
)} )}
{cameras.map((camera) => { {cameras.map((camera) => {
let grow; const grow = "aspect-video";
const aspectRatio =
camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
} else if (aspectRatio < 1) {
grow = `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
} else {
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {}; const availableStreams = camera.live.streams || {};
const firstStreamEntry = const firstStreamEntry =
Object.values(availableStreams)[0] || ""; Object.values(availableStreams)[0] || "";
@ -584,7 +575,7 @@ export default function LiveDashboardView({
<LivePlayer <LivePlayer
cameraRef={cameraRef} cameraRef={cameraRef}
key={camera.name} key={camera.name}
className={`${grow} rounded-lg bg-black md:rounded-2xl`} className={`${grow} size-full overflow-hidden rounded-lg bg-black md:rounded-2xl`}
windowVisible={ windowVisible={
windowVisible && visibleCameras.includes(camera.name) windowVisible && visibleCameras.includes(camera.name)
} }
@ -608,6 +599,9 @@ export default function LiveDashboardView({
} }
playAudio={audioStates[camera.name] ?? false} playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name]} volume={volumeStates[camera.name]}
rotateClockwise={camera.ui.rotate}
fillContainer
applyDashboardTransforms
/> />
</LiveContextMenu> </LiveContextMenu>
); );