Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb5872c326 | |||
| af54e875df |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lms",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.22",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.22",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import struct
|
||||
@@ -207,6 +208,8 @@ class SlamNode(Node):
|
||||
self._fallback_point_cloud: Optional[PointCloud2] = None
|
||||
self._color_point_cloud: Optional[PointCloud2] = None
|
||||
self._camera_image: Optional[CompressedImage] = None
|
||||
self._camera_frames = {}
|
||||
self._camera_subscriptions = {}
|
||||
self._icp_odom: Optional[Odometry] = None
|
||||
self._map: Optional[OccupancyGrid] = None
|
||||
self._plan: Optional[NavPath] = None
|
||||
@@ -261,6 +264,8 @@ class SlamNode(Node):
|
||||
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
|
||||
if not os.environ.get("CAMERA_IMAGE_TOPIC"):
|
||||
self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data)
|
||||
self._camera_discovery_timer = self.create_timer(
|
||||
1.0, self._discover_cameras, clock=Clock(clock_type=ClockType.STEADY_TIME))
|
||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
||||
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
||||
@@ -625,11 +630,45 @@ class SlamNode(Node):
|
||||
self._color_point_cloud_count += 1
|
||||
self._last_color_point_cloud_wall_time = time.time()
|
||||
|
||||
def _on_camera_image(self, msg: CompressedImage):
|
||||
def _discover_cameras(self):
|
||||
try:
|
||||
topics = sorted(name for name, types in self.get_topic_names_and_types()
|
||||
if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name)
|
||||
and 'sensor_msgs/msg/CompressedImage' in types)[:8]
|
||||
except Exception:
|
||||
return
|
||||
for topic in topics:
|
||||
camera_id = topic.split('/')[-2]
|
||||
if camera_id not in self._camera_subscriptions:
|
||||
self._camera_subscriptions[camera_id] = self.create_subscription(
|
||||
CompressedImage, topic,
|
||||
lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name),
|
||||
qos_profile_sensor_data)
|
||||
active = {topic.split('/')[-2] for topic in topics}
|
||||
for camera_id in list(self._camera_subscriptions):
|
||||
if camera_id not in active:
|
||||
self.destroy_subscription(self._camera_subscriptions.pop(camera_id))
|
||||
with self._lock:
|
||||
self._camera_frames.pop(camera_id, None)
|
||||
|
||||
def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None):
|
||||
with self._lock:
|
||||
if camera_id != 'legacy' and camera_id not in self._camera_subscriptions:
|
||||
return
|
||||
self._camera_image = msg
|
||||
self._camera_image_count += 1
|
||||
self._last_camera_image_wall_time = time.time()
|
||||
self._camera_frames[camera_id] = (msg, self._last_camera_image_wall_time,
|
||||
topic or self.camera_image_topic)
|
||||
|
||||
def camera_sources(self):
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
frames = list(self._camera_frames.items())
|
||||
return [{"id": key, "name": f'/dev/{key}' if key != 'legacy' else 'Camera',
|
||||
"topic": topic, "available": now - received <= _CAMERA_FRAME_MAX_AGE_SECONDS
|
||||
and bool(image.data) and 'jpeg' in image.format.lower()}
|
||||
for key, (image, received, topic) in sorted(frames)]
|
||||
|
||||
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
||||
with self._lock:
|
||||
@@ -687,7 +726,6 @@ class SlamNode(Node):
|
||||
point_cloud_last = self._last_point_cloud_wall_time
|
||||
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
|
||||
color_point_cloud_last = self._last_color_point_cloud_wall_time
|
||||
camera_image_last = self._last_camera_image_wall_time
|
||||
map_count = self._map_count
|
||||
plan_count = self._plan_count
|
||||
|
||||
@@ -727,7 +765,7 @@ class SlamNode(Node):
|
||||
},
|
||||
"camera_image": {
|
||||
"name": self.camera_image_topic,
|
||||
"available": received_recently(camera_image_last),
|
||||
"available": any(camera['available'] for camera in self.camera_sources()),
|
||||
},
|
||||
"plan": {"name": self.plan_topic, "available": plan_available},
|
||||
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
|
||||
@@ -1238,6 +1276,7 @@ class SlamNode(Node):
|
||||
"sensor_point_cloud": sensor_point_cloud_payload,
|
||||
"color_point_cloud": color_point_cloud_payload,
|
||||
"camera_image": camera_image_payload,
|
||||
"cameras": self.camera_sources(),
|
||||
"map": map_payload,
|
||||
"tf": {
|
||||
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
||||
@@ -1302,10 +1341,16 @@ class SlamNode(Node):
|
||||
payload["received_at"] = received_at
|
||||
return payload
|
||||
|
||||
def camera_frame_jpeg(self) -> Optional[bytes]:
|
||||
def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]:
|
||||
with self._lock:
|
||||
image = self._camera_image
|
||||
received_at = self._last_camera_image_wall_time
|
||||
if camera_id is not None:
|
||||
entry = self._camera_frames.get(camera_id)
|
||||
if entry is None:
|
||||
return None
|
||||
image, received_at, _topic = entry
|
||||
else:
|
||||
image = self._camera_image
|
||||
received_at = self._last_camera_image_wall_time
|
||||
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
|
||||
return None
|
||||
if not image.data or "jpeg" not in image.format.lower():
|
||||
@@ -1531,10 +1576,10 @@ def color_point_cloud():
|
||||
|
||||
|
||||
@app.get("/camera-frame")
|
||||
def camera_frame():
|
||||
def camera_frame(camera_id: Optional[str] = None):
|
||||
if not slam_node:
|
||||
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||
frame = slam_node.camera_frame_jpeg()
|
||||
frame = slam_node.camera_frame_jpeg(camera_id)
|
||||
if frame is None:
|
||||
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
|
||||
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
import unittest
|
||||
|
||||
|
||||
class CameraTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = 10.0
|
||||
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
|
||||
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
|
||||
names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg')
|
||||
methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names]
|
||||
scope = {'re': re, 'Optional': Optional, 'CompressedImage': object,
|
||||
'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3,
|
||||
'qos_profile_sensor_data': object()}
|
||||
exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope)
|
||||
self.node = type('CameraNode', (), {name: scope[name] for name in names})()
|
||||
self.node._lock = threading.Lock()
|
||||
self.node._camera_frames = {}
|
||||
self.node._camera_subscriptions = {}
|
||||
self.node._camera_image = None
|
||||
self.node._last_camera_image_wall_time = None
|
||||
self.node._camera_image_count = 0
|
||||
self.node.camera_image_topic = '/camera_test/image_jpeg'
|
||||
self.topics = []
|
||||
self.callbacks = {}
|
||||
self.node.get_topic_names_and_types = lambda: self.topics
|
||||
def subscribe(kind, topic, callback, qos):
|
||||
self.callbacks[topic] = callback
|
||||
return topic
|
||||
self.node.create_subscription = subscribe
|
||||
self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub)
|
||||
|
||||
def test_independent_frames_and_disconnect(self):
|
||||
for key in ['video0', 'video2']:
|
||||
self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage']))
|
||||
self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']),
|
||||
('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])]
|
||||
self.node._discover_cameras()
|
||||
self.assertEqual(len(self.callbacks), 2)
|
||||
for topic, callback in self.callbacks.items():
|
||||
callback(SimpleNamespace(data=topic.encode(), format='jpeg'))
|
||||
self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2'])
|
||||
self.assertIn(b'video2', self.node.camera_frame_jpeg('video2'))
|
||||
self.assertIsNone(self.node.camera_frame_jpeg('../video0'))
|
||||
self.now += 4
|
||||
self.assertTrue(all(not item['available'] for item in self.node.camera_sources()))
|
||||
self.assertIsNone(self.node.camera_frame_jpeg('video0'))
|
||||
self.topics = self.topics[:1]
|
||||
self.node._discover_cameras()
|
||||
self.assertNotIn('video2', self.node._camera_frames)
|
||||
|
||||
def test_single_camera_compatibility_and_non_jpeg(self):
|
||||
self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg'))
|
||||
self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg')
|
||||
self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg')
|
||||
self.node._on_camera_image(SimpleNamespace(data=b'png', format='png'))
|
||||
self.assertIsNone(self.node.camera_frame_jpeg('legacy'))
|
||||
self.assertFalse(self.node.camera_sources()[0]['available'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -98,6 +98,51 @@ test('legacy cancelled references can be opened with the same read-only navigati
|
||||
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||
})
|
||||
|
||||
test('code navigation does not block or abort a pending model response', async () => {
|
||||
const h = consentHarness()
|
||||
const controller = new AbortController()
|
||||
h.globals.abortControllerRef.current = controller
|
||||
let opened = 0
|
||||
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
|
||||
assert.equal(isCurrent(), true)
|
||||
opened++
|
||||
})
|
||||
assert.equal(opened, 1)
|
||||
assert.equal(controller.signal.aborted, false)
|
||||
assert.equal(h.globals.abortControllerRef.current, controller)
|
||||
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||
})
|
||||
|
||||
test('an arriving model response does not cancel an in-flight code navigation', async () => {
|
||||
const h = consentHarness()
|
||||
h.globals.abortControllerRef.current = new AbortController()
|
||||
let finish, current
|
||||
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||
current = isCurrent
|
||||
return new Promise(resolve => { finish = resolve })
|
||||
})
|
||||
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
|
||||
h.globals.abortControllerRef.current = null
|
||||
assert.equal(current(), true)
|
||||
finish()
|
||||
await pending
|
||||
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
|
||||
})
|
||||
|
||||
test('starting file application invalidates a navigation that is still reading', async () => {
|
||||
const h = consentHarness()
|
||||
let finish, current
|
||||
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||
current = isCurrent
|
||||
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
|
||||
})
|
||||
h.globals.applyingRef.current = true
|
||||
assert.equal(current(), false)
|
||||
finish()
|
||||
await pending
|
||||
assert.equal(h.message().codeReferenceStates.location.status, 'error')
|
||||
})
|
||||
|
||||
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
||||
const h = consentHarness()
|
||||
let finish, opened = 0
|
||||
|
||||
@@ -22,6 +22,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(
|
||||
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
||||
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
||||
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
||||
const AgentCodeReferences = () => null
|
||||
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
||||
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
|
||||
})
|
||||
@@ -36,6 +37,7 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
|
||||
ChatEmptyState: widgets.ChatEmptyState,
|
||||
ProgressiveChatResponse,
|
||||
ChatImageAttachments,
|
||||
AgentCodeReferences,
|
||||
AgentProgressBubble,
|
||||
AgentWorkDuration,
|
||||
MessageCopyButton: () => null,
|
||||
@@ -43,6 +45,27 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
|
||||
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
||||
})
|
||||
|
||||
test('existing code locations stay usable during model and command work but not file application', () => {
|
||||
for (const applying of [false, true]) {
|
||||
const onOpen = () => {}
|
||||
const tree = messages.AgentMessages({
|
||||
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
|
||||
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
|
||||
onOpenCodeReference: onOpen
|
||||
})
|
||||
let references
|
||||
function visit(element) {
|
||||
if (!React.isValidElement(element)) return
|
||||
if (element.type === AgentCodeReferences) references = element
|
||||
React.Children.toArray(element.props.children).forEach(visit)
|
||||
}
|
||||
visit(tree)
|
||||
assert.ok(references)
|
||||
assert.equal(references.props.busy, applying)
|
||||
assert.equal(typeof references.props.onOpen, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
||||
const tree = messages.AgentMessages({
|
||||
dark: true, enabled: true, statusLoading: false, pending: false,
|
||||
|
||||
+29
-2
@@ -2563,6 +2563,23 @@ async function startRemoteVisualizationBridge(
|
||||
].join('\n'), 900_000)
|
||||
}
|
||||
const command = bridgeContainerCommand(stack)
|
||||
let bridgeContextRevision: string | null = null
|
||||
if (stack !== 'autoware') {
|
||||
const context = resolveResourceContextPath('resources/slam-ros-bridge')
|
||||
if (!context) throw new Error('Bundled SLAM bridge source is missing')
|
||||
const files = { 'bridge.py': await readFile(join(context, 'bridge.py'), 'utf8'), 'requirements.txt': await readFile(join(context, 'requirements.txt'), 'utf8') }
|
||||
bridgeContextRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex')
|
||||
await runUnqueuedSshOutput(target, `python3 -c ${quoteShellSingle([
|
||||
'import json, os, pathlib, sys',
|
||||
'data = json.load(sys.stdin)',
|
||||
'root = pathlib.Path.home() / ".autonomy-studio/runtime/bridges" / data["revision"]',
|
||||
'root.mkdir(parents=True, exist_ok=True)',
|
||||
'for name in ("bridge.py", "requirements.txt"):',
|
||||
' temporary = root / ("." + name + "." + str(os.getpid()) + ".tmp")',
|
||||
' temporary.write_text(data["files"][name], encoding="utf-8")',
|
||||
' temporary.replace(root / name)',
|
||||
].join('\n'))}`, 30_000, JSON.stringify({ revision: bridgeContextRevision, files }))
|
||||
}
|
||||
|
||||
reportVisualizationProgress('시각화 시작 중...')
|
||||
|
||||
@@ -2580,6 +2597,7 @@ async function startRemoteVisualizationBridge(
|
||||
'docker run -d --name "$CONTAINER" --net=host',
|
||||
'-v /dev/shm:/dev/shm',
|
||||
'-v "$ROOT:/workspace"',
|
||||
...(bridgeContextRevision ? [`-v "$HOME/.autonomy-studio/runtime/bridges/${bridgeContextRevision}:/app:ro"`] : []),
|
||||
...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []),
|
||||
'-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"',
|
||||
'-e ROS_LOCALHOST_ONLY=0',
|
||||
@@ -4777,10 +4795,19 @@ function buildNav2DockerRunScript(): string {
|
||||
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
|
||||
...yahboomDevicePreflight(),
|
||||
'CAMERA_DEVICE=""',
|
||||
'CAMERA_DEVICES=()',
|
||||
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
|
||||
' CAMERA_DEVICE=/dev/video0',
|
||||
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
|
||||
' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }',
|
||||
' if [ "$CAMERA_DEVICE" = /dev ]; then',
|
||||
' CAMERA_DISCOVERY="$MODULE_ROOT/$MODULE_FOLDER/src/camera_test_pkg/camera_test_pkg/devices.py"',
|
||||
' test -f "$CAMERA_DISCOVERY" || { echo "Update and rebuild the camera module for automatic discovery" >&2; exit 1; }',
|
||||
' camera_devices=$(python3 "$CAMERA_DISCOVERY" /dev) || exit 1',
|
||||
' mapfile -t CAMERA_DEVICES <<< "$camera_devices"',
|
||||
' else CAMERA_DEVICES=("$CAMERA_DEVICE"); fi',
|
||||
' for camera_device in "${CAMERA_DEVICES[@]}"; do',
|
||||
' [[ "$camera_device" =~ ^/dev/video[0-9]+$ ]] && [ -c "$camera_device" ] || { echo "Camera device not found: $camera_device" >&2; exit 1; }',
|
||||
' done',
|
||||
'fi',
|
||||
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
|
||||
'',
|
||||
@@ -4800,7 +4827,7 @@ function buildNav2DockerRunScript(): string {
|
||||
')',
|
||||
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
|
||||
'if [ -n "$CAMERA_DEVICE" ]; then',
|
||||
' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")',
|
||||
' for camera_device in "${CAMERA_DEVICES[@]}"; do args+=(--device "$camera_device:$camera_device"); done',
|
||||
' for capture_device in /dev/capture-vi-channel*; do',
|
||||
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
|
||||
' done',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -3690,7 +3690,7 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
}
|
||||
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
|
||||
if (agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
|
||||
validateCodeReference(reference)
|
||||
const scope = agentNavigationScope
|
||||
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
|
||||
|
||||
@@ -429,6 +429,8 @@ function AgentMessages({
|
||||
)
|
||||
}
|
||||
|
||||
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{messages.map((message, index) => message.role === 'user' ? (
|
||||
@@ -447,7 +449,7 @@ function AgentMessages({
|
||||
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
|
||||
{message.plan && (
|
||||
<>
|
||||
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={busy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
|
||||
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={codeNavigationBusy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
|
||||
{!!message.plan.evidence?.length && (
|
||||
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
|
||||
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
|
||||
|
||||
@@ -460,11 +460,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
}
|
||||
|
||||
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
|
||||
if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return
|
||||
if (navigatingRef.current || applyingRef.current || messageStateRef.current.session !== session) return
|
||||
const message = messageStateRef.current.messages.find(message => message.id === messageId)
|
||||
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
|
||||
if (!reference) return
|
||||
const latestMessageId = messageStateRef.current.messages.at(-1)?.id
|
||||
const latestUserMessageId = messageStateRef.current.messages.findLast(message => message.role === 'user')?.id
|
||||
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
|
||||
const update = (state: AgentCodeReferenceState): void => {
|
||||
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
|
||||
@@ -472,7 +472,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
navigatingRef.current = true
|
||||
update({ status: 'opening' })
|
||||
try {
|
||||
await open(reference, () => isCurrent() && messageStateRef.current.messages.at(-1)?.id === latestMessageId && !abortControllerRef.current)
|
||||
await open(reference, () => isCurrent() && messageStateRef.current.messages.findLast(message => message.role === 'user')?.id === latestUserMessageId && !applyingRef.current)
|
||||
update({ status: 'opened' })
|
||||
} catch (error) {
|
||||
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const {test} = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const {createHash} = require('node:crypto')
|
||||
const {spawnSync} = require('node:child_process')
|
||||
|
||||
test('remote visualization stages the bundled bridge over stdin and mounts that revision read-only', async () => {
|
||||
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const fn = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'startRemoteVisualizationBridge')
|
||||
assert.ok(fn)
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-camera-bridge-'))
|
||||
const resource = path.resolve(__dirname, '../resources/slam-ros-bridge')
|
||||
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
|
||||
let launch, transfers = 0
|
||||
const context = {
|
||||
normalizeVisualizationStack: () => 'slam3d', normalizeRemoteTarget: target => target,
|
||||
prepareRemoteRosNetworkConfig: async () => null, visualizationBridgeContainerName: () => 'slam-ros-bridge',
|
||||
resolveVisualizationBridgeImage: () => 'fixture:arm64', bridgeArtifact: () => null,
|
||||
slamRosBridgeImage: 'slam:local',
|
||||
bridgeContainerCommand: () => 'python3 /app/bridge.py', resolveResourceContextPath: () => resource,
|
||||
readFile: fs.readFile, join: path.join, createHash, quoteShellSingle: quote,
|
||||
expandRemotePathVar: () => [], reportVisualizationProgress() {},
|
||||
runUnqueuedSshOutput: async (_target, command, timeout, input) => {
|
||||
transfers++
|
||||
assert.equal(timeout, 30000)
|
||||
assert.ok(input.length > 1000)
|
||||
const {spawn} = require('node:child_process')
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn('bash', ['-c', command], {env: {...process.env, HOME: dir}, stdio:['pipe','ignore','pipe']})
|
||||
let error = ''
|
||||
child.stderr.on('data', data => {error += data})
|
||||
child.on('error', reject)
|
||||
child.on('close', code => code === 0 ? resolve() : reject(Error(error)))
|
||||
child.stdin.end(input)
|
||||
})
|
||||
return ''
|
||||
},
|
||||
runSsh: async (_target, command) => { launch = command }
|
||||
}
|
||||
vm.runInNewContext(ts.transpileModule(fn.getText(source), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText, context)
|
||||
try {
|
||||
await context.startRemoteVisualizationBridge({workspaceRoot:'/workspace'}, 'slam3d', '77')
|
||||
assert.equal(transfers, 1)
|
||||
assert.match(launch, /-v "\$HOME\/\.autonomy-studio\/runtime\/bridges\/[a-f0-9]{64}:\/app:ro"/)
|
||||
const roots = await fs.readdir(path.join(dir, '.autonomy-studio/runtime/bridges'))
|
||||
for (const name of ['bridge.py','requirements.txt']) assert.equal(await fs.readFile(path.join(dir,'.autonomy-studio/runtime/bridges',roots[0],name),'utf8'), await fs.readFile(path.join(resource,name),'utf8'))
|
||||
const syntax = spawnSync('bash', ['-n'], {input:launch,encoding:'utf8'})
|
||||
assert.equal(syntax.status, 0, syntax.stderr)
|
||||
assert.ok(launch.includes('docker run'))
|
||||
} finally {await fs.rm(dir,{recursive:true,force:true})}
|
||||
})
|
||||
@@ -35,7 +35,10 @@ for (const script of [run, build]) {
|
||||
}
|
||||
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
|
||||
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
|
||||
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/);
|
||||
assert.match(run, /--device "\$camera_device:\$camera_device"/);
|
||||
assert.match(run, /CAMERA_DEVICE" = \/dev/);
|
||||
assert.match(run, /mapfile -t CAMERA_DEVICES/);
|
||||
assert.doesNotMatch(run, /-v \/dev:\/dev/);
|
||||
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
|
||||
assert.match(run, /--device "\$capture_device:\$capture_device"/);
|
||||
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
|
||||
|
||||
@@ -1,70 +1,103 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
|
||||
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'"));
|
||||
let now = 10000;
|
||||
let requests = 0;
|
||||
let ok = true;
|
||||
let visible = false;
|
||||
let afterBlob = () => {};
|
||||
const context = vm.createContext({
|
||||
ENABLE_CAMERA_PREVIEW: true,
|
||||
cameraFrameRequestInFlight: false,
|
||||
cameraFrameRetryAt: 0,
|
||||
cameraFrameObjectUrl: null,
|
||||
cameraPreview: {},
|
||||
latestSlamState: null,
|
||||
latestSlamStateReceivedAt: now,
|
||||
document: { hidden: false },
|
||||
Date: { now: () => now },
|
||||
BACKEND_URL: 'http://localhost:8765',
|
||||
AbortSignal,
|
||||
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} },
|
||||
setCameraPreviewAvailable(value) { visible = value; },
|
||||
async fetch() {
|
||||
requests++;
|
||||
return { ok, async blob() { afterBlob(); return {}; } };
|
||||
},
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const refresh = () => context.refreshCameraFrame();
|
||||
const available = (value) => {
|
||||
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
|
||||
context.latestSlamStateReceivedAt = now;
|
||||
};
|
||||
function harness() {
|
||||
const exports = {}, requests = [], revoked = [], pending = new Map()
|
||||
let now = 10000, state = null, receivedAt = now, visible = false, enabledFetch = true
|
||||
let sequence = 0
|
||||
function element() {
|
||||
return { children: [], dataset: {}, src: '', append(...children) { this.children.push(...children); children.forEach(child => { child.parent = this }) }, remove() { this.parent.children = this.parent.children.filter(child => child !== this) }, removeAttribute() { this.src = '' }, addEventListener() {} }
|
||||
}
|
||||
const root = element(), document = { hidden: false, createElement: element, addEventListener() {}, removeEventListener() {} }
|
||||
const globals = { exports, document, AbortController, Date: { now: () => now },
|
||||
window: { setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} },
|
||||
URL: { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: url => revoked.push(url) },
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url, signal: options.signal })
|
||||
if (pending.has(url)) await pending.get(url)
|
||||
return { ok: enabledFetch, blob: async () => ({}) }
|
||||
}
|
||||
}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/public/slam-viewer/camera-preview.js'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, globals)
|
||||
const controller = exports.createCameraPreview({ root, baseUrl: 'http://camera.test', getState: () => ({ state, receivedAt }), onAvailable: value => { visible = value } })
|
||||
return { root, requests, revoked, pending, document, controller,
|
||||
state(value) { state = value; receivedAt = now }, advance(ms) { now += ms }, fail(value) { enabledFetch = !value }, visible: () => visible,
|
||||
settle: () => new Promise(resolve => setImmediate(resolve)) }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await refresh();
|
||||
available(false);
|
||||
await refresh();
|
||||
assert.equal(requests, 0, 'Absent camera must not fetch frames');
|
||||
available(true);
|
||||
await refresh();
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(context.cameraPreview.src, 'blob:frame');
|
||||
now += 3001;
|
||||
await refresh();
|
||||
assert.equal(requests, 1, 'Stale websocket state must stop polling');
|
||||
assert.equal(visible, false);
|
||||
available(true);
|
||||
ok = false;
|
||||
await refresh();
|
||||
await refresh();
|
||||
assert.equal(requests, 2, 'HTTP errors must back off');
|
||||
now += 5000;
|
||||
available(true);
|
||||
ok = true;
|
||||
afterBlob = () => available(false);
|
||||
context.cameraPreview.src = '';
|
||||
await refresh();
|
||||
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera');
|
||||
assert.equal(context.cameraFrameRequestInFlight, false);
|
||||
afterBlob = () => {};
|
||||
available(true);
|
||||
await refresh();
|
||||
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames');
|
||||
console.log('camera preview polling tests passed');
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
test('legacy camera polling stays compatible and skips absent, hidden and stale cameras', async () => {
|
||||
const h = harness()
|
||||
h.controller.setEnabled(true)
|
||||
assert.equal(h.requests.length, 0)
|
||||
h.state({ topic_status: { camera_image: { available: true } } })
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.requests[0].url, 'http://camera.test/camera-frame')
|
||||
assert.equal(h.root.children[0].children[1].src, 'blob:1')
|
||||
h.document.hidden = true
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.requests.length, 1)
|
||||
h.advance(3001)
|
||||
h.controller.refresh()
|
||||
assert.equal(h.visible(), false)
|
||||
assert.equal(h.root.children.length, 0)
|
||||
assert.deepEqual(h.revoked, ['blob:1'])
|
||||
})
|
||||
|
||||
test('multiple camera frames are isolated and one unavailable camera cannot hide another', async () => {
|
||||
const h = harness()
|
||||
h.state({ cameras: [{ id: 'video0', name: '/dev/video0', available: true }, { id: 'video2', name: '/dev/video2', available: true }] })
|
||||
h.controller.setEnabled(true); await h.settle()
|
||||
assert.equal(h.root.children.length, 2)
|
||||
assert.equal(h.requests.length, 2)
|
||||
assert.deepEqual(h.requests.map(request => request.url), ['http://camera.test/camera-frame?camera_id=video0', 'http://camera.test/camera-frame?camera_id=video2'])
|
||||
const first = h.root.children[0].children[1].src
|
||||
h.state({ cameras: [{ id: 'video2', name: '/dev/video2', available: true }] })
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.root.children.length, 1)
|
||||
assert.equal(h.root.children[0].dataset.cameraId, 'video2')
|
||||
assert.ok(h.revoked.includes(first))
|
||||
assert.equal(h.visible(), true)
|
||||
h.controller.dispose()
|
||||
assert.equal(h.root.children.length, 0)
|
||||
assert.equal(h.visible(), false)
|
||||
})
|
||||
|
||||
test('in-flight requests are bounded and late frames cannot resurrect a removed or disabled camera', async () => {
|
||||
const h = harness()
|
||||
let release
|
||||
h.pending.set('http://camera.test/camera-frame?camera_id=video0', new Promise(resolve => { release = resolve }))
|
||||
h.state({ cameras: [{ id: 'video0', available: true }] })
|
||||
h.controller.setEnabled(true)
|
||||
h.controller.refresh()
|
||||
assert.equal(h.requests.length, 1)
|
||||
h.controller.setEnabled(false)
|
||||
assert.equal(h.requests[0].signal.aborted, true)
|
||||
release(); await h.settle()
|
||||
assert.equal(h.root.children.length, 0)
|
||||
assert.equal(h.visible(), false)
|
||||
})
|
||||
|
||||
test('HTTP failures back off per camera and reconnecting restores frames', async () => {
|
||||
const h = harness()
|
||||
h.state({ cameras: [{ id: 'video0', available: true }] })
|
||||
h.fail(true)
|
||||
h.controller.setEnabled(true); await h.settle()
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.requests.length, 1)
|
||||
h.advance(5000); h.state({ cameras: [{ id: 'video0', available: true }] }); h.fail(false)
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.requests.length, 2)
|
||||
assert.ok(h.root.children[0].children[1].src.startsWith('blob:'))
|
||||
})
|
||||
|
||||
test('camera identifiers are validated, deduplicated and limited without executing names as markup', async () => {
|
||||
const h = harness()
|
||||
h.state({ cameras: [{ id: '../etc', available: true }, ...Array.from({ length: 12 }, (_, i) => ({ id: `video${i}`, available: true, name: '<script>unsafe</script>' })), { id: 'video0', available: true }] })
|
||||
h.controller.setEnabled(true); await h.settle()
|
||||
assert.equal(h.requests.length, 8)
|
||||
assert.equal(h.root.children.length, 8)
|
||||
assert.equal(h.root.children[0].children[0].textContent, '<script>unsafe</script>')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user