feat: support multiple camera previews and concurrent agent code navigation
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
|
||||
const cameras = new Map();
|
||||
let enabled = false;
|
||||
let timer = null;
|
||||
|
||||
function sources() {
|
||||
const { state, receivedAt } = getState();
|
||||
if (!enabled || Date.now() - receivedAt >= 3000) return [];
|
||||
const list = Array.isArray(state?.cameras) ? state.cameras
|
||||
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
|
||||
const seen = new Set();
|
||||
return list.filter(item => {
|
||||
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
}).slice(0, 8);
|
||||
}
|
||||
|
||||
function clearFrame(camera) {
|
||||
if (camera.url) URL.revokeObjectURL(camera.url);
|
||||
camera.url = null;
|
||||
camera.image.removeAttribute('src');
|
||||
}
|
||||
|
||||
function remove(id, camera) {
|
||||
cameras.delete(id);
|
||||
camera.controller?.abort();
|
||||
clearFrame(camera);
|
||||
camera.element.remove();
|
||||
}
|
||||
|
||||
async function frame(id, camera) {
|
||||
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
|
||||
const controller = new AbortController();
|
||||
camera.controller = controller;
|
||||
const timeout = window.setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
|
||||
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
|
||||
if (!response.ok) throw new Error('Camera frame unavailable');
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
|
||||
const previous = camera.url;
|
||||
camera.url = URL.createObjectURL(blob);
|
||||
camera.image.src = camera.url;
|
||||
camera.retryAt = 0;
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
} catch (_) {
|
||||
if (cameras.get(id) === camera) {
|
||||
camera.retryAt = Date.now() + 5000;
|
||||
clearFrame(camera);
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (camera.controller === controller) camera.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const live = sources();
|
||||
const ids = new Set(live.map(item => item.id));
|
||||
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
|
||||
for (const item of live) {
|
||||
let camera = cameras.get(item.id);
|
||||
if (!camera) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'camera-preview-item';
|
||||
element.dataset.cameraId = item.id;
|
||||
const label = document.createElement('div');
|
||||
label.className = 'camera-preview-label';
|
||||
const image = document.createElement('img');
|
||||
image.className = 'camera-preview-image';
|
||||
element.append(label, image);
|
||||
root.append(element);
|
||||
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
|
||||
image.addEventListener('error', () => {
|
||||
if (cameras.get(item.id) === camera) {
|
||||
clearFrame(camera);
|
||||
camera.retryAt = Date.now() + 5000;
|
||||
}
|
||||
});
|
||||
cameras.set(item.id, camera);
|
||||
}
|
||||
camera.label.textContent = item.name || item.id;
|
||||
camera.image.alt = item.name || item.id;
|
||||
void frame(item.id, camera);
|
||||
}
|
||||
onAvailable(enabled && cameras.size > 0);
|
||||
}
|
||||
|
||||
function visibilityChanged() {
|
||||
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
|
||||
else refresh();
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityChanged);
|
||||
|
||||
function setEnabled(value) {
|
||||
enabled = value;
|
||||
if (timer) window.clearInterval(timer);
|
||||
timer = null;
|
||||
refresh();
|
||||
if (enabled) timer = window.setInterval(refresh, 200);
|
||||
}
|
||||
|
||||
return {
|
||||
setEnabled,
|
||||
refresh,
|
||||
dispose() {
|
||||
setEnabled(false);
|
||||
document.removeEventListener('visibilitychange', visibilityChanged);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -61,14 +61,29 @@
|
||||
#cameraPreviewWrap.is-visible { display:block; }
|
||||
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
|
||||
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
|
||||
#cameraPreview {
|
||||
#cameraPreviewList {
|
||||
max-height:min(60vh, 600px);
|
||||
overflow-y:auto;
|
||||
scrollbar-width:thin;
|
||||
scrollbar-color:rgba(148,163,184,.65) transparent;
|
||||
}
|
||||
.camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); }
|
||||
.camera-preview-label {
|
||||
padding:5px 8px;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
color:rgba(255,255,255,.85);
|
||||
font:11px sans-serif;
|
||||
}
|
||||
.camera-preview-image {
|
||||
display:block;
|
||||
width:100%;
|
||||
aspect-ratio:16 / 9;
|
||||
object-fit:contain;
|
||||
background:#111520;
|
||||
}
|
||||
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
|
||||
#cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
|
||||
#cameraPreviewToggle {
|
||||
position:absolute;
|
||||
left:0;
|
||||
@@ -426,7 +441,7 @@
|
||||
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
|
||||
<div id="cameraPreviewWrap">
|
||||
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
|
||||
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" />
|
||||
<div id="cameraPreviewList"></div>
|
||||
</div>
|
||||
<div id="keyGuide">
|
||||
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
|
||||
@@ -463,6 +478,7 @@
|
||||
<script type="module" src="./locale.js"></script>
|
||||
<script type="module">
|
||||
import { t, setText } from './locale.js';
|
||||
import { createCameraPreview } from './camera-preview.js';
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
@@ -502,7 +518,7 @@ const miniMap = document.getElementById('miniMap');
|
||||
const miniCtx = miniMap.getContext('2d');
|
||||
const miniMapToggle = document.getElementById('miniMapToggle');
|
||||
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
|
||||
const cameraPreview = document.getElementById('cameraPreview');
|
||||
const cameraPreviewList = document.getElementById('cameraPreviewList');
|
||||
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
|
||||
const cameraToggleButton = document.getElementById('cameraToggle');
|
||||
const topRightControls = document.getElementById('topRightControls');
|
||||
@@ -937,12 +953,7 @@ let pointCloudRefreshTimer = null;
|
||||
let savedMapPointCloudLoaded = false;
|
||||
let sensorPointCloudRequestInFlight = false;
|
||||
let sensorPointCloudRefreshTimer = null;
|
||||
let cameraFrameRequestInFlight = false;
|
||||
let cameraFrameRetryAt = 0;
|
||||
let cameraFrameRefreshTimer = null;
|
||||
let cameraFrameObjectUrl = null;
|
||||
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
|
||||
let cameraFrameAbortController = null;
|
||||
let pathVisible = false;
|
||||
// SLAM always renders its live scan, including the forward beam. Navigation
|
||||
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
||||
@@ -2087,84 +2098,27 @@ async function refreshSensorPointCloud() {
|
||||
}
|
||||
}
|
||||
|
||||
function setCameraPreviewAvailable(available) {
|
||||
available = available && cameraPreviewEnabled;
|
||||
cameraPreviewWrap?.classList.toggle('is-visible', available);
|
||||
if (available) return;
|
||||
if (cameraFrameObjectUrl) {
|
||||
URL.revokeObjectURL(cameraFrameObjectUrl);
|
||||
cameraFrameObjectUrl = null;
|
||||
}
|
||||
if (cameraPreview) cameraPreview.removeAttribute('src');
|
||||
}
|
||||
|
||||
function hasLiveCameraState() {
|
||||
return latestSlamState?.topic_status?.camera_image?.available === true
|
||||
&& Date.now() - latestSlamStateReceivedAt < 3000;
|
||||
}
|
||||
|
||||
async function refreshCameraFrame() {
|
||||
if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
|
||||
// Reuse websocket topic status instead of probing an absent camera with HTTP.
|
||||
if (!hasLiveCameraState()) {
|
||||
setCameraPreviewAvailable(false);
|
||||
return;
|
||||
}
|
||||
if (Date.now() < cameraFrameRetryAt) return;
|
||||
cameraFrameRequestInFlight = true;
|
||||
const controller = new AbortController();
|
||||
cameraFrameAbortController = controller;
|
||||
const timeout = window.setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
cameraFrameRetryAt = Date.now() + 5000;
|
||||
setCameraPreviewAvailable(false);
|
||||
return;
|
||||
}
|
||||
const frame = await response.blob();
|
||||
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
|
||||
setCameraPreviewAvailable(false);
|
||||
return;
|
||||
}
|
||||
cameraFrameRetryAt = 0;
|
||||
const nextUrl = URL.createObjectURL(frame);
|
||||
const previousUrl = cameraFrameObjectUrl;
|
||||
cameraFrameObjectUrl = nextUrl;
|
||||
if (cameraPreview) cameraPreview.src = nextUrl;
|
||||
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
||||
} catch (_) {
|
||||
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
|
||||
setCameraPreviewAvailable(false);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
cameraFrameAbortController = null;
|
||||
cameraFrameRequestInFlight = false;
|
||||
}
|
||||
}
|
||||
const cameraPreviewController = createCameraPreview({
|
||||
root: cameraPreviewList,
|
||||
baseUrl: BACKEND_URL,
|
||||
getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
|
||||
onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
|
||||
});
|
||||
|
||||
function setCameraPreviewEnabled(enabled) {
|
||||
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
|
||||
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
|
||||
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
|
||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
||||
cameraFrameRefreshTimer = null;
|
||||
cameraPreviewController.setEnabled(cameraPreviewEnabled);
|
||||
if (!cameraPreviewEnabled) {
|
||||
cameraFrameAbortController?.abort();
|
||||
setCameraPreviewAvailable(false);
|
||||
return;
|
||||
}
|
||||
cameraFrameRetryAt = 0;
|
||||
cameraPreviewWrap.classList.remove('is-collapsed');
|
||||
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
|
||||
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
|
||||
void refreshCameraFrame();
|
||||
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
|
||||
}
|
||||
|
||||
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
|
||||
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
|
||||
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
|
||||
|
||||
function drawMiniMap(state) {
|
||||
pendingMiniMapState = state;
|
||||
@@ -3299,9 +3253,7 @@ window.addEventListener('beforeunload', () => {
|
||||
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
||||
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
||||
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
||||
cameraFrameAbortController?.abort();
|
||||
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
|
||||
cameraPreviewController.dispose();
|
||||
});
|
||||
|
||||
function connectControlSocket() {
|
||||
|
||||
Reference in New Issue
Block a user