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

+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;