fix(cctv): let unchanged image projections idle

Prove native geometry, real image and video pixels on dev and preview before renderer extraction. Scope fixture interception to API routes so geometry workers remain live.
This commit is contained in:
German Khokhlov committed 2026-10-28 21:53:19 +04:00
1 parent 46b3aceafc
commit a72cfc4098
7 files changed
+531 -68

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
import sharp from 'sharp';
// Transport fixtures are parsed by the real CCTV catalog and media owners.
export const cameras = Array.from({ length: 25 }, (_, i) => ({
id: `w2-cctv-${String(i).padStart(3, '0')}`,
name: i === 12 ? 'Cámara pública — Congress' : `W2 camera ${i}`,
cityId: 'austin',
city: 'Austin',
lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018,
lon: -97.7431 + ((i % 5) - 2) * 0.0018,
headingDeg: (i * 37) % 360,
fovDeg: i % 2 ? 44 : 56,
rangeM: i % 2 ? 145 : 210,
mountHeightM: i % 2 ? 8 : 10,
pitchDeg: i % 2 ? -18 : -24,
groundElevationM: 150,
headingConfidence: i % 3 ? 'medium' : 'low',
sourceKind: 'configured',
feedType: 'image',
provider: 'W2 deterministic fixture',
url: 'https://cctv-fixture.invalid/frame.png',
}));
export async function cameraFrame() {
const width = 320,
height = 180;
const pixels = Buffer.alloc(width * height * 3);
const colors = [
[240, 45, 70],
[35, 220, 90],
[40, 90, 235],
[245, 210, 30],
];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const color =
colors[(x >= width / 2 ? 1 : 0) + (y >= height / 2 ? 2 : 0)];
const grid = x % 40 < 2 || y % 30 < 2;
const at = (y * width + x) * 3;
for (let c = 0; c < 3; c++) pixels[at + c] = grid ? 15 : color[c];
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
export async function installCctvFixture(page, { video } = {}) {
const frame = await cameraFrame();
const catalog = cameras.map((camera) => ({ ...camera }));
if (video)
catalog.push({
...cameras[0],
id: 'w2-cctv-video',
name: 'W2 moving video',
lat: 30.2718,
lon: -97.744,
feedType: 'video',
url: 'https://cctv-fixture.invalid/camera.mp4',
});
const ids = new Set(catalog.map((camera) => camera.id));
// Global Puppeteer interception can stall Cesium's module workers in
// Chromium 152. Intercept only fixture API routes on the page's CDP session.
const session = await page.createCDPSession();
let disposed = false;
const routes = [
'/api/cctv/*',
'/api/flights*',
'/api/military*',
'/api/vessels*',
'/api/google/nearby-places*',
];
await session.send('Fetch.enable', {
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
routes.map((route) => ({ urlPattern: `http://${host}:*${route}` })),
),
});
const respond = (
requestId,
{ status, contentType, headers = {}, body = '' },
) =>
session.send('Fetch.fulfillRequest', {
requestId,
responseCode: status,
responseHeaders: Object.entries({
...(contentType ? { 'content-type': contentType } : {}),
...headers,
}).map(([name, value]) => ({ name, value: String(value) })),
body: Buffer.from(body).toString('base64'),
});
const handle = (request) => {
const url = new URL(request.url());
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
return request.continue();
}
const json = (body) =>
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
const auxiliary = {
'/api/flights': { states: [] },
'/api/military': { ac: [] },
'/api/vessels': { connected: false, vessels: [] },
};
if (auxiliary[url.pathname]) {
return json(auxiliary[url.pathname]);
}
if (url.pathname === '/api/cctv/sources') {
return json({ sources: catalog });
}
if (url.pathname === '/api/cctv/health') {
return json({
cameras: catalog.map(({ id, feedType }) => ({
id,
status: 'online',
sourceKind: feedType,
label: 'W2 deterministic fixture',
updatedAt: Date.now(),
})),
});
}
const media = url.pathname.match(/^\/api\/cctv\/(frame|media)\/([^/]+)$/);
if (media) {
const id = decodeURIComponent(media[2]);
if (
!ids.has(id) ||
(media[1] === 'media' && (id !== 'w2-cctv-video' || !video))
) {
return request.respond({ status: 404, body: 'Unknown fixture camera' });
}
if (media[1] === 'frame') {
return request.respond({
status: 200,
contentType: 'image/png',
body: frame,
});
}
const range = request.headers().range?.match(/^bytes=(\d+)-(\d*)$/);
const start = range ? Number(range[1]) : 0;
const end =
range && range[2]
? Math.min(Number(range[2]), video.length - 1)
: video.length - 1;
if (start > end || start >= video.length) {
return request.respond({
status: 416,
headers: { 'content-range': `bytes */${video.length}` },
});
}
return request.respond({
status: range ? 206 : 200,
contentType: 'video/mp4',
headers: {
'accept-ranges': 'bytes',
...(range
? { 'content-range': `bytes ${start}-${end}/${video.length}` }
: {}),
},
body: video.subarray(start, end + 1),
});
}
if (url.pathname === '/api/google/nearby-places') {
return json({ places: [] });
}
return request.continue();
};
session.on('Fetch.requestPaused', (event) => {
const request = {
url: () => event.request.url,
headers: () =>
Object.fromEntries(
Object.entries(event.request.headers).map(([name, value]) => [
name.toLowerCase(),
value,
]),
),
respond: (response) => respond(event.requestId, response),
continue: () =>
session.send('Fetch.continueRequest', { requestId: event.requestId }),
};
Promise.resolve()
.then(() => handle(request))
.catch((error) => {
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
});
});
return {
cameras: catalog,
frame,
async dispose() {
disposed = true;
await session.detach();
},
};
}
+3
View File
@@ -539,6 +539,7 @@
"src/layers/cctv/state.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/projection.js",
"src/layers/cctv/projectionScheduling.test.mjs",
"src/layers/cctv/cards.js",
"src/layers/cctv/rendering.js",
"src/layers/cctv/testing.js",
@@ -580,7 +581,9 @@
"src/layers/alpr/render/navaraOverlay.js",
"src/layers/alpr/render/navaraOverlay.test.mjs",
"scripts/fixtures/w2/alpr.mjs",
"scripts/fixtures/w2/cctv.mjs",
"scripts/qa-w2-alpr.mjs",
"scripts/qa-w2-cctv.mjs",
"src/layers/alpr/visuals.js",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/alpr/overlay.test.mjs",
+124 -60
View File
@@ -38,12 +38,10 @@
* issuing zero transient samples, without touching the save-gated store;
* camera-controller inputs restore and parts hide on mode off.
*
* SwiftShader caveat (proven in Task 3): under headless software GL, plane
* image materials do not visually FILL — screenshotting for a pixel-based
* pass/fail is not viable here. All four assertion groups therefore check
* entity/counter/localStorage/network STATE (GL-independent), never rendered
* pixels. Screenshots are still captured for human visual review but do not
* gate the exit code.
* This legacy harness is the geometry, lifecycle and real-input oracle.
* Migration runs use headless Metal and strict runtime error observation.
* Screenshots remain visual-review evidence here; the W2 companion must
* independently prove actual marker, image-plane and video pixels.
*
* No `cesium` package import for in-page math: the app does not expose a
* `window.Cesium` global, so every in-page evaluate() either (a) reads plain
@@ -69,12 +67,14 @@
* so the coincide check is deterministic whichever camera activates, but it
* is no longer needed to avoid a plane/wireframe disagreement.
*
* Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
*
* Exit 0 = all asserts passed. Non-zero = a hard failure.
*/
import puppeteer from 'puppeteer';
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
import { installCctvFixture } from './fixtures/w2/cctv.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -93,12 +93,29 @@ const getOpt = (name, dflt) => {
};
const getFlag = (name) => argv.includes(name);
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
const explicitUrl = getOpt('--url', process.env.QA_BASE_URL);
const output = getOpt('--out');
if (!explicitUrl || !output)
throw new Error('Explicit --url and --out are required');
const CCTV_URL = new URL(explicitUrl);
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
CCTV_URL.port,
)
)
throw new Error('CCTV QA may not use a reserved port');
if (getFlag('--headful')) throw new Error('CCTV migration QA is headless only');
CCTV_URL.searchParams.set('welcome', '0');
const APP_URL = CCTV_URL.href;
const HEADFUL = getFlag('--headful');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
const SHOTS_DIR = path.resolve(output);
const evidence = { checks: [], browserErrors: [], pass: false };
const saveEvidence = () => {
fs.mkdirSync(SHOTS_DIR, { recursive: true });
fs.writeFileSync(
path.join(SHOTS_DIR, 'result.json'),
JSON.stringify(evidence, null, 2) + '\n',
);
};
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -125,8 +142,9 @@ function findChromeExecutable() {
return null;
}
const results = [];
const results = evidence.checks;
function record(name, ok, detail) {
detail = redact(detail);
results.push({ name, ok, detail });
const tag =
ok === null
@@ -346,54 +364,56 @@ function findSafeCalibrationPatch(basePose, groundAltM) {
async function main() {
console.log(`\nCCTV v2 Subsystem Proof`);
console.log(` App URL : ${APP_URL}`);
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
console.log(` App URL : ${redact(APP_URL)}`);
console.log(' Mode : headless Metal\n');
try {
const res = await fetch(APP_URL, { method: 'GET' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
redact(`Dev server not reachable: ${e.message}`),
);
process.exit(2);
throw new Error('CCTV prerequisite unavailable');
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
headless: 'new',
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
'--use-angle=metal',
'--enable-gpu',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
],
});
const consoleErrors = [];
const consoleErrors = evidence.browserErrors;
let exitCode = 0;
let fixture = null;
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => {
if (msg.type() === 'error') {
const t = msg.text();
if (
!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(
t,
)
) {
consoleErrors.push(t);
}
}
watchW1Page(page, evidence, {
name: 'cctv-legacy',
baseUrl: APP_URL,
preview: getFlag('--preview'),
});
fixture = getFlag('--fixture')
? await installCctvFixture(page)
: null;
await page.evaluateOnNewDocument(() => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
});
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -404,6 +424,14 @@ async function main() {
window.__godsEyeView.dataManager,
{ timeout: 60000 },
);
await page.evaluate(async () => {
const app = window.__godsEyeView;
await app.styleManager.initialRestorePromise;
app.styleManager.orbitController.stop();
for (const [id, layer] of app.dataManager.layers)
if (layer.enabled) await app.dataManager.setEnabled(id, false);
app.styleManager._setDetectionMode('OFF');
});
// Let the initial fly-to Austin and first tiles settle.
await sleep(4000);
@@ -441,7 +469,7 @@ async function main() {
console.error(
'\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m',
);
process.exit(2);
throw new Error('CCTV prerequisite unavailable');
}
const readCounters = () =>
@@ -469,9 +497,20 @@ async function main() {
// ~800ms per real ground sample measured under SwiftShader (each
// scene.sampleHeight forces tile loads at the probe point) — real GPU is
// far faster, so this is a headless-CI ceiling, not an app expectation.
const drainBudgetMs = HEADFUL
? Math.max(30000, camCount * 800 + 30000)
: Math.min(120000, Math.max(30000, camCount * 800 + 30000));
evidence.cameraIds = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('cctv')
.module.getUIState()
.cameras.map(({ id }) => id)
.sort(),
);
if (fixture)
record(
'real catalog parser retains every fixture camera ID',
JSON.stringify(evidence.cameraIds) ===
JSON.stringify(fixture.cameras.map(({ id }) => id).sort()),
);
const drainBudgetMs = Math.max(30000, camCount * 800 + 30000);
console.log(
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
);
@@ -489,12 +528,10 @@ async function main() {
.catch(() => false);
record(
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
drained ? true : HEADFUL ? false : null,
drained,
drained
? 'loading.active === false'
: HEADFUL
? 'timed out waiting for drain on the real-GPU sign-off path'
: 'headless GL geometry sampling did not settle; current headful evidence is required',
: 'timed out waiting for the real-GPU geometry drain',
);
const cAfterDrain = await readCounters();
@@ -532,6 +569,7 @@ async function main() {
.activeCameraId,
);
evidence.activeCameraId = activeId;
const pickDeltaActivation =
cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay;
record(
@@ -591,7 +629,7 @@ async function main() {
const cFloor = await readCounters();
record(
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
tilesSeenLoaded ? sampleFloorOk : null,
tilesSeenLoaded && sampleFloorOk,
tilesSeenLoaded
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run',
@@ -889,8 +927,27 @@ async function main() {
return serializeGeom(camId);
})(${JSON.stringify(camId)})`);
const geomBefore30s = await serializeGeom(activeId);
await sleep(30000);
await sleep(28000);
evidence.staticIdle = await page.evaluate(async () => {
const app = window.__godsEyeView;
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
let frames = 0;
const remove = app.engine.frames.postRender.addEventListener(() => frames++);
const started = performance.now();
await new Promise((resolve) => setTimeout(resolve, 2000));
remove();
return { frames, durationMs: performance.now() - started,
governor: getRenderGovernorDiagnostics() };
});
const geomAfter30s = await serializeGeom(activeId);
evidence.geometry = {
initial: geomInfo,
beforeIdle: geomBefore30s,
afterIdle: geomAfter30s,
};
evidence.countersAfterIdle = await readCounters();
record('static image projection settles to zero frames over 2s',
evidence.staticIdle.frames === 0, JSON.stringify(evidence.staticIdle));
const geomStable =
JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s);
record(
@@ -1062,7 +1119,7 @@ async function main() {
);
// =========================================================================
// Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat)
// Group 4: frame-loop health (state oracle; media pixels have a separate gate)
// =========================================================================
console.log(
'Checking frame-loop health (placeholder -> frame swap, URL family)...',
@@ -1319,25 +1376,18 @@ async function main() {
const gev = window.__godsEyeView;
const viewer = gev.viewer;
const scene = viewer.scene;
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
const canvasPoint = new Cesium.Cartesian2(
emptyPoint.canvasX,
emptyPoint.canvasY,
);
const canvasPoint = { x: emptyPoint.canvasX, y: emptyPoint.canvasY };
const ray = viewer.camera.getPickRay(canvasPoint);
if (!ray) return null;
let position = null;
let projected = null;
for (const distance of [1_000, 10_000, 100_000, 1_000_000]) {
const candidate = Cesium.Ray.getPoint(
const candidate = ray.constructor.getPoint(
ray,
distance,
new Cesium.Cartesian3(),
);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
candidate,
new ray.origin.constructor(),
);
const screen = scene.cartesianToCanvasCoordinates(candidate);
if (
screen &&
Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2
@@ -1353,7 +1403,7 @@ async function main() {
position,
point: {
pixelSize: 64,
color: Cesium.Color.MAGENTA,
color: new scene.backgroundColor.constructor(1, 0, 1, 1),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
@@ -1635,6 +1685,11 @@ async function main() {
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
// =========================================================================
console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...');
// NEXT above selects a different catalog camera without moving the view.
// Frame that actual camera before requiring an on-screen physical drag.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv')
.module.setParams({ focusSelected: true, focusDurationSec: 0.2 }));
await sleep(800);
const GIZMO_PARTS = [
'ring-heading',
'ring-pitch',
@@ -1883,7 +1938,7 @@ async function main() {
record(
'east-arrow drag changes the east offset (live, unsaved)',
null,
`${missDetail} — verify on a current headful GPU surface`,
`${missDetail} — required physical drag evidence is missing from this headless Metal run`,
);
record(
'east-arrow drag keeps mount elevation frozen until mouse-up',
@@ -1907,7 +1962,7 @@ async function main() {
);
} else {
const unavailable =
'no on-screen east-arrow shaft found under this GL stack — verify on real GPU';
'no on-screen east-arrow shaft found in the headless Metal view';
record(
'east-arrow drag changes the east offset (live, unsaved)',
null,
@@ -1958,8 +2013,8 @@ async function main() {
);
// -----------------------------------------------------------------------
// Screenshots for human visual review (NOT a pass/fail signal — SwiftShader
// cannot fill plane image materials under headless software GL).
// Screenshots supplement this state/geometry oracle. Positive rendered
// media pixels are a separate mandatory W2 companion gate.
// -----------------------------------------------------------------------
await sleep(1000);
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
@@ -1976,10 +2031,16 @@ async function main() {
);
for (const r of results) {
if (r.ok === false) exitCode = 1;
if (r.ok !== true) exitCode = 1;
}
} finally {
await fixture?.dispose();
await browser.close();
evidence.pass =
results.length > 0 &&
results.every((r) => r.ok === true) &&
!consoleErrors.length;
saveEvidence();
}
console.log('\n' + '─'.repeat(60));
@@ -1994,6 +2055,9 @@ async function main() {
}
main().catch((e) => {
console.error('\x1b[31mHarness error:\x1b[0m', e);
evidence.pass = false;
evidence.error = redact(e?.stack || e);
saveEvidence();
console.error('Harness error:', evidence.error);
process.exit(3);
});
+1
View File
@@ -142,6 +142,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
runtime.lastTextureSwapAt = now;
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
runtime.planeMaterial.image = buffer;
return true;
}
/**
+36 -8
View File
@@ -7,6 +7,7 @@ import {
PLANE_OUTLINE_COLOR,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
PROJECTION_TEXTURE_SWAP_MS,
} from './policy.js';
export function createProjection({
@@ -15,7 +16,12 @@ export function createProjection({
parts,
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
const {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} = services.render;
let loopRevision = 0;
/**
* Build the protected label associated with one active monitor plane.
@@ -352,14 +358,15 @@ export function createProjection({
function startProjectionLoop() {
if (layerState._projectionRaf) return;
if (!parts.model.projectionLoopIsNeeded()) return;
// The armed projection loop uploads video textures / runs focus fades per
// frame — the scene must render continuously while it runs. Released when
// the tick self-stops. (perf wave 2)
holdContinuousRender('cctv-projection');
clearTimeout(layerState._projectionTimer);
layerState._projectionTimer = 0;
const revision = ++loopRevision;
const tick = () => {
if (revision !== loopRevision) return;
layerState._projectionRaf = 0;
layerState._projectionTimer = 0;
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
layerState._projectionRaf = 0;
releaseContinuousRender('cctv-projection');
return;
}
@@ -371,11 +378,29 @@ export function createProjection({
ensureProjectionRuntime(active);
if (active.projection && !active.projection.video) {
parts.frames.drawProjectionFrame(active);
parts.frames.refreshProjectionTextures(active);
if (parts.frames.refreshProjectionTextures(active))
governorRequestRender('cctv-projection-image');
}
}
layerState._projectionRaf = requestAnimationFrame(tick);
const continuous =
(layerState._showProjection && active?.projection?.video) ||
services.focus.focusPassIsNeeded(
services.focus.getFocusTarget(),
layerState._activeFocusStyleCount,
);
if (continuous) {
holdContinuousRender('cctv-projection');
layerState._projectionRaf = requestAnimationFrame(tick);
} else {
// Stills retain the existing fetch/texture cadence without rendering
// unchanged pixels at display refresh rate.
releaseContinuousRender('cctv-projection');
layerState._projectionTimer = setTimeout(
tick,
PROJECTION_TEXTURE_SWAP_MS,
);
}
};
layerState._projectionRaf = requestAnimationFrame(tick);
@@ -384,6 +409,9 @@ export function createProjection({
/** Cancels the projection animation loop. */
function stopProjectionLoop() {
loopRevision++;
clearTimeout(layerState._projectionTimer);
layerState._projectionTimer = 0;
if (layerState._projectionRaf) {
cancelAnimationFrame(layerState._projectionRaf);
layerState._projectionRaf = 0;
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createProjection } from './projection.js';
function setup(t) {
let next = 0;
const raf = new Map();
const timers = new Map();
const originals = new Map();
for (const [name, value] of Object.entries({
requestAnimationFrame: (fn) => {
raf.set(++next, fn);
return next;
},
cancelAnimationFrame: (id) => raf.delete(id),
setTimeout: (fn) => {
timers.set(++next, fn);
return next;
},
clearTimeout: (id) => timers.delete(id),
})) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, {
configurable: true,
writable: true,
value,
});
}
const holds = new Set();
const requests = [];
let uploads = 0;
let dirty = false;
let focus = false;
const record = { projection: { mode: 'image', disposed: false } };
const state = {
_enabled: true,
_viewer: {},
_showProjection: true,
_projectionRaf: 0,
_projectionTimer: 0,
_activeFocusStyleCount: 0,
};
const projection = createProjection({
state,
services: {
render: {
holdContinuousRender: (id) => holds.add(id),
releaseContinuousRender: (id) => holds.delete(id),
governorRequestRender: (reason) => requests.push(reason),
},
focus: {
getFocusTarget: () => (focus ? {} : null),
focusPassIsNeeded: (target) => !!target,
},
},
parts: {
model: { projectionLoopIsNeeded: () => state._enabled },
selection: { getActiveRecord: () => record },
rendering: { refreshCctvFocusStyles() {} },
frames: {
drawProjectionFrame() {},
refreshProjectionTextures() {
if (!dirty) return;
dirty = false;
uploads++;
return true;
},
},
},
});
t.after(() => {
projection.stopProjectionLoop();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete globalThis[name];
}
});
function run(queue) {
const [id, fn] = queue.entries().next().value || [];
assert.equal(typeof fn, 'function', 'scheduled work exists');
queue.delete(id);
fn();
}
return {
projection,
state,
record,
holds,
requests,
raf,
timers,
frame: () => run(raf),
timer: () => run(timers),
changeImage: () => {
dirty = true;
},
focus: (value) => {
focus = value;
},
uploads: () => uploads,
};
}
test('an unchanged still keeps fetching cadence without holding or requesting GPU frames', (t) => {
const h = setup(t);
h.projection.startProjectionLoop();
assert.equal(h.holds.size, 0);
h.frame();
for (let i = 0; i < 20; i++) h.timer();
assert.equal(h.raf.size, 0);
assert.equal(h.holds.size, 0);
assert.deepEqual(h.requests, []);
h.changeImage();
h.timer();
assert.equal(h.uploads(), 1);
assert.deepEqual(h.requests, ['cctv-projection-image']);
h.timer();
assert.equal(h.requests.length, 1);
});
test('video and focus fades retain continuous rendering, then a still releases it', (t) => {
const h = setup(t);
h.record.projection.video = {};
h.projection.startProjectionLoop();
h.frame();
assert.deepEqual([...h.holds], ['cctv-projection']);
assert.equal(h.timers.size, 0);
h.record.projection.video = null;
h.focus(true);
h.frame();
assert.equal(h.holds.size, 1);
h.focus(false);
h.frame();
assert.equal(h.holds.size, 0);
assert.equal(h.raf.size, 0);
assert.equal(h.timers.size, 1);
});
test('stopping cancels a still timer and a retired callback cannot restart it', (t) => {
const h = setup(t);
h.projection.startProjectionLoop();
h.frame();
const retired = [...h.timers.values()][0];
h.projection.stopProjectionLoop();
assert.equal(h.timers.size, 0);
retired();
assert.equal(h.raf.size + h.timers.size, 0);
h.projection.startProjectionLoop();
h.frame();
retired();
assert.equal(h.timers.size, 1);
h.state._enabled = false;
h.timer();
assert.equal(h.raf.size + h.timers.size + h.holds.size, 0);
});
test('a newly tracked focus wakes a sleeping still without waiting for its next timer', (t) => {
const h = setup(t);
h.projection.startProjectionLoop();
h.frame();
const retired = [...h.timers.values()][0];
h.focus(true);
h.projection.startProjectionLoop();
assert.equal(h.timers.size, 0);
retired();
assert.equal(h.raf.size, 1);
h.frame();
assert.equal(h.holds.size, 1);
});
+1
View File
@@ -65,6 +65,7 @@ export function createState({ services }) {
layerState._listeners = new Set();
layerState._projectionRaf = 0;
layerState._projectionTimer = 0;
layerState._removeFocusAppearListener = null;