From a1bd7ff37ca90ef08b7db85d239333ccdfa6d840 Mon Sep 17 00:00:00 2001 From: Daniel Slay <99130682+danielslay86@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:01:04 -0400 Subject: [PATCH] feat(cctv): draw stills at source aspect, pillarboxed --- docs/CURRENT-STATE.md | 2 +- src/layers/cctv/frames.js | 35 ++++++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 105fdd37f..24fa1a16d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4341,7 +4341,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/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index d137b74b2..7e037f198 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -12,6 +12,28 @@ import { PLACEHOLDER_REPAINT_MS, } from './policy.js'; +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 @@ -328,19 +350,18 @@ export function createFrames({ state: layerState, services, parts, source }) { return; } runtime.lastFrameSignature = signature; - runtime.ctx.clearRect( + runtime.ctx.fillStyle = '#01070c'; + runtime.ctx.fillRect( 0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, ); - runtime.ctx.drawImage( - runtime.image, - 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; }