Merge main into pr-643

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-09-24 22:44:16 -07:00
commit 9a971fef50
381 files changed
+69681 -1198

No files matched your search

+1
View File
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
createCctvProjectionOverlayEntry:
parts.projection.createCctvProjectionOverlayEntry,
getActiveVideoElement: parts.projection.getActiveVideoElement,
setCctvCardPresentationOptions:
parts.cards.setCctvCardPresentationOptions,
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
+3
View File
@@ -91,6 +91,9 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
+72 -19
View File
@@ -1,3 +1,4 @@
import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
@@ -207,6 +208,7 @@ export function createProjection({
// only re-uploads the plane texture when there is genuinely new content.
canvasStamp: 1,
lastSwappedCanvasStamp: 0,
disposed: false,
};
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
@@ -214,16 +216,49 @@ export function createProjection({
if (mode === 'video') {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.src = parts.frames.mediaUrlFor(record.camera);
video.addEventListener('canplay', () => {
video.play().catch(() => {});
});
// Cesium sizes the video texture from the element's width/height
// attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source).
const bindVideoTexture = () => {
if (runtime.video !== video || !runtime.planeMaterial) return;
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
video.width = video.videoWidth;
video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas;
runtime.planeMaterial.image = video;
};
video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture);
runtime.video = video;
runtime.playback = attachCctvVideo(
video,
parts.frames.mediaUrlFor(record.camera),
feedType,
{
onFailure: () => {
if (runtime.disposed) return;
runtime.video = null;
runtime.mode = 'image';
runtime.image = new Image();
runtime.image.decoding = 'async';
runtime.image.onload = () => {
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
runtime.image.onerror = () => {
runtime.imageLoading = false;
runtime.imageReady = false;
};
runtime.planeMaterial.image = runtime.canvas;
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
},
);
} else {
const img = new Image();
img.decoding = 'async';
@@ -254,7 +289,7 @@ export function createProjection({
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
image: mode === 'video' && runtime.video ? runtime.video : canvas,
image: canvas,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
@@ -271,7 +306,8 @@ export function createProjection({
function ensureProjectionRuntime(record) {
if (!record) return null;
if (record.projection) return record.projection;
if (record.projection && !record.projection.disposed)
return record.projection;
const runtime = createProjectionRuntime(record);
record.projection = runtime;
if (runtime) {
@@ -287,7 +323,15 @@ export function createProjection({
*/
function destroyProjectionRuntime(runtime) {
if (!runtime) return;
if (!runtime || runtime.disposed) return;
runtime.disposed = true;
runtime.playback?.dispose();
runtime.playback = null;
if (runtime.image) {
runtime.image.onload = null;
runtime.image.onerror = null;
runtime.image.src = '';
}
if (runtime.video) {
runtime.video.pause();
runtime.video.removeAttribute('src');
@@ -324,10 +368,7 @@ export function createProjection({
const active = parts.selection.getActiveRecord();
if (layerState._enabled && layerState._showProjection && active) {
ensureProjectionRuntime(active);
if (active.projection?.video) {
active.projection.video.play().catch(() => {});
}
if (active.projection) {
if (active.projection && !active.projection.video) {
parts.frames.drawProjectionFrame(active);
parts.frames.refreshProjectionTextures(active);
}
@@ -355,22 +396,34 @@ export function createProjection({
* @param {string|null} activeId - ID of the currently active camera.
*/
/**
* The active camera's decoded <video>, for a second surface (the panel card)
* to paint from. Null when the active feed is a still or not yet attached.
* @returns {HTMLVideoElement|null}
*/
function getActiveVideoElement() {
if (!layerState._enabled) return null;
return parts.selection.getActiveRecord()?.projection?.video || null;
}
function pauseInactiveProjectionFeeds(activeId) {
for (const record of layerState._records) {
if (!record.projection?.video) continue;
if (
record.camera.id === activeId &&
layerState._enabled &&
layerState._showProjection
) {
if (record.camera.id === activeId && layerState._enabled) {
record.projection.video.play().catch(() => {});
} else {
record.projection.video.pause();
const runtime = record.projection;
destroyProjectionRuntime(runtime);
record.projection = null;
layerState._projectionEntities = layerState._projectionEntities.filter(
(entry) => entry !== runtime,
);
}
}
}
return {
createCctvProjectionOverlayEntry,
getActiveVideoElement,
updatePlanePlacement,
clearProjectionOverlay,
setPlaneVisible,
+6 -1
View File
@@ -216,7 +216,12 @@ export function createRendering({
// (set at creation) — see the field-test far-zoom submerge fix there.
}
if (layerState._enabled && layerState._showProjection && isActive) {
if (
layerState._enabled &&
isActive &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
) {
parts.projection.ensureProjectionRuntime(record);
}
// One live plane in the world at a time (§2c): only the active camera's
+111
View File
@@ -0,0 +1,111 @@
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
url,
feedType,
{
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
: url;
let timer = null;
let startup = null;
let governor = null;
let retries = 0;
const release = () => {
if (feedType === 'hls') {
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
() => {},
);
}
};
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
clearTimeout(startup);
clearInterval(governor);
hls?.destroy();
hls = null;
video.removeEventListener('canplay', play);
video.removeEventListener('error', fail);
video.pause();
video.removeAttribute('src');
video.load();
release();
};
const fail = () => {
if (disposed) return;
dispose();
onFailure();
};
const play = () => {
if (disposed) return;
clearTimeout(startup);
video.play().catch(() => {});
};
video.addEventListener('canplay', play);
video.addEventListener('error', fail);
startup = setTimeout(fail, 30000);
const ready = (async () => {
if (feedType !== 'hls') {
video.src = mediaUrl;
return;
}
try {
const { default: Hls } = await loadHls();
if (disposed) return;
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl'))
video.src = mediaUrl;
else fail();
return;
}
hls = new Hls({
enableWorker: true,
maxBufferLength: 24,
maxMaxBufferLength: 30,
backBufferLength: 0,
maxBufferSize: 16 * 1024 * 1024,
liveSyncDurationCount: 3,
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (disposed || !data?.fatal) return;
if (++retries > 2) {
fail();
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (disposed) return;
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else hls.loadSource(mediaUrl);
}, 2000);
});
governor = setInterval(() => {
if (disposed || !video.buffered?.length) return;
const ahead =
video.buffered.end(video.buffered.length - 1) - video.currentTime;
// Some agency encoders publish less media time than wall time. Bound
// correction rather than repeatedly draining the live buffer at 1x.
video.playbackRate =
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
}, 1000);
hls.attachMedia(video);
hls.loadSource(mediaUrl);
} catch {
fail();
}
})();
return { dispose, ready };
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attachCctvVideo } from './videoPlayback.js';
function video() {
const v = new EventTarget();
Object.assign(v, {
src: '',
pause() {},
load() {},
removeAttribute() {},
play: () => Promise.resolve(),
});
return v;
}
test('switch during lazy import never starts the stale decoder', async () => {
let resolve;
let constructed = 0;
class Hls {
constructor() {
constructed++;
}
static isSupported() {
return true;
}
}
const source = video();
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: () =>
new Promise((r) => {
resolve = r;
}),
});
playback.dispose();
resolve({ default: Hls });
await playback.ready;
assert.equal(constructed, 0);
assert.equal(source.src, '');
});
test('missing decoder uses honest failure fallback once', async () => {
let failures = 0;
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
loadHls: async () => {
throw new Error('unavailable');
},
onFailure: () => failures++,
});
await playback.ready;
playback.dispose();
assert.equal(failures, 1);
});
test('native HLS releases its client lease without response-header access', async () => {
const source = video();
source.canPlayType = () => 'probably';
const releases = [];
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async (url, init) => {
releases.push({ url, init });
},
});
await playback.ready;
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
const requested = source.src;
playback.dispose();
playback.dispose();
assert.equal(releases.length, 1);
assert.equal(releases[0].url, requested);
assert.equal(releases[0].init.method, 'DELETE');
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
source.loop = feedType === 'hls';
source.canPlayType = () => 'probably';
let imports = 0;
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
loadHls: async () => {
imports++;
return { default: { isSupported: () => false } };
},
fetchImpl: async () => {},
});
try {
await playback.ready;
assert.equal(source.loop, feedType !== 'hls');
assert.equal(imports, feedType === 'hls' ? 1 : 0);
} finally {
playback.dispose();
}
}
});