Compare commits

..

1 Commits

Author SHA1 Message Date
Blake Blackshear
cb943022f9 updates for yolov9 coral support 2026-02-27 01:35:35 +00:00
10 changed files with 44 additions and 367 deletions

View File

@@ -1,7 +1,7 @@
default_target: local default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.17.1 VERSION = 0.17.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty BOARDS= #Initialized empty

View File

@@ -161,7 +161,7 @@ YOLOv9 models that are compiled for TensorFlow Lite and properly quantized are s
:::tip :::tip
**Frigate+ Users:** Follow the [instructions](/integrations/plus#use-models) to set a model ID in your config file. **Frigate+ Users:** Follow the [instructions](../integrations/plus#use-models) to set a model ID in your config file.
::: :::
@@ -1571,12 +1571,12 @@ YOLOv9 model can be exported as ONNX using the command below. You can copy and p
```sh ```sh
docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF' docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF'
FROM python:3.11 AS build FROM python:3.11 AS build
RUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
WORKDIR /yolov9 WORKDIR /yolov9
ADD https://github.com/WongKinYiu/yolov9.git . ADD https://github.com/WongKinYiu/yolov9.git .
RUN uv pip install --system -r requirements.txt RUN uv pip install --system -r requirements.txt
RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier>=0.4.1 onnxscript
ARG MODEL_SIZE ARG MODEL_SIZE
ARG IMG_SIZE ARG IMG_SIZE
ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt

View File

@@ -37,18 +37,18 @@ The following diagram adds a lot more detail than the simple view explained befo
%%{init: {"themeVariables": {"edgeLabelBackground": "transparent"}}}%% %%{init: {"themeVariables": {"edgeLabelBackground": "transparent"}}}%%
flowchart TD flowchart TD
RecStore[(Recording<br>store)] RecStore[(Recording\nstore)]
SnapStore[(Snapshot<br>store)] SnapStore[(Snapshot\nstore)]
subgraph Acquisition subgraph Acquisition
Cam["Camera"] -->|FFmpeg supported| Stream Cam["Camera"] -->|FFmpeg supported| Stream
Cam -->|"Other streaming<br>protocols"| go2rtc Cam -->|"Other streaming\nprotocols"| go2rtc
go2rtc("go2rtc") --> Stream go2rtc("go2rtc") --> Stream
Stream[Capture main and<br>sub streams] --> |detect stream|Decode(Decode and<br>downscale) Stream[Capture main and\nsub streams] --> |detect stream|Decode(Decode and\ndownscale)
end end
subgraph Motion subgraph Motion
Decode --> MotionM(Apply<br>motion masks) Decode --> MotionM(Apply\nmotion masks)
MotionM --> MotionD(Motion<br>detection) MotionM --> MotionD(Motion\ndetection)
end end
subgraph Detection subgraph Detection
MotionD --> |motion regions| ObjectD(Object detection) MotionD --> |motion regions| ObjectD(Object detection)
@@ -60,8 +60,8 @@ flowchart TD
MotionD --> |motion event|Birdseye MotionD --> |motion event|Birdseye
ObjectZ --> |object event|Birdseye ObjectZ --> |object event|Birdseye
MotionD --> |"video segments<br>(retain motion)"|RecStore MotionD --> |"video segments\n(retain motion)"|RecStore
ObjectZ --> |detection clip|RecStore ObjectZ --> |detection clip|RecStore
Stream -->|"video segments<br>(retain all)"| RecStore Stream -->|"video segments\n(retain all)"| RecStore
ObjectZ --> |detection snapshot|SnapStore ObjectZ --> |detection snapshot|SnapStore
``` ```

View File

@@ -986,16 +986,7 @@ async def require_camera_access(
current_user = await get_current_user(request) current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse): if isinstance(current_user, JSONResponse):
detail = "Authentication required" return current_user
try:
error_payload = json.loads(current_user.body)
detail = (
error_payload.get("message") or error_payload.get("detail") or detail
)
except Exception:
pass
raise HTTPException(status_code=current_user.status_code, detail=detail)
role = current_user["role"] role = current_user["role"]
all_camera_names = set(request.app.frigate_config.cameras.keys()) all_camera_names = set(request.app.frigate_config.cameras.keys())
@@ -1013,61 +1004,6 @@ async def require_camera_access(
) )
def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
owner_cameras: set[str] = set()
for camera_name, camera in request.app.frigate_config.cameras.items():
if stream_name == camera_name:
owner_cameras.add(camera_name)
continue
if stream_name in camera.live.streams.values():
owner_cameras.add(camera_name)
return owner_cameras
async def require_go2rtc_stream_access(
stream_name: Optional[str] = None,
request: Request = None,
):
"""Dependency to enforce go2rtc stream access based on owning camera access."""
if stream_name is None:
return
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
detail = "Authentication required"
try:
error_payload = json.loads(current_user.body)
detail = (
error_payload.get("message") or error_payload.get("detail") or detail
)
except Exception:
pass
raise HTTPException(status_code=current_user.status_code, detail=detail)
role = current_user["role"]
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
# Admin or full access bypasses
if role == "admin" or not roles_dict.get(role):
return
owner_cameras = _get_stream_owner_cameras(request, stream_name)
if owner_cameras & set(allowed_cameras):
return
raise HTTPException(
status_code=403,
detail=f"Access denied to camera '{stream_name}'. Allowed: {allowed_cameras}",
)
async def get_allowed_cameras_for_filter(request: Request): async def get_allowed_cameras_for_filter(request: Request):
"""Dependency to get allowed_cameras for filtering lists.""" """Dependency to get allowed_cameras for filtering lists."""
current_user = await get_current_user(request) current_user = await get_current_user(request)

View File

@@ -17,7 +17,7 @@ from zeep.transports import AsyncTransport
from frigate.api.auth import ( from frigate.api.auth import (
allow_any_authenticated, allow_any_authenticated,
require_go2rtc_stream_access, require_camera_access,
require_role, require_role,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
@@ -71,27 +71,14 @@ def go2rtc_streams():
@router.get( @router.get(
"/go2rtc/streams/{stream_name}", "/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)]
dependencies=[Depends(require_go2rtc_stream_access)],
) )
def go2rtc_camera_stream(request: Request, stream_name: str): def go2rtc_camera_stream(request: Request, camera_name: str):
r = requests.get( r = requests.get(
"http://127.0.0.1:1984/api/streams", f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone"
params={
"src": stream_name,
"video": "all",
"audio": "all",
"microphone": "",
},
) )
if not r.ok: if not r.ok:
camera_config = request.app.frigate_config.cameras.get(stream_name) camera_config = request.app.frigate_config.cameras.get(camera_name)
if camera_config is None:
for camera_name, camera in request.app.frigate_config.cameras.items():
if stream_name in camera.live.streams.values():
camera_config = request.app.frigate_config.cameras.get(camera_name)
break
if camera_config and camera_config.enabled: if camera_config and camera_config.enabled:
logger.error("Failed to fetch streams from go2rtc") logger.error("Failed to fetch streams from go2rtc")

View File

@@ -1,7 +1,6 @@
from unittest.mock import patch from unittest.mock import patch
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
from frigate.api.auth import ( from frigate.api.auth import (
get_allowed_cameras_for_filter, get_allowed_cameras_for_filter,
@@ -10,33 +9,6 @@ from frigate.api.auth import (
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
# Minimal multi-camera config used by go2rtc stream access tests.
# front_door has a stream alias "front_door_main"; back_door uses its own name.
# The "limited_user" role is restricted to front_door only.
_MULTI_CAMERA_CONFIG = {
"mqtt": {"host": "mqtt"},
"auth": {
"roles": {
"limited_user": ["front_door"],
}
},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"live": {"streams": {"default": "front_door_main"}},
},
"back_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
class TestCameraAccessEventReview(BaseTestHttp): class TestCameraAccessEventReview(BaseTestHttp):
def setUp(self): def setUp(self):
@@ -218,179 +190,3 @@ class TestCameraAccessEventReview(BaseTestHttp):
resp = client.get("/events/summary") resp = client.get("/events/summary")
summary_list = resp.json() summary_list = resp.json()
assert len(summary_list) == 2 assert len(summary_list) == 2
class TestGo2rtcStreamAccess(BaseTestHttp):
"""Tests for require_go2rtc_stream_access — the auth dependency on
GET /go2rtc/streams/{stream_name}.
go2rtc is not running in unit tests, so an authorized request returns
500 (the proxy call fails), while an unauthorized request returns 401/403
before the proxy is ever reached.
"""
def _make_app(self, config_override: dict | None = None):
"""Build a test app, optionally replacing self.minimal_config."""
if config_override is not None:
self.minimal_config = config_override
app = super().create_app()
# Allow tests to control the current user via request headers.
async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
if not username or not role:
from fastapi.responses import JSONResponse
return JSONResponse(
content={"message": "No authorization headers."},
status_code=401,
)
return {"username": username, "role": role}
app.dependency_overrides[get_current_user] = mock_get_current_user
return app
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings])
def tearDown(self):
super().tearDown()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_stream(
self, app, stream_name: str, role: str = "admin", user: str = "test"
):
"""Issue GET /go2rtc/streams/{stream_name} with the given role."""
with AuthTestClient(app) as client:
return client.get(
f"/go2rtc/streams/{stream_name}",
headers={"remote-user": user, "remote-role": role},
)
# ------------------------------------------------------------------
# Tests
# ------------------------------------------------------------------
def test_admin_can_access_any_stream(self):
"""Admin role bypasses camera restrictions."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
# front_door stream — go2rtc is not running so expect 500, not 401/403
resp = self._get_stream(app, "front_door", role="admin")
assert resp.status_code not in (401, 403), (
f"Admin should not be blocked; got {resp.status_code}"
)
# back_door stream
resp = self._get_stream(app, "back_door", role="admin")
assert resp.status_code not in (401, 403)
def test_missing_auth_headers_returns_401(self):
"""Requests without auth headers must be rejected with 401."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
# Use plain TestClient (not AuthTestClient) so no headers are injected.
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.get("/go2rtc/streams/front_door")
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}"
def test_unconfigured_role_can_access_any_stream(self):
"""When no camera restrictions are configured for a role the user
should have access to all streams (no roles_dict entry ⇒ no restriction)."""
no_roles_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
app = self._make_app(no_roles_config)
# "myuser" role is not listed in roles_dict — should be allowed everywhere
for stream in ("front_door", "back_door"):
resp = self._get_stream(app, stream, role="myuser")
assert resp.status_code not in (401, 403), (
f"Unconfigured role should not be blocked on '{stream}'; "
f"got {resp.status_code}"
)
def test_restricted_role_can_access_allowed_camera(self):
"""limited_user role (restricted to front_door) can access front_door stream."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
resp = self._get_stream(app, "front_door", role="limited_user")
assert resp.status_code not in (401, 403), (
f"limited_user should be allowed on front_door; got {resp.status_code}"
)
def test_restricted_role_blocked_from_disallowed_camera(self):
"""limited_user role (restricted to front_door) cannot access back_door stream."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
resp = self._get_stream(app, "back_door", role="limited_user")
assert resp.status_code == 403, (
f"limited_user should be denied on back_door; got {resp.status_code}"
)
def test_stream_alias_allowed_for_owning_camera(self):
"""Stream alias 'front_door_main' is owned by front_door; limited_user (who
is allowed front_door) should be permitted."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
# front_door_main is the alias defined in live.streams for front_door
resp = self._get_stream(app, "front_door_main", role="limited_user")
assert resp.status_code not in (401, 403), (
f"limited_user should be allowed on alias front_door_main; "
f"got {resp.status_code}"
)
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see."""
# Give back_door a stream alias and restrict limited_user to front_door only
config = {
"mqtt": {"host": "mqtt"},
"auth": {
"roles": {
"limited_user": ["front_door"],
}
},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"live": {"streams": {"default": "back_door_main"}},
},
},
}
app = self._make_app(config)
resp = self._get_stream(app, "back_door_main", role="limited_user")
assert resp.status_code == 403, (
f"limited_user should be denied on alias back_door_main; "
f"got {resp.status_code}"
)

View File

@@ -77,7 +77,6 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useHasFullCameraAccess } from "@/hooks/use-has-full-camera-access";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state"; import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
@@ -678,7 +677,7 @@ export function CameraGroupEdit({
); );
const allowedCameras = useAllowedCameras(); const allowedCameras = useAllowedCameras();
const hasFullCameraAccess = useHasFullCameraAccess(); const isAdmin = useIsAdmin();
const [openCamera, setOpenCamera] = useState<string | null>(); const [openCamera, setOpenCamera] = useState<string | null>();
@@ -867,7 +866,8 @@ export function CameraGroupEdit({
<FormDescription>{t("group.cameras.desc")}</FormDescription> <FormDescription>{t("group.cameras.desc")}</FormDescription>
<FormMessage /> <FormMessage />
{[ {[
...(birdseyeConfig?.enabled && hasFullCameraAccess ...(birdseyeConfig?.enabled &&
(isAdmin || "birdseye" in allowedCameras)
? ["birdseye"] ? ["birdseye"]
: []), : []),
...Object.keys(config?.cameras ?? {}) ...Object.keys(config?.cameras ?? {})

View File

@@ -18,25 +18,18 @@ export default function useCameraLiveMode(
const streamNames = new Set<string>(); const streamNames = new Set<string>();
cameras.forEach((camera) => { cameras.forEach((camera) => {
if (activeStreams && activeStreams[camera.name]) { const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes(
const selectedStreamName = activeStreams[camera.name]; Object.values(camera.live.streams)[0],
const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes( );
selectedStreamName,
);
if (isRestreamed) { if (isRestreamed) {
streamNames.add(selectedStreamName); if (activeStreams && activeStreams[camera.name]) {
} streamNames.add(activeStreams[camera.name]);
} else { } else {
Object.values(camera.live.streams).forEach((streamName) => { Object.values(camera.live.streams).forEach((streamName) => {
const isRestreamed = Object.keys(
config.go2rtc.streams || {},
).includes(streamName);
if (isRestreamed) {
streamNames.add(streamName); streamNames.add(streamName);
} });
}); }
} }
}); });
@@ -73,11 +66,11 @@ export default function useCameraLiveMode(
} = {}; } = {};
cameras.forEach((camera) => { cameras.forEach((camera) => {
const selectedStreamName =
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
const isRestreamed = const isRestreamed =
config && config &&
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName); Object.keys(config.go2rtc.streams || {}).includes(
Object.values(camera.live.streams)[0],
);
newIsRestreamedStates[camera.name] = isRestreamed ?? false; newIsRestreamedStates[camera.name] = isRestreamed ?? false;
@@ -108,21 +101,14 @@ export default function useCameraLiveMode(
setPreferredLiveModes(newPreferredLiveModes); setPreferredLiveModes(newPreferredLiveModes);
setIsRestreamedStates(newIsRestreamedStates); setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates); setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [activeStreams, cameras, config, windowVisible, streamMetadata]); }, [cameras, config, windowVisible, streamMetadata]);
const resetPreferredLiveMode = useCallback( const resetPreferredLiveMode = useCallback(
(cameraName: string) => { (cameraName: string) => {
const mseSupported = const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window; "MediaSource" in window || "ManagedMediaSource" in window;
const cameraConfig = cameras.find((camera) => camera.name === cameraName);
const selectedStreamName =
activeStreams?.[cameraName] ??
(cameraConfig
? Object.values(cameraConfig.live.streams)[0]
: cameraName);
const isRestreamed = const isRestreamed =
config && config && Object.keys(config.go2rtc.streams || {}).includes(cameraName);
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
setPreferredLiveModes((prevModes) => { setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes }; const newModes = { ...prevModes };
@@ -136,7 +122,7 @@ export default function useCameraLiveMode(
return newModes; return newModes;
}); });
}, },
[activeStreams, cameras, config], [config],
); );
return { return {

View File

@@ -1,26 +0,0 @@
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
/**
* Returns true if the current user has access to all cameras.
* This is used to determine birdseye access — users who can see
* all cameras should also be able to see the birdseye view.
*/
export function useHasFullCameraAccess() {
const allowedCameras = useAllowedCameras();
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
if (!config?.cameras) return false;
const enabledCameraNames = Object.entries(config.cameras)
.filter(([, cam]) => cam.enabled_in_config)
.map(([name]) => name);
return (
enabledCameraNames.length > 0 &&
enabledCameraNames.every((name) => allowedCameras.includes(name))
);
}

View File

@@ -11,12 +11,12 @@ import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useHasFullCameraAccess } from "@/hooks/use-has-full-camera-access"; import { useIsAdmin } from "@/hooks/use-is-admin";
function Live() { function Live() {
const { t } = useTranslation(["views/live"]); const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const hasFullCameraAccess = useHasFullCameraAccess(); const isAdmin = useIsAdmin();
// selection // selection
@@ -90,8 +90,8 @@ function Live() {
const allowedCameras = useAllowedCameras(); const allowedCameras = useAllowedCameras();
const includesBirdseye = useMemo(() => { const includesBirdseye = useMemo(() => {
// Users without access to all cameras should not have access to birdseye // Restricted users should never have access to birdseye
if (!hasFullCameraAccess) { if (!isAdmin) {
return false; return false;
} }
@@ -106,7 +106,7 @@ function Live() {
} else { } else {
return false; return false;
} }
}, [config, cameraGroup, hasFullCameraAccess]); }, [config, cameraGroup, isAdmin]);
const cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config) { if (!config) {
@@ -151,9 +151,7 @@ function Live() {
return ( return (
<div className="size-full" ref={mainRef}> <div className="size-full" ref={mainRef}>
{selectedCameraName === "birdseye" && {selectedCameraName === "birdseye" ? (
hasFullCameraAccess &&
config?.birdseye?.enabled ? (
<LiveBirdseyeView <LiveBirdseyeView
supportsFullscreen={supportsFullScreen} supportsFullscreen={supportsFullScreen}
fullscreen={fullscreen} fullscreen={fullscreen}