mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(weather): add globe wind, observed weather and cyclone context (#617)
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
180 files changed
+30645
-434
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,
|
||||
|
||||
@@ -83,6 +83,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,
|
||||
|
||||
@@ -215,7 +215,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