diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5ec1adb0c..edd1c69af 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -555,6 +555,7 @@ "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", + "src/layers/cctv/frames.test.mjs", "src/data/alprCameras.js", "src/data/alprCameras.test.mjs", "src/data/dataCredits.test.mjs", diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index 7e037f198..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,7 +13,16 @@ import { PLACEHOLDER_REPAINT_MS, } from './policy.js'; -function fitToProjection(sourceW, sourceH) { +/** + * 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, @@ -204,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); @@ -342,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 @@ -350,17 +367,13 @@ export function createFrames({ state: layerState, services, parts, source }) { return; } runtime.lastFrameSignature = signature; - runtime.ctx.fillStyle = '#01070c'; + runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR; runtime.ctx.fillRect( 0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, ); - const fit = fitToProjection( - runtime.image.naturalWidth, - runtime.image.naturalHeight, - ); 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..5f21e4abd --- /dev/null +++ b/src/layers/cctv/frames.test.mjs @@ -0,0 +1,37 @@ +// 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 { fitToProjection } from './frames.js'; + +const FULL_CANVAS = { x: 0, y: 0, w: 1920, h: 1080 }; + +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); +}); diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index cf09ebe64..3cd4910e9 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.