mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge main into pr-643
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
9a971fef50
381 files changed
+69681
-1198
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user