Files
gods-eye-view/src/layers/cctv/frames.js
T
Sameh Khamis f60b9ef7c8 chore: remove unused imports and local helpers
Drop module-local helpers that nothing calls (linkAbort, summarize,
stringOption, summarizeEntity and the polygonCenter, propertyObject and
unwrapProperties helpers only it used, CALLOUT_DOT_SIZE_PX) and unused
import specifiers in runtime modules, tests, QA scripts and tools.

None of the removed bindings is exported. Re-exports such as the ALPR
policy constants and every package export are unchanged.
2026-10-22 18:09:37 -07:00

400 lines
15 KiB
JavaScript

import {
FRAME_SIGNATURE_W,
FRAME_SIGNATURE_H,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
PROJECTION_MATTE_COLOR,
PROJECTION_TEXTURE_SWAP_MS,
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,
};
}