@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
@@ -1,8 +1,9 @@
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, 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";
6
7
  //#region src/builtins/snapshot/snapshot-coalescing.ts
7
8
  /**
8
9
  * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
@@ -355,7 +356,7 @@ function createSnapshotMediaHandler(deps) {
355
356
  * per-device preference decides what it captures, and conflating it with an
356
357
  * explicit request is how the original bug read. */
357
358
  var AUTO = "auto";
358
- function keyOf$1(deviceId, streamId) {
359
+ function keyOf(deviceId, streamId) {
359
360
  return `${deviceId}:${streamId ?? AUTO}`;
360
361
  }
361
362
  var SnapshotCache = class {
@@ -363,10 +364,10 @@ var SnapshotCache = class {
363
364
  /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
364
365
  keysByDevice = /* @__PURE__ */ new Map();
365
366
  get(deviceId, streamId) {
366
- return this.byKey.get(keyOf$1(deviceId, streamId));
367
+ return this.byKey.get(keyOf(deviceId, streamId));
367
368
  }
368
369
  set(deviceId, streamId, entry) {
369
- const key = keyOf$1(deviceId, streamId);
370
+ const key = keyOf(deviceId, streamId);
370
371
  this.byKey.set(key, entry);
371
372
  const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
372
373
  keys.add(key);
@@ -408,80 +409,75 @@ var SnapshotCache = class {
408
409
  * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
409
410
  * same behaviour with one implementation.
410
411
  *
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`.
412
+ * ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
413
+ * This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
414
+ * builtin, so it loads in the hub's ROOT process: sampled on the live hub,
415
+ * ~1.6 of those children were running at any instant, every one of them
416
+ * parented by the root PID.
417
+ *
418
+ * Be precise about what that cost the root process, because it is easy to
419
+ * overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
420
+ * (16 % + 13 % of a core in that same sample), not to the parent. What the
421
+ * parent paid was the fork, the exec, the JPEG written into one pipe and read
422
+ * back out of the other, and the base64 — real event-loop work, on the loop
423
+ * that also serves the tRPC API, but NOT the 52 ms below.
424
+ *
425
+ * Benchmarked in that container, 2560×1440 → 640 wide, wall-clock per resize:
414
426
  *
415
- * `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
416
- * it), so this adds no new one.
427
+ * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
428
+ * sharp 4.8 ms (in-process libvips, on its own threadpool)
429
+ *
430
+ * So this removes ~29 % of a core of container CPU and the per-resize process
431
+ * churn from the root process. It was NOT verified to be the cause of that
432
+ * process sitting at 90 % — nobody has profiled it.
433
+ *
434
+ * `sharp` is not a new dependency: it is already a host-external
435
+ * (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
436
+ * runtime, and already used by the terminal frame renderer. The build preset
437
+ * keeps it out of the bundle, so this import costs nothing at pack time.
438
+ *
439
+ * The cost is still one resize per (device, stream, width) per cache window —
440
+ * not one per request. What changed is what a resize COSTS.
417
441
  */
418
442
  var RESIZE_TIMEOUT_MS = 1e4;
419
443
  /**
420
- * Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
421
- * mjpeg requires).
444
+ * Encode quality for a derived thumbnail. Matches what the ffmpeg path
445
+ * produced (`-q:v 5` on the mjpeg encoder) closely enough that no card visibly
446
+ * changes — this migration is about COST, not about re-tuning the picture.
447
+ */
448
+ var JPEG_QUALITY = 82;
449
+ /**
450
+ * Scale to `width`, preserving aspect ratio.
451
+ *
452
+ * Rejects on failure or timeout. It NEVER falls back to the original silently —
453
+ * a caller that quietly served the 4K frame when the resize failed would
454
+ * reproduce exactly the bug this whole module exists to fix, and nobody would
455
+ * see it. The caller decides, and logs.
422
456
  *
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.
457
+ * It DOES upscale a source narrower than `width`, and that is deliberate. A
458
+ * `withoutEnlargement: true` was tried first it is the obvious saving, since
459
+ * upscaling pays encode cost for a blurrier, larger image. But the caller
460
+ * stamps the response ETag from the width it asked for, and its resize-FAILURE
461
+ * path already returns `width: undefined` specifically so a response can never
462
+ * "claim a width the bytes do not have". Silently returning 320 px bytes for a
463
+ * `w=640` request breaks that invariant on the SUCCESS path, where nobody is
464
+ * looking. Honest output width beats a few saved pixels; revisit only together
465
+ * with the ETag.
427
466
  */
428
467
  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);
468
+ const work = sharp(bytes).resize({ width }).jpeg({
469
+ quality: JPEG_QUALITY,
470
+ mozjpeg: false
471
+ }).toBuffer().then((out) => {
472
+ if (out.length === 0) throw new Error("snapshot resize produced no bytes");
473
+ return out;
474
+ });
475
+ let timer;
476
+ const bound = new Promise((_, reject) => {
477
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`)), timeoutMs);
478
+ });
479
+ return Promise.race([work, bound]).finally(() => {
480
+ if (timer !== void 0) clearTimeout(timer);
485
481
  });
486
482
  }
487
483
  /**
@@ -526,122 +522,107 @@ var SnapshotVariantCache = class SnapshotVariantCache {
526
522
  }
527
523
  };
528
524
  //#endregion
529
- //#region src/builtins/snapshot/snapshot-keep-warm.ts
525
+ //#region src/builtins/snapshot/snapshot-courtesy.ts
530
526
  /**
531
- * Subscription bookkeeping for the snapshot keep-warm loop.
527
+ * A courtesy frame for a camera that CANNOT produce one.
528
+ *
529
+ * ── Why this exists ───────────────────────────────────────────────────────
530
+ * A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
531
+ * snapshot service simply had nothing to say about it: the media route answered
532
+ * **404** and the client painted "Unavailable". Measured on the live grid that
533
+ * day, 10 of 26 tiles were 404s — one genuinely disabled camera plus nine dead
534
+ * legacy rows. A 404 is indistinguishable from a broken camera, so an operator
535
+ * who deliberately switched a camera off saw the same thing as a fault ([D62]:
536
+ * "an off switch is REPORTED off; disabled must never look like broken").
532
537
  *
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
+ * So the service answers with a frame that SAYS what is going on, carrying the
539
+ * camera's own name. The client gets a valid image, the tile paints, and the
540
+ * state is legible instead of inferred from an error.
538
541
  *
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.
542
+ * ── Why sharp and not ffmpeg ──────────────────────────────────────────────
543
+ * The first draft of this file shelled out to `ffmpeg` with a `drawtext`
544
+ * filter. That was written before the resize path was measured, and it was the
545
+ * wrong call for the same reason: this runs in the snapshot wrapper, a system
546
+ * builtin loaded in the hub's ROOT process, so every render was a fork + exec
547
+ * on the loop that serves the API 52 ms against sharp's 4.8 ms in that
548
+ * container, to draw two lines of text.
545
549
  *
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`.
550
+ * `terminal-frame-renderer.ts` already renders text this way (SVG sharp), so
551
+ * this is the house pattern rather than a new one. It also drops the font-PATH
552
+ * probing the ffmpeg version needed: an SVG names a font FAMILY and fontconfig
553
+ * resolves it — verified in the hub image, where `fc-match "DejaVu Sans Mono"`
554
+ * answers with the real file.
555
+ *
556
+ * Rendering is pure input → bytes with no I/O of its own, and the geometry and
557
+ * escaping are separated out so they stay testable without rasterizing.
549
558
  */
550
559
  /**
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.
560
+ * Font stack for the rendered text. Family names, not paths librsvg resolves
561
+ * them through fontconfig, and the trailing generics keep a host without DejaVu
562
+ * rendering something legible instead of nothing.
556
563
  */
557
- var WARM_INTERVAL_MS = 5e3;
564
+ var COURTESY_FONT_STACK = "DejaVu Sans,DejaVu Sans Mono,Helvetica,Arial,sans-serif";
565
+ /** The word the frame carries. Deliberately the operator's vocabulary. */
566
+ function courtesyLabel(reason) {
567
+ switch (reason) {
568
+ case "disabled": return "Disabled";
569
+ case "offline": return "Offline";
570
+ case "sleeping": return "Sleeping";
571
+ }
572
+ }
558
573
  /**
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.
574
+ * Background per reason. A disabled camera is a DELIBERATE state and must not
575
+ * read as an alarm, so it is neutral grey; offline is a fault and is warmer.
563
576
  */
564
- var WARM_WINDOW_MS = 12e4;
565
- function keyOf(deviceId, streamId) {
566
- return `${deviceId}:${streamId ?? "auto"}`;
577
+ function courtesyBackground(reason) {
578
+ switch (reason) {
579
+ case "disabled": return "#2b2b31";
580
+ case "offline": return "#3a2b2b";
581
+ case "sleeping": return "#232b3a";
582
+ }
583
+ }
584
+ /** XML escaping for text placed inside an SVG `<text>` node. */
585
+ function escapeCourtesyText(value) {
586
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
567
587
  }
568
588
  /**
569
- * Who is being watched right now, and at what sizes.
589
+ * The SVG the frame is rasterized from: the state large and centred, the
590
+ * camera's name under it, smaller and dimmer.
570
591
  *
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.
592
+ * Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
593
+ * frame read the same, with a floor so a thumbnail stays legible.
575
594
  */
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
- };
595
+ function buildCourtesySvg(spec) {
596
+ const stateSize = Math.max(12, Math.round(spec.width / 12));
597
+ const nameSize = Math.max(9, Math.round(spec.width / 26));
598
+ const state = escapeCourtesyText(courtesyLabel(spec.reason));
599
+ const name = escapeCourtesyText(spec.deviceName);
600
+ const midY = spec.height / 2;
601
+ return [
602
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
603
+ `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
604
+ `<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
605
+ `<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
606
+ `<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
607
+ `</g></svg>`
608
+ ].join("");
609
+ }
610
+ /** Cache key — a courtesy frame is a pure function of these four. */
611
+ function courtesyCacheKey(spec) {
612
+ return `${spec.reason}:${String(spec.width)}x${String(spec.height)}:${spec.deviceName}`;
613
+ }
614
+ /**
615
+ * Render the frame.
616
+ *
617
+ * Rejects on failure — the caller decides what to do, exactly as `resizeJpeg`
618
+ * does, so a broken courtesy path is never mistaken for a broken camera.
619
+ */
620
+ function renderCourtesyJpeg(spec) {
621
+ return sharp(Buffer.from(buildCourtesySvg(spec))).jpeg({ quality: 82 }).toBuffer().then((bytes) => {
622
+ if (bytes.length === 0) throw new Error("courtesy frame produced no bytes");
623
+ return bytes;
624
+ });
625
+ }
645
626
  //#endregion
646
627
  //#region src/builtins/snapshot/snapshot-link-url.ts
647
628
  /**
@@ -654,14 +635,20 @@ var SnapshotSubscriptions = class {
654
635
  * actually REACHES the server — and that turned out to be the whole bug.
655
636
  *
656
637
  * 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
638
+ * request is what signalled demand for a camera. Both halves are satisfied by
639
+ * the client's own image cache: `expo-image` is URL-keyed and never
659
640
  * revalidates, so a URL the app painted in a previous session is served from
660
641
  * disk with **zero network**. Measured on the live hub, reopening the app after
661
642
  * 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.
643
+ * not one HTTP request, therefore no demand, therefore no capture. The
644
+ * operator's report ("gli snapshot sono vecchi, devo aggiornare più volte") is
645
+ * that measurement.
646
+ *
647
+ * That gap used to be covered on BOTH sides — by this link plane and by a
648
+ * server-side keep-warm timer. The timer was removed on 2026-08-11 (operator
649
+ * directive: snapshots are on-demand, always), which makes this plane the only
650
+ * thing standing between a client cache and a frozen tile. It carries the whole
651
+ * job now.
665
652
  *
666
653
  * A minted link breaks the loop from both ends. It is produced by an RPC —
667
654
  * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
@@ -831,6 +818,15 @@ 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.
@@ -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
@@ -905,7 +890,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
905
890
  };
906
891
  await this.serveMediaDataPlane();
907
892
  await this.serveLinkDataPlane();
908
- this.scheduleWarmTick();
909
893
  return [{
910
894
  capability: snapshotCapability,
911
895
  provider
@@ -1051,14 +1035,11 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1051
1035
  */
1052
1036
  async getSnapshotLinks(input) {
1053
1037
  const secret = await this.ensureLinkSecret();
1054
- const now = Date.now();
1055
- const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
1038
+ const expMs = snapshotLinkExpiry(Date.now(), SNAPSHOT_LINK_TTL_MS);
1056
1039
  const served = this.linkDataPlane !== null && secret !== null;
1057
1040
  const prefix = this.linkRoutePrefix();
1058
1041
  const sleepingByDevice = /* @__PURE__ */ new Map();
1059
1042
  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
1043
  const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
1063
1044
  sleepingByDevice.set(target.deviceId, sleeping);
1064
1045
  if (!sleeping) await this.awaitFreshEnough(target.deviceId);
@@ -1107,7 +1088,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1107
1088
  await this.getSnapshot({
1108
1089
  deviceId,
1109
1090
  force: false
1110
- }, 0, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1091
+ }, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1111
1092
  const current = this.cache.latest(deviceId)?.ts ?? null;
1112
1093
  if (alreadyCurrent || current !== null && (before === null || current > before)) return;
1113
1094
  this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
@@ -1138,7 +1119,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1138
1119
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
1139
1120
  */
1140
1121
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
1141
- this.subscriptions.note(deviceId, streamId, width, Date.now());
1142
1122
  const image = await this.getSnapshot({
1143
1123
  deviceId,
1144
1124
  ...streamId !== void 0 ? { streamId } : {},
@@ -1184,92 +1164,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1184
1164
  };
1185
1165
  }
1186
1166
  /**
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
1167
  * The frame at a card-sized width, derived once per (device, stream, width)
1274
1168
  * per capture.
1275
1169
  *
@@ -1316,12 +1210,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1316
1210
  }
1317
1211
  }
1318
1212
  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
1213
  if (this.mediaDataPlane) {
1326
1214
  await this.mediaDataPlane.dispose().catch(() => void 0);
1327
1215
  this.mediaDataPlane = null;
@@ -1364,7 +1252,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1364
1252
  })]
1365
1253
  }] });
1366
1254
  }
1367
- async getSnapshot(input, warmAheadMs = 0, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1255
+ async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1368
1256
  const { deviceId } = input;
1369
1257
  const force = input.force === true;
1370
1258
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -1375,6 +1263,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1375
1263
  ...deviceName ? { deviceName } : {}
1376
1264
  });
1377
1265
  const now = Date.now();
1266
+ if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
1378
1267
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1379
1268
  const rawPref = prefs.snapshotStreamId;
1380
1269
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -1383,7 +1272,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1383
1272
  meta: { stream: effectiveStreamId ?? "auto" }
1384
1273
  });
1385
1274
  const hit = this.cache.get(deviceId, effectiveStreamId);
1386
- const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs));
1275
+ const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
1387
1276
  const decision = decideSnapshotServe({
1388
1277
  now,
1389
1278
  cachedAt: hit?.ts ?? null,
@@ -1409,7 +1298,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1409
1298
  ageMs: hit ? now - hit.ts : null
1410
1299
  }
1411
1300
  });
1412
- return hit ? hit.data : null;
1301
+ if (hit) return hit.data;
1302
+ return await this.courtesyImage(deviceId, "sleeping", deviceName);
1413
1303
  }
1414
1304
  const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
1415
1305
  const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
@@ -1421,7 +1311,17 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1421
1311
  }));
1422
1312
  flight.catch(() => void 0);
1423
1313
  const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
1424
- if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
1314
+ if (raced.settled) try {
1315
+ const resolved = this.resolveOutcome(raced.value, deviceId, hit, log);
1316
+ if (resolved !== null) return resolved;
1317
+ return meta?.online === false ? await this.courtesyImage(deviceId, "offline", deviceName) : null;
1318
+ } catch (err) {
1319
+ if (meta?.online === false) {
1320
+ const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
1321
+ if (courtesy !== null) return courtesy;
1322
+ }
1323
+ throw err;
1324
+ }
1425
1325
  if (decision.staleFallback && hit) {
1426
1326
  if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
1427
1327
  tags: { deviceId },
@@ -1432,6 +1332,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1432
1332
  });
1433
1333
  return hit.data;
1434
1334
  }
1335
+ if (meta?.online === false) {
1336
+ const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
1337
+ if (courtesy !== null) return courtesy;
1338
+ }
1435
1339
  return null;
1436
1340
  }
1437
1341
  /**
@@ -1745,7 +1649,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1745
1649
  */
1746
1650
  getSnapshotOverview(input) {
1747
1651
  const now = Date.now();
1748
- this.subscriptions.renew(input.deviceIds, now);
1749
1652
  const rows = input.deviceIds.map((deviceId) => {
1750
1653
  const hit = this.cache.latest(deviceId);
1751
1654
  if (!hit) return {
@@ -1834,6 +1737,56 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1834
1737
  }] };
1835
1738
  }
1836
1739
  /**
1740
+ * The frame a camera that CANNOT capture answers with.
1741
+ *
1742
+ * Returns null only if rendering itself fails — the caller then falls through
1743
+ * to its previous behaviour, so a broken courtesy path degrades to the old
1744
+ * 404 rather than masking a real capture failure as a placeholder.
1745
+ *
1746
+ * Rendered at the full tile size; the existing resize ladder derives the
1747
+ * card-sized variants from it exactly as it does for a real frame, so no
1748
+ * downstream consumer needs to know this frame is synthetic.
1749
+ */
1750
+ async courtesyFrame(deviceId, reason, deviceName) {
1751
+ const width = 640;
1752
+ const height = Math.round(width * 9 / 16);
1753
+ const key = courtesyCacheKey({
1754
+ deviceName,
1755
+ reason,
1756
+ width,
1757
+ height
1758
+ });
1759
+ const cached = this.courtesyFrames.get(key);
1760
+ if (cached !== void 0) return cached;
1761
+ try {
1762
+ const bytes = await renderCourtesyJpeg({
1763
+ deviceName,
1764
+ reason,
1765
+ width,
1766
+ height
1767
+ });
1768
+ this.courtesyFrames.set(key, bytes);
1769
+ return bytes;
1770
+ } catch (err) {
1771
+ this.ctx.logger.warn("snapshot: courtesy frame failed — falling back to no image", {
1772
+ tags: { deviceId },
1773
+ meta: {
1774
+ reason,
1775
+ error: errMsg(err)
1776
+ }
1777
+ });
1778
+ return null;
1779
+ }
1780
+ }
1781
+ /** A courtesy frame shaped as the cap's response, or null when unrenderable. */
1782
+ async courtesyImage(deviceId, reason, deviceName) {
1783
+ const bytes = await this.courtesyFrame(deviceId, reason, deviceName ?? `#${String(deviceId)}`);
1784
+ return bytes === null ? null : {
1785
+ base64: bytes.toString("base64"),
1786
+ contentType: "image/jpeg"
1787
+ };
1788
+ }
1789
+ /**
1837
1790
  * Single-trip device lookup against device-manager. Returns the
1838
1791
  * fields the wrapper actually consults — name (logging) + battery
1839
1792
  * flag (cache window + broker-fallback gate). Sourced from the
@@ -1853,10 +1806,13 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1853
1806
  if (!found) return null;
1854
1807
  const features = found.features ?? [];
1855
1808
  const rawType = found.type;
1809
+ const row = found;
1856
1810
  return {
1857
1811
  ...found.name ? { name: found.name } : {},
1858
1812
  isBattery: features.includes(DeviceFeature.BatteryOperated),
1859
- ...rawType ? { type: rawType } : {}
1813
+ ...rawType ? { type: rawType } : {},
1814
+ disabled: row.disabled === true,
1815
+ online: row.online !== false
1860
1816
  };
1861
1817
  } catch (err) {
1862
1818
  this.ctx.logger.debug("deviceManager.getDevice failed during snapshot", {