mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
sapshots
This commit is contained in:
@@ -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({
|
||||
status: 'playing',
|
||||
ts: meta.ts || Date.now(),
|
||||
error: null,
|
||||
objectUrl: url,
|
||||
});
|
||||
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',
|
||||
error: meta.error || null,
|
||||
ts: meta.ts || prev?.ts || null,
|
||||
objectUrl: prev?.objectUrl || null,
|
||||
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[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