@camstack/system 1.2.78 → 1.2.80

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 (53) 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-manager.addon.js +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +253 -296
  34. package/dist/builtins/snapshot/index.mjs +252 -296
  35. package/dist/builtins/snapshot/snapshot-courtesy.d.ts +36 -0
  36. package/dist/builtins/snapshot/snapshot-resize.d.ts +43 -11
  37. package/dist/builtins/snapshot/snapshot.addon.d.ts +23 -45
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-cINpngi1.js → dist-CA2mim74.js} +48 -16
  49. package/dist/{dist-CIIFhXqm.mjs → dist-DZOZtn-0.mjs} +48 -16
  50. package/dist/index.js +1 -1
  51. package/dist/index.mjs +1 -1
  52. package/package.json +1 -1
  53. package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +0 -86
@@ -2,12 +2,14 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-cINpngi1.js");
5
+ const require_chunk = require("../../chunk-Cek0wNdY.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  let zod = require("zod");
8
8
  let node_crypto = require("node:crypto");
9
9
  let _camstack_types_node = require("@camstack/types/node");
10
10
  let node_child_process = require("node:child_process");
11
+ let sharp = require("sharp");
12
+ sharp = require_chunk.__toESM(sharp);
11
13
  //#region src/builtins/snapshot/snapshot-coalescing.ts
12
14
  /**
13
15
  * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
@@ -360,7 +362,7 @@ function createSnapshotMediaHandler(deps) {
360
362
  * per-device preference decides what it captures, and conflating it with an
361
363
  * explicit request is how the original bug read. */
362
364
  var AUTO = "auto";
363
- function keyOf$1(deviceId, streamId) {
365
+ function keyOf(deviceId, streamId) {
364
366
  return `${deviceId}:${streamId ?? AUTO}`;
365
367
  }
366
368
  var SnapshotCache = class {
@@ -368,10 +370,10 @@ var SnapshotCache = class {
368
370
  /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
369
371
  keysByDevice = /* @__PURE__ */ new Map();
370
372
  get(deviceId, streamId) {
371
- return this.byKey.get(keyOf$1(deviceId, streamId));
373
+ return this.byKey.get(keyOf(deviceId, streamId));
372
374
  }
373
375
  set(deviceId, streamId, entry) {
374
- const key = keyOf$1(deviceId, streamId);
376
+ const key = keyOf(deviceId, streamId);
375
377
  this.byKey.set(key, entry);
376
378
  const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
377
379
  keys.add(key);
@@ -413,80 +415,75 @@ var SnapshotCache = class {
413
415
  * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
414
416
  * same behaviour with one implementation.
415
417
  *
416
- * The cost is one ffmpeg run per (device, stream, width) per cache window — not
417
- * one per request. A camera whose card is on screen pays it once every
418
- * `snapshotMaxAgeS`.
418
+ * ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
419
+ * This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
420
+ * builtin, so it loads in the hub's ROOT process: sampled on the live hub,
421
+ * ~1.6 of those children were running at any instant, every one of them
422
+ * parented by the root PID.
423
+ *
424
+ * Be precise about what that cost the root process, because it is easy to
425
+ * overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
426
+ * (16 % + 13 % of a core in that same sample), not to the parent. What the
427
+ * parent paid was the fork, the exec, the JPEG written into one pipe and read
428
+ * back out of the other, and the base64 — real event-loop work, on the loop
429
+ * that also serves the tRPC API, but NOT the 52 ms below.
430
+ *
431
+ * Benchmarked in that container, 2560×1440 → 640 wide, wall-clock per resize:
419
432
  *
420
- * `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
421
- * it), so this adds no new one.
433
+ * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
434
+ * sharp 4.8 ms (in-process libvips, on its own threadpool)
435
+ *
436
+ * So this removes ~29 % of a core of container CPU and the per-resize process
437
+ * churn from the root process. It was NOT verified to be the cause of that
438
+ * process sitting at 90 % — nobody has profiled it.
439
+ *
440
+ * `sharp` is not a new dependency: it is already a host-external
441
+ * (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
442
+ * runtime, and already used by the terminal frame renderer. The build preset
443
+ * keeps it out of the bundle, so this import costs nothing at pack time.
444
+ *
445
+ * The cost is still one resize per (device, stream, width) per cache window —
446
+ * not one per request. What changed is what a resize COSTS.
422
447
  */
423
448
  var RESIZE_TIMEOUT_MS = 1e4;
424
449
  /**
425
- * Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
426
- * mjpeg requires).
450
+ * Encode quality for a derived thumbnail. Matches what the ffmpeg path
451
+ * produced (`-q:v 5` on the mjpeg encoder) closely enough that no card visibly
452
+ * changes — this migration is about COST, not about re-tuning the picture.
453
+ */
454
+ var JPEG_QUALITY = 82;
455
+ /**
456
+ * Scale to `width`, preserving aspect ratio.
457
+ *
458
+ * Rejects on failure or timeout. It NEVER falls back to the original silently —
459
+ * a caller that quietly served the 4K frame when the resize failed would
460
+ * reproduce exactly the bug this whole module exists to fix, and nobody would
461
+ * see it. The caller decides, and logs.
427
462
  *
428
- * Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
429
- * silently a caller that quietly served the 4K frame when the resize failed
430
- * would reproduce exactly the bug this whole change exists to fix, and nobody
431
- * would see it. The caller decides, and logs.
463
+ * It DOES upscale a source narrower than `width`, and that is deliberate. A
464
+ * `withoutEnlargement: true` was tried first it is the obvious saving, since
465
+ * upscaling pays encode cost for a blurrier, larger image. But the caller
466
+ * stamps the response ETag from the width it asked for, and its resize-FAILURE
467
+ * path already returns `width: undefined` specifically so a response can never
468
+ * "claim a width the bytes do not have". Silently returning 320 px bytes for a
469
+ * `w=640` request breaks that invariant on the SUCCESS path, where nobody is
470
+ * looking. Honest output width beats a few saved pixels; revisit only together
471
+ * with the ETag.
432
472
  */
433
473
  function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
434
- return new Promise((resolve, reject) => {
435
- const child = (0, node_child_process.spawn)("ffmpeg", [
436
- "-loglevel",
437
- "error",
438
- "-f",
439
- "image2pipe",
440
- "-i",
441
- "pipe:0",
442
- "-vf",
443
- `scale=${String(width)}:-2:flags=bilinear`,
444
- "-frames:v",
445
- "1",
446
- "-q:v",
447
- "5",
448
- "-f",
449
- "image2pipe",
450
- "-vcodec",
451
- "mjpeg",
452
- "pipe:1"
453
- ], { stdio: [
454
- "pipe",
455
- "pipe",
456
- "pipe"
457
- ] });
458
- const out = [];
459
- let err = "";
460
- let settled = false;
461
- const timer = setTimeout(() => {
462
- if (settled) return;
463
- settled = true;
464
- child.kill("SIGKILL");
465
- reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`));
466
- }, timeoutMs);
467
- child.stdout.on("data", (chunk) => out.push(chunk));
468
- child.stderr.on("data", (chunk) => {
469
- err += chunk.toString();
470
- });
471
- child.on("error", (e) => {
472
- if (settled) return;
473
- settled = true;
474
- clearTimeout(timer);
475
- reject(e);
476
- });
477
- child.on("close", (code) => {
478
- if (settled) return;
479
- settled = true;
480
- clearTimeout(timer);
481
- const buf = Buffer.concat(out);
482
- if (code !== 0 || buf.length === 0) {
483
- reject(/* @__PURE__ */ new Error(`snapshot resize failed (code ${String(code)}): ${err.trim() || "no output"}`));
484
- return;
485
- }
486
- resolve(buf);
487
- });
488
- child.stdin.on("error", () => {});
489
- child.stdin.end(bytes);
474
+ const work = (0, sharp.default)(bytes).resize({ width }).jpeg({
475
+ quality: JPEG_QUALITY,
476
+ mozjpeg: false
477
+ }).toBuffer().then((out) => {
478
+ if (out.length === 0) throw new Error("snapshot resize produced no bytes");
479
+ return out;
480
+ });
481
+ let timer;
482
+ const bound = new Promise((_, reject) => {
483
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`)), timeoutMs);
484
+ });
485
+ return Promise.race([work, bound]).finally(() => {
486
+ if (timer !== void 0) clearTimeout(timer);
490
487
  });
491
488
  }
492
489
  /**
@@ -531,122 +528,107 @@ var SnapshotVariantCache = class SnapshotVariantCache {
531
528
  }
532
529
  };
533
530
  //#endregion
534
- //#region src/builtins/snapshot/snapshot-keep-warm.ts
531
+ //#region src/builtins/snapshot/snapshot-courtesy.ts
535
532
  /**
536
- * Subscription bookkeeping for the snapshot keep-warm loop.
533
+ * A courtesy frame for a camera that CANNOT produce one.
534
+ *
535
+ * ── Why this exists ───────────────────────────────────────────────────────
536
+ * A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
537
+ * snapshot service simply had nothing to say about it: the media route answered
538
+ * **404** and the client painted "Unavailable". Measured on the live grid that
539
+ * day, 10 of 26 tiles were 404s — one genuinely disabled camera plus nine dead
540
+ * legacy rows. A 404 is indistinguishable from a broken camera, so an operator
541
+ * who deliberately switched a camera off saw the same thing as a fault ([D62]:
542
+ * "an off switch is REPORTED off; disabled must never look like broken").
537
543
  *
538
- * The snapshot cache has exactly one writer a successful capture and every
539
- * capture is triggered by somebody asking. Nothing refreshes a frame on its own,
540
- * so with no viewer open every thumbnail in the system is as old as the last
541
- * time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
542
- * captured in one 55 ms burst, all 10 minutes old and not moving.
544
+ * So the service answers with a frame that SAYS what is going on, carrying the
545
+ * camera's own name. The client gets a valid image, the tile paints, and the
546
+ * state is legible instead of inferred from an error.
543
547
  *
544
- * The fix is a warm loop — but a blind one would dial 16 cameras forever for
545
- * nobody. This module holds the thing that makes it bounded: a warm tick only
546
- * touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
547
- * requests themselves. One data-plane request for a device makes it an observer
548
- * for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
549
- * on its own after that window and costs exactly nothing.
548
+ * ── Why sharp and not ffmpeg ──────────────────────────────────────────────
549
+ * The first draft of this file shelled out to `ffmpeg` with a `drawtext`
550
+ * filter. That was written before the resize path was measured, and it was the
551
+ * wrong call for the same reason: this runs in the snapshot wrapper, a system
552
+ * builtin loaded in the hub's ROOT process, so every render was a fork + exec
553
+ * on the loop that serves the API 52 ms against sharp's 4.8 ms in that
554
+ * container, to draw two lines of text.
550
555
  *
551
- * Pure and side-effect-free (no timer, no I/O, `now` injected) the addon owns
552
- * the timer, this owns the decision. Unit-tested in
553
- * `__tests__/snapshot-keep-warm.spec.ts`.
556
+ * `terminal-frame-renderer.ts` already renders text this way (SVG sharp), so
557
+ * this is the house pattern rather than a new one. It also drops the font-PATH
558
+ * probing the ffmpeg version needed: an SVG names a font FAMILY and fontconfig
559
+ * resolves it — verified in the hub image, where `fc-match "DejaVu Sans Mono"`
560
+ * answers with the real file.
561
+ *
562
+ * Rendering is pure input → bytes with no I/O of its own, and the geometry and
563
+ * escaping are separated out so they stay testable without rasterizing.
554
564
  */
555
565
  /**
556
- * How often the warm loop runs. Chosen so a tile is perceptibly fresh while
557
- * watched; it is also the single biggest cost lever in the design (16 cameras ×
558
- * 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
559
- * the next tick is scheduled only after the previous one settles, so a slow
560
- * fleet cannot stack ticks on top of each other.
566
+ * Font stack for the rendered text. Family names, not paths librsvg resolves
567
+ * them through fontconfig, and the trailing generics keep a host without DejaVu
568
+ * rendering something legible instead of nothing.
561
569
  */
562
- var WARM_INTERVAL_MS = 5e3;
570
+ var COURTESY_FONT_STACK = "DejaVu Sans,DejaVu Sans Mono,Helvetica,Arial,sans-serif";
571
+ /** The word the frame carries. Deliberately the operator's vocabulary. */
572
+ function courtesyLabel(reason) {
573
+ switch (reason) {
574
+ case "disabled": return "Disabled";
575
+ case "offline": return "Offline";
576
+ case "sleeping": return "Sleeping";
577
+ }
578
+ }
563
579
  /**
564
- * How long one request keeps a device warm. Two minutes of inertia after the
565
- * last observer goes away: long enough that flipping between screens (or a
566
- * phone locking briefly) does not go cold, short enough that a closed app stops
567
- * costing captures well before anyone notices.
580
+ * Background per reason. A disabled camera is a DELIBERATE state and must not
581
+ * read as an alarm, so it is neutral grey; offline is a fault and is warmer.
568
582
  */
569
- var WARM_WINDOW_MS = 12e4;
570
- function keyOf(deviceId, streamId) {
571
- return `${deviceId}:${streamId ?? "auto"}`;
583
+ function courtesyBackground(reason) {
584
+ switch (reason) {
585
+ case "disabled": return "#2b2b31";
586
+ case "offline": return "#3a2b2b";
587
+ case "sleeping": return "#232b3a";
588
+ }
589
+ }
590
+ /** XML escaping for text placed inside an SVG `<text>` node. */
591
+ function escapeCourtesyText(value) {
592
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
572
593
  }
573
594
  /**
574
- * Who is being watched right now, and at what sizes.
595
+ * The SVG the frame is rasterized from: the state large and centred, the
596
+ * camera's name under it, smaller and dimmer.
575
597
  *
576
- * `note` on every data-plane request; `active` on every warm tick. `active`
577
- * PRUNES as it walks rather than merely filtering the map is fed by request
578
- * traffic, so a filter-only read would leave every device ever requested in
579
- * memory for the life of the process.
598
+ * Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
599
+ * frame read the same, with a floor so a thumbnail stays legible.
580
600
  */
581
- var SnapshotSubscriptions = class {
582
- entries = /* @__PURE__ */ new Map();
583
- /** Record that somebody asked for this (device, stream) — at `width`, when the
584
- * request carried one. */
585
- note(deviceId, streamId, width, nowMs) {
586
- const key = keyOf(deviceId, streamId);
587
- const existing = this.entries.get(key);
588
- if (existing) {
589
- existing.lastRequestedAt = nowMs;
590
- if (width !== void 0) existing.widths.set(width, nowMs);
591
- this.entries.delete(key);
592
- this.entries.set(key, existing);
593
- return;
594
- }
595
- this.entries.set(key, {
596
- deviceId,
597
- streamId,
598
- lastRequestedAt: nowMs,
599
- widths: width === void 0 ? /* @__PURE__ */ new Map() : new Map([[width, nowMs]])
600
- });
601
- while (this.entries.size > 64) {
602
- const oldest = this.entries.keys().next();
603
- if (oldest.done === true) break;
604
- this.entries.delete(oldest.value);
605
- }
606
- }
607
- /**
608
- * Refresh the window on subscriptions that ALREADY exist. Never creates one.
609
- *
610
- * This is what stops the design deadlocking, and the deadlock was measured on
611
- * the live hub before it existed. The client re-requests an image only when
612
- * the frame identity changes, and the identity changes only because the warm
613
- * loop captured — so 120 s after the last image request the subscription
614
- * expired, warming stopped, the identity froze, the client never asked again,
615
- * and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
616
- *
617
- * The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
618
- * the state of these cameras" IS the statement that somebody is looking. It
619
- * deliberately cannot CREATE a subscription, because that poll is app-wide —
620
- * if it did, opening the app would warm every camera on the install, which is
621
- * the blind poll this whole design avoids. Only a rendered surface subscribes.
622
- */
623
- renew(deviceIds, nowMs) {
624
- for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
625
- }
626
- /** The (device, stream) pairs still inside the warm window, dropping the rest. */
627
- active(nowMs, windowMs = WARM_WINDOW_MS) {
628
- const targets = [];
629
- for (const [key, entry] of this.entries) {
630
- if (nowMs - entry.lastRequestedAt >= windowMs) {
631
- this.entries.delete(key);
632
- continue;
633
- }
634
- for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
635
- targets.push({
636
- deviceId: entry.deviceId,
637
- streamId: entry.streamId,
638
- widths: [...entry.widths.keys()].sort((a, b) => a - b)
639
- });
640
- }
641
- return targets;
642
- }
643
- get size() {
644
- return this.entries.size;
645
- }
646
- clear() {
647
- this.entries.clear();
648
- }
649
- };
601
+ function buildCourtesySvg(spec) {
602
+ const stateSize = Math.max(12, Math.round(spec.width / 12));
603
+ const nameSize = Math.max(9, Math.round(spec.width / 26));
604
+ const state = escapeCourtesyText(courtesyLabel(spec.reason));
605
+ const name = escapeCourtesyText(spec.deviceName);
606
+ const midY = spec.height / 2;
607
+ return [
608
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
609
+ `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
610
+ `<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
611
+ `<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
612
+ `<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
613
+ `</g></svg>`
614
+ ].join("");
615
+ }
616
+ /** Cache key — a courtesy frame is a pure function of these four. */
617
+ function courtesyCacheKey(spec) {
618
+ return `${spec.reason}:${String(spec.width)}x${String(spec.height)}:${spec.deviceName}`;
619
+ }
620
+ /**
621
+ * Render the frame.
622
+ *
623
+ * Rejects on failure — the caller decides what to do, exactly as `resizeJpeg`
624
+ * does, so a broken courtesy path is never mistaken for a broken camera.
625
+ */
626
+ function renderCourtesyJpeg(spec) {
627
+ return (0, sharp.default)(Buffer.from(buildCourtesySvg(spec))).jpeg({ quality: 82 }).toBuffer().then((bytes) => {
628
+ if (bytes.length === 0) throw new Error("courtesy frame produced no bytes");
629
+ return bytes;
630
+ });
631
+ }
650
632
  //#endregion
651
633
  //#region src/builtins/snapshot/snapshot-link-url.ts
652
634
  /**
@@ -659,14 +641,20 @@ var SnapshotSubscriptions = class {
659
641
  * actually REACHES the server — and that turned out to be the whole bug.
660
642
  *
661
643
  * Under D93 the image URL is versioned by the frame identity, and an image
662
- * request is what enrols a camera in the keep-warm loop. Both halves are
663
- * satisfied by the client's own image cache: `expo-image` is URL-keyed and never
644
+ * request is what signalled demand for a camera. Both halves are satisfied by
645
+ * the client's own image cache: `expo-image` is URL-keyed and never
664
646
  * revalidates, so a URL the app painted in a previous session is served from
665
647
  * disk with **zero network**. Measured on the live hub, reopening the app after
666
648
  * two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
667
- * not one HTTP request, therefore not one enrolment, therefore nothing warming.
668
- * The operator's report ("gli snapshot sono vecchi, devo aggiornare più volte")
669
- * is that measurement.
649
+ * not one HTTP request, therefore no demand, therefore no capture. The
650
+ * operator's report ("gli snapshot sono vecchi, devo aggiornare più volte") is
651
+ * that measurement.
652
+ *
653
+ * That gap used to be covered on BOTH sides — by this link plane and by a
654
+ * server-side keep-warm timer. The timer was removed on 2026-08-11 (operator
655
+ * directive: snapshots are on-demand, always), which makes this plane the only
656
+ * thing standing between a client cache and a frozen tile. It carries the whole
657
+ * job now.
670
658
  *
671
659
  * A minted link breaks the loop from both ends. It is produced by an RPC —
672
660
  * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
@@ -836,6 +824,15 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
836
824
  * against the source frame's timestamp. */
837
825
  variants = new SnapshotVariantCache();
838
826
  /**
827
+ * Rendered courtesy frames, by (reason, size, device name).
828
+ *
829
+ * A courtesy frame is a pure function of those three, so it is rendered once
830
+ * and reused for as long as the process lives — a disabled camera must not
831
+ * cost an ffmpeg run per poll. Unbounded on purpose: the key space is
832
+ * (3 reasons × this node's own cameras), not user input.
833
+ */
834
+ courtesyFrames = /* @__PURE__ */ new Map();
835
+ /**
839
836
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
840
837
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
841
838
  * buttons) collapses into one capture instead of one dial per tile.
@@ -874,17 +871,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
874
871
  * currently holding, which is a fleet-wide blank grid after each restart. */
875
872
  linkSecret = null;
876
873
  /**
877
- * Who is being looked at, inferred from data-plane traffic. The keep-warm
878
- * loop's entire bound — see `snapshot-keep-warm.ts`.
879
- */
880
- subscriptions = new SnapshotSubscriptions();
881
- /** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
882
- warmTimer = null;
883
- /** Set while a warm tick is running so ticks can never stack. */
884
- warmTickRunning = false;
885
- /** Flipped on shutdown so an in-flight tick does not reschedule itself. */
886
- stopped = false;
887
- /**
888
874
  * How a full frame becomes a card-sized one. Defaults to the real ffmpeg
889
875
  * resize; injectable so the wrapper's own logic is testable without spawning a
890
876
  * process for every fixture. `JpegResizer` was declared for exactly this and
@@ -910,7 +896,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
910
896
  };
911
897
  await this.serveMediaDataPlane();
912
898
  await this.serveLinkDataPlane();
913
- this.scheduleWarmTick();
914
899
  return [{
915
900
  capability: require_dist.snapshotCapability,
916
901
  provider
@@ -1056,14 +1041,11 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1056
1041
  */
1057
1042
  async getSnapshotLinks(input) {
1058
1043
  const secret = await this.ensureLinkSecret();
1059
- const now = Date.now();
1060
- const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
1044
+ const expMs = snapshotLinkExpiry(Date.now(), SNAPSHOT_LINK_TTL_MS);
1061
1045
  const served = this.linkDataPlane !== null && secret !== null;
1062
1046
  const prefix = this.linkRoutePrefix();
1063
1047
  const sleepingByDevice = /* @__PURE__ */ new Map();
1064
1048
  const refreshes = input.targets.map(async (target) => {
1065
- const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1066
- this.subscriptions.note(target.deviceId, void 0, width, now);
1067
1049
  const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
1068
1050
  sleepingByDevice.set(target.deviceId, sleeping);
1069
1051
  if (!sleeping) await this.awaitFreshEnough(target.deviceId);
@@ -1112,7 +1094,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1112
1094
  await this.getSnapshot({
1113
1095
  deviceId,
1114
1096
  force: false
1115
- }, 0, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1097
+ }, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1116
1098
  const current = this.cache.latest(deviceId)?.ts ?? null;
1117
1099
  if (alreadyCurrent || current !== null && (before === null || current > before)) return;
1118
1100
  this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
@@ -1143,7 +1125,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1143
1125
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
1144
1126
  */
1145
1127
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
1146
- this.subscriptions.note(deviceId, streamId, width, Date.now());
1147
1128
  const image = await this.getSnapshot({
1148
1129
  deviceId,
1149
1130
  ...streamId !== void 0 ? { streamId } : {},
@@ -1189,92 +1170,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1189
1170
  };
1190
1171
  }
1191
1172
  /**
1192
- * Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
1193
- * tick is armed only after the previous one settles, so a fleet that is slower
1194
- * than the interval degrades to "as fast as it can" instead of stacking ticks
1195
- * on top of each other and exhausting the ffmpeg pool.
1196
- *
1197
- * `unref` so the loop never holds the process open.
1198
- */
1199
- scheduleWarmTick() {
1200
- if (this.stopped) return;
1201
- this.warmTimer = setTimeout(() => {
1202
- this.runWarmTick().finally(() => {
1203
- this.scheduleWarmTick();
1204
- });
1205
- }, WARM_INTERVAL_MS);
1206
- this.warmTimer.unref?.();
1207
- }
1208
- /**
1209
- * Refresh the cache for every camera somebody is currently looking at.
1210
- *
1211
- * Deliberately NOT a blind poll of the whole fleet: with nobody watching this
1212
- * walks an empty list and costs nothing, which is the entire difference
1213
- * between this and a background poller.
1214
- *
1215
- * Battery cameras need no special case here and must not get one. A warm tick
1216
- * is a plain non-forced `getSnapshot`, so it meets the two gates that already
1217
- * exist — the 3600 s battery max-age (which makes the tick a cache hit) and
1218
- * the sleeping-battery gate (which refuses to capture without `force`). A
1219
- * second battery check here would be a rule that disagrees with the authority
1220
- * that already owns it.
1221
- */
1222
- async runWarmTick() {
1223
- if (this.warmTickRunning || this.stopped) return;
1224
- this.warmTickRunning = true;
1225
- const logger = this.ctx.logger;
1226
- try {
1227
- const targets = this.subscriptions.active(Date.now());
1228
- if (targets.length === 0) return;
1229
- const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
1230
- logger.debug("snapshot: keep-warm tick", { meta: {
1231
- watched: targets.length,
1232
- failed
1233
- } });
1234
- } catch (err) {
1235
- logger.warn("snapshot: keep-warm tick failed", { meta: { error: require_dist.errMsg(err) } });
1236
- } finally {
1237
- this.warmTickRunning = false;
1238
- }
1239
- }
1240
- /**
1241
- * Warm one (device, stream): refresh the frame, then pre-derive the widths
1242
- * that were actually requested.
1243
- *
1244
- * Deriving the variants matters as much as the capture. Warming only the full
1245
- * frame would leave every grid tile still paying an ffmpeg resize on the
1246
- * request path — the cache would be fresh and the tile would still be slow.
1247
- */
1248
- async warmOne(target, logger) {
1249
- const work = this.warmOneUnbounded(target, logger);
1250
- work.catch(() => void 0);
1251
- 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 } });
1252
- }
1253
- async warmOneUnbounded(target, logger) {
1254
- const { deviceId, streamId, widths } = target;
1255
- try {
1256
- if (this.stopped) return;
1257
- const image = await this.getSnapshot({
1258
- deviceId,
1259
- ...streamId !== void 0 ? { streamId } : {},
1260
- force: false
1261
- }, WARM_INTERVAL_MS);
1262
- if (!image) return;
1263
- const capturedAt = this.cache.get(deviceId, streamId)?.ts;
1264
- if (capturedAt === void 0) return;
1265
- const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1266
- for (const width of widths) {
1267
- if (this.stopped) return;
1268
- await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
1269
- }
1270
- } catch (err) {
1271
- logger.debug("snapshot: keep-warm failed for device", {
1272
- tags: { deviceId },
1273
- meta: { error: require_dist.errMsg(err) }
1274
- });
1275
- }
1276
- }
1277
- /**
1278
1173
  * The frame at a card-sized width, derived once per (device, stream, width)
1279
1174
  * per capture.
1280
1175
  *
@@ -1321,12 +1216,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1321
1216
  }
1322
1217
  }
1323
1218
  async onShutdown() {
1324
- this.stopped = true;
1325
- if (this.warmTimer !== null) {
1326
- clearTimeout(this.warmTimer);
1327
- this.warmTimer = null;
1328
- }
1329
- this.subscriptions.clear();
1330
1219
  if (this.mediaDataPlane) {
1331
1220
  await this.mediaDataPlane.dispose().catch(() => void 0);
1332
1221
  this.mediaDataPlane = null;
@@ -1369,7 +1258,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1369
1258
  })]
1370
1259
  }] });
1371
1260
  }
1372
- async getSnapshot(input, warmAheadMs = 0, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1261
+ async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1373
1262
  const { deviceId } = input;
1374
1263
  const force = input.force === true;
1375
1264
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -1380,6 +1269,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1380
1269
  ...deviceName ? { deviceName } : {}
1381
1270
  });
1382
1271
  const now = Date.now();
1272
+ if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
1383
1273
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1384
1274
  const rawPref = prefs.snapshotStreamId;
1385
1275
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -1388,7 +1278,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1388
1278
  meta: { stream: effectiveStreamId ?? "auto" }
1389
1279
  });
1390
1280
  const hit = this.cache.get(deviceId, effectiveStreamId);
1391
- const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs));
1281
+ const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
1392
1282
  const decision = decideSnapshotServe({
1393
1283
  now,
1394
1284
  cachedAt: hit?.ts ?? null,
@@ -1414,7 +1304,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1414
1304
  ageMs: hit ? now - hit.ts : null
1415
1305
  }
1416
1306
  });
1417
- return hit ? hit.data : null;
1307
+ if (hit) return hit.data;
1308
+ return await this.courtesyImage(deviceId, "sleeping", deviceName);
1418
1309
  }
1419
1310
  const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
1420
1311
  const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
@@ -1426,7 +1317,17 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1426
1317
  }));
1427
1318
  flight.catch(() => void 0);
1428
1319
  const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
1429
- if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
1320
+ if (raced.settled) try {
1321
+ const resolved = this.resolveOutcome(raced.value, deviceId, hit, log);
1322
+ if (resolved !== null) return resolved;
1323
+ return meta?.online === false ? await this.courtesyImage(deviceId, "offline", deviceName) : null;
1324
+ } catch (err) {
1325
+ if (meta?.online === false) {
1326
+ const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
1327
+ if (courtesy !== null) return courtesy;
1328
+ }
1329
+ throw err;
1330
+ }
1430
1331
  if (decision.staleFallback && hit) {
1431
1332
  if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
1432
1333
  tags: { deviceId },
@@ -1437,6 +1338,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1437
1338
  });
1438
1339
  return hit.data;
1439
1340
  }
1341
+ if (meta?.online === false) {
1342
+ const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
1343
+ if (courtesy !== null) return courtesy;
1344
+ }
1440
1345
  return null;
1441
1346
  }
1442
1347
  /**
@@ -1750,7 +1655,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1750
1655
  */
1751
1656
  getSnapshotOverview(input) {
1752
1657
  const now = Date.now();
1753
- this.subscriptions.renew(input.deviceIds, now);
1754
1658
  const rows = input.deviceIds.map((deviceId) => {
1755
1659
  const hit = this.cache.latest(deviceId);
1756
1660
  if (!hit) return {
@@ -1839,6 +1743,56 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1839
1743
  }] };
1840
1744
  }
1841
1745
  /**
1746
+ * The frame a camera that CANNOT capture answers with.
1747
+ *
1748
+ * Returns null only if rendering itself fails — the caller then falls through
1749
+ * to its previous behaviour, so a broken courtesy path degrades to the old
1750
+ * 404 rather than masking a real capture failure as a placeholder.
1751
+ *
1752
+ * Rendered at the full tile size; the existing resize ladder derives the
1753
+ * card-sized variants from it exactly as it does for a real frame, so no
1754
+ * downstream consumer needs to know this frame is synthetic.
1755
+ */
1756
+ async courtesyFrame(deviceId, reason, deviceName) {
1757
+ const width = 640;
1758
+ const height = Math.round(width * 9 / 16);
1759
+ const key = courtesyCacheKey({
1760
+ deviceName,
1761
+ reason,
1762
+ width,
1763
+ height
1764
+ });
1765
+ const cached = this.courtesyFrames.get(key);
1766
+ if (cached !== void 0) return cached;
1767
+ try {
1768
+ const bytes = await renderCourtesyJpeg({
1769
+ deviceName,
1770
+ reason,
1771
+ width,
1772
+ height
1773
+ });
1774
+ this.courtesyFrames.set(key, bytes);
1775
+ return bytes;
1776
+ } catch (err) {
1777
+ this.ctx.logger.warn("snapshot: courtesy frame failed — falling back to no image", {
1778
+ tags: { deviceId },
1779
+ meta: {
1780
+ reason,
1781
+ error: require_dist.errMsg(err)
1782
+ }
1783
+ });
1784
+ return null;
1785
+ }
1786
+ }
1787
+ /** A courtesy frame shaped as the cap's response, or null when unrenderable. */
1788
+ async courtesyImage(deviceId, reason, deviceName) {
1789
+ const bytes = await this.courtesyFrame(deviceId, reason, deviceName ?? `#${String(deviceId)}`);
1790
+ return bytes === null ? null : {
1791
+ base64: bytes.toString("base64"),
1792
+ contentType: "image/jpeg"
1793
+ };
1794
+ }
1795
+ /**
1842
1796
  * Single-trip device lookup against device-manager. Returns the
1843
1797
  * fields the wrapper actually consults — name (logging) + battery
1844
1798
  * flag (cache window + broker-fallback gate). Sourced from the
@@ -1858,10 +1812,13 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1858
1812
  if (!found) return null;
1859
1813
  const features = found.features ?? [];
1860
1814
  const rawType = found.type;
1815
+ const row = found;
1861
1816
  return {
1862
1817
  ...found.name ? { name: found.name } : {},
1863
1818
  isBattery: features.includes(require_dist.DeviceFeature.BatteryOperated),
1864
- ...rawType ? { type: rawType } : {}
1819
+ ...rawType ? { type: rawType } : {},
1820
+ disabled: row.disabled === true,
1821
+ online: row.online !== false
1865
1822
  };
1866
1823
  } catch (err) {
1867
1824
  this.ctx.logger.debug("deviceManager.getDevice failed during snapshot", {