Keep CCTV playback and calibration while replacing globe projection surfaces

This commit is contained in:
German Khokhlov committed 2026-11-03 04:25:02 +04:00
1 parent 24190a3e35
commit 133ca14119
9 files changed
+378 -135

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
import puppeteer from 'puppeteer';
import { installCctvFixture } from './fixtures/w2/cctv.mjs';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { switchGlobe } from './qa-switch-globe.mjs';
const url = new URL(process.argv[2]);
if (
!['localhost', '127.0.0.1'].includes(url.hostname) ||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
url.port,
)
)
throw Error('Supply local keyless preview URL');
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l=';
const { stdout: video } = await promisify(execFile)(
'ffmpeg',
[
'-hide_banner',
'-loglevel',
'error',
'-f',
'lavfi',
'-i',
'testsrc2=size=320x180:rate=12',
'-t',
'60',
'-an',
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-preset',
'ultrafast',
'-threads',
'1',
'-movflags',
'frag_keyframe+empty_moov',
'-f',
'mp4',
'pipe:1',
],
{ encoding: 'buffer', maxBuffer: 16000000 },
);
const b = await puppeteer.launch({
headless: true,
args: ['--use-angle=metal', '--enable-gpu'],
});
try {
const p = await b.newPage();
await blockPaidQaMaps(p);
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message);
});
await installCctvFixture(p, { video });
let catalogs = 0;
p.on('request', (req) => {
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
});
await p.goto(url.href);
await p.waitForFunction(
() =>
window.__godsEyeView?.engine.kind === 'navara' &&
document.querySelector('#loading-screen.hidden'),
);
await p.evaluate(async () => {
const { dataManager, styleManager, tileset } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Expected keyless preview');
await styleManager.initialRestorePromise;
await dataManager.setEnabled('cctv', true, { origin: 'user' });
window.qaCctv = dataManager.layers.get('cctv').module;
window.qaCctv.setParams({
selectedCameraId: 'w2-cctv-video',
showProjection: true,
coverageMode: 'viewshed',
calibration: { patch: { heightM: 31, pitchDeg: 20 } },
});
});
await p.waitForFunction(
() =>
window.qaCctv.getActiveVideoElement()?.videoWidth === 320 &&
window.qaCctv.getActiveVideoElement().currentTime > 0.1,
{ timeout: 40000 },
);
const first = await p.evaluate(() => {
window.qaVideo = window.qaCctv.getActiveVideoElement();
return window.qaVideo.getVideoPlaybackQuality().totalVideoFrames;
});
let previous = first;
const count = catalogs;
for (const kind of ['cesium', 'navara']) {
await switchGlobe(p, kind);
await p.waitForFunction(
(n) =>
window.qaCctv.getActiveVideoElement()?.getVideoPlaybackQuality()
.totalVideoFrames > n,
{},
previous,
);
const v = await p.evaluate(() => ({
same:
window.qaCctv ===
window.__godsEyeView.dataManager.layers.get('cctv').module,
media: window.qaCctv.getActiveVideoElement() === window.qaVideo,
frames: window.qaVideo.getVideoPlaybackQuality().totalVideoFrames,
paused: window.qaVideo.paused,
params: window.qaCctv.getParams(),
plane: !!window.qaCctv
._cctvRecordsForTest()
.find((r) => r.camera.id === 'w2-cctv-video').projection?.planeEntity,
}));
console.log(
kind,
v.same,
v.media,
v.frames,
v.plane,
v.params.selectedCameraId,
v.params.coverageMode,
);
assert.ok(v.same && v.media && v.plane && !v.paused);
assert.equal(v.params.selectedCameraId, 'w2-cctv-video');
assert.equal(v.params.coverageMode, 'viewshed');
assert.equal(catalogs, count);
previous = v.frames;
}
assert.deepEqual(errors, []);
console.log('PASS retained CCTV catalog, decoder and textured projection');
} finally {
await b.close();
}
+1 -1
View File
@@ -246,7 +246,7 @@ export function createApplicationCatalog({
source: sources.traffic, surface, nativeRenderer: engine => rendererFor('traffic',engine),
})),
],
['cctv', () => createApplicationCctv({ surface, source: sources.cctv })],
['cctv', () => sessionModel('cctv', () => createApplicationCctv({ surface, source: sources.cctv }))],
[
'radio',
() => sessionModel('radio', () => createApplicationRadio({ surface, source: sources.radio })),
+13 -7
View File
@@ -14,6 +14,7 @@
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
// plain node:test.
import { test } from 'node:test';
import { readFileSync } from 'node:fs';
// Preserve structural pins across component qualification and formatter wrapping.
function componentFunctionSource(fn) {
return fn
@@ -233,7 +234,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig
'building a 100-camera catalog must insert zero coverage entities',
);
assert.doesNotMatch(
componentFunctionSource(cctvLayer.init),
sceneBindings,
/buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/,
);
@@ -968,6 +969,11 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => {
assert.equal(flyCalls, 0);
});
// Both initial mount and live engine switches install the same click handler.
const sceneBindings = componentFunctionSource(
readFileSync(new URL('../layers/cctv/lifecycle.js', import.meta.url), 'utf8')
.split('function bindScene(viewer, options) {')[1].split('function createRenderer(')[0],
);
test('CCTV repeated in-world clicks dispatch focus only for the one real activation', () => {
const target = new EventTarget();
const activated = [];
@@ -997,23 +1003,23 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
assert.deepEqual(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']);
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
assert.match(
componentFunctionSource(cctvLayer.init),
sceneBindings,
/_removeClick = bindClick\(/,
);
assert.match(
componentFunctionSource(cctvLayer.init),
sceneBindings,
/_cctvOverlayHost\.hitTest/,
);
assert.match(
componentFunctionSource(cctvLayer.init),
sceneBindings,
/sourceId: CCTV_OVERLAY_SOURCE_ID/,
);
assert.match(
componentFunctionSource(cctvLayer.init),
sceneBindings,
/activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/,
);
assert.match(
componentFunctionSource(cctvLayer.init),
sceneBindings,
/activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/,
);
});
@@ -1126,7 +1132,7 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
'an ID-less surface pick remains true empty space',
);
const initSource = componentFunctionSource(cctvLayer.init);
const initSource = sceneBindings;
assert.ok(
initSource.indexOf('if (pickedId !== null) return') <
initSource.indexOf('_cctvOverlayHost.hitTest'),
+9 -5
View File
@@ -748,10 +748,9 @@ export function createGeometry({ state: layerState, services, parts, source }) {
* Removes all coverage entities and projection runtimes from the scene.
*/
function destroyCoverageEntities() {
if (!parts.renderer?.ready) return;
function destroyCoverageEntities({ retainMedia = false } = {}) {
for (const entity of layerState._coverageEntities) {
parts.renderer.removeCoverageEntity(entity);
parts.renderer?.removeCoverageEntity(entity);
}
layerState._coverageEntities = [];
@@ -760,9 +759,14 @@ export function createGeometry({ state: layerState, services, parts, source }) {
}
for (const runtime of layerState._projectionEntities) {
parts.projection.destroyProjectionRuntime(runtime);
if (retainMedia) {
if (runtime.planeEntity) parts.renderer.removePlane(runtime);
runtime.planeEntity = runtime.planeMaterial = null;
runtime.planeVisible = false;
runtime.needsPlane = true;
} else parts.projection.destroyProjectionRuntime(runtime);
}
layerState._projectionEntities = [];
if (!retainMedia) layerState._projectionEntities = [];
}
return {
computeFrustumGeometry,
+202 -116
View File
@@ -32,6 +32,7 @@ export function createLifecycle({
* set changed since they were last told, including by motion passes.
*/
function settleView() {
if (layerState._restoringViewport || !parts.renderer?.ready) return;
const changed = parts.rendering.refreshHorizonCulling();
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
@@ -124,6 +125,126 @@ export function createLifecycle({
// recomputes it against the then-current scene.
layerState._lastAppliedRegime = null;
}
function bindScene(viewer, options) {
// Task 5: track the surface regime the initial geometry was computed for
// and listen for map-stack changes (main.js re-dispatches
// MapStackController.onChange as this CustomEvent). The handler compares
// regimes itself, so 'switching'/'error' emissions and same-regime stack
// swaps (bing→osm) no-op.
layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime();
if (!layerState._mapStackListener && typeof window !== 'undefined') {
layerState._mapStackListener = () =>
parts.ground.handleMapStackChanged();
window.addEventListener(
'gev:map-stack-changed',
layerState._mapStackListener,
);
}
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
// the billboards' always-on-top depth setting) + one initial pass so the
// first paint is already culled.
if (!layerState._horizonCullListener) {
// Ambient cards piggyback the same settle event: moveEnd-driven
// reselection only, never per frame (refreshAmbientCards no-ops while
// the layer is disabled).
layerState._horizonCullListener = parts.renderer.onMoveEnd(() => {
layerState._cameraMoving = false;
stopViewMotionTimer();
if (layerState._enabled) settleView();
});
}
if (!layerState._moveStartListener) {
// Item B: hover picking pauses while the camera is in motion.
layerState._moveStartListener = parts.renderer.onMoveStart(() => {
layerState._cameraMoving = true;
if (layerState._enabled) startViewMotionTimer();
});
}
const canvas = options.engine?.host.canvas ?? viewer?.scene?.canvas;
if (canvas && typeof ResizeObserver === 'function') {
layerState._viewResizeObserver = new ResizeObserver(() => {
if (layerState._enabled) settleView();
});
layerState._viewResizeObserver.observe(canvas);
}
parts.rendering.refreshHorizonCulling();
layerState._removeClick = parts.renderer.bindClick(
(click) => {
if (!layerState._enabled) return;
const picked = parts.renderer.pick(click.position);
const cameraId = parts.selection.extractPickedCameraId(picked);
if (cameraId) {
activateCctvCameraFromWorldClick(
cameraId,
parts.selection.setActiveCamera,
);
return;
}
// Any identified scene object owns this click even if its layer does not
// register a shared pick predicate. This keeps selectable siblings ahead
// of an overlapping CCTV card while ID-less globe/terrain/tile surfaces
// remain eligible for true empty-space deselection.
const pickedId = resolvePickId(picked);
if (pickedId !== null) return;
// Item A (owner round 2): the scene pick found no camera — try the
// painted ambient cards. The cards canvas is pointer-events:none (this
// handler owns the events), so a click landing on a card's rect selects
// its camera exactly like a click on the icon. Cesium click positions
// and the recorded rects are both CSS px — direct comparison.
const cardId = layerState._cctvOverlayHost.hitTest(
click.position.x,
click.position.y,
{ sourceId: CCTV_OVERLAY_SOURCE_ID },
)?.entryId;
if (cardId && layerState._recordById.has(cardId)) {
activateCctvCameraFromWorldClick(
cardId,
parts.selection.setActiveCamera,
);
return;
}
if (
parts.selection.cctvEmptyClickDeselects(picked, {
activeCameraId: layerState._activeCameraId,
calibrationMode: layerState._calibrationMode,
})
) {
parts.selection.deactivateActiveCamera();
}
},
{
// Item B: hover summons a card on a cardless camera icon. The gesture
// classifier owns MOUSE_MOVE too, so chain hover work through its seam
// instead of replacing the travel accumulator's handler.
onMouseMove: (movement) =>
parts.hover.handleHoverMove(movement?.endPosition),
},
);
}
function createRenderer(viewer, options) {
if (parts.renderer) throw new Error('CCTV layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('A CCTV renderer factory is required');
const renderer = rendererFactory({
state: layerState,
services,
parts,
source,
engine: options.engine,
});
parts.renderer = renderer;
try {
renderer.attach(viewer);
return renderer;
} catch (error) {
renderer.destroy();
parts.renderer = null;
throw error;
}
}
const methods = {
/**
* Initializes the CCTV layer: loads camera sources, builds the catalog,
@@ -132,19 +253,8 @@ export function createLifecycle({
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
async init(viewer, options = {}) {
if (parts.renderer) throw new Error('CCTV layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('A CCTV renderer factory is required');
const renderer = rendererFactory({
state: layerState,
services,
parts,
source,
engine: options.engine,
});
parts.renderer = renderer;
const renderer = createRenderer(viewer, options);
try {
renderer.attach(viewer);
layerState._sourceAbort?.abort();
const sourceAbort = new AbortController();
layerState._sourceAbort = sourceAbort;
@@ -301,103 +411,7 @@ export function createLifecycle({
.catch(() => {});
}
// Task 5: track the surface regime the initial geometry was computed for
// and listen for map-stack changes (main.js re-dispatches
// MapStackController.onChange as this CustomEvent). The handler compares
// regimes itself, so 'switching'/'error' emissions and same-regime stack
// swaps (bing→osm) no-op.
layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime();
if (!layerState._mapStackListener && typeof window !== 'undefined') {
layerState._mapStackListener = () =>
parts.ground.handleMapStackChanged();
window.addEventListener(
'gev:map-stack-changed',
layerState._mapStackListener,
);
}
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
// the billboards' always-on-top depth setting) + one initial pass so the
// first paint is already culled.
if (!layerState._horizonCullListener) {
// Ambient cards piggyback the same settle event: moveEnd-driven
// reselection only, never per frame (refreshAmbientCards no-ops while
// the layer is disabled).
layerState._horizonCullListener = parts.renderer.onMoveEnd(() => {
layerState._cameraMoving = false;
stopViewMotionTimer();
if (layerState._enabled) settleView();
});
}
if (!layerState._moveStartListener) {
// Item B: hover picking pauses while the camera is in motion.
layerState._moveStartListener = parts.renderer.onMoveStart(() => {
layerState._cameraMoving = true;
if (layerState._enabled) startViewMotionTimer();
});
}
const canvas = options.engine?.host.canvas ?? viewer?.scene?.canvas;
if (canvas && typeof ResizeObserver === 'function') {
layerState._viewResizeObserver = new ResizeObserver(() => {
if (layerState._enabled) settleView();
});
layerState._viewResizeObserver.observe(canvas);
}
parts.rendering.refreshHorizonCulling();
layerState._removeClick = parts.renderer.bindClick(
(click) => {
if (!layerState._enabled) return;
const picked = parts.renderer.pick(click.position);
const cameraId = parts.selection.extractPickedCameraId(picked);
if (cameraId) {
activateCctvCameraFromWorldClick(
cameraId,
parts.selection.setActiveCamera,
);
return;
}
// Any identified scene object owns this click even if its layer does not
// register a shared pick predicate. This keeps selectable siblings ahead
// of an overlapping CCTV card while ID-less globe/terrain/tile surfaces
// remain eligible for true empty-space deselection.
const pickedId = resolvePickId(picked);
if (pickedId !== null) return;
// Item A (owner round 2): the scene pick found no camera — try the
// painted ambient cards. The cards canvas is pointer-events:none (this
// handler owns the events), so a click landing on a card's rect selects
// its camera exactly like a click on the icon. Cesium click positions
// and the recorded rects are both CSS px — direct comparison.
const cardId = layerState._cctvOverlayHost.hitTest(
click.position.x,
click.position.y,
{ sourceId: CCTV_OVERLAY_SOURCE_ID },
)?.entryId;
if (cardId && layerState._recordById.has(cardId)) {
activateCctvCameraFromWorldClick(
cardId,
parts.selection.setActiveCamera,
);
return;
}
if (
parts.selection.cctvEmptyClickDeselects(picked, {
activeCameraId: layerState._activeCameraId,
calibrationMode: layerState._calibrationMode,
})
) {
parts.selection.deactivateActiveCamera();
}
},
{
// Item B: hover summons a card on a cardless camera icon. The gesture
// classifier owns MOUSE_MOVE too, so chain hover work through its seam
// instead of replacing the travel accumulator's handler.
onMouseMove: (movement) =>
parts.hover.handleHoverMove(movement?.endPosition),
},
);
bindScene(viewer, options);
await parts.health.syncHealthState(true);
sourceAbort.signal.throwIfAborted();
parts.rendering.refreshCoverageStyles();
@@ -412,6 +426,76 @@ export function createLifecycle({
}
},
attachRenderer(viewer, options = {}) {
const renderer = createRenderer(viewer, options);
layerState._restoringViewport = Boolean(options.waitForCameraRestore);
renderer.createMarkers();
for (const record of layerState._records) {
record.billboard = renderer.createMarker(record.camera, record.position);
record.groundResolved = {};
record.groundSamples = {};
record.probeClampRangeM = null;
}
bindScene(viewer, options);
if (!layerState._restoringViewport && layerState._enabled) this.resumeDisplay();
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && parts.renderer) this.resumeDisplay();
},
resumeDisplay() {
this.showRenderer();
if (layerState._calibrationMode) {
parts.calibration.ensureGizmo();
layerState._gizmo?.setEnabled(true);
services.render.holdContinuousRender('cctv-adjust');
}
settleView();
},
detachRenderer() {
if (!parts.renderer) return;
layerState._restoringViewport = true;
parts.renderer.hideCredit();
unregisterPickOwner('cctv');
if (layerState._mapStackListener) {
window.removeEventListener('gev:map-stack-changed', layerState._mapStackListener);
layerState._mapStackListener = null;
}
layerState._viewResizeObserver?.disconnect();
layerState._viewResizeObserver = null;
layerState._horizonCullListener?.();
layerState._moveStartListener?.();
layerState._removeFocusAppearListener?.();
layerState._horizonCullListener = layerState._moveStartListener = layerState._removeFocusAppearListener = null;
layerState._gizmo?.destroy();
layerState._gizmo = null;
releaseContinuousRender('cctv-adjust');
layerState._removeClick?.();
layerState._removeClick = null;
parts.projection.stopProjectionLoop();
parts.geometryQueue.stopGeometryLoadQueue();
stopViewMotionTimer();
parts.cards.stopCardFrameLoop();
parts.hover.clearHoverCard();
parts.projection.clearProjectionOverlay();
layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID);
parts.geometry.destroyCoverageEntities({retainMedia:true});
for (const record of layerState._records) {
record.billboard = null;
record.coverageEntities = [];
}
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
layerState._viewPose = null;
layerState._cameraMoving = false;
parts.renderer.destroy();
parts.renderer = null;
},
/**
* Enables the layer: shows entities, starts the projection loop, and kicks
* the staggered geometry-load queue. Heavy work (per-camera ground
@@ -420,14 +504,20 @@ export function createLifecycle({
*/
enable() {
layerState._enabled = true;
if (layerState._cameraMoving) startViewMotionTimer();
layerState._lastUpdate = Date.now();
if (!layerState._activeCameraId && layerState._records.length) {
layerState._activeCameraId = layerState._records[0].camera.id;
layerState._autoHopSuspended = false;
}
this.showRenderer(true);
},
showRenderer(refreshImage = false) {
if (
layerState._records.some(
(record) => record.camera.cityId === 'warendorf',
)
)
parts.renderer?.showCredit();
layerState._lastUpdate = Date.now();
// Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
registerPickOwner('cctv', (pickedId) => {
@@ -443,14 +533,10 @@ export function createLifecycle({
);
return Boolean(coverage && layerState._recordById.has(coverage[1]));
});
if (!layerState._activeCameraId && layerState._records.length) {
layerState._activeCameraId = layerState._records[0].camera.id;
layerState._autoHopSuspended = false;
}
const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) {
parts.projection.ensureProjectionRuntime(activeRecord);
parts.frames.refreshProjectionImage(activeRecord, true);
if (refreshImage) parts.frames.refreshProjectionImage(activeRecord, true);
}
parts.rendering.refreshHorizonCulling();
// Re-enabled mid-flight: moveStart has already fired.
+15 -4
View File
@@ -103,7 +103,7 @@ export function createProjection({
function setPlaneVisible(runtime, visible) {
if (!runtime) return;
runtime.planeVisible = !!visible;
parts.renderer.setPlaneVisible(runtime, !!visible);
if (runtime.planeEntity) parts.renderer?.setPlaneVisible(runtime, !!visible);
if (visible && runtime.overlayEntry && runtime.cameraId) {
if (layerState._projectionOverlayOwnerId !== runtime.cameraId) {
layerState._cctvOverlayHost.setEntries(
@@ -256,7 +256,7 @@ export function createProjection({
runtime.imageLoading = false;
runtime.imageReady = false;
};
parts.renderer.setPlaneImage(runtime, runtime.canvas);
if (runtime.planeMaterial) parts.renderer?.setPlaneImage(runtime, runtime.canvas);
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
@@ -311,8 +311,19 @@ export function createProjection({
function ensureProjectionRuntime(record) {
if (!record) return null;
if (record.projection && !record.projection.disposed)
return record.projection;
if (record.projection && !record.projection.disposed) {
const runtime = record.projection;
if (runtime.needsPlane && parts.renderer?.ready) {
const geometry = record.frustumGeometry || parts.geometry.computeFrustumGeometry(record.camera, parts.ground.groundAltFor(record), record.probeClampRangeM);
const positions = record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = parts.renderer.createPlaneMaterial(runtime.canvas);
createProjectionPlane(record, runtime, geometry, positions);
if (runtime.video?.videoWidth) parts.renderer.setPlaneImage(runtime, runtime.video);
runtime.needsPlane = false;
runtime.lastSwappedCanvasStamp = -1;
}
return runtime;
}
const runtime = createProjectionRuntime(record);
record.projection = runtime;
if (runtime) {
+1
View File
@@ -311,6 +311,7 @@ export function createRendering({
/** Applies coverage visibility/style state and lazily builds eligible sets. */
function refreshCoverageStyles() {
if (!parts.renderer?.ready) return;
const activeRecord = parts.selection.getActiveRecord();
parts.geometry.ensureActiveCoverageEntities(activeRecord);
const activeId = activeRecord?.camera.id || null;
-1
View File
@@ -237,7 +237,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([
'fly_route',
'move_camera',
'set_context_mode',
'control_cctv',
]);
/** The refusal code of a viewer-only tool on an engine without a viewer. */
export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE';
-1
View File
@@ -94,7 +94,6 @@ test('the viewer-only actions are one exported list of real tools', () => {
'fly_route',
'move_camera',
'set_context_mode',
'control_cctv',
]);
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));