mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- Add A22_IMAGE_ORIGIN to the pinned frame directories, so a same-host redirect from www.autobrennero.it/WebCamImg/ cannot leave that directory, as for the other directory-pinned packs. - Give Autostrada del Brennero a 3-minute ambient refresh in cctvLod.js, like the other highway packs, instead of the 5-minute default. - DATA_SOURCES: the Open Data Hub now tags every A22 record CC0; note that this is the aggregator's metadata, not Autobrennero's own terms. - Tests for the redirect pin and the refresh cadence.
530 lines
19 KiB
JavaScript
530 lines
19 KiB
JavaScript
/**
|
||
* @module cctvLod
|
||
*
|
||
* Pure selection policy for the citywide ambient CCTV card tier.
|
||
* Cesium-specific visibility projection stays in cctv.js; this module turns
|
||
* the resulting in-view candidates into a bounded, nearest-first ambient
|
||
* card set, keeps that set stable across small camera moves (eviction
|
||
* grace), and paces static-frame refreshes per source.
|
||
*
|
||
* Engine adapted from Manjunath's Part C work (`fix/cctv-part-c-review`):
|
||
* the zoom-scaled budgets, distance-ranked in-view selection, the
|
||
* eviction-grace planner, and the source-aware refresh cadences are his,
|
||
* retargeted from the rejected world-space static-plane ring to the
|
||
* screen-space thumbnail cards (see
|
||
* docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md).
|
||
*/
|
||
|
||
// Ambient-card budgets (owner round 2, item C: raised 16/24/32 → 20/28/40 —
|
||
// "a lot of empty space"). Owner-tunable as a set, together with the card
|
||
// scale waypoints (1,800/6,000/9,500 m, cctvCards.js) and
|
||
// CCTV_CARD_MIN_SEP_PX: budgets say how many cameras HOLD cards, the
|
||
// waypoints and separation say how many fit on screen.
|
||
export const CCTV_AMBIENT_CARD_MAX = 40;
|
||
export const CCTV_AMBIENT_CARD_MID = 28;
|
||
export const CCTV_AMBIENT_CARD_MIN = 20;
|
||
|
||
const STREET_HEIGHT_M = 2_500;
|
||
const CITY_HEIGHT_M = 15_000;
|
||
const DEFAULT_STATIC_REFRESH_MS = 5 * 60 * 1000;
|
||
const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||
'austin transportation & public works': 5 * 60 * 1000,
|
||
'transport for london': 3 * 60 * 1000,
|
||
caltrans: 3 * 60 * 1000,
|
||
'ontario 511': 3 * 60 * 1000,
|
||
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
|
||
txdot: 3 * 60 * 1000,
|
||
// Digitraffic publishes a new weathercam frame on each station's
|
||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||
// re-fetches the same JPEG.
|
||
fintraffic: 10 * 60 * 1000,
|
||
// Measured empirically (2026-09-15): polling the same SCT/Terrassa frame
|
||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||
// matches the other highway packs' 3-minute cadence.
|
||
'servei català de trànsit': 3 * 60 * 1000,
|
||
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
|
||
// tracks the minute); 3 min matches the other highway packs.
|
||
'autostrada del brennero': 3 * 60 * 1000,
|
||
});
|
||
|
||
/**
|
||
* Returns the bounded ambient-card budget for the current viewer height.
|
||
* Card counts stay inside the 20..40 range (owner round 2): street level
|
||
* keeps the overlay sparse, metro scale earns the full ring.
|
||
*
|
||
* @param {number} cameraHeightM
|
||
* @returns {{cardLimit:number}}
|
||
*/
|
||
export function cctvLodBudgets(cameraHeightM) {
|
||
const height = Number.isFinite(cameraHeightM)
|
||
? Math.max(0, cameraHeightM)
|
||
: CITY_HEIGHT_M;
|
||
if (height <= STREET_HEIGHT_M) {
|
||
return { cardLimit: CCTV_AMBIENT_CARD_MIN };
|
||
}
|
||
if (height <= CITY_HEIGHT_M) {
|
||
return { cardLimit: CCTV_AMBIENT_CARD_MID };
|
||
}
|
||
return { cardLimit: CCTV_AMBIENT_CARD_MAX };
|
||
}
|
||
|
||
/**
|
||
* Selection-level incumbency (owner field-test finding 4, 2026-07-29): a
|
||
* camera currently holding a card ranks with its distance discounted by this
|
||
* factor, so a small camera move never batch-swaps the ring — a non-carded
|
||
* camera displaces a carded one only when it is meaningfully (>20%) closer.
|
||
*/
|
||
export const CCTV_CARD_INCUMBENT_FACTOR = 0.8;
|
||
|
||
/**
|
||
* Effective ranking distance for the ambient-card selection: incumbents
|
||
* (cameras that already hold a card) get the 20% distance discount.
|
||
* @param {number} distanceKm
|
||
* @param {boolean} isIncumbent
|
||
* @param {number} [factor]
|
||
* @returns {number}
|
||
*/
|
||
export function incumbentRankKm(
|
||
distanceKm,
|
||
isIncumbent,
|
||
factor = CCTV_CARD_INCUMBENT_FACTOR,
|
||
) {
|
||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||
return isIncumbent ? km * factor : km;
|
||
}
|
||
|
||
/** Fraction of the ambient-card rank contributed by screen-center distance. */
|
||
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
|
||
|
||
/** Robust pool-distance percentile used to scale the screen-space term. */
|
||
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||
|
||
/** True only when both viewport dimensions are finite positive pixels. */
|
||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||
return (
|
||
Number.isFinite(viewW) && viewW > 0 && Number.isFinite(viewH) && viewH > 0
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Returns an anchor's normalized distance from screen center: zero at center,
|
||
* one at or beyond a viewport corner.
|
||
* @param {number} sx
|
||
* @param {number} sy
|
||
* @param {number} viewW
|
||
* @param {number} viewH
|
||
* @returns {number}
|
||
*/
|
||
export function screenCenterFraction(sx, sy, viewW, viewH) {
|
||
if (!hasFiniteCctvViewport(viewW, viewH)) return 0;
|
||
if (!Number.isFinite(sx) || !Number.isFinite(sy)) return 1;
|
||
const centerX = viewW / 2;
|
||
const centerY = viewH / 2;
|
||
const halfDiagonalPx = Math.hypot(centerX, centerY) || 1;
|
||
return Math.min(1, Math.hypot(sx - centerX, sy - centerY) / halfDiagonalPx);
|
||
}
|
||
|
||
/**
|
||
* Returns a robust distance scale for the eligible candidate pool.
|
||
* @param {number[]} distancesKm
|
||
* @param {number} [percentile]
|
||
* @returns {number}
|
||
*/
|
||
export function cctvCandidateSpreadKm(
|
||
distancesKm,
|
||
percentile = CCTV_CARD_SPREAD_PERCENTILE,
|
||
) {
|
||
const finite = (Array.isArray(distancesKm) ? distancesKm : [])
|
||
.filter((distanceKm) => Number.isFinite(distanceKm) && distanceKm >= 0)
|
||
.sort((a, b) => a - b);
|
||
if (!finite.length) return 0;
|
||
const p = Math.min(
|
||
1,
|
||
Math.max(0, Number.isFinite(percentile) ? percentile : 0),
|
||
);
|
||
return finite[Math.round(p * (finite.length - 1))];
|
||
}
|
||
|
||
/**
|
||
* Blends viewer distance with screen-center distance in common km units.
|
||
* @param {number} distanceKm
|
||
* @param {number} centerFraction
|
||
* @param {number} spreadKm
|
||
* @param {number} [weight]
|
||
* @returns {number}
|
||
*/
|
||
export function blendCenterRankKm(
|
||
distanceKm,
|
||
centerFraction,
|
||
spreadKm,
|
||
weight = CCTV_CARD_CENTER_WEIGHT,
|
||
) {
|
||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||
if (!Number.isFinite(km)) return Infinity;
|
||
const w = Math.min(1, Math.max(0, Number.isFinite(weight) ? weight : 0));
|
||
const spread = Number.isFinite(spreadKm) && spreadKm > 0 ? spreadKm : 0;
|
||
const fraction = Number.isFinite(centerFraction)
|
||
? Math.min(1, Math.max(0, centerFraction))
|
||
: 1;
|
||
return (1 - w) * km + w * spread * fraction;
|
||
}
|
||
|
||
// Screen-distribution grid (owner round 2, item C — "a lot of empty space"):
|
||
// pure nearest-first selection clusters winners at screen center (nearest ==
|
||
// most central at typical view pitch) and leaves the periphery bare. The
|
||
// viewport is bucketed into this grid and every occupied cell gets its best
|
||
// candidate before global rank fills the rest. Owner-tunable together with
|
||
// the budgets above.
|
||
const CCTV_CARD_GRID_COLS = 5;
|
||
const CCTV_CARD_GRID_ROWS = 4;
|
||
|
||
/**
|
||
* Screen-space distribution pass (owner round 2, item C). Buckets the
|
||
* viewport into a CCTV_CARD_GRID_COLS × CCTV_CARD_GRID_ROWS grid, assigns
|
||
* each candidate to its cell (anchors just outside the viewport clamp to
|
||
* the edge cells), ranks within cells by effective distance (`rankKm` —
|
||
* incumbency discount already applied by the caller), then takes each
|
||
* occupied cell's best — nearest cell-winners first, so an under-budget cut
|
||
* stays deterministic — until the budget is filled; leftover budget goes to
|
||
* the remaining candidates in global rank order. Empty cells are skipped.
|
||
* Pure and allocation-honest: decides only WHICH cameras hold cards — the
|
||
* per-frame draw-pass declutter stays authoritative over what paints.
|
||
*
|
||
* @param {Array<{id:string,sx:number,sy:number,rankKm:number}>} candidates
|
||
* @param {Object} [options]
|
||
* @param {number} [options.budget] - Max ids returned.
|
||
* @param {number} [options.viewW] - Viewport width (CSS px).
|
||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||
* @param {number} [options.cols]
|
||
* @param {number} [options.rows]
|
||
* @returns {string[]} Winner ids in priority order (cell winners, then
|
||
* global-rank fill).
|
||
*/
|
||
export function distributeCctvCards(
|
||
candidates,
|
||
{
|
||
budget = CCTV_AMBIENT_CARD_MAX,
|
||
viewW = 0,
|
||
viewH = 0,
|
||
cols = CCTV_CARD_GRID_COLS,
|
||
rows = CCTV_CARD_GRID_ROWS,
|
||
} = {},
|
||
) {
|
||
const cap = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
||
const valid = (Array.isArray(candidates) ? candidates : []).filter(
|
||
(c) =>
|
||
c &&
|
||
typeof c.id === 'string' &&
|
||
c.id &&
|
||
Number.isFinite(c.sx) &&
|
||
Number.isFinite(c.sy),
|
||
);
|
||
if (!cap || !valid.length) return [];
|
||
const validViewport = hasFiniteCctvViewport(viewW, viewH);
|
||
const width = validViewport ? viewW : 1;
|
||
const height = validViewport ? viewH : 1;
|
||
const nCols = Math.max(1, Math.floor(cols));
|
||
const nRows = Math.max(1, Math.floor(rows));
|
||
const byRank = (a, b) =>
|
||
(Number.isFinite(a.rankKm) ? a.rankKm : Infinity) -
|
||
(Number.isFinite(b.rankKm) ? b.rankKm : Infinity) ||
|
||
a.id.localeCompare(b.id);
|
||
|
||
/** @type {Map<number, Array>} occupied cell -> its candidates */
|
||
const cells = new Map();
|
||
for (const candidate of valid) {
|
||
const col = Math.min(
|
||
nCols - 1,
|
||
Math.max(0, Math.floor((candidate.sx / width) * nCols)),
|
||
);
|
||
const row = Math.min(
|
||
nRows - 1,
|
||
Math.max(0, Math.floor((candidate.sy / height) * nRows)),
|
||
);
|
||
const key = row * nCols + col;
|
||
const bucket = cells.get(key);
|
||
if (bucket) bucket.push(candidate);
|
||
else cells.set(key, [candidate]);
|
||
}
|
||
|
||
const cellBest = [];
|
||
const rest = [];
|
||
for (const bucket of cells.values()) {
|
||
bucket.sort(byRank);
|
||
cellBest.push(bucket[0]);
|
||
for (let i = 1; i < bucket.length; i++) rest.push(bucket[i]);
|
||
}
|
||
cellBest.sort(byRank);
|
||
rest.sort(byRank);
|
||
|
||
const picked = cellBest.slice(0, cap);
|
||
for (const candidate of rest) {
|
||
if (picked.length >= cap) break;
|
||
picked.push(candidate);
|
||
}
|
||
return picked.map((candidate) => candidate.id);
|
||
}
|
||
|
||
/**
|
||
* Selects the ambient card cameras from already projected in-view
|
||
* candidates: nearest-first up to the zoom budget. Static image cameras
|
||
* only — video cameras stay icon-only until explicitly activated (the frame
|
||
* endpoint pacing is built for stills), and the ACTIVE camera is excluded
|
||
* upstream by the caller (its monitor plane is the richer representation).
|
||
* Cameras in `incumbentIds` (current card holders) rank with the 20%
|
||
* incumbency discount (finding 4) so the ring changes gradually.
|
||
*
|
||
* When the caller supplies viewport dimensions (`viewW`/`viewH`) and screen
|
||
* anchors (`sx`/`sy` per candidate), ranking blends eye distance with
|
||
* screen-center offset before the existing screen-distribution pass. Without
|
||
* screen info the original nearest-first cap applies unchanged.
|
||
*
|
||
* @param {Array<{id:string,distanceKm:number,inView:boolean,isVideo?:boolean,sx?:number,sy?:number}>} candidates
|
||
* @param {Object} [options]
|
||
* @param {number} [options.cameraHeightM]
|
||
* @param {Iterable<string>|Set<string>} [options.incumbentIds]
|
||
* @param {number} [options.viewW] - Viewport width (CSS px) — enables the
|
||
* screen-distribution pass.
|
||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||
* @returns {{cardIds:string[],budgets:{cardLimit:number}}}
|
||
*/
|
||
export function selectCctvLod(
|
||
candidates,
|
||
{ cameraHeightM, incumbentIds, viewW, viewH } = {},
|
||
) {
|
||
const budgets = cctvLodBudgets(cameraHeightM);
|
||
const incumbents =
|
||
incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||
const screened = hasFiniteCctvViewport(viewW, viewH);
|
||
if (!screened) {
|
||
// Preserve the pre-refinement nearest-first path byte-for-behavior,
|
||
// including its acceptance of any non-empty string ID. The stricter
|
||
// valid-viewport rules below must not leak into this compatibility branch.
|
||
const inView = (Array.isArray(candidates) ? candidates : [])
|
||
.filter(
|
||
(candidate) =>
|
||
candidate && typeof candidate.id === 'string' && candidate.id,
|
||
)
|
||
.map((candidate) => ({
|
||
id: candidate.id,
|
||
distanceKm: Number.isFinite(candidate.distanceKm)
|
||
? Math.max(0, candidate.distanceKm)
|
||
: Infinity,
|
||
rankKm: incumbentRankKm(
|
||
candidate.distanceKm,
|
||
incumbents.has(candidate.id),
|
||
),
|
||
inView: candidate.inView === true,
|
||
isVideo: candidate.isVideo === true,
|
||
}))
|
||
.filter((candidate) => candidate.inView)
|
||
.sort(
|
||
(a, b) =>
|
||
a.rankKm - b.rankKm ||
|
||
a.distanceKm - b.distanceKm ||
|
||
a.id.localeCompare(b.id),
|
||
);
|
||
const cardIds = [];
|
||
const seen = new Set();
|
||
for (const candidate of inView) {
|
||
if (candidate.isVideo || seen.has(candidate.id)) continue;
|
||
cardIds.push(candidate.id);
|
||
seen.add(candidate.id);
|
||
if (cardIds.length >= budgets.cardLimit) break;
|
||
}
|
||
return { cardIds, budgets };
|
||
}
|
||
const eligibleById = new Map();
|
||
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
||
if (
|
||
!candidate ||
|
||
typeof candidate.id !== 'string' ||
|
||
!candidate.id.trim() ||
|
||
candidate.inView !== true ||
|
||
candidate.isVideo === true ||
|
||
!Number.isFinite(candidate.distanceKm)
|
||
)
|
||
continue;
|
||
const normalized = {
|
||
id: candidate.id,
|
||
distanceKm: Math.max(0, candidate.distanceKm),
|
||
rankKm: Infinity,
|
||
sx: Number.isFinite(candidate.sx) ? candidate.sx : NaN,
|
||
sy: Number.isFinite(candidate.sy) ? candidate.sy : NaN,
|
||
};
|
||
const current = eligibleById.get(normalized.id);
|
||
if (
|
||
!current ||
|
||
compareCctvRepresentative(normalized, current, {
|
||
screened,
|
||
viewW,
|
||
viewH,
|
||
}) < 0
|
||
) {
|
||
eligibleById.set(normalized.id, normalized);
|
||
}
|
||
}
|
||
|
||
// Only rankable, still-image, in-view, deduplicated candidates influence the
|
||
// robust distance scale or consume a bounded ambient-card slot.
|
||
const stills = [...eligibleById.values()];
|
||
|
||
const spreadKm = cctvCandidateSpreadKm(
|
||
stills.map((candidate) => candidate.distanceKm),
|
||
);
|
||
for (const candidate of stills) {
|
||
const blendedKm = blendCenterRankKm(
|
||
candidate.distanceKm,
|
||
screenCenterFraction(candidate.sx, candidate.sy, viewW, viewH),
|
||
spreadKm,
|
||
);
|
||
candidate.rankKm = incumbentRankKm(blendedKm, incumbents.has(candidate.id));
|
||
}
|
||
stills.sort(
|
||
(a, b) =>
|
||
a.rankKm - b.rankKm ||
|
||
a.distanceKm - b.distanceKm ||
|
||
a.id.localeCompare(b.id),
|
||
);
|
||
|
||
let cardIds;
|
||
// Item C: screen-distributed fill. Candidates lacking screen anchors are
|
||
// dropped by the distribution pass; top up from the ranked pool
|
||
// (defensive — cctv.js always projects anchors for in-view candidates).
|
||
cardIds = distributeCctvCards(stills, {
|
||
budget: budgets.cardLimit,
|
||
viewW,
|
||
viewH,
|
||
});
|
||
if (cardIds.length < budgets.cardLimit) {
|
||
const chosen = new Set(cardIds);
|
||
for (const candidate of stills) {
|
||
if (cardIds.length >= budgets.cardLimit) break;
|
||
if (!chosen.has(candidate.id)) cardIds.push(candidate.id);
|
||
}
|
||
}
|
||
|
||
return { cardIds, budgets };
|
||
}
|
||
|
||
function compareCctvRepresentative(a, b, { screened, viewW, viewH }) {
|
||
if (a.distanceKm !== b.distanceKm)
|
||
return a.distanceKm < b.distanceKm ? -1 : 1;
|
||
if (screened) {
|
||
const centerDelta =
|
||
screenCenterFraction(a.sx, a.sy, viewW, viewH) -
|
||
screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||
if (centerDelta) return centerDelta;
|
||
}
|
||
const ax = Number.isFinite(a.sx) ? a.sx : Infinity;
|
||
const bx = Number.isFinite(b.sx) ? b.sx : Infinity;
|
||
if (ax !== bx) return ax < bx ? -1 : 1;
|
||
const ay = Number.isFinite(a.sy) ? a.sy : Infinity;
|
||
const by = Number.isFinite(b.sy) ? b.sy : Infinity;
|
||
if (ay !== by) return ay < by ? -1 : 1;
|
||
return 0;
|
||
}
|
||
|
||
export const CCTV_LOD_GRACE_PASSES = 2;
|
||
export const CCTV_LOD_GRACE_MS = 5_000;
|
||
|
||
/**
|
||
* Eviction-grace hysteresis for the ambient card set (Part C review finding
|
||
* P2, kept verbatim).
|
||
*
|
||
* Raw per-pass selection has no memory: a camera sitting at the edge of the
|
||
* card budget churns in/out on every small camera move, blinking its card
|
||
* while orbiting. This planner keeps an already-built card alive for a short
|
||
* grace window after it falls out of the selection — it is only dropped once
|
||
* it STAYS unselected for `gracePasses` consecutive selection passes or
|
||
* `graceMs` of wall time, whichever is crossed first. A newly selected
|
||
* camera always enters immediately, and the hard `cardLimit` cap is never
|
||
* exceeded: when selected + graced cards would overflow the budget,
|
||
* grace-period cards are evicted first, oldest-in-grace first.
|
||
*
|
||
* Pure function: `graceState` is never mutated; the returned `graceState`
|
||
* replaces it for the next pass.
|
||
*
|
||
* @param {Object} [input]
|
||
* @param {string[]} [input.selectedIds] - This pass's selected card ids
|
||
* (already budget-capped, nearest-first — `selectCctvLod().cardIds` after
|
||
* declutter).
|
||
* @param {string[]} [input.builtIds] - Ids that currently have a live card.
|
||
* @param {Map<string,{misses:number,since:number}>} [input.graceState] - Prior
|
||
* pass's grace bookkeeping (id -> consecutive misses + first-miss timestamp).
|
||
* @param {number} [input.nowMs] - Current wall time in ms.
|
||
* @param {number} [input.cardLimit] - Hard cap on total kept cards.
|
||
* @param {number} [input.gracePasses] - Consecutive unselected passes tolerated.
|
||
* @param {number} [input.graceMs] - Max wall-time a card may linger in grace.
|
||
* @returns {{keepIds:string[], evictIds:string[], graceState:Map<string,{misses:number,since:number}>}}
|
||
*/
|
||
export function applyEvictionGrace({
|
||
selectedIds = [],
|
||
builtIds = [],
|
||
graceState = new Map(),
|
||
nowMs = 0,
|
||
cardLimit = CCTV_AMBIENT_CARD_MAX,
|
||
gracePasses = CCTV_LOD_GRACE_PASSES,
|
||
graceMs = CCTV_LOD_GRACE_MS,
|
||
} = {}) {
|
||
const selectedSet = new Set(selectedIds);
|
||
const keepIds = [...selectedSet];
|
||
const evictIds = [];
|
||
const nextGrace = new Map();
|
||
|
||
const graced = [];
|
||
for (const id of builtIds) {
|
||
if (typeof id !== 'string' || !id || selectedSet.has(id)) continue;
|
||
const prior = graceState.get(id);
|
||
const misses = (prior?.misses || 0) + 1;
|
||
const since = Number.isFinite(prior?.since) ? prior.since : nowMs;
|
||
if (misses > gracePasses || nowMs - since >= graceMs) {
|
||
evictIds.push(id);
|
||
} else {
|
||
graced.push({ id, misses, since });
|
||
}
|
||
}
|
||
|
||
// Under cap pressure the grace-period cards go first, oldest-in-grace
|
||
// first (they have had the longest chance to return); ties break on more
|
||
// misses, then id for determinism.
|
||
graced.sort(
|
||
(a, b) =>
|
||
a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id),
|
||
);
|
||
const capacity = Math.max(0, cardLimit - keepIds.length);
|
||
const overflow = Math.max(0, graced.length - capacity);
|
||
for (let i = 0; i < graced.length; i++) {
|
||
if (i < overflow) {
|
||
evictIds.push(graced[i].id);
|
||
continue;
|
||
}
|
||
keepIds.push(graced[i].id);
|
||
nextGrace.set(graced[i].id, {
|
||
misses: graced[i].misses,
|
||
since: graced[i].since,
|
||
});
|
||
}
|
||
|
||
return { keepIds, evictIds, graceState: nextGrace };
|
||
}
|
||
|
||
/**
|
||
* Returns the source-aware static-frame refresh cadence. An explicit source
|
||
* value wins, bounded to one minute through twenty minutes; otherwise known
|
||
* public-pack cadences provide conservative defaults.
|
||
*
|
||
* @param {Object} camera
|
||
* @returns {number}
|
||
*/
|
||
export function staticFrameRefreshMs(camera) {
|
||
const explicit = Number(camera?.frameRefreshMs);
|
||
if (Number.isFinite(explicit) && explicit > 0) {
|
||
return Math.max(60_000, Math.min(20 * 60 * 1000, Math.round(explicit)));
|
||
}
|
||
const provider = String(camera?.provider || '')
|
||
.trim()
|
||
.toLowerCase();
|
||
return PROVIDER_STATIC_REFRESH_MS[provider] || DEFAULT_STATIC_REFRESH_MS;
|
||
}
|