diff --git a/changelog.d/1201-cctv-stills-aspect.md b/changelog.d/1201-cctv-stills-aspect.md new file mode 100644 index 000000000..fdb560064 --- /dev/null +++ b/changelog.d/1201-cctv-stills-aspect.md @@ -0,0 +1,4 @@ +- Draw CCTV stills at their source aspect on the monitor plane instead of + stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so + every still was widened; they are now centered and pillarboxed on the dark + the placeholder already ends on (Danielslay86, #1201). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1008e864c..9cafb15bf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4417,7 +4417,7 @@ silently demoting every later lookup for the session. - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's - far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only — + far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only — auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains; ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2d2ddb361..543b0f9a4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -557,6 +557,7 @@ "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", + "src/layers/cctv/frames.test.mjs", "src/layers/cctv/rendering.test.mjs", "src/data/alprCameras.js", "src/data/alprCameras.test.mjs", diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index d137b74b2..807770c10 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -3,6 +3,7 @@ import { FRAME_SIGNATURE_H, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, + PROJECTION_MATTE_COLOR, PROJECTION_TEXTURE_SWAP_MS, ACTIVE_FRAME_REFRESH_MS, FRAME_ENDPOINT, @@ -12,6 +13,37 @@ import { 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 @@ -182,7 +214,7 @@ export function createFrames({ state: layerState, services, parts, source }) { ctx.clearRect(0, 0, w, h); const g = ctx.createLinearGradient(0, 0, w, h); g.addColorStop(0, '#05111a'); - g.addColorStop(1, '#01070c'); + g.addColorStop(1, PROJECTION_MATTE_COLOR); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); @@ -320,7 +352,14 @@ export function createFrames({ state: layerState, services, parts, source }) { // 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 signature = projectionFrameSignature(runtime); + 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 @@ -328,19 +367,14 @@ export function createFrames({ state: layerState, services, parts, source }) { return; } runtime.lastFrameSignature = signature; - runtime.ctx.clearRect( - 0, - 0, - PROJECTION_CANVAS_WIDTH, - PROJECTION_CANVAS_HEIGHT, - ); - runtime.ctx.drawImage( - runtime.image, + 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; } diff --git a/src/layers/cctv/frames.test.mjs b/src/layers/cctv/frames.test.mjs new file mode 100644 index 000000000..0d03e9457 --- /dev/null +++ b/src/layers/cctv/frames.test.mjs @@ -0,0 +1,141 @@ +// src/layers/cctv/frames.test.mjs +// Still-frame fit (#1201): stills draw at their source aspect, centered on the +// 1920x1080 projection canvas, instead of being stretched to fill it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createFrames, fitToProjection } from './frames.js'; +import { + FRAME_SIGNATURE_W, + FRAME_SIGNATURE_H, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + PROJECTION_MATTE_COLOR, +} from './policy.js'; + +// The expected rectangles below are written for a 1920x1080 canvas. +assert.equal(PROJECTION_CANVAS_WIDTH, 1920); +assert.equal(PROJECTION_CANVAS_HEIGHT, 1080); + +const FULL_CANVAS = { + x: 0, + y: 0, + w: PROJECTION_CANVAS_WIDTH, + h: PROJECTION_CANVAS_HEIGHT, +}; + +test('a 4:3 still is pillarboxed at full height', () => { + assert.deepEqual(fitToProjection(640, 480), { + x: 240, + y: 0, + w: 1440, + h: 1080, + }); +}); + +test('a source wider than 16:9 is letterboxed at full width', () => { + assert.deepEqual(fitToProjection(3840, 1080), { + x: 0, + y: 270, + w: 1920, + h: 540, + }); +}); + +test('a 16:9 still fills the canvas', () => { + assert.deepEqual(fitToProjection(1920, 1080), FULL_CANVAS); +}); + +test('a missing or zero dimension falls back to the full canvas', () => { + assert.deepEqual(fitToProjection(0, 480), FULL_CANVAS); + assert.deepEqual(fitToProjection(640, 0), FULL_CANVAS); + assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS); + assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS); +}); + +// Draw path: the fit reaches drawImage, and the unchanged-frame skip keys on the +// fitted size as well as the pixels (the 64x36 hash alone drops aspect). +function stillHarness() { + const calls = []; + const ctx = { + fillStyle: null, + fillRect: (...rect) => calls.push(['fillRect', ctx.fillStyle, ...rect]), + clearRect: () => {}, + drawImage: (_image, ...rect) => calls.push(['drawImage', ...rect]), + }; + // Every frame downsamples to the same pixels, so only the size differs. + const pixels = new Uint8ClampedArray( + FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4, + ).fill(7); + const runtime = { + mode: 'image', + ctx, + image: { naturalWidth: 0, naturalHeight: 0 }, + imageReady: true, + // In flight, so refreshProjectionImage returns before touching image.src. + imageLoading: true, + imageStamp: 0, + drawnImageStamp: -1, + lastFrameSignature: null, + canvasStamp: 0, + signatureCtx: { + clearRect: () => {}, + drawImage: () => {}, + getImageData: () => ({ data: pixels }), + }, + }; + const record = { camera: { id: 'still' }, projection: runtime }; + const { drawProjectionFrame } = createFrames({ + state: { _healthById: new Map(), _activeCameraId: null }, + services: {}, + parts: {}, + source: {}, + }); + const load = (width, height) => { + runtime.image = { naturalWidth: width, naturalHeight: height }; + runtime.imageStamp += 1; + calls.length = 0; + drawProjectionFrame(record); + return calls.slice(); + }; + return { runtime, load }; +} + +test('a still is drawn into its fitted rectangle over a full matte', () => { + const { runtime, load } = stillHarness(); + assert.deepEqual(load(640, 480), [ + [ + 'fillRect', + PROJECTION_MATTE_COLOR, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ], + ['drawImage', 240, 0, 1440, 1080], + ]); + assert.equal(runtime.canvasStamp, 1); +}); + +test('same pixels at the same size skip the redraw', () => { + const { runtime, load } = stillHarness(); + load(640, 480); + assert.deepEqual(load(640, 480), []); + assert.equal(runtime.canvasStamp, 1); +}); + +test('same pixels at a new aspect redraw at the new fit', () => { + const { runtime, load } = stillHarness(); + load(640, 480); + assert.deepEqual(load(1920, 1080), [ + [ + 'fillRect', + PROJECTION_MATTE_COLOR, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ], + ['drawImage', 0, 0, 1920, 1080], + ]); + assert.equal(runtime.canvasStamp, 2); +}); diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index 7ba4e1231..96d7985f1 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -35,6 +35,9 @@ export const PROJECTION_CANVAS_WIDTH = 1920; export const PROJECTION_CANVAS_HEIGHT = 1080; +/** Matte behind fitted stills; the placeholder gradient's dark end. */ +export const PROJECTION_MATTE_COLOR = '#01070c'; + // Downsample grid for the unchanged-frame signature (drawProjectionFrame). // 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a // full-resolution compare would touch.