@camstack/system 1.2.79 → 1.2.81

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 (55) 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/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-bindings-store.d.ts +21 -0
  14. package/dist/builtins/device-manager/device-manager.addon.js +23 -2
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +23 -2
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  18. package/dist/builtins/hub-forwarder/index.js +1 -1
  19. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  24. package/dist/builtins/local-network/local-network.addon.js +1 -1
  25. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  26. package/dist/builtins/loki-logging/index.js +1 -1
  27. package/dist/builtins/loki-logging/index.mjs +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  30. package/dist/builtins/platform-probe/index.js +1 -1
  31. package/dist/builtins/platform-probe/index.mjs +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  34. package/dist/builtins/snapshot/index.js +557 -533
  35. package/dist/builtins/snapshot/index.mjs +556 -533
  36. package/dist/builtins/snapshot/snapshot-coalescing.d.ts +6 -1
  37. package/dist/builtins/snapshot/snapshot-courtesy.d.ts +36 -0
  38. package/dist/builtins/snapshot/snapshot-resize.d.ts +43 -11
  39. package/dist/builtins/snapshot/snapshot.addon.d.ts +52 -45
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  43. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-cINpngi1.js → dist-CA2mim74.js} +48 -16
  51. package/dist/{dist-CIIFhXqm.mjs → dist-DZOZtn-0.mjs} +48 -16
  52. package/dist/index.js +1 -1
  53. package/dist/index.mjs +1 -1
  54. package/package.json +1 -1
  55. package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +0 -86
@@ -1,8 +1,57 @@
1
- import { Ft as nodePin, St as DeviceType, bt as DeviceFeature, dt as streamQualityLabel, gt as BaseAddon, ht as errMsg, i as BatteryStatusSchema, st as snapshotCapability, w as bareAddonId } from "../../dist-CIIFhXqm.mjs";
1
+ import { Ft as nodePin, Ht as EventCategory, St as DeviceType, bt as DeviceFeature, dt as streamQualityLabel, gt as BaseAddon, ht as errMsg, i as BatteryStatusSchema, st as snapshotCapability, w as bareAddonId } from "../../dist-DZOZtn-0.mjs";
2
2
  import { z } from "zod";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { signExpiringUrl, verifyExpiringUrl } from "@camstack/types/node";
5
- import { execFile, spawn } from "node:child_process";
5
+ import { execFile } from "node:child_process";
6
+ import sharp from "sharp";
7
+ //#region src/builtins/snapshot/snapshot-cache.ts
8
+ /** A request with no explicit stream is its OWN entry, not a wildcard: the
9
+ * per-device preference decides what it captures, and conflating it with an
10
+ * explicit request is how the original bug read. */
11
+ var AUTO = "auto";
12
+ function keyOf(deviceId, streamId) {
13
+ return `${deviceId}:${streamId ?? AUTO}`;
14
+ }
15
+ var SnapshotCache = class {
16
+ byKey = /* @__PURE__ */ new Map();
17
+ /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
18
+ keysByDevice = /* @__PURE__ */ new Map();
19
+ get(deviceId, streamId) {
20
+ return this.byKey.get(keyOf(deviceId, streamId));
21
+ }
22
+ set(deviceId, streamId, entry) {
23
+ const key = keyOf(deviceId, streamId);
24
+ this.byKey.set(key, entry);
25
+ const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
26
+ keys.add(key);
27
+ this.keysByDevice.set(deviceId, keys);
28
+ }
29
+ /** The newest entry for a device, whichever stream produced it. */
30
+ latest(deviceId) {
31
+ let newest;
32
+ for (const key of this.keysByDevice.get(deviceId) ?? []) {
33
+ const entry = this.byKey.get(key);
34
+ if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
35
+ }
36
+ return newest;
37
+ }
38
+ /**
39
+ * Drop every stream of one device.
40
+ *
41
+ * Device-wide on purpose: a settings change or an operator refresh
42
+ * invalidates the CAMERA, and leaving a sibling entry behind would keep
43
+ * serving the pre-change frame from the other key.
44
+ */
45
+ deleteDevice(deviceId) {
46
+ for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
47
+ this.keysByDevice.delete(deviceId);
48
+ }
49
+ clear() {
50
+ this.byKey.clear();
51
+ this.keysByDevice.clear();
52
+ }
53
+ };
54
+ //#endregion
6
55
  //#region src/builtins/snapshot/snapshot-coalescing.ts
7
56
  /**
8
57
  * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
@@ -198,6 +247,241 @@ function raceForResult(promise, timeoutMs) {
198
247
  });
199
248
  }
200
249
  //#endregion
250
+ //#region src/builtins/snapshot/snapshot-courtesy.ts
251
+ /**
252
+ * A courtesy frame for a camera that CANNOT produce one.
253
+ *
254
+ * ── Why this exists ───────────────────────────────────────────────────────
255
+ * A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
256
+ * snapshot service simply had nothing to say about it: the media route answered
257
+ * **404** and the client painted "Unavailable". Measured on the live grid that
258
+ * day, 10 of 26 tiles were 404s — one genuinely disabled camera plus nine dead
259
+ * legacy rows. A 404 is indistinguishable from a broken camera, so an operator
260
+ * who deliberately switched a camera off saw the same thing as a fault ([D62]:
261
+ * "an off switch is REPORTED off; disabled must never look like broken").
262
+ *
263
+ * So the service answers with a frame that SAYS what is going on, carrying the
264
+ * camera's own name. The client gets a valid image, the tile paints, and the
265
+ * state is legible instead of inferred from an error.
266
+ *
267
+ * ── Why sharp and not ffmpeg ──────────────────────────────────────────────
268
+ * The first draft of this file shelled out to `ffmpeg` with a `drawtext`
269
+ * filter. That was written before the resize path was measured, and it was the
270
+ * wrong call for the same reason: this runs in the snapshot wrapper, a system
271
+ * builtin loaded in the hub's ROOT process, so every render was a fork + exec
272
+ * on the loop that serves the API — 52 ms against sharp's 4.8 ms in that
273
+ * container, to draw two lines of text.
274
+ *
275
+ * `terminal-frame-renderer.ts` already renders text this way (SVG → sharp), so
276
+ * this is the house pattern rather than a new one. It also drops the font-PATH
277
+ * probing the ffmpeg version needed: an SVG names a font FAMILY and fontconfig
278
+ * resolves it — verified in the hub image, where `fc-match "DejaVu Sans Mono"`
279
+ * answers with the real file.
280
+ *
281
+ * Rendering is pure input → bytes with no I/O of its own, and the geometry and
282
+ * escaping are separated out so they stay testable without rasterizing.
283
+ */
284
+ /**
285
+ * Font stack for the rendered text. Family names, not paths — librsvg resolves
286
+ * them through fontconfig, and the trailing generics keep a host without DejaVu
287
+ * rendering something legible instead of nothing.
288
+ */
289
+ var COURTESY_FONT_STACK = "DejaVu Sans,DejaVu Sans Mono,Helvetica,Arial,sans-serif";
290
+ /** The word the frame carries. Deliberately the operator's vocabulary. */
291
+ function courtesyLabel(reason) {
292
+ switch (reason) {
293
+ case "disabled": return "Disabled";
294
+ case "offline": return "Offline";
295
+ case "sleeping": return "Sleeping";
296
+ }
297
+ }
298
+ /**
299
+ * Background per reason. A disabled camera is a DELIBERATE state and must not
300
+ * read as an alarm, so it is neutral grey; offline is a fault and is warmer.
301
+ */
302
+ function courtesyBackground(reason) {
303
+ switch (reason) {
304
+ case "disabled": return "#2b2b31";
305
+ case "offline": return "#3a2b2b";
306
+ case "sleeping": return "#232b3a";
307
+ }
308
+ }
309
+ /** XML escaping for text placed inside an SVG `<text>` node. */
310
+ function escapeCourtesyText(value) {
311
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
312
+ }
313
+ /**
314
+ * The SVG the frame is rasterized from: the state large and centred, the
315
+ * camera's name under it, smaller and dimmer.
316
+ *
317
+ * Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
318
+ * frame read the same, with a floor so a thumbnail stays legible.
319
+ */
320
+ function buildCourtesySvg(spec) {
321
+ const stateSize = Math.max(12, Math.round(spec.width / 12));
322
+ const nameSize = Math.max(9, Math.round(spec.width / 26));
323
+ const state = escapeCourtesyText(courtesyLabel(spec.reason));
324
+ const name = escapeCourtesyText(spec.deviceName);
325
+ const midY = spec.height / 2;
326
+ return [
327
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
328
+ `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
329
+ `<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
330
+ `<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
331
+ `<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
332
+ `</g></svg>`
333
+ ].join("");
334
+ }
335
+ /** Cache key — a courtesy frame is a pure function of these four. */
336
+ function courtesyCacheKey(spec) {
337
+ return `${spec.reason}:${String(spec.width)}x${String(spec.height)}:${spec.deviceName}`;
338
+ }
339
+ /**
340
+ * Render the frame.
341
+ *
342
+ * Rejects on failure — the caller decides what to do, exactly as `resizeJpeg`
343
+ * does, so a broken courtesy path is never mistaken for a broken camera.
344
+ */
345
+ function renderCourtesyJpeg(spec) {
346
+ return sharp(Buffer.from(buildCourtesySvg(spec))).jpeg({ quality: 82 }).toBuffer().then((bytes) => {
347
+ if (bytes.length === 0) throw new Error("courtesy frame produced no bytes");
348
+ return bytes;
349
+ });
350
+ }
351
+ //#endregion
352
+ //#region src/builtins/snapshot/snapshot-link-url.ts
353
+ /**
354
+ * Signed, expiring links to a CLIENT-SIZED snapshot frame.
355
+ *
356
+ * ## Why a link plane exists at all
357
+ *
358
+ * The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
359
+ * going away. What it cannot do is guarantee that a client asking for a tile
360
+ * actually REACHES the server — and that turned out to be the whole bug.
361
+ *
362
+ * Under D93 the image URL is versioned by the frame identity, and an image
363
+ * request is what signalled demand for a camera. Both halves are satisfied by
364
+ * the client's own image cache: `expo-image` is URL-keyed and never
365
+ * revalidates, so a URL the app painted in a previous session is served from
366
+ * disk with **zero network**. Measured on the live hub, reopening the app after
367
+ * two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
368
+ * not one HTTP request, therefore no demand, therefore no capture. The
369
+ * operator's report ("gli snapshot sono vecchi, devo aggiornare più volte") is
370
+ * that measurement.
371
+ *
372
+ * That gap used to be covered on BOTH sides — by this link plane and by a
373
+ * server-side keep-warm timer. The timer was removed on 2026-08-11 (operator
374
+ * directive: snapshots are on-demand, always), which makes this plane the only
375
+ * thing standing between a client cache and a frozen tile. It carries the whole
376
+ * job now.
377
+ *
378
+ * A minted link breaks the loop from both ends. It is produced by an RPC —
379
+ * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
380
+ * signal always lands; and it carries the capture identity the RPC just WAITED
381
+ * for, rather than one a cache-only poll happened to be holding.
382
+ *
383
+ * ## What is signed, and what is only a cache key
384
+ *
385
+ * The signature covers `"<deviceId>:<width>"` and the expiry. The width is
386
+ * inside it deliberately: a leaked 240 px tile link must not be replayable as a
387
+ * request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
388
+ * exists only to key the client's image cache, so an unchanged frame is a
389
+ * byte-identical URL and costs no bytes, and a new frame is a new URL and costs
390
+ * exactly one fetch.
391
+ *
392
+ * `exp` is bucketed rather than exact. A URL that were unique per mint would
393
+ * defeat the client cache completely — correct for freshness, and it would make
394
+ * a phone re-download every tile on every 5 s poll whether or not anything
395
+ * changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
396
+ * most once per bucket.
397
+ *
398
+ * Pure and side-effect-free; the addon owns the secret and the clock. Unit
399
+ * tested in `__tests__/snapshot-link-url.spec.ts`.
400
+ */
401
+ /**
402
+ * How long a minted link stays valid.
403
+ *
404
+ * A snapshot is a live view of the operator's home, so this is short on purpose
405
+ * — the exposure of a leaked link is bounded by it. Two minutes is long enough
406
+ * that a page renders, re-renders and survives a brief backgrounding on the URL
407
+ * it was given, and short enough that a link pasted somewhere is dead before it
408
+ * is useful. The client re-mints on every overview poll (5 s), so it never
409
+ * depends on the tail of this window.
410
+ */
411
+ var SNAPSHOT_LINK_TTL_MS = 12e4;
412
+ /**
413
+ * Quantum the expiry is rounded UP to, so a link is stable between mints.
414
+ *
415
+ * Without it every mint produces a different `exp`, hence a different URL, hence
416
+ * a full re-download of an unchanged frame on every poll. With it the URL is a
417
+ * pure function of (device, width, frame, bucket) — so a tile fetches when its
418
+ * frame moves, and at most once more per bucket.
419
+ */
420
+ var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
421
+ /** The token the signature is computed over. Width is part of the identity so a
422
+ * tile link cannot be escalated into a full-frame request. */
423
+ function snapshotLinkId(deviceId, width) {
424
+ return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
425
+ }
426
+ /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
427
+ function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
428
+ return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
429
+ }
430
+ /**
431
+ * The link, as a ROOT-RELATIVE path.
432
+ *
433
+ * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
434
+ * because the fetcher is a phone or a notifier backend that has no idea where
435
+ * the hub is — and picking that base is the `hubUrl: localhost` trap the Alexa
436
+ * work paid for. Here the fetcher is a client that is already connected to the
437
+ * hub and holds its own `serverUrl`, so the correct base is the one it used to
438
+ * make the call. Returning a path makes it impossible to hand a client a link
439
+ * pointing somewhere it cannot reach.
440
+ */
441
+ function buildSnapshotLinkUrl(input) {
442
+ const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
443
+ const id = snapshotLinkId(input.deviceId, input.width);
444
+ const sig = signExpiringUrl(input.secret, id, input.expMs);
445
+ const params = new URLSearchParams();
446
+ if (input.width !== void 0) params.set("w", String(input.width));
447
+ if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
448
+ params.set("exp", String(input.expMs));
449
+ params.set("sig", sig);
450
+ return `${base}/${String(input.deviceId)}.jpg?${params.toString()}`;
451
+ }
452
+ /**
453
+ * Parse and VERIFY a link request in one step, so a caller cannot accidentally
454
+ * use the device id before checking the signature. Null = refuse (404/403);
455
+ * there is deliberately no way to distinguish "bad signature" from "expired"
456
+ * from "malformed" at this boundary, so a public route cannot be probed.
457
+ */
458
+ function parseVerifiedSnapshotLink(input) {
459
+ const qIdx = input.url.indexOf("?");
460
+ const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
461
+ const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
462
+ const segment = rawPath.replace(/^\/+/, "");
463
+ if (segment.length === 0 || segment.includes("/")) return null;
464
+ const idPart = segment.replace(/\.jpe?g$/i, "");
465
+ if (!/^\d+$/.test(idPart)) return null;
466
+ const deviceId = Number.parseInt(idPart, 10);
467
+ if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
468
+ const params = new URLSearchParams(query);
469
+ const rawWidth = params.get("w");
470
+ if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
471
+ const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
472
+ if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
473
+ return verifyExpiringUrl({
474
+ secret: input.secret,
475
+ id: snapshotLinkId(deviceId, width),
476
+ exp: params.get("exp") ?? void 0,
477
+ sig: params.get("sig") ?? void 0,
478
+ nowMs: input.nowMs
479
+ }) ? {
480
+ deviceId,
481
+ width
482
+ } : null;
483
+ }
484
+ //#endregion
201
485
  //#region src/builtins/snapshot/snapshot-media-handler.ts
202
486
  /**
203
487
  * The widths a thumbnail may be served at.
@@ -348,427 +632,130 @@ function createSnapshotMediaHandler(deps) {
348
632
  if (req.method === "HEAD") res.end();
349
633
  else res.end(Buffer.from(media.bytes));
350
634
  };
351
- }
352
- //#endregion
353
- //#region src/builtins/snapshot/snapshot-cache.ts
354
- /** A request with no explicit stream is its OWN entry, not a wildcard: the
355
- * per-device preference decides what it captures, and conflating it with an
356
- * explicit request is how the original bug read. */
357
- var AUTO = "auto";
358
- function keyOf$1(deviceId, streamId) {
359
- return `${deviceId}:${streamId ?? AUTO}`;
360
- }
361
- var SnapshotCache = class {
362
- byKey = /* @__PURE__ */ new Map();
363
- /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
364
- keysByDevice = /* @__PURE__ */ new Map();
365
- get(deviceId, streamId) {
366
- return this.byKey.get(keyOf$1(deviceId, streamId));
367
- }
368
- set(deviceId, streamId, entry) {
369
- const key = keyOf$1(deviceId, streamId);
370
- this.byKey.set(key, entry);
371
- const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
372
- keys.add(key);
373
- this.keysByDevice.set(deviceId, keys);
374
- }
375
- /** The newest entry for a device, whichever stream produced it. */
376
- latest(deviceId) {
377
- let newest;
378
- for (const key of this.keysByDevice.get(deviceId) ?? []) {
379
- const entry = this.byKey.get(key);
380
- if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
381
- }
382
- return newest;
383
- }
384
- /**
385
- * Drop every stream of one device.
386
- *
387
- * Device-wide on purpose: a settings change or an operator refresh
388
- * invalidates the CAMERA, and leaving a sibling entry behind would keep
389
- * serving the pre-change frame from the other key.
390
- */
391
- deleteDevice(deviceId) {
392
- for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
393
- this.keysByDevice.delete(deviceId);
394
- }
395
- clear() {
396
- this.byKey.clear();
397
- this.keysByDevice.clear();
398
- }
399
- };
400
- //#endregion
401
- //#region src/builtins/snapshot/snapshot-resize.ts
402
- /**
403
- * Downscaling a captured frame to a card-sized thumbnail.
404
- *
405
- * Applied AFTER capture rather than during it, and that is deliberate: the two
406
- * capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
407
- * grab) produce a JPEG by different routes, and only one of them has an
408
- * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
409
- * same behaviour with one implementation.
410
- *
411
- * The cost is one ffmpeg run per (device, stream, width) per cache window — not
412
- * one per request. A camera whose card is on screen pays it once every
413
- * `snapshotMaxAgeS`.
414
- *
415
- * `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
416
- * it), so this adds no new one.
417
- */
418
- var RESIZE_TIMEOUT_MS = 1e4;
419
- /**
420
- * Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
421
- * mjpeg requires).
422
- *
423
- * Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
424
- * silently — a caller that quietly served the 4K frame when the resize failed
425
- * would reproduce exactly the bug this whole change exists to fix, and nobody
426
- * would see it. The caller decides, and logs.
427
- */
428
- function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
429
- return new Promise((resolve, reject) => {
430
- const child = spawn("ffmpeg", [
431
- "-loglevel",
432
- "error",
433
- "-f",
434
- "image2pipe",
435
- "-i",
436
- "pipe:0",
437
- "-vf",
438
- `scale=${String(width)}:-2:flags=bilinear`,
439
- "-frames:v",
440
- "1",
441
- "-q:v",
442
- "5",
443
- "-f",
444
- "image2pipe",
445
- "-vcodec",
446
- "mjpeg",
447
- "pipe:1"
448
- ], { stdio: [
449
- "pipe",
450
- "pipe",
451
- "pipe"
452
- ] });
453
- const out = [];
454
- let err = "";
455
- let settled = false;
456
- const timer = setTimeout(() => {
457
- if (settled) return;
458
- settled = true;
459
- child.kill("SIGKILL");
460
- reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`));
461
- }, timeoutMs);
462
- child.stdout.on("data", (chunk) => out.push(chunk));
463
- child.stderr.on("data", (chunk) => {
464
- err += chunk.toString();
465
- });
466
- child.on("error", (e) => {
467
- if (settled) return;
468
- settled = true;
469
- clearTimeout(timer);
470
- reject(e);
471
- });
472
- child.on("close", (code) => {
473
- if (settled) return;
474
- settled = true;
475
- clearTimeout(timer);
476
- const buf = Buffer.concat(out);
477
- if (code !== 0 || buf.length === 0) {
478
- reject(/* @__PURE__ */ new Error(`snapshot resize failed (code ${String(code)}): ${err.trim() || "no output"}`));
479
- return;
480
- }
481
- resolve(buf);
482
- });
483
- child.stdin.on("error", () => {});
484
- child.stdin.end(bytes);
485
- });
486
- }
487
- /**
488
- * Resized frames, keyed by (device, stream, width) AND validated against the
489
- * source frame's timestamp.
490
- *
491
- * The timestamp is the whole correctness argument: a variant outlives nothing.
492
- * When the underlying frame is recaptured its `capturedAt` moves, every variant
493
- * derived from the old one stops matching, and the next request re-derives.
494
- * Without that check a card would keep showing a thumbnail of a frame the
495
- * full-size view had already replaced.
496
- */
497
- var SnapshotVariantCache = class SnapshotVariantCache {
498
- byKey = /* @__PURE__ */ new Map();
499
- keysByDevice = /* @__PURE__ */ new Map();
500
- static key(deviceId, streamId, width) {
501
- return `${deviceId}:${streamId ?? "auto"}:${width}`;
502
- }
503
- /** The variant for this exact frame, or undefined when it is missing or was
504
- * derived from an older capture. */
505
- get(deviceId, streamId, width, sourceTs) {
506
- const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
507
- return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
508
- }
509
- set(deviceId, streamId, width, sourceTs, bytes) {
510
- const key = SnapshotVariantCache.key(deviceId, streamId, width);
511
- this.byKey.set(key, {
512
- bytes,
513
- sourceTs
514
- });
515
- const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
516
- keys.add(key);
517
- this.keysByDevice.set(deviceId, keys);
518
- }
519
- deleteDevice(deviceId) {
520
- for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
521
- this.keysByDevice.delete(deviceId);
522
- }
523
- clear() {
524
- this.byKey.clear();
525
- this.keysByDevice.clear();
526
- }
527
- };
528
- //#endregion
529
- //#region src/builtins/snapshot/snapshot-keep-warm.ts
530
- /**
531
- * Subscription bookkeeping for the snapshot keep-warm loop.
532
- *
533
- * The snapshot cache has exactly one writer — a successful capture — and every
534
- * capture is triggered by somebody asking. Nothing refreshes a frame on its own,
535
- * so with no viewer open every thumbnail in the system is as old as the last
536
- * time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
537
- * captured in one 55 ms burst, all 10 minutes old and not moving.
538
- *
539
- * The fix is a warm loop — but a blind one would dial 16 cameras forever for
540
- * nobody. This module holds the thing that makes it bounded: a warm tick only
541
- * touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
542
- * requests themselves. One data-plane request for a device makes it an observer
543
- * for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
544
- * on its own after that window and costs exactly nothing.
545
- *
546
- * Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
547
- * the timer, this owns the decision. Unit-tested in
548
- * `__tests__/snapshot-keep-warm.spec.ts`.
549
- */
550
- /**
551
- * How often the warm loop runs. Chosen so a tile is perceptibly fresh while
552
- * watched; it is also the single biggest cost lever in the design (16 cameras ×
553
- * 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
554
- * the next tick is scheduled only after the previous one settles, so a slow
555
- * fleet cannot stack ticks on top of each other.
556
- */
557
- var WARM_INTERVAL_MS = 5e3;
558
- /**
559
- * How long one request keeps a device warm. Two minutes of inertia after the
560
- * last observer goes away: long enough that flipping between screens (or a
561
- * phone locking briefly) does not go cold, short enough that a closed app stops
562
- * costing captures well before anyone notices.
563
- */
564
- var WARM_WINDOW_MS = 12e4;
565
- function keyOf(deviceId, streamId) {
566
- return `${deviceId}:${streamId ?? "auto"}`;
567
- }
568
- /**
569
- * Who is being watched right now, and at what sizes.
570
- *
571
- * `note` on every data-plane request; `active` on every warm tick. `active`
572
- * PRUNES as it walks rather than merely filtering — the map is fed by request
573
- * traffic, so a filter-only read would leave every device ever requested in
574
- * memory for the life of the process.
575
- */
576
- var SnapshotSubscriptions = class {
577
- entries = /* @__PURE__ */ new Map();
578
- /** Record that somebody asked for this (device, stream) — at `width`, when the
579
- * request carried one. */
580
- note(deviceId, streamId, width, nowMs) {
581
- const key = keyOf(deviceId, streamId);
582
- const existing = this.entries.get(key);
583
- if (existing) {
584
- existing.lastRequestedAt = nowMs;
585
- if (width !== void 0) existing.widths.set(width, nowMs);
586
- this.entries.delete(key);
587
- this.entries.set(key, existing);
588
- return;
589
- }
590
- this.entries.set(key, {
591
- deviceId,
592
- streamId,
593
- lastRequestedAt: nowMs,
594
- widths: width === void 0 ? /* @__PURE__ */ new Map() : new Map([[width, nowMs]])
595
- });
596
- while (this.entries.size > 64) {
597
- const oldest = this.entries.keys().next();
598
- if (oldest.done === true) break;
599
- this.entries.delete(oldest.value);
600
- }
601
- }
602
- /**
603
- * Refresh the window on subscriptions that ALREADY exist. Never creates one.
604
- *
605
- * This is what stops the design deadlocking, and the deadlock was measured on
606
- * the live hub before it existed. The client re-requests an image only when
607
- * the frame identity changes, and the identity changes only because the warm
608
- * loop captured — so 120 s after the last image request the subscription
609
- * expired, warming stopped, the identity froze, the client never asked again,
610
- * and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
611
- *
612
- * The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
613
- * the state of these cameras" IS the statement that somebody is looking. It
614
- * deliberately cannot CREATE a subscription, because that poll is app-wide —
615
- * if it did, opening the app would warm every camera on the install, which is
616
- * the blind poll this whole design avoids. Only a rendered surface subscribes.
617
- */
618
- renew(deviceIds, nowMs) {
619
- for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
620
- }
621
- /** The (device, stream) pairs still inside the warm window, dropping the rest. */
622
- active(nowMs, windowMs = WARM_WINDOW_MS) {
623
- const targets = [];
624
- for (const [key, entry] of this.entries) {
625
- if (nowMs - entry.lastRequestedAt >= windowMs) {
626
- this.entries.delete(key);
627
- continue;
628
- }
629
- for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
630
- targets.push({
631
- deviceId: entry.deviceId,
632
- streamId: entry.streamId,
633
- widths: [...entry.widths.keys()].sort((a, b) => a - b)
634
- });
635
- }
636
- return targets;
637
- }
638
- get size() {
639
- return this.entries.size;
640
- }
641
- clear() {
642
- this.entries.clear();
643
- }
644
- };
635
+ }
645
636
  //#endregion
646
- //#region src/builtins/snapshot/snapshot-link-url.ts
637
+ //#region src/builtins/snapshot/snapshot-resize.ts
647
638
  /**
648
- * Signed, expiring links to a CLIENT-SIZED snapshot frame.
639
+ * Downscaling a captured frame to a card-sized thumbnail.
649
640
  *
650
- * ## Why a link plane exists at all
641
+ * Applied AFTER capture rather than during it, and that is deliberate: the two
642
+ * capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
643
+ * grab) produce a JPEG by different routes, and only one of them has an
644
+ * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
645
+ * same behaviour with one implementation.
651
646
  *
652
- * The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
653
- * going away. What it cannot do is guarantee that a client asking for a tile
654
- * actually REACHES the server and that turned out to be the whole bug.
647
+ * ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
648
+ * This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
649
+ * builtin, so it loads in the hub's ROOT process: sampled on the live hub,
650
+ * ~1.6 of those children were running at any instant, every one of them
651
+ * parented by the root PID.
655
652
  *
656
- * Under D93 the image URL is versioned by the frame identity, and an image
657
- * request is what enrols a camera in the keep-warm loop. Both halves are
658
- * satisfied by the client's own image cache: `expo-image` is URL-keyed and never
659
- * revalidates, so a URL the app painted in a previous session is served from
660
- * disk with **zero network**. Measured on the live hub, reopening the app after
661
- * two minutes idle painted 15 of 16 tiles from disk frames **168 s old**, with
662
- * not one HTTP request, therefore not one enrolment, therefore nothing warming.
663
- * The operator's report ("gli snapshot sono vecchi, devo aggiornare più volte")
664
- * is that measurement.
653
+ * Be precise about what that cost the root process, because it is easy to
654
+ * overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
655
+ * (16 % + 13 % of a core in that same sample), not to the parent. What the
656
+ * parent paid was the fork, the exec, the JPEG written into one pipe and read
657
+ * back out of the other, and the base64 — real event-loop work, on the loop
658
+ * that also serves the tRPC API, but NOT the 52 ms below.
665
659
  *
666
- * A minted link breaks the loop from both ends. It is produced by an RPC —
667
- * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
668
- * signal always lands; and it carries the capture identity the RPC just WAITED
669
- * for, rather than one a cache-only poll happened to be holding.
660
+ * Benchmarked in that container, 2560×1440 640 wide, wall-clock per resize:
670
661
  *
671
- * ## What is signed, and what is only a cache key
662
+ * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
663
+ * sharp 4.8 ms (in-process libvips, on its own threadpool)
672
664
  *
673
- * The signature covers `"<deviceId>:<width>"` and the expiry. The width is
674
- * inside it deliberately: a leaked 240 px tile link must not be replayable as a
675
- * request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
676
- * exists only to key the client's image cache, so an unchanged frame is a
677
- * byte-identical URL and costs no bytes, and a new frame is a new URL and costs
678
- * exactly one fetch.
665
+ * So this removes ~29 % of a core of container CPU and the per-resize process
666
+ * churn from the root process. It was NOT verified to be the cause of that
667
+ * process sitting at 90 % nobody has profiled it.
679
668
  *
680
- * `exp` is bucketed rather than exact. A URL that were unique per mint would
681
- * defeat the client cache completely correct for freshness, and it would make
682
- * a phone re-download every tile on every 5 s poll whether or not anything
683
- * changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
684
- * most once per bucket.
669
+ * `sharp` is not a new dependency: it is already a host-external
670
+ * (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
671
+ * runtime, and already used by the terminal frame renderer. The build preset
672
+ * keeps it out of the bundle, so this import costs nothing at pack time.
685
673
  *
686
- * Pure and side-effect-free; the addon owns the secret and the clock. Unit
687
- * tested in `__tests__/snapshot-link-url.spec.ts`.
674
+ * The cost is still one resize per (device, stream, width) per cache window —
675
+ * not one per request. What changed is what a resize COSTS.
688
676
  */
677
+ var RESIZE_TIMEOUT_MS = 1e4;
689
678
  /**
690
- * How long a minted link stays valid.
691
- *
692
- * A snapshot is a live view of the operator's home, so this is short on purpose
693
- * — the exposure of a leaked link is bounded by it. Two minutes is long enough
694
- * that a page renders, re-renders and survives a brief backgrounding on the URL
695
- * it was given, and short enough that a link pasted somewhere is dead before it
696
- * is useful. The client re-mints on every overview poll (5 s), so it never
697
- * depends on the tail of this window.
679
+ * Encode quality for a derived thumbnail. Matches what the ffmpeg path
680
+ * produced (`-q:v 5` on the mjpeg encoder) closely enough that no card visibly
681
+ * changes this migration is about COST, not about re-tuning the picture.
698
682
  */
699
- var SNAPSHOT_LINK_TTL_MS = 12e4;
683
+ var JPEG_QUALITY = 82;
700
684
  /**
701
- * Quantum the expiry is rounded UP to, so a link is stable between mints.
685
+ * Scale to `width`, preserving aspect ratio.
702
686
  *
703
- * Without it every mint produces a different `exp`, hence a different URL, hence
704
- * a full re-download of an unchanged frame on every poll. With it the URL is a
705
- * pure function of (device, width, frame, bucket) so a tile fetches when its
706
- * frame moves, and at most once more per bucket.
707
- */
708
- var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
709
- /** The token the signature is computed over. Width is part of the identity so a
710
- * tile link cannot be escalated into a full-frame request. */
711
- function snapshotLinkId(deviceId, width) {
712
- return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
713
- }
714
- /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
715
- function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
716
- return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
717
- }
718
- /**
719
- * The link, as a ROOT-RELATIVE path.
687
+ * Rejects on failure or timeout. It NEVER falls back to the original silently —
688
+ * a caller that quietly served the 4K frame when the resize failed would
689
+ * reproduce exactly the bug this whole module exists to fix, and nobody would
690
+ * see it. The caller decides, and logs.
720
691
  *
721
- * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
722
- * because the fetcher is a phone or a notifier backend that has no idea where
723
- * the hub is and picking that base is the `hubUrl: localhost` trap the Alexa
724
- * work paid for. Here the fetcher is a client that is already connected to the
725
- * hub and holds its own `serverUrl`, so the correct base is the one it used to
726
- * make the call. Returning a path makes it impossible to hand a client a link
727
- * pointing somewhere it cannot reach.
692
+ * It DOES upscale a source narrower than `width`, and that is deliberate. A
693
+ * `withoutEnlargement: true` was tried first it is the obvious saving, since
694
+ * upscaling pays encode cost for a blurrier, larger image. But the caller
695
+ * stamps the response ETag from the width it asked for, and its resize-FAILURE
696
+ * path already returns `width: undefined` specifically so a response can never
697
+ * "claim a width the bytes do not have". Silently returning 320 px bytes for a
698
+ * `w=640` request breaks that invariant on the SUCCESS path, where nobody is
699
+ * looking. Honest output width beats a few saved pixels; revisit only together
700
+ * with the ETag.
728
701
  */
729
- function buildSnapshotLinkUrl(input) {
730
- const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
731
- const id = snapshotLinkId(input.deviceId, input.width);
732
- const sig = signExpiringUrl(input.secret, id, input.expMs);
733
- const params = new URLSearchParams();
734
- if (input.width !== void 0) params.set("w", String(input.width));
735
- if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
736
- params.set("exp", String(input.expMs));
737
- params.set("sig", sig);
738
- return `${base}/${String(input.deviceId)}.jpg?${params.toString()}`;
702
+ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
703
+ const work = sharp(bytes).resize({ width }).jpeg({
704
+ quality: JPEG_QUALITY,
705
+ mozjpeg: false
706
+ }).toBuffer().then((out) => {
707
+ if (out.length === 0) throw new Error("snapshot resize produced no bytes");
708
+ return out;
709
+ });
710
+ let timer;
711
+ const bound = new Promise((_, reject) => {
712
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`)), timeoutMs);
713
+ });
714
+ return Promise.race([work, bound]).finally(() => {
715
+ if (timer !== void 0) clearTimeout(timer);
716
+ });
739
717
  }
740
718
  /**
741
- * Parse and VERIFY a link request in one step, so a caller cannot accidentally
742
- * use the device id before checking the signature. Null = refuse (404/403);
743
- * there is deliberately no way to distinguish "bad signature" from "expired"
744
- * from "malformed" at this boundary, so a public route cannot be probed.
719
+ * Resized frames, keyed by (device, stream, width) AND validated against the
720
+ * source frame's timestamp.
721
+ *
722
+ * The timestamp is the whole correctness argument: a variant outlives nothing.
723
+ * When the underlying frame is recaptured its `capturedAt` moves, every variant
724
+ * derived from the old one stops matching, and the next request re-derives.
725
+ * Without that check a card would keep showing a thumbnail of a frame the
726
+ * full-size view had already replaced.
745
727
  */
746
- function parseVerifiedSnapshotLink(input) {
747
- const qIdx = input.url.indexOf("?");
748
- const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
749
- const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
750
- const segment = rawPath.replace(/^\/+/, "");
751
- if (segment.length === 0 || segment.includes("/")) return null;
752
- const idPart = segment.replace(/\.jpe?g$/i, "");
753
- if (!/^\d+$/.test(idPart)) return null;
754
- const deviceId = Number.parseInt(idPart, 10);
755
- if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
756
- const params = new URLSearchParams(query);
757
- const rawWidth = params.get("w");
758
- if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
759
- const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
760
- if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
761
- return verifyExpiringUrl({
762
- secret: input.secret,
763
- id: snapshotLinkId(deviceId, width),
764
- exp: params.get("exp") ?? void 0,
765
- sig: params.get("sig") ?? void 0,
766
- nowMs: input.nowMs
767
- }) ? {
768
- deviceId,
769
- width
770
- } : null;
771
- }
728
+ var SnapshotVariantCache = class SnapshotVariantCache {
729
+ byKey = /* @__PURE__ */ new Map();
730
+ keysByDevice = /* @__PURE__ */ new Map();
731
+ static key(deviceId, streamId, width) {
732
+ return `${deviceId}:${streamId ?? "auto"}:${width}`;
733
+ }
734
+ /** The variant for this exact frame, or undefined when it is missing or was
735
+ * derived from an older capture. */
736
+ get(deviceId, streamId, width, sourceTs) {
737
+ const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
738
+ return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
739
+ }
740
+ set(deviceId, streamId, width, sourceTs, bytes) {
741
+ const key = SnapshotVariantCache.key(deviceId, streamId, width);
742
+ this.byKey.set(key, {
743
+ bytes,
744
+ sourceTs
745
+ });
746
+ const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
747
+ keys.add(key);
748
+ this.keysByDevice.set(deviceId, keys);
749
+ }
750
+ deleteDevice(deviceId) {
751
+ for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
752
+ this.keysByDevice.delete(deviceId);
753
+ }
754
+ clear() {
755
+ this.byKey.clear();
756
+ this.keysByDevice.clear();
757
+ }
758
+ };
772
759
  //#endregion
773
760
  //#region src/builtins/snapshot/snapshot.addon.ts
774
761
  /**
@@ -831,13 +818,22 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
831
818
  * against the source frame's timestamp. */
832
819
  variants = new SnapshotVariantCache();
833
820
  /**
821
+ * Rendered courtesy frames, by (reason, size, device name).
822
+ *
823
+ * A courtesy frame is a pure function of those three, so it is rendered once
824
+ * and reused for as long as the process lives — a disabled camera must not
825
+ * cost an ffmpeg run per poll. Unbounded on purpose: the key space is
826
+ * (3 reasons × this node's own cameras), not user input.
827
+ */
828
+ courtesyFrames = /* @__PURE__ */ new Map();
829
+ /**
834
830
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
835
831
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
836
832
  * buttons) collapses into one capture instead of one dial per tile.
837
833
  */
838
834
  captureFlight = new SingleFlight(COALESCE_MS, (outcome) => outcome.ok && outcome.image !== null);
839
835
  /** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
840
- grabPool = new Semaphore(3);
836
+ grabPool = new Semaphore(6);
841
837
  /** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
842
838
  nativePool = new Semaphore(6);
843
839
  /**
@@ -869,17 +865,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
869
865
  * currently holding, which is a fleet-wide blank grid after each restart. */
870
866
  linkSecret = null;
871
867
  /**
872
- * Who is being looked at, inferred from data-plane traffic. The keep-warm
873
- * loop's entire bound — see `snapshot-keep-warm.ts`.
874
- */
875
- subscriptions = new SnapshotSubscriptions();
876
- /** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
877
- warmTimer = null;
878
- /** Set while a warm tick is running so ticks can never stack. */
879
- warmTickRunning = false;
880
- /** Flipped on shutdown so an in-flight tick does not reschedule itself. */
881
- stopped = false;
882
- /**
883
868
  * How a full frame becomes a card-sized one. Defaults to the real ffmpeg
884
869
  * resize; injectable so the wrapper's own logic is testable without spawning a
885
870
  * process for every fixture. `JpegResizer` was declared for exactly this and
@@ -903,9 +888,12 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
903
888
  getSnapshotOverview: (input) => this.getSnapshotOverview(input),
904
889
  getSnapshotLinks: (input) => this.getSnapshotLinks(input)
905
890
  };
891
+ this.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
892
+ const deviceId = event.data.deviceId;
893
+ if (typeof deviceId === "number") this.evictRemovedDevice(deviceId, "device-unregistered");
894
+ });
906
895
  await this.serveMediaDataPlane();
907
896
  await this.serveLinkDataPlane();
908
- this.scheduleWarmTick();
909
897
  return [{
910
898
  capability: snapshotCapability,
911
899
  provider
@@ -1051,14 +1039,11 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1051
1039
  */
1052
1040
  async getSnapshotLinks(input) {
1053
1041
  const secret = await this.ensureLinkSecret();
1054
- const now = Date.now();
1055
- const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
1042
+ const expMs = snapshotLinkExpiry(Date.now(), SNAPSHOT_LINK_TTL_MS);
1056
1043
  const served = this.linkDataPlane !== null && secret !== null;
1057
1044
  const prefix = this.linkRoutePrefix();
1058
1045
  const sleepingByDevice = /* @__PURE__ */ new Map();
1059
1046
  const refreshes = input.targets.map(async (target) => {
1060
- const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1061
- this.subscriptions.note(target.deviceId, void 0, width, now);
1062
1047
  const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
1063
1048
  sleepingByDevice.set(target.deviceId, sleeping);
1064
1049
  if (!sleeping) await this.awaitFreshEnough(target.deviceId);
@@ -1107,7 +1092,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1107
1092
  await this.getSnapshot({
1108
1093
  deviceId,
1109
1094
  force: false
1110
- }, 0, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1095
+ }, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS, LINK_MINT_DEADLINE_MS).catch(() => null);
1111
1096
  const current = this.cache.latest(deviceId)?.ts ?? null;
1112
1097
  if (alreadyCurrent || current !== null && (before === null || current > before)) return;
1113
1098
  this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
@@ -1138,7 +1123,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1138
1123
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
1139
1124
  */
1140
1125
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
1141
- this.subscriptions.note(deviceId, streamId, width, Date.now());
1142
1126
  const image = await this.getSnapshot({
1143
1127
  deviceId,
1144
1128
  ...streamId !== void 0 ? { streamId } : {},
@@ -1184,92 +1168,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1184
1168
  };
1185
1169
  }
1186
1170
  /**
1187
- * Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
1188
- * tick is armed only after the previous one settles, so a fleet that is slower
1189
- * than the interval degrades to "as fast as it can" instead of stacking ticks
1190
- * on top of each other and exhausting the ffmpeg pool.
1191
- *
1192
- * `unref` so the loop never holds the process open.
1193
- */
1194
- scheduleWarmTick() {
1195
- if (this.stopped) return;
1196
- this.warmTimer = setTimeout(() => {
1197
- this.runWarmTick().finally(() => {
1198
- this.scheduleWarmTick();
1199
- });
1200
- }, WARM_INTERVAL_MS);
1201
- this.warmTimer.unref?.();
1202
- }
1203
- /**
1204
- * Refresh the cache for every camera somebody is currently looking at.
1205
- *
1206
- * Deliberately NOT a blind poll of the whole fleet: with nobody watching this
1207
- * walks an empty list and costs nothing, which is the entire difference
1208
- * between this and a background poller.
1209
- *
1210
- * Battery cameras need no special case here and must not get one. A warm tick
1211
- * is a plain non-forced `getSnapshot`, so it meets the two gates that already
1212
- * exist — the 3600 s battery max-age (which makes the tick a cache hit) and
1213
- * the sleeping-battery gate (which refuses to capture without `force`). A
1214
- * second battery check here would be a rule that disagrees with the authority
1215
- * that already owns it.
1216
- */
1217
- async runWarmTick() {
1218
- if (this.warmTickRunning || this.stopped) return;
1219
- this.warmTickRunning = true;
1220
- const logger = this.ctx.logger;
1221
- try {
1222
- const targets = this.subscriptions.active(Date.now());
1223
- if (targets.length === 0) return;
1224
- const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
1225
- logger.debug("snapshot: keep-warm tick", { meta: {
1226
- watched: targets.length,
1227
- failed
1228
- } });
1229
- } catch (err) {
1230
- logger.warn("snapshot: keep-warm tick failed", { meta: { error: errMsg(err) } });
1231
- } finally {
1232
- this.warmTickRunning = false;
1233
- }
1234
- }
1235
- /**
1236
- * Warm one (device, stream): refresh the frame, then pre-derive the widths
1237
- * that were actually requested.
1238
- *
1239
- * Deriving the variants matters as much as the capture. Warming only the full
1240
- * frame would leave every grid tile still paying an ffmpeg resize on the
1241
- * request path — the cache would be fresh and the tile would still be slow.
1242
- */
1243
- async warmOne(target, logger) {
1244
- const work = this.warmOneUnbounded(target, logger);
1245
- work.catch(() => void 0);
1246
- if (!(await raceForResult(work, 5e3)).settled) logger.debug("snapshot: keep-warm still in flight past the tick — leaving it to land", { tags: { deviceId: target.deviceId } });
1247
- }
1248
- async warmOneUnbounded(target, logger) {
1249
- const { deviceId, streamId, widths } = target;
1250
- try {
1251
- if (this.stopped) return;
1252
- const image = await this.getSnapshot({
1253
- deviceId,
1254
- ...streamId !== void 0 ? { streamId } : {},
1255
- force: false
1256
- }, WARM_INTERVAL_MS);
1257
- if (!image) return;
1258
- const capturedAt = this.cache.get(deviceId, streamId)?.ts;
1259
- if (capturedAt === void 0) return;
1260
- const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1261
- for (const width of widths) {
1262
- if (this.stopped) return;
1263
- await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
1264
- }
1265
- } catch (err) {
1266
- logger.debug("snapshot: keep-warm failed for device", {
1267
- tags: { deviceId },
1268
- meta: { error: errMsg(err) }
1269
- });
1270
- }
1271
- }
1272
- /**
1273
1171
  * The frame at a card-sized width, derived once per (device, stream, width)
1274
1172
  * per capture.
1275
1173
  *
@@ -1316,12 +1214,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1316
1214
  }
1317
1215
  }
1318
1216
  async onShutdown() {
1319
- this.stopped = true;
1320
- if (this.warmTimer !== null) {
1321
- clearTimeout(this.warmTimer);
1322
- this.warmTimer = null;
1323
- }
1324
- this.subscriptions.clear();
1325
1217
  if (this.mediaDataPlane) {
1326
1218
  await this.mediaDataPlane.dispose().catch(() => void 0);
1327
1219
  this.mediaDataPlane = null;
@@ -1364,7 +1256,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1364
1256
  })]
1365
1257
  }] });
1366
1258
  }
1367
- async getSnapshot(input, warmAheadMs = 0, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1259
+ async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
1368
1260
  const { deviceId } = input;
1369
1261
  const force = input.force === true;
1370
1262
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -1375,6 +1267,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1375
1267
  ...deviceName ? { deviceName } : {}
1376
1268
  });
1377
1269
  const now = Date.now();
1270
+ if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
1378
1271
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1379
1272
  const rawPref = prefs.snapshotStreamId;
1380
1273
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -1383,7 +1276,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1383
1276
  meta: { stream: effectiveStreamId ?? "auto" }
1384
1277
  });
1385
1278
  const hit = this.cache.get(deviceId, effectiveStreamId);
1386
- const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs));
1279
+ const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
1387
1280
  const decision = decideSnapshotServe({
1388
1281
  now,
1389
1282
  cachedAt: hit?.ts ?? null,
@@ -1409,7 +1302,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1409
1302
  ageMs: hit ? now - hit.ts : null
1410
1303
  }
1411
1304
  });
1412
- return hit ? hit.data : null;
1305
+ if (hit) return hit.data;
1306
+ return await this.courtesyImage(deviceId, "sleeping", deviceName);
1413
1307
  }
1414
1308
  const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
1415
1309
  const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
@@ -1420,8 +1314,18 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1420
1314
  log
1421
1315
  }));
1422
1316
  flight.catch(() => void 0);
1423
- const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
1424
- if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
1317
+ const raced = await raceForResult(flight, Math.min(Math.max(decision.waitMs, minimumCaptureWaitMs), maximumCaptureWaitMs));
1318
+ if (raced.settled) try {
1319
+ const resolved = await this.resolveOutcome(raced.value, deviceId, hit, log);
1320
+ if (resolved !== null) return resolved;
1321
+ return meta?.online === false ? await this.courtesyImage(deviceId, "offline", deviceName) : null;
1322
+ } catch (err) {
1323
+ if (meta?.online === false) {
1324
+ const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
1325
+ if (courtesy !== null) return courtesy;
1326
+ }
1327
+ throw err;
1328
+ }
1425
1329
  if (decision.staleFallback && hit) {
1426
1330
  if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
1427
1331
  tags: { deviceId },
@@ -1432,6 +1336,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1432
1336
  });
1433
1337
  return hit.data;
1434
1338
  }
1339
+ if (meta?.online === false) {
1340
+ const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
1341
+ if (courtesy !== null) return courtesy;
1342
+ }
1435
1343
  return null;
1436
1344
  }
1437
1345
  /**
@@ -1440,30 +1348,54 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1440
1348
  * falls back to stale cache (or null); a HARD native error with no frame
1441
1349
  * and no cache propagates.
1442
1350
  */
1443
- resolveOutcome(outcome, deviceId, hit, log) {
1351
+ async resolveOutcome(outcome, deviceId, hit, log) {
1444
1352
  if (outcome.ok) {
1445
1353
  if (outcome.image) return outcome.image;
1446
1354
  if (hit) {
1447
1355
  const ageMs = Date.now() - hit.ts;
1448
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
1449
- tags: { deviceId },
1450
- meta: { ageMs }
1451
- });
1356
+ if (ageMs > this.config.staleTtlMs) {
1357
+ if (await this.staleHitBelongsToRemovedDevice(deviceId, ageMs)) return null;
1358
+ log.warn("snapshot: all live paths failed — serving stale cache", {
1359
+ tags: { deviceId },
1360
+ meta: { ageMs }
1361
+ });
1362
+ }
1452
1363
  return hit.data;
1453
1364
  }
1454
1365
  return null;
1455
1366
  }
1456
1367
  if (hit) {
1457
1368
  const ageMs = Date.now() - hit.ts;
1458
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: native failed — serving stale cache", {
1459
- tags: { deviceId },
1460
- meta: { ageMs }
1461
- });
1369
+ if (ageMs > this.config.staleTtlMs) {
1370
+ if (await this.staleHitBelongsToRemovedDevice(deviceId, ageMs)) return null;
1371
+ log.warn("snapshot: native failed — serving stale cache", {
1372
+ tags: { deviceId },
1373
+ meta: { ageMs }
1374
+ });
1375
+ }
1462
1376
  return hit.data;
1463
1377
  }
1464
1378
  throw outcome.error;
1465
1379
  }
1466
1380
  /**
1381
+ * Reconcile backstop for a stale hit: is the frame we are about to serve the
1382
+ * property of a device that no longer exists?
1383
+ *
1384
+ * Asked ONLY past `staleTtlMs`, which is already the "something is wrong"
1385
+ * threshold — so the common path costs nothing. A `true` answer evicts and
1386
+ * the caller returns null; anything else leaves the existing
1387
+ * keep-the-UI-from-going-blank contract untouched.
1388
+ */
1389
+ async staleHitBelongsToRemovedDevice(deviceId, ageMs) {
1390
+ if (await this.deviceStillExists(deviceId) !== false) return false;
1391
+ this.evictRemovedDevice(deviceId, "stale-hit-absent");
1392
+ this.ctx.logger.debug("snapshot: refused a stale frame for a device that is gone", {
1393
+ tags: { deviceId },
1394
+ meta: { ageMs }
1395
+ });
1396
+ return true;
1397
+ }
1398
+ /**
1467
1399
  * Run the capture ladder ONCE for a device: native provider first, then the
1468
1400
  * stream-broker ffmpeg fallback. Never rejects — resolves a {@link
1469
1401
  * CaptureOutcome}. On a produced frame it populates the cache (so a
@@ -1671,9 +1603,48 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1671
1603
  };
1672
1604
  }
1673
1605
  async invalidateCache(input) {
1674
- this.cache.deleteDevice(input.deviceId);
1675
- this.variants.deleteDevice(input.deviceId);
1676
- this.captureFlight.invalidatePrefix(`${input.deviceId}:`);
1606
+ this.dropDeviceCaches(input.deviceId);
1607
+ }
1608
+ /** Forget every cached artefact of one device: frames, derived variants and
1609
+ * any settled single-flight result that would answer the next request. */
1610
+ dropDeviceCaches(deviceId) {
1611
+ this.cache.deleteDevice(deviceId);
1612
+ this.variants.deleteDevice(deviceId);
1613
+ this.captureFlight.invalidatePrefix(`${deviceId}:`);
1614
+ }
1615
+ /**
1616
+ * The device is GONE — drop its cached frames and say so.
1617
+ *
1618
+ * Distinct from `invalidateCache` (a refresh, expected and silent): this is
1619
+ * the terminal case, and a deleted camera that keeps answering with its last
1620
+ * JPEG looks to an operator exactly like a camera that was never deleted.
1621
+ */
1622
+ evictRemovedDevice(deviceId, reason) {
1623
+ this.dropDeviceCaches(deviceId);
1624
+ this.ctx.logger.info("snapshot: dropped cache for removed device", {
1625
+ tags: { deviceId },
1626
+ meta: { reason }
1627
+ });
1628
+ }
1629
+ /**
1630
+ * Does device-manager still know this id?
1631
+ *
1632
+ * `null` means the question could not be answered — a transport hiccup is
1633
+ * not a deletion (D49), and the caller keeps serving what it has. Only an
1634
+ * explicit "no such device" evicts.
1635
+ */
1636
+ async deviceStillExists(deviceId) {
1637
+ const api = this.ctx.api;
1638
+ if (!api) return null;
1639
+ try {
1640
+ return await api.deviceManager.getDevice.query({ deviceId }) ? true : false;
1641
+ } catch (err) {
1642
+ this.ctx.logger.debug("snapshot: device existence check failed — keeping cache", {
1643
+ tags: { deviceId },
1644
+ meta: { error: errMsg(err) }
1645
+ });
1646
+ return null;
1647
+ }
1677
1648
  }
1678
1649
  /**
1679
1650
  * Sleep state from the device-state MIRROR, not from a cap round-trip.
@@ -1745,7 +1716,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1745
1716
  */
1746
1717
  getSnapshotOverview(input) {
1747
1718
  const now = Date.now();
1748
- this.subscriptions.renew(input.deviceIds, now);
1749
1719
  const rows = input.deviceIds.map((deviceId) => {
1750
1720
  const hit = this.cache.latest(deviceId);
1751
1721
  if (!hit) return {
@@ -1834,6 +1804,56 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1834
1804
  }] };
1835
1805
  }
1836
1806
  /**
1807
+ * The frame a camera that CANNOT capture answers with.
1808
+ *
1809
+ * Returns null only if rendering itself fails — the caller then falls through
1810
+ * to its previous behaviour, so a broken courtesy path degrades to the old
1811
+ * 404 rather than masking a real capture failure as a placeholder.
1812
+ *
1813
+ * Rendered at the full tile size; the existing resize ladder derives the
1814
+ * card-sized variants from it exactly as it does for a real frame, so no
1815
+ * downstream consumer needs to know this frame is synthetic.
1816
+ */
1817
+ async courtesyFrame(deviceId, reason, deviceName) {
1818
+ const width = 640;
1819
+ const height = Math.round(width * 9 / 16);
1820
+ const key = courtesyCacheKey({
1821
+ deviceName,
1822
+ reason,
1823
+ width,
1824
+ height
1825
+ });
1826
+ const cached = this.courtesyFrames.get(key);
1827
+ if (cached !== void 0) return cached;
1828
+ try {
1829
+ const bytes = await renderCourtesyJpeg({
1830
+ deviceName,
1831
+ reason,
1832
+ width,
1833
+ height
1834
+ });
1835
+ this.courtesyFrames.set(key, bytes);
1836
+ return bytes;
1837
+ } catch (err) {
1838
+ this.ctx.logger.warn("snapshot: courtesy frame failed — falling back to no image", {
1839
+ tags: { deviceId },
1840
+ meta: {
1841
+ reason,
1842
+ error: errMsg(err)
1843
+ }
1844
+ });
1845
+ return null;
1846
+ }
1847
+ }
1848
+ /** A courtesy frame shaped as the cap's response, or null when unrenderable. */
1849
+ async courtesyImage(deviceId, reason, deviceName) {
1850
+ const bytes = await this.courtesyFrame(deviceId, reason, deviceName ?? `#${String(deviceId)}`);
1851
+ return bytes === null ? null : {
1852
+ base64: bytes.toString("base64"),
1853
+ contentType: "image/jpeg"
1854
+ };
1855
+ }
1856
+ /**
1837
1857
  * Single-trip device lookup against device-manager. Returns the
1838
1858
  * fields the wrapper actually consults — name (logging) + battery
1839
1859
  * flag (cache window + broker-fallback gate). Sourced from the
@@ -1853,10 +1873,13 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1853
1873
  if (!found) return null;
1854
1874
  const features = found.features ?? [];
1855
1875
  const rawType = found.type;
1876
+ const row = found;
1856
1877
  return {
1857
1878
  ...found.name ? { name: found.name } : {},
1858
1879
  isBattery: features.includes(DeviceFeature.BatteryOperated),
1859
- ...rawType ? { type: rawType } : {}
1880
+ ...rawType ? { type: rawType } : {},
1881
+ disabled: row.disabled === true,
1882
+ online: row.online !== false
1860
1883
  };
1861
1884
  } catch (err) {
1862
1885
  this.ctx.logger.debug("deviceManager.getDevice failed during snapshot", {