mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
sapshots
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-B8x6P1kX.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CrSh4bKN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-MAURNhur.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -866,26 +866,42 @@ function startSnapshotPolling() {
|
||||
}, SNAPSHOT_POLL_MS);
|
||||
}
|
||||
|
||||
function addSnapshotSubscription(socket) {
|
||||
function normalizeSnapshotIds(payload = {}) {
|
||||
/*
|
||||
PTZ only has one camera today, but accepting the same { ids } payload shape
|
||||
as rover snapshots keeps the browser subscription lifecycle consistent.
|
||||
Unknown ids are ignored rather than treated as separate PTZ cameras.
|
||||
*/
|
||||
const rawIds = Array.isArray(payload.ids) ? payload.ids : [payload.id || PTZ_CAMERA_ID];
|
||||
const ids = rawIds.map((id) => String(id || '').trim()).filter((id) => id === PTZ_CAMERA_ID);
|
||||
return ids.length ? ids : [PTZ_CAMERA_ID];
|
||||
}
|
||||
|
||||
function addSnapshotSubscription(socket, ids = [PTZ_CAMERA_ID]) {
|
||||
if (!ids.includes(PTZ_CAMERA_ID)) return;
|
||||
if (!snapshotSubscribers.has(PTZ_CAMERA_ID)) snapshotSubscribers.set(PTZ_CAMERA_ID, new Set());
|
||||
snapshotSubscribers.get(PTZ_CAMERA_ID).add(socket.id);
|
||||
if (!socketSnapshotSubscriptions.has(socket.id)) socketSnapshotSubscriptions.set(socket.id, new Set());
|
||||
socketSnapshotSubscriptions.get(socket.id).add(PTZ_CAMERA_ID);
|
||||
}
|
||||
|
||||
function removeSnapshotSubscriptions(socketId) {
|
||||
function removeSnapshotSubscriptions(socketId, ids = null) {
|
||||
const bucket = socketSnapshotSubscriptions.get(socketId);
|
||||
if (!bucket) return;
|
||||
bucket.forEach((id) => {
|
||||
const idsToRemove = ids ? new Set(ids) : bucket;
|
||||
idsToRemove.forEach((id) => {
|
||||
const subscribers = snapshotSubscribers.get(id);
|
||||
if (subscribers) {
|
||||
subscribers.delete(socketId);
|
||||
if (!subscribers.size) snapshotSubscribers.delete(id);
|
||||
}
|
||||
bucket.delete(id);
|
||||
});
|
||||
if (!bucket.size) {
|
||||
socketSnapshotSubscriptions.delete(socketId);
|
||||
snapshotLastSentBySocket.delete(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
function sendSnapshotFrame(socket, buffer, ts) {
|
||||
socket.emit('ptzCamera:snapshotFrame', { id: PTZ_CAMERA_ID, ts }, buffer);
|
||||
@@ -990,18 +1006,20 @@ function registerSocketHandlers() {
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
|
||||
addSnapshotSubscription(socket);
|
||||
const ids = normalizeSnapshotIds(payload);
|
||||
addSnapshotSubscription(socket, ids);
|
||||
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
|
||||
cb({ ok: true, subscribed: [PTZ_CAMERA_ID] });
|
||||
cb({ ok: true, subscribed: ids });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:snapshotUnsubscribe', () => {
|
||||
removeSnapshotSubscriptions(socket.id);
|
||||
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
|
||||
const { payload } = normalizeSocketArgs(firstArg, secondArg);
|
||||
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
if (state.operatorSocketId === socket.id) {
|
||||
|
||||
@@ -29,6 +29,9 @@ export default function PtzLiveVideo({
|
||||
const videoRef = useRef(null);
|
||||
const retryTimerRef = useRef(null);
|
||||
const playTimerRef = useRef(null);
|
||||
const enabledRef = useRef(enabled);
|
||||
const fallbackRef = useRef(false);
|
||||
const playerGenerationRef = useRef(0);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [restartToken, setRestartToken] = useState(0);
|
||||
@@ -39,8 +42,30 @@ export default function PtzLiveVideo({
|
||||
const source = sources[PTZ_CAMERA_ID] || null;
|
||||
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
|
||||
|
||||
useEffect(() => {
|
||||
enabledRef.current = enabled;
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
fallbackRef.current = shouldUseFallback;
|
||||
}, [shouldUseFallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !shouldUseFallback) return undefined;
|
||||
/*
|
||||
A retry that was scheduled before the server denied live access should not
|
||||
keep firing in snapshot mode. Clear it when live playback is no longer the
|
||||
active display policy.
|
||||
*/
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [enabled, shouldUseFallback]);
|
||||
|
||||
const scheduleRestart = useCallback(() => {
|
||||
if (!enabled || shouldUseFallback) return;
|
||||
if (!enabledRef.current || fallbackRef.current) return;
|
||||
/*
|
||||
WHEP sessions are one-shot browser/server negotiations. When the camera
|
||||
reboots, the old PeerConnection and token can look alive enough to keep a
|
||||
@@ -53,18 +78,25 @@ export default function PtzLiveVideo({
|
||||
retryTimerRef.current = null;
|
||||
setRestartToken(Date.now());
|
||||
}, RESTART_DELAY_MS);
|
||||
}, [enabled, shouldUseFallback]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
||||
let active = true;
|
||||
const generation = playerGenerationRef.current + 1;
|
||||
playerGenerationRef.current = generation;
|
||||
const player = new WhepPlayer({
|
||||
url: source.url,
|
||||
token: source.token,
|
||||
video: videoRef.current,
|
||||
startMuted,
|
||||
onStatus: (nextStatus, info) => {
|
||||
if (!active) return;
|
||||
/*
|
||||
Old PeerConnection callbacks can arrive after React has already
|
||||
cleaned up this effect for a newer token. Only the currently-owned
|
||||
generation is allowed to update status or schedule another restart.
|
||||
*/
|
||||
if (!active || playerGenerationRef.current !== generation) return;
|
||||
const normalized = String(nextStatus || '').toLowerCase();
|
||||
setStatus(nextStatus || 'unknown');
|
||||
setDetail(info || null);
|
||||
@@ -75,7 +107,7 @@ export default function PtzLiveVideo({
|
||||
});
|
||||
|
||||
player.start().catch((err) => {
|
||||
if (!active) return;
|
||||
if (!active || playerGenerationRef.current !== generation) return;
|
||||
setStatus('error');
|
||||
setDetail(err.message || 'WHEP start failed');
|
||||
scheduleRestart();
|
||||
@@ -95,13 +127,16 @@ export default function PtzLiveVideo({
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!enabled || shouldUseFallback || !source?.url || !video) return undefined;
|
||||
const generation = playerGenerationRef.current;
|
||||
|
||||
const handleEnded = () => {
|
||||
if (playerGenerationRef.current !== generation) return;
|
||||
setStatus('stopped');
|
||||
setDetail('ended');
|
||||
scheduleRestart();
|
||||
};
|
||||
const handleError = () => {
|
||||
if (playerGenerationRef.current !== generation) return;
|
||||
setStatus('error');
|
||||
setDetail(video.error?.message || 'video element error');
|
||||
scheduleRestart();
|
||||
@@ -115,12 +150,6 @@ export default function PtzLiveVideo({
|
||||
};
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'stopped' && source?.url && !shouldUseFallback) {
|
||||
scheduleRestart();
|
||||
}
|
||||
}, [scheduleRestart, shouldUseFallback, source?.url, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!enabled || shouldUseFallback || startMuted || !source?.url || !video) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { usePtzCameraSnapshot } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
@@ -304,7 +304,8 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
||||
const { ptzRelease } = useSessionActions();
|
||||
const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator });
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -418,7 +419,8 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const { ptzClaim, ptzRelease } = useSessionActions();
|
||||
const snapshot = usePtzCameraSnapshot({ enabled: Boolean(featureEnabled) });
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: Boolean(featureEnabled) });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const [controllerOpen, setControllerOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
// Hook: usePtzCameraSnapshot
|
||||
// Purpose: Subscribes to the server-generated PTZ camera snapshot feed.
|
||||
// Scope: Mirrors the rover snapshot object-URL lifecycle while using PTZ-specific socket events and authorization.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
// Hook: usePtzCameraSnapshots
|
||||
// Purpose: Subscribes to PTZ snapshot streams using the same map-shaped contract as rover snapshots.
|
||||
// Scope: Keeps PTZ snapshot lifecycle boring: callers pass source ids and receive feeds keyed by id.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
|
||||
export function usePtzCameraSnapshot(options = {}) {
|
||||
export const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
|
||||
export function usePtzCameraSnapshots(sourceList = [], options = {}) {
|
||||
const socket = useSocket();
|
||||
const { enabled = true, version = null } = options;
|
||||
const [feed, setFeed] = useState(null);
|
||||
const objectUrlRef = useRef(null);
|
||||
const [feeds, setFeeds] = useState({});
|
||||
const objectUrls = useRef(new Map());
|
||||
const ids = useMemo(
|
||||
() => sourceList.map((entry) => (typeof entry === 'string' ? entry : entry?.id)).filter(Boolean),
|
||||
[sourceList],
|
||||
);
|
||||
const idsKey = useMemo(() => {
|
||||
const base = ids.join('|');
|
||||
return version ? `${base}|v:${version}` : base;
|
||||
}, [ids, version]);
|
||||
const idsRef = useRef([]);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
const statsRef = useRef(new Map());
|
||||
const debugSnapshots =
|
||||
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return undefined;
|
||||
@@ -19,56 +33,104 @@ export function usePtzCameraSnapshot(options = {}) {
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
setFeed(null);
|
||||
}, [version]);
|
||||
idsRef.current = ids;
|
||||
}, [idsKey, ids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !socket) return undefined;
|
||||
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrls.current.clear();
|
||||
setFeeds({});
|
||||
}, [idsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrls.current.clear();
|
||||
setFeeds({});
|
||||
return undefined;
|
||||
}
|
||||
if (!idsRef.current.length || !socket) return undefined;
|
||||
let cancelled = false;
|
||||
const currentIds = idsRef.current;
|
||||
|
||||
const handleFrame = (meta = {}, buffer) => {
|
||||
if (cancelled || !buffer) return;
|
||||
if (cancelled || !meta.id || !buffer) return;
|
||||
const sizeBytes = buffer.byteLength ?? buffer.length ?? 0;
|
||||
const now = Date.now();
|
||||
const prevStats = statsRef.current.get(meta.id) || {
|
||||
count: 0,
|
||||
totalBytes: 0,
|
||||
lastLogAt: 0,
|
||||
};
|
||||
const nextStats = {
|
||||
count: prevStats.count + 1,
|
||||
totalBytes: prevStats.totalBytes + sizeBytes,
|
||||
lastLogAt: prevStats.lastLogAt,
|
||||
};
|
||||
if (debugSnapshots && (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000)) {
|
||||
const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0;
|
||||
console.log(
|
||||
'[ptzSnapshot]',
|
||||
meta.id,
|
||||
`frame=${sizeBytes}B`,
|
||||
`avg=${Math.round(avgBytes)}B`,
|
||||
`count=${nextStats.count}`,
|
||||
);
|
||||
nextStats.lastLogAt = now;
|
||||
}
|
||||
statsRef.current.set(meta.id, nextStats);
|
||||
const url = URL.createObjectURL(new Blob([buffer], { type: 'image/jpeg' }));
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = url;
|
||||
setFeed({
|
||||
const prevUrl = objectUrls.current.get(meta.id);
|
||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||
objectUrls.current.set(meta.id, url);
|
||||
setFeeds((prev) => ({
|
||||
...prev,
|
||||
[meta.id]: {
|
||||
status: 'playing',
|
||||
ts: meta.ts || Date.now(),
|
||||
error: null,
|
||||
objectUrl: url,
|
||||
});
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleStatus = (meta = {}) => {
|
||||
if (cancelled) return;
|
||||
setFeed((prev) => ({
|
||||
...(prev || {}),
|
||||
status: meta.error ? 'error' : prev?.status || 'connecting',
|
||||
if (cancelled || !meta.id) return;
|
||||
setFeeds((prev) => ({
|
||||
...prev,
|
||||
[meta.id]: {
|
||||
...(prev[meta.id] || {}),
|
||||
status: meta.error ? 'error' : prev[meta.id]?.status || 'connecting',
|
||||
error: meta.error || null,
|
||||
ts: meta.ts || prev?.ts || null,
|
||||
objectUrl: prev?.objectUrl || null,
|
||||
ts: meta.ts || prev[meta.id]?.ts || null,
|
||||
objectUrl: prev[meta.id]?.objectUrl || null,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
socket.on('ptzCamera:snapshotFrame', handleFrame);
|
||||
socket.on('ptzCamera:snapshotStatus', handleStatus);
|
||||
socket.emit('ptzCamera:snapshotSubscribe', {}, () => {});
|
||||
socket.emit('ptzCamera:snapshotSubscribe', { ids: currentIds }, () => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('ptzCamera:snapshotUnsubscribe');
|
||||
socket.emit('ptzCamera:snapshotUnsubscribe', { ids: currentIds });
|
||||
socket.off('ptzCamera:snapshotFrame', handleFrame);
|
||||
socket.off('ptzCamera:snapshotStatus', handleStatus);
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrls.current.clear();
|
||||
};
|
||||
}, [socket, enabled, version, connectionNonce]);
|
||||
}, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
|
||||
|
||||
return feed;
|
||||
return feeds;
|
||||
}
|
||||
|
||||
export function usePtzCameraSnapshot(options = {}) {
|
||||
/*
|
||||
This wrapper keeps older callers working while new PTZ UI uses the same
|
||||
keyed feed shape as useRoverSnapshots(). It should stay tiny so the real
|
||||
subscription behavior only has one implementation to maintain.
|
||||
*/
|
||||
const feeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], options);
|
||||
return feeds[PTZ_CAMERA_ID] || null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// falls back to the PTZ snapshot feed that remote spectators are allowed to see.
|
||||
import PtzLiveVideo from '../../../components/PtzLiveVideo/index.jsx';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { usePtzCameraSnapshot } from '../../../hooks/usePtzCameraSnapshot.js';
|
||||
import { PTZ_CAMERA_ID, usePtzCameraSnapshots } from '../../../hooks/usePtzCameraSnapshot.js';
|
||||
|
||||
function formatRemaining(deadline) {
|
||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
||||
@@ -41,7 +41,8 @@ function InfoRow({ label, value, tone = '' }) {
|
||||
}
|
||||
|
||||
function PtzSnapshotFallback({ label, source }) {
|
||||
const snapshot = usePtzCameraSnapshot({ enabled: true });
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: true });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
return (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded bg-black">
|
||||
{snapshot?.objectUrl ? (
|
||||
|
||||
Reference in New Issue
Block a user