mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
403 lines
15 KiB
JavaScript
403 lines
15 KiB
JavaScript
import {
|
|
FRAME_SIGNATURE_W,
|
|
FRAME_SIGNATURE_H,
|
|
PROJECTION_CANVAS_WIDTH,
|
|
PROJECTION_CANVAS_HEIGHT,
|
|
PROJECTION_MATTE_COLOR,
|
|
PROJECTION_TEXTURE_SWAP_MS,
|
|
ACTIVE_FRAME_REFRESH_MS,
|
|
FRAME_ENDPOINT,
|
|
MEDIA_ENDPOINT,
|
|
PROJECTION_ACTIVE_REFRESH_MS,
|
|
PROJECTION_IDLE_REFRESH_MS,
|
|
PLACEHOLDER_REPAINT_MS,
|
|
} from './policy.js';
|
|
|
|
/**
|
|
* Contain-fits a source frame into the projection canvas: scaled to the
|
|
* largest size that fits, centered, with the margins left for the matte fill.
|
|
* Falls back to the full canvas when either dimension is missing or zero.
|
|
*
|
|
* @param {number} sourceW - Source width in pixels.
|
|
* @param {number} sourceH - Source height in pixels.
|
|
* @returns {{x:number,y:number,w:number,h:number}} Draw rectangle in canvas pixels.
|
|
*/
|
|
export function fitToProjection(sourceW, sourceH) {
|
|
if (!sourceW || !sourceH)
|
|
return {
|
|
x: 0,
|
|
y: 0,
|
|
w: PROJECTION_CANVAS_WIDTH,
|
|
h: PROJECTION_CANVAS_HEIGHT,
|
|
};
|
|
const scale = Math.min(
|
|
PROJECTION_CANVAS_WIDTH / sourceW,
|
|
PROJECTION_CANVAS_HEIGHT / sourceH,
|
|
);
|
|
const w = Math.round(sourceW * scale);
|
|
const h = Math.round(sourceH * scale);
|
|
return {
|
|
x: Math.round((PROJECTION_CANVAS_WIDTH - w) / 2),
|
|
y: Math.round((PROJECTION_CANVAS_HEIGHT - h) / 2),
|
|
w,
|
|
h,
|
|
};
|
|
}
|
|
|
|
export function createFrames({ state: layerState, services, parts, source }) {
|
|
/**
|
|
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
|
* pixel buffer, no DOM) so it is unit-testable.
|
|
*
|
|
* Alpha is skipped deliberately — CCTV stills are opaque, so hashing it would
|
|
* cost a third more work to mix in a constant.
|
|
*
|
|
* @param {Uint8ClampedArray|number[]} data - RGBA pixels, 4 bytes per pixel.
|
|
* @returns {number|null} Unsigned 32-bit signature, or null for empty input.
|
|
*/
|
|
|
|
function frameSignatureFromPixels(data) {
|
|
if (!data || typeof data.length !== 'number' || data.length < 4)
|
|
return null;
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
hash = Math.imul(hash ^ data[i], 0x01000193);
|
|
hash = Math.imul(hash ^ data[i + 1], 0x01000193);
|
|
hash = Math.imul(hash ^ data[i + 2], 0x01000193);
|
|
}
|
|
return hash >>> 0;
|
|
}
|
|
|
|
/**
|
|
* Signature of the runtime's freshly decoded frame, via a reused 64x36
|
|
* scratch canvas.
|
|
*
|
|
* @param {Object} runtime - Projection runtime holding the decoded `.image`.
|
|
* @returns {number|null} Signature, or null when it cannot be computed (the
|
|
* caller then treats the frame as changed — the pre-2026-07-30 behavior).
|
|
*/
|
|
|
|
function projectionFrameSignature(runtime) {
|
|
const image = runtime?.image;
|
|
if (!image) return null;
|
|
if (!runtime.signatureCtx) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = FRAME_SIGNATURE_W;
|
|
canvas.height = FRAME_SIGNATURE_H;
|
|
runtime.signatureCanvas = canvas;
|
|
runtime.signatureCtx = canvas.getContext('2d', {
|
|
willReadFrequently: true,
|
|
});
|
|
}
|
|
const ctx = runtime.signatureCtx;
|
|
if (!ctx) return null;
|
|
try {
|
|
ctx.clearRect(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H);
|
|
ctx.drawImage(image, 0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H);
|
|
return frameSignatureFromPixels(
|
|
ctx.getImageData(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H).data,
|
|
);
|
|
} catch {
|
|
// Tainted canvas (a cross-origin source served without CORS) or a decode
|
|
// race. Returning null means "assume changed", so behavior degrades to
|
|
// the unconditional redraw this optimization replaced.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Blits the latest projection canvas into the next of two alternating
|
|
* offscreen buffer canvases and returns it (H5).
|
|
*
|
|
* Two buffers are required because Cesium's Material image path re-uploads a
|
|
* canvas texture only when the uniform receives a NEW object reference —
|
|
* redrawing the same canvas in place is invisible to the GPU. Alternating
|
|
* references forces a texture recreate, which at <=1Hz and 1080p is cheap.
|
|
*
|
|
* @param {Object} runtime - Projection runtime with `.canvas`.
|
|
* @returns {HTMLCanvasElement|null} The freshly painted buffer, or null.
|
|
*/
|
|
|
|
function paintNextProjectionBuffer(runtime) {
|
|
if (!runtime?.canvas) return null;
|
|
if (!runtime.buffers) {
|
|
runtime.buffers = [0, 1].map(() => {
|
|
const buffer = document.createElement('canvas');
|
|
buffer.width = PROJECTION_CANVAS_WIDTH;
|
|
buffer.height = PROJECTION_CANVAS_HEIGHT;
|
|
return buffer;
|
|
});
|
|
runtime.bufferIndex = 0;
|
|
}
|
|
runtime.bufferIndex = (runtime.bufferIndex + 1) % 2;
|
|
const buffer = runtime.buffers[runtime.bufferIndex];
|
|
const ctx = buffer.getContext('2d');
|
|
if (!ctx) return null;
|
|
ctx.clearRect(0, 0, buffer.width, buffer.height);
|
|
ctx.drawImage(runtime.canvas, 0, 0);
|
|
return buffer;
|
|
}
|
|
|
|
/**
|
|
* Pushes fresh pixels into the monitor plane material. Called every
|
|
* projection tick.
|
|
*
|
|
* Video feeds are skipped entirely — their HTMLVideoElement uniform is
|
|
* updated per-frame by Cesium natively (H5). Image/webcam-frame feeds swap
|
|
* the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS.
|
|
*
|
|
* @param {Object} record - Camera record with an initialized projection runtime.
|
|
*/
|
|
|
|
function refreshProjectionTextures(record) {
|
|
const runtime = record?.projection;
|
|
if (!runtime || runtime.mode === 'video') return;
|
|
const now = Date.now();
|
|
if (
|
|
now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) <
|
|
PROJECTION_TEXTURE_SWAP_MS
|
|
)
|
|
return;
|
|
|
|
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
|
|
if (!planeShowing) return;
|
|
|
|
// Only swap when the canvas content actually changed since the last swap.
|
|
// Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED
|
|
// canvas re-uploads the texture for nothing, and each material image
|
|
// reassignment is a flash opportunity on the live plane (owner field test
|
|
// 2026-07-04: intermittent white flashes on the monitor plane).
|
|
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return;
|
|
|
|
const buffer = paintNextProjectionBuffer(runtime);
|
|
if (!buffer) return;
|
|
runtime.lastTextureSwapAt = now;
|
|
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
|
runtime.planeMaterial.image = buffer;
|
|
}
|
|
|
|
/**
|
|
* Builds the URL for fetching a camera frame image from the backend.
|
|
* Includes a tick parameter to control cache invalidation cadence.
|
|
* @param {Object} camera - Camera object.
|
|
* @param {number} [refreshMs=ACTIVE_FRAME_REFRESH_MS] - Refresh interval used for tick bucketing.
|
|
* @returns {string} Frame URL.
|
|
*/
|
|
|
|
function frameUrlFor(...args) {
|
|
return source.getFrameUrl(...args);
|
|
}
|
|
|
|
/**
|
|
* Builds the URL for fetching a camera's video/media stream.
|
|
* @param {Object} camera - Camera object.
|
|
* @returns {string} Media URL.
|
|
*/
|
|
|
|
function mediaUrlFor(...args) {
|
|
return source.getMediaUrl(...args);
|
|
}
|
|
|
|
/**
|
|
* Paints a placeholder frame onto the projection canvas when no live feed
|
|
* image or video is available. Shows camera name, city, and status text
|
|
* over a dark gradient with tactical border lines.
|
|
* @param {CanvasRenderingContext2D} ctx - 2D context for the projection canvas.
|
|
* @param {Object} camera - Camera object for label info.
|
|
* @param {Object|null} [health=null] - Health state for status message.
|
|
*/
|
|
|
|
function paintProjectionPlaceholder(ctx, camera, health = null) {
|
|
if (!ctx) return;
|
|
const w = PROJECTION_CANVAS_WIDTH;
|
|
const h = PROJECTION_CANVAS_HEIGHT;
|
|
ctx.clearRect(0, 0, w, h);
|
|
const g = ctx.createLinearGradient(0, 0, w, h);
|
|
g.addColorStop(0, '#05111a');
|
|
g.addColorStop(1, PROJECTION_MATTE_COLOR);
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
const label = String(camera?.name || 'CCTV');
|
|
const city = String(camera?.city || 'GLOBAL');
|
|
const status = String(
|
|
health?.message || health?.status || camera?.feedType || 'NO FEED',
|
|
).toUpperCase();
|
|
|
|
ctx.strokeStyle = 'rgba(0, 220, 255, 0.24)';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(18, 18, w - 36, h - 36);
|
|
ctx.strokeRect(36, 36, w - 72, h - 72);
|
|
|
|
ctx.fillStyle = 'rgba(170, 242, 255, 0.95)';
|
|
ctx.font = '600 32px "JetBrains Mono", monospace';
|
|
ctx.fillText(label.slice(0, 42), 46, 74);
|
|
ctx.fillStyle = 'rgba(127, 216, 231, 0.8)';
|
|
ctx.font = '500 24px "JetBrains Mono", monospace';
|
|
ctx.fillText(city.toUpperCase(), 46, 112);
|
|
ctx.font = '500 21px "JetBrains Mono", monospace';
|
|
ctx.fillText(status.slice(0, 58), 46, h - 42);
|
|
}
|
|
|
|
/**
|
|
* Triggers a new frame fetch for an image-mode projection if the refresh
|
|
* interval has elapsed. Active cameras refresh more frequently than idle ones.
|
|
* @param {Object} record - Camera record.
|
|
* @param {boolean} [force=false] - Bypass the interval check.
|
|
*/
|
|
|
|
function refreshProjectionImage(record, force = false) {
|
|
const runtime = record?.projection;
|
|
if (!runtime || runtime.mode !== 'image' || !runtime.image) return;
|
|
// Hidden-state gate (perf wave 2): no new frame fetch/decode for a canvas
|
|
// nobody can see. The refresh interval re-fills naturally on return.
|
|
if (typeof document !== 'undefined' && document.hidden && !force) return;
|
|
// Do not replace an in-flight URL on the 10-second refresh boundary. Slow
|
|
// providers otherwise leave cancelled server requests behind and the plane
|
|
// can remain permanently pending. The proxy bounds each attempt; load/error
|
|
// clears this latch so the next normal tick can refresh.
|
|
if (runtime.imageLoading) return;
|
|
const now = Date.now();
|
|
const refreshMs =
|
|
record.camera.id === layerState._activeCameraId
|
|
? PROJECTION_ACTIVE_REFRESH_MS
|
|
: PROJECTION_IDLE_REFRESH_MS;
|
|
if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
|
|
runtime.lastImageRefreshAt = now;
|
|
|
|
const frameUrl = frameUrlFor(record.camera, refreshMs);
|
|
const sep = frameUrl.includes('?') ? '&' : '?';
|
|
runtime.imageLoading = true;
|
|
runtime.imageReady = false;
|
|
runtime.image.src = `${frameUrl}${sep}projTs=${Math.floor(now / refreshMs)}`;
|
|
}
|
|
|
|
/**
|
|
* Repaints the projection placeholder at most once per PLACEHOLDER_REPAINT_MS.
|
|
* The projection loop runs at RAF cadence — unthrottled, a pending feed would
|
|
* re-fill the 1080p canvas (gradient + text) on every single frame.
|
|
* @param {Object} record - Camera record.
|
|
* @param {Object} runtime - Projection runtime.
|
|
* @param {Object|null} health - Health state for status text.
|
|
*/
|
|
|
|
function paintPlaceholderThrottled(record, runtime, health) {
|
|
const now = Date.now();
|
|
if (
|
|
now - parts.model.safeNumber(runtime.lastPlaceholderPaintAt, 0) <
|
|
PLACEHOLDER_REPAINT_MS
|
|
)
|
|
return;
|
|
runtime.lastPlaceholderPaintAt = now;
|
|
runtime.drawnImageStamp = -1;
|
|
// The placeholder overwrites the canvas, so the last real frame is no longer
|
|
// on it. Drop the signature or an identical frame returning after an outage
|
|
// would be skipped as "unchanged" and leave the placeholder on the plane.
|
|
runtime.lastFrameSignature = null;
|
|
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
|
paintProjectionPlaceholder(runtime.ctx, record.camera, health);
|
|
}
|
|
|
|
/**
|
|
* Draws the current frame (video or image) onto the projection canvas.
|
|
* Falls back to the placeholder if the media source is not yet ready.
|
|
* Image feeds only repaint when a NEW image finished loading (stamp check):
|
|
* re-blitting an unchanged 1080p image every RAF tick is pure waste since
|
|
* texture uploads are already throttled to 1Hz buffer swaps.
|
|
* @param {Object} record - Camera record with an initialized projection runtime.
|
|
*/
|
|
|
|
function drawProjectionFrame(record) {
|
|
const runtime = record?.projection;
|
|
if (!runtime || !runtime.ctx) return;
|
|
|
|
const health = layerState._healthById.get(record.camera.id) || null;
|
|
|
|
if (runtime.mode === 'video' && runtime.video) {
|
|
const video = runtime.video;
|
|
if (
|
|
video.readyState >= 2 &&
|
|
video.videoWidth > 0 &&
|
|
video.videoHeight > 0
|
|
) {
|
|
runtime.ctx.clearRect(
|
|
0,
|
|
0,
|
|
PROJECTION_CANVAS_WIDTH,
|
|
PROJECTION_CANVAS_HEIGHT,
|
|
);
|
|
runtime.ctx.drawImage(
|
|
video,
|
|
0,
|
|
0,
|
|
PROJECTION_CANVAS_WIDTH,
|
|
PROJECTION_CANVAS_HEIGHT,
|
|
);
|
|
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
|
return;
|
|
}
|
|
paintPlaceholderThrottled(record, runtime, health);
|
|
return;
|
|
}
|
|
|
|
refreshProjectionImage(record);
|
|
if (runtime.image && runtime.imageReady) {
|
|
if (runtime.drawnImageStamp !== runtime.imageStamp) {
|
|
// The frame URL carries a 10s cache-buster tick, so a fresh Image
|
|
// DECODES every PROJECTION_ACTIVE_REFRESH_MS whether or not the provider
|
|
// actually published a new picture — measured 2026-07-30: a London
|
|
// camera republished once in 5 minutes, an Austin one not at all.
|
|
// Redrawing regardless bumped canvasStamp, which forced a buffer swap
|
|
// and a fresh 1920x1080 texture upload; the plane renders its white
|
|
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
|
// two Cesium needs to rebind, which IS the periodic white flash from the
|
|
// owner field tests (2026-07-04 and 2026-07-30).
|
|
const fit = fitToProjection(
|
|
runtime.image.naturalWidth,
|
|
runtime.image.naturalHeight,
|
|
);
|
|
// Pixels are hashed at a fixed size, so the fit carries the aspect.
|
|
const pixels = projectionFrameSignature(runtime);
|
|
const signature =
|
|
pixels === null ? null : `${pixels}:${fit.w}x${fit.h}`;
|
|
runtime.drawnImageStamp = runtime.imageStamp;
|
|
if (signature !== null && signature === runtime.lastFrameSignature) {
|
|
// Identical pixels — leave the canvas, and therefore the bound
|
|
// texture, completely alone. No canvasStamp bump, no swap, no flash.
|
|
return;
|
|
}
|
|
runtime.lastFrameSignature = signature;
|
|
runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR;
|
|
runtime.ctx.fillRect(
|
|
0,
|
|
0,
|
|
PROJECTION_CANVAS_WIDTH,
|
|
PROJECTION_CANVAS_HEIGHT,
|
|
);
|
|
runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h);
|
|
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
|
runtime.lastPlaceholderPaintAt = 0;
|
|
}
|
|
return;
|
|
}
|
|
// A refresh is in flight (imageReady=false): keep the last good frame on
|
|
// the canvas instead of flashing the placeholder. Placeholder only paints
|
|
// when nothing has ever been drawn for this camera.
|
|
if (runtime.drawnImageStamp === -1) {
|
|
paintPlaceholderThrottled(record, runtime, health);
|
|
}
|
|
}
|
|
return {
|
|
frameSignatureFromPixels,
|
|
projectionFrameSignature,
|
|
paintNextProjectionBuffer,
|
|
refreshProjectionTextures,
|
|
frameUrlFor,
|
|
mediaUrlFor,
|
|
paintProjectionPlaceholder,
|
|
refreshProjectionImage,
|
|
paintPlaceholderThrottled,
|
|
drawProjectionFrame,
|
|
};
|
|
}
|