@camstack/system 1.1.42 → 1.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts +8 -2
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +25 -10
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +25 -10
  8. package/dist/builtins/alerts/alerts.addon.js +1 -1
  9. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  12. package/dist/builtins/console-logging/index.js +1 -1
  13. package/dist/builtins/console-logging/index.mjs +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  16. package/dist/builtins/hub-forwarder/index.js +1 -1
  17. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  18. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  19. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  20. package/dist/builtins/local-network/local-network.addon.js +1 -1
  21. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  22. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  23. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  24. package/dist/builtins/platform-probe/index.js +1 -1
  25. package/dist/builtins/platform-probe/index.mjs +1 -1
  26. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  27. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  28. package/dist/builtins/snapshot/index.js +337 -27
  29. package/dist/builtins/snapshot/index.mjs +337 -27
  30. package/dist/builtins/snapshot/snapshot-coalescing.d.ts +130 -0
  31. package/dist/builtins/snapshot/snapshot.addon.d.ts +40 -4
  32. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  33. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  34. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  35. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  36. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  37. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  38. package/dist/builtins/system-config/system-config.addon.js +1 -1
  39. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  40. package/dist/builtins/winston-logging/index.js +1 -1
  41. package/dist/builtins/winston-logging/index.mjs +1 -1
  42. package/dist/{dist-BCseJMTD.js → dist-BP89I7gi.js} +33 -2
  43. package/dist/{dist-ClOjKsC4.mjs → dist-C68OyuEd.mjs} +33 -2
  44. package/dist/index.js +2 -2
  45. package/dist/index.mjs +2 -2
  46. package/dist/{manifest-python-deps-BVUZVpma.js → manifest-python-deps-CJn9aExb.js} +1 -1
  47. package/dist/{manifest-python-deps-DyIcZtF5.mjs → manifest-python-deps-MJEshIKy.mjs} +1 -1
  48. package/package.json +1 -1
@@ -1,5 +1,200 @@
1
- import { $ as errMsg, K as snapshotCapability, L as nodePin, Y as streamQualityLabel, et as BaseAddon, it as DeviceFeature, ot as DeviceType } from "../../dist-ClOjKsC4.mjs";
1
+ import { $ as errMsg, K as snapshotCapability, L as nodePin, Y as streamQualityLabel, et as BaseAddon, it as DeviceFeature, ot as DeviceType } from "../../dist-C68OyuEd.mjs";
2
2
  import { execFile } from "node:child_process";
3
+ //#region src/builtins/snapshot/snapshot-coalescing.ts
4
+ /**
5
+ * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
6
+ * primitives for the snapshot wrapper.
7
+ *
8
+ * These are extracted from `SnapshotAddon` so the decision logic is unit-testable
9
+ * without instantiating the addon (no camera, no ffmpeg, no tRPC). The addon wires
10
+ * them to its cache + ladder; this module owns only the mechanics:
11
+ *
12
+ * - {@link decideSnapshotServe} — the pure serve/refresh decision (fresh cache vs
13
+ * stale-while-revalidate vs cold blocking capture) keyed off the cached frame
14
+ * age, the per-device max-age, and whether the caller forced a refresh.
15
+ * - {@link Semaphore} — a counting semaphore bounding concurrent grabs.
16
+ * - {@link SingleFlight} — de-dupes concurrent captures for the same key and
17
+ * retains a SETTLED success for a short hold window so a mount burst collapses
18
+ * into one capture.
19
+ * - {@link raceForResult} — races a promise against a wait bound without
20
+ * cancelling the promise (the loser keeps running in the background).
21
+ */
22
+ /**
23
+ * Retain a SETTLED single-flight capture this long so a grid-mount burst of
24
+ * `getSnapshot` calls (and a row of refresh buttons) collapses into ONE capture
25
+ * instead of one dial per tile. Only success outcomes are held — a null/failed
26
+ * capture expires immediately so a transiently-unreachable camera is retried on
27
+ * the next call rather than pinned blank for the window.
28
+ */
29
+ var COALESCE_MS = 4e3;
30
+ /**
31
+ * Stale-while-revalidate: when a cached frame exists but is past its max-age,
32
+ * start the refresh and wait at most this long for it to land before returning
33
+ * the STALE frame (the refresh keeps running and populates the cache for the
34
+ * next poll). Keeps a grid mount painting instantly from cache.
35
+ */
36
+ var SWR_STALE_WAIT_MS = 2500;
37
+ /**
38
+ * Cold path (no cached frame) or a forced refresh: wait up to this long for a
39
+ * fresh capture. On a cold miss there is nothing to fall back to, so we block
40
+ * (bounded) on the capture; on a forced refresh with a stale frame available we
41
+ * still prefer a fresh one but cap the wait, falling back to stale on timeout.
42
+ */
43
+ var SWR_COLD_WAIT_MS = 1e4;
44
+ /**
45
+ * Pure serve/refresh decision.
46
+ *
47
+ * - fresh cache + !force -> serve-cache
48
+ * - stale cache + !force -> SWR: wait SWR_STALE_WAIT_MS, then stale
49
+ * - no cache + !force -> cold: wait SWR_COLD_WAIT_MS, no fallback
50
+ * - force (cache present or not) -> prefer fresh: wait SWR_COLD_WAIT_MS,
51
+ * fall back to stale only if a frame exists
52
+ */
53
+ function decideSnapshotServe(input) {
54
+ const { now, cachedAt, effectiveMaxAgeMs, force } = input;
55
+ const hasFrame = cachedAt !== null;
56
+ const fresh = hasFrame && now - cachedAt < effectiveMaxAgeMs;
57
+ if (!force && fresh) return { kind: "serve-cache" };
58
+ if (force) return {
59
+ kind: "await-refresh",
60
+ waitMs: SWR_COLD_WAIT_MS,
61
+ staleFallback: hasFrame
62
+ };
63
+ if (hasFrame) return {
64
+ kind: "await-refresh",
65
+ waitMs: SWR_STALE_WAIT_MS,
66
+ staleFallback: true
67
+ };
68
+ return {
69
+ kind: "await-refresh",
70
+ waitMs: SWR_COLD_WAIT_MS,
71
+ staleFallback: false
72
+ };
73
+ }
74
+ /**
75
+ * A counting semaphore. `run` acquires a permit, awaits `fn`, and releases the
76
+ * permit (even if `fn` throws), so at most `max` `fn`s run concurrently. Waiters
77
+ * are served FIFO.
78
+ */
79
+ var Semaphore = class {
80
+ available;
81
+ waiters = [];
82
+ constructor(max) {
83
+ if (!Number.isInteger(max) || max < 1) throw new Error(`Semaphore max must be a positive integer, got ${String(max)}`);
84
+ this.available = max;
85
+ }
86
+ async run(fn) {
87
+ const release = await this.acquire();
88
+ try {
89
+ return await fn();
90
+ } finally {
91
+ release();
92
+ }
93
+ }
94
+ async acquire() {
95
+ if (this.available > 0) {
96
+ this.available -= 1;
97
+ return this.makeReleaser();
98
+ }
99
+ await new Promise((resolve) => this.waiters.push(resolve));
100
+ return this.makeReleaser();
101
+ }
102
+ makeReleaser() {
103
+ let released = false;
104
+ return () => {
105
+ if (released) return;
106
+ released = true;
107
+ const next = this.waiters.shift();
108
+ if (next) {
109
+ next();
110
+ return;
111
+ }
112
+ this.available += 1;
113
+ };
114
+ }
115
+ };
116
+ /**
117
+ * One in-flight capture per key. A settled SUCCESS is retained for `holdMs` so a
118
+ * burst collapses into one capture; a settled failure/empty expires immediately
119
+ * so the next caller retries. Concurrent callers ALWAYS join the running flight
120
+ * regardless of eventual outcome.
121
+ */
122
+ var Flight = class {
123
+ promise;
124
+ settledAt = null;
125
+ hold = false;
126
+ constructor(factory, now, holdWorthy) {
127
+ this.promise = (async () => {
128
+ try {
129
+ const value = await factory();
130
+ this.hold = holdWorthy(value);
131
+ return value;
132
+ } catch (err) {
133
+ this.hold = false;
134
+ throw err;
135
+ } finally {
136
+ this.settledAt = now();
137
+ }
138
+ })();
139
+ }
140
+ };
141
+ var SingleFlight = class {
142
+ holdMs;
143
+ holdWorthy;
144
+ now;
145
+ flights = /* @__PURE__ */ new Map();
146
+ /**
147
+ * @param holdMs how long a settled hold-worthy result is reused
148
+ * @param holdWorthy predicate deciding whether a settled value is worth holding
149
+ * (e.g. a successful capture with a real frame)
150
+ * @param now clock injection for tests
151
+ */
152
+ constructor(holdMs, holdWorthy, now = () => Date.now()) {
153
+ this.holdMs = holdMs;
154
+ this.holdWorthy = holdWorthy;
155
+ this.now = now;
156
+ }
157
+ run(key, factory) {
158
+ const existing = this.flights.get(key);
159
+ if (existing && this.isReusable(existing)) return existing.promise;
160
+ const flight = new Flight(factory, this.now, this.holdWorthy);
161
+ this.flights.set(key, flight);
162
+ return flight.promise;
163
+ }
164
+ /** Drop every held flight whose key starts with `prefix` (e.g. `"<deviceId>:"`). */
165
+ invalidatePrefix(prefix) {
166
+ for (const key of [...this.flights.keys()]) if (key.startsWith(prefix)) this.flights.delete(key);
167
+ }
168
+ clear() {
169
+ this.flights.clear();
170
+ }
171
+ isReusable(flight) {
172
+ if (flight.settledAt === null) return true;
173
+ if (!flight.hold) return false;
174
+ return this.now() - flight.settledAt < this.holdMs;
175
+ }
176
+ };
177
+ /**
178
+ * Race `promise` against a `timeoutMs` bound WITHOUT cancelling it — on timeout
179
+ * the promise keeps running (its result lands in the cache for the next poll).
180
+ * Rejections propagate: if `promise` rejects before the timeout, this rejects.
181
+ * The caller passes a promise that never rejects (a settled outcome union) when
182
+ * background continuation must stay unhandled-safe.
183
+ */
184
+ function raceForResult(promise, timeoutMs) {
185
+ let timer;
186
+ const timeout = new Promise((resolve) => {
187
+ timer = setTimeout(() => resolve({ settled: false }), timeoutMs);
188
+ });
189
+ const settled = promise.then((value) => ({
190
+ settled: true,
191
+ value
192
+ }));
193
+ return Promise.race([settled, timeout]).finally(() => {
194
+ if (timer) clearTimeout(timer);
195
+ });
196
+ }
197
+ //#endregion
3
198
  //#region src/builtins/snapshot/snapshot.addon.ts
4
199
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
5
200
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
@@ -26,6 +221,16 @@ var BATTERY_DEFAULT_MAX_AGE_S = 3600;
26
221
  var SnapshotAddon = class SnapshotAddon extends BaseAddon {
27
222
  cache = /* @__PURE__ */ new Map();
28
223
  /**
224
+ * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
225
+ * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
226
+ * buttons) collapses into one capture instead of one dial per tile.
227
+ */
228
+ captureFlight = new SingleFlight(COALESCE_MS, (outcome) => outcome.ok && outcome.image !== null);
229
+ /** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
230
+ grabPool = new Semaphore(3);
231
+ /** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
232
+ nativePool = new Semaphore(6);
233
+ /**
29
234
  * Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
30
235
  * has no per-request attach hook to resolve this fresh, so it's cached with
31
236
  * a short TTL and invalidated on `onConfigChanged` (covers the common case
@@ -48,12 +253,14 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
48
253
  getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
49
254
  getDeviceLiveContribution: async () => null,
50
255
  applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
51
- getStatus: async (input) => this.getStatus(input.deviceId)
256
+ getStatus: async (input) => this.getStatus(input.deviceId),
257
+ getSnapshotOverview: (input) => this.getSnapshotOverview(input)
52
258
  }
53
259
  }];
54
260
  }
55
261
  async onShutdown() {
56
262
  this.cache.clear();
263
+ this.captureFlight.clear();
57
264
  this.ownerCache = null;
58
265
  }
59
266
  /**
@@ -86,7 +293,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
86
293
  }] });
87
294
  }
88
295
  async getSnapshot(input) {
89
- const { deviceId, force } = input;
296
+ const { deviceId } = input;
297
+ const force = input.force === true;
90
298
  const meta = await this.lookupDeviceMeta(deviceId);
91
299
  const deviceName = meta?.name;
92
300
  const isBatteryDevice = meta?.isBattery ?? false;
@@ -105,8 +313,14 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
105
313
  });
106
314
  const defaultMaxAgeS = isBatteryDevice ? BATTERY_DEFAULT_MAX_AGE_S : NON_BATTERY_DEFAULT_MAX_AGE_S;
107
315
  const effectiveMaxAgeMs = (typeof prefs.snapshotMaxAgeS === "number" && prefs.snapshotMaxAgeS >= 0 ? prefs.snapshotMaxAgeS : defaultMaxAgeS) * 1e3;
108
- if (!force && hit && now - hit.ts < effectiveMaxAgeMs) {
109
- if (prefs.snapshotDebug) log.debug("snapshot: cache hit", {
316
+ const decision = decideSnapshotServe({
317
+ now,
318
+ cachedAt: hit?.ts ?? null,
319
+ effectiveMaxAgeMs,
320
+ force
321
+ });
322
+ if (decision.kind === "serve-cache") {
323
+ if (prefs.snapshotDebug && hit) log.debug("snapshot: cache hit", {
110
324
  tags: { deviceId },
111
325
  meta: {
112
326
  ageMs: now - hit.ts,
@@ -114,21 +328,97 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
114
328
  isBattery: isBatteryDevice
115
329
  }
116
330
  });
331
+ return hit ? hit.data : null;
332
+ }
333
+ const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
334
+ const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
335
+ deviceId,
336
+ input,
337
+ effectiveStreamId,
338
+ isBatteryDevice,
339
+ log
340
+ }));
341
+ flight.catch(() => void 0);
342
+ const raced = await raceForResult(flight, decision.waitMs);
343
+ if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
344
+ if (decision.staleFallback && hit) {
345
+ if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
346
+ tags: { deviceId },
347
+ meta: {
348
+ ageMs: now - hit.ts,
349
+ waitMs: decision.waitMs
350
+ }
351
+ });
352
+ return hit.data;
353
+ }
354
+ return null;
355
+ }
356
+ /**
357
+ * Map a settled capture outcome onto the response, preserving the legacy
358
+ * semantics: a fresh frame wins; a soft miss (native null + broker null)
359
+ * falls back to stale cache (or null); a HARD native error with no frame
360
+ * and no cache propagates.
361
+ */
362
+ resolveOutcome(outcome, deviceId, hit, log) {
363
+ if (outcome.ok) {
364
+ if (outcome.image) return outcome.image;
365
+ if (hit) {
366
+ const ageMs = Date.now() - hit.ts;
367
+ if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
368
+ tags: { deviceId },
369
+ meta: { ageMs }
370
+ });
371
+ return hit.data;
372
+ }
373
+ return null;
374
+ }
375
+ if (hit) {
376
+ const ageMs = Date.now() - hit.ts;
377
+ if (ageMs > this.config.staleTtlMs) log.warn("snapshot: native failed — serving stale cache", {
378
+ tags: { deviceId },
379
+ meta: { ageMs }
380
+ });
117
381
  return hit.data;
118
382
  }
383
+ throw outcome.error;
384
+ }
385
+ /**
386
+ * Run the capture ladder ONCE for a device: native provider first, then the
387
+ * stream-broker ffmpeg fallback. Never rejects — resolves a {@link
388
+ * CaptureOutcome}. On a produced frame it populates the cache (so a
389
+ * background SWR refresh lands for the next poll) and returns it. A hard
390
+ * native error with no broker frame resolves `{ ok:false }` so the caller
391
+ * can surface it; a soft miss resolves `{ ok:true, image:null }`.
392
+ *
393
+ * TODO(decoded-frame fast path): for a device whose detection pipeline is
394
+ * already decoding (the always-on subscription, see docs/design/decode-path.md),
395
+ * the freshest decoded frame is already in the shared-memory ring — a single
396
+ * JPEG encode of the latest FrameHandle would cost zero dials and beat the
397
+ * ffmpeg grab for the wrapper fleet. Slotting it as native -> latest-decoded
398
+ * -> broker needs cross-addon plumbing not cheaply reachable from the hub
399
+ * builtin today: frames are session-scoped (decoder `pullHandles`/`getFrame`
400
+ * need the device's active session id) and raw-pixel (need a JPEG encoder the
401
+ * builtin lacks). Deferred as its own slice.
402
+ */
403
+ async captureFresh(args) {
404
+ const { deviceId, input, effectiveStreamId, isBatteryDevice, log } = args;
405
+ const now = Date.now();
119
406
  let nativeError = null;
120
407
  let nativeAbsent = false;
121
408
  try {
122
409
  const native = this.ctx.getNativeProvider(snapshotCapability, deviceId);
123
410
  if (native) {
124
- const result = await native.getSnapshot(input);
411
+ const result = await this.nativePool.run(() => native.getSnapshot(input));
125
412
  if (result) {
126
413
  this.cache.set(deviceId, {
127
414
  data: result,
128
415
  ts: now,
129
416
  streamId: effectiveStreamId ?? null
130
417
  });
131
- return result;
418
+ return {
419
+ ok: true,
420
+ image: result
421
+ };
132
422
  }
133
423
  log.debug("native snapshot returned null — falling through to broker", { tags: { deviceId } });
134
424
  } else {
@@ -159,7 +449,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
159
449
  ts: now,
160
450
  streamId: effectiveStreamId ?? null
161
451
  });
162
- return fallback;
452
+ return {
453
+ ok: true,
454
+ image: fallback
455
+ };
163
456
  }
164
457
  } catch (err) {
165
458
  log.warn("stream-broker snapshot fallback failed", {
@@ -168,25 +461,16 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
168
461
  });
169
462
  }
170
463
  else log.debug("snapshot: skipping broker fallback — battery device with absent native and no streaming broker", { tags: { deviceId } });
171
- if (hit) {
172
- const ageMs = now - hit.ts;
173
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
174
- tags: { deviceId },
175
- meta: { ageMs }
176
- });
177
- return hit.data;
178
- }
179
- if (nativeError) throw nativeError;
180
- if (nativeAbsent) return null;
181
- return null;
464
+ if (nativeError) return {
465
+ ok: false,
466
+ error: nativeError
467
+ };
468
+ return {
469
+ ok: true,
470
+ image: null
471
+ };
182
472
  }
183
473
  /**
184
- * Tell apart "native provider isn't registered for this device" from
185
- * "native provider ran and threw a real error". The former is the steady
186
- * state for cameras without a vendor snapshot endpoint and should not
187
- * propagate as a 500; the latter should.
188
- */
189
- /**
190
474
  * Pull one JPEG from the device's stream-broker RTSP restream using
191
475
  * a short-lived ffmpeg invocation.
192
476
  *
@@ -291,12 +575,12 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
291
575
  async runGrabWithResumeRetry(url, deviceId) {
292
576
  let buf;
293
577
  try {
294
- buf = await runFfmpegFrameGrab(url, 15e3);
578
+ buf = await this.grabPool.run(() => runFfmpegFrameGrab(url, 15e3));
295
579
  } catch (err) {
296
580
  if (isBrokerColdError(errMsg(err))) {
297
581
  this.ctx.logger.debug("grabFrame: broker-resume race — retrying in 1500ms", { tags: { deviceId } });
298
582
  await new Promise((r) => setTimeout(r, 1500));
299
- buf = await runFfmpegFrameGrab(url, 15e3);
583
+ buf = await this.grabPool.run(() => runFfmpegFrameGrab(url, 15e3));
300
584
  } else throw err;
301
585
  }
302
586
  if (buf.length === 0) return null;
@@ -307,6 +591,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
307
591
  }
308
592
  async invalidateCache(input) {
309
593
  this.cache.delete(input.deviceId);
594
+ this.captureFlight.invalidatePrefix(`${input.deviceId}:`);
310
595
  }
311
596
  /**
312
597
  * Non-throwing probe of the device's battery cap. Returns true only
@@ -346,6 +631,30 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
346
631
  }
347
632
  }
348
633
  /**
634
+ * Cache-only batch overview — O(n) over the in-memory cache, NEVER triggers
635
+ * a capture. Lets a grid skip requesting images for devices that never
636
+ * produced a frame, and hands each an ETag for conditional image fetches.
637
+ */
638
+ getSnapshotOverview(input) {
639
+ const now = Date.now();
640
+ const rows = input.deviceIds.map((deviceId) => {
641
+ const hit = this.cache.get(deviceId);
642
+ if (!hit) return {
643
+ deviceId,
644
+ lastCapturedAt: null,
645
+ cacheAgeMs: null,
646
+ etag: null
647
+ };
648
+ return {
649
+ deviceId,
650
+ lastCapturedAt: hit.ts,
651
+ cacheAgeMs: now - hit.ts,
652
+ etag: `"${deviceId}-${hit.ts}"`
653
+ };
654
+ });
655
+ return Promise.resolve(rows);
656
+ }
657
+ /**
349
658
  * Diagnostic status for the `status` auto-injected cap method. Reports
350
659
  * the cache bookkeeping for this device — when the last snapshot was
351
660
  * captured, how stale the cached image is, its size, and which stream
@@ -502,6 +811,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
502
811
  }
503
812
  await this.ctx.settings.writeDeviceStore(deviceId, next);
504
813
  this.cache.delete(deviceId);
814
+ this.captureFlight.invalidatePrefix(`${deviceId}:`);
505
815
  return { success: true };
506
816
  }
507
817
  };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
3
+ * primitives for the snapshot wrapper.
4
+ *
5
+ * These are extracted from `SnapshotAddon` so the decision logic is unit-testable
6
+ * without instantiating the addon (no camera, no ffmpeg, no tRPC). The addon wires
7
+ * them to its cache + ladder; this module owns only the mechanics:
8
+ *
9
+ * - {@link decideSnapshotServe} — the pure serve/refresh decision (fresh cache vs
10
+ * stale-while-revalidate vs cold blocking capture) keyed off the cached frame
11
+ * age, the per-device max-age, and whether the caller forced a refresh.
12
+ * - {@link Semaphore} — a counting semaphore bounding concurrent grabs.
13
+ * - {@link SingleFlight} — de-dupes concurrent captures for the same key and
14
+ * retains a SETTLED success for a short hold window so a mount burst collapses
15
+ * into one capture.
16
+ * - {@link raceForResult} — races a promise against a wait bound without
17
+ * cancelling the promise (the loser keeps running in the background).
18
+ */
19
+ /**
20
+ * Retain a SETTLED single-flight capture this long so a grid-mount burst of
21
+ * `getSnapshot` calls (and a row of refresh buttons) collapses into ONE capture
22
+ * instead of one dial per tile. Only success outcomes are held — a null/failed
23
+ * capture expires immediately so a transiently-unreachable camera is retried on
24
+ * the next call rather than pinned blank for the window.
25
+ */
26
+ export declare const COALESCE_MS = 4000;
27
+ /**
28
+ * Stale-while-revalidate: when a cached frame exists but is past its max-age,
29
+ * start the refresh and wait at most this long for it to land before returning
30
+ * the STALE frame (the refresh keeps running and populates the cache for the
31
+ * next poll). Keeps a grid mount painting instantly from cache.
32
+ */
33
+ export declare const SWR_STALE_WAIT_MS = 2500;
34
+ /**
35
+ * Cold path (no cached frame) or a forced refresh: wait up to this long for a
36
+ * fresh capture. On a cold miss there is nothing to fall back to, so we block
37
+ * (bounded) on the capture; on a forced refresh with a stale frame available we
38
+ * still prefer a fresh one but cap the wait, falling back to stale on timeout.
39
+ */
40
+ export declare const SWR_COLD_WAIT_MS = 10000;
41
+ /**
42
+ * Max simultaneous ffmpeg keyframe grabs — the WRAPPER path, the common case for
43
+ * cameras with no vendor snapshot URL. A 20-cam grid mount spawns at most this
44
+ * many ffmpeg processes; the rest serve cached frames (SWR) or queue.
45
+ */
46
+ export declare const MAX_CONCURRENT_GRABS = 3;
47
+ /**
48
+ * Max simultaneous cheap native snapshot fetches (vendor HTTP/ONVIF). Higher than
49
+ * the ffmpeg bound because a native fetch is a lightweight HTTP round-trip, not a
50
+ * decode pipeline.
51
+ */
52
+ export declare const MAX_CONCURRENT_NATIVE = 6;
53
+ /**
54
+ * Serve either the fresh cache directly, or start/join a refresh and wait a
55
+ * bounded time. `await-refresh` always runs (or joins) the single flight;
56
+ * `waitMs` bounds how long the caller waits before, when `staleFallback` is set
57
+ * and a cached frame exists, returning the stale frame while the refresh
58
+ * continues in the background.
59
+ */
60
+ export type SnapshotServeDecision = {
61
+ readonly kind: 'serve-cache';
62
+ } | {
63
+ readonly kind: 'await-refresh';
64
+ readonly waitMs: number;
65
+ readonly staleFallback: boolean;
66
+ };
67
+ export interface SnapshotServeInputs {
68
+ readonly now: number;
69
+ /** Epoch ms of the cached frame, or null when nothing is cached. */
70
+ readonly cachedAt: number | null;
71
+ /** Effective per-device max cache age in ms (battery-aware default or override). */
72
+ readonly effectiveMaxAgeMs: number;
73
+ /** Caller forced a refresh (UI refresh button) — bypass the freshness gate. */
74
+ readonly force: boolean;
75
+ }
76
+ /**
77
+ * Pure serve/refresh decision.
78
+ *
79
+ * - fresh cache + !force -> serve-cache
80
+ * - stale cache + !force -> SWR: wait SWR_STALE_WAIT_MS, then stale
81
+ * - no cache + !force -> cold: wait SWR_COLD_WAIT_MS, no fallback
82
+ * - force (cache present or not) -> prefer fresh: wait SWR_COLD_WAIT_MS,
83
+ * fall back to stale only if a frame exists
84
+ */
85
+ export declare function decideSnapshotServe(input: SnapshotServeInputs): SnapshotServeDecision;
86
+ /**
87
+ * A counting semaphore. `run` acquires a permit, awaits `fn`, and releases the
88
+ * permit (even if `fn` throws), so at most `max` `fn`s run concurrently. Waiters
89
+ * are served FIFO.
90
+ */
91
+ export declare class Semaphore {
92
+ private available;
93
+ private readonly waiters;
94
+ constructor(max: number);
95
+ run<T>(fn: () => Promise<T>): Promise<T>;
96
+ private acquire;
97
+ private makeReleaser;
98
+ }
99
+ export declare class SingleFlight<T> {
100
+ private readonly holdMs;
101
+ private readonly holdWorthy;
102
+ private readonly now;
103
+ private readonly flights;
104
+ /**
105
+ * @param holdMs how long a settled hold-worthy result is reused
106
+ * @param holdWorthy predicate deciding whether a settled value is worth holding
107
+ * (e.g. a successful capture with a real frame)
108
+ * @param now clock injection for tests
109
+ */
110
+ constructor(holdMs: number, holdWorthy: (value: T) => boolean, now?: () => number);
111
+ run(key: string, factory: () => Promise<T>): Promise<T>;
112
+ /** Drop every held flight whose key starts with `prefix` (e.g. `"<deviceId>:"`). */
113
+ invalidatePrefix(prefix: string): void;
114
+ clear(): void;
115
+ private isReusable;
116
+ }
117
+ export type RaceOutcome<T> = {
118
+ readonly settled: true;
119
+ readonly value: T;
120
+ } | {
121
+ readonly settled: false;
122
+ };
123
+ /**
124
+ * Race `promise` against a `timeoutMs` bound WITHOUT cancelling it — on timeout
125
+ * the promise keeps running (its result lands in the cache for the next poll).
126
+ * Rejections propagate: if `promise` rejects before the timeout, this rejects.
127
+ * The caller passes a promise that never rejects (a settled outcome union) when
128
+ * background continuation must stay unhandled-safe.
129
+ */
130
+ export declare function raceForResult<T>(promise: Promise<T>, timeoutMs: number): Promise<RaceOutcome<T>>;
@@ -30,6 +30,16 @@ interface SnapshotAddonConfig {
30
30
  */
31
31
  export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
32
32
  private readonly cache;
33
+ /**
34
+ * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
35
+ * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
36
+ * buttons) collapses into one capture instead of one dial per tile.
37
+ */
38
+ private readonly captureFlight;
39
+ /** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
40
+ private readonly grabPool;
41
+ /** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
42
+ private readonly nativePool;
33
43
  /**
34
44
  * Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
35
45
  * has no per-request attach hook to resolve this fresh, so it's cached with
@@ -55,11 +65,31 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
55
65
  protected globalSettingsSchema(): import('@camstack/types').ConfigUISchema;
56
66
  private getSnapshot;
57
67
  /**
58
- * Tell apart "native provider isn't registered for this device" from
59
- * "native provider ran and threw a real error". The former is the steady
60
- * state for cameras without a vendor snapshot endpoint and should not
61
- * propagate as a 500; the latter should.
68
+ * Map a settled capture outcome onto the response, preserving the legacy
69
+ * semantics: a fresh frame wins; a soft miss (native null + broker null)
70
+ * falls back to stale cache (or null); a HARD native error with no frame
71
+ * and no cache propagates.
62
72
  */
73
+ private resolveOutcome;
74
+ /**
75
+ * Run the capture ladder ONCE for a device: native provider first, then the
76
+ * stream-broker ffmpeg fallback. Never rejects — resolves a {@link
77
+ * CaptureOutcome}. On a produced frame it populates the cache (so a
78
+ * background SWR refresh lands for the next poll) and returns it. A hard
79
+ * native error with no broker frame resolves `{ ok:false }` so the caller
80
+ * can surface it; a soft miss resolves `{ ok:true, image:null }`.
81
+ *
82
+ * TODO(decoded-frame fast path): for a device whose detection pipeline is
83
+ * already decoding (the always-on subscription, see docs/design/decode-path.md),
84
+ * the freshest decoded frame is already in the shared-memory ring — a single
85
+ * JPEG encode of the latest FrameHandle would cost zero dials and beat the
86
+ * ffmpeg grab for the wrapper fleet. Slotting it as native -> latest-decoded
87
+ * -> broker needs cross-addon plumbing not cheaply reachable from the hub
88
+ * builtin today: frames are session-scoped (decoder `pullHandles`/`getFrame`
89
+ * need the device's active session id) and raw-pixel (need a JPEG encoder the
90
+ * builtin lacks). Deferred as its own slice.
91
+ */
92
+ private captureFresh;
63
93
  /**
64
94
  * Pull one JPEG from the device's stream-broker RTSP restream using
65
95
  * a short-lived ffmpeg invocation.
@@ -138,6 +168,12 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
138
168
  * host) is byte-identical to the previous unpinned call.
139
169
  */
140
170
  private hasStreamingBrokerForDevice;
171
+ /**
172
+ * Cache-only batch overview — O(n) over the in-memory cache, NEVER triggers
173
+ * a capture. Lets a grid skip requesting images for devices that never
174
+ * produced a frame, and hands each an ETag for conditional image fetches.
175
+ */
176
+ private getSnapshotOverview;
141
177
  /**
142
178
  * Diagnostic status for the `status` auto-injected cap method. Reports
143
179
  * the cache bookkeeping for this device — when the last snapshot was
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BCseJMTD.js");
6
+ const require_dist = require("../../dist-BP89I7gi.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_chunk.__toESM(node_fs);
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { E as filesystemBrowseCapability, J as storageProviderCapability, et as BaseAddon } from "../../dist-ClOjKsC4.mjs";
1
+ import { E as filesystemBrowseCapability, J as storageProviderCapability, et as BaseAddon } from "../../dist-C68OyuEd.mjs";
2
2
  import * as fs from "node:fs";
3
3
  import * as path$1 from "node:path";
4
4
  import { basename, dirname, join, resolve, sep } from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BCseJMTD.js");
6
+ const require_dist = require("../../dist-BP89I7gi.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let better_sqlite3 = require("better-sqlite3");
9
9
  better_sqlite3 = require_chunk.__toESM(better_sqlite3);