@camstack/system 1.2.39 → 1.2.40

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 (59) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/block-package.d.ts +59 -0
  12. package/dist/builtins/core-blocks/block-supervisor.d.ts +86 -0
  13. package/dist/builtins/core-blocks/core-blocks.addon.d.ts +18 -0
  14. package/dist/builtins/core-blocks/core-blocks.addon.js +533 -68
  15. package/dist/builtins/core-blocks/core-blocks.addon.mjs +528 -69
  16. package/dist/builtins/core-blocks/index.d.ts +5 -3
  17. package/dist/builtins/core-blocks/index.js +6 -0
  18. package/dist/builtins/core-blocks/index.mjs +2 -2
  19. package/dist/builtins/device-manager/device-manager.addon.js +14 -6
  20. package/dist/builtins/device-manager/device-manager.addon.mjs +14 -6
  21. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  23. package/dist/builtins/hub-forwarder/index.js +1 -1
  24. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  25. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  26. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  28. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  29. package/dist/builtins/local-network/local-network.addon.js +1 -1
  30. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  31. package/dist/builtins/loki-logging/index.js +1 -1
  32. package/dist/builtins/loki-logging/index.mjs +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  34. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  35. package/dist/builtins/platform-probe/index.js +1 -1
  36. package/dist/builtins/platform-probe/index.mjs +1 -1
  37. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  38. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  39. package/dist/builtins/snapshot/index.js +281 -16
  40. package/dist/builtins/snapshot/index.mjs +282 -17
  41. package/dist/builtins/snapshot/snapshot-cache.d.ts +45 -0
  42. package/dist/builtins/snapshot/snapshot-media-handler.d.ts +32 -2
  43. package/dist/builtins/snapshot/snapshot-resize.d.ts +50 -0
  44. package/dist/builtins/snapshot/snapshot.addon.d.ts +13 -0
  45. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  47. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  49. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  50. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  51. package/dist/builtins/system-config/system-config.addon.js +1 -1
  52. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  53. package/dist/builtins/winston-logging/index.js +1 -1
  54. package/dist/builtins/winston-logging/index.mjs +1 -1
  55. package/dist/{dist-DO76YVO2.mjs → dist-BXS0DP92.mjs} +24 -2
  56. package/dist/{dist-CFinlH7b.js → dist-Cj0LAuXi.js} +24 -2
  57. package/dist/index.js +1 -1
  58. package/dist/index.mjs +1 -1
  59. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { Dt as nodePin, ct as BaseAddon, et as snapshotCapability, ft as DeviceFeature, i as BatteryStatusSchema, mt as DeviceType, rt as streamQualityLabel, st as errMsg } from "../../dist-DO76YVO2.mjs";
2
- import { execFile } from "node:child_process";
1
+ import { Dt as nodePin, ct as BaseAddon, et as snapshotCapability, ft as DeviceFeature, i as BatteryStatusSchema, mt as DeviceType, rt as streamQualityLabel, st as errMsg } from "../../dist-BXS0DP92.mjs";
2
+ import { execFile, spawn } from "node:child_process";
3
3
  //#region src/builtins/snapshot/snapshot-coalescing.ts
4
4
  /**
5
5
  * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
@@ -197,6 +197,32 @@ function raceForResult(promise, timeoutMs) {
197
197
  //#endregion
198
198
  //#region src/builtins/snapshot/snapshot-media-handler.ts
199
199
  /**
200
+ * The widths a thumbnail may be served at.
201
+ *
202
+ * A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
203
+ * honouring `?w=` verbatim would let any caller mint unbounded work and
204
+ * unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
205
+ * variant instead of twelve.
206
+ *
207
+ * Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
208
+ * with headroom for a 2× device pixel ratio.
209
+ */
210
+ var SNAPSHOT_WIDTH_LADDER = [
211
+ 160,
212
+ 240,
213
+ 320,
214
+ 480,
215
+ 640,
216
+ 960
217
+ ];
218
+ /** Snap UP to the next rung — never below what was asked, so the client is not
219
+ * handed an image it has to upscale. Above the ladder, the largest rung: the
220
+ * point is to stop serving the 4K original. */
221
+ function snapSnapshotWidth(requested) {
222
+ for (const rung of SNAPSHOT_WIDTH_LADDER) if (requested <= rung) return rung;
223
+ return SNAPSHOT_WIDTH_LADDER[SNAPSHOT_WIDTH_LADDER.length - 1] ?? requested;
224
+ }
225
+ /**
200
226
  * Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
201
227
  * Returns null for a malformed / nested / non-numeric id so the handler answers
202
228
  * 404 without ever reaching `getMedia`.
@@ -215,13 +241,33 @@ function parseSnapshotMediaRequest(url) {
215
241
  const rawStream = params.get("streamId");
216
242
  const streamId = rawStream !== null && rawStream.length > 0 ? rawStream : void 0;
217
243
  const rawForce = params.get("force");
244
+ const force = rawForce === "1" || rawForce === "true";
245
+ const rawWidth = params.get("w");
218
246
  return {
219
247
  deviceId,
220
248
  streamId,
221
- force: rawForce === "1" || rawForce === "true"
249
+ force,
250
+ width: rawWidth !== null && /^\d+$/.test(rawWidth) && Number.parseInt(rawWidth, 10) > 0 ? snapSnapshotWidth(Number.parseInt(rawWidth, 10)) : void 0
222
251
  };
223
252
  }
224
253
  /**
254
+ * The response's entity tag.
255
+ *
256
+ * It has to identify the VARIANT, not just the frame: the same capture can now
257
+ * be served at several widths and from several streams, and a plain
258
+ * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
259
+ * 304 for `?w=960` and render the small image at full size. The base form is
260
+ * unchanged for a plain request, so the identity
261
+ * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
262
+ * asked to resize.
263
+ */
264
+ function snapshotEtag(request, capturedAt) {
265
+ const parts = [`${String(request.deviceId)}-${String(capturedAt)}`];
266
+ if (request.streamId !== void 0) parts.push(`s${request.streamId}`);
267
+ if (request.width !== void 0) parts.push(`w${String(request.width)}`);
268
+ return `"${parts.join("-")}"`;
269
+ }
270
+ /**
225
271
  * Create a data-plane handler that serves per-device snapshots as JPEG images.
226
272
  * `deps.getMedia` is called once per request; null → 404, throw → 500. A
227
273
  * conditional GET with a matching `If-None-Match` produces a 304.
@@ -239,7 +285,7 @@ function createSnapshotMediaHandler(deps) {
239
285
  }
240
286
  let media;
241
287
  try {
242
- media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force);
288
+ media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
243
289
  } catch {
244
290
  const body = "Internal server error";
245
291
  res.writeHead(500, {
@@ -254,7 +300,7 @@ function createSnapshotMediaHandler(deps) {
254
300
  res.writeHead(404).end();
255
301
  return;
256
302
  }
257
- const etag = `"${parsed.deviceId}-${media.capturedAt}"`;
303
+ const etag = snapshotEtag(parsed, media.capturedAt);
258
304
  const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
259
305
  if (req.headers["if-none-match"] === etag) {
260
306
  res.writeHead(304, {
@@ -274,6 +320,182 @@ function createSnapshotMediaHandler(deps) {
274
320
  };
275
321
  }
276
322
  //#endregion
323
+ //#region src/builtins/snapshot/snapshot-cache.ts
324
+ /** A request with no explicit stream is its OWN entry, not a wildcard: the
325
+ * per-device preference decides what it captures, and conflating it with an
326
+ * explicit request is how the original bug read. */
327
+ var AUTO = "auto";
328
+ function keyOf(deviceId, streamId) {
329
+ return `${deviceId}:${streamId ?? AUTO}`;
330
+ }
331
+ var SnapshotCache = class {
332
+ byKey = /* @__PURE__ */ new Map();
333
+ /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
334
+ keysByDevice = /* @__PURE__ */ new Map();
335
+ get(deviceId, streamId) {
336
+ return this.byKey.get(keyOf(deviceId, streamId));
337
+ }
338
+ set(deviceId, streamId, entry) {
339
+ const key = keyOf(deviceId, streamId);
340
+ this.byKey.set(key, entry);
341
+ const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
342
+ keys.add(key);
343
+ this.keysByDevice.set(deviceId, keys);
344
+ }
345
+ /** The newest entry for a device, whichever stream produced it. */
346
+ latest(deviceId) {
347
+ let newest;
348
+ for (const key of this.keysByDevice.get(deviceId) ?? []) {
349
+ const entry = this.byKey.get(key);
350
+ if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
351
+ }
352
+ return newest;
353
+ }
354
+ /**
355
+ * Drop every stream of one device.
356
+ *
357
+ * Device-wide on purpose: a settings change or an operator refresh
358
+ * invalidates the CAMERA, and leaving a sibling entry behind would keep
359
+ * serving the pre-change frame from the other key.
360
+ */
361
+ deleteDevice(deviceId) {
362
+ for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
363
+ this.keysByDevice.delete(deviceId);
364
+ }
365
+ clear() {
366
+ this.byKey.clear();
367
+ this.keysByDevice.clear();
368
+ }
369
+ };
370
+ //#endregion
371
+ //#region src/builtins/snapshot/snapshot-resize.ts
372
+ /**
373
+ * Downscaling a captured frame to a card-sized thumbnail.
374
+ *
375
+ * Applied AFTER capture rather than during it, and that is deliberate: the two
376
+ * capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
377
+ * grab) produce a JPEG by different routes, and only one of them has an
378
+ * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
379
+ * same behaviour with one implementation.
380
+ *
381
+ * The cost is one ffmpeg run per (device, stream, width) per cache window — not
382
+ * one per request. A camera whose card is on screen pays it once every
383
+ * `snapshotMaxAgeS`.
384
+ *
385
+ * `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
386
+ * it), so this adds no new one.
387
+ */
388
+ var RESIZE_TIMEOUT_MS = 1e4;
389
+ /**
390
+ * Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
391
+ * mjpeg requires).
392
+ *
393
+ * Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
394
+ * silently — a caller that quietly served the 4K frame when the resize failed
395
+ * would reproduce exactly the bug this whole change exists to fix, and nobody
396
+ * would see it. The caller decides, and logs.
397
+ */
398
+ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
399
+ return new Promise((resolve, reject) => {
400
+ const child = spawn("ffmpeg", [
401
+ "-loglevel",
402
+ "error",
403
+ "-f",
404
+ "image2pipe",
405
+ "-i",
406
+ "pipe:0",
407
+ "-vf",
408
+ `scale=${String(width)}:-2:flags=bilinear`,
409
+ "-frames:v",
410
+ "1",
411
+ "-q:v",
412
+ "5",
413
+ "-f",
414
+ "image2pipe",
415
+ "-vcodec",
416
+ "mjpeg",
417
+ "pipe:1"
418
+ ], { stdio: [
419
+ "pipe",
420
+ "pipe",
421
+ "pipe"
422
+ ] });
423
+ const out = [];
424
+ let err = "";
425
+ let settled = false;
426
+ const timer = setTimeout(() => {
427
+ if (settled) return;
428
+ settled = true;
429
+ child.kill("SIGKILL");
430
+ reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`));
431
+ }, timeoutMs);
432
+ child.stdout.on("data", (chunk) => out.push(chunk));
433
+ child.stderr.on("data", (chunk) => {
434
+ err += chunk.toString();
435
+ });
436
+ child.on("error", (e) => {
437
+ if (settled) return;
438
+ settled = true;
439
+ clearTimeout(timer);
440
+ reject(e);
441
+ });
442
+ child.on("close", (code) => {
443
+ if (settled) return;
444
+ settled = true;
445
+ clearTimeout(timer);
446
+ const buf = Buffer.concat(out);
447
+ if (code !== 0 || buf.length === 0) {
448
+ reject(/* @__PURE__ */ new Error(`snapshot resize failed (code ${String(code)}): ${err.trim() || "no output"}`));
449
+ return;
450
+ }
451
+ resolve(buf);
452
+ });
453
+ child.stdin.on("error", () => {});
454
+ child.stdin.end(bytes);
455
+ });
456
+ }
457
+ /**
458
+ * Resized frames, keyed by (device, stream, width) AND validated against the
459
+ * source frame's timestamp.
460
+ *
461
+ * The timestamp is the whole correctness argument: a variant outlives nothing.
462
+ * When the underlying frame is recaptured its `capturedAt` moves, every variant
463
+ * derived from the old one stops matching, and the next request re-derives.
464
+ * Without that check a card would keep showing a thumbnail of a frame the
465
+ * full-size view had already replaced.
466
+ */
467
+ var SnapshotVariantCache = class SnapshotVariantCache {
468
+ byKey = /* @__PURE__ */ new Map();
469
+ keysByDevice = /* @__PURE__ */ new Map();
470
+ static key(deviceId, streamId, width) {
471
+ return `${deviceId}:${streamId ?? "auto"}:${width}`;
472
+ }
473
+ /** The variant for this exact frame, or undefined when it is missing or was
474
+ * derived from an older capture. */
475
+ get(deviceId, streamId, width, sourceTs) {
476
+ const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
477
+ return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
478
+ }
479
+ set(deviceId, streamId, width, sourceTs, bytes) {
480
+ const key = SnapshotVariantCache.key(deviceId, streamId, width);
481
+ this.byKey.set(key, {
482
+ bytes,
483
+ sourceTs
484
+ });
485
+ const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
486
+ keys.add(key);
487
+ this.keysByDevice.set(deviceId, keys);
488
+ }
489
+ deleteDevice(deviceId) {
490
+ for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
491
+ this.keysByDevice.delete(deviceId);
492
+ }
493
+ clear() {
494
+ this.byKey.clear();
495
+ this.keysByDevice.clear();
496
+ }
497
+ };
498
+ //#endregion
277
499
  //#region src/builtins/snapshot/snapshot.addon.ts
278
500
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
279
501
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
@@ -309,7 +531,10 @@ function effectiveMaxAgeS(prefs, isBattery) {
309
531
  * publishes stream endpoints.
310
532
  */
311
533
  var SnapshotAddon = class SnapshotAddon extends BaseAddon {
312
- cache = /* @__PURE__ */ new Map();
534
+ cache = new SnapshotCache();
535
+ /** Card-sized derivatives of the frames above, keyed by width and validated
536
+ * against the source frame's timestamp. */
537
+ variants = new SnapshotVariantCache();
313
538
  /**
314
539
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
315
540
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
@@ -367,7 +592,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
367
592
  * base64 path.
368
593
  */
369
594
  async serveMediaDataPlane() {
370
- const handler = createSnapshotMediaHandler({ getMedia: (deviceId, streamId, force) => this.resolveSnapshotMedia(deviceId, streamId, force) });
595
+ const handler = createSnapshotMediaHandler({ getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width) });
371
596
  try {
372
597
  this.mediaDataPlane = await this.ctx.dataPlane?.serve({
373
598
  prefix: "media",
@@ -388,29 +613,67 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
388
613
  * the cache entry (matching the ETag `getSnapshotOverview` advertises) and the
389
614
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
390
615
  */
391
- async resolveSnapshotMedia(deviceId, streamId, force) {
616
+ async resolveSnapshotMedia(deviceId, streamId, force, width) {
392
617
  const image = await this.getSnapshot({
393
618
  deviceId,
394
619
  ...streamId !== void 0 ? { streamId } : {},
395
620
  force
396
621
  });
397
622
  if (!image) return null;
398
- const capturedAt = this.cache.get(deviceId)?.ts ?? Date.now();
623
+ const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
399
624
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
400
625
  const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
401
626
  return {
402
- bytes: Buffer.from(image.base64, "base64"),
627
+ bytes: width === void 0 ? Buffer.from(image.base64, "base64") : await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs),
403
628
  contentType: image.contentType,
404
629
  capturedAt,
405
630
  maxAgeS: effectiveMaxAgeS(prefs, isBattery)
406
631
  };
407
632
  }
633
+ /**
634
+ * The frame at a card-sized width, derived once per (device, stream, width)
635
+ * per capture.
636
+ *
637
+ * A failed resize falls back to the FULL frame — a visible card beats a
638
+ * broken one — but never silently: the card would otherwise keep costing a
639
+ * megabyte with nothing anywhere saying why, which is the shape of the bug
640
+ * this whole change came from.
641
+ */
642
+ async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
643
+ const cached = this.variants.get(deviceId, streamId, width, capturedAt);
644
+ if (cached !== void 0) return cached;
645
+ const full = Buffer.from(image.base64, "base64");
646
+ try {
647
+ const resized = await resizeJpeg(full, width);
648
+ this.variants.set(deviceId, streamId, width, capturedAt, resized);
649
+ if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
650
+ tags: { deviceId },
651
+ meta: {
652
+ width,
653
+ fullBytes: full.length,
654
+ thumbBytes: resized.length
655
+ }
656
+ });
657
+ return resized;
658
+ } catch (err) {
659
+ this.ctx.logger.warn("snapshot: resize failed — serving the full frame", {
660
+ tags: { deviceId },
661
+ meta: {
662
+ width,
663
+ bytes: full.length,
664
+ error: errMsg(err)
665
+ }
666
+ });
667
+ return full;
668
+ }
669
+ }
408
670
  async onShutdown() {
409
671
  if (this.mediaDataPlane) {
410
672
  await this.mediaDataPlane.dispose().catch(() => void 0);
411
673
  this.mediaDataPlane = null;
412
674
  }
413
675
  this.cache.clear();
676
+ this.variants.clear();
414
677
  this.captureFlight.clear();
415
678
  this.ownerCache = null;
416
679
  }
@@ -454,7 +717,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
454
717
  ...deviceName ? { deviceName } : {}
455
718
  });
456
719
  const now = Date.now();
457
- const hit = this.cache.get(deviceId);
458
720
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
459
721
  const rawPref = prefs.snapshotStreamId;
460
722
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -462,6 +724,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
462
724
  tags: { deviceId },
463
725
  meta: { stream: effectiveStreamId ?? "auto" }
464
726
  });
727
+ const hit = this.cache.get(deviceId, effectiveStreamId);
465
728
  const effectiveMaxAgeMs = effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3;
466
729
  const decision = decideSnapshotServe({
467
730
  now,
@@ -570,7 +833,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
570
833
  if (native) {
571
834
  const result = await this.nativePool.run(() => native.getSnapshot(input));
572
835
  if (result) {
573
- this.cache.set(deviceId, {
836
+ this.cache.set(deviceId, effectiveStreamId, {
574
837
  data: result,
575
838
  ts: now,
576
839
  streamId: effectiveStreamId ?? null
@@ -604,7 +867,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
604
867
  if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
605
868
  const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
606
869
  if (fallback) {
607
- this.cache.set(deviceId, {
870
+ this.cache.set(deviceId, effectiveStreamId, {
608
871
  data: fallback,
609
872
  ts: now,
610
873
  streamId: effectiveStreamId ?? null
@@ -750,7 +1013,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
750
1013
  };
751
1014
  }
752
1015
  async invalidateCache(input) {
753
- this.cache.delete(input.deviceId);
1016
+ this.cache.deleteDevice(input.deviceId);
1017
+ this.variants.deleteDevice(input.deviceId);
754
1018
  this.captureFlight.invalidatePrefix(`${input.deviceId}:`);
755
1019
  }
756
1020
  /**
@@ -824,7 +1088,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
824
1088
  getSnapshotOverview(input) {
825
1089
  const now = Date.now();
826
1090
  const rows = input.deviceIds.map((deviceId) => {
827
- const hit = this.cache.get(deviceId);
1091
+ const hit = this.cache.latest(deviceId);
828
1092
  if (!hit) return {
829
1093
  deviceId,
830
1094
  lastCapturedAt: null,
@@ -848,7 +1112,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
848
1112
  * (cache miss) since the addon started.
849
1113
  */
850
1114
  async getStatus(deviceId) {
851
- const hit = this.cache.get(deviceId);
1115
+ const hit = this.cache.latest(deviceId);
852
1116
  if (!hit) return null;
853
1117
  return {
854
1118
  lastCapturedAt: hit.ts,
@@ -996,7 +1260,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
996
1260
  else delete next["snapshotMaxAgeS"];
997
1261
  }
998
1262
  await this.ctx.settings.writeDeviceStore(deviceId, next);
999
- this.cache.delete(deviceId);
1263
+ this.cache.deleteDevice(deviceId);
1264
+ this.variants.deleteDevice(deviceId);
1000
1265
  this.captureFlight.invalidatePrefix(`${deviceId}:`);
1001
1266
  return { success: true };
1002
1267
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The per-device snapshot cache, keyed by (device, STREAM).
3
+ *
4
+ * It used to be keyed by device alone, and `getSnapshot` served the hit before
5
+ * it had resolved which stream the caller asked for — so `?streamId=low`
6
+ * silently returned whatever was already cached. In production that meant the
7
+ * 4K main frame: probing a live camera with `low`, `sub` and `high` returned
8
+ * three byte-identical 1.2 MB responses. The parameter existed, was documented,
9
+ * and did nothing.
10
+ *
11
+ * Two views still need to ask a device-wide question — "when did this camera
12
+ * last produce a frame" (`getSnapshotOverview`, `getStatus`) — so
13
+ * {@link SnapshotCache.latest} answers it across streams rather than making
14
+ * every caller guess a key.
15
+ */
16
+ /** The cached frame. Structurally the addon's `SnapshotImage` — declared here
17
+ * rather than imported so the cache does not depend on the 1,100-line addon. */
18
+ export interface SnapshotCacheImage {
19
+ readonly base64: string;
20
+ readonly contentType: string;
21
+ }
22
+ export interface SnapshotCacheEntry {
23
+ readonly data: SnapshotCacheImage;
24
+ readonly ts: number;
25
+ /** Stream id resolved at capture time — 'high' | 'mid' | 'low' | custom | null. */
26
+ readonly streamId: string | null;
27
+ }
28
+ export declare class SnapshotCache {
29
+ private readonly byKey;
30
+ /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
31
+ private readonly keysByDevice;
32
+ get(deviceId: number, streamId: string | undefined): SnapshotCacheEntry | undefined;
33
+ set(deviceId: number, streamId: string | undefined, entry: SnapshotCacheEntry): void;
34
+ /** The newest entry for a device, whichever stream produced it. */
35
+ latest(deviceId: number): SnapshotCacheEntry | undefined;
36
+ /**
37
+ * Drop every stream of one device.
38
+ *
39
+ * Device-wide on purpose: a settings change or an operator refresh
40
+ * invalidates the CAMERA, and leaving a sibling entry behind would keep
41
+ * serving the pre-change frame from the other key.
42
+ */
43
+ deleteDevice(deviceId: number): void;
44
+ clear(): void;
45
+ }
@@ -16,20 +16,50 @@ export interface SnapshotMediaHandlerDeps {
16
16
  * the wrapper's coalesced capture ladder; returns null when no frame can be
17
17
  * produced (→ 404). Throwing produces an opaque 500 (caller logs).
18
18
  */
19
- readonly getMedia: (deviceId: number, streamId: string | undefined, force: boolean) => Promise<SnapshotMedia | null>;
19
+ readonly getMedia: (deviceId: number, streamId: string | undefined, force: boolean, width: number | undefined) => Promise<SnapshotMedia | null>;
20
20
  }
21
- /** Parsed `/<deviceId>[.jpg][?streamId=…&force=1]` request. */
21
+ /** Parsed `/<deviceId>[.jpg][?streamId=…&force=1&w=320]` request. */
22
22
  export interface SnapshotMediaRequest {
23
23
  readonly deviceId: number;
24
24
  readonly streamId: string | undefined;
25
25
  readonly force: boolean;
26
+ /** Snapped target width in px, or undefined for the frame as captured. */
27
+ readonly width: number | undefined;
26
28
  }
29
+ /**
30
+ * The widths a thumbnail may be served at.
31
+ *
32
+ * A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
33
+ * honouring `?w=` verbatim would let any caller mint unbounded work and
34
+ * unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
35
+ * variant instead of twelve.
36
+ *
37
+ * Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
38
+ * with headroom for a 2× device pixel ratio.
39
+ */
40
+ export declare const SNAPSHOT_WIDTH_LADDER: readonly number[];
41
+ /** Snap UP to the next rung — never below what was asked, so the client is not
42
+ * handed an image it has to upscale. Above the ladder, the largest rung: the
43
+ * point is to stop serving the 4K original. */
44
+ export declare function snapSnapshotWidth(requested: number): number;
27
45
  /**
28
46
  * Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
29
47
  * Returns null for a malformed / nested / non-numeric id so the handler answers
30
48
  * 404 without ever reaching `getMedia`.
31
49
  */
32
50
  export declare function parseSnapshotMediaRequest(url: string): SnapshotMediaRequest | null;
51
+ /**
52
+ * The response's entity tag.
53
+ *
54
+ * It has to identify the VARIANT, not just the frame: the same capture can now
55
+ * be served at several widths and from several streams, and a plain
56
+ * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
57
+ * 304 for `?w=960` and render the small image at full size. The base form is
58
+ * unchanged for a plain request, so the identity
59
+ * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
60
+ * asked to resize.
61
+ */
62
+ export declare function snapshotEtag(request: SnapshotMediaRequest, capturedAt: number): string;
33
63
  /**
34
64
  * Create a data-plane handler that serves per-device snapshots as JPEG images.
35
65
  * `deps.getMedia` is called once per request; null → 404, throw → 500. A
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Downscaling a captured frame to a card-sized thumbnail.
3
+ *
4
+ * Applied AFTER capture rather than during it, and that is deliberate: the two
5
+ * capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
6
+ * grab) produce a JPEG by different routes, and only one of them has an
7
+ * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
8
+ * same behaviour with one implementation.
9
+ *
10
+ * The cost is one ffmpeg run per (device, stream, width) per cache window — not
11
+ * one per request. A camera whose card is on screen pays it once every
12
+ * `snapshotMaxAgeS`.
13
+ *
14
+ * `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
15
+ * it), so this adds no new one.
16
+ */
17
+ /** Runs the resize. Injected so the caller is testable without a real ffmpeg. */
18
+ export type JpegResizer = (bytes: Buffer, width: number) => Promise<Buffer>;
19
+ export declare const RESIZE_TIMEOUT_MS = 10000;
20
+ /**
21
+ * Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
22
+ * mjpeg requires).
23
+ *
24
+ * Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
25
+ * silently — a caller that quietly served the 4K frame when the resize failed
26
+ * would reproduce exactly the bug this whole change exists to fix, and nobody
27
+ * would see it. The caller decides, and logs.
28
+ */
29
+ export declare function resizeJpeg(bytes: Buffer, width: number, timeoutMs?: number): Promise<Buffer>;
30
+ /**
31
+ * Resized frames, keyed by (device, stream, width) AND validated against the
32
+ * source frame's timestamp.
33
+ *
34
+ * The timestamp is the whole correctness argument: a variant outlives nothing.
35
+ * When the underlying frame is recaptured its `capturedAt` moves, every variant
36
+ * derived from the old one stops matching, and the next request re-derives.
37
+ * Without that check a card would keep showing a thumbnail of a frame the
38
+ * full-size view had already replaced.
39
+ */
40
+ export declare class SnapshotVariantCache {
41
+ private readonly byKey;
42
+ private readonly keysByDevice;
43
+ private static key;
44
+ /** The variant for this exact frame, or undefined when it is missing or was
45
+ * derived from an older capture. */
46
+ get(deviceId: number, streamId: string | undefined, width: number, sourceTs: number): Buffer | undefined;
47
+ set(deviceId: number, streamId: string | undefined, width: number, sourceTs: number, bytes: Buffer): void;
48
+ deleteDevice(deviceId: number): void;
49
+ clear(): void;
50
+ }
@@ -30,6 +30,9 @@ interface SnapshotAddonConfig {
30
30
  */
31
31
  export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
32
32
  private readonly cache;
33
+ /** Card-sized derivatives of the frames above, keyed by width and validated
34
+ * against the source frame's timestamp. */
35
+ private readonly variants;
33
36
  /**
34
37
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
35
38
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
@@ -78,6 +81,16 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
78
81
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
79
82
  */
80
83
  private resolveSnapshotMedia;
84
+ /**
85
+ * The frame at a card-sized width, derived once per (device, stream, width)
86
+ * per capture.
87
+ *
88
+ * A failed resize falls back to the FULL frame — a visible card beats a
89
+ * broken one — but never silently: the card would otherwise keep costing a
90
+ * megabyte with nothing anywhere saying why, which is the shape of the bug
91
+ * this whole change came from.
92
+ */
93
+ private thumbnailBytes;
81
94
  protected onShutdown(): Promise<void>;
82
95
  /**
83
96
  * Drop the cached ingest owner whenever addon-level settings change —
@@ -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-CFinlH7b.js");
6
+ const require_dist = require("../../dist-Cj0LAuXi.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 { N as filesystemBrowseCapability, ct as BaseAddon, nt as storageProviderCapability } from "../../dist-DO76YVO2.mjs";
1
+ import { N as filesystemBrowseCapability, ct as BaseAddon, nt as storageProviderCapability } from "../../dist-BXS0DP92.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-CFinlH7b.js");
6
+ const require_dist = require("../../dist-Cj0LAuXi.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);
@@ -1,4 +1,4 @@
1
- import { C as dataStoreProviderCapability, ct as BaseAddon, kt as parseJsonUnknown, l as RUNTIME_DEFAULTS, st as errMsg, vt as asJsonObject } from "../../dist-DO76YVO2.mjs";
1
+ import { C as dataStoreProviderCapability, ct as BaseAddon, kt as parseJsonUnknown, l as RUNTIME_DEFAULTS, st as errMsg, vt as asJsonObject } from "../../dist-BXS0DP92.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import Database from "better-sqlite3";
4
4
  //#region src/builtins/sqlite-storage/filter-compiler.ts
@@ -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-CFinlH7b.js");
6
+ const require_dist = require("../../dist-Cj0LAuXi.js");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_chunk.__toESM(node_path);
9
9
  let node_fs_promises = require("node:fs/promises");
@@ -1,4 +1,4 @@
1
- import { $ as settingsStoreCapability, Ot as parseJsonObject, ct as BaseAddon, f as StorageLocationTypeSchema, tt as storageCapability } from "../../dist-DO76YVO2.mjs";
1
+ import { $ as settingsStoreCapability, Ot as parseJsonObject, ct as BaseAddon, f as StorageLocationTypeSchema, tt as storageCapability } from "../../dist-BXS0DP92.mjs";
2
2
  import * as path$1 from "node:path";
3
3
  import * as fs from "node:fs/promises";
4
4
  import { buildStorageLocationRegistry } from "@camstack/system";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-CFinlH7b.js");
6
+ const require_dist = require("../../dist-Cj0LAuXi.js");
7
7
  //#region src/builtins/system-config/system-config.addon.ts
8
8
  /**
9
9
  * Built-in `system-config` addon — Phase 4 of the settings redesign.