mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
TranStar's disclaimer prohibits copying, distribution and external framing of specific images without TxDOT's prior written permission, so the pack no longer loads by default. It now uses envOptIn and loads only when CCTV_HOUSTON_ENABLED=1, the same pattern as NYC DOT and Via Lietuva. - DATA_SOURCES.md: replace the licence column with the disclaimer's terms and a link, and document the opt-in - Ambient stills for Houston refresh every 3 minutes, matching TranStar's stated snapshot cadence - README, CHANGELOG, CURRENT-STATE and .env.example describe the pack as opt-in; the README camera count no longer includes Houston - Tests cover the default-off and =0 cases and the opted-in catalog path
529 lines
19 KiB
JavaScript
529 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,
|
||
// TranStar says snapshots update about every 3 minutes.
|
||
'houston transtar': 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,
|
||
});
|
||
|
||
/**
|
||
* 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. */
|
||
export 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.
|
||
export const CCTV_CARD_GRID_COLS = 5;
|
||
export 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;
|
||
}
|