@camstack/system 1.2.80 → 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.
@@ -1,9 +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-DZOZtn-0.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
5
  import { execFile } from "node:child_process";
6
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
7
55
  //#region src/builtins/snapshot/snapshot-coalescing.ts
8
56
  /**
9
57
  * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
@@ -199,563 +247,515 @@ function raceForResult(promise, timeoutMs) {
199
247
  });
200
248
  }
201
249
  //#endregion
202
- //#region src/builtins/snapshot/snapshot-media-handler.ts
250
+ //#region src/builtins/snapshot/snapshot-courtesy.ts
203
251
  /**
204
- * The widths a thumbnail may be served at.
252
+ * A courtesy frame for a camera that CANNOT produce one.
205
253
  *
206
- * A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
207
- * honouring `?w=` verbatim would let any caller mint unbounded work and
208
- * unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
209
- * variant instead of twelve.
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").
210
262
  *
211
- * Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
212
- * with headroom for a device pixel ratio.
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.
213
283
  */
214
- var SNAPSHOT_WIDTH_LADDER = [
215
- 160,
216
- 240,
217
- 320,
218
- 480,
219
- 640,
220
- 960
221
- ];
222
- /** Snap UP to the next rung — never below what was asked, so the client is not
223
- * handed an image it has to upscale. Above the ladder, the largest rung: the
224
- * point is to stop serving the 4K original. */
225
- function snapSnapshotWidth(requested) {
226
- for (const rung of SNAPSHOT_WIDTH_LADDER) if (requested <= rung) return rung;
227
- return SNAPSHOT_WIDTH_LADDER[SNAPSHOT_WIDTH_LADDER.length - 1] ?? requested;
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
+ }
228
297
  }
229
298
  /**
230
- * Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
231
- * Returns null for a malformed / nested / non-numeric id so the handler answers
232
- * 404 without ever reaching `getMedia`.
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.
233
301
  */
234
- function parseSnapshotMediaRequest(url) {
235
- const qIdx = url.indexOf("?");
236
- const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
237
- const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
238
- const segment = rawPath.replace(/^\/+/, "");
239
- if (segment.length === 0 || segment.includes("/")) return null;
240
- const idPart = segment.replace(/\.jpe?g$/i, "");
241
- if (!/^\d+$/.test(idPart)) return null;
242
- const deviceId = Number.parseInt(idPart, 10);
243
- if (!Number.isSafeInteger(deviceId)) return null;
244
- const params = new URLSearchParams(query);
245
- const rawStream = params.get("streamId");
246
- const streamId = rawStream !== null && rawStream.length > 0 ? rawStream : void 0;
247
- const rawForce = params.get("force");
248
- const force = rawForce === "1" || rawForce === "true";
249
- const rawWidth = params.get("w");
250
- return {
251
- deviceId,
252
- streamId,
253
- force,
254
- width: rawWidth !== null && /^\d+$/.test(rawWidth) && Number.parseInt(rawWidth, 10) > 0 ? snapSnapshotWidth(Number.parseInt(rawWidth, 10)) : void 0
255
- };
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;");
256
312
  }
257
313
  /**
258
- * The response's entity tag.
314
+ * The SVG the frame is rasterized from: the state large and centred, the
315
+ * camera's name under it, smaller and dimmer.
259
316
  *
260
- * It has to identify the VARIANT, not just the frame: the same capture can now
261
- * be served at several widths and from several streams, and a plain
262
- * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
263
- * 304 for `?w=960` and render the small image at full size. The base form is
264
- * unchanged for a plain request, so the identity
265
- * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
266
- * asked to resize.
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.
267
319
  */
268
- function snapshotEtag(variant, capturedAt) {
269
- const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
270
- if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
271
- if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
272
- return `"${parts.join("-")}"`;
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}`;
273
338
  }
274
339
  /**
275
- * Create a data-plane handler that serves per-device snapshots as JPEG images.
276
- * `deps.getMedia` is called once per request; null → 404, throw → 500. A
277
- * conditional GET with a matching `If-None-Match` produces a 304.
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.
278
344
  */
279
- function createSnapshotMediaHandler(deps) {
280
- return async (req, res) => {
281
- if (req.method !== "GET" && req.method !== "HEAD") {
282
- res.writeHead(405, { allow: "GET, HEAD" }).end();
283
- return;
284
- }
285
- const parsed = parseSnapshotMediaRequest(req.url ?? "/");
286
- if (parsed === null) {
287
- res.writeHead(404).end();
288
- return;
289
- }
290
- const inm = req.headers["if-none-match"];
291
- if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
292
- let peeked = null;
293
- try {
294
- peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
295
- } catch {
296
- peeked = null;
297
- }
298
- if (peeked !== null) {
299
- const peekedEtag = snapshotEtag({
300
- deviceId: parsed.deviceId,
301
- streamId: parsed.streamId,
302
- width: parsed.width
303
- }, peeked.capturedAt);
304
- if (inm === peekedEtag) {
305
- res.writeHead(304, {
306
- etag: peekedEtag,
307
- "cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
308
- }).end();
309
- return;
310
- }
311
- }
312
- }
313
- let media;
314
- try {
315
- media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
316
- } catch {
317
- const body = "Internal server error";
318
- res.writeHead(500, {
319
- "content-type": "text/plain",
320
- "content-length": String(Buffer.byteLength(body))
321
- });
322
- if (req.method === "HEAD") res.end();
323
- else res.end(body);
324
- return;
325
- }
326
- if (media === null) {
327
- res.writeHead(404).end();
328
- return;
329
- }
330
- const etag = snapshotEtag({
331
- deviceId: parsed.deviceId,
332
- streamId: parsed.streamId,
333
- width: "servedWidth" in media ? media.servedWidth : parsed.width
334
- }, media.capturedAt);
335
- const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
336
- if (inm === etag) {
337
- res.writeHead(304, {
338
- etag,
339
- "cache-control": cacheControl
340
- }).end();
341
- return;
342
- }
343
- res.writeHead(200, {
344
- "content-type": media.contentType,
345
- "cache-control": cacheControl,
346
- etag,
347
- "content-length": String(media.bytes.byteLength)
348
- });
349
- if (req.method === "HEAD") res.end();
350
- else res.end(Buffer.from(media.bytes));
351
- };
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
+ });
352
350
  }
353
351
  //#endregion
354
- //#region src/builtins/snapshot/snapshot-cache.ts
355
- /** A request with no explicit stream is its OWN entry, not a wildcard: the
356
- * per-device preference decides what it captures, and conflating it with an
357
- * explicit request is how the original bug read. */
358
- var AUTO = "auto";
359
- function keyOf(deviceId, streamId) {
360
- return `${deviceId}:${streamId ?? AUTO}`;
361
- }
362
- var SnapshotCache = class {
363
- byKey = /* @__PURE__ */ new Map();
364
- /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
365
- keysByDevice = /* @__PURE__ */ new Map();
366
- get(deviceId, streamId) {
367
- return this.byKey.get(keyOf(deviceId, streamId));
368
- }
369
- set(deviceId, streamId, entry) {
370
- const key = keyOf(deviceId, streamId);
371
- this.byKey.set(key, entry);
372
- const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
373
- keys.add(key);
374
- this.keysByDevice.set(deviceId, keys);
375
- }
376
- /** The newest entry for a device, whichever stream produced it. */
377
- latest(deviceId) {
378
- let newest;
379
- for (const key of this.keysByDevice.get(deviceId) ?? []) {
380
- const entry = this.byKey.get(key);
381
- if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
382
- }
383
- return newest;
384
- }
385
- /**
386
- * Drop every stream of one device.
387
- *
388
- * Device-wide on purpose: a settings change or an operator refresh
389
- * invalidates the CAMERA, and leaving a sibling entry behind would keep
390
- * serving the pre-change frame from the other key.
391
- */
392
- deleteDevice(deviceId) {
393
- for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
394
- this.keysByDevice.delete(deviceId);
395
- }
396
- clear() {
397
- this.byKey.clear();
398
- this.keysByDevice.clear();
399
- }
400
- };
401
- //#endregion
402
- //#region src/builtins/snapshot/snapshot-resize.ts
352
+ //#region src/builtins/snapshot/snapshot-link-url.ts
403
353
  /**
404
- * Downscaling a captured frame to a card-sized thumbnail.
354
+ * Signed, expiring links to a CLIENT-SIZED snapshot frame.
405
355
  *
406
- * Applied AFTER capture rather than during it, and that is deliberate: the two
407
- * capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
408
- * grab) produce a JPEG by different routes, and only one of them has an
409
- * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
410
- * same behaviour with one implementation.
356
+ * ## Why a link plane exists at all
411
357
  *
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.
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.
417
361
  *
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.
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.
424
371
  *
425
- * Benchmarked in that container, 2560×1440 640 wide, wall-clock per resize:
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.
426
377
  *
427
- * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
428
- * sharp 4.8 ms (in-process libvips, on its own threadpool)
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.
429
382
  *
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.
383
+ * ## What is signed, and what is only a cache key
433
384
  *
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.
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.
438
391
  *
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.
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`.
441
400
  */
442
- var RESIZE_TIMEOUT_MS = 1e4;
443
401
  /**
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.
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.
447
410
  */
448
- var JPEG_QUALITY = 82;
411
+ var SNAPSHOT_LINK_TTL_MS = 12e4;
449
412
  /**
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.
413
+ * Quantum the expiry is rounded UP to, so a link is stable between mints.
456
414
  *
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.
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.
466
419
  */
467
- function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
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);
481
- });
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;
482
429
  }
483
430
  /**
484
- * Resized frames, keyed by (device, stream, width) AND validated against the
485
- * source frame's timestamp.
431
+ * The link, as a ROOT-RELATIVE path.
486
432
  *
487
- * The timestamp is the whole correctness argument: a variant outlives nothing.
488
- * When the underlying frame is recaptured its `capturedAt` moves, every variant
489
- * derived from the old one stops matching, and the next request re-derives.
490
- * Without that check a card would keep showing a thumbnail of a frame the
491
- * full-size view had already replaced.
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.
492
440
  */
493
- var SnapshotVariantCache = class SnapshotVariantCache {
494
- byKey = /* @__PURE__ */ new Map();
495
- keysByDevice = /* @__PURE__ */ new Map();
496
- static key(deviceId, streamId, width) {
497
- return `${deviceId}:${streamId ?? "auto"}:${width}`;
498
- }
499
- /** The variant for this exact frame, or undefined when it is missing or was
500
- * derived from an older capture. */
501
- get(deviceId, streamId, width, sourceTs) {
502
- const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
503
- return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
504
- }
505
- set(deviceId, streamId, width, sourceTs, bytes) {
506
- const key = SnapshotVariantCache.key(deviceId, streamId, width);
507
- this.byKey.set(key, {
508
- bytes,
509
- sourceTs
510
- });
511
- const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
512
- keys.add(key);
513
- this.keysByDevice.set(deviceId, keys);
514
- }
515
- deleteDevice(deviceId) {
516
- for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
517
- this.keysByDevice.delete(deviceId);
518
- }
519
- clear() {
520
- this.byKey.clear();
521
- this.keysByDevice.clear();
522
- }
523
- };
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
+ }
524
484
  //#endregion
525
- //#region src/builtins/snapshot/snapshot-courtesy.ts
485
+ //#region src/builtins/snapshot/snapshot-media-handler.ts
526
486
  /**
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").
537
- *
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.
541
- *
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.
487
+ * The widths a thumbnail may be served at.
549
488
  *
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.
489
+ * A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
490
+ * honouring `?w=` verbatim would let any caller mint unbounded work and
491
+ * unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
492
+ * variant instead of twelve.
555
493
  *
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.
558
- */
559
- /**
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.
494
+ * Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
495
+ * with headroom for a device pixel ratio.
563
496
  */
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
- }
497
+ var SNAPSHOT_WIDTH_LADDER = [
498
+ 160,
499
+ 240,
500
+ 320,
501
+ 480,
502
+ 640,
503
+ 960
504
+ ];
505
+ /** Snap UP to the next rung — never below what was asked, so the client is not
506
+ * handed an image it has to upscale. Above the ladder, the largest rung: the
507
+ * point is to stop serving the 4K original. */
508
+ function snapSnapshotWidth(requested) {
509
+ for (const rung of SNAPSHOT_WIDTH_LADDER) if (requested <= rung) return rung;
510
+ return SNAPSHOT_WIDTH_LADDER[SNAPSHOT_WIDTH_LADDER.length - 1] ?? requested;
572
511
  }
573
512
  /**
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.
513
+ * Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
514
+ * Returns null for a malformed / nested / non-numeric id so the handler answers
515
+ * 404 without ever reaching `getMedia`.
576
516
  */
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;");
517
+ function parseSnapshotMediaRequest(url) {
518
+ const qIdx = url.indexOf("?");
519
+ const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
520
+ const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
521
+ const segment = rawPath.replace(/^\/+/, "");
522
+ if (segment.length === 0 || segment.includes("/")) return null;
523
+ const idPart = segment.replace(/\.jpe?g$/i, "");
524
+ if (!/^\d+$/.test(idPart)) return null;
525
+ const deviceId = Number.parseInt(idPart, 10);
526
+ if (!Number.isSafeInteger(deviceId)) return null;
527
+ const params = new URLSearchParams(query);
528
+ const rawStream = params.get("streamId");
529
+ const streamId = rawStream !== null && rawStream.length > 0 ? rawStream : void 0;
530
+ const rawForce = params.get("force");
531
+ const force = rawForce === "1" || rawForce === "true";
532
+ const rawWidth = params.get("w");
533
+ return {
534
+ deviceId,
535
+ streamId,
536
+ force,
537
+ width: rawWidth !== null && /^\d+$/.test(rawWidth) && Number.parseInt(rawWidth, 10) > 0 ? snapSnapshotWidth(Number.parseInt(rawWidth, 10)) : void 0
538
+ };
587
539
  }
588
540
  /**
589
- * The SVG the frame is rasterized from: the state large and centred, the
590
- * camera's name under it, smaller and dimmer.
541
+ * The response's entity tag.
591
542
  *
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.
543
+ * It has to identify the VARIANT, not just the frame: the same capture can now
544
+ * be served at several widths and from several streams, and a plain
545
+ * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
546
+ * 304 for `?w=960` and render the small image at full size. The base form is
547
+ * unchanged for a plain request, so the identity
548
+ * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
549
+ * asked to resize.
594
550
  */
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}`;
551
+ function snapshotEtag(variant, capturedAt) {
552
+ const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
553
+ if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
554
+ if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
555
+ return `"${parts.join("-")}"`;
613
556
  }
614
557
  /**
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.
558
+ * Create a data-plane handler that serves per-device snapshots as JPEG images.
559
+ * `deps.getMedia` is called once per request; null → 404, throw → 500. A
560
+ * conditional GET with a matching `If-None-Match` produces a 304.
619
561
  */
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
- });
562
+ function createSnapshotMediaHandler(deps) {
563
+ return async (req, res) => {
564
+ if (req.method !== "GET" && req.method !== "HEAD") {
565
+ res.writeHead(405, { allow: "GET, HEAD" }).end();
566
+ return;
567
+ }
568
+ const parsed = parseSnapshotMediaRequest(req.url ?? "/");
569
+ if (parsed === null) {
570
+ res.writeHead(404).end();
571
+ return;
572
+ }
573
+ const inm = req.headers["if-none-match"];
574
+ if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
575
+ let peeked = null;
576
+ try {
577
+ peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
578
+ } catch {
579
+ peeked = null;
580
+ }
581
+ if (peeked !== null) {
582
+ const peekedEtag = snapshotEtag({
583
+ deviceId: parsed.deviceId,
584
+ streamId: parsed.streamId,
585
+ width: parsed.width
586
+ }, peeked.capturedAt);
587
+ if (inm === peekedEtag) {
588
+ res.writeHead(304, {
589
+ etag: peekedEtag,
590
+ "cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
591
+ }).end();
592
+ return;
593
+ }
594
+ }
595
+ }
596
+ let media;
597
+ try {
598
+ media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
599
+ } catch {
600
+ const body = "Internal server error";
601
+ res.writeHead(500, {
602
+ "content-type": "text/plain",
603
+ "content-length": String(Buffer.byteLength(body))
604
+ });
605
+ if (req.method === "HEAD") res.end();
606
+ else res.end(body);
607
+ return;
608
+ }
609
+ if (media === null) {
610
+ res.writeHead(404).end();
611
+ return;
612
+ }
613
+ const etag = snapshotEtag({
614
+ deviceId: parsed.deviceId,
615
+ streamId: parsed.streamId,
616
+ width: "servedWidth" in media ? media.servedWidth : parsed.width
617
+ }, media.capturedAt);
618
+ const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
619
+ if (inm === etag) {
620
+ res.writeHead(304, {
621
+ etag,
622
+ "cache-control": cacheControl
623
+ }).end();
624
+ return;
625
+ }
626
+ res.writeHead(200, {
627
+ "content-type": media.contentType,
628
+ "cache-control": cacheControl,
629
+ etag,
630
+ "content-length": String(media.bytes.byteLength)
631
+ });
632
+ if (req.method === "HEAD") res.end();
633
+ else res.end(Buffer.from(media.bytes));
634
+ };
625
635
  }
626
636
  //#endregion
627
- //#region src/builtins/snapshot/snapshot-link-url.ts
637
+ //#region src/builtins/snapshot/snapshot-resize.ts
628
638
  /**
629
- * Signed, expiring links to a CLIENT-SIZED snapshot frame.
630
- *
631
- * ## Why a link plane exists at all
639
+ * Downscaling a captured frame to a card-sized thumbnail.
632
640
  *
633
- * The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
634
- * going away. What it cannot do is guarantee that a client asking for a tile
635
- * actually REACHES the server and that turned out to be the whole bug.
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.
636
646
  *
637
- * Under D93 the image URL is versioned by the frame identity, and an image
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
640
- * revalidates, so a URL the app painted in a previous session is served from
641
- * disk with **zero network**. Measured on the live hub, reopening the app after
642
- * two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
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.
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.
646
652
  *
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.
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.
652
659
  *
653
- * A minted link breaks the loop from both ends. It is produced by an RPC —
654
- * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
655
- * signal always lands; and it carries the capture identity the RPC just WAITED
656
- * 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:
657
661
  *
658
- * ## 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)
659
664
  *
660
- * The signature covers `"<deviceId>:<width>"` and the expiry. The width is
661
- * inside it deliberately: a leaked 240 px tile link must not be replayable as a
662
- * request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
663
- * exists only to key the client's image cache, so an unchanged frame is a
664
- * byte-identical URL and costs no bytes, and a new frame is a new URL and costs
665
- * 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.
666
668
  *
667
- * `exp` is bucketed rather than exact. A URL that were unique per mint would
668
- * defeat the client cache completely correct for freshness, and it would make
669
- * a phone re-download every tile on every 5 s poll whether or not anything
670
- * changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
671
- * 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.
672
673
  *
673
- * Pure and side-effect-free; the addon owns the secret and the clock. Unit
674
- * 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.
675
676
  */
677
+ var RESIZE_TIMEOUT_MS = 1e4;
676
678
  /**
677
- * How long a minted link stays valid.
678
- *
679
- * A snapshot is a live view of the operator's home, so this is short on purpose
680
- * — the exposure of a leaked link is bounded by it. Two minutes is long enough
681
- * that a page renders, re-renders and survives a brief backgrounding on the URL
682
- * it was given, and short enough that a link pasted somewhere is dead before it
683
- * is useful. The client re-mints on every overview poll (5 s), so it never
684
- * 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.
685
682
  */
686
- var SNAPSHOT_LINK_TTL_MS = 12e4;
683
+ var JPEG_QUALITY = 82;
687
684
  /**
688
- * Quantum the expiry is rounded UP to, so a link is stable between mints.
685
+ * Scale to `width`, preserving aspect ratio.
689
686
  *
690
- * Without it every mint produces a different `exp`, hence a different URL, hence
691
- * a full re-download of an unchanged frame on every poll. With it the URL is a
692
- * pure function of (device, width, frame, bucket) so a tile fetches when its
693
- * frame moves, and at most once more per bucket.
694
- */
695
- var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
696
- /** The token the signature is computed over. Width is part of the identity so a
697
- * tile link cannot be escalated into a full-frame request. */
698
- function snapshotLinkId(deviceId, width) {
699
- return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
700
- }
701
- /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
702
- function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
703
- return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
704
- }
705
- /**
706
- * 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.
707
691
  *
708
- * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
709
- * because the fetcher is a phone or a notifier backend that has no idea where
710
- * the hub is and picking that base is the `hubUrl: localhost` trap the Alexa
711
- * work paid for. Here the fetcher is a client that is already connected to the
712
- * hub and holds its own `serverUrl`, so the correct base is the one it used to
713
- * make the call. Returning a path makes it impossible to hand a client a link
714
- * 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.
715
701
  */
716
- function buildSnapshotLinkUrl(input) {
717
- const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
718
- const id = snapshotLinkId(input.deviceId, input.width);
719
- const sig = signExpiringUrl(input.secret, id, input.expMs);
720
- const params = new URLSearchParams();
721
- if (input.width !== void 0) params.set("w", String(input.width));
722
- if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
723
- params.set("exp", String(input.expMs));
724
- params.set("sig", sig);
725
- 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
+ });
726
717
  }
727
718
  /**
728
- * Parse and VERIFY a link request in one step, so a caller cannot accidentally
729
- * use the device id before checking the signature. Null = refuse (404/403);
730
- * there is deliberately no way to distinguish "bad signature" from "expired"
731
- * 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.
732
727
  */
733
- function parseVerifiedSnapshotLink(input) {
734
- const qIdx = input.url.indexOf("?");
735
- const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
736
- const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
737
- const segment = rawPath.replace(/^\/+/, "");
738
- if (segment.length === 0 || segment.includes("/")) return null;
739
- const idPart = segment.replace(/\.jpe?g$/i, "");
740
- if (!/^\d+$/.test(idPart)) return null;
741
- const deviceId = Number.parseInt(idPart, 10);
742
- if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
743
- const params = new URLSearchParams(query);
744
- const rawWidth = params.get("w");
745
- if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
746
- const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
747
- if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
748
- return verifyExpiringUrl({
749
- secret: input.secret,
750
- id: snapshotLinkId(deviceId, width),
751
- exp: params.get("exp") ?? void 0,
752
- sig: params.get("sig") ?? void 0,
753
- nowMs: input.nowMs
754
- }) ? {
755
- deviceId,
756
- width
757
- } : null;
758
- }
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
+ };
759
759
  //#endregion
760
760
  //#region src/builtins/snapshot/snapshot.addon.ts
761
761
  /**
@@ -833,7 +833,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
833
833
  */
834
834
  captureFlight = new SingleFlight(COALESCE_MS, (outcome) => outcome.ok && outcome.image !== null);
835
835
  /** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
836
- grabPool = new Semaphore(3);
836
+ grabPool = new Semaphore(6);
837
837
  /** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
838
838
  nativePool = new Semaphore(6);
839
839
  /**
@@ -888,6 +888,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
888
888
  getSnapshotOverview: (input) => this.getSnapshotOverview(input),
889
889
  getSnapshotLinks: (input) => this.getSnapshotLinks(input)
890
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
+ });
891
895
  await this.serveMediaDataPlane();
892
896
  await this.serveLinkDataPlane();
893
897
  return [{
@@ -1088,7 +1092,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1088
1092
  await this.getSnapshot({
1089
1093
  deviceId,
1090
1094
  force: false
1091
- }, 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);
1092
1096
  const current = this.cache.latest(deviceId)?.ts ?? null;
1093
1097
  if (alreadyCurrent || current !== null && (before === null || current > before)) return;
1094
1098
  this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
@@ -1252,7 +1256,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1252
1256
  })]
1253
1257
  }] });
1254
1258
  }
1255
- async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1259
+ async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
1256
1260
  const { deviceId } = input;
1257
1261
  const force = input.force === true;
1258
1262
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -1310,9 +1314,9 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1310
1314
  log
1311
1315
  }));
1312
1316
  flight.catch(() => void 0);
1313
- const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
1317
+ const raced = await raceForResult(flight, Math.min(Math.max(decision.waitMs, minimumCaptureWaitMs), maximumCaptureWaitMs));
1314
1318
  if (raced.settled) try {
1315
- const resolved = this.resolveOutcome(raced.value, deviceId, hit, log);
1319
+ const resolved = await this.resolveOutcome(raced.value, deviceId, hit, log);
1316
1320
  if (resolved !== null) return resolved;
1317
1321
  return meta?.online === false ? await this.courtesyImage(deviceId, "offline", deviceName) : null;
1318
1322
  } catch (err) {
@@ -1344,30 +1348,54 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1344
1348
  * falls back to stale cache (or null); a HARD native error with no frame
1345
1349
  * and no cache propagates.
1346
1350
  */
1347
- resolveOutcome(outcome, deviceId, hit, log) {
1351
+ async resolveOutcome(outcome, deviceId, hit, log) {
1348
1352
  if (outcome.ok) {
1349
1353
  if (outcome.image) return outcome.image;
1350
1354
  if (hit) {
1351
1355
  const ageMs = Date.now() - hit.ts;
1352
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
1353
- tags: { deviceId },
1354
- meta: { ageMs }
1355
- });
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
+ }
1356
1363
  return hit.data;
1357
1364
  }
1358
1365
  return null;
1359
1366
  }
1360
1367
  if (hit) {
1361
1368
  const ageMs = Date.now() - hit.ts;
1362
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: native failed — serving stale cache", {
1363
- tags: { deviceId },
1364
- meta: { ageMs }
1365
- });
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
+ }
1366
1376
  return hit.data;
1367
1377
  }
1368
1378
  throw outcome.error;
1369
1379
  }
1370
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
+ /**
1371
1399
  * Run the capture ladder ONCE for a device: native provider first, then the
1372
1400
  * stream-broker ffmpeg fallback. Never rejects — resolves a {@link
1373
1401
  * CaptureOutcome}. On a produced frame it populates the cache (so a
@@ -1575,9 +1603,48 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1575
1603
  };
1576
1604
  }
1577
1605
  async invalidateCache(input) {
1578
- this.cache.deleteDevice(input.deviceId);
1579
- this.variants.deleteDevice(input.deviceId);
1580
- 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
+ }
1581
1648
  }
1582
1649
  /**
1583
1650
  * Sleep state from the device-state MIRROR, not from a cap round-trip.